Add read-only runtime inventory settings (#130)
* add gated codex goals support * sync README SDK versions * bump claude agent sdk * add codex goals settings toggle * reuse status dashboard message and simplify settings actions * refine settings page UX * fix settings nav hash routing * add dashboard UX verification * refine dashboard settings and inbox UX * remove inbox top-level navigation * remove dashboard health top-level navigation * fix: allow runtime image attachment paths * feat: configure attachment allowlist dirs * fix: clean duplicate dashboard status messages * fix: poll dashboard duplicate cleanup between status updates * fix: delete dashboard duplicates on create * Refine settings IA with tabbed sections * Add read-only runtime inventory settings
This commit is contained in:
91
src/runtime-inventory.test.ts
Normal file
91
src/runtime-inventory.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getRuntimeInventory } from './runtime-inventory.js';
|
||||
|
||||
describe('runtime inventory', () => {
|
||||
let tempDir: string;
|
||||
let homeDir: string;
|
||||
let projectRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-runtime-inv-'));
|
||||
homeDir = path.join(tempDir, 'home');
|
||||
projectRoot = path.join(tempDir, 'repo');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('summarizes safe Codex and Claude runtime metadata without reading secrets', () => {
|
||||
const codexDir = path.join(homeDir, '.codex');
|
||||
const claudeDir = path.join(homeDir, '.claude');
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexDir, 'config.toml'),
|
||||
'[mcp_servers.ejclaw]\ncommand = "node"\n[mcp_servers.other]\ncommand = "x"\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'settings.json'),
|
||||
'{"fastMode":true,"secret":"should-not-leak"}\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(codexDir, 'auth.json'),
|
||||
'{"OPENAI_API_KEY":"sk-secret"}\n',
|
||||
);
|
||||
|
||||
const codexSkill = path.join(homeDir, '.agents', 'skills', 'browser');
|
||||
fs.mkdirSync(codexSkill, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexSkill, 'SKILL.md'),
|
||||
'---\nname: browser\ndescription: Browser automation\n---\n# Body\n',
|
||||
);
|
||||
|
||||
const runnerSkill = path.join(projectRoot, 'runners', 'skills', 'runner');
|
||||
fs.mkdirSync(runnerSkill, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(runnerSkill, 'SKILL.md'),
|
||||
'---\nname: runner\ndescription: Runner skill\n---\n',
|
||||
);
|
||||
const mcpServerPath = path.join(
|
||||
projectRoot,
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'ipc-mcp-stdio.js',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true });
|
||||
fs.writeFileSync(mcpServerPath, 'console.log("mcp");\n');
|
||||
|
||||
const snapshot = getRuntimeInventory({
|
||||
generatedAt: '2026-05-04T00:00:00.000Z',
|
||||
homeDir,
|
||||
projectRoot,
|
||||
});
|
||||
|
||||
expect(snapshot.projectRoot).toBe(projectRoot);
|
||||
expect(snapshot.codex.mcp).toMatchObject({
|
||||
ejclawConfigured: true,
|
||||
serverCount: 2,
|
||||
});
|
||||
expect(snapshot.codex.skillDirs[0]).toMatchObject({
|
||||
count: 1,
|
||||
skills: [{ name: 'browser', description: 'Browser automation' }],
|
||||
});
|
||||
expect(snapshot.ejclaw.runnerSkillDir).toMatchObject({
|
||||
count: 1,
|
||||
skills: [{ name: 'runner', description: 'Runner skill' }],
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
expect(serialized).not.toContain('sk-secret');
|
||||
expect(serialized).not.toContain('should-not-leak');
|
||||
});
|
||||
});
|
||||
212
src/runtime-inventory.ts
Normal file
212
src/runtime-inventory.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
CURRENT_RUNTIME_AGENT_TYPE,
|
||||
DATA_DIR,
|
||||
SERVICE_ID,
|
||||
SERVICE_SESSION_SCOPE,
|
||||
} from './config.js';
|
||||
|
||||
export interface RuntimePathSnapshot {
|
||||
label: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeSkillSummary {
|
||||
name: string;
|
||||
description: string | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RuntimeSkillDirSnapshot extends RuntimePathSnapshot {
|
||||
count: number;
|
||||
skills: RuntimeSkillSummary[];
|
||||
}
|
||||
|
||||
export interface RuntimeMcpSnapshot {
|
||||
configPath: RuntimePathSnapshot;
|
||||
ejclawConfigured: boolean;
|
||||
serverCount: number;
|
||||
}
|
||||
|
||||
export interface RuntimeAgentInventory {
|
||||
configFiles: RuntimePathSnapshot[];
|
||||
skillDirs: RuntimeSkillDirSnapshot[];
|
||||
mcp: RuntimeMcpSnapshot;
|
||||
}
|
||||
|
||||
export interface RuntimeInventorySnapshot {
|
||||
generatedAt: string;
|
||||
projectRoot: string;
|
||||
dataDir: string;
|
||||
service: {
|
||||
id: string;
|
||||
sessionScope: string;
|
||||
agentType: string;
|
||||
};
|
||||
codex: RuntimeAgentInventory;
|
||||
claude: RuntimeAgentInventory;
|
||||
ejclaw: {
|
||||
runnerSkillDir: RuntimeSkillDirSnapshot;
|
||||
mcpServer: RuntimePathSnapshot;
|
||||
};
|
||||
}
|
||||
|
||||
interface RuntimeInventoryOptions {
|
||||
homeDir?: string;
|
||||
projectRoot?: string;
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
function pathSnapshot(label: string, filePath: string): RuntimePathSnapshot {
|
||||
return {
|
||||
label,
|
||||
path: filePath,
|
||||
exists: fs.existsSync(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSkillMetadata(skillMdPath: string): {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
} {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(skillMdPath, 'utf-8').slice(0, 8000);
|
||||
} catch {
|
||||
return { name: null, description: null };
|
||||
}
|
||||
|
||||
const name = content.match(/^name:\s*(.+?)\s*$/m)?.[1]?.trim() ?? null;
|
||||
const description =
|
||||
content.match(/^description:\s*(.+?)\s*$/m)?.[1]?.trim() ?? null;
|
||||
return { name, description };
|
||||
}
|
||||
|
||||
function readSkillDir(label: string, dirPath: string): RuntimeSkillDirSnapshot {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return {
|
||||
...pathSnapshot(label, dirPath),
|
||||
count: 0,
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
|
||||
const skills = fs
|
||||
.readdirSync(dirPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const skillDir = path.join(dirPath, entry.name);
|
||||
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) return null;
|
||||
const metadata = parseSkillMetadata(skillMdPath);
|
||||
return {
|
||||
name: metadata.name || entry.name,
|
||||
description: metadata.description,
|
||||
path: skillDir,
|
||||
};
|
||||
})
|
||||
.filter((skill): skill is RuntimeSkillSummary => Boolean(skill))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
...pathSnapshot(label, dirPath),
|
||||
count: skills.length,
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
function readMcpSnapshot(
|
||||
label: string,
|
||||
configPath: string,
|
||||
): RuntimeMcpSnapshot {
|
||||
let content = '';
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
content = fs.readFileSync(configPath, 'utf-8');
|
||||
} catch {
|
||||
content = '';
|
||||
}
|
||||
}
|
||||
|
||||
const serverMatches = content.match(/^\s*\[mcp_servers\.[^\]]+\]/gm) ?? [];
|
||||
return {
|
||||
configPath: pathSnapshot(label, configPath),
|
||||
ejclawConfigured: /^\s*\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]/m.test(content),
|
||||
serverCount: serverMatches.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeInventory(
|
||||
options: RuntimeInventoryOptions = {},
|
||||
): RuntimeInventorySnapshot {
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const projectRoot = options.projectRoot ?? process.cwd();
|
||||
const codexConfigPath = path.join(homeDir, '.codex', 'config.toml');
|
||||
const claudeSettingsPath = path.join(homeDir, '.claude', 'settings.json');
|
||||
const runnerSkillDir = path.join(projectRoot, 'runners', 'skills');
|
||||
const mcpServerPath = path.join(
|
||||
projectRoot,
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'ipc-mcp-stdio.js',
|
||||
);
|
||||
|
||||
const ejclawRunnerSkills = readSkillDir(
|
||||
'EJClaw runner skills',
|
||||
runnerSkillDir,
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
||||
projectRoot,
|
||||
dataDir: DATA_DIR,
|
||||
service: {
|
||||
id: SERVICE_ID,
|
||||
sessionScope: SERVICE_SESSION_SCOPE,
|
||||
agentType: CURRENT_RUNTIME_AGENT_TYPE,
|
||||
},
|
||||
codex: {
|
||||
configFiles: [
|
||||
pathSnapshot('Codex config.toml', codexConfigPath),
|
||||
pathSnapshot(
|
||||
'Codex auth.json',
|
||||
path.join(homeDir, '.codex', 'auth.json'),
|
||||
),
|
||||
],
|
||||
skillDirs: [
|
||||
readSkillDir(
|
||||
'Codex user skills',
|
||||
path.join(homeDir, '.agents', 'skills'),
|
||||
),
|
||||
ejclawRunnerSkills,
|
||||
],
|
||||
mcp: readMcpSnapshot('Codex config.toml', codexConfigPath),
|
||||
},
|
||||
claude: {
|
||||
configFiles: [
|
||||
pathSnapshot('Claude settings.json', claudeSettingsPath),
|
||||
pathSnapshot(
|
||||
'Claude credentials',
|
||||
path.join(homeDir, '.claude', '.credentials.json'),
|
||||
),
|
||||
],
|
||||
skillDirs: [
|
||||
readSkillDir(
|
||||
'Claude user skills',
|
||||
path.join(homeDir, '.claude', 'skills'),
|
||||
),
|
||||
ejclawRunnerSkills,
|
||||
],
|
||||
mcp: readMcpSnapshot('Claude settings.json', claudeSettingsPath),
|
||||
},
|
||||
ejclaw: {
|
||||
runnerSkillDir: ejclawRunnerSkills,
|
||||
mcpServer: pathSnapshot('EJClaw IPC MCP server', mcpServerPath),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
FastModeSnapshot,
|
||||
ModelConfigSnapshot,
|
||||
} from './settings-store.js';
|
||||
import type { RuntimeInventorySnapshot } from './runtime-inventory.js';
|
||||
import type { MoaSettingsSnapshot } from './settings-store-moa.js';
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
@@ -44,6 +45,88 @@ const modelConfig: ModelConfigSnapshot = {
|
||||
|
||||
const fastMode: FastModeSnapshot = { codex: true, claude: false };
|
||||
const codexFeatures: CodexFeatureSnapshot = { goals: false };
|
||||
const runtimeInventory: RuntimeInventorySnapshot = {
|
||||
generatedAt: '2026-05-04T00:00:00.000Z',
|
||||
projectRoot: '/repo',
|
||||
dataDir: '/repo/data',
|
||||
service: {
|
||||
id: 'codex-main',
|
||||
sessionScope: 'codex-main',
|
||||
agentType: 'codex',
|
||||
},
|
||||
codex: {
|
||||
configFiles: [
|
||||
{
|
||||
label: 'Codex config.toml',
|
||||
path: '/home/.codex/config.toml',
|
||||
exists: true,
|
||||
},
|
||||
],
|
||||
skillDirs: [
|
||||
{
|
||||
label: 'Codex user skills',
|
||||
path: '/home/.agents/skills',
|
||||
exists: true,
|
||||
count: 1,
|
||||
skills: [
|
||||
{
|
||||
name: 'agent-browser',
|
||||
description: 'Browser automation',
|
||||
path: '/home/.agents/skills/agent-browser',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
mcp: {
|
||||
configPath: {
|
||||
label: 'Codex config.toml',
|
||||
path: '/home/.codex/config.toml',
|
||||
exists: true,
|
||||
},
|
||||
ejclawConfigured: true,
|
||||
serverCount: 1,
|
||||
},
|
||||
},
|
||||
claude: {
|
||||
configFiles: [
|
||||
{
|
||||
label: 'Claude settings.json',
|
||||
path: '/home/.claude/settings.json',
|
||||
exists: true,
|
||||
},
|
||||
],
|
||||
skillDirs: [],
|
||||
mcp: {
|
||||
configPath: {
|
||||
label: 'Claude settings.json',
|
||||
path: '/home/.claude/settings.json',
|
||||
exists: true,
|
||||
},
|
||||
ejclawConfigured: false,
|
||||
serverCount: 0,
|
||||
},
|
||||
},
|
||||
ejclaw: {
|
||||
runnerSkillDir: {
|
||||
label: 'EJClaw runner skills',
|
||||
path: '/repo/runners/skills',
|
||||
exists: true,
|
||||
count: 1,
|
||||
skills: [
|
||||
{
|
||||
name: 'agent-browser',
|
||||
description: 'Browser automation',
|
||||
path: '/repo/runners/skills/agent-browser',
|
||||
},
|
||||
],
|
||||
},
|
||||
mcpServer: {
|
||||
label: 'EJClaw IPC MCP server',
|
||||
path: '/repo/runners/agent-runner/dist/ipc-mcp-stdio.js',
|
||||
exists: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const moaSettings: MoaSettingsSnapshot = {
|
||||
enabled: true,
|
||||
@@ -113,6 +196,7 @@ function makeDeps(
|
||||
getFastMode: () => fastMode,
|
||||
getModelConfig: () => modelConfig,
|
||||
getMoaSettings: () => moaSettings,
|
||||
getRuntimeInventory: () => runtimeInventory,
|
||||
listClaudeAccounts: () => [makeClaudeAccount()],
|
||||
listCodexAccounts: () => [makeCodexAccount()],
|
||||
refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }),
|
||||
@@ -159,6 +243,14 @@ describe('web dashboard settings routes', () => {
|
||||
expect(mode?.status).toBe(200);
|
||||
await expect(mode?.json()).resolves.toEqual(fastMode);
|
||||
|
||||
const inventory = await route('/api/settings/runtime-inventory');
|
||||
expect(inventory?.status).toBe(200);
|
||||
await expect(inventory?.json()).resolves.toMatchObject({
|
||||
service: { id: 'codex-main', agentType: 'codex' },
|
||||
codex: { mcp: { ejclawConfigured: true } },
|
||||
ejclaw: { runnerSkillDir: { count: 1 } },
|
||||
});
|
||||
|
||||
const features = await route('/api/settings/codex-features');
|
||||
expect(features?.status).toBe(200);
|
||||
await expect(features?.json()).resolves.toEqual(codexFeatures);
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
type FastModeSnapshot,
|
||||
type ModelConfigSnapshot,
|
||||
} from './settings-store.js';
|
||||
import {
|
||||
getRuntimeInventory,
|
||||
type RuntimeInventorySnapshot,
|
||||
} from './runtime-inventory.js';
|
||||
import {
|
||||
checkMoaModel,
|
||||
getMoaSettings,
|
||||
@@ -40,6 +44,7 @@ export interface SettingsRouteDependencies {
|
||||
getFastMode: typeof getFastMode;
|
||||
getModelConfig: typeof getModelConfig;
|
||||
getMoaSettings: typeof getMoaSettings;
|
||||
getRuntimeInventory: typeof getRuntimeInventory;
|
||||
listClaudeAccounts: typeof listClaudeAccounts;
|
||||
listCodexAccounts: typeof listCodexAccounts;
|
||||
refreshAllCodexAccounts: typeof refreshAllCodexAccounts;
|
||||
@@ -67,6 +72,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
|
||||
getFastMode,
|
||||
getModelConfig,
|
||||
getMoaSettings,
|
||||
getRuntimeInventory,
|
||||
listClaudeAccounts,
|
||||
listCodexAccounts,
|
||||
refreshAllCodexAccounts,
|
||||
@@ -207,6 +213,15 @@ function handleAccountsRoute(
|
||||
});
|
||||
}
|
||||
|
||||
function handleRuntimeInventoryRoute(
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
deps: SettingsRouteDependencies,
|
||||
): Response | null {
|
||||
if (!readMethod(request.method)) return null;
|
||||
return jsonResponse(deps.getRuntimeInventory());
|
||||
}
|
||||
|
||||
async function handleClaudeAccountAddRoute(
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
@@ -320,6 +335,9 @@ export async function handleSettingsRoute({
|
||||
if (url.pathname === '/api/settings/models') {
|
||||
return handleModelSettingsRoute(request, jsonResponse, deps);
|
||||
}
|
||||
if (url.pathname === '/api/settings/runtime-inventory') {
|
||||
return handleRuntimeInventoryRoute(request, jsonResponse, deps);
|
||||
}
|
||||
if (url.pathname === '/api/settings/fast-mode') {
|
||||
return handleFastModeRoute(request, jsonResponse, deps);
|
||||
}
|
||||
@@ -366,4 +384,5 @@ export type {
|
||||
FastModeSnapshot,
|
||||
ModelConfigSnapshot,
|
||||
MoaSettingsSnapshot,
|
||||
RuntimeInventorySnapshot,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user