@koalaauto/agentchannel 3.10.0 → 3.11.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 +4 -0
- package/dist/src/cli.js +21 -6
- package/dist/src/client.d.ts +14 -0
- package/dist/src/client.js +7 -0
- package/dist/src/doctor.d.ts +4 -0
- package/dist/src/doctor.js +12 -0
- package/dist/src/onboard.d.ts +11 -2
- package/dist/src/onboard.js +54 -2
- package/dist/src/runner/runner.d.ts +17 -1
- package/dist/src/runner/runner.js +53 -0
- package/dist/src/sandboxcheck.d.ts +94 -0
- package/dist/src/sandboxcheck.js +232 -0
- package/dist/src/service.d.ts +9 -3
- package/dist/src/service.js +39 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,10 @@ installed or changed.
|
|
|
42
42
|
--interactive-first` for the current directory; `--notify` only on a machine
|
|
43
43
|
with a desktop), and starts it. Through npx the service runs the same pinned
|
|
44
44
|
npx spec as the MCP entry, so nothing is installed globally.
|
|
45
|
+
- When the agent tool's sandbox does not work on this machine (on Linux: bubblewrap and
|
|
46
|
+
socat missing, or Ubuntu's AppArmor rule), it shows the commands that fix it and runs
|
|
47
|
+
them only after you say yes in a terminal (they use sudo). Without a terminal it only
|
|
48
|
+
prints them. The background helper takes no tasks until the sandbox works.
|
|
45
49
|
- `--dry-run` prints the plan; the invite is not used and nothing is written.
|
|
46
50
|
- `--yes` skips the question.
|
|
47
51
|
|
package/dist/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { execFileSync, execSync, spawn } from 'node:child_process';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
4
|
import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
@@ -415,7 +415,7 @@ async function dispatch(p, io) {
|
|
|
415
415
|
: `joined ${inv.hub} as agent ${me.name}${me.owner_name ? ` (owner ${me.owner_name})` : ''}; key stored in the ${where === 'keychain' ? 'OS keychain' : 'agent-key file (0600)'}.`);
|
|
416
416
|
const { runSetup, doneLine } = await import('./onboard.js');
|
|
417
417
|
const { defaultExec } = await import('./doctor.js');
|
|
418
|
-
const r = await runSetup({ env, out: io.out, err: io.err, exec: io.exec ?? defaultExec, ask: io.ask, cwd: io.cwd ?? process.cwd() }, { yes: !!v.yes, service: !!v.service, runtime: typeof me.runtime === 'string' ? me.runtime : undefined, zh });
|
|
418
|
+
const r = await runSetup({ env, out: io.out, err: io.err, exec: io.exec ?? defaultExec, ask: io.ask, cwd: io.cwd ?? process.cwd(), ...(io.ask ? { runTty: (line) => runInTerminal(line, env) } : {}) }, { yes: !!v.yes, service: !!v.service, runtime: typeof me.runtime === 'string' ? me.runtime : undefined, zh });
|
|
419
419
|
out(r.failed.length ? (zh ? `部分步骤失败(${r.failed.join(', ')}):运行 ach doctor 查看怎么修。` : `Some steps failed (${r.failed.join(', ')}): run ach doctor to see how to fix them.`) : r.tools.length ? doneLine(r.tools, zh) : zh ? '密钥已保存,但还没有接入任何工具(见上面)。' : 'The key is stored, but no tool is connected yet (see above).');
|
|
420
420
|
return r.failed.length ? 1 : 0;
|
|
421
421
|
}
|
|
@@ -1485,6 +1485,14 @@ function linkOf(env, t) {
|
|
|
1485
1485
|
function splitList(v) {
|
|
1486
1486
|
return v.split(',').map((x) => x.trim()).filter(Boolean);
|
|
1487
1487
|
}
|
|
1488
|
+
/** A shell line with this terminal's stdin/stdout/stderr (sudo asks for its password there); its exit code. */
|
|
1489
|
+
function runInTerminal(line, env) {
|
|
1490
|
+
return new Promise((res) => {
|
|
1491
|
+
const c = spawn('/bin/sh', ['-c', line], { env, stdio: 'inherit' });
|
|
1492
|
+
c.on('error', () => res(127));
|
|
1493
|
+
c.on('exit', (code) => res(code ?? 1));
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1488
1496
|
function shq(s) {
|
|
1489
1497
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1490
1498
|
}
|
|
@@ -1576,6 +1584,10 @@ async function runCommand(p, io) {
|
|
|
1576
1584
|
return shim ? { ...env, PATH: `${shim}${delimiter}${env.PATH ?? ''}` } : env;
|
|
1577
1585
|
})(),
|
|
1578
1586
|
log: io.err,
|
|
1587
|
+
sandboxCheck: async () => {
|
|
1588
|
+
const { probeSandbox } = await import('./sandboxcheck.js');
|
|
1589
|
+
return probeSandbox(runtime === 'codex' ? 'codex' : 'claude', { env });
|
|
1590
|
+
},
|
|
1579
1591
|
});
|
|
1580
1592
|
io.err(`ach run: agent ${me.name}, runtime ${runtime}, perm min(${me.perm_level}, ${perm}), dir ${dir}` +
|
|
1581
1593
|
(Object.keys(repos).length ? `, repos ${Object.entries(repos).map(([k, x]) => `${k}=${x}`).join(' ')}` : '') +
|
|
@@ -1666,8 +1678,8 @@ async function installService(p, io, o) {
|
|
|
1666
1678
|
const { isBundledBin, npmSpec, whichAll } = await import('./invite.js');
|
|
1667
1679
|
const env = io.env;
|
|
1668
1680
|
const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
|
|
1669
|
-
if (platform !== 'darwin' && platform !== 'linux')
|
|
1670
|
-
throw new UsageError(`run --install-service: unsupported platform ${platform} (macOS
|
|
1681
|
+
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32')
|
|
1682
|
+
throw new UsageError(`run --install-service: unsupported platform ${platform} (macOS, Linux and Windows only)`);
|
|
1671
1683
|
const v = p.values;
|
|
1672
1684
|
const args = ['--dir', o.dir];
|
|
1673
1685
|
for (const [name, path] of Object.entries(o.repos))
|
|
@@ -1704,14 +1716,17 @@ async function installService(p, io, o) {
|
|
|
1704
1716
|
args,
|
|
1705
1717
|
env: svcEnv,
|
|
1706
1718
|
logFile: join(o.home, 'logs', 'runner.log'),
|
|
1707
|
-
}, env.ACH_SERVICE_DIR || undefined);
|
|
1719
|
+
}, env.ACH_SERVICE_DIR || undefined, env);
|
|
1708
1720
|
writeServiceFile(f);
|
|
1709
1721
|
const load = !env.ACH_SERVICE_DIR;
|
|
1710
1722
|
let failed = '';
|
|
1711
1723
|
if (load) {
|
|
1712
1724
|
for (const c of f.start) {
|
|
1713
1725
|
try {
|
|
1714
|
-
|
|
1726
|
+
if (platform === 'win32')
|
|
1727
|
+
execSync(c, { env, stdio: ['ignore', 'ignore', 'pipe'] }); // cmd.exe
|
|
1728
|
+
else
|
|
1729
|
+
execFileSync('/bin/sh', ['-c', c], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
1715
1730
|
}
|
|
1716
1731
|
catch (e) {
|
|
1717
1732
|
failed = `${c}: ${String(e?.stderr ?? e?.message ?? e).trim().split('\n')[0]}`;
|
package/dist/src/client.d.ts
CHANGED
|
@@ -379,6 +379,20 @@ export declare class Client {
|
|
|
379
379
|
/** The total size of a work prompt (Memory trial metrics). */
|
|
380
380
|
promptSize(taskId: string, run_id: string, chars: number, parts?: Record<string, number>): Promise<any>;
|
|
381
381
|
usage(taskId: string, run_id: string, cost_usd: number, estimated: boolean): Promise<any>;
|
|
382
|
+
/**
|
|
383
|
+
* design §27: what keeps this agent's runner from taking tasks on its machine (null: nothing). The hub shows it as
|
|
384
|
+
* "needs setup" with the fix.
|
|
385
|
+
*/
|
|
386
|
+
reportSetup(sandbox: {
|
|
387
|
+
tool: string;
|
|
388
|
+
problem: string;
|
|
389
|
+
detail?: string;
|
|
390
|
+
fix?: {
|
|
391
|
+
en: string;
|
|
392
|
+
zh: string;
|
|
393
|
+
commands: string[];
|
|
394
|
+
};
|
|
395
|
+
} | null): Promise<any>;
|
|
382
396
|
/** 409 when the run lost the task. Carries a fresh `run_token` when the old one is near expiry (C5). */
|
|
383
397
|
heartbeat(taskId: string, run_id: string): Promise<{
|
|
384
398
|
ok: boolean;
|
package/dist/src/client.js
CHANGED
|
@@ -297,6 +297,13 @@ export class Client {
|
|
|
297
297
|
async usage(taskId, run_id, cost_usd, estimated) {
|
|
298
298
|
return this.ach('POST', `/tasks/${enc(taskId)}/usage`, { run_id, cost_usd, estimated });
|
|
299
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* design §27: what keeps this agent's runner from taking tasks on its machine (null: nothing). The hub shows it as
|
|
302
|
+
* "needs setup" with the fix.
|
|
303
|
+
*/
|
|
304
|
+
async reportSetup(sandbox) {
|
|
305
|
+
return this.ach('PUT', '/me/setup', { sandbox });
|
|
306
|
+
}
|
|
300
307
|
/** 409 when the run lost the task. Carries a fresh `run_token` when the old one is near expiry (C5). */
|
|
301
308
|
async heartbeat(taskId, run_id) {
|
|
302
309
|
return this.ach('POST', `/tasks/${enc(taskId)}/heartbeat`, { run_id });
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -49,6 +49,10 @@ export declare function runDoctor(o: {
|
|
|
49
49
|
fetch?: FetchLike;
|
|
50
50
|
exec?: ExecLike;
|
|
51
51
|
self?: string;
|
|
52
|
+
/** the sandbox probe (tests) */
|
|
53
|
+
sandbox?: (tool: 'claude' | 'codex', o: {
|
|
54
|
+
env: NodeJS.ProcessEnv;
|
|
55
|
+
}) => Promise<import('./sandboxcheck.js').SandboxStatus>;
|
|
52
56
|
}): Promise<DoctorCheck[]>;
|
|
53
57
|
/**
|
|
54
58
|
* Command, args and AGENTCHANNEL_HOME of an MCP entry from `claude mcp get`
|
package/dist/src/doctor.js
CHANGED
|
@@ -247,6 +247,7 @@ export async function runDoctor(o) {
|
|
|
247
247
|
const { serviceFile, serviceLabel } = await import('./service.js');
|
|
248
248
|
const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
|
|
249
249
|
if (platform === 'darwin' || platform === 'linux') {
|
|
250
|
+
// (Windows: the Startup script has no status command; doctor says nothing rather than guess)
|
|
250
251
|
const f = serviceFile({ platform, agentName, command: [], args: [], env: {}, logFile: '' }, env.ACH_SERVICE_DIR || undefined);
|
|
251
252
|
if (!existsSync(f.path))
|
|
252
253
|
add('background', 'info', 'no background runner on this machine (optional: ach run --dir <repo> --install-service --interactive-first)');
|
|
@@ -299,6 +300,17 @@ export async function runDoctor(o) {
|
|
|
299
300
|
if (codexPath)
|
|
300
301
|
await mcpCheck('codex', codexPath);
|
|
301
302
|
}
|
|
303
|
+
// design §27: the agent tool's sandbox, probed for real (every run that answers a question or an @mention is sandboxed)
|
|
304
|
+
const { probeSandbox } = await import('./sandboxcheck.js');
|
|
305
|
+
const tools = runtime === 'codex' ? ['codex'] : runtime === 'claude' ? ['claude'] : [...(claude ? ['claude'] : []), ...(codexPath || bundled ? ['codex'] : [])];
|
|
306
|
+
for (const t of tools) {
|
|
307
|
+
const st = await (o.sandbox ?? probeSandbox)(t, { env });
|
|
308
|
+
const name = `${t} sandbox`;
|
|
309
|
+
if (st.ok)
|
|
310
|
+
add(name, st.unverified ? 'info' : 'ok', st.unverified ? `not checked: ${st.detail ?? 'unknown'}` : 'starts, and stops writes outside the task directory');
|
|
311
|
+
else
|
|
312
|
+
add(name, runtime ? 'fail' : 'warn', `${st.fix?.en ?? st.detail ?? st.problem}${runtime ? ' The background runner takes no tasks until this is fixed.' : ''}`, 'machine', st.fix?.commands.length ? st.fix.commands.join('\n ') : st.fix?.en);
|
|
313
|
+
}
|
|
302
314
|
return out;
|
|
303
315
|
/**
|
|
304
316
|
* The `ach` MCP entry as the runtime itself reports it (`<runtime> mcp get
|
package/dist/src/onboard.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ExecLike } from './doctor.js';
|
|
2
|
+
import { type SandboxStatus, type SandboxTool } from './sandboxcheck.js';
|
|
2
3
|
/**
|
|
3
4
|
* One-line onboarding: what `ach join` sets up after the key is stored.
|
|
4
5
|
*
|
|
5
6
|
* 1. connect: add the `ach` MCP server to the agent's own tool (Claude Code
|
|
6
7
|
* for a claude agent, Codex for a codex one; both found ones for "other")
|
|
7
|
-
* 2.
|
|
8
|
+
* 2. sandbox (design §27): when the probe of the agent tool's sandbox fails, the
|
|
9
|
+
* commands that fix it (sudo included), run only after Enter in a terminal;
|
|
10
|
+
* with no terminal they are printed and nothing runs
|
|
11
|
+
* 3. background helper, only with --service: `ach run --install-service`
|
|
8
12
|
* (+ --notify on a machine with a desktop), which installs and starts it.
|
|
9
13
|
* Run through npx, the helper runs the same pinned npx spec as the MCP
|
|
10
14
|
* entry, never a path in the npx cache.
|
|
@@ -22,6 +26,10 @@ export interface SetupIo {
|
|
|
22
26
|
/** Asks one question; undefined = not interactive. */
|
|
23
27
|
ask?: (q: string) => Promise<string>;
|
|
24
28
|
cwd: string;
|
|
29
|
+
/** Runs a shell line with this terminal (sudo can ask for a password); the exit code. Absent: sandbox fixes are only printed. */
|
|
30
|
+
runTty?: (line: string) => Promise<number>;
|
|
31
|
+
/** The sandbox probe (tests). */
|
|
32
|
+
sandbox?: (tool: SandboxTool) => Promise<SandboxStatus>;
|
|
25
33
|
}
|
|
26
34
|
export interface SetupOptions {
|
|
27
35
|
yes: boolean;
|
|
@@ -40,7 +48,7 @@ export interface SetupOptions {
|
|
|
40
48
|
}
|
|
41
49
|
export type Argv = string[];
|
|
42
50
|
export interface Step {
|
|
43
|
-
id: 'connect' | 'service';
|
|
51
|
+
id: 'connect' | 'sandbox' | 'service';
|
|
44
52
|
question: string;
|
|
45
53
|
/** Commands, printed as shell lines and run one by one. */
|
|
46
54
|
commands: Argv[];
|
|
@@ -92,6 +100,7 @@ export declare function planSteps(d: Detected, o: {
|
|
|
92
100
|
runtime?: string;
|
|
93
101
|
service?: boolean;
|
|
94
102
|
desktop?: boolean;
|
|
103
|
+
sandbox?: SandboxStatus[];
|
|
95
104
|
}): Step[];
|
|
96
105
|
export interface SetupResult {
|
|
97
106
|
ran: string[];
|
package/dist/src/onboard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { probeSandbox } from './sandboxcheck.js';
|
|
3
4
|
import { identifyAch, isOurs, MCP_SERVER_NAME, npxAch, thisCliPath, userBin, whichAll } from './invite.js';
|
|
4
5
|
/** The closing line: which tool to open and what to say to it. */
|
|
5
6
|
export function doneLine(tools, zh) {
|
|
@@ -105,7 +106,20 @@ export function planSteps(d, o) {
|
|
|
105
106
|
connect.question = [zh ? `把 AgentChannel 接入 ${where}?会运行:` : `Add AgentChannel to ${where}? This runs:`, ...lines].join('\n') + '\n';
|
|
106
107
|
steps.push(connect);
|
|
107
108
|
}
|
|
108
|
-
// 2.
|
|
109
|
+
// 2. sandbox: the fix for each tool whose sandbox probe failed (one command list; the same line twice runs once)
|
|
110
|
+
const broken = (o.sandbox ?? []).filter((st) => !st.ok && st.fix);
|
|
111
|
+
if (broken.length) {
|
|
112
|
+
const lines = [...new Set(broken.flatMap((st) => st.fix.commands))];
|
|
113
|
+
const why = broken.map((st) => ` ${zh ? st.fix.zh : st.fix.en}`);
|
|
114
|
+
steps.push({
|
|
115
|
+
id: 'sandbox',
|
|
116
|
+
question: lines.length
|
|
117
|
+
? [...why, zh ? '现在修复?会运行(可能要输入你的系统密码):' : 'Fix it now? This runs (it may ask for your password):', ...lines.map((l) => ` ${l}`)].join('\n') + '\n'
|
|
118
|
+
: why.join('\n') + '\n',
|
|
119
|
+
commands: lines.map((l) => ['/bin/sh', '-c', l]),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// 3. background helper: only when asked for (--service)
|
|
109
123
|
if (o.service) {
|
|
110
124
|
const notify = o.desktop !== false;
|
|
111
125
|
const helper = ['run', '--install-service', ...(notify ? ['--notify'] : []), '--interactive-first', '--dir', o.cwd];
|
|
@@ -135,7 +149,10 @@ export async function runSetup(io, o) {
|
|
|
135
149
|
const result = { ran: [], skipped: [], failed: [], tools: [] };
|
|
136
150
|
const zh = !!o.zh;
|
|
137
151
|
const desktop = hasDesktop(env);
|
|
138
|
-
|
|
152
|
+
// design §27: probe the sandbox of the tools this agent runs with (not in a dry run: it runs nothing)
|
|
153
|
+
const probe = io.sandbox ?? ((t) => probeSandbox(t, { env }));
|
|
154
|
+
const sandbox = o.dryRun ? [] : await Promise.all(toolsFor(o.runtime).map((t) => probe(t)));
|
|
155
|
+
const plan = planSteps(d, { home, achCmd: d.achCmd, cwd: io.cwd, zh, env, runtime: o.runtime, service: o.service, desktop, sandbox });
|
|
139
156
|
if (o.dryRun) {
|
|
140
157
|
printOnly(io, d, plan, { zh, join: o.join });
|
|
141
158
|
return result;
|
|
@@ -173,10 +190,23 @@ export async function runSetup(io, o) {
|
|
|
173
190
|
return failedBins.size === 0;
|
|
174
191
|
};
|
|
175
192
|
for (const s of plan) {
|
|
193
|
+
// nothing to run (no safe command for this machine), or no terminal to run sudo in: say what to do and go on
|
|
194
|
+
if (s.id === 'sandbox' && (!s.commands.length || !io.ask || !io.runTty)) {
|
|
195
|
+
io.err(s.question.trimEnd());
|
|
196
|
+
if (s.commands.length)
|
|
197
|
+
io.err(zh ? ' 没有终端,所以没有运行。请在终端里运行上面的命令,然后运行 ach doctor 检查。' : ' No terminal, so nothing was run. Run the commands above in a terminal, then check with ach doctor.');
|
|
198
|
+
result.failed.push('sandbox');
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
176
201
|
if (!(await ask(s))) {
|
|
177
202
|
result.skipped.push(s.id);
|
|
178
203
|
continue;
|
|
179
204
|
}
|
|
205
|
+
if (s.id === 'sandbox') {
|
|
206
|
+
const ok = await fixSandbox(io, s, sandbox, probe, zh);
|
|
207
|
+
(ok ? result.ran : result.failed).push('sandbox');
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
180
210
|
if (s.id === 'service') {
|
|
181
211
|
const ok = await installService(io, s.commands[0]);
|
|
182
212
|
(ok ? result.ran : result.failed).push('service');
|
|
@@ -198,6 +228,27 @@ export async function runSetup(io, o) {
|
|
|
198
228
|
}
|
|
199
229
|
return result;
|
|
200
230
|
}
|
|
231
|
+
/** Run the sandbox fix in this terminal, then probe again: true when every tool's sandbox works now. */
|
|
232
|
+
async function fixSandbox(io, s, before, probe, zh) {
|
|
233
|
+
for (const c of s.commands) {
|
|
234
|
+
const code = await io.runTty(c[2]);
|
|
235
|
+
if (code !== 0) {
|
|
236
|
+
io.err(` failed (exit ${code}): ${c[2]}`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let ok = true;
|
|
241
|
+
for (const st of before.filter((x) => !x.ok)) {
|
|
242
|
+
const now = await probe(st.tool);
|
|
243
|
+
if (now.ok)
|
|
244
|
+
io.out(zh ? ` ${st.tool === 'claude' ? 'Claude Code' : 'Codex'} 的沙箱可以用了` : ` ${st.tool === 'claude' ? 'Claude Code' : 'Codex'}'s sandbox works now`);
|
|
245
|
+
else {
|
|
246
|
+
ok = false;
|
|
247
|
+
io.err(` ${zh ? now.fix?.zh : now.fix?.en}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return ok;
|
|
251
|
+
}
|
|
201
252
|
/** `ach run --install-service --json`: it writes the unit and starts it (not under ACH_SERVICE_DIR: tests). */
|
|
202
253
|
async function installService(io, argv) {
|
|
203
254
|
const r = await io.exec(argv[0], [...argv.slice(1), '--json'], io.env);
|
|
@@ -226,6 +277,7 @@ function printOnly(io, d, plan, o) {
|
|
|
226
277
|
}
|
|
227
278
|
for (const s of plan)
|
|
228
279
|
out.push(s.question.trimEnd());
|
|
280
|
+
out.push(zh ? ' 检查这台机器能否运行智能体的沙箱;不能时给出修复命令,你同意后才运行' : " check that the agent's sandbox works on this machine; if not, show the fix and run it only after you agree");
|
|
229
281
|
out.push('', `${zh ? '随时检查配置:' : 'Check the setup any time with: '}${shellLine([...(d.npx ? d.achCmd : ['ach']), 'doctor'])}`);
|
|
230
282
|
io.out(out.join('\n'));
|
|
231
283
|
}
|
|
@@ -3,7 +3,8 @@ import { type Agent, type Client, type MentionItem, type Message } from '../clie
|
|
|
3
3
|
import { type Perm } from '../perm.js';
|
|
4
4
|
import { RunnerState } from './state.js';
|
|
5
5
|
import type { Workspace } from './workspace.js';
|
|
6
|
-
|
|
6
|
+
import { type SandboxStatus } from '../sandboxcheck.js';
|
|
7
|
+
export type RunnerClient = Pick<Client, 'me' | 'inbox' | 'claim' | 'getTask' | 'postMessage' | 'complete' | 'fail' | 'usage' | 'heartbeat' | 'answerRun' | 'endRun'> & Partial<Pick<Client, 'download' | 'channelPosts' | 'mentionRun' | 'mentionEnd' | 'mentionUsage' | 'promptSize' | 'reportSetup'>>;
|
|
7
8
|
export interface RunnerOptions {
|
|
8
9
|
client: RunnerClient;
|
|
9
10
|
adapter: RuntimeAdapter;
|
|
@@ -39,6 +40,12 @@ export interface RunnerOptions {
|
|
|
39
40
|
agentHomesDir?: string;
|
|
40
41
|
/** §16: how many recent channel posts work prompts carry (default 15; 0 = none). */
|
|
41
42
|
channelContext?: number;
|
|
43
|
+
/**
|
|
44
|
+
* design §27: probes the agent tool's sandbox. While it fails the runner takes nothing new (tasks wait in the hub's
|
|
45
|
+
* inbox), reports why, and probes again every `sandboxRecheckMs`. Absent: no check (tests).
|
|
46
|
+
*/
|
|
47
|
+
sandboxCheck?: () => Promise<SandboxStatus>;
|
|
48
|
+
sandboxRecheckMs?: number;
|
|
42
49
|
}
|
|
43
50
|
export type Job = {
|
|
44
51
|
kind: 'work';
|
|
@@ -141,6 +148,13 @@ export declare class Runner {
|
|
|
141
148
|
/** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
|
|
142
149
|
fatal?: 'unauthorized';
|
|
143
150
|
private wake?;
|
|
151
|
+
/** design §27: the last sandbox probe, when it ran, and what the hub was last told (a JSON key). */
|
|
152
|
+
sandbox?: {
|
|
153
|
+
st: SandboxStatus;
|
|
154
|
+
at: number;
|
|
155
|
+
};
|
|
156
|
+
private sandboxReported?;
|
|
157
|
+
private sandboxRecheck;
|
|
144
158
|
constructor(opts: RunnerOptions);
|
|
145
159
|
private log;
|
|
146
160
|
private taskLog;
|
|
@@ -155,6 +169,8 @@ export declare class Runner {
|
|
|
155
169
|
authFailed(where: string): void;
|
|
156
170
|
/** One poll cycle. */
|
|
157
171
|
tick(): Promise<void>;
|
|
172
|
+
/** design §27: probe the sandbox when due, tell the hub when the answer changes; true = take nothing new. */
|
|
173
|
+
private sandboxBlocked;
|
|
158
174
|
private notifyBudget;
|
|
159
175
|
/**
|
|
160
176
|
* Cancel running jobs whose task went terminal; forget parked runs of
|
|
@@ -10,6 +10,7 @@ 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 { looksLikeSandboxError } from '../sandboxcheck.js';
|
|
13
14
|
export function jobKey(j) {
|
|
14
15
|
if (j.kind === 'mention')
|
|
15
16
|
return `mention:${j.item.mention.id}`;
|
|
@@ -207,6 +208,10 @@ export class Runner {
|
|
|
207
208
|
/** B7: set when the hub answered 401 (key rotated, agent or owner deactivated): the runner stops. */
|
|
208
209
|
fatal;
|
|
209
210
|
wake;
|
|
211
|
+
/** design §27: the last sandbox probe, when it ran, and what the hub was last told (a JSON key). */
|
|
212
|
+
sandbox;
|
|
213
|
+
sandboxReported;
|
|
214
|
+
sandboxRecheck = false;
|
|
210
215
|
constructor(opts) {
|
|
211
216
|
this.o = { max: 2, perm: 'workdir', intervalMs: 3000, heartbeatMs: 30_000, idleMs: 60 * 60_000, ...opts };
|
|
212
217
|
this.state = new RunnerState(opts.home);
|
|
@@ -285,6 +290,8 @@ export class Runner {
|
|
|
285
290
|
await this.prune();
|
|
286
291
|
if (this.paused)
|
|
287
292
|
return; // heartbeats of parked runs went out in checkActive; nothing new is taken (it waits in the hub's inbox)
|
|
293
|
+
if (await this.sandboxBlocked())
|
|
294
|
+
return; // design §27: same, until the sandbox works
|
|
288
295
|
if (!this.me || inbox.tasks_to_me.length)
|
|
289
296
|
this.me = await this.o.client.me();
|
|
290
297
|
const me = this.me;
|
|
@@ -332,6 +339,50 @@ export class Runner {
|
|
|
332
339
|
}
|
|
333
340
|
this.pump();
|
|
334
341
|
}
|
|
342
|
+
/** design §27: probe the sandbox when due, tell the hub when the answer changes; true = take nothing new. */
|
|
343
|
+
async sandboxBlocked() {
|
|
344
|
+
const check = this.o.sandboxCheck;
|
|
345
|
+
if (!check)
|
|
346
|
+
return false;
|
|
347
|
+
const prev = this.sandbox;
|
|
348
|
+
const due = !prev || this.sandboxRecheck || (!prev.st.ok && Date.now() - prev.at >= (this.o.sandboxRecheckMs ?? 5 * 60_000));
|
|
349
|
+
if (due) {
|
|
350
|
+
this.sandboxRecheck = false;
|
|
351
|
+
let st;
|
|
352
|
+
try {
|
|
353
|
+
st = await check();
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
st = { tool: 'claude', ok: false, problem: 'failed', detail: String(e?.message ?? e) };
|
|
357
|
+
}
|
|
358
|
+
this.sandbox = { st, at: Date.now() };
|
|
359
|
+
if (!prev || prev.st.ok !== st.ok || prev.st.problem !== st.problem) {
|
|
360
|
+
if (st.ok)
|
|
361
|
+
this.log(`sandbox: ${st.unverified ? `not checked (${st.detail})` : 'works'}${prev ? '; taking tasks again' : ''}`);
|
|
362
|
+
else
|
|
363
|
+
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')}` : ''}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const st = this.sandbox.st;
|
|
367
|
+
const report = st.ok ? null : { tool: st.tool, problem: st.problem ?? 'failed', ...(st.detail ? { detail: st.detail } : {}), ...(st.fix ? { fix: st.fix } : {}) };
|
|
368
|
+
const key = JSON.stringify(report);
|
|
369
|
+
if (key !== this.sandboxReported && this.o.client.reportSetup) {
|
|
370
|
+
try {
|
|
371
|
+
await this.o.client.reportSetup(report);
|
|
372
|
+
this.sandboxReported = key;
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
if (is401(e))
|
|
376
|
+
this.authFailed('setup');
|
|
377
|
+
// an older hub without /me/setup: the log above is all there is
|
|
378
|
+
else if (e instanceof HubError && e.status === 404)
|
|
379
|
+
this.sandboxReported = key;
|
|
380
|
+
else
|
|
381
|
+
this.log(`setup report: ${e?.message ?? e}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return !st.ok;
|
|
385
|
+
}
|
|
335
386
|
async notifyBudget(t, me) {
|
|
336
387
|
if (this.state.runs.budgetNotified[t.id])
|
|
337
388
|
return;
|
|
@@ -778,6 +829,8 @@ export class Runner {
|
|
|
778
829
|
return;
|
|
779
830
|
}
|
|
780
831
|
if (!result.ok) {
|
|
832
|
+
if (looksLikeSandboxError(result.error ?? ''))
|
|
833
|
+
this.sandboxRecheck = true;
|
|
781
834
|
await client.fail(taskId, `runtime error: ${result.error ?? 'unknown'}`, rec.runId).catch((e) => this.log(`fail ${taskId}: ${e?.message ?? e}`));
|
|
782
835
|
this.state.drop(taskId);
|
|
783
836
|
return;
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
/** anything else: `detail` has the first line of the error */
|
|
26
|
+
| 'failed';
|
|
27
|
+
export interface SandboxFix {
|
|
28
|
+
/** One sentence: what is wrong and what the commands do. */
|
|
29
|
+
en: string;
|
|
30
|
+
zh: string;
|
|
31
|
+
/** Shell lines to run, in order (sudo included). Empty: there is nothing safe to run; the sentence says what to do. */
|
|
32
|
+
commands: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface SandboxStatus {
|
|
35
|
+
tool: SandboxTool;
|
|
36
|
+
ok: boolean;
|
|
37
|
+
/** ok, but the probe could not run (an older Codex without `codex sandbox`): not blocking, doctor warns. */
|
|
38
|
+
unverified?: boolean;
|
|
39
|
+
problem?: SandboxProblem;
|
|
40
|
+
missing?: string[];
|
|
41
|
+
detail?: string;
|
|
42
|
+
fix?: SandboxFix;
|
|
43
|
+
}
|
|
44
|
+
export type PkgManager = 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk';
|
|
45
|
+
/** What the fix depends on, read from the machine (tests pass their own). */
|
|
46
|
+
export interface Host {
|
|
47
|
+
platform: NodeJS.Platform;
|
|
48
|
+
wsl: boolean;
|
|
49
|
+
/** /etc/os-release ID and ID_LIKE */
|
|
50
|
+
distro?: string[];
|
|
51
|
+
pkg?: PkgManager;
|
|
52
|
+
/** kernel.apparmor_restrict_unprivileged_userns ('1' = on) */
|
|
53
|
+
appArmorUserns?: string;
|
|
54
|
+
/** an AppArmor profile that lets /usr/bin/bwrap create user namespaces is installed */
|
|
55
|
+
bwrapProfile: boolean;
|
|
56
|
+
/** absolute paths, when found on PATH */
|
|
57
|
+
bwrap?: string;
|
|
58
|
+
socat?: string;
|
|
59
|
+
}
|
|
60
|
+
export type RunResult = {
|
|
61
|
+
code: number;
|
|
62
|
+
stdout: string;
|
|
63
|
+
stderr: string;
|
|
64
|
+
};
|
|
65
|
+
export type Run = (file: string, args: string[], opts: {
|
|
66
|
+
env: NodeJS.ProcessEnv;
|
|
67
|
+
cwd?: string;
|
|
68
|
+
}) => Promise<RunResult>;
|
|
69
|
+
export declare const defaultRun: Run;
|
|
70
|
+
export declare function pkgFor(distro: string[] | undefined): PkgManager | undefined;
|
|
71
|
+
/** Read the machine (Linux files; elsewhere only the platform matters). */
|
|
72
|
+
export declare function detectHost(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): Host;
|
|
73
|
+
/** The Codex CLI the runner's SDK uses (the @openai/codex package next to this CLI), else `codex` on PATH. */
|
|
74
|
+
export declare function codexCommand(env?: NodeJS.ProcessEnv): string[] | undefined;
|
|
75
|
+
/** What a failed sandbox start said, as a problem. */
|
|
76
|
+
export declare function classify(stderr: string, host: Host): SandboxProblem;
|
|
77
|
+
export interface ProbeOptions {
|
|
78
|
+
env?: NodeJS.ProcessEnv;
|
|
79
|
+
platform?: NodeJS.Platform;
|
|
80
|
+
host?: Host;
|
|
81
|
+
run?: Run;
|
|
82
|
+
/** where the forbidden write goes (default: the home directory) */
|
|
83
|
+
home?: string;
|
|
84
|
+
/** the Codex CLI (default: codexCommand()) */
|
|
85
|
+
codex?: string[];
|
|
86
|
+
}
|
|
87
|
+
/** Probe `tool`'s sandbox on this machine. Never throws. */
|
|
88
|
+
export declare function probeSandbox(tool: SandboxTool, o?: ProbeOptions): Promise<SandboxStatus>;
|
|
89
|
+
/** Lets the system bubblewrap (only that path) create user namespaces under Ubuntu's AppArmor rule; never machine-wide. */
|
|
90
|
+
export declare const BWRAP_APPARMOR: string;
|
|
91
|
+
/** The fix for a failed probe on this host. */
|
|
92
|
+
export declare function sandboxFix(st: SandboxStatus, host: Host): SandboxFix;
|
|
93
|
+
/** Does a run's error look like the sandbox failing (the runner then probes again)? */
|
|
94
|
+
export declare function looksLikeSandboxError(s: string): boolean;
|
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
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 'windows-native':
|
|
182
|
+
return {
|
|
183
|
+
en: 'Claude Code has no sandbox on native Windows: run this agent inside WSL2 (wsl --install, then join again there).',
|
|
184
|
+
zh: 'Claude Code 在原生 Windows 上没有沙箱:请在 WSL2 里运行这个智能体(wsl --install,然后在 WSL 里重新 join)。',
|
|
185
|
+
commands: [],
|
|
186
|
+
};
|
|
187
|
+
case 'unsupported':
|
|
188
|
+
return { en: `${name} has no sandbox on ${st.detail ?? 'this system'}.`, zh: `${name} 在 ${st.detail ?? '这个系统'} 上没有沙箱。`, commands: [] };
|
|
189
|
+
case 'userns-off':
|
|
190
|
+
return {
|
|
191
|
+
en: 'This kernel does not allow user namespaces, which the sandbox needs. Ask whoever runs this machine to allow them (user.max_user_namespaces).',
|
|
192
|
+
zh: '这台机器的内核不允许用户命名空间,沙箱需要它。请让管理这台机器的人开启(user.max_user_namespaces)。',
|
|
193
|
+
commands: [],
|
|
194
|
+
};
|
|
195
|
+
case 'not-enforced':
|
|
196
|
+
return { en: `${name}'s sandbox started but did not stop a write outside its directory: do not run this agent here.`, zh: `${name} 的沙箱能启动,但没有拦住目录外的写入:不要在这台机器上运行这个智能体。`, commands: [] };
|
|
197
|
+
case 'missing':
|
|
198
|
+
case 'userns-blocked': {
|
|
199
|
+
if (host.platform !== 'linux')
|
|
200
|
+
break;
|
|
201
|
+
const pkgs = st.tool === 'claude' ? ['bubblewrap', 'socat'] : ['bubblewrap'];
|
|
202
|
+
// the profile only covers /usr/bin/bwrap: a bwrap from elsewhere (a user install) means installing the system one
|
|
203
|
+
const need = pkgs.filter((p) => (p === 'socat' ? !host.socat : !host.bwrap || host.bwrap !== '/usr/bin/bwrap' || st.missing?.includes(p)));
|
|
204
|
+
const appArmor = host.appArmorUserns === '1' && (st.problem === 'userns-blocked' || !host.bwrapProfile);
|
|
205
|
+
const commands = [];
|
|
206
|
+
if (need.length) {
|
|
207
|
+
if (!host.pkg) {
|
|
208
|
+
return {
|
|
209
|
+
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)' : ''}.`,
|
|
210
|
+
zh: `请用系统的包管理器安装 ${need.join(' 和 ')}${appArmor ? ',再在 AppArmor 里放行 /usr/bin/bwrap(见 Claude Code 沙箱文档)' : ''}。`,
|
|
211
|
+
commands: [],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
commands.push(INSTALL[host.pkg](need));
|
|
215
|
+
}
|
|
216
|
+
if (appArmor)
|
|
217
|
+
commands.push(BWRAP_APPARMOR);
|
|
218
|
+
const what = [need.length ? `install ${need.join(' and ')}` : '', appArmor ? 'let /usr/bin/bwrap create user namespaces under AppArmor (this program only)' : ''].filter(Boolean);
|
|
219
|
+
const zhWhat = [need.length ? `安装 ${need.join(' 和 ')}` : '', appArmor ? '在 AppArmor 里只放行 /usr/bin/bwrap 创建用户命名空间' : ''].filter(Boolean);
|
|
220
|
+
return {
|
|
221
|
+
en: `${name}'s sandbox cannot start on this machine${st.missing?.length ? ` (${st.missing.join(', ')} missing)` : ''}. Fix: ${what.join(', then ')}.`,
|
|
222
|
+
zh: `${name} 的沙箱在这台机器上起不来${st.missing?.length ? `(缺 ${st.missing.join('、')})` : ''}。修复:${zhWhat.join(',然后')}。`,
|
|
223
|
+
commands,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { en: `${name}'s sandbox cannot start on this machine: ${st.detail ?? 'unknown error'}.`, zh: `${name} 的沙箱在这台机器上起不来:${st.detail ?? '未知错误'}。`, commands: [] };
|
|
228
|
+
}
|
|
229
|
+
/** Does a run's error look like the sandbox failing (the runner then probes again)? */
|
|
230
|
+
export function looksLikeSandboxError(s) {
|
|
231
|
+
return /sandbox|bwrap|bubblewrap|socat|seatbelt|user namespace|uid map|RTM_NEWADDR/i.test(s);
|
|
232
|
+
}
|
package/dist/src/service.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* P4: `ach run --install-service` writes a macOS launchd agent or a Linux
|
|
3
3
|
* systemd user unit that starts this runner at login and restarts it, then
|
|
4
|
-
* loads it (startService).
|
|
4
|
+
* loads it (startService). design §27, Windows: a script in the user's Startup
|
|
5
|
+
* folder (no admin rights needed) that runs the runner hidden and restarts it.
|
|
5
6
|
*/
|
|
6
7
|
export interface ServiceSpec {
|
|
7
|
-
platform: 'darwin' | 'linux';
|
|
8
|
+
platform: 'darwin' | 'linux' | 'win32';
|
|
8
9
|
agentName: string;
|
|
9
10
|
/** What starts the CLI: node + cli.js of an install, or `npx -y <spec>` when it runs from the npx cache. */
|
|
10
11
|
command: string[];
|
|
@@ -23,6 +24,11 @@ export interface ServiceFile {
|
|
|
23
24
|
export declare function serviceLabel(agentName: string): string;
|
|
24
25
|
export declare function launchdPlist(s: ServiceSpec): string;
|
|
25
26
|
export declare function systemdUnit(s: ServiceSpec): string;
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Windows: a VBScript in the Startup folder. It runs the runner with no window, appends its output to the log, and
|
|
29
|
+
* starts it again 10 s after it exits, except after exit 78 (key rotated or agent deactivated: restarting cannot help).
|
|
30
|
+
*/
|
|
31
|
+
export declare function windowsScript(s: ServiceSpec): string;
|
|
32
|
+
export declare function serviceFile(s: ServiceSpec, dir?: string, env?: NodeJS.ProcessEnv): ServiceFile;
|
|
27
33
|
/** Write the unit file (mode 0644; it holds no secrets). */
|
|
28
34
|
export declare function writeServiceFile(f: ServiceFile): void;
|
package/dist/src/service.js
CHANGED
|
@@ -72,7 +72,45 @@ RestartPreventExitStatus=78
|
|
|
72
72
|
WantedBy=default.target
|
|
73
73
|
`;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
/** VBScript string literal. */
|
|
76
|
+
function vbs(s) {
|
|
77
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
78
|
+
}
|
|
79
|
+
/** cmd.exe argument: quoted unless plain. */
|
|
80
|
+
function cmdArg(s) {
|
|
81
|
+
return /^[A-Za-z0-9_.:\\/=@,+-]+$/.test(s) ? s : `"${s.replace(/"/g, '\\"')}"`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Windows: a VBScript in the Startup folder. It runs the runner with no window, appends its output to the log, and
|
|
85
|
+
* starts it again 10 s after it exits, except after exit 78 (key rotated or agent deactivated: restarting cannot help).
|
|
86
|
+
*/
|
|
87
|
+
export function windowsScript(s) {
|
|
88
|
+
const line = `${[...s.command, 'run', ...s.args].map(cmdArg).join(' ')} >> ${cmdArg(s.logFile)} 2>&1`;
|
|
89
|
+
const env = Object.entries(s.env).map(([k, v]) => `env(${vbs(k)}) = ${vbs(v)}`).join('\n');
|
|
90
|
+
return `' AgentChannel runner (agent ${s.agentName}): starts at login, restarts after it exits (not after exit 78).
|
|
91
|
+
Option Explicit
|
|
92
|
+
Dim sh, env, code
|
|
93
|
+
Set sh = CreateObject("WScript.Shell")
|
|
94
|
+
Set env = sh.Environment("Process")
|
|
95
|
+
${env}
|
|
96
|
+
Do
|
|
97
|
+
code = sh.Run("cmd /c " & ${vbs(`"${line}"`)}, 0, True)
|
|
98
|
+
If code = 78 Then Exit Do
|
|
99
|
+
WScript.Sleep 10000
|
|
100
|
+
Loop
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
export function serviceFile(s, dir, env = process.env) {
|
|
104
|
+
if (s.platform === 'win32') {
|
|
105
|
+
const name = `agentchannel-runner-${s.agentName.replace(/[^A-Za-z0-9-]/g, '-')}.vbs`;
|
|
106
|
+
const startup = join(env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
|
|
107
|
+
const path = join(dir ?? startup, name);
|
|
108
|
+
// stop: the script's own wscript, then the runner it started (its command line holds the same --dir)
|
|
109
|
+
const kill = (like) => `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '${like.replace(/'/g, "''")}' } | ForEach-Object { Invoke-CimMethod -InputObject $_ -MethodName Terminate | Out-Null }`;
|
|
110
|
+
const dirArg = s.args[s.args.indexOf('--dir') + 1] ?? '';
|
|
111
|
+
const stop = `powershell -NoProfile -Command "${kill(`*${name}*`)}; ${kill(`* run *--dir*${dirArg}*`)}"`;
|
|
112
|
+
return { path, content: windowsScript(s), start: [`${stop}`, `wscript "${path}"`], stop: [stop, `del "${path}"`] };
|
|
113
|
+
}
|
|
76
114
|
if (s.platform === 'darwin') {
|
|
77
115
|
const label = serviceLabel(s.agentName);
|
|
78
116
|
const path = join(dir ?? join(homedir(), 'Library', 'LaunchAgents'), `${label}.plist`);
|
package/dist/src/version.d.ts
CHANGED
package/dist/src/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koalaauto/agentchannel",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "Hand work between your team's agents (Claude Code, Codex) and follow it in one panel: the ach CLI, MCP server and runner for AgentChannel v2",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agentchannel",
|