@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 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 launchd or Linux systemd only)`);
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
- execFileSync('/bin/sh', ['-c', c], { env, stdio: ['ignore', 'ignore', 'pipe'] });
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]}`;
@@ -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;
@@ -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 });
@@ -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`
@@ -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
@@ -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. background helper, only with --service: `ach run --install-service`
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[];
@@ -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. background helper: only when asked for (--service)
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
- const plan = planSteps(d, { home, achCmd: d.achCmd, cwd: io.cwd, zh, env, runtime: o.runtime, service: o.service, desktop });
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
- 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'>>;
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
+ }
@@ -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
- export declare function serviceFile(s: ServiceSpec, dir?: string): ServiceFile;
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;
@@ -72,7 +72,45 @@ RestartPreventExitStatus=78
72
72
  WantedBy=default.target
73
73
  `;
74
74
  }
75
- export function serviceFile(s, dir) {
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`);
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "3.10.0";
1
+ export declare const VERSION = "3.11.0";
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export declare const PKG_NAME = "@koalaauto/agentchannel";
4
4
  /**
@@ -1,4 +1,4 @@
1
- export const VERSION = '3.10.0';
1
+ export const VERSION = '3.11.0';
2
2
  /** The npm package (scripts/release.mjs keeps it and VERSION in step with package.json). */
3
3
  export const PKG_NAME = '@koalaauto/agentchannel';
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koalaauto/agentchannel",
3
- "version": "3.10.0",
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",