diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index a469d515..cae659cc 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -26,6 +26,10 @@ export { export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; export const SESSION_STEERING_METHOD = "_session/steering"; +export const KANDEV_GUARDED_TTY_CAPABILITY = "kandev.guarded-tty-exec"; +export const KANDEV_GUARDED_TTY_VERSION = 1; +export const KANDEV_GUARDED_TTY_CAPABILITY_METHOD = "_kandev/guarded_tty/capability"; +export const KANDEV_GUARDED_TTY_EXEC_METHOD = "_kandev/guarded_tty/exec"; export type LegacySessionModel = { modelId: string; @@ -63,6 +67,8 @@ export type ExtMethodRequest = | LegacySetSessionModelExtRequest | SessionSteeringExtRequest | GoalControlExtRequest + | KandevGuardedTtyCapabilityExtRequest + | KandevGuardedTtyExecExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" @@ -70,7 +76,9 @@ export function isExtMethodRequest(request: { method: string, params: Record, params: SessionSteerRequest, diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 68b74e00..8e2d0e1b 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -66,6 +66,11 @@ import { import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; +import { + executeGuardedTtyExec, + type GuardedTtyExecOptions, +} from "./GuardedTtyExec"; +import type {KandevGuardedTtyExecReceipt} from "./AcpExtensions"; export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; /** @@ -149,6 +154,10 @@ export class CodexAcpClient { return this.configPath; } + async guardedTtyExec(options: GuardedTtyExecOptions): Promise { + return await executeGuardedTtyExec(this.codexClient, options); + } + async authenticate( authRequest: acp.AuthenticateRequest, urlElicitationRequester?: UrlElicitationRequester, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 2c7937f3..88a2dec6 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -55,6 +55,10 @@ import { GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, isExtMethodRequest, + KANDEV_GUARDED_TTY_CAPABILITY, + KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + KANDEV_GUARDED_TTY_EXEC_METHOD, + KANDEV_GUARDED_TTY_VERSION, LEGACY_GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, type LegacyLoadSessionResponse, @@ -66,6 +70,10 @@ import { SESSION_STEERING_METHOD, type SessionSteeringResponse, type SessionSteerRequest, + type KandevGuardedTtyCapabilityRequest, + type KandevGuardedTtyCapabilityResponse, + type KandevGuardedTtyExecReceipt, + type KandevGuardedTtyExecRequest, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, @@ -126,6 +134,10 @@ import { createUnavailableAgentFileChangeReport, parseAgentFileChangeReportRequest, } from "./AgentFileChangeReport"; +import { + createUndispatchedGuardedTtyReceipt, + validateGuardedTtyArgv, +} from "./GuardedTtyExec"; export interface SessionState { @@ -253,6 +265,7 @@ export class CodexAcpServer { private readonly sessionGenerations: Map; private readonly sessionOpenGenerations: Map; private readonly goalControlGenerations: Map; + private readonly guardedTtyExecutions: Map>; private readonly permissionLifecycleContexts: WeakMap; private readonly codexProcessState: CodexProcessState | null; private initializeRequest: acp.InitializeRequest | null = null; @@ -275,6 +288,7 @@ export class CodexAcpServer { this.sessionGenerations = new Map(); this.sessionOpenGenerations = new Map(); this.goalControlGenerations = new Map(); + this.guardedTtyExecutions = new Map(); this.permissionLifecycleContexts = new WeakMap(); this.connection = connection; this.codexAcpClient = codexAcpClient; @@ -353,6 +367,12 @@ export class CodexAcpServer { controlMethod: GOAL_CONTROL_METHOD, actions: [...GOAL_CONTROL_ACTIONS], }, + guardedTtyExec: { + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + capabilityMethod: KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + execMethod: KANDEV_GUARDED_TTY_EXEC_METHOD, + }, [JETBRAINS_META_KEY]: { [AIR_META_KEY]: { [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION, @@ -367,7 +387,11 @@ export class CodexAcpServer { }; } - async extMethod(method: string, params: Record): Promise> { + async extMethod( + method: string, + params: Record, + signal?: AbortSignal, + ): Promise> { const methodRequest = { method: method, params: params }; if (!isExtMethodRequest(methodRequest)) { return {}; @@ -383,6 +407,13 @@ export class CodexAcpServer { return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); case SESSION_STEERING_METHOD: return await this.executeOrQueueSteeringRequest(this.parseSessionSteerParams(methodRequest.params)); + case KANDEV_GUARDED_TTY_CAPABILITY_METHOD: + return this.guardedTtyCapability(this.parseGuardedTtyCapabilityParams(methodRequest.params)); + case KANDEV_GUARDED_TTY_EXEC_METHOD: + return await this.executeGuardedTtyExec( + this.parseGuardedTtyExecParams(methodRequest.params), + signal, + ); case GOAL_CONTROL_METHOD: case LEGACY_GOAL_CONTROL_METHOD: { const sessionState = this.sessions.get(methodRequest.params.sessionId); @@ -790,6 +821,7 @@ export class CodexAcpServer { const closeGeneration = this.bumpSessionGeneration(params.sessionId); const sessionState = this.sessions.get(params.sessionId); this.beginSessionCloseFence(params.sessionId); + this.abortGuardedTtyExecutions(params.sessionId); try { if (sessionState) { @@ -821,6 +853,14 @@ export class CodexAcpServer { return {}; } + private abortGuardedTtyExecutions(sessionId: string): void { + const executions = this.guardedTtyExecutions.get(sessionId); + if (!executions) return; + for (const execution of executions) { + execution.abort("stale_session"); + } + } + async deleteSession(params: acp.DeleteSessionRequest): Promise { logger.log("Deleting session...", {sessionId: params.sessionId}); const sessionId = params.sessionId; @@ -1203,6 +1243,86 @@ export class CodexAcpServer { return {}; } + private guardedTtyCapability( + params: KandevGuardedTtyCapabilityRequest, + ): KandevGuardedTtyCapabilityResponse { + const sessionState = this.sessions.get(params.sessionId); + if (!sessionState || !this.sessionPublishIsCurrent( + sessionState, + this.getSessionGeneration(params.sessionId), + )) { + throw RequestError.invalidParams(undefined, "Unknown or stale session"); + } + return { + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + supported: true, + capability_method: KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + exec_method: KANDEV_GUARDED_TTY_EXEC_METHOD, + session_id: sessionState.sessionId, + }; + } + + private async executeGuardedTtyExec( + params: KandevGuardedTtyExecRequest, + requestSignal?: AbortSignal, + ): Promise { + const sessionState = this.sessions.get(params.sessionId); + if (!sessionState || this.sessionIsClosing(params.sessionId)) { + return createUndispatchedGuardedTtyReceipt(params.sessionId, "stale_session"); + } + const sessionGeneration = this.getSessionGeneration(params.sessionId); + const controller = new AbortController(); + const abortFromRequest = () => controller.abort("cancelled"); + if (requestSignal?.aborted) { + abortFromRequest(); + } else { + requestSignal?.addEventListener("abort", abortFromRequest, {once: true}); + } + const executions = this.guardedTtyExecutions.get(params.sessionId) ?? new Set(); + executions.add(controller); + this.guardedTtyExecutions.set(params.sessionId, executions); + + try { + return await this.codexAcpClient.guardedTtyExec({ + sessionId: sessionState.sessionId, + argv: params.argv, + cwd: sessionState.cwd, + sandboxPolicy: sessionState.agentMode.sandboxPolicy, + signal: controller.signal, + isSessionCurrent: () => this.sessionPublishIsCurrent(sessionState, sessionGeneration), + }); + } finally { + requestSignal?.removeEventListener("abort", abortFromRequest); + executions.delete(controller); + if (executions.size === 0) { + this.guardedTtyExecutions.delete(params.sessionId); + } + } + } + + private parseGuardedTtyCapabilityParams( + params: Record, + ): KandevGuardedTtyCapabilityRequest { + if (!hasExactKeys(params, ["sessionId"]) || typeof params["sessionId"] !== "string") { + throw RequestError.invalidParams(); + } + return {sessionId: params["sessionId"]}; + } + + private parseGuardedTtyExecParams(params: Record): KandevGuardedTtyExecRequest { + const sessionId = params["sessionId"]; + const argv = params["argv"]; + if (!hasExactKeys(params, ["argv", "sessionId"]) + || typeof sessionId !== "string" + || !Array.isArray(argv) + || !argv.every((arg): arg is string => typeof arg === "string") + || !validateGuardedTtyArgv(argv)) { + throw RequestError.invalidParams(); + } + return {sessionId, argv}; + } + private parseLegacySetSessionModelParams(params: Record): LegacySetSessionModelRequest { const sessionId = params["sessionId"]; const modelId = params["modelId"]; @@ -3070,3 +3190,8 @@ function historyUpdateContentKey(update: UpdateSessionEvent): string | null { function getRequestedMcpServerNames(mcpServers: Array): Array { return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name)))); } + +function hasExactKeys(value: Record, expected: string[]): boolean { + const actual = Object.keys(value).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 0f802d68..d4dcd807 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -74,6 +74,11 @@ import type { PermissionsRequestApprovalParams, PermissionsRequestApprovalResponse, ItemCompletedNotification, + CommandExecOutputDeltaNotification, + CommandExecParams, + CommandExecResponse, + CommandExecTerminateParams, + CommandExecTerminateResponse, } from "./app-server/v2"; export interface ApprovalHandler { @@ -150,6 +155,10 @@ export class CodexAppServerClient { private readonly threadGoalClearedCaptures = new Map void>>(); private readonly threadSettings = new Map(); private readonly staleTurnIds = new Map>(); + private readonly commandExecOutputCaptures = new Map< + string, + Set<(event: CommandExecOutputDeltaNotification) => void> + >(); constructor(connection: MessageConnection) { this.connection = connection; @@ -182,6 +191,9 @@ export class CodexAppServerClient { if (serverNotification.method === "thread/settings/updated") { this.threadSettings.set(serverNotification.params.threadId, serverNotification.params.threadSettings); } + if (serverNotification.method === "command/exec/outputDelta") { + this.recordCommandExecOutput(serverNotification.params); + } const routing = extractTurnRouting(serverNotification); if (this.handleStaleTurnNotification(serverNotification, routing)) { return; @@ -672,6 +684,32 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "skills/list", params }); } + async commandExec(params: CommandExecParams): Promise { + return await this.sendRequest({method: "command/exec", params}); + } + + async commandExecTerminate(params: CommandExecTerminateParams): Promise { + return await this.sendRequest({method: "command/exec/terminate", params}); + } + + captureCommandExecOutput( + processId: string, + capture: (event: CommandExecOutputDeltaNotification) => void, + ): () => void { + const captures = this.commandExecOutputCaptures.get(processId) ?? new Set(); + captures.add(capture); + this.commandExecOutputCaptures.set(processId, captures); + let released = false; + return () => { + if (released) return; + released = true; + captures.delete(capture); + if (captures.size === 0) { + this.commandExecOutputCaptures.delete(processId); + } + }; + } + /** * Registers a notification handler for a specific session. * Replaces any existing handler for the same session, preventing handler accumulation. @@ -766,6 +804,14 @@ export class CodexAppServerClient { } } + private recordCommandExecOutput(event: CommandExecOutputDeltaNotification): void { + const captures = this.commandExecOutputCaptures.get(event.processId); + if (!captures) return; + for (const capture of captures) { + capture(event); + } + } + private recordTurnRouting(routing: { threadId: string | null, turnId: string | null }): void { if (routing.threadId === null || routing.turnId === null) { return; diff --git a/src/GuardedTtyExec.ts b/src/GuardedTtyExec.ts new file mode 100644 index 00000000..f2daf5c4 --- /dev/null +++ b/src/GuardedTtyExec.ts @@ -0,0 +1,270 @@ +import {randomUUID} from "node:crypto"; +import type {SandboxPolicy} from "./app-server/v2"; +import type {CommandExecOutputDeltaNotification, CommandExecResponse} from "./app-server/v2"; +import { + KANDEV_GUARDED_TTY_CAPABILITY, + KANDEV_GUARDED_TTY_VERSION, + type KandevGuardedTtyDenialCode, + type KandevGuardedTtyExecReceipt, +} from "./AcpExtensions"; + +export const GUARDED_TTY_MAX_ARG_COUNT = 64; +export const GUARDED_TTY_MAX_ARG_BYTES = 8 * 1024; +export const GUARDED_TTY_MAX_SINGLE_ARG_BYTES = 4 * 1024; +export const GUARDED_TTY_OUTPUT_BYTES_MAX = 64 * 1024; +export const GUARDED_TTY_TIMEOUT_MS = 10_000; +const GUARDED_TTY_BRIDGE_TIMEOUT_GRACE_MS = 1_000; +const GUARDED_TTY_TERMINATION_GRACE_MS = 1_000; + +export interface GuardedTtyExecTransport { + commandExec(params: { + command: string[]; + processId: string; + tty: true; + streamStdin: true; + streamStdoutStderr: true; + outputBytesCap: number; + timeoutMs: number; + cwd: string; + sandboxPolicy: SandboxPolicy; + }): Promise; + commandExecTerminate(params: {processId: string}): Promise>; + captureCommandExecOutput( + processId: string, + capture: (event: CommandExecOutputDeltaNotification) => void, + ): () => void; +} + +export interface GuardedTtyExecOptions { + sessionId: string; + argv: string[]; + cwd: string; + sandboxPolicy: SandboxPolicy; + signal?: AbortSignal; + isSessionCurrent: () => boolean; + now?: () => Date; +} + +type TerminalEvent = + | {kind: "response", response: CommandExecResponse} + | {kind: "failure", code: KandevGuardedTtyDenialCode}; + +export function validateGuardedTtyArgv(argv: string[]): boolean { + if (argv.length === 0 || argv.length > GUARDED_TTY_MAX_ARG_COUNT) return false; + let totalBytes = 0; + for (const arg of argv) { + if (typeof arg !== "string" || arg.length === 0 || arg.includes("\0")) return false; + const bytes = Buffer.byteLength(arg); + if (bytes > GUARDED_TTY_MAX_SINGLE_ARG_BYTES) return false; + totalBytes += bytes; + if (totalBytes > GUARDED_TTY_MAX_ARG_BYTES) return false; + } + return true; +} + +export function createUndispatchedGuardedTtyReceipt( + sessionId: string, + denialCode: KandevGuardedTtyDenialCode, + now: () => Date = () => new Date(), +): KandevGuardedTtyExecReceipt { + const timestamp = now().toISOString(); + return { + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + session_id: sessionId, + method: "command/exec", + requested_tty: true, + dispatched_tty: false, + process_id: null, + cwd: null, + outcome: "denied", + denial_code: denialCode, + stdout: "", + stderr: "", + stdout_bytes: 0, + stderr_bytes: 0, + output_bytes: 0, + exit_code: null, + started_at: timestamp, + completed_at: timestamp, + }; +} + +export async function executeGuardedTtyExec( + transport: GuardedTtyExecTransport, + options: GuardedTtyExecOptions, +): Promise { + const now = options.now ?? (() => new Date()); + if (!options.isSessionCurrent()) { + return createUndispatchedGuardedTtyReceipt(options.sessionId, "stale_session", now); + } + + const processId = randomUUID(); + const startedAt = now().toISOString(); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let resolveTerminal: (event: TerminalEvent) => void = () => {}; + const terminal = new Promise((resolve) => { + resolveTerminal = resolve; + }); + let finalized = false; + let terminationRequested = false; + let dispatched = false; + let termination: Promise | null = null; + + const requestTermination = () => { + if (!dispatched || terminationRequested) return; + terminationRequested = true; + termination = transport.commandExecTerminate({processId}).then( + () => {}, + () => {}, + ); + }; + const finalize = (event: TerminalEvent) => { + if (finalized) return; + finalized = true; + resolveTerminal(event); + }; + const fail = (code: KandevGuardedTtyDenialCode) => { + requestTermination(); + finalize({kind: "failure", code}); + }; + + const releaseOutput = transport.captureCommandExecOutput(processId, (event) => { + if (finalized || event.processId !== processId) return; + if (!options.isSessionCurrent()) { + fail("stale_session"); + return; + } + const bytes = decodeBase64(event.deltaBase64); + if (bytes === null) { + fail("invalid_output"); + return; + } + if (event.capReached || stdoutBytes + stderrBytes + bytes.length > GUARDED_TTY_OUTPUT_BYTES_MAX) { + fail("output_overflow"); + return; + } + if (event.stream === "stdout") { + stdout.push(bytes); + stdoutBytes += bytes.length; + } else { + stderr.push(bytes); + stderrBytes += bytes.length; + } + }); + + const abort = () => { + const reason = options.signal?.reason; + fail(reason === "stale_session" ? "stale_session" : "cancelled"); + }; + if (options.signal?.aborted) { + abort(); + } else { + options.signal?.addEventListener("abort", abort, {once: true}); + } + + const timeout = setTimeout(() => { + fail("timeout"); + }, GUARDED_TTY_TIMEOUT_MS + GUARDED_TTY_BRIDGE_TIMEOUT_GRACE_MS); + + if (!finalized) { + dispatched = true; + void transport.commandExec({ + command: [...options.argv], + processId, + tty: true, + streamStdin: true, + streamStdoutStderr: true, + outputBytesCap: GUARDED_TTY_OUTPUT_BYTES_MAX, + timeoutMs: GUARDED_TTY_TIMEOUT_MS, + cwd: options.cwd, + sandboxPolicy: options.sandboxPolicy, + }).then( + (response) => { + if (!options.isSessionCurrent()) { + fail("stale_session"); + return; + } + finalize({kind: "response", response}); + }, + () => fail("app_server_error"), + ); + } + + const event = await terminal; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abort); + releaseOutput(); + if (termination !== null) { + await waitWithTimeout(termination, GUARDED_TTY_TERMINATION_GRACE_MS); + } + + const stdoutContent = Buffer.concat(stdout, stdoutBytes).toString("utf8"); + const stderrContent = Buffer.concat(stderr, stderrBytes).toString("utf8"); + const completedAt = now().toISOString(); + if (event.kind === "response") { + return { + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + session_id: options.sessionId, + method: "command/exec", + requested_tty: true, + dispatched_tty: true, + process_id: processId, + cwd: options.cwd, + outcome: "completed", + denial_code: null, + stdout: stdoutContent, + stderr: stderrContent, + stdout_bytes: stdoutBytes, + stderr_bytes: stderrBytes, + output_bytes: stdoutBytes + stderrBytes, + exit_code: event.response.exitCode, + started_at: startedAt, + completed_at: completedAt, + }; + } + + return { + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + session_id: options.sessionId, + method: "command/exec", + requested_tty: true, + dispatched_tty: dispatched, + process_id: processId, + cwd: options.cwd, + outcome: "failed", + denial_code: event.code, + stdout: stdoutContent, + stderr: stderrContent, + stdout_bytes: stdoutBytes, + stderr_bytes: stderrBytes, + output_bytes: stdoutBytes + stderrBytes, + exit_code: null, + started_at: startedAt, + completed_at: completedAt, + }; +} + +function decodeBase64(value: string): Buffer | null { + if (value.length === 0) return Buffer.alloc(0); + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return null; + } + return Buffer.from(value, "base64"); +} + +async function waitWithTimeout(operation: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | null = null; + await Promise.race([ + operation, + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs); + }), + ]); + if (timeout !== null) clearTimeout(timeout); +} diff --git a/src/__tests__/CodexACPAgent/guarded-tty-exec.test.ts b/src/__tests__/CodexACPAgent/guarded-tty-exec.test.ts new file mode 100644 index 00000000..57734a89 --- /dev/null +++ b/src/__tests__/CodexACPAgent/guarded-tty-exec.test.ts @@ -0,0 +1,326 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {RequestError} from "@agentclientprotocol/sdk"; +import {AgentMode} from "../../AgentMode"; +import { + KANDEV_GUARDED_TTY_CAPABILITY, + KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + KANDEV_GUARDED_TTY_EXEC_METHOD, + KANDEV_GUARDED_TTY_VERSION, +} from "../../AcpExtensions"; +import {GUARDED_TTY_OUTPUT_BYTES_MAX, GUARDED_TTY_TIMEOUT_MS} from "../../GuardedTtyExec"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {CodexMockTestFixture} from "../acp-test-utils"; +import type {SessionState} from "../../CodexAcpServer"; +import type {CommandExecParams, CommandExecResponse} from "../../app-server/v2"; + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} + +function installSession( + fixture: CodexMockTestFixture, + overrides: Partial = {}, +): SessionState { + const state = createTestSessionState({ + sessionId: "session-id", + cwd: "/trusted/task/worktree", + agentMode: AgentMode.ReadOnly, + ...overrides, + }); + const agent = fixture.getCodexAcpAgent() as unknown as { + sessions: Map; + }; + agent.sessions.set(state.sessionId, state); + return state; +} + +function outputDelta(processId: string, stream: "stdout" | "stderr", bytes: Uint8Array, capReached = false) { + return { + method: "command/exec/outputDelta" as const, + params: { + processId, + stream, + deltaBase64: Buffer.from(bytes).toString("base64"), + capReached, + }, + }; +} + +describe("Kandev guarded TTY ACP extension", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("advertises and probes the exact version for an active session", async () => { + const fixture = createCodexMockTestFixture(); + installSession(fixture); + + const initialized = await fixture.getCodexAcpAgent().initialize({protocolVersion: 1}); + expect(initialized._meta).toMatchObject({ + guardedTtyExec: { + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + capabilityMethod: KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + execMethod: KANDEV_GUARDED_TTY_EXEC_METHOD, + }, + }); + await expect(fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_CAPABILITY_METHOD, { + sessionId: "session-id", + })).resolves.toEqual({ + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + supported: true, + capability_method: KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + exec_method: KANDEV_GUARDED_TTY_EXEC_METHOD, + session_id: "session-id", + }); + }); + + it("fails closed for unsupported, malformed, and stale capability probes", async () => { + const fixture = createCodexMockTestFixture(); + + await expect(fixture.getCodexAcpAgent().extMethod("_kandev/guarded_tty/v0", { + sessionId: "session-id", + })).resolves.toEqual({}); + await expect(fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_CAPABILITY_METHOD, { + sessionId: "session-id", + version: 1, + })).rejects.toThrow(RequestError); + await expect(fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_CAPABILITY_METHOD, { + sessionId: "stale-session", + })).rejects.toThrow("Unknown or stale session"); + }); + + it("rejects empty, oversized, malformed, and security-field requests before dispatch", async () => { + const fixture = createCodexMockTestFixture(); + installSession(fixture); + const commandExec = vi.spyOn(fixture.getCodexAppServerClient(), "commandExec"); + const invalidRequests = [ + {sessionId: "session-id", argv: []}, + {sessionId: "session-id", argv: Array.from({length: 65}, () => "x")}, + {sessionId: "session-id", argv: ["x".repeat(4 * 1024 + 1)]}, + {sessionId: "session-id", argv: ["x".repeat(4 * 1024), "y".repeat(4 * 1024), "z"]}, + {sessionId: "session-id", argv: ["contains\0nul"]}, + {sessionId: "session-id", argv: ["printf", 123]}, + {sessionId: "session-id", argv: ["pwd"], cwd: "/tmp"}, + {sessionId: "session-id", argv: ["pwd"], tty: false}, + {sessionId: "session-id", argv: ["pwd"], processId: "forged"}, + {sessionId: "session-id", argv: ["pwd"], sandboxPolicy: {type: "dangerFullAccess"}}, + {sessionId: "session-id", argv: ["pwd"], permissionProfile: "full"}, + {sessionId: "session-id", argv: ["pwd"], env: {TOKEN: "forged"}}, + {sessionId: "session-id", argv: ["pwd"], stdin: "secret"}, + {sessionId: "session-id", argv: ["pwd"], write: "secret"}, + {sessionId: "session-id", argv: ["pwd"], resize: {rows: 100}}, + {sessionId: "session-id", argv: ["pwd"], attach: true}, + ]; + + for (const request of invalidRequests) { + await expect(fixture.getCodexAcpAgent().extMethod( + KANDEV_GUARDED_TTY_EXEC_METHOD, + request as Record, + )).rejects.toThrow(RequestError); + } + expect(commandExec).not.toHaveBeenCalled(); + }); + + it("dispatches tty:true with a generated id and trusted session execution state", async () => { + const fixture = createCodexMockTestFixture(); + const state = installSession(fixture); + const configBefore = process.env["CODEX_CONFIG"]; + const commandExec = vi.spyOn(fixture.getCodexAppServerClient(), "commandExec") + .mockImplementation(async (params: CommandExecParams) => { + fixture.sendServerNotification(outputDelta("wrong-process", "stdout", Buffer.from("ignored"))); + const emoji = Buffer.from("TTY ✅\n"); + fixture.sendServerNotification(outputDelta(params.processId!, "stdout", emoji.subarray(0, 5))); + fixture.sendServerNotification(outputDelta(params.processId!, "stdout", emoji.subarray(5))); + fixture.sendServerNotification(outputDelta(params.processId!, "stderr", Buffer.from("stty ok\n"))); + return {exitCode: 0, stdout: "", stderr: ""}; + }); + + const receipt = await fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: state.sessionId, + argv: ["sh", "-lc", "test -t 0 && test -t 1 && stty && pwd && git status --short"], + }); + + expect(commandExec).toHaveBeenCalledOnce(); + const request = commandExec.mock.calls[0]![0]; + expect(request).toEqual({ + command: ["sh", "-lc", "test -t 0 && test -t 1 && stty && pwd && git status --short"], + processId: expect.stringMatching(/^[0-9a-f-]{36}$/), + tty: true, + streamStdin: true, + streamStdoutStderr: true, + outputBytesCap: GUARDED_TTY_OUTPUT_BYTES_MAX, + timeoutMs: GUARDED_TTY_TIMEOUT_MS, + cwd: "/trusted/task/worktree", + sandboxPolicy: state.agentMode.sandboxPolicy, + }); + expect(request).not.toHaveProperty("env"); + expect(request).not.toHaveProperty("permissionProfile"); + expect(receipt).toMatchObject({ + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: 1, + session_id: "session-id", + method: "command/exec", + requested_tty: true, + dispatched_tty: true, + process_id: request.processId, + cwd: "/trusted/task/worktree", + outcome: "completed", + denial_code: null, + stdout: "TTY ✅\n", + stderr: "stty ok\n", + exit_code: 0, + }); + expect(process.env["CODEX_CONFIG"]).toBe(configBefore); + }); + + it("terminates and finalizes once when App Server reports output overflow", async () => { + const fixture = createCodexMockTestFixture(); + installSession(fixture); + const terminate = vi.spyOn(fixture.getCodexAppServerClient(), "commandExecTerminate") + .mockResolvedValue({}); + vi.spyOn(fixture.getCodexAppServerClient(), "commandExec") + .mockImplementation(async (params: CommandExecParams) => { + fixture.sendServerNotification(outputDelta(params.processId!, "stdout", Buffer.from("bounded"), true)); + return {exitCode: 0, stdout: "", stderr: ""}; + }); + + await expect(fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: "session-id", + argv: ["yes"], + })).resolves.toMatchObject({ + outcome: "failed", + denial_code: "output_overflow", + stdout: "", + output_bytes: 0, + exit_code: null, + }); + expect(terminate).toHaveBeenCalledOnce(); + }); + + it("cancels a dispatched command promptly and ignores its later completion", async () => { + const fixture = createCodexMockTestFixture(); + installSession(fixture); + const response = deferred(); + vi.spyOn(fixture.getCodexAppServerClient(), "commandExec").mockReturnValue(response.promise); + const terminate = vi.spyOn(fixture.getCodexAppServerClient(), "commandExecTerminate") + .mockResolvedValue({}); + const controller = new AbortController(); + const execution = fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: "session-id", + argv: ["sh", "-lc", "sleep 30"], + }, controller.signal); + await vi.waitFor(() => expect(fixture.getCodexAppServerClient().commandExec).toHaveBeenCalled()); + + controller.abort(); + await expect(execution).resolves.toMatchObject({ + outcome: "failed", + denial_code: "cancelled", + dispatched_tty: true, + exit_code: null, + }); + expect(terminate).toHaveBeenCalledOnce(); + response.resolve({exitCode: 0, stdout: "", stderr: ""}); + }); + + it("aborts an in-flight execution when its session closes", async () => { + const fixture = createCodexMockTestFixture(); + installSession(fixture); + vi.spyOn(fixture.getCodexAppServerClient(), "commandExec") + .mockReturnValue(new Promise(() => {})); + const terminate = vi.spyOn(fixture.getCodexAppServerClient(), "commandExecTerminate") + .mockResolvedValue({}); + vi.spyOn(fixture.getCodexAcpClient(), "closeSession").mockResolvedValue(); + const execution = fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: "session-id", + argv: ["sh", "-lc", "sleep 30"], + }); + await vi.waitFor(() => expect(fixture.getCodexAppServerClient().commandExec).toHaveBeenCalled()); + + await fixture.getCodexAcpAgent().closeSession({sessionId: "session-id"}); + await expect(execution).resolves.toMatchObject({ + outcome: "failed", + denial_code: "stale_session", + dispatched_tty: true, + }); + expect(terminate).toHaveBeenCalledOnce(); + }); + + it("returns stable failures for invalid output and App Server errors", async () => { + const fixture = createCodexMockTestFixture(); + installSession(fixture); + const terminate = vi.spyOn(fixture.getCodexAppServerClient(), "commandExecTerminate") + .mockResolvedValue({}); + vi.spyOn(fixture.getCodexAppServerClient(), "commandExec") + .mockImplementationOnce(async (params: CommandExecParams) => { + fixture.sendServerNotification({ + method: "command/exec/outputDelta", + params: {processId: params.processId!, stream: "stdout", deltaBase64: "***", capReached: false}, + }); + return {exitCode: 0, stdout: "", stderr: ""}; + }) + .mockRejectedValueOnce(new Error("secret-bearing App Server failure")); + + await expect(fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: "session-id", + argv: ["pwd"], + })).resolves.toMatchObject({denial_code: "invalid_output"}); + const appServerFailure = await fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: "session-id", + argv: ["pwd"], + }); + expect(appServerFailure).toMatchObject({denial_code: "app_server_error"}); + expect(JSON.stringify(appServerFailure)).not.toContain("secret-bearing"); + expect(terminate).toHaveBeenCalledTimes(2); + }); + + it("terminates a command that outlives the fixed bridge deadline", async () => { + vi.useFakeTimers(); + const fixture = createCodexMockTestFixture(); + installSession(fixture); + vi.spyOn(fixture.getCodexAppServerClient(), "commandExec") + .mockReturnValue(new Promise(() => {})); + const terminate = vi.spyOn(fixture.getCodexAppServerClient(), "commandExecTerminate") + .mockResolvedValue({}); + + const execution = fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: "session-id", + argv: ["sh", "-lc", "sleep 30"], + }); + await vi.advanceTimersByTimeAsync(GUARDED_TTY_TIMEOUT_MS + 1_000); + + await expect(execution).resolves.toMatchObject({ + outcome: "failed", + denial_code: "timeout", + }); + expect(terminate).toHaveBeenCalledOnce(); + }); + + it("denies unknown sessions without creating or terminating a process", async () => { + const fixture = createCodexMockTestFixture(); + const commandExec = vi.spyOn(fixture.getCodexAppServerClient(), "commandExec"); + const terminate = vi.spyOn(fixture.getCodexAppServerClient(), "commandExecTerminate"); + + await expect(fixture.getCodexAcpAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, { + sessionId: "unknown-session", + argv: ["pwd"], + })).resolves.toMatchObject({ + outcome: "denied", + denial_code: "stale_session", + dispatched_tty: false, + process_id: null, + cwd: null, + }); + expect(commandExec).not.toHaveBeenCalled(); + expect(terminate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 9f8fe458..a4b3ad9a 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -6,6 +6,12 @@ import {getCodexAuthMethods} from "../../CodexAuthMethod"; import {CodexAcpClient} from "../../CodexAcpClient"; import {CodexAppServerClient} from "../../CodexAppServerClient"; import packageJson from "../../../package.json"; +import { + KANDEV_GUARDED_TTY_CAPABILITY, + KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + KANDEV_GUARDED_TTY_EXEC_METHOD, + KANDEV_GUARDED_TTY_VERSION, +} from "../../AcpExtensions"; describe('CodexACPAgent - initialize', () => { let agent: CodexAcpServer; @@ -72,6 +78,12 @@ describe('CodexACPAgent - initialize', () => { controlMethod: "_session/goal", actions: ["set", "pause", "resume", "clear"], }, + guardedTtyExec: { + capability: KANDEV_GUARDED_TTY_CAPABILITY, + version: KANDEV_GUARDED_TTY_VERSION, + capabilityMethod: KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + execMethod: KANDEV_GUARDED_TTY_EXEC_METHOD, + }, jetbrains: { air: { version: 1, diff --git a/src/index.ts b/src/index.ts index 68df2ccd..76a75770 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,8 +14,14 @@ import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; import { GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, + KANDEV_GUARDED_TTY_CAPABILITY_METHOD, + KANDEV_GUARDED_TTY_EXEC_METHOD, SESSION_STEERING_METHOD, } from "./AcpExtensions"; +import { + GUARDED_TTY_MAX_ARG_COUNT, + GUARDED_TTY_MAX_SINGLE_ARG_BYTES, +} from "./GuardedTtyExec"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -44,6 +50,17 @@ const goalControlParamsParser = z.discriminatedUnion("action", [ }).passthrough(), ]); +const guardedTtyCapabilityParamsParser = z.object({ + sessionId: z.string(), +}).strict(); + +const guardedTtyExecParamsParser = z.object({ + sessionId: z.string(), + argv: z.array(z.string().min(1).max(GUARDED_TTY_MAX_SINGLE_ARG_BYTES)) + .min(1) + .max(GUARDED_TTY_MAX_ARG_COUNT), +}).strict(); + if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -162,5 +179,7 @@ function startAcpServer() { .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) + .onRequest(KANDEV_GUARDED_TTY_CAPABILITY_METHOD, guardedTtyCapabilityParamsParser, (ctx) => getAgent().extMethod(KANDEV_GUARDED_TTY_CAPABILITY_METHOD, ctx.params, ctx.signal)) + .onRequest(KANDEV_GUARDED_TTY_EXEC_METHOD, guardedTtyExecParamsParser, (ctx) => getAgent().extMethod(KANDEV_GUARDED_TTY_EXEC_METHOD, ctx.params, ctx.signal)) .connect(acpJsonStream); }