Tier-2 code review fixes: - P1 correctness: EventBridge per-turn state never reset — once the per-turn output cap tripped, all later turns were silently suppressed and tool/accum state bled across turns. Surface resetTurn() and call it per prompt turn. - P1: plan_update read a non-existent .entries field (wrong SDK shape) and wiped the displayed plan — now a documented no-op (full 'plan' is source of truth). - P1 security: write-path TOCTOU — open without O_TRUNC, re-validate realpath, then truncate, so an intermediate-symlink-swapped escaped target is never truncated before rejection. - DoS: fs read stat-gates and bounded-reads oversized files instead of loading them fully before the ceiling. - Security: stderr redaction now spans chunk boundaries; secret deny-list adds .git-credentials/*.p12/*.pfx/*.keystore/.pgpass/.htpasswd/etc. - Reliability: cancelAcpSession bounded by a timeout so a blocked stdin can't delay the registry SIGKILL. - Maintainability: drop dead ACP_NOT_IMPLEMENTED export; type agentCapabilities via the SDK AgentCapabilities; strengthen the S1 write-denial assertion. +4 tests (181 total); typecheck + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.2 KiB
TypeScript
161 lines
6.2 KiB
TypeScript
// AgentRuntime adapter for the ACP runtime.
|
|
//
|
|
// U3 implements the real session lifecycle: createSession spawns + handshakes
|
|
// (U2 connect()) then opens a `session/new`; promptWithFallback drives one
|
|
// prompt turn to its terminal stopReason; dispose tears down the connection
|
|
// (KTD4a — registry SIGKILL is authoritative). The `session/update` event
|
|
// bridge (U4) and the permission gate (U5) are wired in later units; for U3 the
|
|
// default client handler from U2 is used and a turn still resolves with a
|
|
// stopReason.
|
|
|
|
import { resolveCliSettings, type AcpCliSettings } from "./cli-spawn.js";
|
|
import {
|
|
connect,
|
|
newAcpSession,
|
|
promptAcpSession,
|
|
cancelAcpSession,
|
|
createBridgingClientHandler,
|
|
} from "./provider.js";
|
|
import { buildSpawnEnv } from "./process-manager.js";
|
|
import { buildPromptBlocks } from "./prompt-builder.js";
|
|
import type {
|
|
AgentRuntime,
|
|
AgentRuntimeOptions,
|
|
AgentSession,
|
|
AgentSessionResult,
|
|
AcpSession,
|
|
} from "./types.js";
|
|
|
|
export class AcpRuntimeAdapter implements AgentRuntime {
|
|
readonly id = "acp";
|
|
readonly name = "ACP Runtime";
|
|
private readonly settings: AcpCliSettings;
|
|
|
|
constructor(settings?: Record<string, unknown>) {
|
|
this.settings = resolveCliSettings(settings);
|
|
}
|
|
|
|
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
|
const model = this.settings.model ?? options.defaultModelId ?? "acp";
|
|
|
|
// Bridge streamed `session/update` notifications onto the engine callbacks
|
|
// (U4) so ACP agents render like existing runtimes.
|
|
const callbacks = {
|
|
onText: options.onText,
|
|
onThinking: options.onThinking,
|
|
onToolStart: options.onToolStart,
|
|
onToolEnd: options.onToolEnd,
|
|
};
|
|
|
|
// Build the bridging client handler with the per-run permission gate (U5):
|
|
// its `requestPermission` classifies each call per-category against the live
|
|
// gate (KTD3a) and selects `allow_once` only (S2). `cancelPending` drains
|
|
// in-flight permission requests on teardown so the agent never deadlocks.
|
|
// fs client capabilities (U7) are gated by settings — reads opt-in, writes
|
|
// default OFF (KTD6) — and confined to the task cwd by the path jail. The
|
|
// same toggles drive the advertised `fs` capability in connect() below, so
|
|
// advertisement and registered handlers stay consistent.
|
|
const { handler: clientHandler, cancelPending, resetTurn } = createBridgingClientHandler(
|
|
callbacks,
|
|
options.actionGateContext,
|
|
{
|
|
cwd: options.cwd,
|
|
allowRead: this.settings.fsRead,
|
|
allowWrite: this.settings.fsWrite,
|
|
},
|
|
// Risk S1: unless the user acknowledged the untrusted-agent risk, a blanket
|
|
// `allow` on a sensitive category is escalated to approval rather than
|
|
// auto-approved — so the default `unrestricted` policy can't silently
|
|
// green-light this untrusted subprocess.
|
|
{ allowUnrestricted: this.settings.allowUnrestricted },
|
|
);
|
|
|
|
// Spawn + initialize (U2). fs capabilities are advertised only where the
|
|
// resolved settings enable them (KTD6); the subprocess env is built from the
|
|
// allow-list, never inherited process.env (KTD6b).
|
|
const connection = await connect({
|
|
binaryPath: this.settings.binaryPath,
|
|
args: this.settings.args,
|
|
cwd: options.cwd,
|
|
env: buildSpawnEnv(this.settings.envAllowList),
|
|
advertiseFs: { read: this.settings.fsRead, write: this.settings.fsWrite },
|
|
clientHandler,
|
|
});
|
|
|
|
// Open the ACP session over the task worktree (empty mcpServers — KTD5).
|
|
let sessionId: string;
|
|
try {
|
|
const opened = await newAcpSession(connection, { cwd: options.cwd });
|
|
sessionId = opened.sessionId;
|
|
} catch (err) {
|
|
// Don't leak the subprocess if session/new fails after a good handshake.
|
|
connection.dispose();
|
|
throw err;
|
|
}
|
|
|
|
let disposed = false;
|
|
const session: AcpSession = {
|
|
model,
|
|
systemPrompt: options.systemPrompt,
|
|
sessionId,
|
|
cwd: options.cwd,
|
|
lastModelDescription: `acp/${model}`,
|
|
callbacks,
|
|
// Persist the per-run gate (KTD3) so U5/U7 can reach the live action gate.
|
|
gate: options.actionGateContext,
|
|
connection,
|
|
// Reset the event bridge's per-turn state at the start of each turn so a
|
|
// turn that trips the per-turn output cap can't latch and suppress every
|
|
// subsequent turn (FIX 1).
|
|
resetTurn,
|
|
dispose: () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
// Drain in-flight permission requests BEFORE the registry kill so a
|
|
// blocked agent is released (KTD4a — the SIGKILL is still authoritative).
|
|
cancelPending();
|
|
connection.dispose();
|
|
},
|
|
};
|
|
|
|
return { session };
|
|
}
|
|
|
|
async promptWithFallback(
|
|
session: AgentSession,
|
|
prompt: string,
|
|
_options?: unknown,
|
|
): Promise<void> {
|
|
const acp = session as AcpSession;
|
|
if (!acp.connection) {
|
|
throw new Error("ACP session has no live connection (createSession not completed)");
|
|
}
|
|
// Clear per-turn event-bridge state BEFORE driving the turn so tool
|
|
// correlation, delta accumulators, and the output-cap latch all start clean
|
|
// each turn (FIX 1). Without this, a turn that hit the per-turn output cap
|
|
// would silently suppress all later turns.
|
|
acp.resetTurn?.();
|
|
const blocks = buildPromptBlocks(prompt);
|
|
// Resolve when the SDK prompt promise resolves — it already drains all
|
|
// session/update notifications for the turn before reporting the stopReason.
|
|
// The bridging client handler installed at createSession (U4) has already
|
|
// surfaced streamed text/thinking/tool updates onto session.callbacks.
|
|
await promptAcpSession(acp.connection, acp.sessionId, blocks);
|
|
}
|
|
|
|
describeModel(session: AgentSession): string {
|
|
return session.lastModelDescription || "acp";
|
|
}
|
|
|
|
async dispose(session: AgentSession): Promise<void> {
|
|
// KTD4a teardown: best-effort cancel of any in-flight turn, then force the
|
|
// connection down. The process-registry SIGKILL is the authoritative
|
|
// no-orphan guarantee, not the cancel round-trip. Idempotent.
|
|
const acp = session as AcpSession;
|
|
if (acp.connection && acp.sessionId) {
|
|
await cancelAcpSession(acp.connection, acp.sessionId);
|
|
}
|
|
session.dispose();
|
|
}
|
|
}
|