Add read-only runtime inventory settings

This commit is contained in:
ejclaw
2026-05-04 02:08:21 +09:00
parent 1b945ff214
commit 20c06ea5a4
11 changed files with 890 additions and 1 deletions

View 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
View 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),
},
};
}

View File

@@ -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);

View File

@@ -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,
};