From 1d9f96b1340d7f044e5aef45efb8bdd51fb2b4f3 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 3 Nov 2026 01:25:23 +0300 Subject: [PATCH] feat(studio): AA13 requirements service (readiness of a spec) computeRequirements(spec, deps) lists what a spec v2 needs to run - provider key, named secrets, env vars, models, MCP servers, builtin and registry tools, local files - each ok, missing or unknown, with a fix where one exists. Never throws (one log line, returns []), never blocks a save; labels name things, never secret values. - Requirement wire type next to RequirementKind in shared/wireTypes.ts, re-exported from shared/authorWire.ts; AgentRecord.requirements added. - server/author/requirements.ts: providerRows, modelRows, secretRefRows (deep walk, skips instructions), mcpRows, toolRows, fileRows; sorted missing/unknown/ok then id, deduped by id. - requirementDeps(root, secrets) builds prod deps per call from the workspace context; fileExists resolves under the workspace root and refuses traversal/absolute escapes. - GET /agents/:id/requirements -> Requirement[] | 404; GET /agents/:id now embeds record.requirements. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../__tests__/agentRequirements.test.ts | 119 +++++ apps/agent-forge/server/app.ts | 5 +- .../author/__tests__/requirements.test.ts | 436 ++++++++++++++++++ .../agent-forge/server/author/requirements.ts | 357 ++++++++++++++ apps/agent-forge/server/routes/agentRoutes.ts | 26 +- apps/agent-forge/shared/authorWire.ts | 6 + apps/agent-forge/shared/wireTypes.ts | 24 + 7 files changed, 970 insertions(+), 3 deletions(-) create mode 100644 apps/agent-forge/server/__tests__/agentRequirements.test.ts create mode 100644 apps/agent-forge/server/author/__tests__/requirements.test.ts create mode 100644 apps/agent-forge/server/author/requirements.ts diff --git a/apps/agent-forge/server/__tests__/agentRequirements.test.ts b/apps/agent-forge/server/__tests__/agentRequirements.test.ts new file mode 100644 index 00000000..4a1b0634 --- /dev/null +++ b/apps/agent-forge/server/__tests__/agentRequirements.test.ts @@ -0,0 +1,119 @@ +/** + * `GET /agents/:id/requirements` (AA13): the readiness rows of the saved + * spec v2 - 200 `Requirement[]`, 404 for an unknown id. `GET /agents/:id` + * carries the same list on `record.requirements` so the detail header and + * the Author's proposal cards can read it without a second call. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import request from 'supertest'; +import type { AgentSpecV2 } from 'lousho'; +import { createApp } from '../app'; +import { RunManager } from '../runRegistry'; +import { newTestHost } from './testHost'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; +import { SecretsStore } from '../secretsStore'; +import { createFsAgentStore } from '../../src/persistence/fsAgentStore'; +import { AgentRepo } from '../storage/agentRepo'; +import type { AgentStore } from '../../src/persistence/AgentStore'; +import type { Requirement } from '../../shared/authorWire'; + +const SPEC: AgentSpecV2 = { + version: 2, + name: 'needs things', + model: 'anthropic/claude-x', + instructions: 'Check the queues.', + mcpServers: { + gh: { command: 'gh-mcp', env: { GITHUB_TOKEN: { secret: 'GITHUB_TOKEN' } } }, + }, +}; + +describe('GET /agents/:id/requirements', () => { + let baseDir: string; + let agentStore: AgentStore; + let runManager: RunManager; + let repo: AgentRepo; + let secrets: SecretsStore; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-aa13-reqs-')); + agentStore = createFsAgentStore(baseDir); + repo = new AgentRepo(baseDir); + runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + host: newTestHost(baseDir), + loadSpec: (id) => repo.readSpec(id), + saveSpec: (id, spec) => repo.writeSpec(id, spec), + }); + secrets = new SecretsStore(baseDir); + // Deterministic env: nothing the spec could resolve leaks in from the host. + vi.stubEnv('ANTHROPIC_API_KEY', ''); + vi.stubEnv('OPENAI_API_KEY', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + const app = () => createApp({ agentStore, runManager, baseDir, secretsStore: secrets }); + const putSpec = (id: string, spec: unknown = SPEC) => request(app()).put(`/agents/${id}`).send({ spec }); + + it('answers the spec\'s Requirement[] - missing rows first', async () => { + expect((await putSpec('ops')).status).toBe(204); + const res = await request(app()).get('/agents/ops/requirements'); + expect(res.status).toBe(200); + const rows = res.body as Requirement[]; + const map = new Map(rows.map((row) => [row.id, row])); + expect(map.get('provider:anthropic')).toMatchObject({ + kind: 'provider_key', + status: 'missing', + label: 'Anthropic API key', + fix: { action: 'open_settings', target: '/settings' }, + }); + expect(map.get('secret:GITHUB_TOKEN')).toMatchObject({ + kind: 'secret', + status: 'missing', + fix: { action: 'add_secret', target: 'GITHUB_TOKEN' }, + }); + expect(map.get('mcp:gh')).toMatchObject({ + kind: 'mcp', + status: 'unknown', + fix: { action: 'probe', target: 'gh' }, + }); + // No secret value ever appears in the payload. + expect(JSON.stringify(rows)).not.toContain('ghp_'); + }); + + it('marks refs satisfied by the store and the env as ok', async () => { + secrets.setSecret('GITHUB_TOKEN', 'ghp_route_test'); + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-route'); + expect((await putSpec('ops')).status).toBe(204); + const res = await request(app()).get('/agents/ops/requirements'); + const map = new Map((res.body as Requirement[]).map((row) => [row.id, row])); + expect(map.get('secret:GITHUB_TOKEN')?.status).toBe('ok'); + expect(map.get('provider:anthropic')?.status).toBe('ok'); + expect(JSON.stringify(res.body)).not.toContain('ghp_route_test'); + }); + + it('GET /agents/:id carries the same list on record.requirements', async () => { + expect((await putSpec('ops')).status).toBe(204); + const res = await request(app()).get('/agents/ops'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.requirements)).toBe(true); + const map = new Map((res.body.requirements as Requirement[]).map((row) => [row.id, row])); + expect(map.get('provider:anthropic')?.status).toBe('missing'); + expect(map.get('secret:GITHUB_TOKEN')?.status).toBe('missing'); + }); + + it('404 { error: \'Agent not found.\' } for an unknown id', async () => { + const res = await request(app()).get('/agents/nope/requirements'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Agent not found.' }); + }); +}); diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index d615e24c..027241c3 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -7,7 +7,8 @@ * POST /agents -> create blank/template/copy (body: CreateAgentRequest), 201 AgentRecord * GET /templates -> TemplateSummary[] - the SDK's shipped templates (templates/templateCatalog.ts) * GET /templates/:id -> TemplateDetail (summary + spec v2 + README) | 400/404 - * GET /agents/:id -> AgentRecord | 404; ?view=legacy -> v1 AgentSpec + * GET /agents/:id -> AgentRecord (+ requirements) | 404; ?view=legacy -> v1 AgentSpec + * GET /agents/:id/requirements -> Requirement[] readiness rows of the spec | 404 (routes/agentRoutes.ts) * PUT /agents/:id -> save the spec v2 working copy (body: { spec } or a bare v1 AgentSpec), 204 * PATCH /agents/:id/meta -> toggle enabled / set labels (body: PatchAgentMetaRequest), 200 AgentMeta * PUT /agents/:id/layout -> save the canvas layout (body: CanvasLayout), 204 @@ -953,7 +954,7 @@ export function createApp(options: CreateAppOptions): Express { // Saved-agent list/record/save/delete (routes/agentRoutes.ts - the v2 // wire shapes, with ?view=legacy for the still-v1 canvas; stores come // from workspaceOf(req), the workspace seam). - registerAgentRoutes(app, { templates }); + registerAgentRoutes(app, { templates, secrets }); // Draft routes: get/create/withdraw/approve/reject the per-agent pending // ChangeDraft (server/routes/draftRoutes.ts - stores come from // workspaceOf(req), the workspace seam). diff --git a/apps/agent-forge/server/author/__tests__/requirements.test.ts b/apps/agent-forge/server/author/__tests__/requirements.test.ts new file mode 100644 index 00000000..bb46e50c --- /dev/null +++ b/apps/agent-forge/server/author/__tests__/requirements.test.ts @@ -0,0 +1,436 @@ +/** + * computeRequirements (author/requirements.ts): the readiness list of a + * spec v2 - provider key, models, named secrets and env vars, MCP servers, + * builtin/registry/file tools - each `ok`, `missing` or `unknown` with a + * fix where one exists. Never throws, never blocks a save. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { AgentSpecV2 } from 'lousho'; +import { computeRequirements, requirementDeps, type RequirementDeps } from '../requirements'; +import type { Requirement } from '../../../shared/authorWire'; +import type { SecretsStore } from '../../secretsStore'; + +const MODEL_IDS = new Set([ + 'anthropic/claude-x', + 'openai/gpt-1', + 'openrouter/vendormodel', + 'google/gemini-x', + 'ollama/llama3', + 'mock/mock-1', + 'pi/openrouter/x', +]); + +const BUILTIN_TOOLS = [ + { name: 'web_fetch', needsCredentials: [] as string[] }, + { name: 'credentialed_tool', needsCredentials: ['CRED_KEY', 'CRED_URL'] }, +]; + +function spec(extra: Partial = {}): AgentSpecV2 { + return { + version: 2, + name: 'test agent', + model: 'mock/mock-1', + instructions: 'Do things.', + ...extra, + }; +} + +/** Deps with every check failing by default; tests override the seam they exercise. */ +function deps(overrides: Partial = {}): RequirementDeps { + return { + secrets: { getKey: () => undefined, hasNamed: () => false }, + env: {}, + catalog: { + modelIds: async () => MODEL_IDS, + builtinTools: async () => BUILTIN_TOOLS, + }, + fileExists: () => false, + ...overrides, + }; +} + +const byId = (rows: Requirement[]) => new Map(rows.map((row) => [row.id, row])); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('computeRequirements', () => { + describe('provider rows', () => { + it('anthropic model, no stored key and no env -> one missing provider row with the settings fix', async () => { + const rows = await computeRequirements(spec({ model: 'anthropic/claude-x' }), deps()); + expect(byId(rows).get('provider:anthropic')).toEqual({ + id: 'provider:anthropic', + kind: 'provider_key', + status: 'missing', + label: 'Anthropic API key', + fix: { action: 'open_settings', target: '/settings' }, + }); + }); + + it('anthropic is ok with a stored key, and openai is ok with its env var', async () => { + const rows = await computeRequirements( + spec({ model: 'anthropic/claude-x', fallbackModels: ['openai/gpt-1'] }), + deps({ + secrets: { getKey: (p) => (p === 'anthropic' ? 'sk-stored' : undefined), hasNamed: () => false }, + env: { OPENAI_API_KEY: 'sk-env' }, + }) + ); + const map = byId(rows); + expect(map.get('provider:anthropic')?.status).toBe('ok'); + expect(map.get('provider:openai')).toMatchObject({ + kind: 'provider_key', + status: 'ok', + label: 'OpenAI API key', + fix: { action: 'open_settings', target: '/settings' }, + }); + }); + + it('openai falls back to OPENAI_API_KEY and anthropic to ANTHROPIC_API_KEY when no key is stored', async () => { + const rows = await computeRequirements( + spec({ model: 'openai/gpt-1', fallbackModels: ['anthropic/claude-x'] }), + deps({ env: { OPENAI_API_KEY: 'k', ANTHROPIC_API_KEY: 'k' } }) + ); + expect(byId(rows).get('provider:openai')?.status).toBe('ok'); + expect(byId(rows).get('provider:anthropic')?.status).toBe('ok'); + }); + + it('openrouter and google read their env vars (missing and ok)', async () => { + const missing = await computeRequirements(spec({ model: 'openrouter/vendormodel' }), deps()); + expect(byId(missing).get('provider:openrouter')).toEqual({ + id: 'provider:openrouter', + kind: 'provider_key', + status: 'missing', + label: 'OpenRouter API key', + fix: { action: 'open_settings', target: '/settings' }, + }); + const googleMissing = await computeRequirements(spec({ model: 'google/gemini-x' }), deps()); + expect(byId(googleMissing).get('provider:google')).toMatchObject({ status: 'missing', label: 'Google API key' }); + + const ok = await computeRequirements( + spec({ model: 'openrouter/vendormodel', fallbackModels: ['google/gemini-x'] }), + deps({ env: { OPENROUTER_API_KEY: 'k', GOOGLE_GENERATIVE_AI_API_KEY: 'k' } }) + ); + expect(byId(ok).get('provider:openrouter')?.status).toBe('ok'); + expect(byId(ok).get('provider:google')?.status).toBe('ok'); + }); + + it('ollama and mock providers produce no row', async () => { + const rows = await computeRequirements(spec({ model: 'mock/mock-1', fallbackModels: ['ollama/llama3'] }), deps()); + expect(rows.some((row) => row.id === 'provider:ollama' || row.id === 'provider:mock')).toBe(false); + }); + + it('any other provider is an unknown row with no fix', async () => { + const rows = await computeRequirements(spec({ model: 'pi/openrouter/x' }), deps()); + expect(byId(rows).get('provider:pi')).toEqual({ + id: 'provider:pi', + kind: 'provider_key', + status: 'unknown', + label: 'pi provider', + }); + }); + + it('the provider of every fallback model is checked too', async () => { + const rows = await computeRequirements( + spec({ model: 'mock/mock-1', fallbackModels: ['anthropic/claude-x', 'openrouter/vendormodel'] }), + deps() + ); + const map = byId(rows); + expect(map.get('provider:anthropic')?.status).toBe('missing'); + expect(map.get('provider:openrouter')?.status).toBe('missing'); + expect(map.has('provider:mock')).toBe(false); + }); + }); + + describe('model rows', () => { + it('a model absent from the catalog is an unknown row', async () => { + const rows = await computeRequirements(spec({ model: 'anthropic/not-real' }), deps()); + expect(byId(rows).get('model:anthropic/not-real')).toEqual({ + id: 'model:anthropic/not-real', + kind: 'model', + status: 'unknown', + label: 'Model anthropic/not-real is not in the catalog', + }); + }); + + it('a fallback model absent from the catalog is an unknown row; catalog models get none', async () => { + const rows = await computeRequirements( + spec({ model: 'anthropic/claude-x', fallbackModels: ['openai/gpt-1', 'openai/gpt-9'] }), + deps() + ); + const map = byId(rows); + expect(map.has('model:anthropic/claude-x')).toBe(false); + expect(map.has('model:openai/gpt-1')).toBe(false); + expect(map.get('model:openai/gpt-9')?.status).toBe('unknown'); + }); + }); + + describe('secret and env ref rows (deep walk)', () => { + it('a { secret } ref nested in mcpServers..env is found once even when referenced twice', async () => { + const rows = await computeRequirements( + spec({ + mcpServers: { + gh: { + command: 'gh-mcp', + env: { + GITHUB_TOKEN: { secret: 'GITHUB_TOKEN' }, + OTHER: { secret: 'GITHUB_TOKEN' }, + }, + }, + }, + }), + deps() + ); + const secretRows = rows.filter((row) => row.id === 'secret:GITHUB_TOKEN'); + expect(secretRows).toEqual([ + { + id: 'secret:GITHUB_TOKEN', + kind: 'secret', + status: 'missing', + label: 'Secret GITHUB_TOKEN', + fix: { action: 'add_secret', target: 'GITHUB_TOKEN' }, + }, + ]); + }); + + it('a { secret } ref is ok when the named secret is stored', async () => { + const rows = await computeRequirements( + spec({ + mcpServers: { m: { command: 'm', env: { T: { secret: 'MY_KEY' } } } }, + }), + deps({ secrets: { getKey: () => undefined, hasNamed: (name) => name === 'MY_KEY' } }) + ); + expect(byId(rows).get('secret:MY_KEY')?.status).toBe('ok'); + }); + + it('a { env } ref is ok when the variable is set, missing when unset, and has no fix', async () => { + const missing = await computeRequirements( + spec({ mcpServers: { gh: { command: 'gh-mcp', env: { T: { env: 'GH_TOKEN' } } } } }), + deps() + ); + expect(byId(missing).get('env:GH_TOKEN')).toEqual({ + id: 'env:GH_TOKEN', + kind: 'secret', + status: 'missing', + label: 'Environment variable GH_TOKEN', + }); + const ok = await computeRequirements( + spec({ mcpServers: { gh: { command: 'gh-mcp', env: { T: { env: 'GH_TOKEN' } } } } }), + deps({ env: { GH_TOKEN: 'x' } }) + ); + expect(byId(ok).get('env:GH_TOKEN')?.status).toBe('ok'); + }); + + it('{ env } and { secret } refs of the same name are separate rows', async () => { + const rows = await computeRequirements( + spec({ + mcpServers: { a: { command: 'a', env: { A: { env: 'TOKEN' }, B: { secret: 'TOKEN' } } } }, + }), + deps() + ); + expect(byId(rows).has('env:TOKEN')).toBe(true); + expect(byId(rows).has('secret:TOKEN')).toBe(true); + }); + + it('refs inside tool options, triggers and inline subagent specs are found; instructions is not walked', async () => { + const rows = await computeRequirements( + spec({ + instructions: { file: 'prompts/main.md' }, + tools: [{ openapi: { url: 'https://api.example.com/spec.json', bearerToken: { secret: 'API_KEY' } } }], + triggers: [{ id: 't1', type: 'webhook', path: '/hook', secret: { secret: 'HOOK_SECRET' }, promptTemplate: 'x' }], + subagents: { + sub: { + inline: { + name: 'sub', + description: 'a sub', + model: 'mock/mock-1', + instructions: 'sub prompt', + mcpServers: { m: { command: 'm', env: { K: { secret: 'SUB_SECRET' } } } }, + }, + }, + }, + }), + deps() + ); + const map = byId(rows); + expect(map.has('secret:API_KEY')).toBe(true); + expect(map.has('secret:HOOK_SECRET')).toBe(true); + expect(map.has('secret:SUB_SECRET')).toBe(true); + }); + }); + + describe('mcp rows', () => { + it('each mcpServers key is an unknown row with the probe fix', async () => { + const rows = await computeRequirements( + spec({ + mcpServers: { + gh: { command: 'gh-mcp' }, + remote: { url: 'https://mcp.example.com' }, + }, + }), + deps() + ); + const map = byId(rows); + expect(map.get('mcp:gh')).toEqual({ + id: 'mcp:gh', + kind: 'mcp', + status: 'unknown', + label: 'MCP server gh', + fix: { action: 'probe', target: 'gh' }, + }); + expect(map.get('mcp:remote')).toMatchObject({ status: 'unknown', fix: { action: 'probe', target: 'remote' } }); + }); + }); + + describe('tool rows', () => { + it('a builtin absent from the catalog is a missing row', async () => { + const rows = await computeRequirements(spec({ tools: [{ builtin: 'no_such_tool' }] }), deps()); + expect(byId(rows).get('tool:no_such_tool')).toEqual({ + id: 'tool:no_such_tool', + kind: 'tool', + status: 'missing', + label: 'Tool no_such_tool is not available', + }); + }); + + it('a catalog builtin with no credentials produces no row', async () => { + const rows = await computeRequirements(spec({ tools: [{ builtin: 'web_fetch' }] }), deps()); + expect(rows.filter((row) => row.id.startsWith('tool:'))).toEqual([]); + }); + + it('a builtin with needsCredentials gets one row per credential, env or named secret satisfying it', async () => { + const rows = await computeRequirements(spec({ tools: [{ builtin: 'credentialed_tool' }] }), deps()); + const map = byId(rows); + expect(map.get('tool:credentialed_tool:CRED_KEY')).toEqual({ + id: 'tool:credentialed_tool:CRED_KEY', + kind: 'tool', + status: 'missing', + label: 'CRED_KEY for credentialed_tool', + fix: { action: 'add_secret', target: 'CRED_KEY' }, + }); + expect(map.get('tool:credentialed_tool:CRED_URL')?.status).toBe('missing'); + + const satisfied = await computeRequirements( + spec({ tools: [{ builtin: 'credentialed_tool' }] }), + deps({ + env: { CRED_KEY: 'v' }, + secrets: { getKey: () => undefined, hasNamed: (n) => n === 'CRED_URL' }, + }) + ); + expect(byId(satisfied).get('tool:credentialed_tool:CRED_KEY')?.status).toBe('ok'); + expect(byId(satisfied).get('tool:credentialed_tool:CRED_URL')?.status).toBe('ok'); + }); + + it('a { registry } tool is an unknown row', async () => { + const rows = await computeRequirements(spec({ tools: [{ registry: 'github-issues' }] }), deps()); + expect(byId(rows).get('registry:github-issues')).toEqual({ + id: 'registry:github-issues', + kind: 'tool', + status: 'unknown', + label: 'Registry tool github-issues', + }); + }); + }); + + describe('file rows', () => { + it('tools[i].file, hooks[i].file and skills[i].dir are file rows, ok when the file exists', async () => { + const rows = await computeRequirements( + spec({ + tools: [{ file: 'tools/mine.ts' }], + hooks: [{ id: 'h1', file: 'hooks/mine.ts' }], + skills: [{ dir: 'skills/mine' }], + }), + deps({ fileExists: (rel) => rel === 'tools/mine.ts' }) + ); + const map = byId(rows); + expect(map.get('file:tools/mine.ts')).toEqual({ + id: 'file:tools/mine.ts', + kind: 'tool', + status: 'ok', + label: 'File tools/mine.ts', + }); + expect(map.get('file:hooks/mine.ts')?.status).toBe('missing'); + expect(map.get('file:skills/mine')?.status).toBe('missing'); + }); + }); + + describe('sorting, dedupe and the never-throw contract', () => { + it('rows sort missing first, then unknown, then ok; ids sort inside a status', async () => { + const rows = await computeRequirements( + spec({ + model: 'google/gemini-x', + tools: [{ builtin: 'no_such_tool' }, { registry: 'zzz' }], + mcpServers: { aaa: { command: 'a', env: { V: { env: 'SET_VAR' } } } }, + }), + deps({ env: { SET_VAR: 'x', GOOGLE_GENERATIVE_AI_API_KEY: 'k' } }) + ); + const rank = { missing: 0, unknown: 1, ok: 2 } as const; + const statuses = rows.map((row) => row.status); + for (let i = 1; i < statuses.length; i++) { + expect(rank[statuses[i]]).toBeGreaterThanOrEqual(rank[statuses[i - 1]]); + if (statuses[i] === statuses[i - 1]) { + expect(rows[i].id >= rows[i - 1].id).toBe(true); + } + } + expect(rows[0]).toMatchObject({ id: 'tool:no_such_tool', status: 'missing' }); + expect(statuses.at(-1)).toBe('ok'); + }); + + it('duplicate sources dedupe by id (same secret twice, model and fallback same provider)', async () => { + const rows = await computeRequirements( + spec({ + model: 'anthropic/claude-x', + fallbackModels: ['anthropic/claude-x'], + mcpServers: { + a: { command: 'a', env: { A: { secret: 'S' }, B: { secret: 'S' } } }, + }, + }), + deps() + ); + expect(rows.filter((row) => row.id === 'provider:anthropic')).toHaveLength(1); + expect(rows.filter((row) => row.id === 'secret:S')).toHaveLength(1); + expect(rows.filter((row) => row.id === 'model:anthropic/claude-x')).toHaveLength(0); + }); + + it('an exception inside a helper logs one line and returns [] instead of throwing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const rows = await computeRequirements( + spec(), + deps({ + catalog: { + modelIds: async () => MODEL_IDS, + builtinTools: async () => { + throw new Error('catalog broke'); + }, + }, + }) + ); + expect(rows).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('catalog broke')); + }); + }); +}); + +describe('requirementDeps.fileExists (prod dep)', () => { + it('resolves under the workspace root and never stats outside it', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-aa13-files-')); + try { + fs.mkdirSync(path.join(root, 'tools'), { recursive: true }); + fs.writeFileSync(path.join(root, 'tools', 'mine.ts'), 'export {};\n'); + const secretsStub = { getKey: () => undefined, getSecret: () => undefined } as unknown as SecretsStore; + const d = requirementDeps(root, secretsStub); + expect(d.fileExists('tools/mine.ts')).toBe(true); + // Traversal and absolute paths are reported missing without leaving root. + expect(d.fileExists('../../etc/passwd')).toBe(false); + expect(d.fileExists('../../../outside.txt')).toBe(false); + expect(d.fileExists(path.resolve(root, '..', 'outside.txt'))).toBe(false); + expect(d.fileExists('tools/missing.ts')).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/agent-forge/server/author/requirements.ts b/apps/agent-forge/server/author/requirements.ts new file mode 100644 index 00000000..07f09fdf --- /dev/null +++ b/apps/agent-forge/server/author/requirements.ts @@ -0,0 +1,357 @@ +/** + * `computeRequirements(spec, deps)` - the readiness list of a spec v2 + * (PLAN §8.5): what the agent needs before it can run - a provider API + * key, named secrets and env vars its `{ secret }`/`{ env }` refs resolve + * to, its models in the catalog, its MCP servers (unprobed: `unknown`), + * its builtin/registry tools and its local files. Every row is `ok`, + * `missing` or `unknown` and carries the fix that makes it `ok` where one + * exists. + * + * The whole computation is advisory: it never throws (an internal error + * logs one line and returns `[]`) and never blocks a save. Labels name + * secrets and variables, never values - no stored or env secret ever + * appears in output. + * + * Deps are injected so tests pass fixtures and the route builds them per + * call from the request's workspace (`requirementDeps(ws.ref.root, + * secrets)`): `fileExists` resolves under that workspace's root, not the + * launch dir, and refuses paths that would escape it. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { listBuiltinTools, listModels, type AgentSpecV2 } from 'lousho'; +import type { Requirement } from '../../shared/authorWire'; +import { isSecretProvider, PROVIDER_KEY_ENV, type SecretProvider, type SecretsStore } from '../secretsStore'; + +/** Everything `computeRequirements` reads, injectable per call. */ +export interface RequirementDeps { + /** The Studio secrets store, projected to the two reads requirements needs. */ + secrets: { + /** A stored provider key (`undefined` when unset); `provider` is one Studio manages (`openai`/`anthropic`). */ + getKey(provider: string): string | undefined; + /** Whether a named secret exists in the store (`SecretsStore.getSecret` in the merged code). */ + hasNamed(name: string): boolean; + }; + /** `process.env` in production, a fixture in tests. */ + env: Readonly>; + /** The SDK catalogs (D09): 'provider/model' ids and the builtin tool table. */ + catalog: { + modelIds(): Promise>; + builtinTools(): Promise>; + }; + /** Whether an agentDir-relative path exists under the workspace root; `false` on any error or escape. */ + fileExists(relPath: string): boolean; +} + +/** Status order: what needs attention first, then unknown, then ok. */ +const STATUS_RANK: Record = { missing: 0, unknown: 1, ok: 2 }; + +/** The fix every missing provider-key row carries: open Settings. */ +const SETTINGS_FIX = { action: 'open_settings', target: '/settings' } as const; + +/** Providers that need no credential at all (local or the test provider): they produce no row. */ +const KEYLESS_PROVIDERS = new Set(['ollama', 'mock']); + +/** Labels for the providers Studio stores keys for (the `PROVIDER_KEY_ENV` pair). */ +const MANAGED_PROVIDER_LABELS: Record = { + openai: 'OpenAI API key', + anthropic: 'Anthropic API key', +}; + +/** Env-keyed providers outside `PROVIDER_KEY_ENV` (Studio stores no key for them), from PROVIDERS in src/providers/providerSpec.ts. */ +const ENV_KEYED_PROVIDERS: Record = { + openrouter: { envKey: 'OPENROUTER_API_KEY', label: 'OpenRouter API key' }, + google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', label: 'Google API key' }, +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** `a < b` / `a > b` as -1 / 0 / 1: code-point order, so sorting never depends on the locale. */ +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** The provider of a 'provider/model' string: the text before the first `/` (the whole string when there is none). */ +function providerOf(model: string): string { + const slash = model.indexOf('/'); + return slash < 0 ? model : model.slice(0, slash); +} + +/** Every distinct model string the spec runs on: `model` first, then its fallbacks. */ +function modelIdsOf(spec: AgentSpecV2): string[] { + return [spec.model, ...(spec.fallbackModels ?? [])]; +} + +// --------------------------------------------------------------------- +// provider_key rows: one per distinct provider of model + fallbackModels. +// --------------------------------------------------------------------- + +/** One provider's row, or `undefined` for a provider that needs no key. */ +function providerRow(provider: string, deps: RequirementDeps): Requirement | undefined { + if (KEYLESS_PROVIDERS.has(provider)) return undefined; + if (isSecretProvider(provider)) { + const ok = Boolean(deps.secrets.getKey(provider)) || Boolean(deps.env[PROVIDER_KEY_ENV[provider]]); + return { + id: `provider:${provider}`, + kind: 'provider_key', + status: ok ? 'ok' : 'missing', + label: MANAGED_PROVIDER_LABELS[provider], + fix: { ...SETTINGS_FIX }, + }; + } + const envKeyed = ENV_KEYED_PROVIDERS[provider]; + if (envKeyed !== undefined) { + return { + id: `provider:${provider}`, + kind: 'provider_key', + status: deps.env[envKeyed.envKey] ? 'ok' : 'missing', + label: envKeyed.label, + fix: { ...SETTINGS_FIX }, + }; + } + return { id: `provider:${provider}`, kind: 'provider_key', status: 'unknown', label: `${provider} provider` }; +} + +/** `provider:

` rows for every provider `model`/`fallbackModels` name. */ +function providerRows(spec: AgentSpecV2, deps: RequirementDeps): Requirement[] { + const providers = new Set(modelIdsOf(spec).map(providerOf)); + const rows: Requirement[] = []; + for (const provider of providers) { + const row = providerRow(provider, deps); + if (row !== undefined) rows.push(row); + } + return rows; +} + +// --------------------------------------------------------------------- +// model rows: a model the catalog does not know, unknown. +// --------------------------------------------------------------------- + +/** `model:` rows for `model`/`fallbackModels` absent from `modelIds`. */ +function modelRows(spec: AgentSpecV2, modelIds: ReadonlySet): Requirement[] { + return modelIdsOf(spec) + .filter((model) => !modelIds.has(model)) + .map((model) => ({ + id: `model:${model}`, + kind: 'model' as const, + status: 'unknown' as const, + label: `Model ${model} is not in the catalog`, + })); +} + +// --------------------------------------------------------------------- +// secret/env rows: a deep walk of the spec for `{ secret }` / `{ env }` refs. +// --------------------------------------------------------------------- + +/** The SecretRef names found so far in a deep walk. */ +interface FoundRefs { + secrets: Set; + envs: Set; +} + +/** + * Every `{ secret: NAME }`/`{ env: VAR }` ref under `value`: a plain object + * with exactly one of those keys and a string value is a ref, everything + * else is recursed into - except `instructions`, which is prose (a prompt + * file ref is `{ file }`, never a secret) and is never walked. + */ +function collectSecretRefs(value: unknown, found: FoundRefs): void { + if (Array.isArray(value)) { + for (const item of value) collectSecretRefs(item, found); + return; + } + if (!isObject(value)) return; + const keys = Object.keys(value); + if (keys.length === 1 && keys[0] === 'secret' && typeof value['secret'] === 'string') { + found.secrets.add(value['secret']); + return; + } + if (keys.length === 1 && keys[0] === 'env' && typeof value['env'] === 'string') { + found.envs.add(value['env']); + return; + } + for (const [key, child] of Object.entries(value)) { + if (key === 'instructions') continue; + collectSecretRefs(child, found); + } +} + +/** `secret:` and `env:` rows for every SecretRef the spec carries. */ +function secretRefRows(spec: AgentSpecV2, deps: RequirementDeps): Requirement[] { + const found: FoundRefs = { secrets: new Set(), envs: new Set() }; + collectSecretRefs(spec, found); + const rows: Requirement[] = []; + for (const name of found.secrets) { + rows.push({ + id: `secret:${name}`, + kind: 'secret', + status: deps.secrets.hasNamed(name) ? 'ok' : 'missing', + label: `Secret ${name}`, + fix: { action: 'add_secret', target: name }, + }); + } + for (const name of found.envs) { + rows.push({ + id: `env:${name}`, + kind: 'secret', + status: deps.env[name] ? 'ok' : 'missing', + label: `Environment variable ${name}`, + }); + } + return rows; +} + +// --------------------------------------------------------------------- +// mcp rows: one unknown row per configured server (probing is the fix's job). +// --------------------------------------------------------------------- + +/** `mcp:` rows for each `mcpServers` entry - `unknown`: the card's Check button probes on demand. */ +function mcpRows(spec: AgentSpecV2): Requirement[] { + return Object.keys(spec.mcpServers ?? {}).map((key) => ({ + id: `mcp:${key}`, + kind: 'mcp' as const, + status: 'unknown' as const, + label: `MCP server ${key}`, + fix: { action: 'probe' as const, target: key }, + })); +} + +// --------------------------------------------------------------------- +// tool rows: builtin membership + credentials, registry installs. +// --------------------------------------------------------------------- + +/** The credential rows of one catalog-known builtin (one per `needsCredentials` entry). */ +function credentialRows(name: string, needsCredentials: readonly string[], deps: RequirementDeps): Requirement[] { + return needsCredentials.map((cred) => { + const ok = Boolean(deps.env[cred]) || deps.secrets.hasNamed(cred); + return { + id: `tool:${name}:${cred}`, + kind: 'tool' as const, + status: ok ? ('ok' as const) : ('missing' as const), + label: `${cred} for ${name}`, + fix: { action: 'add_secret' as const, target: cred }, + }; + }); +} + +/** `tool:`/`tool::` rows for builtins and `registry:` rows for registry tools. */ +function toolRows( + spec: AgentSpecV2, + deps: RequirementDeps, + tools: ReadonlyArray<{ name: string; needsCredentials: string[] }> +): Requirement[] { + const byName = new Map(tools.map((tool) => [tool.name, tool])); + const rows: Requirement[] = []; + for (const ref of spec.tools ?? []) { + if ('builtin' in ref && typeof ref.builtin === 'string') { + const entry = byName.get(ref.builtin); + if (entry === undefined) { + rows.push({ + id: `tool:${ref.builtin}`, + kind: 'tool', + status: 'missing', + label: `Tool ${ref.builtin} is not available`, + }); + } else { + rows.push(...credentialRows(ref.builtin, entry.needsCredentials, deps)); + } + } else if ('registry' in ref && typeof ref.registry === 'string') { + rows.push({ + id: `registry:${ref.registry}`, + kind: 'tool', + status: 'unknown', + label: `Registry tool ${ref.registry}`, + }); + } + } + return rows; +} + +// --------------------------------------------------------------------- +// file rows: tools[i].file, hooks[i].file and skills[i].dir under the root. +// --------------------------------------------------------------------- + +/** Every agentDir-relative path the spec references, in spec order. */ +function filePathsOf(spec: AgentSpecV2): string[] { + const paths: string[] = []; + for (const ref of spec.tools ?? []) { + if ('file' in ref && typeof ref.file === 'string') paths.push(ref.file); + } + for (const hook of spec.hooks ?? []) { + if ('file' in hook) paths.push(hook.file); + } + for (const skill of spec.skills ?? []) { + if ('dir' in skill) paths.push(skill.dir); + } + return paths; +} + +/** `file:` rows for every local path the spec references. */ +function fileRows(spec: AgentSpecV2, deps: RequirementDeps): Requirement[] { + return filePathsOf(spec).map((relPath) => ({ + id: `file:${relPath}`, + kind: 'tool' as const, + status: deps.fileExists(relPath) ? ('ok' as const) : ('missing' as const), + label: `File ${relPath}`, + })); +} + +/** + * The spec's readiness rows, sorted `missing`, `unknown`, `ok` then by id, + * deduped by id (a secret referenced twice is one row). Never throws: an + * internal error logs one line and returns `[]`. + */ +export async function computeRequirements(spec: AgentSpecV2, deps: RequirementDeps): Promise { + try { + const [modelIds, tools] = await Promise.all([deps.catalog.modelIds(), deps.catalog.builtinTools()]); + const rows = [ + ...providerRows(spec, deps), + ...modelRows(spec, modelIds), + ...secretRefRows(spec, deps), + ...mcpRows(spec), + ...toolRows(spec, deps, tools), + ...fileRows(spec, deps), + ]; + const seen = new Set(); + return rows + .filter((row) => { + if (seen.has(row.id)) return false; + seen.add(row.id); + return true; + }) + .sort((a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status] || cmp(a.id, b.id)); + } catch (error) { + console.warn(`[agent-forge] computeRequirements failed: ${(error as Error).message}`); + return []; + } +} + +/** + * The production {@link RequirementDeps} for one workspace: the app's + * secrets store (provider keys via `getKey`, named secrets via the merged + * `getSecret` - the ticket's `hasNamed`), `process.env`, the SDK's own + * `listModels`/`listBuiltinTools` catalogs, and a `fileExists` that + * resolves `relPath` under `root` and reports `false` for anything that + * would escape it (`../..`, absolute paths) - such a path is `missing`, + * never stat-ed outside the workspace. + */ +export function requirementDeps(root: string, secrets: SecretsStore): RequirementDeps { + const base = path.resolve(root); + return { + secrets: { + getKey: (provider) => (isSecretProvider(provider) ? secrets.getKey(provider) : undefined), + hasNamed: (name) => secrets.getSecret(name) !== undefined, + }, + env: process.env, + catalog: { + modelIds: async () => new Set(listModels().map((model) => model.id)), + builtinTools: async () => listBuiltinTools(), + }, + fileExists: (relPath) => { + const abs = path.resolve(base, relPath); + return (abs === base || abs.startsWith(base + path.sep)) && fs.existsSync(abs); + }, + }; +} diff --git a/apps/agent-forge/server/routes/agentRoutes.ts b/apps/agent-forge/server/routes/agentRoutes.ts index 37c8d792..d2e45e49 100644 --- a/apps/agent-forge/server/routes/agentRoutes.ts +++ b/apps/agent-forge/server/routes/agentRoutes.ts @@ -44,7 +44,9 @@ import type { PatchAgentMetaRequest, VersionSource, } from '../../shared/wireTypes'; +import { computeRequirements, requirementDeps } from '../author/requirements'; import { asyncRoute, paramId, sendError } from '../http/routeUtils'; +import type { SecretsStore } from '../secretsStore'; import { defaultMeta, InvalidSpecError } from '../storage/agentRepo'; import { copyAgentId, slugAgentId, uniqueAgentId } from '../storage/agentIds'; import { withAgentLock } from '../storage/atomic'; @@ -276,6 +278,8 @@ function parseMetaBody(body: unknown): PatchAgentMetaRequest | string { export interface AgentRouteDeps { /** The app's one TemplateCatalog - the `{ template }` create branch reads through it (the templates folder ships in the SDK package). */ templates: TemplateCatalog; + /** The app's one SecretsStore - the readiness checks (`GET .../requirements` and `AgentRecord.requirements`) read stored keys and named secrets through it. */ + secrets: SecretsStore; } /** @@ -379,7 +383,27 @@ export function registerAgentRoutes(app: Express, deps: AgentRouteDeps): void { sendAgentNotFound(res, id); return; } - res.json(record); + // The readiness list (AA13): built per request from this workspace's + // root and the Studio-global secrets store - advisory, never blocks. + const requirements = await computeRequirements(record.spec, requirementDeps(ws.ref.root, deps.secrets)); + res.json({ ...record, requirements }); + }) + ); + + // The spec's readiness rows on their own (PLAN §8.5): the same list + // `GET /agents/:id` embeds on `record.requirements`. Deps are built per + // call so `fileExists` resolves under this request's workspace root. + app.get( + '/agents/:id/requirements', + asyncRoute(async (req, res) => { + const ws = workspaceOf(req); + const id = paramId(req); + const spec = await ws.repo.readSpec(id); + if (spec === undefined) { + res.status(404).json({ error: 'Agent not found.' }); + return; + } + res.json(await computeRequirements(spec, requirementDeps(ws.ref.root, deps.secrets))); }) ); diff --git a/apps/agent-forge/shared/authorWire.ts b/apps/agent-forge/shared/authorWire.ts index 9908e73a..f4165900 100644 --- a/apps/agent-forge/shared/authorWire.ts +++ b/apps/agent-forge/shared/authorWire.ts @@ -9,6 +9,12 @@ * module instead of re-declaring them. */ +// The readiness row AA13's `computeRequirements` returns and the Author's +// proposal cards carry: defined next to `RequirementKind` in wireTypes.ts +// (where `AgentRecord.requirements` reads it), re-exported here so Author +// code has one import site per the PLAN §8.5 contract. +export type { Requirement } from './wireTypes'; + /** What the Author is working on this session. */ export type AuthorFocus = { mode: 'create' } | { mode: 'edit'; agentId: string }; diff --git a/apps/agent-forge/shared/wireTypes.ts b/apps/agent-forge/shared/wireTypes.ts index a25741e9..3691ac08 100644 --- a/apps/agent-forge/shared/wireTypes.ts +++ b/apps/agent-forge/shared/wireTypes.ts @@ -544,6 +544,12 @@ export interface AgentRecord { updatedAt: string; /** Newest version number, `null` when the agent has no versions yet. */ latestVersion: number | null; + /** + * The spec's readiness rows (`computeRequirements`, AA13): what the agent + * needs before it can run. The header's readiness chips and the Author's + * proposal cards read this; it is advisory only and never blocks a save. + */ + requirements?: Requirement[]; } /** @@ -561,6 +567,24 @@ export type CreateAgentFrom = 'blank' | { template: string } | { copyOf: string */ export type RequirementKind = 'provider_key' | 'secret' | 'mcp' | 'tool' | 'model'; +/** + * One readiness row `computeRequirements` returns (AA13, PLAN §8.5) - on + * `GET /agents/:id/requirements`, on `AgentRecord.requirements` and on the + * Author's proposal cards. `id` is exact and dedupe-stable + * (`provider:

`, `model:`, `secret:`, `env:`, `mcp:`, + * `tool:`, `file:`, `registry:`); `status` is `ok`, + * `missing` or `unknown`; `label` names the thing, never a secret value; + * `fix` is the action that makes the row `ok` where one exists. Rows sort + * `missing`, then `unknown`, then `ok`, then by `id`. + */ +export interface Requirement { + id: string; + kind: RequirementKind; + status: 'ok' | 'missing' | 'unknown'; + label: string; + fix?: { action: 'add_secret' | 'open_settings' | 'probe'; target: string }; +} + /** * One row of `GET /templates` (the Templates screen's card grid): the * display fields of `templates//template.json` - everything the card