@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.
- package/README.md +5 -1
- package/dist/src/adapters/claude.d.ts +19 -0
- package/dist/src/adapters/claude.js +52 -1
- package/dist/src/adapters/codex.d.ts +11 -0
- package/dist/src/adapters/codex.js +34 -2
- package/dist/src/adapters/fake.d.ts +10 -0
- package/dist/src/adapters/fake.js +9 -0
- package/dist/src/adapters/paths.d.ts +9 -1
- package/dist/src/adapters/paths.js +28 -3
- package/dist/src/adapters/types.d.ts +3 -2
- package/dist/src/args.d.ts +1 -1
- package/dist/src/args.js +18 -9
- package/dist/src/cli.js +81 -17
- package/dist/src/client.d.ts +48 -4
- package/dist/src/client.js +41 -5
- package/dist/src/desknotify.d.ts +2 -2
- package/dist/src/desknotify.js +2 -2
- package/dist/src/doctor.d.ts +4 -0
- package/dist/src/doctor.js +12 -0
- package/dist/src/envcheck.d.ts +22 -0
- package/dist/src/envcheck.js +98 -0
- package/dist/src/mcp.d.ts +1 -1
- package/dist/src/mcp.js +24 -12
- package/dist/src/names.d.ts +6 -1
- package/dist/src/names.js +15 -2
- package/dist/src/onboard.d.ts +11 -2
- package/dist/src/onboard.js +54 -2
- package/dist/src/prompts.d.ts +3 -3
- package/dist/src/prompts.js +4 -4
- package/dist/src/redact.d.ts +5 -0
- package/dist/src/redact.js +41 -0
- package/dist/src/runner/runner.d.ts +32 -1
- package/dist/src/runner/runner.js +214 -20
- package/dist/src/sandboxcheck.d.ts +97 -0
- package/dist/src/sandboxcheck.js +236 -0
- package/dist/src/service.d.ts +9 -3
- package/dist/src/service.js +39 -1
- package/dist/src/trace.d.ts +51 -0
- package/dist/src/trace.js +137 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
+
}
|