diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d7a80c4..807f188 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -42,7 +42,10 @@ export async function startScan(input: { }); } -export async function fetchCards(scanId: string): Promise<{ +export async function fetchCards( + scanId: string, + signal?: AbortSignal, +): Promise<{ scanId: string; status: string; cards: ApiCard[]; @@ -50,7 +53,7 @@ export async function fetchCards(scanId: string): Promise<{ driver?: string | null; recordingId?: string | null; }> { - return request(`/scans/${scanId}/cards`); + return request(`/scans/${scanId}/cards`, { signal }); } export async function postDecision( diff --git a/apps/web/src/hooks/useScanSession.test.ts b/apps/web/src/hooks/useScanSession.test.ts index 881c4dc..28670ad 100644 --- a/apps/web/src/hooks/useScanSession.test.ts +++ b/apps/web/src/hooks/useScanSession.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { applyEvent, emptyActivity } from "./useScanSession.js"; +import type { ApiCard } from "../api/types.js"; +import { + applyEvent, + createScanStartCoordinator, + emptyActivity, + reduce, + type ScanSessionState, +} from "./useScanSession.js"; const event = (type: string, systemId?: string) => ({ type, @@ -34,4 +41,113 @@ describe("scan session activity reducer", () => { expect(afterReconcile.subagents["failed-system"]?.status).toBe("failed"); expect(afterReconcile.subagents["healthy-system"]?.status).toBe("done"); }); + + it("discards a previous scan's card refresh after a new scan starts", () => { + const oldCard = { id: "old-card" } as ApiCard; + const newCard = { id: "new-card" } as ApiCard; + let state: ScanSessionState = { + activity: emptyActivity(), + cards: [] as ApiCard[], + loading: false, + error: null, + }; + + state = reduce(state, { type: "scan_starting", person: "Ada Lovelace" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-1", + person: "Ada Lovelace", + }); + const staleRefresh = { + type: "cards" as const, + scanId: "scan-1", + cards: [oldCard], + status: "completed", + }; + state = reduce(state, { type: "scan_starting", person: "Grace Hopper" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-2", + person: "Grace Hopper", + }); + + const staleResult = reduce(state, staleRefresh); + expect(staleResult.cards).toEqual([]); + expect(staleResult.activity.status).toBe("running"); + + const currentResult = reduce(staleResult, { + type: "cards", + scanId: "scan-2", + cards: [newCard], + status: "completed", + }); + expect(currentResult.cards).toEqual([newCard]); + expect(currentResult.activity.status).toBe("completed"); + + const lateStaleResult = reduce(currentResult, staleRefresh); + expect(lateStaleResult.cards).toEqual([newCard]); + expect(lateStaleResult.activity.status).toBe("completed"); + }); + + it("lets only the latest scan start own the SSE subscription and queue", () => { + const coordinator = createScanStartCoordinator(); + const existingUnsubscribe = { called: false }; + const existingToken = coordinator.begin(); + expect( + coordinator.commit(existingToken, "scan-0", () => { + existingUnsubscribe.called = true; + }), + ).toBe(true); + + const firstToken = coordinator.begin(); + const secondToken = coordinator.begin(); + const secondUnsubscribe = { called: false }; + expect( + coordinator.commit(secondToken, "scan-2", () => { + secondUnsubscribe.called = true; + }), + ).toBe(true); + expect(existingUnsubscribe.called).toBe(true); + expect(coordinator.activeScanId).toBe("scan-2"); + expect(coordinator.hasSubscription).toBe(true); + + const staleUnsubscribe = { called: false }; + expect( + coordinator.commit(firstToken, "scan-1", () => { + staleUnsubscribe.called = true; + }), + ).toBe(false); + expect(staleUnsubscribe.called).toBe(true); + expect(coordinator.activeScanId).toBe("scan-2"); + expect(secondUnsubscribe.called).toBe(false); + + let state: ScanSessionState = { + activity: emptyActivity(), + cards: [], + loading: false, + error: null, + }; + state = reduce(state, { type: "scan_starting", person: "Ada Lovelace" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-1", + person: "Ada Lovelace", + }); + state = reduce(state, { type: "scan_starting", person: "Grace Hopper" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-2", + person: "Grace Hopper", + }); + state = reduce(state, { + type: "cards", + scanId: "scan-1", + cards: [{ id: "stale" } as ApiCard], + status: "completed", + }); + + expect(state.activity.scanId).toBe("scan-2"); + expect(state.cards).toEqual([]); + expect(state.activity.status).toBe("running"); + }); }); diff --git a/apps/web/src/hooks/useScanSession.ts b/apps/web/src/hooks/useScanSession.ts index 7ca2e4b..94154d6 100644 --- a/apps/web/src/hooks/useScanSession.ts +++ b/apps/web/src/hooks/useScanSession.ts @@ -9,7 +9,7 @@ import type { } from "../api/types.js"; import { classifyClientError, recoveryFor } from "../lib/errors.js"; -type State = { +export type ScanSessionState = { activity: AgentActivityState; cards: ApiCard[]; loading: boolean; @@ -31,6 +31,7 @@ type Action = | { type: "event"; event: ScanProgressEvent } | { type: "cards"; + scanId: string; cards: ApiCard[]; status: string; costs?: AgentActivityState["costs"]; @@ -39,6 +40,55 @@ type Action = } | { type: "card_updated"; card: ApiCard }; +export interface ScanStartCoordinator { + begin(): number; + canCommit(token: number): boolean; + commit(token: number, scanId: string, unsubscribe: () => void): boolean; + cancel(): void; + readonly activeScanId: string | null; + readonly hasSubscription: boolean; +} + +export function createScanStartCoordinator(): ScanStartCoordinator { + let latestToken = 0; + let activeScanId: string | null = null; + let unsubscribe: (() => void) | null = null; + + return { + begin() { + latestToken += 1; + unsubscribe?.(); + unsubscribe = null; + activeScanId = null; + return latestToken; + }, + canCommit(token) { + return token === latestToken; + }, + commit(token, scanId, nextUnsubscribe) { + if (token !== latestToken) { + nextUnsubscribe(); + return false; + } + activeScanId = scanId; + unsubscribe = nextUnsubscribe; + return true; + }, + cancel() { + latestToken += 1; + unsubscribe?.(); + unsubscribe = null; + activeScanId = null; + }, + get activeScanId() { + return activeScanId; + }, + get hasSubscription() { + return unsubscribe !== null; + }, + }; +} + export const emptyActivity = (): AgentActivityState => ({ scanId: null, status: "idle", @@ -67,7 +117,7 @@ function pushLog( }; } -function reduce(state: State, action: Action): State { +export function reduce(state: ScanSessionState, action: Action): ScanSessionState { switch (action.type) { case "reset": return { activity: emptyActivity(), cards: [], loading: false, error: null }; @@ -130,6 +180,7 @@ function reduce(state: State, action: Action): State { }; } case "cards": + if (state.activity.scanId !== action.scanId) return state; return { ...state, cards: action.cards, @@ -460,29 +511,57 @@ export function useScanSession() { loading: false, error: null, }); - const unsubRef = useRef<(() => void) | null>(null); + const scanStartCoordinatorRef = useRef(null); + const refreshAbortRef = useRef(null); + if (scanStartCoordinatorRef.current === null) { + scanStartCoordinatorRef.current = createScanStartCoordinator(); + } + const scanStartCoordinator = scanStartCoordinatorRef.current; useEffect(() => { - return () => unsubRef.current?.(); - }, []); + return () => { + scanStartCoordinator.cancel(); + refreshAbortRef.current?.abort(); + }; + }, [scanStartCoordinator]); async function refreshCards(scanId: string) { - const res = await fetchCards(scanId); - dispatch({ - type: "cards", - cards: res.cards, - status: res.status, - costs: res.costs ?? null, - driver: (res.driver as string | null) ?? null, - recordingId: (res.recordingId as string | null) ?? null, - }); + refreshAbortRef.current?.abort(); + const controller = new AbortController(); + refreshAbortRef.current = controller; + try { + const res = await fetchCards(scanId, controller.signal); + if (scanStartCoordinator.activeScanId !== scanId || controller.signal.aborted) { + return; + } + dispatch({ + type: "cards", + scanId, + cards: res.cards, + status: res.status, + costs: res.costs ?? null, + driver: (res.driver as string | null) ?? null, + recordingId: (res.recordingId as string | null) ?? null, + }); + } catch (err) { + if (!controller.signal.aborted && scanStartCoordinator.activeScanId === scanId) { + throw err; + } + } finally { + if (refreshAbortRef.current === controller) { + refreshAbortRef.current = null; + } + } } async function beginScan(person: string) { - unsubRef.current?.(); + const startToken = scanStartCoordinator.begin(); + refreshAbortRef.current?.abort(); + refreshAbortRef.current = null; dispatch({ type: "scan_starting", person }); try { const started = await startScan({ person }); + if (!scanStartCoordinator.canCommit(startToken)) return; dispatch({ type: "scan_started", scanId: started.scanId, @@ -490,7 +569,7 @@ export function useScanSession() { driver: started.driver, recordingId: started.recordingId ?? null, }); - unsubRef.current = subscribeScanStream(started.scanId, { + const unsubscribe = subscribeScanStream(started.scanId, { onEvent: (event) => { dispatch({ type: "event", event }); if ( @@ -505,9 +584,13 @@ export function useScanSession() { } }, }); + if (!scanStartCoordinator.commit(startToken, started.scanId, unsubscribe)) { + return; + } // Initial poll in case events already finished void refreshCards(started.scanId); } catch (err) { + if (!scanStartCoordinator.canCommit(startToken)) return; dispatch({ type: "scan_error", error: err instanceof Error ? err.message : String(err), diff --git a/scripts/register-keyring-agent.ts b/scripts/register-keyring-agent.ts index f7a8a91..a2f2c94 100644 --- a/scripts/register-keyring-agent.ts +++ b/scripts/register-keyring-agent.ts @@ -16,14 +16,8 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const baseUrl = (process.env.TRUEFORGE_BASE_URL ?? "http://localhost:8791").replace( - /\/$/, - "", -); -const keyringUrl = (process.env.KEYRING_BASE_URL ?? "http://localhost:3001").replace( - /\/$/, - "", -); +const baseUrl = (process.env.TRUEFORGE_BASE_URL ?? "http://localhost:8791").replace(/\/$/, ""); +const keyringUrl = (process.env.KEYRING_BASE_URL ?? "http://localhost:3001").replace(/\/$/, ""); const stubModelUrl = ( process.env.KEYRING_STUB_MODEL_URL ?? "http://host.docker.internal:4099/v1" ).replace(/\/$/, ""); @@ -34,7 +28,7 @@ async function tf( method: string, urlPath: string, body?: unknown, -): Promise<{ ok: boolean; status: number; json: unknown }> { +): Promise<{ ok: boolean; status: number; json: unknown; body: string }> { const res = await fetch(`${baseUrl}${urlPath}`, { method, headers: { @@ -52,7 +46,19 @@ async function tf( } catch { json = { raw: text }; } - return { ok: res.ok, status: res.status, json }; + return { ok: res.ok, status: res.status, json, body: text }; +} + +function reportResult( + label: string, + result: { ok: boolean; status: number; body: string }, +): boolean { + if (result.ok) { + console.log(`${label}: ${result.status} ok`); + return true; + } + console.error(`${label}: ${result.status} failed\nResponse body: ${result.body || "(empty)"}`); + return false; } async function upsertMcp(name: string, url: string, description: string) { @@ -69,8 +75,7 @@ async function upsertMcp(name: string, url: string, description: string) { if (!r.ok && r.status === 409) { r = await tf("PUT", `/api/v1/mcp-servers/${name}`, { manifest }); } - console.log(`MCP ${name}: ${r.status}`, r.ok ? "ok" : "failed"); - return r.ok; + return reportResult(`MCP ${name}`, r); } async function upsertOpenAI() { @@ -94,11 +99,9 @@ async function upsertOpenAI() { }; let r = await tf("PUT", "/api/v1/settings/model-providers", { manifest }); if (!r.ok) r = await tf("POST", "/api/v1/settings/model-providers", { manifest }); - console.log( - `Model provider openai: ${r.status}`, - r.ok ? "ok (key from OPENAI_API_KEY)" : "failed", - ); - return r.ok; + const ok = reportResult("Model provider openai", r); + if (ok) console.log("Model provider openai: key from OPENAI_API_KEY"); + return ok; } async function upsertAnthropic() { @@ -122,11 +125,9 @@ async function upsertAnthropic() { }; let r = await tf("PUT", "/api/v1/settings/model-providers", { manifest }); if (!r.ok) r = await tf("POST", "/api/v1/settings/model-providers", { manifest }); - console.log( - `Model provider anthropic: ${r.status}`, - r.ok ? "ok (key from ANTHROPIC_API_KEY)" : "failed", - ); - return r.ok; + const ok = reportResult("Model provider anthropic", r); + if (ok) console.log("Model provider anthropic: key from ANTHROPIC_API_KEY"); + return ok; } async function upsertStubModel() { @@ -149,8 +150,7 @@ async function upsertStubModel() { }; let r = await tf("PUT", "/api/v1/settings/model-providers", { manifest }); if (!r.ok) r = await tf("POST", "/api/v1/settings/model-providers", { manifest }); - console.log(`Model provider keyring-stub: ${r.status}`, r.ok ? "ok" : "failed"); - return r.ok; + return reportResult("Model provider keyring-stub", r); } async function upsertSkill() { @@ -172,8 +172,7 @@ async function upsertSkill() { }; let r = await tf("PUT", "/api/v1/settings/skills", { manifest }); if (!r.ok) r = await tf("POST", "/api/v1/settings/skills", { manifest }); - console.log(`Skill keyring-audit: ${r.status}`, r.ok ? "ok" : "failed"); - return r.ok; + return reportResult("Skill keyring-audit", r); } async function upsertAgent(skillRegistered: boolean) { @@ -189,38 +188,104 @@ async function upsertAgent(skillRegistered: boolean) { if (!skillRegistered) { delete manifest.skills; } + if (!skillRegistered && process.env.KEYRING_ALLOW_STUB === "1") { + disableSandbox(manifest); + } + const configuredModel = configuredAgentModel(); + if (configuredModel && typeof manifest.model === "object" && manifest.model !== null) { + manifest.model = { ...(manifest.model as Record), name: configuredModel }; + } let r = await tf("POST", "/api/v1/agents", { name: "keyring", manifest }); if (r.status === 409) { const listed = await tf("GET", "/api/v1/agents"); - const agents = - (listed.json as { data?: Array<{ id: string; name: string }> })?.data ?? - (listed.json as Array<{ id: string; name: string }>) ?? - []; - const existing = Array.isArray(agents) - ? agents.find((a) => a.name === "keyring") - : undefined; + if (!listed.ok) { + reportResult("Agent list", listed); + return false; + } + const agents = extractAgents(listed.json); + const existing = Array.isArray(agents) ? agents.find((a) => a.name === "keyring") : undefined; if (!existing?.id) { console.error("Agent name conflict but could not find keyring id"); return false; } r = await tf("PUT", `/api/v1/agents/${existing.id}`, { manifest }); + } else if (!r.ok) { + reportResult("Agent keyring", r); + return false; + } + if (!reportResult("Agent keyring", r)) return false; + + const listed = await tf("GET", "/api/v1/agents"); + if (!listed.ok) { + reportResult("Agent verification list", listed); + return false; + } + const existing = extractAgents(listed.json).find((agent) => agent.name === "keyring"); + if (!existing?.id) { + console.error( + `Agent verification failed: keyring is not present in GET /api/v1/agents\nResponse body: ${listed.body || "(empty)"}`, + ); + return false; } - console.log(`Agent keyring: ${r.status}`, r.ok ? "ok" : "failed"); - return r.ok; + console.log(`Agent keyring: verified (id ${existing.id})`); + return true; +} + +function configuredAgentModel(): string | null { + if (process.env.OPENAI_API_KEY) return "openai/gpt-4o-mini"; + if (process.env.ANTHROPIC_API_KEY) return "anthropic/claude-haiku-4-5"; + if (process.env.KEYRING_ALLOW_STUB === "1") return "keyring-stub/keyring-stub"; + return null; +} + +function disableSandbox(manifest: Record): void { + const config = + manifest.config && typeof manifest.config === "object" + ? (manifest.config as Record) + : {}; + const sandbox = + config.sandbox && typeof config.sandbox === "object" + ? (config.sandbox as Record) + : {}; + manifest.config = { ...config, sandbox: { ...sandbox, enabled: false } }; +} + +function extractAgents(value: unknown): Array<{ id: string; name: string }> { + const body = value as { data?: unknown; agents?: unknown } | Array<{ id: string; name: string }>; + const candidates = Array.isArray(body) + ? body + : Array.isArray(body?.data) + ? body.data + : Array.isArray(body?.agents) + ? body.agents + : []; + return candidates.filter( + (agent): agent is { id: string; name: string } => + typeof agent === "object" && + agent !== null && + typeof (agent as { id?: unknown }).id === "string" && + typeof (agent as { name?: unknown }).name === "string", + ); } async function main() { console.log(`TrueForge: ${baseUrl}`); console.log(`Keyring: ${keyringUrl}`); - console.log( - "Keys: OPENAI_API_KEY / ANTHROPIC_API_KEY are read from env only — never logged.", - ); + console.log("Keys: OPENAI_API_KEY / ANTHROPIC_API_KEY are read from env only — never logged."); + const failures: string[] = []; const openai = await upsertOpenAI(); + if (process.env.OPENAI_API_KEY && !openai) failures.push("openai provider"); const anthropic = openai ? false : await upsertAnthropic(); + if (!openai && process.env.ANTHROPIC_API_KEY && !anthropic) { + failures.push("anthropic provider"); + } if (!openai && !anthropic) { - await upsertStubModel(); + const stub = await upsertStubModel(); + if (process.env.KEYRING_ALLOW_STUB === "1" && !stub) { + failures.push("stub model provider"); + } if (process.env.KEYRING_ALLOW_STUB !== "1") { console.warn( "No OPENAI_API_KEY or ANTHROPIC_API_KEY — agent model FQN may not resolve until you add a provider in TrueForge Settings → Models.", @@ -228,18 +293,34 @@ async function main() { } } - await upsertMcp( - "keyring-scan", - `${rewriteLocalhost(keyringUrl)}/mcp/scan`, - "Keyring read-only scan: list systems, inventory, reconcile, persist ApprovalCards. No write credentials.", - ); - await upsertMcp( - "keyring-mutate", - `${rewriteLocalhost(keyringUrl)}/mcp/mutate`, - "Keyring mutate: revoke_grant only. Requires TrueForge human approval. Write credentials live here only.", - ); + if ( + !(await upsertMcp( + "keyring-scan", + `${rewriteLocalhost(keyringUrl)}/mcp/scan`, + "Keyring read-only scan: list systems, inventory, reconcile, persist ApprovalCards. No write credentials.", + )) + ) { + failures.push("keyring-scan MCP"); + } + if ( + !(await upsertMcp( + "keyring-mutate", + `${rewriteLocalhost(keyringUrl)}/mcp/mutate`, + "Keyring mutate: revoke_grant only. Requires TrueForge human approval. Write credentials live here only.", + )) + ) { + failures.push("keyring-mutate MCP"); + } const skillOk = await upsertSkill(); - await upsertAgent(skillOk); + if (process.env.KEYRING_SKILL_GIT_URL && !skillOk) { + failures.push("keyring-audit skill"); + } + if (!(await upsertAgent(skillOk))) failures.push("keyring agent"); + if (failures.length > 0) { + console.error(`\nRegistration failed: ${failures.join(", ")}`); + process.exitCode = 1; + return; + } console.log("\nDone. See docs/COSTS.md for role models, hard cap, and record/replay."); } @@ -248,11 +329,11 @@ function rewriteLocalhost(url: string): string { return process.env.KEYRING_MCP_PUBLIC_URL.replace(/\/$/, ""); } if (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1")) { - return url; + return url + .replace("://localhost", "://host.docker.internal") + .replace("://127.0.0.1", "://host.docker.internal"); } - return url - .replace("://localhost", "://host.docker.internal") - .replace("://127.0.0.1", "://host.docker.internal"); + return url; } main().catch((err) => { diff --git a/scripts/stub-model-server.ts b/scripts/stub-model-server.ts index 52fb724..b1d44c3 100644 --- a/scripts/stub-model-server.ts +++ b/scripts/stub-model-server.ts @@ -104,6 +104,60 @@ function completionText(content: string) { }; } +function streamCompletion( + res: ServerResponse, + completion: ReturnType | ReturnType, +) { + const choice = completion.choices[0]!; + const message = choice.message; + const id = completion.id; + const created = completion.created; + const model = completion.model; + const toolCalls = "tool_calls" in message ? message.tool_calls : undefined; + const firstChunk = { + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + role: "assistant", + ...(toolCalls + ? { + tool_calls: toolCalls.map((call, index) => ({ + index, + id: call.id, + type: call.type, + function: call.function, + })), + } + : { content: message.content ?? "" }), + }, + finish_reason: null, + }, + ], + }; + const finishChunk = { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: choice.finish_reason }], + }; + + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + "access-control-allow-origin": "*", + }); + res.write(`data: ${JSON.stringify(firstChunk)}\n\n`); + res.write(`data: ${JSON.stringify(finishChunk)}\n\n`); + res.end("data: [DONE]\n\n"); +} + function findTool(tools: ToolDef[] | undefined, names: string[]): string | null { if (!tools) return null; for (const n of names) { @@ -182,7 +236,10 @@ function unwrapMcpText(raw: string | null): unknown { } function decide(messages: ChatMessage[], tools: ToolDef[] | undefined) { - const blob = messages.map((m) => `${m.role}:${messageText(m)}`).join("\n").toLowerCase(); + const blob = messages + .map((m) => `${m.role}:${messageText(m)}`) + .join("\n") + .toLowerCase(); // Subagent: inventory a single system const systemMatch = @@ -230,8 +287,7 @@ function decide(messages: ChatMessage[], tools: ToolDef[] | undefined) { const reconciled = messages.some( (m) => m.role === "tool" && - (messageText(m).includes("reconciliation") || - m.name === "run_identity_reconciliation"), + (messageText(m).includes('"reconciliation"') || m.name === "run_identity_reconciliation"), ); const persisted = messages.some( (m) => @@ -252,7 +308,9 @@ function decide(messages: ChatMessage[], tools: ToolDef[] | undefined) { } // After list: spawn subagents (or inventory sequentially if harness tool missing) - const systems = parseSystems(lastToolResult(messages, "list_connected_systems") ?? lastToolResult(messages)); + const systems = parseSystems( + lastToolResult(messages, "list_connected_systems") ?? lastToolResult(messages), + ); const hasSubagentResults = messages.filter((m) => m.role === "tool" && messageText(m).includes("grants")).length >= Math.max(1, systems.length); @@ -314,7 +372,8 @@ function decide(messages: ChatMessage[], tools: ToolDef[] | undefined) { } if (reconciled && persistName) { - const reconRaw = lastToolResult(messages, "run_identity_reconciliation") ?? lastToolResult(messages); + const reconRaw = + lastToolResult(messages, "run_identity_reconciliation") ?? lastToolResult(messages); const recon = unwrapMcpText(reconRaw) as { reconciliation?: unknown; }; @@ -369,9 +428,14 @@ const server = createServer(async (req, res) => { const body = JSON.parse(raw) as { messages: ChatMessage[]; tools?: ToolDef[]; + stream?: boolean; }; const out = decide(body.messages ?? [], body.tools); - json(res, 200, out); + if (body.stream) { + streamCompletion(res, out); + } else { + json(res, 200, out); + } return; }