@sweny-ai/core 0.1.148 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,942 @@
1
+ /**
2
+ * ACP harness (#416): runs a node on any Agent Client Protocol agent
3
+ * (`--agent acp:<command>`, for example `acp:opencode acp`,
4
+ * `acp:hermes acp`, `acp:goose acp`, `acp:gemini --experimental-acp`).
5
+ *
6
+ * ACP is the long-tail adapter. It is JSON-RPC 2.0 over the agent's stdio and
7
+ * carries a prompt and a stream of updates; it does not carry sweny's
8
+ * opinions. Read from agentclientprotocol/agent-client-protocol at tag
9
+ * schema-v1.24.1 (`schema/v1/schema.json`, `docs/protocol/v1/*.mdx`):
10
+ * - transports: newline-delimited JSON over stdio (`transports.mdx`).
11
+ * - `initialize` negotiates `protocolVersion` (1) and capabilities
12
+ * (`initialization.mdx`); `InitializeRequest` / `InitializeResponse`.
13
+ * - `session/new {cwd, mcpServers}` returns `{sessionId}`; every agent MUST
14
+ * support stdio MCP servers, http and sse are optional
15
+ * (`session-setup.mdx`, `McpServerStdio`, `McpServerHttp`).
16
+ * - `session/prompt {sessionId, prompt: ContentBlock[]}` streams
17
+ * `session/update` notifications (`agent_message_chunk`, `tool_call`,
18
+ * `tool_call_update`, `usage_update`, ...) and ends with
19
+ * `{stopReason}`; `PromptResponse` has no other field (`prompt-turn.mdx`).
20
+ * - `session/cancel` (notification) ends a turn with stopReason `cancelled`
21
+ * (`prompt-turn.mdx`, Cancellation); pending permission requests get the
22
+ * `cancelled` outcome.
23
+ * - `session/request_permission {toolCall, options}` is how an agent MAY ask
24
+ * before a tool runs; the client picks an option or answers `cancelled`
25
+ * (`tool-calls.mdx`, `RequestPermissionResponse`).
26
+ * - `fs/read_text_file` and `fs/write_text_file` are client methods an agent
27
+ * may use only when the client advertised them (`file-system.mdx`).
28
+ *
29
+ * Every sweny opinion is enforced by sweny here, refused or reported as
30
+ * `degraded` by `policyGate` (see ACP_CAPABILITIES for the honest list):
31
+ * - Structured output: the schema rides in the prompt; sweny parses the last
32
+ * JSON object, checks it and asks once more before giving up.
33
+ * - Tool deny and read-only: best effort through `session/request_permission`
34
+ * (denied classes and every non-read tool under `readOnly` are rejected),
35
+ * plus a refusal of every `fs/write_text_file`. The protocol has no deny list
36
+ * and an agent that does not ask is not stopped, so `policyGate` still
37
+ * reports deny and read-only as unenforced; strict refuses the node unless
38
+ * the process wrapper provides the read-only mount.
39
+ * - Sandbox and egress: none in the protocol. The whole agent process runs
40
+ * inside the sandbox wrapper (sandbox-wrapper.ts); strict refuses without it.
41
+ * - Env: sweny spawns the agent with the scoped env (agent-env.ts).
42
+ * - Skill tools: passed in `session/new` as the stdio MCP server of the tool
43
+ * bridge (#414), so the executor still observes every call.
44
+ * - Turn limit: a watchdog counts tool calls and cancels past the budget.
45
+ * - Timeout: `session/cancel`, a grace period, then the process is killed.
46
+ */
47
+ import { spawn } from "node:child_process";
48
+ import * as fs from "node:fs";
49
+ import * as path from "node:path";
50
+ import { consoleLogger } from "../types.js";
51
+ import { parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, } from "../agent-env.js";
52
+ import { ACP_CAPABILITIES } from "./capabilities.js";
53
+ import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
54
+ import { buildNodePrompt } from "./prompts.js";
55
+ import { makeAbort } from "./abort.js";
56
+ import { parseToolResultContent, schemaMismatches, summarizeToolError, tryParseJSON } from "./parse.js";
57
+ import { startToolBridge } from "./tool-bridge/server.js";
58
+ import { defaultSandboxWrapper, prepareAgentSpawn, wrappersFrom, } from "./sandbox-wrapper.js";
59
+ import { TOKEN_ENV } from "./tool-bridge/protocol.js";
60
+ import { translateDenyNames } from "./codex.js";
61
+ import { RPC_ERROR, RpcError, RpcPeer, splitCommandLine } from "./acp-rpc.js";
62
+ export { ACP_CAPABILITIES };
63
+ /** The ACP major version sweny speaks (`InitializeRequest.protocolVersion`). */
64
+ export const ACP_PROTOCOL_VERSION = 1;
65
+ /** The reason every pending request is rejected with when the agent process is gone. */
66
+ const PROCESS_EXITED = "agent process exited";
67
+ /** Name of the MCP server that carries sweny's skill tools (the tool bridge). */
68
+ const BRIDGE_SERVER = "sweny-core";
69
+ // ─── Tool classification (permission requests) ───────────────────
70
+ /** Portable tool classes an ACP tool call can be, from its `kind` (schema `ToolKind`) and name. */
71
+ export function classifyAcpTool(kind, name) {
72
+ switch (kind) {
73
+ case "read":
74
+ case "search":
75
+ case "think":
76
+ return { classes: [], readOnlySafe: true };
77
+ case "edit":
78
+ return { classes: ["write", "edit"], readOnlySafe: false };
79
+ case "delete":
80
+ case "move":
81
+ return { classes: ["write"], readOnlySafe: false };
82
+ case "execute":
83
+ return { classes: ["shell"], readOnlySafe: false };
84
+ case "fetch":
85
+ return { classes: ["net"], readOnlySafe: false };
86
+ default: {
87
+ // `other`, `switch_mode` and anything the agent invented: only the name can say.
88
+ const named = name ? translateDenyNames([name]).classes : [];
89
+ return { classes: named, readOnlySafe: false };
90
+ }
91
+ }
92
+ }
93
+ /** Does this tool call name one of sweny's skill tools (they reach the agent as MCP tools of the tool bridge)? */
94
+ export function isSkillToolCall(names, skillTools) {
95
+ const have = names.filter((n) => typeof n === "string" && n !== "");
96
+ return skillTools.some((t) => [t, `${BRIDGE_SERVER}_${t}`, `${BRIDGE_SERVER}__${t}`, `mcp__${BRIDGE_SERVER}__${t}`, `${BRIDGE_SERVER}.${t}`].some((c) => have.includes(c)));
97
+ }
98
+ /** The permission outcome for an option list: the first option of a wanted kind, else `cancelled`. */
99
+ export function pickPermissionOutcome(options, allow) {
100
+ const wanted = allow ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
101
+ for (const kind of wanted) {
102
+ const hit = options.find((o) => o.kind === kind);
103
+ if (hit)
104
+ return { outcome: "selected", optionId: hit.optionId };
105
+ }
106
+ return { outcome: "cancelled" };
107
+ }
108
+ function isObj(v) {
109
+ return !!v && typeof v === "object" && !Array.isArray(v);
110
+ }
111
+ /** The text of a `ContentBlock` (schema `ContentBlock`: `{type:"text", text}`), or "". */
112
+ function blockText(block) {
113
+ return isObj(block) && block.type === "text" && typeof block.text === "string" ? block.text : "";
114
+ }
115
+ /** `McpServerConfig` map to the ACP `mcpServers` array (schema `McpServer`). Returns what the agent cannot load too. */
116
+ export function toAcpMcpServers(servers, agentHttp) {
117
+ const list = [];
118
+ const unsupported = [];
119
+ for (const [name, s] of Object.entries(servers)) {
120
+ if (s.type === "http" || (!s.type && s.url && !s.command)) {
121
+ if (!s.url) {
122
+ unsupported.push(`${name} (http without url)`);
123
+ }
124
+ else if (!agentHttp) {
125
+ unsupported.push(`${name} (agent has no http MCP transport)`);
126
+ }
127
+ else {
128
+ list.push({
129
+ type: "http",
130
+ name,
131
+ url: s.url,
132
+ headers: Object.entries(s.headers ?? {}).map(([k, v]) => ({ name: k, value: v })),
133
+ });
134
+ }
135
+ continue;
136
+ }
137
+ if (!s.command) {
138
+ unsupported.push(`${name} (type ${String(s.type)})`);
139
+ continue;
140
+ }
141
+ list.push({
142
+ name,
143
+ command: s.command,
144
+ args: s.args ?? [],
145
+ env: Object.entries(s.env ?? {}).map(([k, v]) => ({ name: k, value: v })),
146
+ });
147
+ }
148
+ return { list, unsupported };
149
+ }
150
+ function resolveOnPath(command, env) {
151
+ const ok = (p) => {
152
+ try {
153
+ fs.accessSync(p, fs.constants.X_OK);
154
+ return fs.statSync(p).isFile();
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ };
160
+ if (command.includes(path.sep) || command.includes("/"))
161
+ return ok(command) ? command : undefined;
162
+ for (const dir of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
163
+ const p = path.join(dir, command);
164
+ if (ok(p))
165
+ return p;
166
+ }
167
+ return undefined;
168
+ }
169
+ // ─── Process registry (crash cleanup) ────────────────────────────
170
+ const liveChildren = new Set();
171
+ let exitHookInstalled = false;
172
+ function stopChild(child, signal) {
173
+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
174
+ return;
175
+ try {
176
+ child.kill(signal);
177
+ }
178
+ catch {
179
+ // already gone
180
+ }
181
+ }
182
+ function trackChild(child) {
183
+ liveChildren.add(child);
184
+ if (!exitHookInstalled) {
185
+ exitHookInstalled = true;
186
+ // A crashing sweny must not leave an agent (and its MCP servers) running.
187
+ process.on("exit", () => {
188
+ for (const c of liveChildren)
189
+ stopChild(c, "SIGKILL");
190
+ });
191
+ }
192
+ }
193
+ // ─── Adapter ─────────────────────────────────────────────────────
194
+ export class AcpHarness {
195
+ id;
196
+ capabilities = ACP_CAPABILITIES;
197
+ /** Empty on purpose: judges use the agent's own default model. */
198
+ defaultJudgeModel = "";
199
+ logger;
200
+ agent;
201
+ maxTurns;
202
+ cwd;
203
+ defaultContext;
204
+ mcpServers;
205
+ defaultMcpServers;
206
+ envPassthrough;
207
+ authVars;
208
+ envScope;
209
+ sandboxMode;
210
+ sandboxWrapper;
211
+ policyMode;
212
+ egress;
213
+ toolBridgeShim;
214
+ killGraceMs;
215
+ cancelGraceMs;
216
+ startupTimeoutMs;
217
+ agentVersion = "unknown";
218
+ constructor(opts = {}) {
219
+ const given = opts.acpCommand;
220
+ const agent = typeof given === "string"
221
+ ? (() => {
222
+ const argv = splitCommandLine(given);
223
+ return argv.length > 0 ? { command: argv[0], args: argv.slice(1) } : undefined;
224
+ })()
225
+ : given;
226
+ if (!agent || !agent.command) {
227
+ throw new Error('ACP agent command is empty: use --agent "acp:<command>", for example acp:opencode acp');
228
+ }
229
+ this.agent = agent;
230
+ this.id = `acp:${path.basename(agent.command)}`;
231
+ this.maxTurns = opts.maxTurns ?? 20;
232
+ this.cwd = opts.cwd ?? process.cwd();
233
+ this.logger = opts.logger ?? consoleLogger;
234
+ this.defaultContext = opts.defaultContext ?? { config: {}, logger: this.logger };
235
+ this.mcpServers = opts.mcpServers ?? {};
236
+ this.defaultMcpServers = opts.defaultMcpServers ?? {};
237
+ this.envPassthrough = opts.envPassthrough;
238
+ this.authVars = opts.authVars ?? [];
239
+ this.envScope = opts.envScope;
240
+ this.sandboxMode = opts.sandbox;
241
+ this.sandboxWrapper = opts.sandboxWrapper;
242
+ this.policyMode = opts.policy ?? resolveHarnessPolicy(process.env, undefined, this.logger);
243
+ this.egress = opts.egress ?? [];
244
+ this.toolBridgeShim = opts.toolBridgeShim;
245
+ this.killGraceMs = opts.killGraceMs ?? 2000;
246
+ this.cancelGraceMs = opts.cancelGraceMs ?? 5000;
247
+ this.startupTimeoutMs = opts.startupTimeoutMs ?? 60_000;
248
+ }
249
+ info() {
250
+ return { id: this.id, version: this.agentVersion };
251
+ }
252
+ /**
253
+ * Is the agent command there? Deliberately does not start it: it would run
254
+ * outside the sandbox wrapper. The version comes from `initialize` on the
255
+ * first run (`agentInfo.version`).
256
+ */
257
+ async preflight() {
258
+ const found = resolveOnPath(this.agent.command, process.env);
259
+ if (!found) {
260
+ const example = [this.agent.command, ...this.agent.args].join(" ");
261
+ return {
262
+ ok: false,
263
+ reason: `ACP agent command "${this.agent.command}" was not found on PATH (or is not executable). Install the agent and check that \`${example}\` starts an ACP server.`,
264
+ };
265
+ }
266
+ return { ok: true, version: this.agentVersion };
267
+ }
268
+ /** Env for the agent process: scoped like Claude Code's, with only the auth vars the operator named. */
269
+ buildEnv(extraVars = []) {
270
+ const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
271
+ if (!resolveEnvScope(process.env, this.envScope, this.logger))
272
+ return full;
273
+ const { env, withheld } = scopeAgentEnv(full, {
274
+ extraVars,
275
+ passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
276
+ authVars: this.authVars,
277
+ prefixes: ["LC_"],
278
+ logger: this.logger,
279
+ });
280
+ reportWithheldEnv(withheld, this.logger);
281
+ return env;
282
+ }
283
+ /** The node policy, with the harness policy mode and the watchdog budget applied. */
284
+ compilePolicy(req, maxTurns) {
285
+ const base = req.policy
286
+ ? { ...req.policy, strict: req.policy.strict || this.policyMode === "strict" }
287
+ : {
288
+ readOnly: !!req.readOnly,
289
+ deny: req.deny ?? [],
290
+ nativeDeny: req.disallowedTools,
291
+ egress: req.agentAccess?.domains ?? [],
292
+ strict: this.policyMode === "strict",
293
+ };
294
+ return { ...base, maxTurns: base.maxTurns ?? maxTurns };
295
+ }
296
+ /**
297
+ * Talk to one agent process from `initialize` to the last `session/prompt`
298
+ * response, then stop it. Never throws: every failure is on the result.
299
+ */
300
+ async converse(o) {
301
+ const out = {
302
+ turns: [],
303
+ toolCalls: [],
304
+ exitCode: null,
305
+ exitSignal: null,
306
+ stderrTail: "",
307
+ unsupportedMcp: [],
308
+ rejected: [],
309
+ };
310
+ const abort = makeAbort(o.timeoutMs, o.signal);
311
+ if (abort?.controller.signal.aborted) {
312
+ // Aborted before the agent started: nothing to spawn or stop.
313
+ abort.clear();
314
+ out.abortReason = abort.reason();
315
+ return out;
316
+ }
317
+ let child;
318
+ try {
319
+ child = spawn(o.spawn.command, o.spawn.args, {
320
+ cwd: o.spawn.cwd,
321
+ env: o.spawn.env,
322
+ stdio: ["pipe", "pipe", "pipe"],
323
+ });
324
+ }
325
+ catch (err) {
326
+ abort?.clear();
327
+ out.spawnError = err instanceof Error ? err.message : String(err);
328
+ return out;
329
+ }
330
+ trackChild(child);
331
+ let sessionId;
332
+ let stopRequested = false;
333
+ let cancelSent = false;
334
+ let exited = false;
335
+ let cancelTimer;
336
+ let toolCount = 0;
337
+ let acceptUpdates = true;
338
+ const byId = new Map();
339
+ // Text of the turn in progress: one segment per agent message, a new one after every tool call.
340
+ let segments = [];
341
+ let startNewSegment = true;
342
+ let lastMessageId;
343
+ let costWarned = false;
344
+ const startCall = (id, u) => {
345
+ const name = typeof u.name === "string" && u.name !== "" ? u.name : typeof u.title === "string" ? u.title : id;
346
+ const call = { tool: name, input: u.rawInput ?? {} };
347
+ out.toolCalls.push(call);
348
+ byId.set(id, { call, kind: u.kind, name: u.name ?? u.title });
349
+ toolCount++;
350
+ startNewSegment = true;
351
+ if (o.onProgress) {
352
+ const detail = typeof u.title === "string" && u.title !== "" ? u.title : name;
353
+ o.onProgress(detail.length > 80 ? detail.slice(0, 79) + "…" : detail);
354
+ }
355
+ if (o.maxToolCalls !== undefined && toolCount > o.maxToolCalls && !out.overBudget) {
356
+ out.overBudget = { count: toolCount, limit: o.maxToolCalls };
357
+ acceptUpdates = false;
358
+ requestStop();
359
+ }
360
+ return byId.get(id);
361
+ };
362
+ const settleCall = (entry, u) => {
363
+ const call = entry.call;
364
+ if (call.status !== undefined)
365
+ return;
366
+ const status = u.status;
367
+ if (status !== "completed" && status !== "failed")
368
+ return;
369
+ const content = Array.isArray(u.content) ? u.content : [];
370
+ const texts = [];
371
+ const diffs = [];
372
+ for (const item of content) {
373
+ if (!isObj(item))
374
+ continue;
375
+ if (item.type === "content" && isObj(item.content) && item.content.type === "text")
376
+ texts.push(item.content);
377
+ else if (item.type === "diff")
378
+ diffs.push({ path: item.path, oldText: item.oldText, newText: item.newText });
379
+ }
380
+ const parsed = texts.length > 0 ? parseToolResultContent(texts) : (u.rawOutput ?? (diffs.length > 0 ? diffs : null));
381
+ if (status === "failed") {
382
+ call.status = "error";
383
+ call.output = { error: parsed };
384
+ this.logger.warn(` tool ${call.tool} failed: ${summarizeToolError(parsed)}`);
385
+ }
386
+ else {
387
+ call.status = "success";
388
+ call.output = parsed;
389
+ }
390
+ };
391
+ const onUpdate = (u) => {
392
+ switch (u.sessionUpdate) {
393
+ case "agent_message_chunk": {
394
+ const text = blockText(u.content);
395
+ if (!text)
396
+ return;
397
+ const mid = typeof u.messageId === "string" ? u.messageId : undefined;
398
+ if (startNewSegment || (mid !== undefined && lastMessageId !== undefined && mid !== lastMessageId)) {
399
+ segments.push("");
400
+ startNewSegment = false;
401
+ }
402
+ if (mid !== undefined)
403
+ lastMessageId = mid;
404
+ segments[segments.length - 1] += text;
405
+ return;
406
+ }
407
+ case "tool_call": {
408
+ const id = typeof u.toolCallId === "string" ? u.toolCallId : "";
409
+ if (!id)
410
+ return;
411
+ const existing = byId.get(id);
412
+ const entry = existing ?? startCall(id, u);
413
+ if (existing && u.rawInput !== undefined)
414
+ existing.call.input = u.rawInput;
415
+ settleCall(entry, u);
416
+ return;
417
+ }
418
+ case "tool_call_update": {
419
+ const id = typeof u.toolCallId === "string" ? u.toolCallId : "";
420
+ if (!id)
421
+ return;
422
+ let entry = byId.get(id);
423
+ if (!entry)
424
+ entry = startCall(id, u);
425
+ if (typeof u.name === "string" && u.name !== "")
426
+ entry.call.tool = u.name;
427
+ else if (typeof u.title === "string" && u.title !== "" && entry.name == null)
428
+ entry.call.tool = u.title;
429
+ if (typeof u.kind === "string")
430
+ entry.kind = u.kind;
431
+ if (u.rawInput !== undefined)
432
+ entry.call.input = u.rawInput;
433
+ settleCall(entry, u);
434
+ return;
435
+ }
436
+ case "usage_update": {
437
+ // `used` and `size` are context window occupancy, not billed tokens: not mapped.
438
+ const cost = u.cost;
439
+ if (isObj(cost) && typeof cost.amount === "number" && Number.isFinite(cost.amount)) {
440
+ if (typeof cost.currency === "string" && cost.currency.toUpperCase() === "USD")
441
+ out.costUsd = cost.amount;
442
+ else if (!costWarned) {
443
+ costWarned = true;
444
+ this.logger.debug(`[acp] usage_update cost is in ${String(cost.currency)}, not USD: not recorded`);
445
+ }
446
+ }
447
+ return;
448
+ }
449
+ default:
450
+ return;
451
+ }
452
+ };
453
+ /** `session/request_permission`: answer by policy (best effort, the agent chooses whether to ask). */
454
+ const decide = (params) => {
455
+ const options = Array.isArray(params.options)
456
+ ? params.options.filter(isObj).map((x) => ({ optionId: String(x.optionId), kind: String(x.kind) }))
457
+ : [];
458
+ if (cancelSent)
459
+ return { outcome: { outcome: "cancelled" } };
460
+ const tc = isObj(params.toolCall) ? params.toolCall : {};
461
+ const known = typeof tc.toolCallId === "string" ? byId.get(tc.toolCallId) : undefined;
462
+ const kind = (typeof tc.kind === "string" ? tc.kind : undefined) ?? known?.kind;
463
+ const name = (typeof tc.name === "string" ? tc.name : undefined) ?? known?.name;
464
+ const title = typeof tc.title === "string" ? tc.title : undefined;
465
+ const { classes, readOnlySafe } = classifyAcpTool(kind, name ?? title);
466
+ const skill = isSkillToolCall([name, title], o.skillTools);
467
+ let allow = true;
468
+ let why = "";
469
+ if (!skill) {
470
+ const denied = classes.filter((c) => o.deny.has(c));
471
+ if (denied.length > 0) {
472
+ allow = false;
473
+ why = `tools.deny [${denied.join(", ")}]`;
474
+ }
475
+ else if (o.readOnly && !readOnlySafe) {
476
+ allow = false;
477
+ why = "read-only dry run";
478
+ }
479
+ }
480
+ if (!allow) {
481
+ const label = title ?? name ?? String(tc.toolCallId ?? "tool call");
482
+ out.rejected.push(label);
483
+ this.logger.warn(` acp: rejected permission request "${label}" (${why})`);
484
+ }
485
+ return { outcome: pickPermissionOutcome(options, allow) };
486
+ };
487
+ const peer = new RpcPeer(child, {
488
+ onRequest: async (method, params) => {
489
+ const p = isObj(params) ? params : {};
490
+ if (method === "session/request_permission")
491
+ return decide(p);
492
+ if (method === "fs/write_text_file") {
493
+ // sweny does not advertise fs access, so a spec-following agent never calls this. One that does is refused.
494
+ out.rejected.push(`fs/write_text_file ${String(p.path ?? "")}`.trim());
495
+ this.logger.warn(` acp: refused fs/write_text_file ${String(p.path ?? "")} (${o.readOnly ? "read-only dry run" : "sweny does not advertise client file access"})`);
496
+ throw new RpcError(o.readOnly
497
+ ? "sweny refused: this is a read-only dry run"
498
+ : "sweny did not advertise fs.writeTextFile; write the file with your own tools", RPC_ERROR.invalidRequest);
499
+ }
500
+ if (method === "fs/read_text_file") {
501
+ throw new RpcError("sweny did not advertise fs.readTextFile; read the file with your own tools", RPC_ERROR.invalidRequest);
502
+ }
503
+ throw new RpcError(`method not supported by sweny: ${method}`, RPC_ERROR.methodNotFound);
504
+ },
505
+ onNotification: (method, params) => {
506
+ if (method !== "session/update" || !acceptUpdates)
507
+ return;
508
+ if (!isObj(params) || !isObj(params.update))
509
+ return;
510
+ onUpdate(params.update);
511
+ },
512
+ onJunk: (line) => this.logger.debug(`[acp] non-JSON stdout line ignored: ${line}`),
513
+ });
514
+ const closed = new Promise((resolve) => {
515
+ child.once("close", (code, signal) => {
516
+ exited = true;
517
+ out.exitCode = code;
518
+ out.exitSignal = signal;
519
+ peer.end(PROCESS_EXITED);
520
+ resolve();
521
+ });
522
+ child.once("error", (err) => {
523
+ // Spawn failures (ENOENT, EACCES) have no pid and no `close` event to follow.
524
+ if (child.pid === undefined) {
525
+ out.spawnError = err.message;
526
+ exited = true;
527
+ peer.end("agent process could not start");
528
+ resolve();
529
+ }
530
+ else {
531
+ this.logger.debug(`[acp] child error: ${err.message}`);
532
+ }
533
+ });
534
+ });
535
+ child.stderr?.setEncoding("utf8");
536
+ child.stderr?.on("data", (chunk) => {
537
+ this.logger.debug(`[acp] ${chunk.trimEnd()}`);
538
+ out.stderrTail = (out.stderrTail + chunk).slice(-2000);
539
+ });
540
+ const waitExit = (ms) => new Promise((resolve) => {
541
+ if (exited)
542
+ return resolve(true);
543
+ const t = setTimeout(() => resolve(exited), ms);
544
+ t.unref?.();
545
+ void closed.then(() => {
546
+ clearTimeout(t);
547
+ resolve(true);
548
+ });
549
+ });
550
+ /** SIGTERM, then SIGKILL after the grace period. Resolves when the process is gone. */
551
+ const terminate = async () => {
552
+ if (exited)
553
+ return;
554
+ stopChild(child, "SIGTERM");
555
+ if (await waitExit(this.killGraceMs))
556
+ return;
557
+ stopChild(child, "SIGKILL");
558
+ await closed;
559
+ };
560
+ /** Normal end: close stdin (the stdio transport's way to say goodbye), then terminate if it lingers. */
561
+ const shutdown = async () => {
562
+ if (exited)
563
+ return;
564
+ try {
565
+ child.stdin?.end();
566
+ }
567
+ catch {
568
+ // already closed
569
+ }
570
+ if (await waitExit(this.killGraceMs))
571
+ return;
572
+ await terminate();
573
+ };
574
+ const cancelGraceMs = this.cancelGraceMs;
575
+ /** Ask the agent to stop (`session/cancel`); kill it if it does not answer in time. */
576
+ function requestStop() {
577
+ if (stopRequested)
578
+ return;
579
+ stopRequested = true;
580
+ if (sessionId) {
581
+ cancelSent = true;
582
+ peer.notify("session/cancel", { sessionId });
583
+ cancelTimer = setTimeout(() => void terminate(), cancelGraceMs);
584
+ cancelTimer.unref?.();
585
+ }
586
+ else {
587
+ // Still starting up: there is no session to cancel.
588
+ void terminate();
589
+ }
590
+ }
591
+ if (abort) {
592
+ const onAbort = () => {
593
+ out.abortReason = abort.reason();
594
+ requestStop();
595
+ };
596
+ abort.controller.signal.addEventListener("abort", onAbort, { once: true });
597
+ if (abort.controller.signal.aborted)
598
+ onAbort();
599
+ }
600
+ const startup = (p, what) => new Promise((resolve, reject) => {
601
+ const t = setTimeout(() => reject(new RpcError(`${what} did not finish within ${this.startupTimeoutMs}ms`, RPC_ERROR.internal)), this.startupTimeoutMs);
602
+ t.unref?.();
603
+ p.then((v) => {
604
+ clearTimeout(t);
605
+ resolve(v);
606
+ }, (e) => {
607
+ clearTimeout(t);
608
+ reject(e);
609
+ });
610
+ });
611
+ try {
612
+ const init = await startup(peer.request("initialize", {
613
+ protocolVersion: ACP_PROTOCOL_VERSION,
614
+ // No fs and no terminal: the agent uses its own tools, which the sandbox wrapper contains.
615
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
616
+ clientInfo: { name: "sweny", title: "SWEny", version: "5" },
617
+ }), "initialize");
618
+ if (!isObj(init) || init.protocolVersion !== ACP_PROTOCOL_VERSION) {
619
+ throw new RpcError(`the agent speaks ACP protocol version ${String(isObj(init) ? init.protocolVersion : undefined)}; sweny speaks version ${ACP_PROTOCOL_VERSION}`, RPC_ERROR.invalidRequest);
620
+ }
621
+ if (isObj(init.agentInfo) && typeof init.agentInfo.version === "string") {
622
+ out.agentVersion = init.agentInfo.version;
623
+ }
624
+ const agentHttp = isObj(init.agentCapabilities) && init.agentCapabilities.mcpCapabilities?.http === true;
625
+ const mcp = toAcpMcpServers(o.mcpServers, agentHttp);
626
+ out.unsupportedMcp = mcp.unsupported;
627
+ const session = await startup(peer.request("session/new", { cwd: path.resolve(o.cwd), mcpServers: mcp.list }), "session/new");
628
+ if (!isObj(session) || typeof session.sessionId !== "string") {
629
+ throw new RpcError("session/new returned no sessionId", RPC_ERROR.invalidRequest);
630
+ }
631
+ sessionId = session.sessionId;
632
+ // An abort that landed during startup could not cancel a session that did not exist yet.
633
+ if (stopRequested) {
634
+ cancelSent = true;
635
+ peer.notify("session/cancel", { sessionId });
636
+ }
637
+ // A stop that landed during startup already cancelled the session: do not prompt it.
638
+ let prompt = stopRequested ? undefined : o.prompt;
639
+ while (prompt !== undefined) {
640
+ segments = [];
641
+ startNewSegment = true;
642
+ lastMessageId = undefined;
643
+ const res = await peer.request("session/prompt", {
644
+ sessionId,
645
+ prompt: [{ type: "text", text: prompt }],
646
+ });
647
+ const stopReason = isObj(res) && typeof res.stopReason === "string" ? res.stopReason : "unknown";
648
+ const text = [...segments].reverse().find((s) => s.trim() !== "") ?? "";
649
+ const turn = { stopReason, text };
650
+ out.turns.push(turn);
651
+ if (stopReason !== "end_turn" || stopRequested)
652
+ break;
653
+ prompt = o.followUp?.(turn);
654
+ }
655
+ }
656
+ catch (err) {
657
+ // After a stop we asked for, the failure is ours (timeout, budget). A process that went away has
658
+ // its exit code and stderr reported instead. Anything else is the agent's own error.
659
+ if (!stopRequested && !(err instanceof RpcError && err.message === PROCESS_EXITED)) {
660
+ out.error = {
661
+ message: err instanceof Error ? err.message : String(err),
662
+ ...(err instanceof RpcError ? { code: err.code } : {}),
663
+ };
664
+ }
665
+ }
666
+ finally {
667
+ if (cancelTimer)
668
+ clearTimeout(cancelTimer);
669
+ await shutdown();
670
+ abort?.clear();
671
+ liveChildren.delete(child);
672
+ peer.end(PROCESS_EXITED);
673
+ }
674
+ return out;
675
+ }
676
+ /**
677
+ * Run a node. The gate runs first: in strict mode a node whose opinions an
678
+ * ACP agent cannot honor is refused before the agent starts. The agent
679
+ * process runs inside the host's sandbox wrapper (#360 step 2) when the
680
+ * sandbox mode asks for one and the host has one.
681
+ */
682
+ async run(req) {
683
+ const maxTurns = req.maxTurns ?? this.maxTurns;
684
+ const mode = req.policy?.sandbox ?? resolveSandboxMode(process.env, this.sandboxMode, this.logger);
685
+ const policy = { ...this.compilePolicy(req, maxTurns), sandbox: mode };
686
+ // ACP has no sandbox of its own: any mode but off needs the process wrapper.
687
+ const wrapper = mode !== "off" && this.sandboxWrapper !== null
688
+ ? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
689
+ : undefined;
690
+ const gate = policyGate(this.capabilities, policy, wrappersFrom(wrapper));
691
+ let degraded = gate.degraded;
692
+ const extraDegraded = [];
693
+ const tag = (r) => ({
694
+ ...r,
695
+ harness: this.info(),
696
+ degraded: [...degraded, ...extraDegraded],
697
+ });
698
+ const refused = (why) => {
699
+ const msg = `ACP agent refused this node: ${why}`;
700
+ this.logger.error(msg);
701
+ // `refused` keeps fail_soft from softening a policy refusal (executor.ts).
702
+ return tag({ status: "failed", data: { error: msg, refused: true }, toolCalls: [] });
703
+ };
704
+ if (gate.refuse)
705
+ return refused(gate.refuse);
706
+ const pre = await this.preflight();
707
+ if (!pre.ok) {
708
+ this.logger.error(pre.reason);
709
+ return tag({ status: "failed", data: { error: pre.reason }, toolCalls: [] });
710
+ }
711
+ if (req.model) {
712
+ extraDegraded.push("model: ACP has no portable model selector; the agent uses its own configured model");
713
+ }
714
+ const readOnly = policy.readOnly;
715
+ // Permission answers: the node's deny list, plus legacy `disallowed_tools` names that map to a class.
716
+ const deny = new Set([...policy.deny, ...translateDenyNames(policy.nativeDeny).classes]);
717
+ const env = this.buildEnv(req.agentAccess?.envVars);
718
+ let bridge;
719
+ try {
720
+ // Dry run: external MCP servers cannot be classified per tool, so only
721
+ // sweny's own (already read-filtered) skill tools remain.
722
+ const servers = readOnly
723
+ ? {}
724
+ : { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
725
+ if (req.tools.length > 0) {
726
+ bridge = await startToolBridge({
727
+ tools: req.tools,
728
+ context: this.defaultContext,
729
+ logger: this.logger,
730
+ ...(this.toolBridgeShim ? { shimCommand: this.toolBridgeShim } : {}),
731
+ });
732
+ // The token rides in the shim's own env entry of session/new, never in argv.
733
+ const shim = bridge.mcpServer;
734
+ servers[BRIDGE_SERVER] = {
735
+ ...shim,
736
+ env: { ...(shim.env ?? {}), SWENY_NO_UPDATE_CHECK: "1", [TOKEN_ENV]: bridge.token },
737
+ };
738
+ }
739
+ const basePrompt = buildNodePrompt(req.instruction, req.context, req.outputSchema);
740
+ const schema = req.outputSchema;
741
+ const retry = { asked: false };
742
+ // One correction turn when the answer does not match the declared output.
743
+ const followUp = (turn) => {
744
+ if (!schema || retry.asked)
745
+ return undefined;
746
+ const parsed = tryParseJSON(turn.text, schema, { warn() { } });
747
+ const problems = schemaMismatches(parsed, schema);
748
+ if (problems.length === 0)
749
+ return undefined;
750
+ retry.asked = true;
751
+ this.logger.info(` acp: the answer does not match the output shape (${problems.join("; ")}); asking once more`);
752
+ return [
753
+ `Your last reply did not match the required output: ${problems.join("; ")}.`,
754
+ "Reply again, ending with ONLY a JSON object that matches this schema:",
755
+ "```json",
756
+ JSON.stringify(schema, null, 2),
757
+ "```",
758
+ ].join("\n");
759
+ };
760
+ const prep = await prepareAgentSpawn({
761
+ caps: this.capabilities,
762
+ policy,
763
+ spawn: { command: this.agent.command, args: this.agent.args, env, cwd: this.cwd },
764
+ wrapper: wrapper ?? null,
765
+ harnessEgress: this.egress,
766
+ env: process.env,
767
+ });
768
+ degraded = prep.degraded;
769
+ if (prep.refuse) {
770
+ await prep.cleanup();
771
+ return refused(prep.refuse);
772
+ }
773
+ let conv;
774
+ try {
775
+ conv = await this.converse({
776
+ spawn: prep.spawn,
777
+ cwd: this.cwd,
778
+ mcpServers: servers,
779
+ prompt: basePrompt,
780
+ followUp,
781
+ readOnly,
782
+ deny,
783
+ skillTools: req.tools.map((t) => t.name),
784
+ timeoutMs: req.timeoutMs,
785
+ signal: req.signal,
786
+ maxToolCalls: maxTurns,
787
+ onProgress: req.onProgress,
788
+ });
789
+ }
790
+ finally {
791
+ await prep.cleanup();
792
+ }
793
+ if (conv.agentVersion)
794
+ this.agentVersion = conv.agentVersion;
795
+ for (const u of conv.unsupportedMcp) {
796
+ const note = `mcp: the ACP agent cannot load ${u}`;
797
+ this.logger.warn(` ${note}`);
798
+ extraDegraded.push(note);
799
+ }
800
+ const result = this.toResult(conv, schema, req.timeoutMs);
801
+ if (result.status === "success" && schema && retry.asked) {
802
+ const last = conv.turns.at(-1)?.text ?? "";
803
+ const left = schemaMismatches(tryParseJSON(last, schema, { warn() { } }), schema);
804
+ if (left.length > 0) {
805
+ extraDegraded.push(`structured_output: the answer still did not match the declared output after one retry`);
806
+ }
807
+ }
808
+ return tag(result);
809
+ }
810
+ catch (err) {
811
+ const msg = `ACP agent run failed: ${err instanceof Error ? err.message : String(err)}`;
812
+ this.logger.error(msg);
813
+ return tag({ status: "failed", data: { error: msg }, toolCalls: [] });
814
+ }
815
+ finally {
816
+ await bridge?.close();
817
+ }
818
+ }
819
+ /** Map one conversation onto a NodeResult with Claude Code's semantics. */
820
+ toResult(conv, outputSchema, timeoutMs) {
821
+ const { toolCalls } = conv;
822
+ const usage = conv.costUsd !== undefined ? { costUsd: conv.costUsd } : undefined;
823
+ const withUsage = (r) => (usage ? { ...r, usage } : r);
824
+ const last = conv.turns.at(-1)?.text ?? "";
825
+ const fail = (error, partial = false) => {
826
+ this.logger.error(error);
827
+ return withUsage({
828
+ status: "failed",
829
+ data: { error, ...(partial && last.trim() !== "" ? { summary: last.trim() } : {}) },
830
+ toolCalls,
831
+ });
832
+ };
833
+ if (conv.spawnError)
834
+ return fail(`ACP agent could not start: ${conv.spawnError}`);
835
+ if (conv.abortReason === "timeout")
836
+ return fail(`ACP agent run timed out after ${timeoutMs}ms`);
837
+ if (conv.abortReason === "signal")
838
+ return fail("ACP agent run aborted");
839
+ if (conv.overBudget) {
840
+ // Same shape as Claude Code's max_turns stop: partial text is kept for fail_soft nodes.
841
+ return fail(`ACP agent run terminated early: max_turns (${conv.overBudget.limit} tool calls; stopped by the sweny watchdog)`, true);
842
+ }
843
+ if (conv.error) {
844
+ const hint = conv.error.code === RPC_ERROR.authRequired
845
+ ? ` (the agent wants you to sign in: pass its API key env var through SWENY_ENV_PASSTHROUGH, since sweny cannot run an interactive login)`
846
+ : "";
847
+ return fail(`ACP agent run failed: ${conv.error.message}${hint}`, true);
848
+ }
849
+ const turn = conv.turns.at(-1);
850
+ if (!turn) {
851
+ const code = conv.exitSignal ? `signal ${conv.exitSignal}` : `code ${conv.exitCode}`;
852
+ const tail = conv.stderrTail.trim().split("\n").slice(-3).join(" | ");
853
+ return fail(conv.exitCode === 0 && !conv.exitSignal
854
+ ? `agent stream ended without a result message (${toolCalls.length} tool calls captured)`
855
+ : `ACP agent exited with ${code}${tail ? `: ${tail}` : ""}`);
856
+ }
857
+ if (turn.stopReason !== "end_turn") {
858
+ return fail(`ACP agent stopped the turn early: ${turn.stopReason}`, true);
859
+ }
860
+ const parsed = tryParseJSON(last, outputSchema, this.logger, "ACP agent");
861
+ return withUsage({ status: "success", data: { summary: last, ...parsed }, toolCalls });
862
+ }
863
+ /**
864
+ * One completion: a fresh session with no MCP servers, every permission
865
+ * request rejected, and any tool call over budget. null when it failed
866
+ * (callers fail closed).
867
+ */
868
+ async complete(req) {
869
+ const purpose = req.purpose ?? "ask";
870
+ const failClosed = (why) => {
871
+ this.logger.warn(purpose === "evaluate"
872
+ ? `acp.evaluate: ${why}. Failing closed (no route decision).`
873
+ : `acp.ask: ${why}. Returning empty string.`);
874
+ return null;
875
+ };
876
+ const pre = await this.preflight();
877
+ if (!pre.ok)
878
+ return failClosed(pre.reason);
879
+ // A judge call is a dry run with nothing allowed: same gate, same wrapper.
880
+ const mode = resolveSandboxMode(process.env, this.sandboxMode, this.logger);
881
+ const policy = {
882
+ readOnly: true,
883
+ // Nothing to deny at the gate: there is no MCP server and every permission request is rejected below.
884
+ deny: [],
885
+ egress: [],
886
+ strict: this.policyMode === "strict",
887
+ sandbox: mode,
888
+ };
889
+ const wrapper = mode !== "off" && this.sandboxWrapper !== null
890
+ ? (this.sandboxWrapper ?? (await defaultSandboxWrapper()).wrapper)
891
+ : undefined;
892
+ const prep = await prepareAgentSpawn({
893
+ caps: this.capabilities,
894
+ policy,
895
+ spawn: { command: this.agent.command, args: this.agent.args, env: this.buildEnv(), cwd: this.cwd },
896
+ wrapper: wrapper ?? null,
897
+ harnessEgress: this.egress,
898
+ env: process.env,
899
+ });
900
+ if (prep.refuse) {
901
+ await prep.cleanup();
902
+ return failClosed(prep.refuse);
903
+ }
904
+ let conv;
905
+ try {
906
+ conv = await this.converse({
907
+ spawn: prep.spawn,
908
+ cwd: this.cwd,
909
+ mcpServers: {},
910
+ prompt: req.prompt,
911
+ readOnly: true,
912
+ deny: new Set(TOOL_CLASSES),
913
+ skillTools: [],
914
+ timeoutMs: req.timeoutMs,
915
+ signal: req.signal,
916
+ // Classification calls use no tools; any tool call is over budget.
917
+ maxToolCalls: 0,
918
+ });
919
+ }
920
+ finally {
921
+ await prep.cleanup();
922
+ }
923
+ if (conv.agentVersion)
924
+ this.agentVersion = conv.agentVersion;
925
+ if (conv.spawnError)
926
+ return failClosed(`the ACP agent could not start: ${conv.spawnError}`);
927
+ if (conv.abortReason === "timeout")
928
+ return failClosed(`timed out after ${req.timeoutMs}ms`);
929
+ if (conv.abortReason === "signal")
930
+ return failClosed("aborted");
931
+ if (conv.overBudget)
932
+ return failClosed("the model tried to call a tool");
933
+ if (conv.error)
934
+ return failClosed(`the ACP agent failed: ${conv.error.message}`);
935
+ const turn = conv.turns.at(-1);
936
+ if (!turn)
937
+ return failClosed("agent stream ended without a result message");
938
+ if (turn.stopReason !== "end_turn")
939
+ return failClosed(`the ACP agent stopped the turn early: ${turn.stopReason}`);
940
+ return turn.text;
941
+ }
942
+ }