@koalaauto/agentchannel 3.10.0 → 3.12.0

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.
Files changed (42) hide show
  1. package/README.md +5 -1
  2. package/dist/src/adapters/claude.d.ts +19 -0
  3. package/dist/src/adapters/claude.js +52 -1
  4. package/dist/src/adapters/codex.d.ts +11 -0
  5. package/dist/src/adapters/codex.js +34 -2
  6. package/dist/src/adapters/fake.d.ts +10 -0
  7. package/dist/src/adapters/fake.js +9 -0
  8. package/dist/src/adapters/paths.d.ts +9 -1
  9. package/dist/src/adapters/paths.js +28 -3
  10. package/dist/src/adapters/types.d.ts +3 -2
  11. package/dist/src/args.d.ts +1 -1
  12. package/dist/src/args.js +18 -9
  13. package/dist/src/cli.js +81 -17
  14. package/dist/src/client.d.ts +48 -4
  15. package/dist/src/client.js +41 -5
  16. package/dist/src/desknotify.d.ts +2 -2
  17. package/dist/src/desknotify.js +2 -2
  18. package/dist/src/doctor.d.ts +4 -0
  19. package/dist/src/doctor.js +12 -0
  20. package/dist/src/envcheck.d.ts +22 -0
  21. package/dist/src/envcheck.js +98 -0
  22. package/dist/src/mcp.d.ts +1 -1
  23. package/dist/src/mcp.js +24 -12
  24. package/dist/src/names.d.ts +6 -1
  25. package/dist/src/names.js +15 -2
  26. package/dist/src/onboard.d.ts +11 -2
  27. package/dist/src/onboard.js +54 -2
  28. package/dist/src/prompts.d.ts +3 -3
  29. package/dist/src/prompts.js +4 -4
  30. package/dist/src/redact.d.ts +5 -0
  31. package/dist/src/redact.js +41 -0
  32. package/dist/src/runner/runner.d.ts +32 -1
  33. package/dist/src/runner/runner.js +214 -20
  34. package/dist/src/sandboxcheck.d.ts +97 -0
  35. package/dist/src/sandboxcheck.js +236 -0
  36. package/dist/src/service.d.ts +9 -3
  37. package/dist/src/service.js +39 -1
  38. package/dist/src/trace.d.ts +51 -0
  39. package/dist/src/trace.js +137 -0
  40. package/dist/src/version.d.ts +1 -1
  41. package/dist/src/version.js +1 -1
  42. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { gitCommonDir } from '../adapters/index.js';
@@ -10,6 +10,10 @@ import { buildAgentEnv } from '../env.js';
10
10
  import { effectivePerm } from '../perm.js';
11
11
  import { ANSWER_SCHEMA, buildAnswerPrompt, buildMentionPrompt, buildResumePrompt, buildTaskPrompt, postUrl, refOf, taskUrl } from '../prompts.js';
12
12
  import { RunnerState } from './state.js';
13
+ import { looksLikeLoginError, summarizeRuntimeError } from '../envcheck.js';
14
+ import { redact } from '../redact.js';
15
+ import { TraceRecorder } from '../trace.js';
16
+ import { looksLikeSandboxError } from '../sandboxcheck.js';
13
17
  export function jobKey(j) {
14
18
  if (j.kind === 'mention')
15
19
  return `mention:${j.item.mention.id}`;
@@ -207,9 +211,17 @@ export class Runner {
207
211
  /** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
208
212
  fatal;
209
213
  wake;
214
+ /** design §27: the last sandbox probe, when it ran, and what the hub was last told (a JSON key). */
215
+ sandbox;
216
+ sandboxReported;
217
+ sandboxRecheck = false;
218
+ /** Execution traces the hub has not taken yet (kept on disk too), and the last trace of each task (a commit joins it). */
219
+ traceQueue = new Map();
220
+ lastTrace = new Map();
210
221
  constructor(opts) {
211
222
  this.o = { max: 2, perm: 'workdir', intervalMs: 3000, heartbeatMs: 30_000, idleMs: 60 * 60_000, ...opts };
212
223
  this.state = new RunnerState(opts.home);
224
+ this.loadPendingTraces();
213
225
  }
214
226
  log(s) {
215
227
  (this.o.log ?? ((x) => console.error(x)))(`[ach run] ${s}`);
@@ -283,8 +295,11 @@ export class Runner {
283
295
  }
284
296
  await this.checkActive();
285
297
  await this.prune();
298
+ await this.flushTraces();
286
299
  if (this.paused)
287
300
  return; // heartbeats of parked runs went out in checkActive; nothing new is taken (it waits in the hub's inbox)
301
+ if (await this.sandboxBlocked())
302
+ return; // design §27: same, until the sandbox works
288
303
  if (!this.me || inbox.tasks_to_me.length)
289
304
  this.me = await this.o.client.me();
290
305
  const me = this.me;
@@ -332,6 +347,50 @@ export class Runner {
332
347
  }
333
348
  this.pump();
334
349
  }
350
+ /** design §27: probe the sandbox when due, tell the hub when the answer changes; true = take nothing new. */
351
+ async sandboxBlocked() {
352
+ const check = this.o.sandboxCheck;
353
+ if (!check)
354
+ return false;
355
+ const prev = this.sandbox;
356
+ const due = !prev || this.sandboxRecheck || (!prev.st.ok && Date.now() - prev.at >= (this.o.sandboxRecheckMs ?? 5 * 60_000)) || (prev.st.ok && Date.now() - prev.at >= (this.o.okRecheckMs ?? 6 * 3600_000));
357
+ if (due) {
358
+ this.sandboxRecheck = false;
359
+ let st;
360
+ try {
361
+ st = await check();
362
+ }
363
+ catch (e) {
364
+ st = { tool: 'claude', ok: false, problem: 'failed', detail: String(e?.message ?? e) };
365
+ }
366
+ this.sandbox = { st, at: Date.now() };
367
+ if (!prev || prev.st.ok !== st.ok || prev.st.problem !== st.problem) {
368
+ if (st.ok)
369
+ this.log(`sandbox: ${st.unverified ? `not checked (${st.detail})` : 'works'}${prev ? '; taking tasks again' : ''}`);
370
+ else
371
+ this.log(`sandbox: ${st.fix?.en ?? st.detail ?? st.problem} Taking no tasks until it works.${st.fix?.commands.length ? `\n fix:\n${st.fix.commands.map((c) => ` ${c}`).join('\n')}` : ''}`);
372
+ }
373
+ }
374
+ const st = this.sandbox.st;
375
+ const report = st.ok ? null : { tool: st.tool, problem: st.problem ?? 'failed', ...(st.detail ? { detail: st.detail } : {}), ...(st.fix ? { fix: st.fix } : {}) };
376
+ const key = JSON.stringify(report);
377
+ if (key !== this.sandboxReported && this.o.client.reportSetup) {
378
+ try {
379
+ await this.o.client.reportSetup(report);
380
+ this.sandboxReported = key;
381
+ }
382
+ catch (e) {
383
+ if (is401(e))
384
+ this.authFailed('setup');
385
+ // an older hub without /me/setup: the log above is all there is
386
+ else if (e instanceof HubError && e.status === 404)
387
+ this.sandboxReported = key;
388
+ else
389
+ this.log(`setup report: ${e?.message ?? e}`);
390
+ }
391
+ }
392
+ return !st.ok;
393
+ }
335
394
  async notifyBudget(t, me) {
336
395
  if (this.state.runs.budgetNotified[t.id])
337
396
  return;
@@ -717,8 +776,19 @@ export class Runner {
717
776
  return;
718
777
  try {
719
778
  const sha = await this.o.workspace.commit(rec.taskId, `ach: ${task.title ?? rec.taskId} (task ${rec.taskId})`.slice(0, 200));
720
- if (sha)
779
+ if (sha) {
721
780
  this.taskLog(rec.taskId, `runner committed ${sha}`);
781
+ // the commit joins the run's trace, so the panel can name what the run produced
782
+ const id = this.lastTrace.get(rec.taskId);
783
+ const q = id ? this.traceQueue.get(id) : undefined;
784
+ if (id && q) {
785
+ q.body.commit = sha;
786
+ this.persistTrace(id);
787
+ }
788
+ else if (id && this.o.client.putTrace)
789
+ await this.o.client.putTrace(rec.taskId, id, { commit: sha }).catch((e) => this.taskLog(rec.taskId, `trace commit: ${e?.message ?? e}`));
790
+ await this.flushTraces();
791
+ }
722
792
  }
723
793
  catch (e) {
724
794
  this.taskLog(rec.taskId, `commit failed: ${e?.message ?? e}`);
@@ -778,7 +848,22 @@ export class Runner {
778
848
  return;
779
849
  }
780
850
  if (!result.ok) {
781
- await client.fail(taskId, `runtime error: ${result.error ?? 'unknown'}`, rec.runId).catch((e) => this.log(`fail ${taskId}: ${e?.message ?? e}`));
851
+ const err = result.error ?? 'unknown';
852
+ // The machine, not the task: when the run died of a login or sandbox problem and the check now confirms it, the task goes
853
+ // back to the queue (the runner takes nothing more until the check passes, and the owner has been told) instead of failing.
854
+ if (this.o.sandboxCheck && (looksLikeSandboxError(err) || looksLikeLoginError(err))) {
855
+ this.sandboxRecheck = true;
856
+ if ((await this.sandboxBlocked()) && client.release) {
857
+ const why = this.sandbox?.st.fix?.en ?? this.sandbox?.st.detail ?? summarizeRuntimeError(err);
858
+ const done = await client.release(taskId, why, rec.runId).then(() => true, (e) => { this.log(`release ${taskId}: ${e?.message ?? e}`); return false; });
859
+ if (done) {
860
+ this.taskLog(taskId, `released: ${why}`);
861
+ this.state.drop(taskId);
862
+ return;
863
+ }
864
+ }
865
+ }
866
+ await client.fail(taskId, `runtime error: ${summarizeRuntimeError(err)}`, rec.runId).catch((e) => this.log(`fail ${taskId}: ${e?.message ?? e}`));
782
867
  this.state.drop(taskId);
783
868
  return;
784
869
  }
@@ -1032,22 +1117,33 @@ export class Runner {
1032
1117
  // adapter events, unless a tool call is still in flight.
1033
1118
  let lastEvent = Date.now();
1034
1119
  const inflight = new Set();
1035
- const hb = mode === 'work'
1036
- ? setInterval(() => {
1037
- this.heartbeat(rec).catch((e) => {
1038
- this.log(`heartbeat ${taskId}: ${e?.message ?? e}`);
1039
- // C3: 409 = this run no longer owns the task; stop working on it.
1040
- // Except when the task is completed / failed: that is the agent's own
1041
- // `ach complete` / `ach fail`; let the turn end so its usage is recorded.
1042
- const st = e instanceof HubError ? String(e.body?.status ?? '') : '';
1043
- if (is409(e) && !entry.reason && st !== 'completed' && st !== 'failed') {
1044
- entry.reason = 'lost';
1045
- this.taskLog(taskId, 'heartbeat 409: run lost the task; aborting');
1046
- entry.controller.abort();
1047
- }
1048
- });
1049
- }, this.o.heartbeatMs)
1050
- : undefined;
1120
+ // the execution trace of this run: what it thought, ran and printed, masked before it leaves this machine
1121
+ const startedAt = new Date();
1122
+ const recorder = new TraceRecorder([rec.runToken, ...(this.o.secrets ?? []), this.o.cfSecret ?? ''].filter(Boolean));
1123
+ let hb;
1124
+ hb =
1125
+ mode === 'work'
1126
+ ? setInterval(() => {
1127
+ this.heartbeat(rec).catch((e) => {
1128
+ this.log(`heartbeat ${taskId}: ${e?.message ?? e}`);
1129
+ // C3: 409 = this run no longer owns the task; stop working on it.
1130
+ // Except when the task is completed / failed: that is the agent's own
1131
+ // `ach complete` / `ach fail`; let the turn end so its usage is recorded.
1132
+ const st = e instanceof HubError ? String(e.body?.status ?? '') : '';
1133
+ if (is409(e) && !entry.reason && st !== 'completed' && st !== 'failed') {
1134
+ entry.reason = 'lost';
1135
+ this.taskLog(taskId, 'heartbeat 409: run lost the task; aborting');
1136
+ entry.controller.abort();
1137
+ }
1138
+ // the task is finished (the agent's own complete / fail) but the run goes on: say so in the trace and stop beating for it
1139
+ if (is409(e) && (st === 'completed' || st === 'failed' || st === 'awaiting_acceptance')) {
1140
+ recorder.mark(`task_${st}`);
1141
+ if (hb)
1142
+ clearInterval(hb);
1143
+ }
1144
+ });
1145
+ }, this.o.heartbeatMs)
1146
+ : undefined;
1051
1147
  const idle = setInterval(() => {
1052
1148
  if (!inflight.size && Date.now() - lastEvent > this.o.idleMs && !entry.reason) {
1053
1149
  entry.reason = 'idle';
@@ -1083,7 +1179,9 @@ export class Runner {
1083
1179
  if (e.type === 'session' && e.data && mode !== 'mention') {
1084
1180
  this.state.setSession(taskId, mode, { id: String(e.data), cwd: rec.workdir });
1085
1181
  }
1086
- this.taskLog(taskId, `${e.type} ${typeof e.data === 'string' ? e.data.slice(0, 500) : JSON.stringify(e.data)?.slice(0, 500)}`);
1182
+ recorder.event(e);
1183
+ // the log keeps its one line per event; what a tool printed is in the trace, not here
1184
+ this.taskLog(taskId, `${e.type} ${typeof e.data === 'string' ? e.data.slice(0, 500) : JSON.stringify(e.data, (k, v) => (k === 'output' ? undefined : v))?.slice(0, 500)}`);
1087
1185
  },
1088
1186
  });
1089
1187
  }
@@ -1102,8 +1200,104 @@ export class Runner {
1102
1200
  if (!this.fatal)
1103
1201
  await usage(Number(result.costUsd ?? 0), !!result.estimated).catch((e) => this.log(`usage ${taskId}: ${e?.message ?? e}`));
1104
1202
  this.taskLog(taskId, `${mode} run end ok=${result.ok} cost=${result.costUsd ?? 0} err=${result.error ?? ''}`);
1203
+ if (mode !== 'mention')
1204
+ await this.uploadTrace(taskId, rec.runId, mode, startedAt, result, recorder, !this.fatal);
1105
1205
  return result;
1106
1206
  }
1207
+ // ---- the execution trace -------------------------------------------------------------------------------------------------
1208
+ traceDir() {
1209
+ return join(this.o.home, 'traces');
1210
+ }
1211
+ /** Traces an earlier process could not upload are tried again. */
1212
+ loadPendingTraces() {
1213
+ try {
1214
+ for (const f of readdirSync(this.traceDir())) {
1215
+ if (!f.endsWith('.json'))
1216
+ continue;
1217
+ const j = JSON.parse(readFileSync(join(this.traceDir(), f), 'utf8'));
1218
+ if (j?.taskId && j?.traceId && j?.body)
1219
+ this.traceQueue.set(j.traceId, { taskId: j.taskId, body: j.body });
1220
+ }
1221
+ }
1222
+ catch {
1223
+ /* none */
1224
+ }
1225
+ }
1226
+ /** Build and send the trace of the run that just ended. Never throws; a failed upload is kept and retried. */
1227
+ async uploadTrace(taskId, runId, mode, startedAt, result, recorder, send) {
1228
+ if (!this.o.client.putTrace)
1229
+ return;
1230
+ try {
1231
+ const { steps, omitted } = recorder.finish();
1232
+ if (!steps.length && result.ok)
1233
+ return; // nothing happened worth auditing
1234
+ const traceId = `tr_${randomBytes(9).toString('base64url')}`;
1235
+ const body = {
1236
+ run_id: runId, mode, started: startedAt.toISOString(), ended: new Date().toISOString(), ok: !!result.ok,
1237
+ ...(result.error ? { error: redact(summarizeRuntimeError(result.error), [...(this.o.secrets ?? [])]).slice(0, 600) } : {}),
1238
+ steps, omitted,
1239
+ };
1240
+ this.lastTrace.set(taskId, traceId);
1241
+ this.traceQueue.set(traceId, { taskId, body });
1242
+ this.persistTrace(traceId);
1243
+ if (send)
1244
+ await this.flushTraces();
1245
+ }
1246
+ catch (e) {
1247
+ this.taskLog(taskId, `trace: ${e?.message ?? e}`);
1248
+ }
1249
+ }
1250
+ persistTrace(traceId) {
1251
+ const q = this.traceQueue.get(traceId);
1252
+ if (!q)
1253
+ return;
1254
+ try {
1255
+ mkdirSync(this.traceDir(), { recursive: true, mode: 0o700 });
1256
+ writeFileSync(join(this.traceDir(), `${traceId}.json`), JSON.stringify({ traceId, ...q }), { mode: 0o600 });
1257
+ }
1258
+ catch {
1259
+ /* kept in memory */
1260
+ }
1261
+ }
1262
+ /** Send what is waiting (a few at a time). A trace never holds up a task: failures are logged and tried again on the next poll. */
1263
+ async flushTraces() {
1264
+ const put = this.o.client.putTrace;
1265
+ if (!put || !this.traceQueue.size)
1266
+ return;
1267
+ let n = 0;
1268
+ for (const [id, q] of [...this.traceQueue]) {
1269
+ if (n++ >= 5)
1270
+ break;
1271
+ try {
1272
+ await put.call(this.o.client, q.taskId, id, q.body);
1273
+ this.traceQueue.delete(id);
1274
+ try {
1275
+ unlinkSync(join(this.traceDir(), `${id}.json`));
1276
+ }
1277
+ catch {
1278
+ /* already gone */
1279
+ }
1280
+ }
1281
+ catch (e) {
1282
+ const code = e instanceof HubError ? e.status : 0;
1283
+ if (is401(e))
1284
+ return this.authFailed('trace');
1285
+ // an older hub (404), a task that is gone or not ours (403/404/409): not worth keeping
1286
+ if (code === 404 || code === 403 || code === 409 || code === 400) {
1287
+ this.traceQueue.delete(id);
1288
+ try {
1289
+ unlinkSync(join(this.traceDir(), `${id}.json`));
1290
+ }
1291
+ catch {
1292
+ /* */
1293
+ }
1294
+ this.taskLog(q.taskId, `trace not taken (${code}): ${e?.message ?? e}`);
1295
+ }
1296
+ else
1297
+ this.taskLog(q.taskId, `trace upload failed, will retry: ${e?.message ?? e}`);
1298
+ }
1299
+ }
1300
+ }
1107
1301
  /** Polls until stop() or a 401 (B7: resolves 'unauthorized' after aborting every job). */
1108
1302
  async start() {
1109
1303
  this.recover();
@@ -0,0 +1,97 @@
1
+ /**
2
+ * design §27: does the agent tool's sandbox work on this machine?
3
+ *
4
+ * Probe, do not guess: run the mechanism the tool uses (bubblewrap on Linux for Claude Code, Seatbelt on macOS, `codex
5
+ * sandbox` for Codex) on a throwaway directory, once with a command that must succeed (write inside it) and once with one
6
+ * that must be refused (write in the home directory). Passing means the sandbox starts and enforces. No model call.
7
+ *
8
+ * When it fails, `sandboxFix` maps what it can see about the machine (package manager, AppArmor, WSL) to the commands
9
+ * that fix it. Nothing here runs them: setup asks first, doctor and the runner print them.
10
+ */
11
+ export type SandboxTool = 'claude' | 'codex';
12
+ export type SandboxProblem =
13
+ /** a package the sandbox needs is not installed (`missing` names them) */
14
+ 'missing'
15
+ /** Ubuntu's AppArmor rule stops unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1) */
16
+ | 'userns-blocked'
17
+ /** the kernel has user namespaces turned off */
18
+ | 'userns-off'
19
+ /** the sandbox ran the command but let it write outside its directory */
20
+ | 'not-enforced'
21
+ /** Claude Code has no sandbox on native Windows */
22
+ | 'windows-native'
23
+ /** an OS neither tool sandboxes */
24
+ | 'unsupported'
25
+ /** the tool is not logged in on this machine (envcheck.ts): not the sandbox, but the same "takes no tasks" state */
26
+ | 'not-logged-in'
27
+ /** anything else: `detail` has the first line of the error */
28
+ | 'failed';
29
+ export interface SandboxFix {
30
+ /** One sentence: what is wrong and what the commands do. */
31
+ en: string;
32
+ zh: string;
33
+ /** Shell lines to run, in order (sudo included). Empty: there is nothing safe to run; the sentence says what to do. */
34
+ commands: string[];
35
+ }
36
+ export interface SandboxStatus {
37
+ tool: SandboxTool;
38
+ ok: boolean;
39
+ /** ok, but the probe could not run (an older Codex without `codex sandbox`): not blocking, doctor warns. */
40
+ unverified?: boolean;
41
+ problem?: SandboxProblem;
42
+ missing?: string[];
43
+ detail?: string;
44
+ fix?: SandboxFix;
45
+ }
46
+ export type PkgManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk';
47
+ /** What the fix depends on, read from the machine (tests pass their own). */
48
+ export interface Host {
49
+ platform: NodeJS.Platform;
50
+ wsl: boolean;
51
+ /** /etc/os-release ID and ID_LIKE */
52
+ distro?: string[];
53
+ pkg?: PkgManager;
54
+ /** kernel.apparmor_restrict_unprivileged_userns ('1' = on) */
55
+ appArmorUserns?: string;
56
+ /** an AppArmor profile that lets /usr/bin/bwrap create user namespaces is installed */
57
+ bwrapProfile: boolean;
58
+ /** absolute paths, when found on PATH */
59
+ bwrap?: string;
60
+ socat?: string;
61
+ }
62
+ export type RunResult = {
63
+ code: number;
64
+ stdout: string;
65
+ stderr: string;
66
+ };
67
+ export type Run = (file: string, args: string[], opts: {
68
+ env: NodeJS.ProcessEnv;
69
+ cwd?: string;
70
+ }) => Promise<RunResult>;
71
+ export declare const defaultRun: Run;
72
+ export declare function onPath(name: string, env: NodeJS.ProcessEnv): string | undefined;
73
+ export declare function pkgFor(distro: string[] | undefined): PkgManager | undefined;
74
+ /** Read the machine (Linux files; elsewhere only the platform matters). */
75
+ export declare function detectHost(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): Host;
76
+ /** The Codex CLI the runner's SDK uses (the @openai/codex package next to this CLI), else `codex` on PATH. */
77
+ export declare function codexCommand(env?: NodeJS.ProcessEnv): string[] | undefined;
78
+ /** What a failed sandbox start said, as a problem. */
79
+ export declare function classify(stderr: string, host: Host): SandboxProblem;
80
+ export interface ProbeOptions {
81
+ env?: NodeJS.ProcessEnv;
82
+ platform?: NodeJS.Platform;
83
+ host?: Host;
84
+ run?: Run;
85
+ /** where the forbidden write goes (default: the home directory) */
86
+ home?: string;
87
+ /** the Codex CLI (default: codexCommand()) */
88
+ codex?: string[];
89
+ }
90
+ /** Probe `tool`'s sandbox on this machine. Never throws. */
91
+ export declare function probeSandbox(tool: SandboxTool, o?: ProbeOptions): Promise<SandboxStatus>;
92
+ /** Lets the system bubblewrap (only that path) create user namespaces under Ubuntu's AppArmor rule; never machine-wide. */
93
+ export declare const BWRAP_APPARMOR: string;
94
+ /** The fix for a failed probe on this host. */
95
+ export declare function sandboxFix(st: SandboxStatus, host: Host): SandboxFix;
96
+ /** Does a run's error look like the sandbox failing (the runner then probes again)? */
97
+ export declare function looksLikeSandboxError(s: string): boolean;
@@ -0,0 +1,236 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { homedir, tmpdir } from 'node:os';
6
+ import { delimiter, dirname, join } from 'node:path';
7
+ export const defaultRun = (file, args, opts) => new Promise((res) => {
8
+ execFile(file, args, { env: opts.env, cwd: opts.cwd, timeout: 20_000, encoding: 'utf8', maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
9
+ const code = err ? (typeof err.code === 'number' ? err.code : err.code === 'ENOENT' ? 127 : 1) : 0;
10
+ res({ code, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') || (err?.code === 'ENOENT' ? `${file}: not found` : '') });
11
+ });
12
+ });
13
+ export function onPath(name, env) {
14
+ for (const d of (env.PATH ?? '').split(delimiter)) {
15
+ if (!d)
16
+ continue;
17
+ const f = join(d, name);
18
+ if (existsSync(f))
19
+ return f;
20
+ }
21
+ return undefined;
22
+ }
23
+ function read(f) {
24
+ try {
25
+ return readFileSync(f, 'utf8');
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ const PKG_BY_DISTRO = [
32
+ [/^(debian|ubuntu|linuxmint|pop|elementary|raspbian|kali)$/, 'apt'],
33
+ [/^(fedora|rhel|centos|rocky|almalinux|amzn|ol)$/, 'dnf'],
34
+ [/^(arch|manjaro|endeavouros)$/, 'pacman'],
35
+ [/^(opensuse.*|suse|sles)$/, 'zypper'],
36
+ [/^alpine$/, 'apk'],
37
+ ];
38
+ export function pkgFor(distro) {
39
+ for (const id of distro ?? [])
40
+ for (const [re, pm] of PKG_BY_DISTRO)
41
+ if (re.test(id))
42
+ return pm;
43
+ return undefined;
44
+ }
45
+ /** Read the machine (Linux files; elsewhere only the platform matters). */
46
+ export function detectHost(env = process.env, platform = process.platform) {
47
+ const h = { platform, wsl: false, bwrapProfile: false };
48
+ if (platform !== 'linux')
49
+ return h;
50
+ const osr = read('/etc/os-release') ?? '';
51
+ const field = (k) => (osr.match(new RegExp(`^${k}=("?)([^"\\n]*)\\1`, 'm'))?.[2] ?? '').toLowerCase();
52
+ h.distro = [field('ID'), ...field('ID_LIKE').split(/\s+/)].filter(Boolean);
53
+ h.pkg = pkgFor(h.distro);
54
+ h.wsl = /microsoft/i.test(read('/proc/version') ?? '') || !!env.WSL_DISTRO_NAME;
55
+ h.appArmorUserns = read('/proc/sys/kernel/apparmor_restrict_unprivileged_userns')?.trim();
56
+ h.bwrapProfile = existsSync('/etc/apparmor.d/bwrap') || existsSync('/etc/apparmor.d/bwrap-userns-restrict');
57
+ h.bwrap = onPath('bwrap', env);
58
+ h.socat = onPath('socat', env);
59
+ return h;
60
+ }
61
+ /** The Codex CLI the runner's SDK uses (the @openai/codex package next to this CLI), else `codex` on PATH. */
62
+ export function codexCommand(env = process.env) {
63
+ try {
64
+ const req = createRequire(import.meta.url);
65
+ const f = join(dirname(req.resolve('@openai/codex/package.json')), 'bin', 'codex.js');
66
+ if (existsSync(f))
67
+ return [process.execPath, f];
68
+ }
69
+ catch {
70
+ /* not installed next to us */
71
+ }
72
+ const p = onPath(process.platform === 'win32' ? 'codex.cmd' : 'codex', env);
73
+ return p ? [p] : undefined;
74
+ }
75
+ /** What a failed sandbox start said, as a problem. */
76
+ export function classify(stderr, host) {
77
+ if (/No permissions to create new namespace|max_user_namespaces|user namespaces? (are|is) (disabled|not (enabled|supported))/i.test(stderr))
78
+ return 'userns-off';
79
+ // bwrap's own namespace setup failing: under Ubuntu's AppArmor rule that is the rule; elsewhere namespaces are off
80
+ if (/setting up uid map|RTM_NEWADDR|loopback: Failed|creating new namespace|unshare/i.test(stderr))
81
+ return host.appArmorUserns === '1' ? 'userns-blocked' : 'userns-off';
82
+ // a bare "not permitted" from bwrap is the AppArmor rule only when that rule is on; else it is something else
83
+ if (host.appArmorUserns === '1' && /^bwrap: .*(Operation not permitted|Permission denied)/im.test(stderr))
84
+ return 'userns-blocked';
85
+ return 'failed';
86
+ }
87
+ function firstLine(s) {
88
+ return (s.split('\n').map((l) => l.trim()).find(Boolean) ?? '').slice(0, 200);
89
+ }
90
+ /** The two writes: inside the sandbox's directory (must work) and in the home directory (must be refused). */
91
+ function probeScript() {
92
+ return 'touch "$1/ok" || exit 3; if touch "$2" 2>/dev/null; then exit 4; fi; exit 0';
93
+ }
94
+ /** Probe `tool`'s sandbox on this machine. Never throws. */
95
+ export async function probeSandbox(tool, o = {}) {
96
+ const env = o.env ?? process.env;
97
+ // the CLI's own tests (scripts/test-sandbox.mjs) run on machines whose sandbox is not what they test
98
+ if ((env.ACH_SANDBOX_CHECK ?? process.env.ACH_SANDBOX_CHECK) === 'off' && !o.run)
99
+ return { tool, ok: true, unverified: true, detail: 'skipped (ACH_SANDBOX_CHECK=off)' };
100
+ const platform = o.platform ?? process.platform;
101
+ const host = o.host ?? detectHost(env, platform);
102
+ const run = o.run ?? defaultRun;
103
+ const st = await probe(tool, { env, platform, host, run, home: o.home ?? homedir(), codex: o.codex });
104
+ if (!st.ok)
105
+ st.fix = sandboxFix(st, host);
106
+ return st;
107
+ }
108
+ async function probe(tool, o) {
109
+ const { platform, host, run, env } = o;
110
+ if (platform === 'win32') {
111
+ if (tool === 'claude')
112
+ return { tool, ok: false, problem: 'windows-native' };
113
+ }
114
+ else if (platform !== 'linux' && platform !== 'darwin') {
115
+ return { tool, ok: false, problem: 'unsupported', detail: platform };
116
+ }
117
+ if (platform === 'linux' && tool === 'claude') {
118
+ const missing = [...(host.bwrap ? [] : ['bubblewrap']), ...(host.socat ? [] : ['socat'])];
119
+ if (missing.length)
120
+ return { tool, ok: false, problem: 'missing', missing };
121
+ }
122
+ let dir;
123
+ try {
124
+ dir = mkdtempSync(join(tmpdir(), 'ach-sandbox-probe-'));
125
+ }
126
+ catch (e) {
127
+ return { tool, ok: false, problem: 'failed', detail: `cannot make a temporary directory: ${e?.message ?? e}` };
128
+ }
129
+ const outside = join(o.home, `.ach-sandbox-probe-${randomBytes(4).toString('hex')}`);
130
+ const sh = ['/bin/sh', '-c', probeScript(), 'probe', dir, outside];
131
+ try {
132
+ let r;
133
+ if (tool === 'claude' && platform === 'linux') {
134
+ // what Claude Code's sandbox does: a new session, no network, the filesystem read-only except the work directory
135
+ r = await run(host.bwrap, ['--new-session', '--die-with-parent', '--unshare-net', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--bind', dir, dir, '--', ...sh], { env });
136
+ }
137
+ else if (tool === 'claude') {
138
+ const q = (p) => JSON.stringify(p);
139
+ const profile = `(version 1)(allow default)(deny file-write* (subpath ${q(o.home)}))(allow file-write* (subpath ${q(dir)}))`;
140
+ r = await run('/usr/bin/sandbox-exec', ['-p', profile, ...sh], { env });
141
+ }
142
+ else {
143
+ const codex = o.codex ?? codexCommand(env);
144
+ if (!codex)
145
+ return { tool, ok: true, unverified: true, detail: 'Codex was not found' };
146
+ const cmd = platform === 'win32' ? ['cmd', '/c', `type nul > "${join(dir, 'ok')}" && (type nul > "${outside}" && exit 4 || exit 0)`] : sh;
147
+ // the work directory is the cwd (`-C` would also need a named permission profile)
148
+ r = await run(codex[0], [...codex.slice(1), 'sandbox', '-c', 'sandbox_mode="workspace-write"', '--', ...cmd], { env, cwd: dir });
149
+ if (r.code !== 0 && /unrecognized subcommand|unexpected argument|Usage: codex \[OPTIONS\]/i.test(r.stderr)) {
150
+ return { tool, ok: true, unverified: true, detail: 'this Codex has no `codex sandbox` command' };
151
+ }
152
+ }
153
+ if (existsSync(outside) || r.code === 4)
154
+ return { tool, ok: false, problem: 'not-enforced', detail: `a sandboxed command could write ${outside}` };
155
+ if (r.code === 0 && existsSync(join(dir, 'ok')))
156
+ return { tool, ok: true };
157
+ const err = `${r.stderr}\n${r.stdout}`;
158
+ if (r.code === 127 && platform === 'linux' && tool === 'claude')
159
+ return { tool, ok: false, problem: 'missing', missing: ['bubblewrap'] };
160
+ return { tool, ok: false, problem: classify(err, host), detail: firstLine(err) || `exit ${r.code}` };
161
+ }
162
+ finally {
163
+ rmSync(dir, { recursive: true, force: true });
164
+ rmSync(outside, { force: true });
165
+ }
166
+ }
167
+ const INSTALL = {
168
+ apt: (p) => `sudo apt-get install -y ${p.join(' ')}`,
169
+ dnf: (p) => `sudo dnf install -y ${p.join(' ')}`,
170
+ pacman: (p) => `sudo pacman -S --needed --noconfirm ${p.join(' ')}`,
171
+ zypper: (p) => `sudo zypper install -y ${p.join(' ')}`,
172
+ apk: (p) => `sudo apk add ${p.join(' ')}`,
173
+ };
174
+ /** Lets the system bubblewrap (only that path) create user namespaces under Ubuntu's AppArmor rule; never machine-wide. */
175
+ export const BWRAP_APPARMOR = `printf '%s\\n' 'abi <abi/4.0>,' 'include <tunables/global>' 'profile bwrap /usr/bin/bwrap flags=(unconfined) {' ' userns,' ' include if exists <local/bwrap>' '}' ` +
176
+ `| sudo tee /etc/apparmor.d/bwrap >/dev/null && sudo apparmor_parser -r /etc/apparmor.d/bwrap`;
177
+ /** The fix for a failed probe on this host. */
178
+ export function sandboxFix(st, host) {
179
+ const name = st.tool === 'claude' ? 'Claude Code' : 'Codex';
180
+ switch (st.problem) {
181
+ case 'not-logged-in': {
182
+ const cmd = st.tool === 'claude' ? 'claude auth login' : 'codex login';
183
+ return { en: `${name} is not logged in on this machine: run \`${cmd}\` as the user that runs this agent.`, zh: `${name} 在这台机器上没有登录:以运行这个智能体的用户身份执行 \`${cmd}\`。`, commands: [cmd] };
184
+ }
185
+ case 'windows-native':
186
+ return {
187
+ en: 'Claude Code has no sandbox on native Windows: run this agent inside WSL2 (wsl --install, then join again there).',
188
+ zh: 'Claude Code 在原生 Windows 上没有沙箱:请在 WSL2 里运行这个智能体(wsl --install,然后在 WSL 里重新 join)。',
189
+ commands: [],
190
+ };
191
+ case 'unsupported':
192
+ return { en: `${name} has no sandbox on ${st.detail ?? 'this system'}.`, zh: `${name} 在 ${st.detail ?? '这个系统'} 上没有沙箱。`, commands: [] };
193
+ case 'userns-off':
194
+ return {
195
+ en: 'This kernel does not allow user namespaces, which the sandbox needs. Ask whoever runs this machine to allow them (user.max_user_namespaces).',
196
+ zh: '这台机器的内核不允许用户命名空间,沙箱需要它。请让管理这台机器的人开启(user.max_user_namespaces)。',
197
+ commands: [],
198
+ };
199
+ case 'not-enforced':
200
+ return { en: `${name}'s sandbox started but did not stop a write outside its directory: do not run this agent here.`, zh: `${name} 的沙箱能启动,但没有拦住目录外的写入:不要在这台机器上运行这个智能体。`, commands: [] };
201
+ case 'missing':
202
+ case 'userns-blocked': {
203
+ if (host.platform !== 'linux')
204
+ break;
205
+ const pkgs = st.tool === 'claude' ? ['bubblewrap', 'socat'] : ['bubblewrap'];
206
+ // the profile only covers /usr/bin/bwrap: a bwrap from elsewhere (a user install) means installing the system one
207
+ const need = pkgs.filter((p) => (p === 'socat' ? !host.socat : !host.bwrap || host.bwrap !== '/usr/bin/bwrap' || st.missing?.includes(p)));
208
+ const appArmor = host.appArmorUserns === '1' && (st.problem === 'userns-blocked' || !host.bwrapProfile);
209
+ const commands = [];
210
+ if (need.length) {
211
+ if (!host.pkg) {
212
+ return {
213
+ en: `Install ${need.join(' and ')} with this system's package manager${appArmor ? ', then allow /usr/bin/bwrap in AppArmor (see the Claude Code sandbox docs)' : ''}.`,
214
+ zh: `请用系统的包管理器安装 ${need.join(' 和 ')}${appArmor ? ',再在 AppArmor 里放行 /usr/bin/bwrap(见 Claude Code 沙箱文档)' : ''}。`,
215
+ commands: [],
216
+ };
217
+ }
218
+ commands.push(INSTALL[host.pkg](need));
219
+ }
220
+ if (appArmor)
221
+ commands.push(BWRAP_APPARMOR);
222
+ const what = [need.length ? `install ${need.join(' and ')}` : '', appArmor ? 'let /usr/bin/bwrap create user namespaces under AppArmor (this program only)' : ''].filter(Boolean);
223
+ const zhWhat = [need.length ? `安装 ${need.join(' 和 ')}` : '', appArmor ? '在 AppArmor 里只放行 /usr/bin/bwrap 创建用户命名空间' : ''].filter(Boolean);
224
+ return {
225
+ en: `${name}'s sandbox cannot start on this machine${st.missing?.length ? ` (${st.missing.join(', ')} missing)` : ''}. Fix: ${what.join(', then ')}.`,
226
+ zh: `${name} 的沙箱在这台机器上起不来${st.missing?.length ? `(缺 ${st.missing.join('、')})` : ''}。修复:${zhWhat.join(',然后')}。`,
227
+ commands,
228
+ };
229
+ }
230
+ }
231
+ return { en: `${name}'s sandbox cannot start on this machine: ${st.detail ?? 'unknown error'}.`, zh: `${name} 的沙箱在这台机器上起不来:${st.detail ?? '未知错误'}。`, commands: [] };
232
+ }
233
+ /** Does a run's error look like the sandbox failing (the runner then probes again)? */
234
+ export function looksLikeSandboxError(s) {
235
+ return /sandbox|bwrap|bubblewrap|socat|seatbelt|user namespace|uid map|RTM_NEWADDR/i.test(s);
236
+ }