diff --git a/src/bundled/codex/codex-sh.test.ts b/src/bundled/codex/codex-sh.test.ts index 0702f95898..35dcc84825 100644 --- a/src/bundled/codex/codex-sh.test.ts +++ b/src/bundled/codex/codex-sh.test.ts @@ -429,21 +429,29 @@ it("lets go of the hand-over lock before Codex starts", async () => { ); await mkdir(f.own, { recursive: true }); await writeFile(join(f.own, "auth.json"), "first", { mode: 0o600 }); + let observed!: () => void, failed!: (error: Error) => void; + const boundary = new Promise((resolve, reject) => { + observed = resolve; + failed = reject; + }); + const watcher = watch(f.home, (_event, name) => { + if (name === "started") observed(); + }); + watcher.once("error", failed); // An app server that stays up, as Buzz's does, must not hold up the next run. const first = f.step("run"); try { - await expect - .poll(() => - stat(started).then( - () => true, - () => false, - ), - ) - .toBe(true); + await Promise.race([ + boundary, + first.then(() => { + throw new Error("Codex exited before its startup boundary"); + }), + ]); await signIn(f.own, "second"); await f.step("run"); expect(await readFile(join(f.codex, "auth.json"), "utf8")).toBe("second"); } finally { + watcher.close(); first.child.kill(); await first.catch(() => {}); } diff --git a/src/bundled/codex/index.test.ts b/src/bundled/codex/index.test.ts index 35f4c7366b..ad3d29f3c3 100644 --- a/src/bundled/codex/index.test.ts +++ b/src/bundled/codex/index.test.ts @@ -24,6 +24,7 @@ function applied() { let type: AgentType | undefined; const subscribe = () => () => undefined; const ctx = { + agentProtection: { registerModels: vi.fn() }, pluginOwner: { id: "buzz.codex", revision: "r1" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { @@ -124,6 +125,7 @@ it("stops agents that stop running even when the agent list could not be read", let state = { status: "ready", agents: [agent], running: [agent] }; let notify = () => undefined; apply({ + agentProtection: { registerModels: vi.fn() }, pluginOwner: { id: "buzz.codex" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { @@ -188,6 +190,7 @@ it("starts the server once Codex is updated and checked, only while a Codex agen }; let cleanup: () => void = () => undefined; apply({ + agentProtection: { registerModels: vi.fn() }, pluginOwner: { id: "buzz.codex" }, host: { spawn: vi.fn(), request: vi.fn() }, agents: { diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 0d64dabb74..82d231cc21 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -11,9 +11,11 @@ import { CodexRuntime } from "./runtime"; import { CodexSetup, withScript } from "./setup"; import { CodexProvider, createTabs } from "./tabs"; import { readUsage } from "./usage"; +import { reviewModel } from "./security-review"; +import { AppServer, listModels } from "./rpc"; /** How often Buzz's copy of Codex looks for a new release. */ const UPDATE_MS = 6 * 60 * 60_000; -export const inject = ["agents", "host"]; +export const inject = ["agents", "host", "agentProtection"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `codex`, and setup and file uploads run // `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere. @@ -60,7 +62,26 @@ export const apply: PluginModule["apply"] = (ctx) => { }, } : undefined, + ctx.host, + (pubkey) => ctx.agents.handle(pubkey), ); + ctx.agentProtection.registerModels({ + label: "Codex", + models: async () => { + const rpc = new AppServer(); + try { + await rpc.open(start); + return (await listModels(rpc)).map((model) => ({ + id: model.model, + label: model.displayName, + })); + } finally { + await rpc.close(); + } + }, + complete: (model, prompt, signal, protectedSpawn) => + reviewModel(protectedSpawn, model, prompt, signal), + }); /** A Codex agent is running, so Buzz's copy is needed; unknown until the * agents have loaded. */ let needed: boolean | undefined; @@ -121,6 +142,8 @@ export const apply: PluginModule["apply"] = (ctx) => { ctx.agents.register({ id: TYPE_ID, hooks: { events: CODEX_HOOK_EVENTS }, + retire: (pubkey) => runtime.retire(pubkey), + protection: true, title: "Codex", icon: CodexLogoIcon, description: diff --git a/src/bundled/codex/rpc.ts b/src/bundled/codex/rpc.ts index efad5a4508..3eebdee6a8 100644 --- a/src/bundled/codex/rpc.ts +++ b/src/bundled/codex/rpc.ts @@ -56,7 +56,7 @@ export class AppServer { private readonly toolCall?: (call: ToolCall) => Promise, ) {} - async open(spawn: Spawn, cwd?: string) { + async open(spawn: Spawn, cwd?: string, protectedRuntime = false) { this.process = await spawn("app-server", { ...(cwd ? { cwd } : {}), onStdout: (data) => this.read(data), @@ -73,24 +73,34 @@ export class AppServer { await this.close(); throw this.failure; } - await this.request("initialize", { - clientInfo: { - name: "buzz_codex_plugin", - title: "Buzz Codex", - version: "0.1.0", + const initialized = await this.request<{ userAgent?: string }>( + "initialize", + { + clientInfo: { + name: "buzz_codex_plugin", + title: "Buzz Codex", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + optOutNotificationMethods: [ + "item/agentMessage/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/plan/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + ], + }, }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: [ - "item/agentMessage/delta", - "item/reasoning/summaryTextDelta", - "item/reasoning/textDelta", - "item/plan/delta", - "item/commandExecution/outputDelta", - "item/fileChange/outputDelta", - ], - }, - }); + ); + if ( + protectedRuntime && + !initialized.userAgent?.includes("buzz-protection-v2") + ) { + await this.close(); + throw new Error("This Codex build lacks mandatory security hooks"); + } await this.send({ method: "initialized" }); return this; } diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 3c00737920..a1107085a8 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -7,7 +7,12 @@ import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; import { localStore } from "../../features/agents/state"; import { RESUMED_PROMPT } from "../../buzz-mcp/prompt"; -import type { Agent, Delivery } from "../../features/agents/service"; +import type { + Agent, + AgentHandle, + Delivery, +} from "../../features/agents/service"; +import type { AgentProtection } from "../../features/agent-protection/service"; import type { RelayData } from "../../features/relay/service"; import type { EventData } from "../../features/relay/events"; import { eventDto } from "../../features/relay/events"; @@ -59,12 +64,17 @@ function fixture( } = {}, repositories?: (scope: string | undefined) => string, hooks?: ConstructorParameters[7], + security?: { + host: ConstructorParameters[8]; + handle: ConstructorParameters[9]; + }, ) { let terminals = remainingTerminals; let options: HostProcessOptions | undefined; let thread = 0; let turn = 0; const sent: Wire[] = []; + const modelIds: [string, string] = ["test-model", "deep-model"]; /** Methods the server answers with a JSON-RPC error instead of a result. */ const failing = new Set(); /** Methods the server answers only once their promise settles. */ @@ -107,11 +117,13 @@ function fixture( } const params = request.params as Record; let result: unknown = {}; + if (request.method === "initialize") + result = { userAgent: "fixture buzz-protection-v2" }; if (request.method === "model/list") result = { data: [ { - model: "test-model", + model: modelIds[0], displayName: "Test Model", isDefault: true, defaultReasoningEffort: "medium", @@ -120,7 +132,7 @@ function fixture( ), }, { - model: "deep-model", + model: modelIds[1], displayName: "", isDefault: false, defaultReasoningEffort: "high", @@ -339,6 +351,8 @@ function fixture( () => added, repositories, hooks, + security?.host, + security?.handle, ); runtimes.push(runtime); const events: EventData[] = []; @@ -457,6 +471,7 @@ function fixture( vi.stubGlobal("localStorage", local); vi.stubGlobal("navigator", { platform: "MacIntel" }); apply({ + agentProtection: { registerModels: vi.fn() }, host: { spawn }, agents: { snapshot: () => ({ status: "ready", agents: [], running }), @@ -589,6 +604,7 @@ function fixture( adopted, process, sent, + modelIds, failing, held, emit, @@ -1068,6 +1084,199 @@ it("shares model discovery with active turns and another agent", async () => { f.complete(); await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); }); + +function protectedPreview() { + const engine = () => { + const next = fixture(); + next.modelIds.splice(0, 2, "protected-model", "protected-deep"); + return next; + }; + let worker = engine(); + let handle: AgentHandle | undefined; + const assertAvailable = vi.fn(() => {}); + const protection = (key: string): AgentProtection => ({ + key, + stateKey: key, + assertAvailable, + check: async () => {}, + processProtection: { readOnlyPaths: [], hiddenPaths: [] }, + spawn: (options) => worker.spawn("app-server", options), + }); + const f = fixture([], undefined, false, {}, undefined, undefined, { + host: { + serveMcp: async () => { + const closed = deferred(); + return { + url: "http://127.0.0.1:1/mcp", + headers: [{ name: "Authorization", value: "Bearer fixture" }], + closed: closed.promise, + close: async () => closed.resolve(), + }; + }, + }, + handle: (key) => (key === pubkey ? handle : undefined), + }); + const original = { + ...f.delivery("unused").agent, + protection: protection("generation-1"), + }; + handle = original; + const agent = { + key: pubkey, + pubkey, + config: { workspace: "/tmp/codex-test", model: "protected-model" }, + protection: { provider: "security", policy: {} }, + } as Agent; + return { + ...f, + agent, + assertAvailable, + get worker() { + return worker; + }, + get handle() { + return handle; + }, + bind(next: AgentHandle | undefined) { + handle = next; + }, + replace(key = "generation-2") { + worker = engine(); + worker.modelIds.splice(0, 2, "replacement-model", "replacement-deep"); + handle = { ...original, protection: protection(key) }; + }, + }; +} + +it("discovers protected models from the same engine that runs the selected model", async () => { + const f = protectedPreview(); + const ordinaryAgent = { + ...f.agent, + key: "e".repeat(64), + config: {}, + }; + delete ordinaryAgent.protection; + const ordinary = await f.runtime.preview(ordinaryAgent); + expect(ordinary.models.map((model) => model.id)).toEqual([ + "test-model", + "deep-model", + ]); + const pending = f.runtime.preview(f.agent); + const preview = await pending; + expect(preview.models.map((model) => model.id)).toEqual([ + "protected-model", + "protected-deep", + ]); + expect(f.runtime.preview(f.agent)).toBe(pending); + expect(f.spawn).toHaveBeenCalledTimes(1); + expect(f.worker.spawn).toHaveBeenCalledTimes(1); + if (!f.handle) throw new Error("Missing protected handle"); + const picked = preview.models[1]; + if (!picked) throw new Error("Missing protected model choice"); + const delivery = { + ...f.delivery("use the selected protected model"), + agent: f.handle, + config: f.agent.config, + model: picked.id, + }; + await f.runtime.run(delivery); + await vi.waitFor(() => expect(f.worker.starts()).toHaveLength(1)); + expect(f.worker.starts()[0]?.params).toMatchObject({ + model: "protected-deep", + }); + expect(f.starts()).toEqual([]); + await f.worker.complete(0, ""); +}); + +it("refuses missing or revoked protection before returning a cached model preview", async () => { + const f = protectedPreview(); + const handle = f.handle; + f.bind(undefined); + expect(() => f.runtime.preview(f.agent)).toThrow( + "The protected agent has no live policy binding", + ); + expect(f.spawn).not.toHaveBeenCalled(); + expect(f.worker.spawn).not.toHaveBeenCalled(); + f.bind(handle); + await f.runtime.preview(f.agent); + f.bind(undefined); + expect(() => f.runtime.preview(f.agent)).toThrow( + "The protected agent has no live policy binding", + ); + f.bind(handle); + f.assertAvailable.mockImplementation(() => { + throw new Error("protection revoked"); + }); + expect(() => f.runtime.preview(f.agent)).toThrow("protection revoked"); + expect(f.spawn).not.toHaveBeenCalled(); +}); + +it("reports a protection startup failure and settles the waiting activity", async () => { + const f = protectedPreview(); + await f.runtime.preview(f.agent); + f.assertAvailable.mockImplementation(() => { + throw new Error("protection revoked"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + if (!f.handle) throw new Error("Missing protected handle"); + await f.runtime.run({ + ...f.delivery("reply safely"), + agent: f.handle, + config: f.agent.config, + }); + await vi.waitFor(() => + expect(f.runtime.sessions(pubkey)).toMatchObject([ + { + status: "Needs attention", + detail: expect.stringContaining("protection revoked"), + }, + ]), + ); + await expect(f.workingUntil.mock.calls[0]?.[0]).resolves.toEqual({ + ok: false, + error: "protection revoked", + }); + expect(f.publish).toHaveBeenCalledOnce(); + expect(f.publish.mock.calls[0]?.[0]).toMatchObject({ + content: expect.stringContaining("protection revoked"), + }); + expect(f.worker.starts()).toEqual([]); +}); + +it("invalidates model previews on retirement even when the protection key is unchanged", async () => { + const f = protectedPreview(); + const first = f.runtime.preview(f.agent); + await first; + const previous = f.worker; + await f.runtime.retire(pubkey); + expect(previous.process.end).toHaveBeenCalledOnce(); + f.replace("generation-1"); + const next = f.runtime.preview(f.agent); + expect(next).not.toBe(first); + expect((await next).models.map((model) => model.id)).toEqual([ + "protected-model", + "replacement-model", + "replacement-deep", + ]); + expect(f.worker.spawn).toHaveBeenCalledOnce(); + expect(f.spawn).not.toHaveBeenCalled(); +}); + +it("refreshes a cached preview when a replacement provider has another protection key", async () => { + const f = protectedPreview(); + const first = f.runtime.preview(f.agent); + await first; + await f.worker.process.end(); + f.replace(); + const next = f.runtime.preview(f.agent); + expect(next).not.toBe(first); + expect((await next).models.map((model) => model.id)).toContain( + "replacement-deep", + ); + expect(f.worker.spawn).toHaveBeenCalledOnce(); + expect(f.spawn).not.toHaveBeenCalled(); +}); + it("ignores changes to local Codex model preferences in the composer", async () => { const configured = { model: "deep-model" }; const f = fixture([], undefined, false, configured); @@ -3924,6 +4133,30 @@ it("does not title a conversation outside a Solo chat", async () => { expect(named).not.toHaveBeenCalled(); }); +it("keeps the first-message title without another protected model input", async () => { + const f = protectedPreview(); + asSoloChat(f); + const named = vi.fn(); + if (!f.handle) throw new Error("Missing protected handle"); + await f.runtime.run({ + ...f.delivery("Summarize this untrusted note"), + agent: f.handle, + config: f.agent.config, + named, + }); + await vi.waitFor(() => expect(f.worker.starts()).toHaveLength(1)); + await f.worker.complete(0, "Summary"); + await vi.waitFor(() => + expect(f.runtime.sessions(pubkey)[0]?.status).toBe("Idle"), + ); + expect( + f.worker.sent.filter((wire) => wire.method === "thread/start"), + ).toHaveLength(1); + expect(f.worker.starts()).toHaveLength(1); + expect(named).not.toHaveBeenCalled(); + expect(f.starts()).toEqual([]); +}); + it("finishes a Solo turn under its policy when the chat is converted mid-turn", async () => { const f = fixture(); const soloChannels = asSoloChat(f); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 3384cab611..0a5e75098a 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -3,6 +3,8 @@ import type { Host } from "../../features/host/service"; import { nativeHookState } from "./hooks"; import { isFinalMessage } from "../../buzz-mcp/session-tools"; import type { WorkOutcome } from "../../features/agents/local-activity"; +import type { AgentProtection } from "../../features/agent-protection/service"; +import { nativeProtectionBridge } from "../../features/agent-protection/native-bridge"; import { fromOwner, workflowOf } from "../../features/agents/owner-attestation"; import { type ConversationWake, @@ -172,6 +174,7 @@ type Entry = { waiting: Set; }; type Server = { + protection?: AgentProtection; rpc: AppServer; opening: Promise; /** Threads this process has loaded, and the agent, by key, each belongs to. */ @@ -241,6 +244,7 @@ function textOf(event: EventData, name: string) { export class CodexRuntime { private entries = new Map(); private server: Server | undefined; + private protectedServers = new Map(); private stopping = new Map>(); private views = new Map(); private listeners = new Set<() => void>(); @@ -272,6 +276,9 @@ export class CodexRuntime { input: unknown, ): Promise>; }, + private readonly protectionHost?: Pick, + private readonly handle: (pubkey: string) => AgentHandle | undefined = () => + undefined, ) {} subscribe = (listener: () => void) => { this.listeners.add(listener); @@ -334,7 +341,9 @@ export class CodexRuntime { return; } if (!saved) throw new Error("There is no session here to compact yet."); - const server = this.open(); + if (agent.protection && !this.handle(agent.key)?.protection) + throw new Error("The protected agent has no live policy binding"); + const server = this.open(this.handle(agent.key)); const rpc = await server.opening; const settings = this.resolveConfig(agent.config); const workspace = await this.workspace(settings.workspace); @@ -377,11 +386,19 @@ export class CodexRuntime { }); } preview(agent: Agent) { + const handle = this.handle(agent.key); + if (agent.protection && !handle?.protection) + throw new Error("The protected agent has no live policy binding"); + handle?.protection?.assertAvailable(); const settings = this.resolveConfig(agent.config); - const key = JSON.stringify([settings.model, settings.effort]); + const key = JSON.stringify([ + settings.model, + settings.effort, + handle?.protection?.key, + ]); const existing = this.previews.get(agent.key); if (existing?.key === key) return existing.ready; - const server = this.open(); + const server = this.open(handle); const rpc = server.rpc; const ready = (async () => { await server.opening; @@ -461,6 +478,32 @@ export class CodexRuntime { ); for (const listener of this.listeners) listener(); } + private readonly retirements = new Map>(); + async retire(pubkey: string) { + this.previews.delete(pubkey); + let retiring = this.retirements.get(pubkey); + if (!retiring) { + retiring = (async () => { + const entry = this.entries.get(pubkey); + if (entry) { + entry.abort.abort(); + this.entries.delete(pubkey); + this.views.delete(pubkey); + const server = this.protectedServers.get(pubkey) ?? this.server; + if (server) this.stop(server, pubkey, entry); + } + const protectedServer = this.protectedServers.get(pubkey); + if (protectedServer) { + await protectedServer.rpc.close(); + this.protectedServers.delete(pubkey); + } + await this.stopping.get(pubkey); + })(); + this.retirements.set(pubkey, retiring); + } + await retiring; + this.retirements.delete(pubkey); + } sync(agents: readonly Agent[]) { const present = new Set(agents.map((agent) => agent.key)); for (const key of this.previews.keys()) @@ -469,12 +512,19 @@ export class CodexRuntime { } for (const [key, entry] of this.entries) if (!present.has(key)) { - entry.abort.abort(); - this.entries.delete(key); - this.views.delete(key); - if (this.server) this.stop(this.server, key, entry); + if (this.protectedServers.has(key)) { + void this.retire(key).catch(() => undefined); + } else { + entry.abort.abort(); + this.entries.delete(key); + this.views.delete(key); + if (this.server) this.stop(this.server, key, entry); + } } - if (!present.size) this.close(); + if (!present.size) { + void this.server?.rpc.close(); + this.server = undefined; + } for (const listener of this.listeners) listener(); } /** Other agents share the server, so a stopped agent's turns interrupt @@ -491,7 +541,11 @@ export class CodexRuntime { ]); for (const [threadId, owner] of server.threads) if (owner === agent) { - if (this.server !== server) return; + if ( + this.server !== server && + this.protectedServers.get(agent) !== server + ) + return; server.threads.delete(threadId); await server.rpc .request("thread/backgroundTerminals/clean", { threadId }) @@ -521,10 +575,74 @@ export class CodexRuntime { this.open(); } private close() { + for (const server of this.protectedServers.values()) + void server.rpc.close(); + this.protectedServers.clear(); void this.server?.rpc.close(); this.server = undefined; } - private open() { + private open(agent?: Pick) { + if (agent?.protection) { + const protection = agent.protection; + protection.assertAvailable(); + const prior = this.protectedServers.get(agent.key); + if (prior) { + if (prior.protection?.key !== protection.key) + throw new Error("Retire the previous protected Codex worker first"); + return prior; + } + const host = this.protectionHost; + if (!host?.serveMcp) + throw new Error("Protected Codex requires the desktop security host"); + const serveMcp = host.serveMcp.bind(host); + const rpc = new AppServer((call) => this.tool(call)); + const server: Server = { + rpc, + protection, + threads: new Map(), + opening: Promise.resolve(rpc), + }; + server.opening = (async () => { + const bridge = await nativeProtectionBridge(serveMcp, protection); + rpc.onMessage((wire) => { + if (wire.method === "connection/closed") { + void bridge.close(); + if (this.protectedServers.get(agent.key) === server) + this.protectedServers.delete(agent.key); + } + }); + try { + return await rpc.open( + (_id, options) => + protection.spawn({ + ...options, + env: { + ...bridge.env, + BASH_ENV: null, + ENV: null, + DYLD_INSERT_LIBRARIES: null, + DYLD_LIBRARY_PATH: null, + DYLD_FRAMEWORK_PATH: null, + LD_PRELOAD: null, + LD_LIBRARY_PATH: null, + }, + }), + defaults.workspace, + true, + ); + } catch (error) { + await bridge.close(); + await rpc.close(); + throw error; + } + })(); + this.protectedServers.set(agent.key, server); + void server.opening.catch(() => { + if (this.protectedServers.get(agent.key) === server) + this.protectedServers.delete(agent.key); + }); + return server; + } if (this.server) return this.server; const rpc = new AppServer((call) => this.tool(call)); // Threads set their own cwd; the process just needs a directory that exists. @@ -556,6 +674,8 @@ export class CodexRuntime { return server; } private entry(agent: AgentKey, scope: string) { + if (this.retirements.has(agent)) + throw new Error("This agent’s previous native sessions have not retired"); const prior = this.entries.get(agent); if (prior) return prior; const saved = this.load(this.storageKey(scope, agent)); @@ -935,7 +1055,7 @@ export class CodexRuntime { "Choose an absolute workspace path or ~/.buzz in Codex settings first.", }; await entry.after.catch(() => undefined); - const server = this.open(); + const server = this.open(agent); const signal = AbortSignal.any([entry.abort.signal, turnSignal]); let release!: () => void; const once: Once = { @@ -1156,7 +1276,14 @@ export class CodexRuntime { this.status(request.agent.key, key, "Starting"); // Agents can deliver follow-ups and other conversations immediately. void job - .catch((error) => console.error("Codex turn failed", error)) + .catch(async (error) => { + request.failActivity(message(error)); + const reason = `Codex could not start: ${message(error)}.`; + this.status(request.agent.key, key, "Needs attention", reason); + await this.publish(request, reason).catch((failure) => + console.error("Codex could not publish its failure", failure), + ); + }) .finally(() => { if (current.tail === job && !current.active) { entry.lanes.delete(key); @@ -1232,6 +1359,17 @@ export class CodexRuntime { const owner = request ?? (once as Once); const { agent } = owner; const client = appClient(agent, { + protectionContext: async () => { + const rpc = + active?.rpc ?? + this.protectedServers.get(agent.key)?.rpc ?? + this.server?.rpc; + if (!rpc) throw new Error("The protected Codex session is unavailable"); + return rpc.request("thread/read", { + threadId: call.threadId, + includeTurns: true, + }); + }, relayAuthor: this.reads(agent.key)?.relayAuthor?.(), signingAuthority: this.reads(agent.key)?.workflowAuthority(), read: (path) => readFile(this.spawn, turn.workspace, path, turn.signal), @@ -1492,7 +1630,8 @@ export class CodexRuntime { !disabled && this.server?.rpc === rpc && this.hooks ? await nativeHookState(rpc, workspace) : undefined; - const hookAdapter = disabled ? undefined : this.hooks; + const hookAdapter = + !disabled && this.server?.rpc === rpc ? this.hooks : undefined; const bridge = hookAdapter ? await openHookBridge(hookAdapter.serve, (session, event, input) => hookAdapter.run(agent.key, session, event, input), @@ -1530,7 +1669,7 @@ export class CodexRuntime { let ready!: () => void; let notReady!: (error: unknown) => void; const turnAbort = new AbortController(); - const server = this.open(); + const server = this.open(request.agent); // No deadline of our own: a turn runs until Codex ends it. const signal = AbortSignal.any([entry.abort.signal, turnAbort.signal]); const active: Active = { @@ -1669,7 +1808,7 @@ export class CodexRuntime { active.turnId = turn.turn.id; this.acknowledgeActivity(active, request); // A conversation new to this agent: name its Solo chat meanwhile. - if (request.named && !existing) + if (request.named && !existing && !server.protection) void this.offerTitle( rpc, request.named, diff --git a/src/bundled/codex/security-review.test.ts b/src/bundled/codex/security-review.test.ts new file mode 100644 index 0000000000..804059e345 --- /dev/null +++ b/src/bundled/codex/security-review.test.ts @@ -0,0 +1,212 @@ +import { expect, it, vi } from "vitest"; +import type { ProtectedSpawn } from "../../features/agent-protection/models"; +import type { HostProcessOptions } from "../../features/host/service"; +import type { Wire } from "./rpc"; +import { reviewModel } from "./security-review"; + +const catalog = [ + { + model: "other-model", + defaultReasoningEffort: "high", + supportedReasoningEfforts: [{ reasoningEffort: "high" }], + }, + { + model: "recommended-model", + isDefault: true, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [{ reasoningEffort: "low" }], + }, +]; +const prompt = { + system: "Assess the supplied evidence without executing it.", + input: '{"content":"untrusted evidence"}', + outputSchema: { + type: "object", + properties: { blocked: { type: "boolean" } }, + }, +}; +function fixture(hold = false, catalogFailure = false) { + let options: HostProcessOptions | undefined; + let exit!: (code: number) => void; + let began!: () => void; + const started = new Promise((resolve) => { + began = resolve; + }); + const sent: Wire[] = []; + const emit = (wire: Wire) => options?.onStdout?.(`${JSON.stringify(wire)}\n`); + const process = { + write: async (text: string) => { + const wire: Wire = JSON.parse(text); + sent.push(wire); + if (wire.id == null || !wire.method) return; + const params = wire.params as Record; + let result: unknown; + if (wire.method === "initialize") + result = { userAgent: "fixture buzz-protection-v2" }; + else if (wire.method === "config/read") + result = { + config: { + model: "personal-astra", + model_reasoning_effort: "high", + mcp_servers: { + local: { command: "external-tool" }, + remote: { url: "https://tools.test/mcp" }, + }, + }, + }; + else if (wire.method === "model/list") { + if (catalogFailure) { + emit({ id: wire.id, error: { message: "Catalog unavailable" } }); + return; + } + result = { data: catalog, nextCursor: null }; + } else if (wire.method === "thread/start") + result = { + thread: { id: "review" }, + model: params.model || "personal-astra", + }; + else if (wire.method === "turn/start") result = { turn: { id: "turn" } }; + else throw new Error(`Unexpected request: ${wire.method}`); + emit({ id: wire.id, result }); + if (wire.method === "turn/start") { + began(); + if (!hold) { + emit({ + method: "item/completed", + params: { + threadId: "review", + item: { type: "agentMessage", text: '{"blocked":false}' }, + }, + }); + emit({ + method: "turn/completed", + params: { threadId: "review", turn: { status: "completed" } }, + }); + } + } + }, + end: vi.fn(async () => { + exit(0); + }), + kill: vi.fn(async () => { + exit(1); + }), + exited: new Promise((resolve) => { + exit = resolve; + }), + }; + const spawn = vi.fn(async (value) => { + options = value; + return process; + }); + const params = (method: string) => + sent.find((wire) => wire.method === method)?.params as Record< + string, + unknown + >; + return { spawn, process, started, sent, emit, exit, params }; +} + +it("uses Buzz's recommended model and supported low effort for blank review selections", async () => { + const f = fixture(); + await expect( + reviewModel(f.spawn, { id: "" }, prompt, new AbortController().signal), + ).resolves.toEqual({ + text: '{"blocked":false}', + model: { id: "recommended-model" }, + }); + expect(f.params("thread/start")).toEqual({ + model: "recommended-model", + ephemeral: true, + developerInstructions: prompt.system, + approvalPolicy: "never", + sandbox: "read-only", + config: { + mcp_servers: { + local: { command: "external-tool", enabled: false }, + remote: { url: "https://tools.test/mcp", enabled: false }, + }, + web_search: "disabled", + "features.plugins": false, + "features.apps": false, + }, + }); + expect(f.params("turn/start")).toEqual({ + threadId: "review", + input: [{ type: "text", text: prompt.input, text_elements: [] }], + outputSchema: prompt.outputSchema, + effort: "low", + }); + expect(f.spawn.mock.calls[0]?.[0].env).toEqual({ + BUZZ_SECURITY_REVIEW_ONLY: "1", + BASH_ENV: null, + ENV: null, + }); + expect(f.process.end).toHaveBeenCalledOnce(); +}); + +it.each([ + ["other-model", "custom-provider", false, undefined], + ["custom-model", "custom-provider", true, undefined], + ["", "custom-provider", true, undefined], + ["custom-model", undefined, true, undefined], + ["other-model", undefined, false, "high"], +] as const)( + "preserves explicit selection %s / %s with catalog failure %s", + async (id, provider, catalogFailure, effort) => { + const f = fixture(false, catalogFailure); + const model = { id, ...(provider ? { provider } : {}) }; + const result = await reviewModel( + f.spawn, + model, + prompt, + new AbortController().signal, + ); + expect(result.model).toEqual({ ...model, id: id || "personal-astra" }); + expect(f.params("thread/start").model).toBe(id || undefined); + expect( + (f.params("thread/start").config as Record) + .model_provider, + ).toBe(provider); + expect(f.sent.some((wire) => wire.method === "model/list")).toBe(!provider); + if (effort) expect(f.params("turn/start")).toHaveProperty("effort", effort); + else expect(f.params("turn/start")).not.toHaveProperty("effort"); + }, +); + +it("rejects a blank default selection if its catalog is unavailable", async () => { + const f = fixture(false, true); + await expect( + reviewModel(f.spawn, { id: "" }, prompt, new AbortController().signal), + ).rejects.toThrow("Catalog unavailable"); + expect(f.params("thread/start")).toBeUndefined(); + expect(f.process.end).toHaveBeenCalledOnce(); +}); + +it.each(["cancel", "exit"])( + "refuses tool requests and rejects a review on %s", + async (mode) => { + const f = fixture(true); + const cancelled = new AbortController(); + const review = reviewModel( + f.spawn, + { id: "recommended-model" }, + prompt, + cancelled.signal, + ); + const rejected = expect(review).rejects.toThrow(/closed|abort/i); + await f.started; + f.emit({ + id: "tool", + method: "item/tool/call", + params: { tool: "execute" }, + }); + expect(f.sent.find((wire) => wire.id === "tool")).toMatchObject({ + error: { code: -32601 }, + }); + if (mode === "cancel") cancelled.abort(); + else f.exit(1); + await rejected; + expect(f.process.end).toHaveBeenCalledOnce(); + }, +); diff --git a/src/bundled/codex/security-review.ts b/src/bundled/codex/security-review.ts new file mode 100644 index 0000000000..7d5ae600e4 --- /dev/null +++ b/src/bundled/codex/security-review.ts @@ -0,0 +1,138 @@ +import { resolveModel } from "./config"; +import { AppServer, listModels } from "./rpc"; +import type { + ProtectedSpawn, + ReviewPrompt, +} from "../../features/agent-protection/models"; + +/** Reviews on a separate native worker whose model requests offer no tools and + * whose dispatcher refuses every tool, including unadvertised calls. */ +export async function reviewModel( + spawn: ProtectedSpawn | undefined, + model: { provider?: string; id: string }, + prompt: ReviewPrompt, + cancelled: AbortSignal, +) { + if (!spawn) throw new Error("Protected Codex review worker is unavailable"); + const rpc = new AppServer(); + const signal = AbortSignal.any([cancelled, AbortSignal.timeout(60_000)]); + const cancel = () => void rpc.close(); + let threadId: string | undefined; + let reply = ""; + let finish!: () => void, fail!: (error: Error) => void; + const completed = new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }); + void completed.catch(() => undefined); + const off = rpc.onMessage((wire) => { + const params = wire.params as + | { + threadId?: string; + item?: { type?: string; text?: string }; + turn?: { status?: string; error?: { message?: string } }; + } + | undefined; + if (wire.method === "connection/closed") + fail(new Error("The security model connection closed")); + if (!threadId || params?.threadId !== threadId) return; + if ( + wire.method === "item/completed" && + params.item?.type === "agentMessage" + ) + reply = params.item.text ?? ""; + if (wire.method === "turn/completed") { + if (params.turn?.status === "completed") finish(); + else + fail( + new Error( + params.turn?.error?.message ?? + "The security model did not complete", + ), + ); + } + }); + signal.addEventListener("abort", cancel, { once: true }); + try { + signal.throwIfAborted(); + await rpc.open( + (_id, options) => + spawn({ + ...options, + env: { BUZZ_SECURITY_REVIEW_ONLY: "1", BASH_ENV: null, ENV: null }, + }), + undefined, + true, + ); + signal.throwIfAborted(); + const normalized = await rpc.request<{ + config: { mcp_servers?: Record> }; + }>("config/read", {}); + const models = model.provider + ? [] + : await listModels(rpc).catch((error) => { + if (!model.id) throw error; + return []; + }); + const selected = model.provider ? model.id : resolveModel(model.id, models); + if (!selected && !model.provider) + throw new Error("The recommended Codex review model is unavailable"); + const entry = models.find((entry) => entry.model === selected); + const effort = entry?.supportedReasoningEfforts.some( + (choice) => choice.reasoningEffort === "low", + ) + ? "low" + : entry?.defaultReasoningEffort; + const mcp = Object.fromEntries( + Object.entries(normalized.config.mcp_servers ?? {}).map( + ([name, server]) => [ + name, + { + ...(typeof server.command === "string" + ? { command: server.command } + : { url: server.url }), + enabled: false, + }, + ], + ), + ); + const started = await rpc.request<{ + thread: { id: string }; + model: string; + }>("thread/start", { + ...(selected ? { model: selected } : {}), + ephemeral: true, + developerInstructions: prompt.system, + approvalPolicy: "never", + sandbox: "read-only", + config: { + mcp_servers: mcp, + web_search: "disabled", + "features.plugins": false, + "features.apps": false, + ...(model.provider ? { model_provider: model.provider } : {}), + }, + }); + threadId = started.thread.id; + signal.throwIfAborted(); + await rpc.request("turn/start", { + threadId, + input: [{ type: "text", text: prompt.input, text_elements: [] }], + ...(effort ? { effort } : {}), + ...(prompt.outputSchema ? { outputSchema: prompt.outputSchema } : {}), + }); + await completed; + signal.throwIfAborted(); + return { + text: reply, + model: { + ...(model.provider ? { provider: model.provider } : {}), + id: started.model, + }, + }; + } finally { + off(); + signal.removeEventListener("abort", cancel); + await rpc.close(); + } +}