From 907f9c51725954d1e60334ecc4ea6154f3214f27 Mon Sep 17 00:00:00 2001 From: GautamTalksDev Date: Sun, 30 Aug 2026 00:54:39 -0400 Subject: [PATCH 1/3] fix(ui): ignore stale scan card refreshes Abort prior card requests and require responses to match the active scan so old cards and terminal status cannot overwrite a new scan or its headline. Co-authored-by: Cursor --- apps/web/src/api/client.ts | 7 ++- apps/web/src/hooks/useScanSession.test.ts | 50 +++++++++++++++++++++- apps/web/src/hooks/useScanSession.ts | 52 +++++++++++++++++------ 3 files changed, 94 insertions(+), 15 deletions(-) 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..4d92de1 100644 --- a/apps/web/src/hooks/useScanSession.test.ts +++ b/apps/web/src/hooks/useScanSession.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { applyEvent, emptyActivity } from "./useScanSession.js"; +import type { ApiCard } from "../api/types.js"; +import { applyEvent, emptyActivity, reduce, type ScanSessionState } from "./useScanSession.js"; const event = (type: string, systemId?: string) => ({ type, @@ -34,4 +35,51 @@ 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"); + }); }); diff --git a/apps/web/src/hooks/useScanSession.ts b/apps/web/src/hooks/useScanSession.ts index 7ca2e4b..6f6027f 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"]; @@ -67,7 +68,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 +131,7 @@ function reduce(state: State, action: Action): State { }; } case "cards": + if (state.activity.scanId !== action.scanId) return state; return { ...state, cards: action.cards, @@ -461,25 +463,50 @@ export function useScanSession() { error: null, }); const unsubRef = useRef<(() => void) | null>(null); + const activeScanIdRef = useRef(null); + const refreshAbortRef = useRef(null); useEffect(() => { - return () => unsubRef.current?.(); + return () => { + unsubRef.current?.(); + refreshAbortRef.current?.abort(); + activeScanIdRef.current = null; + }; }, []); 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 (activeScanIdRef.current !== 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 && activeScanIdRef.current === scanId) { + throw err; + } + } finally { + if (refreshAbortRef.current === controller) { + refreshAbortRef.current = null; + } + } } async function beginScan(person: string) { unsubRef.current?.(); + unsubRef.current = null; + refreshAbortRef.current?.abort(); + refreshAbortRef.current = null; + activeScanIdRef.current = null; dispatch({ type: "scan_starting", person }); try { const started = await startScan({ person }); @@ -490,6 +517,7 @@ export function useScanSession() { driver: started.driver, recordingId: started.recordingId ?? null, }); + activeScanIdRef.current = started.scanId; unsubRef.current = subscribeScanStream(started.scanId, { onEvent: (event) => { dispatch({ type: "event", event }); From 93c188a5ee1eec89b698df2f322ade328f163903 Mon Sep 17 00:00:00 2001 From: GautamTalksDev Date: Sun, 30 Aug 2026 01:00:03 -0400 Subject: [PATCH 2/3] fix(ui): guard superseded scan starts Use a monotonic scan-start token to prevent late start responses from replacing the active scan or installing stale SSE subscriptions, keeping queue updates tied to the latest invocation. Co-authored-by: Cursor --- apps/web/src/hooks/useScanSession.test.ts | 70 +++++++++++++++++++- apps/web/src/hooks/useScanSession.ts | 79 +++++++++++++++++++---- 2 files changed, 136 insertions(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useScanSession.test.ts b/apps/web/src/hooks/useScanSession.test.ts index 4d92de1..28670ad 100644 --- a/apps/web/src/hooks/useScanSession.test.ts +++ b/apps/web/src/hooks/useScanSession.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import type { ApiCard } from "../api/types.js"; -import { applyEvent, emptyActivity, reduce, type ScanSessionState } from "./useScanSession.js"; +import { + applyEvent, + createScanStartCoordinator, + emptyActivity, + reduce, + type ScanSessionState, +} from "./useScanSession.js"; const event = (type: string, systemId?: string) => ({ type, @@ -82,4 +88,66 @@ describe("scan session activity reducer", () => { 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 6f6027f..94154d6 100644 --- a/apps/web/src/hooks/useScanSession.ts +++ b/apps/web/src/hooks/useScanSession.ts @@ -40,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", @@ -462,17 +511,19 @@ export function useScanSession() { loading: false, error: null, }); - const unsubRef = useRef<(() => void) | null>(null); - const activeScanIdRef = useRef(null); + const scanStartCoordinatorRef = useRef(null); const refreshAbortRef = useRef(null); + if (scanStartCoordinatorRef.current === null) { + scanStartCoordinatorRef.current = createScanStartCoordinator(); + } + const scanStartCoordinator = scanStartCoordinatorRef.current; useEffect(() => { return () => { - unsubRef.current?.(); + scanStartCoordinator.cancel(); refreshAbortRef.current?.abort(); - activeScanIdRef.current = null; }; - }, []); + }, [scanStartCoordinator]); async function refreshCards(scanId: string) { refreshAbortRef.current?.abort(); @@ -480,7 +531,9 @@ export function useScanSession() { refreshAbortRef.current = controller; try { const res = await fetchCards(scanId, controller.signal); - if (activeScanIdRef.current !== scanId || controller.signal.aborted) return; + if (scanStartCoordinator.activeScanId !== scanId || controller.signal.aborted) { + return; + } dispatch({ type: "cards", scanId, @@ -491,7 +544,7 @@ export function useScanSession() { recordingId: (res.recordingId as string | null) ?? null, }); } catch (err) { - if (!controller.signal.aborted && activeScanIdRef.current === scanId) { + if (!controller.signal.aborted && scanStartCoordinator.activeScanId === scanId) { throw err; } } finally { @@ -502,14 +555,13 @@ export function useScanSession() { } async function beginScan(person: string) { - unsubRef.current?.(); - unsubRef.current = null; + const startToken = scanStartCoordinator.begin(); refreshAbortRef.current?.abort(); refreshAbortRef.current = null; - activeScanIdRef.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, @@ -517,8 +569,7 @@ export function useScanSession() { driver: started.driver, recordingId: started.recordingId ?? null, }); - activeScanIdRef.current = started.scanId; - unsubRef.current = subscribeScanStream(started.scanId, { + const unsubscribe = subscribeScanStream(started.scanId, { onEvent: (event) => { dispatch({ type: "event", event }); if ( @@ -533,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), From 355cb6de6159278d9a85d9dff550f30b9196d9fa Mon Sep 17 00:00:00 2001 From: GautamTalksDev Date: Sun, 30 Aug 2026 02:05:19 -0400 Subject: [PATCH 3/3] fix: register TrueForge providers with exact schemas Use the raw TrueForge REST field names, select an available agent model, and make local Docker MCP endpoints reachable. Add streaming support and reconciliation handling to keep the stub harness compatible with TrueForge. Co-authored-by: Cursor --- scripts/register-keyring-agent.ts | 189 +++++++++++++++++++++--------- scripts/stub-model-server.ts | 76 +++++++++++- 2 files changed, 205 insertions(+), 60 deletions(-) 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; }