@archme/cli 0.13.0 → 0.14.1

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/dist/cli.js CHANGED
@@ -8291,9 +8291,13 @@ var init_execa = __esm({
8291
8291
  // src/auth/credentials.ts
8292
8292
  var credentials_exports = {};
8293
8293
  __export(credentials_exports, {
8294
+ CredentialStoreError: () => CredentialStoreError,
8295
+ CredentialStoreUnreadableError: () => CredentialStoreUnreadableError,
8296
+ CredentialStoreUnwritableError: () => CredentialStoreUnwritableError,
8294
8297
  clearCredential: () => clearCredential,
8295
8298
  clearCredentials: () => clearCredentials,
8296
8299
  credentialKind: () => credentialKind,
8300
+ credentialStoreMessage: () => credentialStoreMessage,
8297
8301
  describeCredentialSource: () => describeCredentialSource,
8298
8302
  loadAllCredentials: () => loadAllCredentials,
8299
8303
  loadCredentials: () => loadCredentials,
@@ -8344,10 +8348,32 @@ function parseStore(raw) {
8344
8348
  function serializeStore(identities) {
8345
8349
  return JSON.stringify({ v: 2, identities }, null, 2);
8346
8350
  }
8351
+ function credentialStoreMessage(platform3, detail, action = "read") {
8352
+ const lead = `Could not ${action} your ArchMe sign-in`;
8353
+ if (platform3 === "darwin") {
8354
+ return `${lead}: the macOS login keychain is locked (common over SSH). Run \`security unlock-keychain ~/Library/Keychains/login.keychain-db\`, or run this from the Mac's desktop session. (keytar: ${detail})`;
8355
+ }
8356
+ if (platform3 === "linux") {
8357
+ return `${lead}: the system keyring (Secret Service) is locked or unavailable (common over SSH): ${detail}. Unlock the keyring, or run this from your desktop session.`;
8358
+ }
8359
+ return `${lead} from the OS credential store: ${detail}`;
8360
+ }
8361
+ async function keychainWrite(action, op) {
8362
+ try {
8363
+ return await op();
8364
+ } catch (err4) {
8365
+ throw new CredentialStoreUnwritableError(action, err4);
8366
+ }
8367
+ }
8347
8368
  async function readStore(supabaseUrl) {
8348
8369
  const keytar = await loadKeytar();
8349
8370
  if (keytar) {
8350
- const raw = await keytar.getPassword(SERVICE_NAME, supabaseUrl);
8371
+ let raw;
8372
+ try {
8373
+ raw = await keytar.getPassword(SERVICE_NAME, supabaseUrl);
8374
+ } catch (err4) {
8375
+ throw new CredentialStoreUnreadableError(err4);
8376
+ }
8351
8377
  return { list: raw ? parseStore(raw).filter((c3) => c3.supabase_url === supabaseUrl) : [], source: "keychain" };
8352
8378
  }
8353
8379
  const all = await readFileStore();
@@ -8383,12 +8409,7 @@ async function saveCredentials(credentials) {
8383
8409
  const merged2 = mergeBindings(credentials, prior2);
8384
8410
  const next2 = list2.filter((c3) => c3.device_id !== credentials.device_id);
8385
8411
  next2.push(merged2);
8386
- try {
8387
- await keytar.setPassword(SERVICE_NAME, credentials.supabase_url, serializeStore(next2));
8388
- } catch (err4) {
8389
- warnCredentialPersistFailure("macOS Keychain", err4);
8390
- throw err4;
8391
- }
8412
+ await keychainWrite("save", () => keytar.setPassword(SERVICE_NAME, credentials.supabase_url, serializeStore(next2)));
8392
8413
  return { credentials: merged2, source: "keychain" };
8393
8414
  }
8394
8415
  const all = await readFileStore();
@@ -8411,10 +8432,10 @@ async function replaceUrlCredentials(supabaseUrl, creds) {
8411
8432
  const keytar = await loadKeytar();
8412
8433
  if (keytar) {
8413
8434
  if (creds.length === 0) {
8414
- await keytar.deletePassword(SERVICE_NAME, supabaseUrl);
8435
+ await keychainWrite("remove", () => keytar.deletePassword(SERVICE_NAME, supabaseUrl));
8415
8436
  return { credentials: null, source: "keychain" };
8416
8437
  }
8417
- await keytar.setPassword(SERVICE_NAME, supabaseUrl, serializeStore(creds));
8438
+ await keychainWrite("save", () => keytar.setPassword(SERVICE_NAME, supabaseUrl, serializeStore(creds)));
8418
8439
  return { credentials: creds[creds.length - 1], source: "keychain" };
8419
8440
  }
8420
8441
  const all = await readFileStore();
@@ -8437,10 +8458,10 @@ async function removeFromStore(supabaseUrl, match2) {
8437
8458
  const { list: list2 } = await readStore(supabaseUrl);
8438
8459
  const remaining = list2.filter((c3) => !match2(c3));
8439
8460
  if (remaining.length === 0) {
8440
- await keytar.deletePassword(SERVICE_NAME, supabaseUrl);
8461
+ await keychainWrite("remove", () => keytar.deletePassword(SERVICE_NAME, supabaseUrl));
8441
8462
  return;
8442
8463
  }
8443
- await keytar.setPassword(SERVICE_NAME, supabaseUrl, serializeStore(remaining));
8464
+ await keychainWrite("remove", () => keytar.setPassword(SERVICE_NAME, supabaseUrl, serializeStore(remaining)));
8444
8465
  return;
8445
8466
  }
8446
8467
  const all = await readFileStore();
@@ -8480,12 +8501,31 @@ async function loadKeytar() {
8480
8501
  return null;
8481
8502
  }
8482
8503
  }
8483
- var SERVICE_NAME, storeMutex;
8504
+ var SERVICE_NAME, storeMutex, CredentialStoreError, CredentialStoreUnreadableError, CredentialStoreUnwritableError;
8484
8505
  var init_credentials = __esm({
8485
8506
  "src/auth/credentials.ts"() {
8486
8507
  "use strict";
8487
8508
  SERVICE_NAME = "archme-daemon";
8488
8509
  storeMutex = Promise.resolve();
8510
+ CredentialStoreError = class extends Error {
8511
+ constructor(action, cause, platform3 = process.platform) {
8512
+ super(credentialStoreMessage(platform3, cause instanceof Error ? cause.message : String(cause), action), { cause });
8513
+ this.action = action;
8514
+ this.name = "CredentialStoreError";
8515
+ }
8516
+ };
8517
+ CredentialStoreUnreadableError = class extends CredentialStoreError {
8518
+ constructor(cause, platform3 = process.platform) {
8519
+ super("read", cause, platform3);
8520
+ this.name = "CredentialStoreUnreadableError";
8521
+ }
8522
+ };
8523
+ CredentialStoreUnwritableError = class extends CredentialStoreError {
8524
+ constructor(action, cause, platform3 = process.platform) {
8525
+ super(action, cause, platform3);
8526
+ this.name = "CredentialStoreUnwritableError";
8527
+ }
8528
+ };
8489
8529
  }
8490
8530
  });
8491
8531
 
@@ -9294,7 +9334,14 @@ async function mintKeyedAccount(deps) {
9294
9334
  `Created ${parsed.handle}${saved ? " and saved its key" : ""}, but signing in failed: ${grant.error}. ` + (saved ? "Run `archme login --key` to try again." : `Try again with the key printed above: ${KEY_VIA_ENV}.`)
9295
9335
  );
9296
9336
  }
9297
- await storeKeyedLogin(deps, grant, data);
9337
+ try {
9338
+ await storeKeyedLogin(deps, grant, data);
9339
+ } catch (err4) {
9340
+ if (!(err4 instanceof CredentialStoreError)) throw err4;
9341
+ throw new LoginError(
9342
+ `${err4.message} Your account ${parsed.handle} was created${saved ? " and its key saved" : ""}; once that's fixed, sign in with it: ${saved ? "`archme login --key`" : KEY_VIA_ENV}.`
9343
+ );
9344
+ }
9298
9345
  deps.log(`\u2713 Created your ArchMe account ${parsed.handle} \u2014 no email or password, just a key.`);
9299
9346
  if (saved) {
9300
9347
  deps.log(` The key is saved in ${deps.keyRingPath}. Save it in your password manager too: it is the`);
@@ -9895,7 +9942,17 @@ async function ensureStartPrerequisites(opts = {}) {
9895
9942
  `archme daemon service commands are not supported on ${platform3}. Use \`archme daemon run\` for foreground operation.`
9896
9943
  );
9897
9944
  }
9898
- const signedIn = opts.isSignedIn ? await opts.isSignedIn() : await defaultIsSignedIn(env);
9945
+ let signedIn;
9946
+ try {
9947
+ signedIn = opts.isSignedIn ? await opts.isSignedIn() : await defaultIsSignedIn(env);
9948
+ } catch (err4) {
9949
+ if (!(err4 instanceof CredentialStoreUnreadableError)) throw err4;
9950
+ const controller = opts.controller ?? pickController(platform3, env);
9951
+ if (!await controller.unitExists()) throw err4;
9952
+ console.error(`Warning: ${err4.message}
9953
+ Starting the installed service anyway; it reads its own sign-in when it boots.`);
9954
+ return;
9955
+ }
9899
9956
  if (!signedIn) {
9900
9957
  if (!opts.noLogin && isatty(0) && isatty(1)) {
9901
9958
  out.write("Not signed in \u2014 launching `archme login`\u2026\n");
@@ -9920,7 +9977,7 @@ async function runStartFlow(opts = {}) {
9920
9977
  stderrPath: logPaths.stderr
9921
9978
  };
9922
9979
  const unitContent = platform3 === "darwin" ? renderLaunchdPlist(renderOpts) : renderSystemdUnit(renderOpts);
9923
- const controller = pickController(platform3, env);
9980
+ const controller = opts.controller ?? pickController(platform3, env);
9924
9981
  const result = await orchestrateStart(controller, unitContent, {
9925
9982
  skipStart: opts.start === false
9926
9983
  });
@@ -9946,6 +10003,7 @@ var init_install = __esm({
9946
10003
  init_paths();
9947
10004
  init_unitFiles();
9948
10005
  init_serviceController();
10006
+ init_credentials();
9949
10007
  ENV_KEYS_TO_BAKE = [
9950
10008
  "PATH",
9951
10009
  "HOME",
@@ -10102,10 +10160,15 @@ var init_logs = __esm({
10102
10160
  import { readFile as readFile5, stat as stat2 } from "node:fs/promises";
10103
10161
  function renderStatus(s) {
10104
10162
  const lines = [];
10163
+ const unreadable = s.credentials && "unreadable" in s.credentials ? s.credentials.unreadable : null;
10164
+ const early = (exitCode) => {
10165
+ if (unreadable) lines.push(`Signed in: unknown \u2014 ${unreadable}`);
10166
+ return { exitCode, rendered: lines.join("\n") + "\n" };
10167
+ };
10105
10168
  if (!s.unitInstalled) {
10106
10169
  lines.push(`Service: ${LABEL} (not installed)`);
10107
10170
  lines.push("Run `archme daemon start` to install + start.");
10108
- return { exitCode: 1, rendered: lines.join("\n") + "\n" };
10171
+ return early(1);
10109
10172
  }
10110
10173
  const serviceStateBits = ["installed"];
10111
10174
  if (s.inspection.enabled) serviceStateBits.push("enabled at login");
@@ -10113,17 +10176,19 @@ function renderStatus(s) {
10113
10176
  lines.push(`Service: ${LABEL} (${serviceStateBits.join(", ")})`);
10114
10177
  if (!s.inspection.loaded) {
10115
10178
  lines.push("Process: installed, not running \u2014 run `archme daemon start` to load + start the service.");
10116
- return { exitCode: 2, rendered: lines.join("\n") + "\n" };
10179
+ return early(2);
10117
10180
  }
10118
10181
  if (!s.inspection.running) {
10119
10182
  lines.push("Process: service loaded but no process");
10120
10183
  lines.push("Check `archme daemon logs` for crash details.");
10121
- return { exitCode: 2, rendered: lines.join("\n") + "\n" };
10184
+ return early(2);
10122
10185
  }
10123
10186
  const uptime = s.uptimeMs != null ? `, uptime ${formatDuration(s.uptimeMs)}` : "";
10124
10187
  lines.push(`Process: running, pid ${s.inspection.pid}${uptime}`);
10125
10188
  let unhealthy = false;
10126
- if (s.credentials) {
10189
+ if (unreadable) {
10190
+ lines.push(`Signed in: unknown \u2014 ${unreadable}`);
10191
+ } else if (s.credentials && "deviceId" in s.credentials) {
10127
10192
  const dev = s.credentials.deviceId ?? "unknown";
10128
10193
  const who = s.credentials.email ?? "OAuth user";
10129
10194
  lines.push(`Signed in: ${who} device ${dev}`);
@@ -10193,7 +10258,8 @@ async function gatherStatus(d) {
10193
10258
  let supabase = null;
10194
10259
  if (inspection.running) {
10195
10260
  try {
10196
- supabase = await d.loadSupabaseState(credentials?.deviceId ?? null);
10261
+ const deviceId = credentials && "deviceId" in credentials ? credentials.deviceId : null;
10262
+ supabase = await d.loadSupabaseState(deviceId);
10197
10263
  } catch {
10198
10264
  supabase = { reachable: false, inFlightJob: null };
10199
10265
  }
@@ -10741,7 +10807,9 @@ function digestSpawnOpts(o2) {
10741
10807
  // tool-less unless the caller wired MCP reads
10742
10808
  builtinTools: [],
10743
10809
  timeoutMs: DIGEST_TIMEOUT_MS,
10744
- model: o2.model
10810
+ model: o2.model,
10811
+ settingSources: "user"
10812
+ // never a project's .claude/settings.json hooks on the host
10745
10813
  };
10746
10814
  }
10747
10815
  function isCleanDigestExit(res) {
@@ -16391,15 +16459,23 @@ var init_sessionAuth = __esm({
16391
16459
  this.userId = resolveUserId(userInfo2, ctx.fallbackUserId);
16392
16460
  this.lastUserInfo = userInfo2;
16393
16461
  }
16394
- const saved = await saveCredentials({
16395
- kind: this.kind,
16396
- refresh_token: nextRefreshToken,
16397
- access_token: this.accessToken,
16398
- org_id: ctx.orgId,
16399
- device_id: ctx.deviceId,
16400
- user_id: this.userId ?? void 0,
16401
- supabase_url: this.cfg.supabaseUrl
16402
- });
16462
+ let saved = null;
16463
+ try {
16464
+ saved = await saveCredentials({
16465
+ kind: this.kind,
16466
+ refresh_token: nextRefreshToken,
16467
+ access_token: this.accessToken,
16468
+ org_id: ctx.orgId,
16469
+ device_id: ctx.deviceId,
16470
+ user_id: this.userId ?? void 0,
16471
+ supabase_url: this.cfg.supabaseUrl
16472
+ });
16473
+ } catch (err4) {
16474
+ if (!(err4 instanceof CredentialStoreError)) throw err4;
16475
+ console.error(
16476
+ `auth: ${err4.message} The refreshed sign-in was NOT saved: this process keeps working, but the saved one is now out of date. If this process ends before a later refresh saves it, sign in again then. ${this.reloginHint()}`
16477
+ );
16478
+ }
16403
16479
  return { userInfo: userInfo2, saved, nextRefreshToken };
16404
16480
  }
16405
16481
  // The timer closure reads the LIVE this.refreshToken/deviceId/orgId at fire
@@ -16492,11 +16568,11 @@ var init_sessionAuth = __esm({
16492
16568
  const refreshed = await this.refreshOrRecover(refreshToken, this.userId);
16493
16569
  if (!refreshed.ok) {
16494
16570
  if (refreshed.permanent) {
16495
- await clearCredential(this.cfg.supabaseUrl, deviceId);
16571
+ const notCleared = await this.clearStoredCredential(deviceId);
16496
16572
  this.clearTokensOnly();
16497
16573
  throw new LoadStoredAndRefreshError(
16498
16574
  "invalid_grant",
16499
- `${errorPrefix} failed permanently: ${refreshed.error}. ${this.reloginHint()}`
16575
+ `${errorPrefix} failed permanently: ${refreshed.error}. ${this.reloginHint()}${notCleared}`
16500
16576
  );
16501
16577
  }
16502
16578
  throw new LoadStoredAndRefreshError("transient", `${errorPrefix} failed: ${refreshed.error}`);
@@ -16507,7 +16583,7 @@ var init_sessionAuth = __esm({
16507
16583
  orgId,
16508
16584
  fallbackUserId: this.userId ?? void 0
16509
16585
  });
16510
- console.log(`auth: persisted refreshed token to ${describeCredentialSource(saved)}`);
16586
+ if (saved) console.log(`auth: persisted refreshed token to ${describeCredentialSource(saved)}`);
16511
16587
  })();
16512
16588
  this.refreshInFlight = p;
16513
16589
  return p.finally(() => {
@@ -16520,6 +16596,18 @@ var init_sessionAuth = __esm({
16520
16596
  if (refreshed.ok || !refreshed.permanent) return refreshed;
16521
16597
  return this.signInWithSavedKey(userId, refreshed.error);
16522
16598
  }
16599
+ // Remove a dead login from the store. The token is revoked whether or not
16600
+ // this works, so a store that refuses the delete must not turn a permanent
16601
+ // failure into a transient one (which would retry a dead token forever).
16602
+ async clearStoredCredential(deviceId) {
16603
+ try {
16604
+ await clearCredential(this.cfg.supabaseUrl, deviceId);
16605
+ return "";
16606
+ } catch (err4) {
16607
+ if (!(err4 instanceof CredentialStoreError)) throw err4;
16608
+ return ` (${err4.message})`;
16609
+ }
16610
+ }
16523
16611
  reloginHint() {
16524
16612
  return this.kind === "key" ? "Run `archme login --key` to sign in with your key again." : "Run `archme login` to pair again.";
16525
16613
  }
@@ -16564,10 +16652,10 @@ var init_sessionAuth = __esm({
16564
16652
  const refreshed = await this.refreshOrRecover(stored.refresh_token, stored.user_id ?? null);
16565
16653
  if (!refreshed.ok) {
16566
16654
  if (refreshed.permanent) {
16567
- await clearCredential(this.cfg.supabaseUrl, stored.device_id);
16655
+ const notCleared = await this.clearStoredCredential(stored.device_id);
16568
16656
  throw new LoadStoredAndRefreshError(
16569
16657
  "invalid_grant",
16570
- `stored archme credentials are invalid or revoked: ${refreshed.error}. ${this.reloginHint()}`
16658
+ `stored archme credentials are invalid or revoked: ${refreshed.error}. ${this.reloginHint()}${notCleared}`
16571
16659
  );
16572
16660
  }
16573
16661
  throw new LoadStoredAndRefreshError("transient", `token refresh failed (transient): ${refreshed.error}`);
@@ -16941,12 +17029,12 @@ function defaultReadFile(path7) {
16941
17029
  return readFileSync4(path7, "utf8");
16942
17030
  }
16943
17031
  function findPackageRoot2(startDir, opts = {}) {
16944
- const readFile19 = opts.readFile ?? defaultReadFile;
17032
+ const readFile20 = opts.readFile ?? defaultReadFile;
16945
17033
  const stopAt = parse(startDir).root;
16946
17034
  let dir = startDir;
16947
17035
  for (; ; ) {
16948
17036
  try {
16949
- const parsed = JSON.parse(readFile19(join16(dir, "package.json")));
17037
+ const parsed = JSON.parse(readFile20(join16(dir, "package.json")));
16950
17038
  if (parsed.name === PACKAGE_NAME) return dir;
16951
17039
  } catch {
16952
17040
  }
@@ -17157,7 +17245,9 @@ function observeGithubPull(p) {
17157
17245
  mergedAt: p.merged_at ?? null,
17158
17246
  closedAt: p.closed_at ?? null,
17159
17247
  // Before the merge it is GitHub's throwaway test merge.
17160
- mergeCommitSha: p.merged_at ? p.merge_commit_sha ?? null : null
17248
+ mergeCommitSha: p.merged_at ? p.merge_commit_sha ?? null : null,
17249
+ headRepo: p.head.repo?.full_name ?? null,
17250
+ baseRepo: p.base.repo?.full_name ?? null
17161
17251
  };
17162
17252
  }
17163
17253
  function metaKey(o2) {
@@ -17194,6 +17284,8 @@ function toItem(o2, action, sent) {
17194
17284
  closed_at: o2.closedAt,
17195
17285
  merge_commit_sha: o2.mergeCommitSha,
17196
17286
  body: o2.body,
17287
+ head_repo: o2.headRepo,
17288
+ ...o2.baseRepo ? { base_repo: o2.baseRepo } : {},
17197
17289
  ...action === "synchronize" && sent.seq !== void 0 ? { seq: sent.seq } : {}
17198
17290
  };
17199
17291
  }
@@ -17921,7 +18013,12 @@ async function pollPrsOnce(entry, deps) {
17921
18013
  const kinds = new Map(results.map((r) => [r.number, r.kind]));
17922
18014
  for (const item of plan.send) {
17923
18015
  const kind = kinds.get(item.number);
17924
- if (kind !== void 0) {
18016
+ if (kind === "held") {
18017
+ const fork = !!item.head_repo && !!item.base_repo && item.head_repo.toLowerCase() !== item.base_repo.toLowerCase();
18018
+ deps.log(
18019
+ fork ? ` ${repo}: PR #${item.number} ${item.action} \u2192 held: it comes from a fork, and a locally watched repo does not review fork pull requests (there is no way to approve one without the GitHub App).` : ` ${repo}: PR #${item.number} ${item.action} \u2192 held: the server did not approve it for review (its head repo could not be confirmed as this repo, e.g. GitHub did not report it).`
18020
+ );
18021
+ } else if (kind !== void 0) {
17925
18022
  deps.log(` ${repo}: PR #${item.number} ${item.action} \u2192 ${kind}`);
17926
18023
  }
17927
18024
  if (kind !== void 0 && !UNDELIVERED.has(kind)) continue;
@@ -18343,6 +18440,26 @@ function expandHome(p, home2) {
18343
18440
  if (p.startsWith("~/")) return join20(home2, p.slice(2));
18344
18441
  return p;
18345
18442
  }
18443
+ function sandboxTokenLocation(env, home2 = homedir6()) {
18444
+ const raw = (env.ARCHME_SANDBOX_CLAUDE_TOKEN_FILE ?? "").trim();
18445
+ return raw ? { path: expandHome(raw, home2), override: true } : { path: join20(home2, ".archme", "sandbox-claude-token"), override: false };
18446
+ }
18447
+ function resolveSandboxImage(env, shippedImage) {
18448
+ let image = (env.ARCHME_SANDBOX_IMAGE ?? "").trim();
18449
+ if (!image) {
18450
+ try {
18451
+ image = shippedImage() ?? "";
18452
+ } catch (e) {
18453
+ throw new SandboxSettingsError(`the shipped sandbox image is unusable: ${e.message}`);
18454
+ }
18455
+ }
18456
+ if (!image) {
18457
+ throw new SandboxSettingsError(
18458
+ "no sandbox image: this install ships no sandbox-image.json, so set ARCHME_SANDBOX_IMAGE"
18459
+ );
18460
+ }
18461
+ return image;
18462
+ }
18346
18463
  function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage = () => null) {
18347
18464
  const modeRaw = (env.ARCHME_SANDBOX ?? "").trim().toLowerCase() || "off";
18348
18465
  if (modeRaw !== "off" && modeRaw !== "shadow" && modeRaw !== "required") {
@@ -18358,14 +18475,10 @@ function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage
18358
18475
  boxCpus: SANDBOX_DEFAULTS.boxCpus,
18359
18476
  volumeGiB: SANDBOX_DEFAULTS.volumeGiB,
18360
18477
  maxModelRequests: SANDBOX_DEFAULTS.maxModelRequests,
18361
- claudeTokenPath: join20(home2, ".archme", "sandbox-claude-token")
18478
+ claudeTokenPath: sandboxTokenLocation({}, home2).path,
18479
+ claudeTokenOverride: false
18362
18480
  };
18363
18481
  if (mode === "off") return { mode, ...base };
18364
- if (mode === "required") {
18365
- throw new SandboxSettingsError(
18366
- "ARCHME_SANDBOX=required needs network confinement (pf + the GitHub gatekeeper, step 19.3), which this release does not have; use ARCHME_SANDBOX=shadow"
18367
- );
18368
- }
18369
18482
  if (!Number.isInteger(concurrency)) {
18370
18483
  throw new SandboxSettingsError(`MAX_CONCURRENT_JOBS must be a whole number (got ${concurrency})`);
18371
18484
  }
@@ -18378,23 +18491,11 @@ function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage
18378
18491
  if (runtime !== "apple-container") {
18379
18492
  throw new SandboxSettingsError(`ARCHME_SANDBOX_RUNTIME must be apple-container (got "${runtime}")`);
18380
18493
  }
18381
- let image = (env.ARCHME_SANDBOX_IMAGE ?? "").trim();
18382
- if (!image) {
18383
- try {
18384
- image = shippedImage() ?? "";
18385
- } catch (e) {
18386
- throw new SandboxSettingsError(`the shipped sandbox image is unusable: ${e.message}`);
18387
- }
18388
- }
18389
- if (!image) {
18390
- throw new SandboxSettingsError(
18391
- "no sandbox image: this install ships no sandbox-image.json, so set ARCHME_SANDBOX_IMAGE"
18392
- );
18393
- }
18394
- const projects = new Set(
18494
+ const image = resolveSandboxImage(env, shippedImage);
18495
+ const projects = mode === "required" ? /* @__PURE__ */ new Set() : new Set(
18395
18496
  (env.ARCHME_SANDBOX_PROJECTS ?? "").split(",").map((s) => s.trim()).filter(Boolean)
18396
18497
  );
18397
- const tokenRaw = (env.ARCHME_SANDBOX_CLAUDE_TOKEN_FILE ?? "").trim();
18498
+ const token = sandboxTokenLocation(env, home2);
18398
18499
  return {
18399
18500
  mode,
18400
18501
  projects,
@@ -18407,7 +18508,8 @@ function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage
18407
18508
  boxCpus: SANDBOX_DEFAULTS.boxCpus,
18408
18509
  volumeGiB: intEnv(env, "ARCHME_SANDBOX_VOLUME_GIB", SANDBOX_DEFAULTS.volumeGiB, { min: 1 }),
18409
18510
  maxModelRequests: intEnv(env, "ARCHME_SANDBOX_MAX_MODEL_REQUESTS", SANDBOX_DEFAULTS.maxModelRequests, { min: 1 }),
18410
- claudeTokenPath: tokenRaw ? expandHome(tokenRaw, home2) : base.claudeTokenPath
18511
+ claudeTokenPath: token.path,
18512
+ claudeTokenOverride: token.override
18411
18513
  };
18412
18514
  }
18413
18515
  function shouldSandbox(settings, job, { isLocalRepo }) {
@@ -18483,6 +18585,132 @@ var init_imageManifest = __esm({
18483
18585
  }
18484
18586
  });
18485
18587
 
18588
+ // src/daemon/sandboxJob/result.ts
18589
+ import { readFile as readFile13 } from "node:fs/promises";
18590
+ function isValidNonce(nonce) {
18591
+ return typeof nonce === "string" && RESULT_NONCE_RE.test(nonce);
18592
+ }
18593
+ function encode(result, nonce) {
18594
+ return `${RESULT_PREFIX}${nonce} ${Buffer.from(JSON.stringify(result), "utf8").toString("base64")}`;
18595
+ }
18596
+ function fits(line, cap) {
18597
+ return Buffer.byteLength(line, "utf8") <= cap;
18598
+ }
18599
+ function withTruncatedError(r) {
18600
+ return { ...r, error: r.error ? `${r.error}; ${RESULT_TRUNCATED}` : RESULT_TRUNCATED };
18601
+ }
18602
+ function minimal(r) {
18603
+ if (r.kind === "probe") {
18604
+ const m2 = { kind: "probe", outcomes: {}, memoryPeakBytes: r.memoryPeakBytes, durations_ms: {}, error: RESULT_TRUNCATED };
18605
+ return m2;
18606
+ }
18607
+ if (r.kind === "clone") {
18608
+ const m2 = { kind: "clone", outcome: r.outcome, memoryPeakBytes: r.memoryPeakBytes, durations_ms: {}, error: RESULT_TRUNCATED };
18609
+ return m2;
18610
+ }
18611
+ const m = {
18612
+ kind: "agent",
18613
+ exitCode: r.exitCode,
18614
+ signal: r.signal,
18615
+ claudeStdout: "",
18616
+ affected: null,
18617
+ promptBytes: r.promptBytes,
18618
+ memoryPeakBytes: r.memoryPeakBytes,
18619
+ durations_ms: {},
18620
+ error: RESULT_TRUNCATED
18621
+ };
18622
+ return m;
18623
+ }
18624
+ function formatResultLine(result, nonce, cap = RESULT_MAX_BYTES) {
18625
+ if (!isValidNonce(nonce)) throw new Error("result: invalid nonce");
18626
+ const encode_ = (r) => encode(r, nonce);
18627
+ let line = encode_(result);
18628
+ if (fits(line, cap)) return line;
18629
+ if (result.kind === "agent") {
18630
+ const noAffected = { ...result, affected: null };
18631
+ line = encode_(withTruncatedError(noAffected));
18632
+ if (fits(line, cap)) return line;
18633
+ line = encode_(withTruncatedError({ ...noAffected, claudeStdout: "" }));
18634
+ if (fits(line, cap)) return line;
18635
+ }
18636
+ return encode_(minimal(result));
18637
+ }
18638
+ function isObj(v) {
18639
+ return typeof v === "object" && v !== null && !Array.isArray(v);
18640
+ }
18641
+ function isNumOrNull(v) {
18642
+ return v === null || typeof v === "number" && Number.isFinite(v);
18643
+ }
18644
+ function shapeError(o2) {
18645
+ if (!isNumOrNull(o2.memoryPeakBytes)) return "memoryPeakBytes";
18646
+ if (!isObj(o2.durations_ms) || !Object.values(o2.durations_ms).every((v) => typeof v === "number")) return "durations_ms";
18647
+ if (o2.error !== void 0 && typeof o2.error !== "string") return "error";
18648
+ if (o2.kind === "clone") {
18649
+ if (o2.outcome !== "diff" && o2.outcome !== "skip" && o2.outcome !== "rejected") return "outcome";
18650
+ if (o2.diff !== void 0 && !isObj(o2.diff)) return "diff";
18651
+ return null;
18652
+ }
18653
+ if (o2.kind === "probe") {
18654
+ if (!isObj(o2.outcomes) || !Object.values(o2.outcomes).every((v) => typeof v === "string")) return "outcomes";
18655
+ return null;
18656
+ }
18657
+ if (o2.kind === "agent") {
18658
+ if (!isNumOrNull(o2.exitCode)) return "exitCode";
18659
+ if (o2.signal !== null && typeof o2.signal !== "string") return "signal";
18660
+ if (typeof o2.claudeStdout !== "string") return "claudeStdout";
18661
+ if (o2.affected !== null && !(isObj(o2.affected) && ["stories", "components", "unresolvedFiles"].every((k) => Array.isArray(o2.affected[k])))) {
18662
+ return "affected";
18663
+ }
18664
+ if (typeof o2.promptBytes !== "number") return "promptBytes";
18665
+ return null;
18666
+ }
18667
+ return "kind";
18668
+ }
18669
+ function parseResultLine(stdout, nonce, cap = RESULT_MAX_BYTES) {
18670
+ if (!isValidNonce(nonce)) return { ok: false, reason: "no result line" };
18671
+ const prefix = `${RESULT_PREFIX}${nonce} `;
18672
+ const lines = stdout.split("\n");
18673
+ let line;
18674
+ for (let i2 = lines.length - 1; i2 >= 0; i2--) {
18675
+ const l = lines[i2].replace(/\r$/, "");
18676
+ if (l.startsWith(prefix)) {
18677
+ line = l;
18678
+ break;
18679
+ }
18680
+ }
18681
+ if (line === void 0) return { ok: false, reason: "no result line" };
18682
+ if (Buffer.byteLength(line, "utf8") > cap) return { ok: false, reason: "result line exceeds cap" };
18683
+ const b64 = line.slice(prefix.length).trim();
18684
+ if (b64 === "" || b64.length % 4 !== 0 || !BASE64_RE.test(b64)) return { ok: false, reason: "result is not base64" };
18685
+ let data;
18686
+ try {
18687
+ data = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
18688
+ } catch {
18689
+ return { ok: false, reason: "result is not JSON" };
18690
+ }
18691
+ if (!isObj(data)) return { ok: false, reason: "result is not an object" };
18692
+ const bad = shapeError(data);
18693
+ if (bad) return { ok: false, reason: `result has a malformed ${bad}` };
18694
+ return { ok: true, result: data };
18695
+ }
18696
+ async function readMemoryPeak(path7 = "/sys/fs/cgroup/memory.peak", read = (p) => readFile13(p, "utf8")) {
18697
+ try {
18698
+ const n2 = Number.parseInt((await read(path7)).trim(), 10);
18699
+ return Number.isFinite(n2) && n2 >= 0 ? n2 : null;
18700
+ } catch {
18701
+ return null;
18702
+ }
18703
+ }
18704
+ var RESULT_TRUNCATED, BASE64_RE;
18705
+ var init_result2 = __esm({
18706
+ "src/daemon/sandboxJob/result.ts"() {
18707
+ "use strict";
18708
+ init_types();
18709
+ RESULT_TRUNCATED = "result truncated";
18710
+ BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
18711
+ }
18712
+ });
18713
+
18486
18714
  // src/daemon/sandbox/inputDir.ts
18487
18715
  import { mkdtemp as mkdtemp3, readdir as readdir2, rm as rm7, writeFile as writeFile10, chmod as chmod4 } from "node:fs/promises";
18488
18716
  import { tmpdir as tmpdir3 } from "node:os";
@@ -18514,893 +18742,1272 @@ var init_inputDir = __esm({
18514
18742
  }
18515
18743
  });
18516
18744
 
18517
- // src/daemon/sandbox/host.ts
18518
- import { constants as fsConstants } from "node:fs";
18519
- import { open as open3 } from "node:fs/promises";
18520
- function sandboxBootFailurePolicy(mode) {
18521
- return mode === "shadow" ? "run_unboxed" : "refuse";
18522
- }
18523
- function shutdownKillTargets(boxedJobs2, registry) {
18524
- return [...boxedJobs2].filter((id) => registry.hasLiveBoxes(id));
18525
- }
18526
- async function readClaudeTokenFile(path7) {
18527
- let fh;
18528
- try {
18529
- fh = await open3(path7, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
18530
- } catch (e) {
18531
- throw new SandboxBootError(`cannot read the sandbox Claude token at ${path7}: ${e.message}`);
18532
- }
18533
- try {
18534
- const st = await fh.stat();
18535
- if (!st.isFile()) throw new SandboxBootError(`sandbox Claude token ${path7} is not a regular file`);
18536
- if ((st.mode & 63) !== 0) {
18537
- throw new SandboxBootError(
18538
- `sandbox Claude token ${path7} is readable by group or others (mode ${(st.mode & 511).toString(8)}); chmod 600 it`
18539
- );
18745
+ // src/daemon/sandbox/sweep.ts
18746
+ async function sweepJobs(deps) {
18747
+ const now = deps.now ?? Date.now;
18748
+ const t0 = now();
18749
+ const failures = [];
18750
+ const boxes = [];
18751
+ const volumes = [];
18752
+ const jobBoxes = (await deps.list()).filter(isJobBox);
18753
+ await Promise.all(jobBoxes.map(async (b) => {
18754
+ try {
18755
+ await deps.kill(b.id).catch(() => void 0);
18756
+ await deps.remove(b.id);
18757
+ boxes.push(b.id);
18758
+ } catch (e) {
18759
+ failures.push(`box ${b.id}: ${e.message}`);
18540
18760
  }
18541
- const token = (await fh.readFile("utf8")).trim();
18542
- if (!token) throw new SandboxBootError(`sandbox Claude token ${path7} is empty`);
18543
- if (!/^[\x21-\x7e]+$/.test(token)) {
18544
- throw new SandboxBootError(
18545
- `sandbox Claude token ${path7} contains whitespace or control characters; save it as one line`
18546
- );
18761
+ }));
18762
+ const jobVolumes = (await deps.listVolumes()).filter(isJobVolume);
18763
+ for (const v of jobVolumes) {
18764
+ try {
18765
+ await deps.removeVolume(v.name);
18766
+ volumes.push(v.name);
18767
+ } catch (e) {
18768
+ failures.push(`volume ${v.name}: ${e.message}`);
18547
18769
  }
18548
- return token;
18549
- } finally {
18550
- await fh.close();
18551
18770
  }
18771
+ const report = { boxes: boxes.sort(), volumes, ms: now() - t0 };
18772
+ if (failures.length > 0) throw new SweepError(report, failures);
18773
+ return report;
18552
18774
  }
18553
- async function bootSandbox(settings, parts, deps) {
18554
- const sweep = await parts.runtime.sweep();
18555
- const inputDirs = await (deps.sweepInputDirs ?? sweepInputDirs)();
18556
- deps.log(
18557
- ` sandbox: swept ${sweep.boxes.length} box(es), ${sweep.volumes.length} volume(s) in ${sweep.ms}ms, ${inputDirs} input dir(s)`
18558
- );
18559
- const slots = await parts.runtime.ensureSlots(settings.slots);
18560
- const pool = parts.makePool(slots);
18561
- const claudeToken = await (deps.readClaudeToken ?? readClaudeTokenFile)(settings.claudeTokenPath);
18562
- const gates = await parts.startGates(slots, {
18563
- mcpBaseUrl: deps.mcpBaseUrl,
18564
- claudeToken,
18565
- remint: deps.remint,
18566
- log: deps.log
18567
- });
18568
- let preflight;
18569
- try {
18570
- preflight = await parts.runtime.preflight();
18571
- } catch (e) {
18572
- await gates.close().catch(() => {
18573
- });
18574
- throw e;
18575
- }
18576
- if (!preflight.ok) {
18577
- await gates.close().catch(() => {
18578
- });
18579
- const failed = preflight.checks.filter((c3) => !c3.ok).map((c3) => `${c3.name}${c3.detail ? ` (${c3.detail})` : ""}`);
18580
- throw new SandboxBootError(`sandbox preflight failed: ${failed.join(", ") || "unknown"}`);
18581
- }
18582
- return {
18583
- host: { settings, runtime: parts.runtime, registry: parts.registry, gates, pool, io: parts.io },
18584
- sweep,
18585
- preflight
18586
- };
18587
- }
18588
- var SandboxBootError;
18589
- var init_host = __esm({
18590
- "src/daemon/sandbox/host.ts"() {
18775
+ var JOB_VOLUME_PREFIX, SweepError, isJobBox, isJobVolume;
18776
+ var init_sweep = __esm({
18777
+ "src/daemon/sandbox/sweep.ts"() {
18591
18778
  "use strict";
18592
- init_inputDir();
18593
- SandboxBootError = class extends Error {
18594
- constructor(message2) {
18595
- super(message2);
18596
- this.name = "SandboxBootError";
18779
+ init_types();
18780
+ JOB_VOLUME_PREFIX = "archme-job-";
18781
+ SweepError = class extends Error {
18782
+ constructor(report, failures) {
18783
+ super(`sandbox sweep left ${failures.length} item(s): ${failures.join("; ")}`);
18784
+ this.report = report;
18785
+ this.failures = failures;
18786
+ this.name = "SweepError";
18597
18787
  }
18598
18788
  };
18789
+ isJobBox = (b) => JOB_LABEL in b.labels;
18790
+ isJobVolume = (v) => v.name.startsWith(JOB_VOLUME_PREFIX) || JOB_LABEL in v.labels;
18599
18791
  }
18600
18792
  });
18601
18793
 
18602
- // src/daemon/sandbox/runSandboxedJob.ts
18603
- function sandboxJobOf(job) {
18794
+ // src/daemon/sandbox/appleContainer.ts
18795
+ import { randomBytes as randomBytes2 } from "node:crypto";
18796
+ import { homedir as homedir7 } from "node:os";
18797
+ import { isAbsolute, normalize } from "node:path";
18798
+ function assertName(kind, value) {
18799
+ if (!NAME_RE.test(value)) throw new Error(`sandbox: invalid ${kind} name ${JSON.stringify(value)}`);
18800
+ }
18801
+ function assertImage(image) {
18802
+ if (!image || image.startsWith("-") || /\s/.test(image)) {
18803
+ throw new Error(`sandbox: invalid image ${JSON.stringify(image)}`);
18804
+ }
18805
+ }
18806
+ function labelArgs(labels) {
18807
+ const out = [];
18808
+ for (const [k, v] of Object.entries(labels)) {
18809
+ if (!LABEL_KEY_RE.test(k)) throw new Error(`sandbox: invalid label key ${JSON.stringify(k)}`);
18810
+ if (CONTROL_RE.test(v)) throw new Error(`sandbox: label ${k} value contains a control character`);
18811
+ out.push("--label", `${k}=${v}`);
18812
+ }
18813
+ return out;
18814
+ }
18815
+ function assertInputDir(dir, home2 = homedir7()) {
18816
+ if (!isAbsolute(dir)) throw new Error(`sandbox: inputDir must be absolute: ${dir}`);
18817
+ const strip2 = (p) => p.replace(/\/+$/, "") || "/";
18818
+ const n2 = strip2(normalize(dir));
18819
+ if (n2 !== strip2(dir)) throw new Error(`sandbox: inputDir must be normalized: ${dir}`);
18820
+ if (/[:,]/.test(n2) || CONTROL_RE.test(n2)) throw new Error(`sandbox: inputDir has a forbidden character: ${dir}`);
18821
+ const homeN = strip2(normalize(home2));
18822
+ if (n2 === "/" || n2 === homeN || /^\/(Users|home)(\/[^/]+)?$/.test(n2) || n2 === "/root" || n2 === "/var/root") {
18823
+ throw new Error(`sandbox: inputDir must not be / or a home directory: ${dir}`);
18824
+ }
18825
+ }
18826
+ function envArgs(env) {
18827
+ const out = [];
18828
+ for (const [k, v] of Object.entries(env)) {
18829
+ if (!ENV_KEY_RE.test(k)) throw new Error(`sandbox: invalid env key ${JSON.stringify(k)}`);
18830
+ if (CONTROL_RE.test(v)) throw new Error(`sandbox: env ${k} value contains a newline or NUL`);
18831
+ out.push("-e", `${k}=${v}`);
18832
+ }
18833
+ return out;
18834
+ }
18835
+ function slotFor(index) {
18836
+ if (!Number.isInteger(index) || index < 0 || index >= MAX_SLOTS) {
18837
+ throw new Error(`sandbox: slot index out of range: ${index}`);
18838
+ }
18839
+ const third = SLOT_RANGE_BASE_THIRD_OCTET + index;
18604
18840
  return {
18605
- id: job.id,
18606
- project_id: job.project_id,
18607
- repo_owner: job.repo_owner,
18608
- repo_name: job.repo_name,
18609
- repo_branch: job.repo_branch,
18610
- archme_branch: job.archme_branch,
18611
- before_sha: job.before_sha,
18612
- after_sha: job.after_sha,
18613
- forced: job.forced,
18614
- pr_number: job.pr_number ?? null,
18615
- source_mode: job.source_mode,
18616
- commit_message: job.commit_message ?? null,
18617
- head_commit_timestamp: job.head_commit_timestamp ?? null,
18618
- author_name: job.author_name ?? null,
18619
- author_login: job.author_login ?? null
18841
+ index,
18842
+ network: `archme-slot-${index}`,
18843
+ subnet: `192.168.${third}.0/24`,
18844
+ gateway: `192.168.${third}.1`
18620
18845
  };
18621
18846
  }
18622
- async function runSandboxedJob(deps, job, opts) {
18623
- const { slot } = opts;
18624
- const t = { ...DEFAULT_SANDBOX_TIMEOUTS, ...opts.timeouts };
18625
- const pollMs = opts.pollMs ?? 1e3;
18626
- const now = deps.now ?? Date.now;
18627
- const { settings } = deps;
18628
- const volume = `archme-job-${job.id}`;
18629
- const labels = { [JOB_LABEL]: job.id, [SLOT_LABEL]: String(slot.index) };
18630
- const sjob = sandboxJobOf(job);
18631
- const sandbox = {
18632
- slot: slot.index,
18633
- memory_peak_bytes: { clone: null, agent: null },
18634
- model_requests: 0,
18635
- upstream_429: 0,
18636
- last_upstream_status: null,
18637
- cap_hits: 0,
18638
- mcp_requests: 0,
18639
- remints: 0,
18640
- clone_ms: null,
18641
- box_ms: 0
18642
- };
18643
- const durations = {};
18644
- const metrics = { sandbox, durations_ms: durations };
18645
- const liveBoxes = /* @__PURE__ */ new Set();
18646
- const inputCleanups = [];
18647
- let volumeTouched = false;
18648
- let agentStarted = false;
18649
- const readCounters = () => {
18650
- const c3 = deps.gates.countersFor(slot);
18651
- if (!c3) return;
18652
- sandbox.model_requests = c3.model_requests;
18653
- sandbox.upstream_429 = c3.upstream_429;
18654
- sandbox.last_upstream_status = c3.last_upstream_status;
18655
- sandbox.cap_hits = c3.cap_hits;
18656
- sandbox.mcp_requests = c3.mcp_requests;
18657
- sandbox.remints = c3.remints;
18658
- };
18659
- const failed = (step, reason, stderr = "") => {
18660
- deps.log(`sandbox ${step} step failed: ${reason}`);
18661
- return { kind: "step_failed", step, reason, stderr, metrics };
18662
- };
18663
- const currentKillCause = () => {
18664
- if (deps.isCancelled(job.id)) return "cancel";
18665
- if (deps.killReason(job.id) === "shutdown") return "shutdown";
18666
- if (deps.isLeaseLost(job.id)) return "lease_lost";
18667
- return null;
18668
- };
18669
- const writeInput = async (step, input) => {
18670
- const w = await deps.writeInputDir(step, input);
18671
- let done = false;
18672
- const cleanup = async () => {
18673
- if (done) return;
18674
- done = true;
18675
- await w.cleanup().catch(() => {
18676
- });
18677
- };
18678
- inputCleanups.push(cleanup);
18679
- return { dir: w.dir, cleanup };
18680
- };
18681
- const runBox = async (spec2, timeoutMs) => {
18682
- if (deps.isCancelled(job.id)) return { kind: "refused", reason: "cancel" };
18683
- if (!await deps.registry.beginStep(job.id, spec2.name)) return { kind: "refused", reason: "shutting_down" };
18684
- liveBoxes.add(spec2.name);
18685
- const started = now();
18686
- let killCause = null;
18687
- const kill = (cause) => {
18688
- if (killCause) return;
18689
- killCause = cause;
18690
- deps.log(`killing ${spec2.name} (${cause})`);
18691
- if (cause === "lease_lost") deps.gates.clearJob(slot);
18692
- void deps.runtime.kill(spec2.name).catch((e) => deps.log(`kill ${spec2.name} failed: ${e.message}`));
18693
- };
18694
- if (spec2.args[0] === "agent") agentStarted = true;
18695
- const handle = await deps.runtime.runStep(spec2);
18696
- const timer = setTimeout(() => kill("timeout"), timeoutMs);
18697
- const poll = setInterval(() => {
18698
- const c3 = currentKillCause();
18699
- if (c3) kill(c3);
18700
- }, pollMs);
18701
- try {
18702
- const exit = await handle.done;
18703
- liveBoxes.delete(spec2.name);
18704
- deps.registry.endStep(job.id, spec2.name);
18705
- if (!killCause && exit.killed) killCause = currentKillCause() ?? "shutdown";
18706
- return { kind: "ran", exit, killCause, ms: now() - started };
18707
- } finally {
18708
- clearTimeout(timer);
18709
- clearInterval(poll);
18710
- }
18711
- };
18712
- const spec = (step, network, env, inputDir) => ({
18713
- name: `archme-job-${job.id}-${step}`,
18714
- image: settings.image,
18715
- args: [step],
18847
+ function slotNetwork(slot) {
18848
+ if (!slot || typeof slot !== "object") throw new Error("sandbox: a step needs a slot network");
18849
+ const want = slotFor(slot.index);
18850
+ if (slot.network !== want.network) throw new Error(`sandbox: ${slot.network} is not slot ${slot.index}'s network`);
18851
+ return want.network;
18852
+ }
18853
+ function buildRunArgs(spec, home2) {
18854
+ assertName("box", spec.name);
18855
+ if (spec.volume !== void 0) assertName("volume", spec.volume);
18856
+ assertImage(spec.image);
18857
+ assertInputDir(spec.inputDir, home2);
18858
+ if (!spec.labels[JOB_LABEL]) throw new Error(`sandbox: step labels must include ${JOB_LABEL}`);
18859
+ const { cpus, memoryMiB } = spec.limits;
18860
+ if (!Number.isInteger(cpus) || cpus < 1) throw new Error(`sandbox: invalid cpus ${cpus}`);
18861
+ if (!Number.isInteger(memoryMiB) || memoryMiB < MIN_MEMORY_MIB) {
18862
+ throw new Error(`sandbox: memoryMiB must be an integer \u2265 ${MIN_MEMORY_MIB}: ${memoryMiB}`);
18863
+ }
18864
+ const network = slotNetwork(spec.network);
18865
+ return [
18866
+ // `-i`: stdin carries the per-step result nonce (one line, then EOF) to
18867
+ // the wrapper — the only channel that is neither a file, env nor argv.
18868
+ "run",
18869
+ "-i",
18870
+ "--rm",
18871
+ "--init",
18872
+ "--read-only",
18873
+ "--cap-drop",
18874
+ "ALL",
18875
+ "--user",
18876
+ `${BOX_UID}:${BOX_UID}`,
18877
+ "--tmpfs",
18878
+ "/tmp",
18879
+ "--no-dns",
18880
+ "--kernel-arg",
18881
+ "ipv6.disable=1",
18882
+ // Progress lines otherwise land on stderr ahead of the box's own output.
18883
+ "--progress",
18884
+ "none",
18885
+ "--cpus",
18886
+ String(cpus),
18887
+ "--memory",
18888
+ `${memoryMiB}M`,
18889
+ "--name",
18890
+ spec.name,
18891
+ ...labelArgs(spec.labels),
18892
+ "--network",
18716
18893
  network,
18717
- env,
18718
- inputDir,
18719
- volume,
18720
- limits: { cpus: settings.boxCpus, memoryMiB: settings.boxMemoryMiB },
18721
- labels: { ...labels }
18722
- });
18723
- const runBoxes = async (pre) => {
18724
- deps.registry.setVolume(job.id, volume);
18725
- volumeTouched = true;
18726
- await deps.runtime.createVolume(volume, settings.volumeGiB, labels);
18727
- await deps.runtime.initVolume(volume, BOX_UID, labels, settings.image);
18728
- deps.gates.bindJob(slot, {
18729
- jobId: job.id,
18730
- maxModelRequests: settings.maxModelRequests,
18731
- mcp: {
18732
- token: pre.jobToken.token,
18733
- expiresAt: pre.jobToken.expires_at ? Date.parse(pre.jobToken.expires_at) || null : null,
18734
- projectSlug: pre.projectSlug,
18735
- branch: job.archme_branch
18736
- }
18737
- });
18738
- const cloneIn = await writeInput("clone", {
18739
- kind: "clone",
18740
- job: sjob,
18741
- marker: pre.marker,
18742
- credentials: {
18743
- https_url: pre.credentials.https_url,
18744
- username: pre.credentials.username,
18745
- password: pre.credentials.password
18746
- }
18747
- });
18748
- const clone = await runBox(spec("clone", "nat", deps.cloneBoxEnv(), cloneIn.dir), t.cloneMs);
18749
- await cloneIn.cleanup();
18750
- if (clone.kind === "refused") return failed("clone", clone.reason);
18751
- sandbox.clone_ms = clone.ms;
18752
- sandbox.box_ms += clone.ms;
18753
- durations.sandbox_clone = clone.ms;
18754
- if (clone.killCause) return failed("clone", clone.killCause, clone.exit.stderr);
18755
- const cp = deps.parseResultLine(clone.exit.stdout, clone.exit.nonce);
18756
- if (!cp.ok || cp.result.kind !== "clone") {
18757
- deps.log(`clone result line: ${cp.ok ? `unexpected kind ${cp.result.kind}` : cp.reason}`);
18758
- return failed("clone", "result_malformed", clone.exit.stderr);
18759
- }
18760
- const cr = cp.result;
18761
- sandbox.memory_peak_bytes.clone = cr.memoryPeakBytes;
18762
- for (const [k, v] of Object.entries(cr.durations_ms ?? {})) durations[`clone_${k}`] = v;
18763
- if (cr.outcome === "skip") return { kind: "skip", metrics };
18764
- if (cr.outcome === "rejected") return { kind: "rejected", message: cr.error ?? "job rejected by the clone step", metrics };
18765
- if (cr.error || clone.exit.exitCode !== 0 || !cr.diff) {
18766
- return failed("clone", cr.error ?? `clone step exited ${clone.exit.exitCode}`, clone.exit.stderr);
18767
- }
18768
- Object.assign(metrics, {
18769
- mode: job.source_mode === "pr_update" ? "pr_update" : "diff",
18770
- files: cr.diff.files,
18771
- bytes: cr.diff.bytes,
18772
- materialized: cr.diff.materialized,
18773
- range: cr.diff.rangeLabel,
18774
- model: pre.model
18775
- });
18776
- const claudeMs = cr.diff.materialized ? t.largeDiffClaudeMs : t.claudeMs;
18777
- const agentIn = await writeInput("agent", {
18778
- kind: "agent",
18779
- job: sjob,
18780
- projectSlug: pre.projectSlug,
18781
- seedArch: pre.seedArch,
18782
- repoScope: pre.repoScope,
18783
- repoContainer: pre.repoContainer,
18784
- bugsFolderId: pre.bugsFolderId,
18785
- model: pre.model,
18786
- timeoutMs: claudeMs,
18787
- gateway: slot.gateway
18788
- });
18789
- const agent = await runBox(spec("agent", slot, deps.agentBoxEnv(slot.gateway), agentIn.dir), claudeMs + t.graceMs);
18790
- await agentIn.cleanup();
18791
- if (agent.kind === "refused") return failed("agent", agent.reason);
18792
- sandbox.box_ms += agent.ms;
18793
- durations.sandbox_agent = agent.ms;
18794
- readCounters();
18795
- if (agent.killCause) return failed("agent", agent.killCause, agent.exit.stderr);
18796
- const ap = deps.parseResultLine(agent.exit.stdout, agent.exit.nonce);
18797
- if (!ap.ok || ap.result.kind !== "agent") {
18798
- deps.log(`agent result line: ${ap.ok ? `unexpected kind ${ap.result.kind}` : ap.reason}`);
18799
- return failed("agent", "result_malformed", agent.exit.stderr);
18800
- }
18801
- const ar = ap.result;
18802
- sandbox.memory_peak_bytes.agent = ar.memoryPeakBytes;
18803
- for (const [k, v] of Object.entries(ar.durations_ms ?? {})) durations[`agent_${k}`] = v;
18804
- if (ar.seed) metrics.seed = ar.seed;
18805
- if (ar.error) return failed("agent", ar.error, agent.exit.stderr);
18894
+ // Verified: `:ro` on a directory bind is a read-only virtiofs mount — even
18895
+ // root in the box gets EROFS on create/mkdir.
18896
+ "-v",
18897
+ `${spec.inputDir}:/job:ro`,
18898
+ ...spec.volume !== void 0 ? ["-v", `${spec.volume}:/work`] : [],
18899
+ ...envArgs(spec.env),
18900
+ spec.image,
18901
+ ...spec.args
18902
+ ];
18903
+ }
18904
+ function buildInitVolumeArgs(volume, uid, labels, image, slot) {
18905
+ assertName("volume", volume);
18906
+ assertImage(image);
18907
+ const network = slotNetwork(slot);
18908
+ if (!Number.isInteger(uid) || uid <= 0) throw new Error(`sandbox: invalid uid ${uid}`);
18909
+ return [
18910
+ "run",
18911
+ "--rm",
18912
+ "--init",
18913
+ "--read-only",
18914
+ "--cap-drop",
18915
+ "ALL",
18916
+ "--cap-add",
18917
+ "CHOWN",
18918
+ "--user",
18919
+ "0:0",
18920
+ "--no-dns",
18921
+ "--kernel-arg",
18922
+ "ipv6.disable=1",
18923
+ "--progress",
18924
+ "none",
18925
+ "--network",
18926
+ network,
18927
+ "--memory",
18928
+ `${INIT_MEMORY_MIB}M`,
18929
+ ...labelArgs(labels),
18930
+ "-v",
18931
+ `${volume}:/work`,
18932
+ "--entrypoint",
18933
+ "chown",
18934
+ image,
18935
+ `${uid}:${uid}`,
18936
+ "/work"
18937
+ ];
18938
+ }
18939
+ function buildCreateVolumeArgs(name, sizeGiB, labels) {
18940
+ assertName("volume", name);
18941
+ if (!Number.isInteger(sizeGiB) || sizeGiB < 1) throw new Error(`sandbox: invalid volume size ${sizeGiB}`);
18942
+ return ["volume", "create", "-s", `${sizeGiB}G`, ...labelArgs(labels), name];
18943
+ }
18944
+ function buildEnsureNetworkArgs(slot) {
18945
+ return [
18946
+ "network",
18947
+ "create",
18948
+ "--internal",
18949
+ "--subnet",
18950
+ slot.subnet,
18951
+ "--label",
18952
+ `${SLOT_LABEL}=${slot.index}`,
18953
+ slot.network
18954
+ ];
18955
+ }
18956
+ function labelsOf(v) {
18957
+ const out = {};
18958
+ for (const [k, x] of Object.entries(obj(v))) if (typeof x === "string") out[k] = x;
18959
+ return out;
18960
+ }
18961
+ function parseArray(json, what) {
18962
+ const parsed = JSON.parse(json.trim() || "[]");
18963
+ if (!Array.isArray(parsed)) throw new Error(`sandbox: ${what} is not a JSON array`);
18964
+ return parsed;
18965
+ }
18966
+ function parseBoxList(json) {
18967
+ return parseArray(json, "container ls").flatMap((raw) => {
18968
+ const r = obj(raw);
18969
+ const id = str(r.id) ?? str(obj(r.configuration).id);
18970
+ if (!id) return [];
18971
+ return [{ id, state: str(obj(r.status).state) ?? "unknown", labels: labelsOf(obj(r.configuration).labels) }];
18972
+ });
18973
+ }
18974
+ function parseVolumeList(json) {
18975
+ return parseArray(json, "container volume ls").flatMap((raw) => {
18976
+ const r = obj(raw);
18977
+ const name = str(obj(r.configuration).name) ?? str(r.id);
18978
+ if (!name) return [];
18979
+ return [{ name, labels: labelsOf(obj(r.configuration).labels) }];
18980
+ });
18981
+ }
18982
+ function parseNetworkList(json) {
18983
+ return parseArray(json, "container network ls").flatMap((raw) => {
18984
+ const r = obj(raw);
18985
+ const c3 = obj(r.configuration);
18986
+ const name = str(c3.name) ?? str(r.id);
18987
+ if (!name) return [];
18988
+ return [{
18989
+ name,
18990
+ subnet: str(c3.ipv4Subnet) ?? str(obj(r.status).ipv4Subnet),
18991
+ mode: str(c3.mode),
18992
+ labels: labelsOf(c3.labels)
18993
+ }];
18994
+ });
18995
+ }
18996
+ function isBoxNotFound(stderr) {
18997
+ return /notFound: "container with ID .* not found"/.test(stderr);
18998
+ }
18999
+ function isBoxStopped(stderr) {
19000
+ return /invalidState: "no runtime client exists: container is stopped"/.test(stderr);
19001
+ }
19002
+ function defaultExec2(bin = "container") {
19003
+ return async (args) => {
19004
+ const r = await execa(bin, args, { reject: false, stdin: "ignore" });
19005
+ const stderr = typeof r.stderr === "string" ? r.stderr : "";
18806
19006
  return {
18807
- kind: "claude",
18808
- exitCode: ar.exitCode,
18809
- signal: ar.signal,
18810
- claudeStdout: ar.claudeStdout,
18811
- stderr: agent.exit.stderr,
18812
- affected: ar.affected,
18813
- metrics
19007
+ exitCode: r.exitCode ?? -1,
19008
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
19009
+ stderr: r.exitCode === void 0 ? `${stderr}
19010
+ ${r.message}`.trim() : stderr
18814
19011
  };
18815
19012
  };
18816
- try {
18817
- const pre = await deps.prefetch(job);
18818
- try {
18819
- return await runBoxes(pre);
18820
- } catch (e) {
18821
- if (e instanceof SandboxInfraError) throw e;
18822
- throw new SandboxInfraError(e instanceof Error ? e.message : String(e), agentStarted);
18823
- }
18824
- } finally {
18825
- const step = async (what, fn) => {
18826
- try {
18827
- await fn();
18828
- } catch (e) {
18829
- deps.log(`sandbox cleanup (${what}) failed: ${e.message}`);
18830
- }
19013
+ }
19014
+ function defaultSpawn(bin = "container") {
19015
+ return (args) => {
19016
+ const p = execa(bin, args, { reject: false, stdin: "pipe", buffer: false });
19017
+ return {
19018
+ stdin: p.stdin,
19019
+ stdout: p.stdout,
19020
+ stderr: p.stderr,
19021
+ exited: p.then((r) => ({
19022
+ exitCode: r.exitCode ?? null,
19023
+ signal: r.signal ?? null,
19024
+ error: r.exitCode === void 0 && !r.signal ? r.message : void 0
19025
+ }))
18831
19026
  };
18832
- for (const name of liveBoxes) await step(`kill ${name}`, () => deps.runtime.kill(name));
18833
- if (volumeTouched) await step("remove volume", () => deps.runtime.removeVolume(volume));
18834
- await step("counters", readCounters);
18835
- await step("clear gates", () => deps.gates.clearJob(slot));
18836
- await step("free slot", () => deps.pool.free(slot.index));
18837
- for (const c3 of inputCleanups) await step("input dir", c3);
18838
- await step("release", () => deps.registry.release(job.id));
18839
- }
19027
+ };
18840
19028
  }
18841
- var DEFAULT_SANDBOX_TIMEOUTS, SandboxInfraError;
18842
- var init_runSandboxedJob = __esm({
18843
- "src/daemon/sandbox/runSandboxedJob.ts"() {
19029
+ var MIN_MEMORY_MIB, STDOUT_CAP_BYTES, STDERR_CAP_BYTES, INIT_MEMORY_MIB, NAME_RE, ENV_KEY_RE, LABEL_KEY_RE, CONTROL_RE, obj, str, TailBuffer, AppleContainerRuntime;
19030
+ var init_appleContainer = __esm({
19031
+ "src/daemon/sandbox/appleContainer.ts"() {
18844
19032
  "use strict";
19033
+ init_execa();
19034
+ init_sweep();
18845
19035
  init_types();
18846
- DEFAULT_SANDBOX_TIMEOUTS = {
18847
- claudeMs: 30 * 60 * 1e3,
18848
- largeDiffClaudeMs: 45 * 60 * 1e3,
18849
- graceMs: 2 * 60 * 1e3,
18850
- cloneMs: 10 * 60 * 1e3
18851
- };
18852
- SandboxInfraError = class extends Error {
18853
- constructor(message2, agentStarted) {
18854
- super(message2);
18855
- this.agentStarted = agentStarted;
18856
- this.name = "SandboxInfraError";
18857
- }
18858
- };
18859
- }
18860
- });
18861
-
18862
- // src/daemon/sandbox/classifySandboxExit.ts
18863
- function classifySandboxExit(input) {
18864
- const { counters, exitCode, stderr, stepFailure } = input;
18865
- if ((counters?.cap_hits ?? 0) > 0 || stderr.includes(SANDBOX_CAP_BODY)) {
18866
- return {
18867
- kind: "terminal",
18868
- reason: SANDBOX_REQUEST_CAP_REASON,
18869
- message: `${SANDBOX_REQUEST_CAP_REASON}: the job exceeded the sandbox model-request cap` + (counters ? ` (${counters.model_requests} requests, ${counters.cap_hits} refused)` : "")
19036
+ MIN_MEMORY_MIB = 200;
19037
+ STDOUT_CAP_BYTES = 4 * 1024 * 1024;
19038
+ STDERR_CAP_BYTES = 1024 * 1024;
19039
+ INIT_MEMORY_MIB = 256;
19040
+ NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/;
19041
+ ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
19042
+ LABEL_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19043
+ CONTROL_RE = /[\u0000\n\r]/;
19044
+ obj = (v) => v && typeof v === "object" ? v : {};
19045
+ str = (v) => typeof v === "string" ? v : null;
19046
+ TailBuffer = class {
19047
+ constructor(cap) {
19048
+ this.cap = cap;
19049
+ }
19050
+ chunks = [];
19051
+ size = 0;
19052
+ truncated = false;
19053
+ push(chunk) {
19054
+ this.chunks.push(chunk);
19055
+ this.size += chunk.length;
19056
+ while (this.size > this.cap && this.chunks.length > 0) {
19057
+ const head = this.chunks[0];
19058
+ const over = this.size - this.cap;
19059
+ this.truncated = true;
19060
+ if (head.length <= over) {
19061
+ this.chunks.shift();
19062
+ this.size -= head.length;
19063
+ } else {
19064
+ this.chunks[0] = head.subarray(over);
19065
+ this.size -= over;
19066
+ }
19067
+ }
19068
+ }
19069
+ toString() {
19070
+ let buf = Buffer.concat(this.chunks);
19071
+ if (this.truncated) {
19072
+ let i2 = 0;
19073
+ while (i2 < buf.length && i2 < 3 && (buf[i2] & 192) === 128) i2++;
19074
+ buf = buf.subarray(i2);
19075
+ }
19076
+ return buf.toString("utf8");
19077
+ }
19078
+ };
19079
+ AppleContainerRuntime = class {
19080
+ constructor(opts = {}) {
19081
+ this.opts = opts;
19082
+ this.exec = opts.exec ?? defaultExec2(opts.bin);
19083
+ this.spawn = opts.spawn ?? defaultSpawn(opts.bin);
19084
+ }
19085
+ name = "apple-container";
19086
+ exec;
19087
+ spawn;
19088
+ /** Boxes `kill()` was called for while their step was running. */
19089
+ killedNames = /* @__PURE__ */ new Set();
19090
+ running = /* @__PURE__ */ new Set();
19091
+ async must(args, what) {
19092
+ const r = await this.exec(args);
19093
+ if (r.exitCode !== 0) throw new Error(`sandbox: ${what} failed (exit ${r.exitCode}): ${r.stderr.trim()}`);
19094
+ return r;
19095
+ }
19096
+ async preflight() {
19097
+ const checks = [];
19098
+ const status = await this.exec(["system", "status"]);
19099
+ const up = status.exitCode === 0 && /^status\s+running\b/m.test(status.stdout);
19100
+ checks.push({ name: "runtime_status", ok: up, detail: up ? void 0 : (status.stderr || status.stdout).trim() });
19101
+ if (this.opts.image) {
19102
+ let img = await this.exec(["image", "inspect", this.opts.image]);
19103
+ if (img.exitCode !== 0 && up && this.opts.image.includes("@sha256:")) {
19104
+ const pull = await this.exec(["image", "pull", this.opts.image]);
19105
+ img = pull.exitCode === 0 ? await this.exec(["image", "inspect", this.opts.image]) : pull;
19106
+ }
19107
+ checks.push({ name: "image_present", ok: img.exitCode === 0, detail: img.exitCode === 0 ? void 0 : img.stderr.trim() });
19108
+ } else {
19109
+ checks.push({ name: "image_present", ok: false, detail: "no image configured" });
19110
+ }
19111
+ const want = this.opts.slots ?? 0;
19112
+ if (up) {
19113
+ const nets = await this.listNetworks();
19114
+ const missing = [];
19115
+ for (let i2 = 0; i2 < want; i2++) {
19116
+ const s = slotFor(i2);
19117
+ const n2 = nets.find((x) => x.name === s.network);
19118
+ if (!n2 || n2.subnet !== s.subnet) missing.push(n2 ? `${s.network} (subnet ${n2.subnet})` : s.network);
19119
+ }
19120
+ checks.push({ name: "slot_networks", ok: missing.length === 0, detail: missing.length ? `missing/wrong: ${missing.join(", ")}` : void 0 });
19121
+ } else {
19122
+ checks.push({ name: "slot_networks", ok: false, detail: "runtime not running" });
19123
+ }
19124
+ return { ok: checks.every((c3) => c3.ok), checks };
19125
+ }
19126
+ async listNetworks() {
19127
+ const r = await this.must(["network", "ls", "--format", "json"], "network ls");
19128
+ return parseNetworkList(r.stdout);
19129
+ }
19130
+ async ensureSlots(n2) {
19131
+ if (!Number.isInteger(n2) || n2 < 1 || n2 > MAX_SLOTS) throw new Error(`sandbox: slot count out of range: ${n2}`);
19132
+ const slots = Array.from({ length: n2 }, (_, i2) => slotFor(i2));
19133
+ let nets = await this.listNetworks();
19134
+ for (const s of slots) {
19135
+ const existing = nets.find((x) => x.name === s.network);
19136
+ if (existing) {
19137
+ if (existing.subnet !== s.subnet) {
19138
+ throw new Error(`sandbox: ${s.network} exists with subnet ${existing.subnet}, expected ${s.subnet}`);
19139
+ }
19140
+ continue;
19141
+ }
19142
+ const r = await this.exec(buildEnsureNetworkArgs(s));
19143
+ if (r.exitCode !== 0) {
19144
+ nets = await this.listNetworks();
19145
+ const now = nets.find((x) => x.name === s.network);
19146
+ if (!now || now.subnet !== s.subnet) {
19147
+ throw new Error(`sandbox: creating ${s.network} failed: ${r.stderr.trim()}`);
19148
+ }
19149
+ }
19150
+ }
19151
+ return slots;
19152
+ }
19153
+ async createVolume(name, sizeGiB, labels) {
19154
+ await this.must(buildCreateVolumeArgs(name, sizeGiB, labels), `volume create ${name}`);
19155
+ }
19156
+ async initVolume(name, uid, labels, image, slot) {
19157
+ await this.must(buildInitVolumeArgs(name, uid, labels, image, slot), `volume init ${name}`);
19158
+ }
19159
+ async runStep(spec) {
19160
+ const args = buildRunArgs(spec, this.opts.homeDir);
19161
+ const name = spec.name;
19162
+ this.running.add(name);
19163
+ this.killedNames.delete(name);
19164
+ const out = new TailBuffer(STDOUT_CAP_BYTES);
19165
+ const err4 = new TailBuffer(STDERR_CAP_BYTES);
19166
+ const nonce = randomBytes2(16).toString("hex");
19167
+ const proc = this.spawn(args);
19168
+ if (proc.stdin) {
19169
+ proc.stdin.on("error", () => {
19170
+ });
19171
+ proc.stdin.end(`${nonce}
19172
+ `);
19173
+ }
19174
+ const tee = (stream, buf, which) => new Promise((resolve7) => {
19175
+ if (!stream) return resolve7();
19176
+ stream.on("data", (c3) => {
19177
+ const b = typeof c3 === "string" ? Buffer.from(c3) : c3;
19178
+ buf.push(b);
19179
+ this.opts.onOutput?.(name, which, b);
19180
+ });
19181
+ stream.on("end", () => resolve7());
19182
+ stream.on("close", () => resolve7());
19183
+ stream.on("error", () => resolve7());
19184
+ });
19185
+ const done = (async () => {
19186
+ const [exit] = await Promise.all([proc.exited, tee(proc.stdout, out, "stdout"), tee(proc.stderr, err4, "stderr")]);
19187
+ const killed = this.killedNames.has(name);
19188
+ this.killedNames.delete(name);
19189
+ this.running.delete(name);
19190
+ const stderr = err4.toString();
19191
+ return {
19192
+ exitCode: exit.exitCode,
19193
+ signal: exit.signal,
19194
+ stdout: out.toString(),
19195
+ stderr: exit.error ? `${stderr}
19196
+ ${exit.error}`.trim() : stderr,
19197
+ killed,
19198
+ nonce
19199
+ };
19200
+ })();
19201
+ return { name, done };
19202
+ }
19203
+ /** Not-found and already-stopped both count as success: the box is not running. */
19204
+ async kill(name) {
19205
+ assertName("box", name);
19206
+ if (this.running.has(name)) this.killedNames.add(name);
19207
+ const r = await this.exec(["kill", name]);
19208
+ if (r.exitCode !== 0 && !isBoxNotFound(r.stderr) && !isBoxStopped(r.stderr)) {
19209
+ throw new Error(`sandbox: kill ${name} failed: ${r.stderr.trim()}`);
19210
+ }
19211
+ }
19212
+ async stop(name, graceSeconds) {
19213
+ assertName("box", name);
19214
+ const t = Math.max(0, Math.floor(graceSeconds));
19215
+ const r = await this.exec(["stop", "-t", String(t), name]);
19216
+ if (r.exitCode !== 0 && !isBoxNotFound(r.stderr) && !isBoxStopped(r.stderr)) {
19217
+ throw new Error(`sandbox: stop ${name} failed: ${r.stderr.trim()}`);
19218
+ }
19219
+ }
19220
+ /** `container rm`; not-found counts as removed (a `--rm` box is gone once stopped). */
19221
+ async remove(name) {
19222
+ assertName("box", name);
19223
+ const r = await this.exec(["rm", name]);
19224
+ if (r.exitCode !== 0 && !isBoxNotFound(r.stderr)) {
19225
+ throw new Error(`sandbox: rm ${name} failed: ${r.stderr.trim()}`);
19226
+ }
19227
+ }
19228
+ async removeVolume(name) {
19229
+ assertName("volume", name);
19230
+ const r = await this.exec(["volume", "rm", name]);
19231
+ if (r.exitCode === 0) return;
19232
+ const still = (await this.listVolumes()).some((v) => v.name === name);
19233
+ if (still) throw new Error(`sandbox: volume rm ${name} failed: ${r.stderr.trim()}`);
19234
+ }
19235
+ async list() {
19236
+ const r = await this.must(["ls", "-a", "--format", "json"], "ls");
19237
+ return parseBoxList(r.stdout);
19238
+ }
19239
+ async listVolumes() {
19240
+ const r = await this.must(["volume", "ls", "--format", "json"], "volume ls");
19241
+ return parseVolumeList(r.stdout);
19242
+ }
19243
+ sweep() {
19244
+ return sweepJobs(this);
19245
+ }
18870
19246
  };
18871
19247
  }
18872
- if (stepFailure == null && exitCode !== null && exitCode !== 0 && counters?.last_upstream_status === 429) {
18873
- return { kind: "upstream_rate_limit" };
19248
+ });
19249
+
19250
+ // src/daemon/sandbox/slotMap.ts
19251
+ function parseIpv4(addr) {
19252
+ const m = IPV4_RE.exec(addr);
19253
+ if (!m) return null;
19254
+ let v = 0;
19255
+ for (let i2 = 1; i2 <= 4; i2++) {
19256
+ const o2 = Number(m[i2]);
19257
+ if (o2 > 255) return null;
19258
+ v = v * 256 + o2;
18874
19259
  }
18875
- return { kind: "normal" };
19260
+ return v >>> 0;
18876
19261
  }
18877
- function routeSandboxFailure(verdict, planFor, nowMs = Date.now()) {
18878
- if (verdict?.kind === "terminal") return { kind: "terminal", message: verdict.message };
18879
- const plan = planFor();
18880
- if (plan) return { kind: "defer", plan };
18881
- if (verdict?.kind === "upstream_rate_limit") {
18882
- return {
18883
- kind: "defer",
18884
- plan: { parsed: false, pauseUntil: new Date(nowMs + RATE_LIMIT_TENTATIVE_PAUSE_MS), info: null }
18885
- };
18886
- }
18887
- return { kind: "fail" };
19262
+ function parseCidr(cidr) {
19263
+ const [ip, bits, extra] = cidr.split("/");
19264
+ if (ip === void 0 || bits === void 0 || extra !== void 0 || !/^\d{1,2}$/.test(bits)) return null;
19265
+ const n2 = Number(bits);
19266
+ const base = parseIpv4(ip);
19267
+ if (base === null || n2 > 32) return null;
19268
+ const mask2 = n2 === 0 ? 0 : ~0 << 32 - n2 >>> 0;
19269
+ return { base: (base & mask2) >>> 0, mask: mask2 };
18888
19270
  }
18889
- var SANDBOX_REQUEST_CAP_REASON, SandboxExitError;
18890
- var init_classifySandboxExit = __esm({
18891
- "src/daemon/sandbox/classifySandboxExit.ts"() {
19271
+ function stripMapped(addr) {
19272
+ return (addr ?? "").replace(/^::ffff:/i, "");
19273
+ }
19274
+ var IPV4_RE, SlotMap;
19275
+ var init_slotMap = __esm({
19276
+ "src/daemon/sandbox/slotMap.ts"() {
18892
19277
  "use strict";
18893
- init_rateLimit();
18894
- init_types();
18895
- SANDBOX_REQUEST_CAP_REASON = "sandbox_request_cap";
18896
- SandboxExitError = class extends Error {
18897
- constructor(message2, verdict) {
18898
- super(message2);
18899
- this.verdict = verdict;
18900
- this.name = "SandboxExitError";
19278
+ init_appleContainer();
19279
+ IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
19280
+ SlotMap = class {
19281
+ byIndex = /* @__PURE__ */ new Map();
19282
+ byGateway = /* @__PURE__ */ new Map();
19283
+ allowGatewayAsPeer;
19284
+ constructor(slots, opts = {}) {
19285
+ this.allowGatewayAsPeer = opts.allowGatewayAsPeer ?? false;
19286
+ for (const slot of slots) {
19287
+ const gateway = parseIpv4(slot.gateway);
19288
+ const cidr = parseCidr(slot.subnet);
19289
+ if (gateway === null) throw new Error(`slot ${slot.index}: gateway is not an IPv4 address`);
19290
+ if (cidr === null) throw new Error(`slot ${slot.index}: subnet is not an IPv4 CIDR`);
19291
+ if (this.byIndex.has(slot.index)) throw new Error(`slot ${slot.index}: duplicate index`);
19292
+ if (this.byGateway.has(slot.gateway)) throw new Error(`slot ${slot.index}: duplicate gateway`);
19293
+ const entry = { slot, gateway, cidr, binding: null };
19294
+ this.byIndex.set(slot.index, entry);
19295
+ this.byGateway.set(slot.gateway, entry);
19296
+ }
19297
+ }
19298
+ /**
19299
+ * The slot a connection belongs to, or null (drop it). `local` must be a slot
19300
+ * gateway; `remote` must be a plain IPv4 inside THAT slot's subnet — never
19301
+ * IPv6, never another slot's range.
19302
+ */
19303
+ resolve(localAddress, remoteAddress) {
19304
+ const entry = this.byGateway.get(stripMapped(localAddress));
19305
+ if (!entry) return null;
19306
+ const remoteStr = stripMapped(remoteAddress);
19307
+ const remote = parseIpv4(remoteStr);
19308
+ if (remote === null) return null;
19309
+ if ((remote & entry.cidr.mask) >>> 0 !== entry.cidr.base) return null;
19310
+ if (remote === entry.gateway && !this.allowGatewayAsPeer) return null;
19311
+ return entry.slot;
19312
+ }
19313
+ slots() {
19314
+ return [...this.byIndex.values()].map((e) => e.slot);
19315
+ }
19316
+ slot(index) {
19317
+ return this.byIndex.get(index)?.slot ?? null;
19318
+ }
19319
+ bind(index, binding) {
19320
+ const entry = this.entry(index);
19321
+ if (entry.binding && entry.binding.jobId !== binding.jobId) {
19322
+ throw new Error(`slot ${index} is bound to another job`);
19323
+ }
19324
+ entry.binding = binding;
19325
+ }
19326
+ clear(index) {
19327
+ this.entry(index).binding = null;
19328
+ }
19329
+ binding(index) {
19330
+ return this.byIndex.get(index)?.binding ?? null;
19331
+ }
19332
+ bindingForJob(jobId) {
19333
+ for (const e of this.byIndex.values()) {
19334
+ if (e.binding?.jobId === jobId) return { slot: e.slot, binding: e.binding };
19335
+ }
19336
+ return null;
19337
+ }
19338
+ entry(index) {
19339
+ const e = this.byIndex.get(index);
19340
+ if (!e) throw new Error(`unknown slot ${index}`);
19341
+ return e;
18901
19342
  }
18902
19343
  };
18903
19344
  }
18904
19345
  });
18905
19346
 
18906
- // src/daemon/sandbox/sweep.ts
18907
- async function sweepJobs(deps) {
18908
- const now = deps.now ?? Date.now;
18909
- const t0 = now();
19347
+ // src/daemon/sandbox/confinement.ts
19348
+ import { randomBytes as randomBytes3 } from "node:crypto";
19349
+ function probeTargets(slot, lanIp) {
19350
+ const gw = slot.gateway;
19351
+ return [
19352
+ ...HOST_SERVICE_PORTS.map((port) => ({ id: `host_${port}`, kind: "tcp", host: gw, port })),
19353
+ ...lanIp ? LAN_PORTS.map((port) => ({ id: `lan_${port}`, kind: "tcp", host: lanIp, port })) : [],
19354
+ { id: "public_443", kind: "tcp", host: PUBLIC_PROBE_IP, port: 443 },
19355
+ { id: "public_dns", kind: "udp_dns", host: PUBLIC_PROBE_IP, port: 53 },
19356
+ { id: "ipv6", kind: "ipv6_socket", host: "::", port: 0 },
19357
+ { id: "gate_anthropic", kind: "http", host: gw, port: GATE_PORTS.anthropic },
19358
+ { id: "gate_mcp", kind: "http", host: gw, port: GATE_PORTS.mcp },
19359
+ { id: "gate_github", kind: "connect", host: gw, port: GATE_PORTS.github }
19360
+ ];
19361
+ }
19362
+ function expectation(id) {
19363
+ if (id.startsWith("host_") || id.startsWith("lan_")) return { want: "timeout", ok: (o2) => o2 === "timeout" };
19364
+ if (id === "public_dns") return { want: "silent", ok: (o2) => o2 === "silent" };
19365
+ if (id.startsWith("public_")) return { want: "not connected", ok: (o2) => o2 !== "connected" };
19366
+ if (id === "ipv6") return { want: "unsupported", ok: (o2) => o2 === "unsupported" };
19367
+ if (id.startsWith("gate_")) return { want: "an HTTP status", ok: (o2) => /^\d{3}$/.test(o2) };
19368
+ return { want: "a known target", ok: () => false };
19369
+ }
19370
+ function judgeProbe(outcomes, targets) {
19371
+ const ids = targets ? targets.map((t) => t.id) : Object.keys(outcomes);
19372
+ for (const id of ["host_22", "public_443", "public_dns", "ipv6", "gate_anthropic", "gate_mcp", "gate_github"]) {
19373
+ if (!ids.includes(id)) ids.push(id);
19374
+ }
18910
19375
  const failures = [];
18911
- const boxes = [];
18912
- const volumes = [];
18913
- const jobBoxes = (await deps.list()).filter(isJobBox);
18914
- await Promise.all(jobBoxes.map(async (b) => {
18915
- try {
18916
- await deps.kill(b.id).catch(() => void 0);
18917
- await deps.remove(b.id);
18918
- boxes.push(b.id);
18919
- } catch (e) {
18920
- failures.push(`box ${b.id}: ${e.message}`);
19376
+ for (const id of ids) {
19377
+ const o2 = outcomes[id];
19378
+ const e = expectation(id);
19379
+ if (o2 === void 0) failures.push(`${id}: no outcome (want: ${e.want})`);
19380
+ else if (!e.ok(o2)) failures.push(`${id}: ${o2} (want: ${e.want})`);
19381
+ }
19382
+ return { ok: failures.length === 0, failures };
19383
+ }
19384
+ function primaryLanIpv4(ifaces) {
19385
+ const lo = parseIpv4(`192.168.${SLOT_RANGE_BASE_THIRD_OCTET}.0`);
19386
+ for (const list2 of Object.values(ifaces)) {
19387
+ for (const i2 of list2 ?? []) {
19388
+ if (i2.internal || i2.family !== "IPv4") continue;
19389
+ const v = parseIpv4(i2.address);
19390
+ if (v === null || (v & 4294965248) >>> 0 === lo) continue;
19391
+ return i2.address;
18921
19392
  }
18922
- }));
18923
- const jobVolumes = (await deps.listVolumes()).filter(isJobVolume);
18924
- for (const v of jobVolumes) {
18925
- try {
18926
- await deps.removeVolume(v.name);
18927
- volumes.push(v.name);
18928
- } catch (e) {
18929
- failures.push(`volume ${v.name}: ${e.message}`);
19393
+ }
19394
+ return null;
19395
+ }
19396
+ async function runProbeBox(deps, slot, targets, targetTimeoutMs = TARGET_TIMEOUT_MS) {
19397
+ const id = randomBytes3(6).toString("hex");
19398
+ const name = `archme-probe-${id}`;
19399
+ const input = { kind: "probe", targets, timeoutMs: targetTimeoutMs };
19400
+ let cleanup = null;
19401
+ let timer;
19402
+ try {
19403
+ const dir = await (deps.writeInputDir ?? writeInputDir)("probe", input);
19404
+ cleanup = dir.cleanup;
19405
+ const handle = await deps.runtime.runStep({
19406
+ name,
19407
+ image: deps.image,
19408
+ args: ["probe"],
19409
+ network: slot,
19410
+ env: {},
19411
+ inputDir: dir.dir,
19412
+ limits: { cpus: 1, memoryMiB: 256 },
19413
+ labels: { [JOB_LABEL]: `probe-${id}`, [SLOT_LABEL]: String(slot.index) }
19414
+ });
19415
+ const timeout = new Promise((r) => {
19416
+ timer = setTimeout(() => r("timeout"), deps.boxTimeoutMs ?? 6e4);
19417
+ });
19418
+ const exit = await Promise.race([handle.done, timeout]);
19419
+ if (exit === "timeout") return { outcomes: {}, error: "probe box timed out" };
19420
+ const parsed = (deps.parseResultLine ?? parseResultLine)(exit.stdout, exit.nonce);
19421
+ if (!parsed.ok || parsed.result.kind !== "probe") {
19422
+ return { outcomes: {}, error: `probe result: ${parsed.ok ? `unexpected kind ${parsed.result.kind}` : parsed.reason}` };
18930
19423
  }
19424
+ return { outcomes: parsed.result.outcomes, error: parsed.result.error ? `probe box: ${parsed.result.error}` : null };
19425
+ } catch (e) {
19426
+ return { outcomes: {}, error: `probe box failed: ${e.message}` };
19427
+ } finally {
19428
+ clearTimeout(timer);
19429
+ await deps.runtime.kill(name).catch((e) => deps.log?.(`kill ${name} failed: ${e.message}`));
19430
+ await cleanup?.();
18931
19431
  }
18932
- const report = { boxes: boxes.sort(), volumes, ms: now() - t0 };
18933
- if (failures.length > 0) throw new SweepError(report, failures);
18934
- return report;
18935
19432
  }
18936
- var JOB_VOLUME_PREFIX, SweepError, isJobBox, isJobVolume;
18937
- var init_sweep = __esm({
18938
- "src/daemon/sandbox/sweep.ts"() {
19433
+ async function runConfinementProbe(deps, slot, lanIp) {
19434
+ const now = deps.now ?? Date.now;
19435
+ const started = now();
19436
+ const targets = probeTargets(slot, lanIp);
19437
+ const { outcomes, error } = await runProbeBox(deps, slot, targets);
19438
+ const j = judgeProbe(outcomes, targets);
19439
+ const failures = error ? [error, ...Object.keys(outcomes).length ? j.failures : []] : j.failures;
19440
+ return { ok: !error && j.ok, slot: slot.index, failures, outcomes, ms: now() - started };
19441
+ }
19442
+ function formatConfinementReport(r) {
19443
+ return r.ok ? `confinement probe passed on slot ${r.slot} (${r.ms}ms)` : `confinement probe failed on slot ${r.slot}: ${r.failures.join("; ")}`;
19444
+ }
19445
+ var PUBLIC_PROBE_IP, HOST_SERVICE_PORTS, LAN_PORTS, TARGET_TIMEOUT_MS, CONFINEMENT_PROBE_INTERVAL_MS, CONFINEMENT_RETRY_MS, realTimers, ConfinementMonitor;
19446
+ var init_confinement = __esm({
19447
+ "src/daemon/sandbox/confinement.ts"() {
18939
19448
  "use strict";
19449
+ init_result2();
19450
+ init_inputDir();
19451
+ init_slotMap();
18940
19452
  init_types();
18941
- JOB_VOLUME_PREFIX = "archme-job-";
18942
- SweepError = class extends Error {
18943
- constructor(report, failures) {
18944
- super(`sandbox sweep left ${failures.length} item(s): ${failures.join("; ")}`);
18945
- this.report = report;
18946
- this.failures = failures;
18947
- this.name = "SweepError";
19453
+ PUBLIC_PROBE_IP = "1.1.1.1";
19454
+ HOST_SERVICE_PORTS = [22, 88, 5e3, 7e3];
19455
+ LAN_PORTS = [22, 5e3];
19456
+ TARGET_TIMEOUT_MS = 2e3;
19457
+ CONFINEMENT_PROBE_INTERVAL_MS = 60 * 60 * 1e3;
19458
+ CONFINEMENT_RETRY_MS = 5 * 60 * 1e3;
19459
+ realTimers = {
19460
+ setTimeout: (fn, ms) => {
19461
+ const t = setTimeout(fn, ms);
19462
+ t.unref?.();
19463
+ return t;
19464
+ },
19465
+ clearTimeout: (h2) => clearTimeout(h2)
19466
+ };
19467
+ ConfinementMonitor = class {
19468
+ constructor(opts) {
19469
+ this.opts = opts;
19470
+ this.timers = opts.timers ?? realTimers;
19471
+ }
19472
+ timers;
19473
+ handle = null;
19474
+ failing = false;
19475
+ probing = false;
19476
+ stopped = false;
19477
+ /** The boot probe already ran; the next is an hour out. */
19478
+ start() {
19479
+ this.stopped = false;
19480
+ this.schedule(CONFINEMENT_PROBE_INTERVAL_MS);
19481
+ }
19482
+ stop() {
19483
+ this.stopped = true;
19484
+ if (this.handle !== null) this.timers.clearTimeout(this.handle);
19485
+ this.handle = null;
19486
+ }
19487
+ allowsClaims() {
19488
+ return this.opts.mode !== "required" || !this.failing && !this.probing;
19489
+ }
19490
+ schedule(ms) {
19491
+ if (this.stopped) return;
19492
+ if (this.handle !== null) this.timers.clearTimeout(this.handle);
19493
+ this.handle = this.timers.setTimeout(() => {
19494
+ this.handle = null;
19495
+ void this.runOnce();
19496
+ }, ms);
19497
+ }
19498
+ async runOnce() {
19499
+ const slot = this.opts.pool.lease();
19500
+ if (!slot) {
19501
+ this.opts.log("confinement probe: no free slot, retrying in 5 minutes");
19502
+ return this.schedule(CONFINEMENT_RETRY_MS);
19503
+ }
19504
+ this.probing = true;
19505
+ let report;
19506
+ try {
19507
+ report = await this.opts.probe(slot);
19508
+ } catch (e) {
19509
+ report = { ok: false, slot: slot.index, failures: [`probe threw: ${e.message}`], outcomes: {}, ms: 0 };
19510
+ } finally {
19511
+ this.opts.pool.free(slot.index);
19512
+ this.probing = false;
19513
+ }
19514
+ this.opts.log(formatConfinementReport(report));
19515
+ if (report.ok) {
19516
+ if (this.failing) this.opts.log("confinement holds again; claiming resumes");
19517
+ this.failing = false;
19518
+ return this.schedule(CONFINEMENT_PROBE_INTERVAL_MS);
19519
+ }
19520
+ if (this.opts.mode === "required") {
19521
+ if (!this.failing) this.opts.log("ARCHME_SANDBOX=required: not claiming jobs until confinement holds (re-probing every 5 minutes)");
19522
+ this.failing = true;
19523
+ return this.schedule(CONFINEMENT_RETRY_MS);
19524
+ }
19525
+ this.schedule(CONFINEMENT_PROBE_INTERVAL_MS);
18948
19526
  }
18949
19527
  };
18950
- isJobBox = (b) => JOB_LABEL in b.labels;
18951
- isJobVolume = (v) => v.name.startsWith(JOB_VOLUME_PREFIX) || JOB_LABEL in v.labels;
18952
19528
  }
18953
19529
  });
18954
19530
 
18955
- // src/daemon/sandbox/appleContainer.ts
18956
- import { randomBytes as randomBytes2 } from "node:crypto";
18957
- import { homedir as homedir7 } from "node:os";
18958
- import { isAbsolute, normalize } from "node:path";
18959
- function assertName(kind, value) {
18960
- if (!NAME_RE.test(value)) throw new Error(`sandbox: invalid ${kind} name ${JSON.stringify(value)}`);
18961
- }
18962
- function assertImage(image) {
18963
- if (!image || image.startsWith("-") || /\s/.test(image)) {
18964
- throw new Error(`sandbox: invalid image ${JSON.stringify(image)}`);
19531
+ // src/daemon/sandbox/claudeToken.ts
19532
+ import { constants as fsConstants } from "node:fs";
19533
+ import { chmod as chmod5, mkdir as mkdir11, open as open3 } from "node:fs/promises";
19534
+ import { dirname as dirname15 } from "node:path";
19535
+ function assertTokenShape(raw, where) {
19536
+ const token = raw.trim();
19537
+ if (!token) throw new SandboxBootError(`sandbox Claude token ${where} is empty`);
19538
+ if (!/^[\x21-\x7e]+$/.test(token)) {
19539
+ throw new SandboxBootError(`sandbox Claude token ${where} contains whitespace or control characters; save it as one line`);
18965
19540
  }
19541
+ return token;
18966
19542
  }
18967
- function labelArgs(labels) {
18968
- const out = [];
18969
- for (const [k, v] of Object.entries(labels)) {
18970
- if (!LABEL_KEY_RE.test(k)) throw new Error(`sandbox: invalid label key ${JSON.stringify(k)}`);
18971
- if (CONTROL_RE.test(v)) throw new Error(`sandbox: label ${k} value contains a control character`);
18972
- out.push("--label", `${k}=${v}`);
19543
+ async function readClaudeTokenFile(path7) {
19544
+ let fh;
19545
+ try {
19546
+ fh = await open3(path7, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
19547
+ } catch (e) {
19548
+ throw new SandboxBootError(`cannot read the sandbox Claude token at ${path7}: ${e.message}`);
18973
19549
  }
18974
- return out;
18975
- }
18976
- function assertInputDir(dir, home2 = homedir7()) {
18977
- if (!isAbsolute(dir)) throw new Error(`sandbox: inputDir must be absolute: ${dir}`);
18978
- const strip2 = (p) => p.replace(/\/+$/, "") || "/";
18979
- const n2 = strip2(normalize(dir));
18980
- if (n2 !== strip2(dir)) throw new Error(`sandbox: inputDir must be normalized: ${dir}`);
18981
- if (/[:,]/.test(n2) || CONTROL_RE.test(n2)) throw new Error(`sandbox: inputDir has a forbidden character: ${dir}`);
18982
- const homeN = strip2(normalize(home2));
18983
- if (n2 === "/" || n2 === homeN || /^\/(Users|home)(\/[^/]+)?$/.test(n2) || n2 === "/root" || n2 === "/var/root") {
18984
- throw new Error(`sandbox: inputDir must not be / or a home directory: ${dir}`);
19550
+ try {
19551
+ const st = await fh.stat();
19552
+ if (!st.isFile()) throw new SandboxBootError(`sandbox Claude token ${path7} is not a regular file`);
19553
+ if ((st.mode & 63) !== 0) {
19554
+ throw new SandboxBootError(
19555
+ `sandbox Claude token ${path7} is readable by group or others (mode ${(st.mode & 511).toString(8)}); chmod 600 it`
19556
+ );
19557
+ }
19558
+ return assertTokenShape(await fh.readFile("utf8"), path7);
19559
+ } finally {
19560
+ await fh.close();
18985
19561
  }
18986
19562
  }
18987
- function envArgs(env) {
18988
- const out = [];
18989
- for (const [k, v] of Object.entries(env)) {
18990
- if (!ENV_KEY_RE.test(k)) throw new Error(`sandbox: invalid env key ${JSON.stringify(k)}`);
18991
- if (CONTROL_RE.test(v)) throw new Error(`sandbox: env ${k} value contains a newline or NUL`);
18992
- out.push("-e", `${k}=${v}`);
19563
+ async function readSandboxClaudeToken(settings, deps = {}) {
19564
+ if (settings.claudeTokenOverride) {
19565
+ return { token: await readClaudeTokenFile(settings.claudeTokenPath), source: "file-override" };
18993
19566
  }
18994
- return out;
18995
- }
18996
- function slotFor(index) {
18997
- if (!Number.isInteger(index) || index < 0 || index >= MAX_SLOTS) {
18998
- throw new Error(`sandbox: slot index out of range: ${index}`);
19567
+ const keytar = await (deps.loadKeytar ?? loadKeytar)();
19568
+ const stored = keytar ? await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).catch(() => null) : null;
19569
+ if (stored) return { token: assertTokenShape(stored, "in the keychain"), source: "keychain" };
19570
+ try {
19571
+ return { token: await readClaudeTokenFile(settings.claudeTokenPath), source: "file" };
19572
+ } catch (e) {
19573
+ throw new SandboxBootError(
19574
+ `${e.message}; no token in the keychain either \u2014 run \`archme daemon sandbox setup\` to store one`
19575
+ );
18999
19576
  }
19000
- const third = SLOT_RANGE_BASE_THIRD_OCTET + index;
19001
- return {
19002
- index,
19003
- network: `archme-slot-${index}`,
19004
- subnet: `192.168.${third}.0/24`,
19005
- gateway: `192.168.${third}.1`
19006
- };
19007
19577
  }
19008
- function buildRunArgs(spec, home2) {
19009
- assertName("box", spec.name);
19010
- assertName("volume", spec.volume);
19011
- assertImage(spec.image);
19012
- assertInputDir(spec.inputDir, home2);
19013
- if (!spec.labels[JOB_LABEL]) throw new Error(`sandbox: step labels must include ${JOB_LABEL}`);
19014
- const { cpus, memoryMiB } = spec.limits;
19015
- if (!Number.isInteger(cpus) || cpus < 1) throw new Error(`sandbox: invalid cpus ${cpus}`);
19016
- if (!Number.isInteger(memoryMiB) || memoryMiB < MIN_MEMORY_MIB) {
19017
- throw new Error(`sandbox: memoryMiB must be an integer \u2265 ${MIN_MEMORY_MIB}: ${memoryMiB}`);
19578
+ async function storeSandboxClaudeToken(raw, opts) {
19579
+ const token = assertTokenShape(raw, "(entered)");
19580
+ let note;
19581
+ const keytar = opts.override ? null : await (opts.loadKeytar ?? loadKeytar)();
19582
+ if (opts.override) {
19583
+ note = `ARCHME_SANDBOX_CLAUDE_TOKEN_FILE is set: ${opts.path}`;
19584
+ } else if (keytar) {
19585
+ try {
19586
+ await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, token);
19587
+ return { source: "keychain" };
19588
+ } catch (e) {
19589
+ note = `the keychain refused it (${e.message})`;
19590
+ }
19591
+ } else {
19592
+ note = "no keychain access on this machine";
19018
19593
  }
19019
- const network = spec.network === "nat" ? NAT_NETWORK : spec.network.network;
19020
- assertName("network", network);
19021
- return [
19022
- // `-i`: stdin carries the per-step result nonce (one line, then EOF) to
19023
- // the wrapper — the only channel that is neither a file, env nor argv.
19024
- "run",
19025
- "-i",
19026
- "--rm",
19027
- "--init",
19028
- "--read-only",
19029
- "--cap-drop",
19030
- "ALL",
19031
- "--user",
19032
- `${BOX_UID}:${BOX_UID}`,
19033
- "--tmpfs",
19034
- "/tmp",
19035
- "--no-dns",
19036
- "--kernel-arg",
19037
- "ipv6.disable=1",
19038
- // Progress lines otherwise land on stderr ahead of the box's own output.
19039
- "--progress",
19040
- "none",
19041
- "--cpus",
19042
- String(cpus),
19043
- "--memory",
19044
- `${memoryMiB}M`,
19045
- "--name",
19046
- spec.name,
19047
- ...labelArgs(spec.labels),
19048
- "--network",
19049
- network,
19050
- // Verified: `:ro` on a directory bind is a read-only virtiofs mount — even
19051
- // root in the box gets EROFS on create/mkdir.
19052
- "-v",
19053
- `${spec.inputDir}:/job:ro`,
19054
- "-v",
19055
- `${spec.volume}:/work`,
19056
- ...envArgs(spec.env),
19057
- spec.image,
19058
- ...spec.args
19059
- ];
19060
- }
19061
- function buildInitVolumeArgs(volume, uid, labels, image) {
19062
- assertName("volume", volume);
19063
- assertImage(image);
19064
- if (!Number.isInteger(uid) || uid <= 0) throw new Error(`sandbox: invalid uid ${uid}`);
19065
- return [
19066
- "run",
19067
- "--rm",
19068
- "--init",
19069
- "--read-only",
19070
- "--cap-drop",
19071
- "ALL",
19072
- "--cap-add",
19073
- "CHOWN",
19074
- "--user",
19075
- "0:0",
19076
- "--no-dns",
19077
- "--kernel-arg",
19078
- "ipv6.disable=1",
19079
- "--progress",
19080
- "none",
19081
- "--network",
19082
- NAT_NETWORK,
19083
- "--memory",
19084
- `${INIT_MEMORY_MIB}M`,
19085
- ...labelArgs(labels),
19086
- "-v",
19087
- `${volume}:/work`,
19088
- "--entrypoint",
19089
- "chown",
19090
- image,
19091
- `${uid}:${uid}`,
19092
- "/work"
19093
- ];
19094
- }
19095
- function buildCreateVolumeArgs(name, sizeGiB, labels) {
19096
- assertName("volume", name);
19097
- if (!Number.isInteger(sizeGiB) || sizeGiB < 1) throw new Error(`sandbox: invalid volume size ${sizeGiB}`);
19098
- return ["volume", "create", "-s", `${sizeGiB}G`, ...labelArgs(labels), name];
19099
- }
19100
- function buildEnsureNetworkArgs(slot) {
19101
- return [
19102
- "network",
19103
- "create",
19104
- "--internal",
19105
- "--subnet",
19106
- slot.subnet,
19107
- "--label",
19108
- `${SLOT_LABEL}=${slot.index}`,
19109
- slot.network
19110
- ];
19111
- }
19112
- function labelsOf(v) {
19113
- const out = {};
19114
- for (const [k, x] of Object.entries(obj(v))) if (typeof x === "string") out[k] = x;
19115
- return out;
19116
- }
19117
- function parseArray(json, what) {
19118
- const parsed = JSON.parse(json.trim() || "[]");
19119
- if (!Array.isArray(parsed)) throw new Error(`sandbox: ${what} is not a JSON array`);
19120
- return parsed;
19121
- }
19122
- function parseBoxList(json) {
19123
- return parseArray(json, "container ls").flatMap((raw) => {
19124
- const r = obj(raw);
19125
- const id = str(r.id) ?? str(obj(r.configuration).id);
19126
- if (!id) return [];
19127
- return [{ id, state: str(obj(r.status).state) ?? "unknown", labels: labelsOf(obj(r.configuration).labels) }];
19128
- });
19129
- }
19130
- function parseVolumeList(json) {
19131
- return parseArray(json, "container volume ls").flatMap((raw) => {
19132
- const r = obj(raw);
19133
- const name = str(obj(r.configuration).name) ?? str(r.id);
19134
- if (!name) return [];
19135
- return [{ name, labels: labelsOf(obj(r.configuration).labels) }];
19136
- });
19594
+ await mkdir11(dirname15(opts.path), { recursive: true, mode: 448 });
19595
+ const fh = await open3(
19596
+ opts.path,
19597
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW,
19598
+ 384
19599
+ );
19600
+ try {
19601
+ await fh.writeFile(`${token}
19602
+ `);
19603
+ } finally {
19604
+ await fh.close();
19605
+ }
19606
+ await chmod5(opts.path, 384);
19607
+ return { source: "file", note };
19137
19608
  }
19138
- function parseNetworkList(json) {
19139
- return parseArray(json, "container network ls").flatMap((raw) => {
19140
- const r = obj(raw);
19141
- const c3 = obj(r.configuration);
19142
- const name = str(c3.name) ?? str(r.id);
19143
- if (!name) return [];
19144
- return [{
19145
- name,
19146
- subnet: str(c3.ipv4Subnet) ?? str(obj(r.status).ipv4Subnet),
19147
- mode: str(c3.mode),
19148
- labels: labelsOf(c3.labels)
19149
- }];
19609
+ var SandboxBootError, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT;
19610
+ var init_claudeToken = __esm({
19611
+ "src/daemon/sandbox/claudeToken.ts"() {
19612
+ "use strict";
19613
+ init_credentials();
19614
+ SandboxBootError = class extends Error {
19615
+ constructor(message2) {
19616
+ super(message2);
19617
+ this.name = "SandboxBootError";
19618
+ }
19619
+ };
19620
+ KEYCHAIN_SERVICE = "archme-daemon";
19621
+ KEYCHAIN_ACCOUNT = "sandbox-claude-token";
19622
+ }
19623
+ });
19624
+
19625
+ // src/daemon/sandbox/host.ts
19626
+ import { networkInterfaces } from "node:os";
19627
+ function sandboxBootFailurePolicy(mode) {
19628
+ return mode === "shadow" ? "run_unboxed" : "refuse";
19629
+ }
19630
+ function shutdownKillTargets(boxedJobs2, registry) {
19631
+ return [...boxedJobs2].filter((id) => registry.hasLiveBoxes(id));
19632
+ }
19633
+ async function bootSandbox(settings, parts, deps) {
19634
+ const sweep = await parts.runtime.sweep();
19635
+ const inputDirs = await (deps.sweepInputDirs ?? sweepInputDirs)();
19636
+ deps.log(
19637
+ ` sandbox: swept ${sweep.boxes.length} box(es), ${sweep.volumes.length} volume(s) in ${sweep.ms}ms, ${inputDirs} input dir(s)`
19638
+ );
19639
+ const slots = await parts.runtime.ensureSlots(settings.slots);
19640
+ const pool = parts.makePool(slots);
19641
+ const { token: claudeToken, source } = await (deps.readClaudeToken ?? ((s) => readSandboxClaudeToken(s)))(settings);
19642
+ deps.log(` sandbox: Claude token from ${source === "keychain" ? "the macOS keychain" : source === "file-override" ? `ARCHME_SANDBOX_CLAUDE_TOKEN_FILE (${settings.claudeTokenPath})` : settings.claudeTokenPath}`);
19643
+ const allSlots = Array.from({ length: MAX_SLOTS }, (_, i2) => slotFor(i2));
19644
+ const gates = await parts.startGates(allSlots, {
19645
+ mcpBaseUrl: deps.mcpBaseUrl,
19646
+ claudeToken,
19647
+ remint: deps.remint,
19648
+ log: deps.log
19150
19649
  });
19650
+ let preflight;
19651
+ try {
19652
+ preflight = await parts.runtime.preflight();
19653
+ } catch (e) {
19654
+ await gates.close().catch(() => {
19655
+ });
19656
+ throw e;
19657
+ }
19658
+ if (!preflight.ok) {
19659
+ await gates.close().catch(() => {
19660
+ });
19661
+ throw new SandboxBootError(`sandbox preflight failed: ${failedChecks(preflight)}`);
19662
+ }
19663
+ const probe = deps.probe ?? ((slot) => runConfinementProbe({ runtime: parts.runtime, image: settings.image, log: deps.log }, slot, primaryLanIpv4(networkInterfaces())));
19664
+ let report;
19665
+ try {
19666
+ report = await probe(slots[0]);
19667
+ } catch (e) {
19668
+ report = { ok: false, slot: 0, failures: [`probe threw: ${e.message}`], outcomes: {}, ms: 0 };
19669
+ }
19670
+ preflight = {
19671
+ ok: preflight.ok && report.ok,
19672
+ checks: [...preflight.checks, { name: "confinement_probe", ok: report.ok, detail: report.ok ? void 0 : report.failures.join("; ") }]
19673
+ };
19674
+ deps.log(` sandbox: ${formatConfinementReport(report)}`);
19675
+ if (!report.ok) {
19676
+ if (settings.mode === "required") {
19677
+ await gates.close().catch(() => {
19678
+ });
19679
+ throw new SandboxBootError(`sandbox preflight failed: ${failedChecks(preflight)}`);
19680
+ }
19681
+ deps.log(" sandbox: shadow mode boots anyway; run `archme daemon sandbox setup` to install the confinement");
19682
+ }
19683
+ const monitor = new ConfinementMonitor({ mode: settings.mode, probe, pool, log: deps.log });
19684
+ return {
19685
+ host: { settings, runtime: parts.runtime, registry: parts.registry, gates, pool, io: parts.io },
19686
+ sweep,
19687
+ preflight,
19688
+ monitor
19689
+ };
19151
19690
  }
19152
- function isBoxNotFound(stderr) {
19153
- return /notFound: "container with ID .* not found"/.test(stderr);
19691
+ function failedChecks(p) {
19692
+ return p.checks.filter((c3) => !c3.ok).map((c3) => `${c3.name}${c3.detail ? ` (${c3.detail})` : ""}`).join(", ") || "unknown";
19154
19693
  }
19155
- function isBoxStopped(stderr) {
19156
- return /invalidState: "no runtime client exists: container is stopped"/.test(stderr);
19694
+ var init_host = __esm({
19695
+ "src/daemon/sandbox/host.ts"() {
19696
+ "use strict";
19697
+ init_confinement();
19698
+ init_appleContainer();
19699
+ init_claudeToken();
19700
+ init_inputDir();
19701
+ init_types();
19702
+ init_claudeToken();
19703
+ }
19704
+ });
19705
+
19706
+ // src/daemon/sandbox/runSandboxedJob.ts
19707
+ function sandboxJobOf(job) {
19708
+ return {
19709
+ id: job.id,
19710
+ project_id: job.project_id,
19711
+ repo_owner: job.repo_owner,
19712
+ repo_name: job.repo_name,
19713
+ repo_branch: job.repo_branch,
19714
+ archme_branch: job.archme_branch,
19715
+ before_sha: job.before_sha,
19716
+ after_sha: job.after_sha,
19717
+ forced: job.forced,
19718
+ pr_number: job.pr_number ?? null,
19719
+ source_mode: job.source_mode,
19720
+ commit_message: job.commit_message ?? null,
19721
+ head_commit_timestamp: job.head_commit_timestamp ?? null,
19722
+ author_name: job.author_name ?? null,
19723
+ author_login: job.author_login ?? null
19724
+ };
19157
19725
  }
19158
- function defaultExec2(bin = "container") {
19159
- return async (args) => {
19160
- const r = await execa(bin, args, { reject: false, stdin: "ignore" });
19161
- const stderr = typeof r.stderr === "string" ? r.stderr : "";
19162
- return {
19163
- exitCode: r.exitCode ?? -1,
19164
- stdout: typeof r.stdout === "string" ? r.stdout : "",
19165
- stderr: r.exitCode === void 0 ? `${stderr}
19166
- ${r.message}`.trim() : stderr
19726
+ async function runSandboxedJob(deps, job, opts) {
19727
+ const { slot } = opts;
19728
+ const t = { ...DEFAULT_SANDBOX_TIMEOUTS, ...opts.timeouts };
19729
+ const pollMs = opts.pollMs ?? 1e3;
19730
+ const now = deps.now ?? Date.now;
19731
+ const { settings } = deps;
19732
+ const volume = `archme-job-${job.id}`;
19733
+ const labels = { [JOB_LABEL]: job.id, [SLOT_LABEL]: String(slot.index) };
19734
+ const sjob = sandboxJobOf(job);
19735
+ const sandbox = {
19736
+ slot: slot.index,
19737
+ memory_peak_bytes: { clone: null, agent: null },
19738
+ model_requests: 0,
19739
+ upstream_429: 0,
19740
+ last_upstream_status: null,
19741
+ cap_hits: 0,
19742
+ mcp_requests: 0,
19743
+ remints: 0,
19744
+ egress_denied: 0,
19745
+ first_egress_denied: null,
19746
+ clone_ms: null,
19747
+ box_ms: 0
19748
+ };
19749
+ const durations = {};
19750
+ const metrics = { sandbox, durations_ms: durations };
19751
+ const liveBoxes = /* @__PURE__ */ new Set();
19752
+ const inputCleanups = [];
19753
+ let volumeTouched = false;
19754
+ let agentStarted = false;
19755
+ const readCounters = () => {
19756
+ const c3 = deps.gates.countersFor(slot);
19757
+ if (!c3) return;
19758
+ sandbox.model_requests = c3.model_requests;
19759
+ sandbox.upstream_429 = c3.upstream_429;
19760
+ sandbox.last_upstream_status = c3.last_upstream_status;
19761
+ sandbox.cap_hits = c3.cap_hits;
19762
+ sandbox.mcp_requests = c3.mcp_requests;
19763
+ sandbox.remints = c3.remints;
19764
+ sandbox.egress_denied = c3.egress_denied;
19765
+ sandbox.first_egress_denied = c3.first_egress_denied;
19766
+ };
19767
+ const failed = (step, reason, stderr = "") => {
19768
+ deps.log(`sandbox ${step} step failed: ${reason}`);
19769
+ return { kind: "step_failed", step, reason, stderr, metrics };
19770
+ };
19771
+ const currentKillCause = () => {
19772
+ if (deps.isCancelled(job.id)) return "cancel";
19773
+ if (deps.killReason(job.id) === "shutdown") return "shutdown";
19774
+ if (deps.isLeaseLost(job.id)) return "lease_lost";
19775
+ return null;
19776
+ };
19777
+ const writeInput = async (step, input) => {
19778
+ const w = await deps.writeInputDir(step, input);
19779
+ let done = false;
19780
+ const cleanup = async () => {
19781
+ if (done) return;
19782
+ done = true;
19783
+ await w.cleanup().catch(() => {
19784
+ });
19167
19785
  };
19786
+ inputCleanups.push(cleanup);
19787
+ return { dir: w.dir, cleanup };
19168
19788
  };
19169
- }
19170
- function defaultSpawn(bin = "container") {
19171
- return (args) => {
19172
- const p = execa(bin, args, { reject: false, stdin: "pipe", buffer: false });
19789
+ const runBox = async (spec2, timeoutMs) => {
19790
+ if (deps.isCancelled(job.id)) return { kind: "refused", reason: "cancel" };
19791
+ if (!await deps.registry.beginStep(job.id, spec2.name)) return { kind: "refused", reason: "shutting_down" };
19792
+ liveBoxes.add(spec2.name);
19793
+ const started = now();
19794
+ let killCause = null;
19795
+ const kill = (cause) => {
19796
+ if (killCause) return;
19797
+ killCause = cause;
19798
+ deps.log(`killing ${spec2.name} (${cause})`);
19799
+ if (cause === "lease_lost") deps.gates.clearJob(slot);
19800
+ void deps.runtime.kill(spec2.name).catch((e) => deps.log(`kill ${spec2.name} failed: ${e.message}`));
19801
+ };
19802
+ if (spec2.args[0] === "agent") agentStarted = true;
19803
+ const handle = await deps.runtime.runStep(spec2);
19804
+ const timer = setTimeout(() => kill("timeout"), timeoutMs);
19805
+ const poll = setInterval(() => {
19806
+ const c3 = currentKillCause();
19807
+ if (c3) kill(c3);
19808
+ }, pollMs);
19809
+ try {
19810
+ const exit = await handle.done;
19811
+ liveBoxes.delete(spec2.name);
19812
+ deps.registry.endStep(job.id, spec2.name);
19813
+ if (!killCause && exit.killed) killCause = currentKillCause() ?? "shutdown";
19814
+ return { kind: "ran", exit, killCause, ms: now() - started };
19815
+ } finally {
19816
+ clearTimeout(timer);
19817
+ clearInterval(poll);
19818
+ }
19819
+ };
19820
+ const spec = (step, env, inputDir) => ({
19821
+ name: `archme-job-${job.id}-${step}`,
19822
+ image: settings.image,
19823
+ args: [step],
19824
+ network: slot,
19825
+ env,
19826
+ inputDir,
19827
+ volume,
19828
+ limits: { cpus: settings.boxCpus, memoryMiB: settings.boxMemoryMiB },
19829
+ labels: { ...labels }
19830
+ });
19831
+ const runBoxes = async (pre) => {
19832
+ deps.registry.setVolume(job.id, volume);
19833
+ volumeTouched = true;
19834
+ await deps.runtime.createVolume(volume, settings.volumeGiB, labels);
19835
+ await deps.runtime.initVolume(volume, BOX_UID, labels, settings.image, slot);
19836
+ deps.gates.bindJob(slot, {
19837
+ jobId: job.id,
19838
+ maxModelRequests: settings.maxModelRequests,
19839
+ mcp: {
19840
+ token: pre.jobToken.token,
19841
+ expiresAt: pre.jobToken.expires_at ? Date.parse(pre.jobToken.expires_at) || null : null,
19842
+ projectSlug: pre.projectSlug,
19843
+ branch: job.archme_branch
19844
+ }
19845
+ });
19846
+ const cloneIn = await writeInput("clone", {
19847
+ kind: "clone",
19848
+ job: sjob,
19849
+ marker: pre.marker,
19850
+ credentials: {
19851
+ https_url: pre.credentials.https_url,
19852
+ username: pre.credentials.username,
19853
+ password: pre.credentials.password
19854
+ }
19855
+ });
19856
+ deps.gates.setStep(slot, "clone");
19857
+ const clone = await runBox(spec("clone", deps.cloneBoxEnv(slot.gateway), cloneIn.dir), t.cloneMs);
19858
+ await cloneIn.cleanup();
19859
+ if (clone.kind === "refused") return failed("clone", clone.reason);
19860
+ sandbox.clone_ms = clone.ms;
19861
+ sandbox.box_ms += clone.ms;
19862
+ durations.sandbox_clone = clone.ms;
19863
+ if (clone.killCause) return failed("clone", clone.killCause, clone.exit.stderr);
19864
+ const cp = deps.parseResultLine(clone.exit.stdout, clone.exit.nonce);
19865
+ if (!cp.ok || cp.result.kind !== "clone") {
19866
+ deps.log(`clone result line: ${cp.ok ? `unexpected kind ${cp.result.kind}` : cp.reason}`);
19867
+ return failed("clone", "result_malformed", clone.exit.stderr);
19868
+ }
19869
+ const cr = cp.result;
19870
+ sandbox.memory_peak_bytes.clone = cr.memoryPeakBytes;
19871
+ for (const [k, v] of Object.entries(cr.durations_ms ?? {})) durations[`clone_${k}`] = v;
19872
+ if (cr.outcome === "skip") return { kind: "skip", metrics };
19873
+ if (cr.outcome === "rejected") return { kind: "rejected", message: cr.error ?? "job rejected by the clone step", metrics };
19874
+ if (cr.error || clone.exit.exitCode !== 0 || !cr.diff) {
19875
+ return failed("clone", cr.error ?? `clone step exited ${clone.exit.exitCode}`, clone.exit.stderr);
19876
+ }
19877
+ Object.assign(metrics, {
19878
+ mode: job.source_mode === "pr_update" ? "pr_update" : "diff",
19879
+ files: cr.diff.files,
19880
+ bytes: cr.diff.bytes,
19881
+ materialized: cr.diff.materialized,
19882
+ range: cr.diff.rangeLabel,
19883
+ model: pre.model
19884
+ });
19885
+ const claudeMs = cr.diff.materialized ? t.largeDiffClaudeMs : t.claudeMs;
19886
+ const agentIn = await writeInput("agent", {
19887
+ kind: "agent",
19888
+ job: sjob,
19889
+ projectSlug: pre.projectSlug,
19890
+ seedArch: pre.seedArch,
19891
+ repoScope: pre.repoScope,
19892
+ repoContainer: pre.repoContainer,
19893
+ bugsFolderId: pre.bugsFolderId,
19894
+ model: pre.model,
19895
+ timeoutMs: claudeMs,
19896
+ gateway: slot.gateway
19897
+ });
19898
+ deps.gates.setStep(slot, "agent");
19899
+ const agent = await runBox(spec("agent", deps.agentBoxEnv(slot.gateway), agentIn.dir), claudeMs + t.graceMs);
19900
+ await agentIn.cleanup();
19901
+ if (agent.kind === "refused") return failed("agent", agent.reason);
19902
+ sandbox.box_ms += agent.ms;
19903
+ durations.sandbox_agent = agent.ms;
19904
+ readCounters();
19905
+ if (agent.killCause) return failed("agent", agent.killCause, agent.exit.stderr);
19906
+ const ap = deps.parseResultLine(agent.exit.stdout, agent.exit.nonce);
19907
+ if (!ap.ok || ap.result.kind !== "agent") {
19908
+ deps.log(`agent result line: ${ap.ok ? `unexpected kind ${ap.result.kind}` : ap.reason}`);
19909
+ return failed("agent", "result_malformed", agent.exit.stderr);
19910
+ }
19911
+ const ar = ap.result;
19912
+ sandbox.memory_peak_bytes.agent = ar.memoryPeakBytes;
19913
+ for (const [k, v] of Object.entries(ar.durations_ms ?? {})) durations[`agent_${k}`] = v;
19914
+ if (ar.seed) metrics.seed = ar.seed;
19915
+ if (ar.error) return failed("agent", ar.error, agent.exit.stderr);
19173
19916
  return {
19174
- stdin: p.stdin,
19175
- stdout: p.stdout,
19176
- stderr: p.stderr,
19177
- exited: p.then((r) => ({
19178
- exitCode: r.exitCode ?? null,
19179
- signal: r.signal ?? null,
19180
- error: r.exitCode === void 0 && !r.signal ? r.message : void 0
19181
- }))
19917
+ kind: "claude",
19918
+ exitCode: ar.exitCode,
19919
+ signal: ar.signal,
19920
+ claudeStdout: ar.claudeStdout,
19921
+ stderr: agent.exit.stderr,
19922
+ affected: ar.affected,
19923
+ metrics
19182
19924
  };
19183
19925
  };
19926
+ try {
19927
+ const pre = await deps.prefetch(job);
19928
+ try {
19929
+ return await runBoxes(pre);
19930
+ } catch (e) {
19931
+ if (e instanceof SandboxInfraError) throw e;
19932
+ throw new SandboxInfraError(e instanceof Error ? e.message : String(e), agentStarted);
19933
+ }
19934
+ } finally {
19935
+ const step = async (what, fn) => {
19936
+ try {
19937
+ await fn();
19938
+ } catch (e) {
19939
+ deps.log(`sandbox cleanup (${what}) failed: ${e.message}`);
19940
+ }
19941
+ };
19942
+ for (const name of liveBoxes) await step(`kill ${name}`, () => deps.runtime.kill(name));
19943
+ if (volumeTouched) await step("remove volume", () => deps.runtime.removeVolume(volume));
19944
+ await step("counters", readCounters);
19945
+ await step("clear gates", () => deps.gates.clearJob(slot));
19946
+ await step("free slot", () => deps.pool.free(slot.index));
19947
+ for (const c3 of inputCleanups) await step("input dir", c3);
19948
+ await step("release", () => deps.registry.release(job.id));
19949
+ }
19184
19950
  }
19185
- var MIN_MEMORY_MIB, STDOUT_CAP_BYTES, STDERR_CAP_BYTES, NAT_NETWORK, INIT_MEMORY_MIB, NAME_RE, ENV_KEY_RE, LABEL_KEY_RE, CONTROL_RE, obj, str, TailBuffer, AppleContainerRuntime;
19186
- var init_appleContainer = __esm({
19187
- "src/daemon/sandbox/appleContainer.ts"() {
19951
+ var DEFAULT_SANDBOX_TIMEOUTS, SandboxInfraError;
19952
+ var init_runSandboxedJob = __esm({
19953
+ "src/daemon/sandbox/runSandboxedJob.ts"() {
19188
19954
  "use strict";
19189
- init_execa();
19190
- init_sweep();
19191
19955
  init_types();
19192
- MIN_MEMORY_MIB = 200;
19193
- STDOUT_CAP_BYTES = 4 * 1024 * 1024;
19194
- STDERR_CAP_BYTES = 1024 * 1024;
19195
- NAT_NETWORK = "default";
19196
- INIT_MEMORY_MIB = 256;
19197
- NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/;
19198
- ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
19199
- LABEL_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19200
- CONTROL_RE = /[\u0000\n\r]/;
19201
- obj = (v) => v && typeof v === "object" ? v : {};
19202
- str = (v) => typeof v === "string" ? v : null;
19203
- TailBuffer = class {
19204
- constructor(cap) {
19205
- this.cap = cap;
19206
- }
19207
- chunks = [];
19208
- size = 0;
19209
- truncated = false;
19210
- push(chunk) {
19211
- this.chunks.push(chunk);
19212
- this.size += chunk.length;
19213
- while (this.size > this.cap && this.chunks.length > 0) {
19214
- const head = this.chunks[0];
19215
- const over = this.size - this.cap;
19216
- this.truncated = true;
19217
- if (head.length <= over) {
19218
- this.chunks.shift();
19219
- this.size -= head.length;
19220
- } else {
19221
- this.chunks[0] = head.subarray(over);
19222
- this.size -= over;
19223
- }
19224
- }
19225
- }
19226
- toString() {
19227
- let buf = Buffer.concat(this.chunks);
19228
- if (this.truncated) {
19229
- let i2 = 0;
19230
- while (i2 < buf.length && i2 < 3 && (buf[i2] & 192) === 128) i2++;
19231
- buf = buf.subarray(i2);
19232
- }
19233
- return buf.toString("utf8");
19234
- }
19235
- };
19236
- AppleContainerRuntime = class {
19237
- constructor(opts = {}) {
19238
- this.opts = opts;
19239
- this.exec = opts.exec ?? defaultExec2(opts.bin);
19240
- this.spawn = opts.spawn ?? defaultSpawn(opts.bin);
19241
- }
19242
- name = "apple-container";
19243
- exec;
19244
- spawn;
19245
- /** Boxes `kill()` was called for while their step was running. */
19246
- killedNames = /* @__PURE__ */ new Set();
19247
- running = /* @__PURE__ */ new Set();
19248
- async must(args, what) {
19249
- const r = await this.exec(args);
19250
- if (r.exitCode !== 0) throw new Error(`sandbox: ${what} failed (exit ${r.exitCode}): ${r.stderr.trim()}`);
19251
- return r;
19252
- }
19253
- async preflight() {
19254
- const checks = [];
19255
- const status = await this.exec(["system", "status"]);
19256
- const up = status.exitCode === 0 && /^status\s+running\b/m.test(status.stdout);
19257
- checks.push({ name: "runtime_status", ok: up, detail: up ? void 0 : (status.stderr || status.stdout).trim() });
19258
- if (this.opts.image) {
19259
- let img = await this.exec(["image", "inspect", this.opts.image]);
19260
- if (img.exitCode !== 0 && up && this.opts.image.includes("@sha256:")) {
19261
- const pull = await this.exec(["image", "pull", this.opts.image]);
19262
- img = pull.exitCode === 0 ? await this.exec(["image", "inspect", this.opts.image]) : pull;
19263
- }
19264
- checks.push({ name: "image_present", ok: img.exitCode === 0, detail: img.exitCode === 0 ? void 0 : img.stderr.trim() });
19265
- } else {
19266
- checks.push({ name: "image_present", ok: false, detail: "no image configured" });
19267
- }
19268
- const want = this.opts.slots ?? 0;
19269
- if (up) {
19270
- const nets = await this.listNetworks();
19271
- const missing = [];
19272
- for (let i2 = 0; i2 < want; i2++) {
19273
- const s = slotFor(i2);
19274
- const n2 = nets.find((x) => x.name === s.network);
19275
- if (!n2 || n2.subnet !== s.subnet) missing.push(n2 ? `${s.network} (subnet ${n2.subnet})` : s.network);
19276
- }
19277
- checks.push({ name: "slot_networks", ok: missing.length === 0, detail: missing.length ? `missing/wrong: ${missing.join(", ")}` : void 0 });
19278
- } else {
19279
- checks.push({ name: "slot_networks", ok: false, detail: "runtime not running" });
19280
- }
19281
- checks.push({ name: "pf_anchor", ok: true, detail: "skipped (19.3)" });
19282
- checks.push({ name: "ipv6_probe", ok: true, detail: "skipped (19.3)" });
19283
- return { ok: checks.every((c3) => c3.ok), checks };
19284
- }
19285
- async listNetworks() {
19286
- const r = await this.must(["network", "ls", "--format", "json"], "network ls");
19287
- return parseNetworkList(r.stdout);
19288
- }
19289
- async ensureSlots(n2) {
19290
- if (!Number.isInteger(n2) || n2 < 1 || n2 > MAX_SLOTS) throw new Error(`sandbox: slot count out of range: ${n2}`);
19291
- const slots = Array.from({ length: n2 }, (_, i2) => slotFor(i2));
19292
- let nets = await this.listNetworks();
19293
- for (const s of slots) {
19294
- const existing = nets.find((x) => x.name === s.network);
19295
- if (existing) {
19296
- if (existing.subnet !== s.subnet) {
19297
- throw new Error(`sandbox: ${s.network} exists with subnet ${existing.subnet}, expected ${s.subnet}`);
19298
- }
19299
- continue;
19300
- }
19301
- const r = await this.exec(buildEnsureNetworkArgs(s));
19302
- if (r.exitCode !== 0) {
19303
- nets = await this.listNetworks();
19304
- const now = nets.find((x) => x.name === s.network);
19305
- if (!now || now.subnet !== s.subnet) {
19306
- throw new Error(`sandbox: creating ${s.network} failed: ${r.stderr.trim()}`);
19307
- }
19308
- }
19309
- }
19310
- return slots;
19311
- }
19312
- async createVolume(name, sizeGiB, labels) {
19313
- await this.must(buildCreateVolumeArgs(name, sizeGiB, labels), `volume create ${name}`);
19314
- }
19315
- async initVolume(name, uid, labels, image) {
19316
- await this.must(buildInitVolumeArgs(name, uid, labels, image), `volume init ${name}`);
19317
- }
19318
- async runStep(spec) {
19319
- const args = buildRunArgs(spec, this.opts.homeDir);
19320
- const name = spec.name;
19321
- this.running.add(name);
19322
- this.killedNames.delete(name);
19323
- const out = new TailBuffer(STDOUT_CAP_BYTES);
19324
- const err4 = new TailBuffer(STDERR_CAP_BYTES);
19325
- const nonce = randomBytes2(16).toString("hex");
19326
- const proc = this.spawn(args);
19327
- if (proc.stdin) {
19328
- proc.stdin.on("error", () => {
19329
- });
19330
- proc.stdin.end(`${nonce}
19331
- `);
19332
- }
19333
- const tee = (stream, buf, which) => new Promise((resolve7) => {
19334
- if (!stream) return resolve7();
19335
- stream.on("data", (c3) => {
19336
- const b = typeof c3 === "string" ? Buffer.from(c3) : c3;
19337
- buf.push(b);
19338
- this.opts.onOutput?.(name, which, b);
19339
- });
19340
- stream.on("end", () => resolve7());
19341
- stream.on("close", () => resolve7());
19342
- stream.on("error", () => resolve7());
19343
- });
19344
- const done = (async () => {
19345
- const [exit] = await Promise.all([proc.exited, tee(proc.stdout, out, "stdout"), tee(proc.stderr, err4, "stderr")]);
19346
- const killed = this.killedNames.has(name);
19347
- this.killedNames.delete(name);
19348
- this.running.delete(name);
19349
- const stderr = err4.toString();
19350
- return {
19351
- exitCode: exit.exitCode,
19352
- signal: exit.signal,
19353
- stdout: out.toString(),
19354
- stderr: exit.error ? `${stderr}
19355
- ${exit.error}`.trim() : stderr,
19356
- killed,
19357
- nonce
19358
- };
19359
- })();
19360
- return { name, done };
19361
- }
19362
- /** Not-found and already-stopped both count as success: the box is not running. */
19363
- async kill(name) {
19364
- assertName("box", name);
19365
- if (this.running.has(name)) this.killedNames.add(name);
19366
- const r = await this.exec(["kill", name]);
19367
- if (r.exitCode !== 0 && !isBoxNotFound(r.stderr) && !isBoxStopped(r.stderr)) {
19368
- throw new Error(`sandbox: kill ${name} failed: ${r.stderr.trim()}`);
19369
- }
19370
- }
19371
- async stop(name, graceSeconds) {
19372
- assertName("box", name);
19373
- const t = Math.max(0, Math.floor(graceSeconds));
19374
- const r = await this.exec(["stop", "-t", String(t), name]);
19375
- if (r.exitCode !== 0 && !isBoxNotFound(r.stderr) && !isBoxStopped(r.stderr)) {
19376
- throw new Error(`sandbox: stop ${name} failed: ${r.stderr.trim()}`);
19377
- }
19378
- }
19379
- /** `container rm`; not-found counts as removed (a `--rm` box is gone once stopped). */
19380
- async remove(name) {
19381
- assertName("box", name);
19382
- const r = await this.exec(["rm", name]);
19383
- if (r.exitCode !== 0 && !isBoxNotFound(r.stderr)) {
19384
- throw new Error(`sandbox: rm ${name} failed: ${r.stderr.trim()}`);
19385
- }
19386
- }
19387
- async removeVolume(name) {
19388
- assertName("volume", name);
19389
- const r = await this.exec(["volume", "rm", name]);
19390
- if (r.exitCode === 0) return;
19391
- const still = (await this.listVolumes()).some((v) => v.name === name);
19392
- if (still) throw new Error(`sandbox: volume rm ${name} failed: ${r.stderr.trim()}`);
19393
- }
19394
- async list() {
19395
- const r = await this.must(["ls", "-a", "--format", "json"], "ls");
19396
- return parseBoxList(r.stdout);
19397
- }
19398
- async listVolumes() {
19399
- const r = await this.must(["volume", "ls", "--format", "json"], "volume ls");
19400
- return parseVolumeList(r.stdout);
19956
+ DEFAULT_SANDBOX_TIMEOUTS = {
19957
+ claudeMs: 30 * 60 * 1e3,
19958
+ largeDiffClaudeMs: 45 * 60 * 1e3,
19959
+ graceMs: 2 * 60 * 1e3,
19960
+ cloneMs: 10 * 60 * 1e3
19961
+ };
19962
+ SandboxInfraError = class extends Error {
19963
+ constructor(message2, agentStarted) {
19964
+ super(message2);
19965
+ this.agentStarted = agentStarted;
19966
+ this.name = "SandboxInfraError";
19401
19967
  }
19402
- sweep() {
19403
- return sweepJobs(this);
19968
+ };
19969
+ }
19970
+ });
19971
+
19972
+ // src/daemon/sandbox/classifySandboxExit.ts
19973
+ function classifySandboxExit(input) {
19974
+ const { counters, exitCode, stderr, stepFailure } = input;
19975
+ if ((counters?.cap_hits ?? 0) > 0 || stderr.includes(SANDBOX_CAP_BODY)) {
19976
+ return {
19977
+ kind: "terminal",
19978
+ reason: SANDBOX_REQUEST_CAP_REASON,
19979
+ message: `${SANDBOX_REQUEST_CAP_REASON}: the job exceeded the sandbox model-request cap` + (counters ? ` (${counters.model_requests} requests, ${counters.cap_hits} refused)` : "")
19980
+ };
19981
+ }
19982
+ if (stepFailure == null && exitCode !== null && exitCode !== 0 && counters?.last_upstream_status === 429) {
19983
+ return { kind: "upstream_rate_limit" };
19984
+ }
19985
+ return { kind: "normal" };
19986
+ }
19987
+ function routeSandboxFailure(verdict, planFor, nowMs = Date.now()) {
19988
+ if (verdict?.kind === "terminal") return { kind: "terminal", message: verdict.message };
19989
+ const plan = planFor();
19990
+ if (plan) return { kind: "defer", plan };
19991
+ if (verdict?.kind === "upstream_rate_limit") {
19992
+ return {
19993
+ kind: "defer",
19994
+ plan: { parsed: false, pauseUntil: new Date(nowMs + RATE_LIMIT_TENTATIVE_PAUSE_MS), info: null }
19995
+ };
19996
+ }
19997
+ return { kind: "fail" };
19998
+ }
19999
+ var SANDBOX_REQUEST_CAP_REASON, SandboxExitError;
20000
+ var init_classifySandboxExit = __esm({
20001
+ "src/daemon/sandbox/classifySandboxExit.ts"() {
20002
+ "use strict";
20003
+ init_rateLimit();
20004
+ init_types();
20005
+ SANDBOX_REQUEST_CAP_REASON = "sandbox_request_cap";
20006
+ SandboxExitError = class extends Error {
20007
+ constructor(message2, verdict) {
20008
+ super(message2);
20009
+ this.verdict = verdict;
20010
+ this.name = "SandboxExitError";
19404
20011
  }
19405
20012
  };
19406
20013
  }
@@ -19540,6 +20147,47 @@ function createGateServer(opts) {
19540
20147
  fail2(e);
19541
20148
  }
19542
20149
  });
20150
+ if (opts.connect) {
20151
+ const connect = opts.connect;
20152
+ server.on("connect", (req, socket, head) => {
20153
+ const target = (req.url ?? "").slice(0, 300);
20154
+ const slot = opts.slotMap.resolve(req.socket.localAddress, req.socket.remoteAddress);
20155
+ socket.on("error", () => {
20156
+ });
20157
+ if (!slot) {
20158
+ log(`[${opts.name}] CONNECT ${target} drop job=-`);
20159
+ socket.destroy();
20160
+ return;
20161
+ }
20162
+ const binding = opts.slotMap.binding(slot.index);
20163
+ if (!binding) {
20164
+ log(`[${opts.name}] CONNECT ${target} 403 job=- slot=${slot.index} idle`);
20165
+ socket.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
20166
+ return;
20167
+ }
20168
+ let logged = false;
20169
+ const ctx = {
20170
+ slot,
20171
+ binding,
20172
+ path: target,
20173
+ done(status, note) {
20174
+ if (logged) return;
20175
+ logged = true;
20176
+ log(`[${opts.name}] CONNECT ${target} ${status} job=${binding.jobId}${note ? ` ${note}` : ""}`);
20177
+ }
20178
+ };
20179
+ const fail2 = (e) => {
20180
+ ctx.done(502, `handler error: ${e?.name ?? "error"}`);
20181
+ if (socket.writable) socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
20182
+ else socket.destroy();
20183
+ };
20184
+ try {
20185
+ Promise.resolve(connect(req, socket, head, ctx)).catch(fail2);
20186
+ } catch (e) {
20187
+ fail2(e);
20188
+ }
20189
+ });
20190
+ }
19543
20191
  server.requestTimeout = 0;
19544
20192
  server.headersTimeout = 6e4;
19545
20193
  return {
@@ -19706,12 +20354,121 @@ var init_credentialGate = __esm({
19706
20354
  }
19707
20355
  });
19708
20356
 
20357
+ // src/daemon/sandbox/githubGate.ts
20358
+ import net from "node:net";
20359
+ function parseConnectTarget(authority) {
20360
+ const i2 = authority.lastIndexOf(":");
20361
+ if (i2 <= 0) return { ok: false };
20362
+ const portStr = authority.slice(i2 + 1);
20363
+ let host = authority.slice(0, i2).toLowerCase();
20364
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
20365
+ if (host.endsWith(".")) host = host.slice(0, -1);
20366
+ if (portStr !== "443" || net.isIP(host) !== 0 || !GITHUB_ALLOWED_HOSTS.has(host)) return { ok: false };
20367
+ return { ok: true, host, port: 443 };
20368
+ }
20369
+ var GITHUB_ALLOWED_HOSTS, FORBIDDEN, BAD_GATEWAY, GithubGate;
20370
+ var init_githubGate = __esm({
20371
+ "src/daemon/sandbox/githubGate.ts"() {
20372
+ "use strict";
20373
+ init_gateServer();
20374
+ init_types();
20375
+ GITHUB_ALLOWED_HOSTS = /* @__PURE__ */ new Set(["github.com", "codeload.github.com"]);
20376
+ FORBIDDEN = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
20377
+ BAD_GATEWAY = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n";
20378
+ GithubGate = class {
20379
+ gate;
20380
+ dial;
20381
+ /** Open tunnels per slot: a clone-step tunnel must not outlive the clone step. */
20382
+ tunnels = /* @__PURE__ */ new Map();
20383
+ constructor(opts) {
20384
+ this.dial = opts.dial ?? ((host, port) => net.connect({ host, port }));
20385
+ this.gate = createGateServer({
20386
+ name: "github-gate",
20387
+ slotMap: opts.slotMap,
20388
+ host: opts.host,
20389
+ port: opts.port ?? GATE_PORTS.github,
20390
+ logger: opts.logger,
20391
+ handler: (_req, res, ctx) => {
20392
+ ctx.done(404);
20393
+ res.writeHead(404).end();
20394
+ },
20395
+ connect: (req, socket, head, ctx) => this.tunnel(req, socket, head, ctx)
20396
+ });
20397
+ }
20398
+ listen() {
20399
+ return this.gate.listen();
20400
+ }
20401
+ async close() {
20402
+ for (const index of [...this.tunnels.keys()]) this.closeTunnels(index);
20403
+ await this.gate.close();
20404
+ }
20405
+ /** The slot's step moved (clone → agent): its tunnels go. */
20406
+ onStepChange(index) {
20407
+ this.closeTunnels(index);
20408
+ }
20409
+ onClear(index) {
20410
+ this.closeTunnels(index);
20411
+ }
20412
+ closeTunnels(index) {
20413
+ const set = this.tunnels.get(index);
20414
+ if (!set) return;
20415
+ this.tunnels.delete(index);
20416
+ for (const destroy of set) destroy();
20417
+ }
20418
+ tunnel(_req, socket, head, ctx) {
20419
+ const { counters } = ctx.binding;
20420
+ const refuse = (note) => {
20421
+ counters.egress_denied++;
20422
+ counters.first_egress_denied ??= ctx.path;
20423
+ ctx.done(403, note);
20424
+ socket.end(FORBIDDEN);
20425
+ };
20426
+ if (ctx.binding.step !== "clone") return refuse(`step=${ctx.binding.step ?? "none"}`);
20427
+ const target = parseConnectTarget(ctx.path);
20428
+ if (!target.ok) return refuse("not allowed");
20429
+ const upstream = this.dial(target.host, target.port);
20430
+ let set = this.tunnels.get(ctx.slot.index);
20431
+ if (!set) this.tunnels.set(ctx.slot.index, set = /* @__PURE__ */ new Set());
20432
+ const destroy = () => {
20433
+ upstream.destroy();
20434
+ socket.destroy();
20435
+ };
20436
+ set.add(destroy);
20437
+ let open4 = false;
20438
+ const cleanup = () => {
20439
+ set.delete(destroy);
20440
+ destroy();
20441
+ };
20442
+ upstream.once("connect", () => {
20443
+ open4 = true;
20444
+ ctx.done(200);
20445
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
20446
+ if (head.length) upstream.write(head);
20447
+ upstream.pipe(socket);
20448
+ socket.pipe(upstream);
20449
+ });
20450
+ upstream.on("error", () => {
20451
+ if (open4) return;
20452
+ ctx.done(502, "dial failed");
20453
+ set.delete(destroy);
20454
+ socket.end(BAD_GATEWAY);
20455
+ });
20456
+ upstream.on("close", () => {
20457
+ if (open4) cleanup();
20458
+ });
20459
+ socket.on("close", cleanup);
20460
+ socket.on("error", cleanup);
20461
+ }
20462
+ };
20463
+ }
20464
+ });
20465
+
19709
20466
  // src/daemon/sandbox/mcpGate.ts
19710
20467
  function redact(err4) {
19711
20468
  const msg = err4 instanceof Error ? err4.message : "error";
19712
20469
  return msg.replace(/\b(amj_|am_|sk-ant-|eyJ)[A-Za-z0-9._-]*/g, "[redacted]").slice(0, 200);
19713
20470
  }
19714
- var MCP_PATH, REMINT_LEAD_MS, MAX_TIMER_MS, realTimers, McpGate;
20471
+ var MCP_PATH, REMINT_LEAD_MS, MAX_TIMER_MS, realTimers2, McpGate;
19715
20472
  var init_mcpGate = __esm({
19716
20473
  "src/daemon/sandbox/mcpGate.ts"() {
19717
20474
  "use strict";
@@ -19720,7 +20477,7 @@ var init_mcpGate = __esm({
19720
20477
  MCP_PATH = "/functions/v1/mcp";
19721
20478
  REMINT_LEAD_MS = 10 * 60 * 1e3;
19722
20479
  MAX_TIMER_MS = 2 ** 31 - 1;
19723
- realTimers = {
20480
+ realTimers2 = {
19724
20481
  now: () => Date.now(),
19725
20482
  setTimeout: (fn, ms) => {
19726
20483
  const t = setTimeout(fn, ms);
@@ -19733,7 +20490,7 @@ var init_mcpGate = __esm({
19733
20490
  constructor(opts) {
19734
20491
  this.opts = opts;
19735
20492
  this.upstream = new URL(opts.upstream);
19736
- this.timers = opts.timers ?? realTimers;
20493
+ this.timers = opts.timers ?? realTimers2;
19737
20494
  this.log = opts.logger ?? ((line) => console.log(line));
19738
20495
  this.gate = createGateServer({
19739
20496
  name: "mcp-gate",
@@ -19775,177 +20532,80 @@ var init_mcpGate = __esm({
19775
20532
  cancel(index) {
19776
20533
  const s = this.schedules.get(index);
19777
20534
  if (s) {
19778
- this.timers.clearTimeout(s.handle);
19779
- this.schedules.delete(index);
19780
- }
19781
- this.generation++;
19782
- }
19783
- schedule(index, jobId, expiresAt) {
19784
- const generation = ++this.generation;
19785
- const arm = (fireAt) => {
19786
- const delay = Math.max(0, fireAt - this.timers.now());
19787
- const handle = this.timers.setTimeout(() => {
19788
- if (this.schedules.get(index)?.generation !== generation) return;
19789
- if (fireAt - this.timers.now() > 0) arm(fireAt);
19790
- else void this.remint(index, jobId, generation);
19791
- }, Math.min(delay, MAX_TIMER_MS));
19792
- this.schedules.set(index, { jobId, handle, generation });
19793
- };
19794
- arm(expiresAt - REMINT_LEAD_MS);
19795
- }
19796
- async remint(index, jobId, generation) {
19797
- let next;
19798
- try {
19799
- next = await this.opts.remint(jobId);
19800
- } catch (err4) {
19801
- if (this.schedules.get(index)?.generation === generation) this.schedules.delete(index);
19802
- this.log(`[mcp-gate] remint failed job=${jobId}: ${redact(err4)}`);
19803
- return;
19804
- }
19805
- const binding = this.opts.slotMap.binding(index);
19806
- if (this.schedules.get(index)?.generation !== generation || binding?.jobId !== jobId || !binding.mcp) {
19807
- this.log(`[mcp-gate] remint discarded job=${jobId}: slot moved on`);
19808
- return;
19809
- }
19810
- binding.mcp = { ...binding.mcp, token: next.token, expiresAt: next.expiresAt };
19811
- binding.counters.remints++;
19812
- this.schedules.delete(index);
19813
- this.log(`[mcp-gate] remint ok job=${jobId}`);
19814
- if (next.expiresAt === null) return;
19815
- if (next.expiresAt - REMINT_LEAD_MS <= this.timers.now()) {
19816
- this.log(`[mcp-gate] reminted token expires inside the lead window job=${jobId}; not rescheduling`);
19817
- return;
19818
- }
19819
- this.schedule(index, jobId, next.expiresAt);
19820
- }
19821
- handle(req, res, ctx) {
19822
- if (ctx.path !== MCP_PATH) {
19823
- ctx.done(404);
19824
- res.writeHead(404).end();
19825
- return;
19826
- }
19827
- if (req.method === "GET") {
19828
- ctx.done(405);
19829
- res.writeHead(405, { allow: "POST" }).end();
19830
- return;
19831
- }
19832
- if (req.method !== "POST") {
19833
- ctx.done(404);
19834
- res.writeHead(404).end();
19835
- return;
19836
- }
19837
- const mcp = ctx.binding.mcp;
19838
- if (!mcp) {
19839
- ctx.done(403, "no-mcp");
19840
- res.writeHead(403).end();
19841
- return;
19842
- }
19843
- ctx.binding.counters.mcp_requests++;
19844
- const headers = filterHeaders(
19845
- req.headers,
19846
- (k) => k === "authorization" || k === "apikey" || k.startsWith("x-archme-source-")
19847
- );
19848
- headers.authorization = `Bearer ${mcp.token}`;
19849
- const target = new URL(this.upstream.href);
19850
- target.search = new URLSearchParams({ project: mcp.projectSlug, branch: mcp.branch }).toString();
19851
- void forward(req, res, target, headers).then((r) => ctx.done(r.status));
19852
- }
19853
- };
19854
- }
19855
- });
19856
-
19857
- // src/daemon/sandbox/slotMap.ts
19858
- function parseIpv4(addr) {
19859
- const m = IPV4_RE.exec(addr);
19860
- if (!m) return null;
19861
- let v = 0;
19862
- for (let i2 = 1; i2 <= 4; i2++) {
19863
- const o2 = Number(m[i2]);
19864
- if (o2 > 255) return null;
19865
- v = v * 256 + o2;
19866
- }
19867
- return v >>> 0;
19868
- }
19869
- function parseCidr(cidr) {
19870
- const [ip, bits, extra] = cidr.split("/");
19871
- if (ip === void 0 || bits === void 0 || extra !== void 0 || !/^\d{1,2}$/.test(bits)) return null;
19872
- const n2 = Number(bits);
19873
- const base = parseIpv4(ip);
19874
- if (base === null || n2 > 32) return null;
19875
- const mask = n2 === 0 ? 0 : ~0 << 32 - n2 >>> 0;
19876
- return { base: (base & mask) >>> 0, mask };
19877
- }
19878
- function stripMapped(addr) {
19879
- return (addr ?? "").replace(/^::ffff:/i, "");
19880
- }
19881
- var IPV4_RE, SlotMap;
19882
- var init_slotMap = __esm({
19883
- "src/daemon/sandbox/slotMap.ts"() {
19884
- "use strict";
19885
- init_appleContainer();
19886
- IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
19887
- SlotMap = class {
19888
- byIndex = /* @__PURE__ */ new Map();
19889
- byGateway = /* @__PURE__ */ new Map();
19890
- allowGatewayAsPeer;
19891
- constructor(slots, opts = {}) {
19892
- this.allowGatewayAsPeer = opts.allowGatewayAsPeer ?? false;
19893
- for (const slot of slots) {
19894
- const gateway = parseIpv4(slot.gateway);
19895
- const cidr = parseCidr(slot.subnet);
19896
- if (gateway === null) throw new Error(`slot ${slot.index}: gateway is not an IPv4 address`);
19897
- if (cidr === null) throw new Error(`slot ${slot.index}: subnet is not an IPv4 CIDR`);
19898
- if (this.byIndex.has(slot.index)) throw new Error(`slot ${slot.index}: duplicate index`);
19899
- if (this.byGateway.has(slot.gateway)) throw new Error(`slot ${slot.index}: duplicate gateway`);
19900
- const entry = { slot, gateway, cidr, binding: null };
19901
- this.byIndex.set(slot.index, entry);
19902
- this.byGateway.set(slot.gateway, entry);
19903
- }
19904
- }
19905
- /**
19906
- * The slot a connection belongs to, or null (drop it). `local` must be a slot
19907
- * gateway; `remote` must be a plain IPv4 inside THAT slot's subnet — never
19908
- * IPv6, never another slot's range.
19909
- */
19910
- resolve(localAddress, remoteAddress) {
19911
- const entry = this.byGateway.get(stripMapped(localAddress));
19912
- if (!entry) return null;
19913
- const remoteStr = stripMapped(remoteAddress);
19914
- const remote = parseIpv4(remoteStr);
19915
- if (remote === null) return null;
19916
- if ((remote & entry.cidr.mask) >>> 0 !== entry.cidr.base) return null;
19917
- if (remote === entry.gateway && !this.allowGatewayAsPeer) return null;
19918
- return entry.slot;
19919
- }
19920
- slots() {
19921
- return [...this.byIndex.values()].map((e) => e.slot);
19922
- }
19923
- slot(index) {
19924
- return this.byIndex.get(index)?.slot ?? null;
19925
- }
19926
- bind(index, binding) {
19927
- const entry = this.entry(index);
19928
- if (entry.binding && entry.binding.jobId !== binding.jobId) {
19929
- throw new Error(`slot ${index} is bound to another job`);
19930
- }
19931
- entry.binding = binding;
19932
- }
19933
- clear(index) {
19934
- this.entry(index).binding = null;
20535
+ this.timers.clearTimeout(s.handle);
20536
+ this.schedules.delete(index);
20537
+ }
20538
+ this.generation++;
19935
20539
  }
19936
- binding(index) {
19937
- return this.byIndex.get(index)?.binding ?? null;
20540
+ schedule(index, jobId, expiresAt) {
20541
+ const generation = ++this.generation;
20542
+ const arm = (fireAt) => {
20543
+ const delay = Math.max(0, fireAt - this.timers.now());
20544
+ const handle = this.timers.setTimeout(() => {
20545
+ if (this.schedules.get(index)?.generation !== generation) return;
20546
+ if (fireAt - this.timers.now() > 0) arm(fireAt);
20547
+ else void this.remint(index, jobId, generation);
20548
+ }, Math.min(delay, MAX_TIMER_MS));
20549
+ this.schedules.set(index, { jobId, handle, generation });
20550
+ };
20551
+ arm(expiresAt - REMINT_LEAD_MS);
19938
20552
  }
19939
- bindingForJob(jobId) {
19940
- for (const e of this.byIndex.values()) {
19941
- if (e.binding?.jobId === jobId) return { slot: e.slot, binding: e.binding };
20553
+ async remint(index, jobId, generation) {
20554
+ let next;
20555
+ try {
20556
+ next = await this.opts.remint(jobId);
20557
+ } catch (err4) {
20558
+ if (this.schedules.get(index)?.generation === generation) this.schedules.delete(index);
20559
+ this.log(`[mcp-gate] remint failed job=${jobId}: ${redact(err4)}`);
20560
+ return;
19942
20561
  }
19943
- return null;
20562
+ const binding = this.opts.slotMap.binding(index);
20563
+ if (this.schedules.get(index)?.generation !== generation || binding?.jobId !== jobId || !binding.mcp) {
20564
+ this.log(`[mcp-gate] remint discarded job=${jobId}: slot moved on`);
20565
+ return;
20566
+ }
20567
+ binding.mcp = { ...binding.mcp, token: next.token, expiresAt: next.expiresAt };
20568
+ binding.counters.remints++;
20569
+ this.schedules.delete(index);
20570
+ this.log(`[mcp-gate] remint ok job=${jobId}`);
20571
+ if (next.expiresAt === null) return;
20572
+ if (next.expiresAt - REMINT_LEAD_MS <= this.timers.now()) {
20573
+ this.log(`[mcp-gate] reminted token expires inside the lead window job=${jobId}; not rescheduling`);
20574
+ return;
20575
+ }
20576
+ this.schedule(index, jobId, next.expiresAt);
19944
20577
  }
19945
- entry(index) {
19946
- const e = this.byIndex.get(index);
19947
- if (!e) throw new Error(`unknown slot ${index}`);
19948
- return e;
20578
+ handle(req, res, ctx) {
20579
+ if (ctx.path !== MCP_PATH) {
20580
+ ctx.done(404);
20581
+ res.writeHead(404).end();
20582
+ return;
20583
+ }
20584
+ if (req.method === "GET") {
20585
+ ctx.done(405);
20586
+ res.writeHead(405, { allow: "POST" }).end();
20587
+ return;
20588
+ }
20589
+ if (req.method !== "POST") {
20590
+ ctx.done(404);
20591
+ res.writeHead(404).end();
20592
+ return;
20593
+ }
20594
+ const mcp = ctx.binding.mcp;
20595
+ if (!mcp) {
20596
+ ctx.done(403, "no-mcp");
20597
+ res.writeHead(403).end();
20598
+ return;
20599
+ }
20600
+ ctx.binding.counters.mcp_requests++;
20601
+ const headers = filterHeaders(
20602
+ req.headers,
20603
+ (k) => k === "authorization" || k === "apikey" || k.startsWith("x-archme-source-")
20604
+ );
20605
+ headers.authorization = `Bearer ${mcp.token}`;
20606
+ const target = new URL(this.upstream.href);
20607
+ target.search = new URLSearchParams({ project: mcp.projectSlug, branch: mcp.branch }).toString();
20608
+ void forward(req, res, target, headers).then((r) => ctx.done(r.status));
19949
20609
  }
19950
20610
  };
19951
20611
  }
@@ -19953,7 +20613,16 @@ var init_slotMap = __esm({
19953
20613
 
19954
20614
  // src/daemon/sandbox/slots.ts
19955
20615
  function zeroCounters() {
19956
- return { model_requests: 0, upstream_429: 0, last_upstream_status: null, cap_hits: 0, mcp_requests: 0, remints: 0 };
20616
+ return {
20617
+ model_requests: 0,
20618
+ upstream_429: 0,
20619
+ last_upstream_status: null,
20620
+ cap_hits: 0,
20621
+ mcp_requests: 0,
20622
+ remints: 0,
20623
+ egress_denied: 0,
20624
+ first_egress_denied: null
20625
+ };
19957
20626
  }
19958
20627
  function indexOf(slot) {
19959
20628
  return typeof slot === "number" ? slot : slot.index;
@@ -19963,6 +20632,7 @@ var init_slots = __esm({
19963
20632
  "src/daemon/sandbox/slots.ts"() {
19964
20633
  "use strict";
19965
20634
  init_credentialGate();
20635
+ init_githubGate();
19966
20636
  init_mcpGate();
19967
20637
  init_slotMap();
19968
20638
  init_types();
@@ -19970,6 +20640,7 @@ var init_slots = __esm({
19970
20640
  slotMap;
19971
20641
  credential;
19972
20642
  mcp;
20643
+ github;
19973
20644
  boundPorts = null;
19974
20645
  constructor(opts) {
19975
20646
  this.slotMap = new SlotMap(opts.slots, opts.slotMapOptions);
@@ -19990,21 +20661,32 @@ var init_slots = __esm({
19990
20661
  logger: opts.logger,
19991
20662
  timers: opts.timers
19992
20663
  });
20664
+ this.github = new GithubGate({
20665
+ slotMap: this.slotMap,
20666
+ host: opts.host,
20667
+ port: opts.ports?.github ?? GATE_PORTS.github,
20668
+ logger: opts.logger,
20669
+ dial: opts.githubDial
20670
+ });
19993
20671
  }
19994
- /** Binds both gates; resolves with the ports actually bound. */
20672
+ /** Binds all three gates; resolves with the ports actually bound. */
19995
20673
  async start() {
19996
- const anthropic = await this.credential.listen();
20674
+ const started = [];
19997
20675
  try {
20676
+ const anthropic = await this.credential.listen();
20677
+ started.push(this.credential);
19998
20678
  const mcp = await this.mcp.listen();
19999
- this.boundPorts = { anthropic, mcp };
20679
+ started.push(this.mcp);
20680
+ const github = await this.github.listen();
20681
+ this.boundPorts = { anthropic, mcp, github };
20000
20682
  return this.boundPorts;
20001
20683
  } catch (err4) {
20002
- await this.credential.close();
20684
+ await Promise.all(started.map((g) => g.close()));
20003
20685
  throw err4;
20004
20686
  }
20005
20687
  }
20006
20688
  async stop() {
20007
- await Promise.all([this.credential.close(), this.mcp.close()]);
20689
+ await Promise.all([this.credential.close(), this.mcp.close(), this.github.close()]);
20008
20690
  this.boundPorts = null;
20009
20691
  }
20010
20692
  ports() {
@@ -20014,176 +20696,69 @@ var init_slots = __esm({
20014
20696
  bindJob(slot, input) {
20015
20697
  const index = indexOf(slot);
20016
20698
  this.slotMap.bind(index, {
20017
- jobId: input.jobId,
20018
- maxModelRequests: input.maxModelRequests,
20019
- mcp: input.mcp ? { ...input.mcp } : null,
20020
- counters: zeroCounters()
20021
- });
20022
- this.mcp.onBind(index);
20023
- }
20024
- /** Unbinds the slot (it answers 403 again) and returns the job's final counters. */
20025
- clearJob(slot) {
20026
- const index = indexOf(slot);
20027
- const counters = this.countersFor(index);
20028
- this.mcp.onClear(index);
20029
- this.slotMap.clear(index);
20030
- return counters;
20031
- }
20032
- /** A snapshot of the slot's counters, or null when idle. */
20033
- countersFor(slot) {
20034
- const b = this.slotMap.binding(indexOf(slot));
20035
- return b ? { ...b.counters } : null;
20036
- }
20037
- };
20038
- SlotPool = class {
20039
- free_ = /* @__PURE__ */ new Set();
20040
- byIndex = /* @__PURE__ */ new Map();
20041
- constructor(slots) {
20042
- for (const s of slots) {
20043
- this.byIndex.set(s.index, s);
20044
- this.free_.add(s.index);
20045
- }
20046
- }
20047
- /** The lowest free slot, or null when every slot is busy. */
20048
- lease() {
20049
- let best = null;
20050
- for (const i2 of this.free_) if (best === null || i2 < best) best = i2;
20051
- if (best === null) return null;
20052
- this.free_.delete(best);
20053
- return this.byIndex.get(best) ?? null;
20054
- }
20055
- /** Idempotent; unknown indices are ignored. */
20056
- free(index) {
20057
- if (this.byIndex.has(index)) this.free_.add(index);
20058
- }
20059
- available() {
20060
- return this.free_.size;
20061
- }
20062
- };
20063
- }
20064
- });
20065
-
20066
- // src/daemon/sandboxJob/result.ts
20067
- import { readFile as readFile13 } from "node:fs/promises";
20068
- function isValidNonce(nonce) {
20069
- return typeof nonce === "string" && RESULT_NONCE_RE.test(nonce);
20070
- }
20071
- function encode(result, nonce) {
20072
- return `${RESULT_PREFIX}${nonce} ${Buffer.from(JSON.stringify(result), "utf8").toString("base64")}`;
20073
- }
20074
- function fits(line, cap) {
20075
- return Buffer.byteLength(line, "utf8") <= cap;
20076
- }
20077
- function withTruncatedError(r) {
20078
- return { ...r, error: r.error ? `${r.error}; ${RESULT_TRUNCATED}` : RESULT_TRUNCATED };
20079
- }
20080
- function minimal(r) {
20081
- if (r.kind === "clone") {
20082
- const m2 = { kind: "clone", outcome: r.outcome, memoryPeakBytes: r.memoryPeakBytes, durations_ms: {}, error: RESULT_TRUNCATED };
20083
- return m2;
20084
- }
20085
- const m = {
20086
- kind: "agent",
20087
- exitCode: r.exitCode,
20088
- signal: r.signal,
20089
- claudeStdout: "",
20090
- affected: null,
20091
- promptBytes: r.promptBytes,
20092
- memoryPeakBytes: r.memoryPeakBytes,
20093
- durations_ms: {},
20094
- error: RESULT_TRUNCATED
20095
- };
20096
- return m;
20097
- }
20098
- function formatResultLine(result, nonce, cap = RESULT_MAX_BYTES) {
20099
- if (!isValidNonce(nonce)) throw new Error("result: invalid nonce");
20100
- const encode_ = (r) => encode(r, nonce);
20101
- let line = encode_(result);
20102
- if (fits(line, cap)) return line;
20103
- if (result.kind === "agent") {
20104
- const noAffected = { ...result, affected: null };
20105
- line = encode_(withTruncatedError(noAffected));
20106
- if (fits(line, cap)) return line;
20107
- line = encode_(withTruncatedError({ ...noAffected, claudeStdout: "" }));
20108
- if (fits(line, cap)) return line;
20109
- }
20110
- return encode_(minimal(result));
20111
- }
20112
- function isObj(v) {
20113
- return typeof v === "object" && v !== null && !Array.isArray(v);
20114
- }
20115
- function isNumOrNull(v) {
20116
- return v === null || typeof v === "number" && Number.isFinite(v);
20117
- }
20118
- function shapeError(o2) {
20119
- if (!isNumOrNull(o2.memoryPeakBytes)) return "memoryPeakBytes";
20120
- if (!isObj(o2.durations_ms) || !Object.values(o2.durations_ms).every((v) => typeof v === "number")) return "durations_ms";
20121
- if (o2.error !== void 0 && typeof o2.error !== "string") return "error";
20122
- if (o2.kind === "clone") {
20123
- if (o2.outcome !== "diff" && o2.outcome !== "skip" && o2.outcome !== "rejected") return "outcome";
20124
- if (o2.diff !== void 0 && !isObj(o2.diff)) return "diff";
20125
- return null;
20126
- }
20127
- if (o2.kind === "agent") {
20128
- if (!isNumOrNull(o2.exitCode)) return "exitCode";
20129
- if (o2.signal !== null && typeof o2.signal !== "string") return "signal";
20130
- if (typeof o2.claudeStdout !== "string") return "claudeStdout";
20131
- if (o2.affected !== null && !(isObj(o2.affected) && ["stories", "components", "unresolvedFiles"].every((k) => Array.isArray(o2.affected[k])))) {
20132
- return "affected";
20133
- }
20134
- if (typeof o2.promptBytes !== "number") return "promptBytes";
20135
- return null;
20136
- }
20137
- return "kind";
20138
- }
20139
- function parseResultLine(stdout, nonce, cap = RESULT_MAX_BYTES) {
20140
- if (!isValidNonce(nonce)) return { ok: false, reason: "no result line" };
20141
- const prefix = `${RESULT_PREFIX}${nonce} `;
20142
- const lines = stdout.split("\n");
20143
- let line;
20144
- for (let i2 = lines.length - 1; i2 >= 0; i2--) {
20145
- const l = lines[i2].replace(/\r$/, "");
20146
- if (l.startsWith(prefix)) {
20147
- line = l;
20148
- break;
20149
- }
20150
- }
20151
- if (line === void 0) return { ok: false, reason: "no result line" };
20152
- if (Buffer.byteLength(line, "utf8") > cap) return { ok: false, reason: "result line exceeds cap" };
20153
- const b64 = line.slice(prefix.length).trim();
20154
- if (b64 === "" || b64.length % 4 !== 0 || !BASE64_RE.test(b64)) return { ok: false, reason: "result is not base64" };
20155
- let data;
20156
- try {
20157
- data = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
20158
- } catch {
20159
- return { ok: false, reason: "result is not JSON" };
20160
- }
20161
- if (!isObj(data)) return { ok: false, reason: "result is not an object" };
20162
- const bad = shapeError(data);
20163
- if (bad) return { ok: false, reason: `result has a malformed ${bad}` };
20164
- return { ok: true, result: data };
20165
- }
20166
- async function readMemoryPeak(path7 = "/sys/fs/cgroup/memory.peak", read = (p) => readFile13(p, "utf8")) {
20167
- try {
20168
- const n2 = Number.parseInt((await read(path7)).trim(), 10);
20169
- return Number.isFinite(n2) && n2 >= 0 ? n2 : null;
20170
- } catch {
20171
- return null;
20172
- }
20173
- }
20174
- var RESULT_TRUNCATED, BASE64_RE;
20175
- var init_result2 = __esm({
20176
- "src/daemon/sandboxJob/result.ts"() {
20177
- "use strict";
20178
- init_types();
20179
- RESULT_TRUNCATED = "result truncated";
20180
- BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
20699
+ jobId: input.jobId,
20700
+ maxModelRequests: input.maxModelRequests,
20701
+ mcp: input.mcp ? { ...input.mcp } : null,
20702
+ step: null,
20703
+ counters: zeroCounters()
20704
+ });
20705
+ this.mcp.onBind(index);
20706
+ }
20707
+ /** Which step's box is running on the slot; set before each box starts.
20708
+ * Leaving the clone step closes the slot's GitHub tunnels. */
20709
+ setStep(slot, step) {
20710
+ const index = indexOf(slot);
20711
+ const b = this.slotMap.binding(index);
20712
+ if (!b) throw new Error(`slot ${index} is not bound`);
20713
+ b.step = step;
20714
+ this.github.onStepChange(index);
20715
+ }
20716
+ /** Unbinds the slot (it answers 403 again) and returns the job's final counters. */
20717
+ clearJob(slot) {
20718
+ const index = indexOf(slot);
20719
+ const counters = this.countersFor(index);
20720
+ this.mcp.onClear(index);
20721
+ this.github.onClear(index);
20722
+ this.slotMap.clear(index);
20723
+ return counters;
20724
+ }
20725
+ /** A snapshot of the slot's counters, or null when idle. */
20726
+ countersFor(slot) {
20727
+ const b = this.slotMap.binding(indexOf(slot));
20728
+ return b ? { ...b.counters } : null;
20729
+ }
20730
+ };
20731
+ SlotPool = class {
20732
+ free_ = /* @__PURE__ */ new Set();
20733
+ byIndex = /* @__PURE__ */ new Map();
20734
+ constructor(slots) {
20735
+ for (const s of slots) {
20736
+ this.byIndex.set(s.index, s);
20737
+ this.free_.add(s.index);
20738
+ }
20739
+ }
20740
+ /** The lowest free slot, or null when every slot is busy. */
20741
+ lease() {
20742
+ let best = null;
20743
+ for (const i2 of this.free_) if (best === null || i2 < best) best = i2;
20744
+ if (best === null) return null;
20745
+ this.free_.delete(best);
20746
+ return this.byIndex.get(best) ?? null;
20747
+ }
20748
+ /** Idempotent; unknown indices are ignored. */
20749
+ free(index) {
20750
+ if (this.byIndex.has(index)) this.free_.add(index);
20751
+ }
20752
+ available() {
20753
+ return this.free_.size;
20754
+ }
20755
+ };
20181
20756
  }
20182
20757
  });
20183
20758
 
20184
20759
  // src/daemon/sandboxJob/clone.ts
20185
- import { mkdir as mkdir11, writeFile as writeFile11 } from "node:fs/promises";
20186
- import { dirname as dirname15 } from "node:path";
20760
+ import { mkdir as mkdir12, writeFile as writeFile11 } from "node:fs/promises";
20761
+ import { dirname as dirname16 } from "node:path";
20187
20762
  function asArchmeJob(job) {
20188
20763
  return job;
20189
20764
  }
@@ -20208,7 +20783,7 @@ async function runCloneStep(input, deps = {}) {
20208
20783
  const { env: gitEnv, cleanup } = await buildGitEnv(input.credentials);
20209
20784
  const restoreEnv = applyEnv(gitEnv);
20210
20785
  try {
20211
- await mkdir11(repoDir, { recursive: true });
20786
+ await mkdir12(repoDir, { recursive: true });
20212
20787
  const must = async (args, cwd) => {
20213
20788
  const r = await git4(args, { cwd, env: gitEnv });
20214
20789
  if (r.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${r.stderr.trim()}`);
@@ -20260,7 +20835,7 @@ async function runCloneStep(input, deps = {}) {
20260
20835
  after: diff.after,
20261
20836
  ...diff.provenance ? { provenance: diff.provenance } : {}
20262
20837
  };
20263
- await mkdir11(dirname15(diffPath), { recursive: true });
20838
+ await mkdir12(dirname16(diffPath), { recursive: true });
20264
20839
  await writeFile11(diffPath, JSON.stringify(persisted), { mode: 384 });
20265
20840
  const bytes = Buffer.byteLength(diff.patch, "utf8");
20266
20841
  log(`diff ${diff.rangeLabel}: ${diff.changedFiles.length} file(s), ${bytes} bytes${diff.materialized ? " (materialized)" : ""}`);
@@ -20316,7 +20891,7 @@ var init_clone = __esm({
20316
20891
  });
20317
20892
 
20318
20893
  // src/daemon/sandboxJob/agent.ts
20319
- import { mkdir as mkdir12, readFile as readFile14, writeFile as writeFile12 } from "node:fs/promises";
20894
+ import { mkdir as mkdir13, readFile as readFile14, writeFile as writeFile12 } from "node:fs/promises";
20320
20895
  import { join as join23 } from "node:path";
20321
20896
  function agentBoxEnv(gateway) {
20322
20897
  return {
@@ -20329,10 +20904,16 @@ function agentBoxEnv(gateway) {
20329
20904
  DISABLE_ERROR_REPORTING: "1"
20330
20905
  };
20331
20906
  }
20332
- function cloneBoxEnv() {
20907
+ function cloneBoxEnv(gateway) {
20908
+ const proxy = `http://${gateway}:${GATE_PORTS.github}`;
20333
20909
  return {
20334
20910
  HOME: BOX_HOME,
20335
- GIT_TERMINAL_PROMPT: "0"
20911
+ GIT_TERMINAL_PROMPT: "0",
20912
+ https_proxy: proxy,
20913
+ HTTPS_PROXY: proxy,
20914
+ GIT_CONFIG_COUNT: "1",
20915
+ GIT_CONFIG_KEY_0: "http.proxy",
20916
+ GIT_CONFIG_VALUE_0: proxy
20336
20917
  };
20337
20918
  }
20338
20919
  function boxMcpConfig(gateway) {
@@ -20343,7 +20924,7 @@ function boxMcpConfig(gateway) {
20343
20924
  };
20344
20925
  }
20345
20926
  async function writeBoxMcpConfig(gateway, dir = BOX_MCP_DIR) {
20346
- await mkdir12(dir, { recursive: true, mode: 448 });
20927
+ await mkdir13(dir, { recursive: true, mode: 448 });
20347
20928
  const path7 = join23(dir, "mcp.json");
20348
20929
  await writeFile12(path7, JSON.stringify(boxMcpConfig(gateway), null, 2), { mode: 384 });
20349
20930
  return path7;
@@ -20411,7 +20992,7 @@ async function runAgentStep(input, deps = {}) {
20411
20992
  );
20412
20993
  const promptBytes = Buffer.byteLength(prompt, "utf8");
20413
20994
  const mcpConfigPath = await writeBoxMcpConfig(input.gateway, deps.mcpDir);
20414
- for (const d of deps.homeDirs ?? [BOX_HOME, BOX_CLAUDE_CONFIG_DIR]) await mkdir12(d, { recursive: true, mode: 448 });
20995
+ for (const d of deps.homeDirs ?? [BOX_HOME, BOX_CLAUDE_CONFIG_DIR]) await mkdir13(d, { recursive: true, mode: 448 });
20415
20996
  log(`spawning claude (model ${input.model}, prompt ${promptBytes} bytes)`);
20416
20997
  const proc = spawn2({
20417
20998
  prompt,
@@ -20515,6 +21096,7 @@ function createSandboxParts(settings) {
20515
21096
  await gates.start();
20516
21097
  return {
20517
21098
  bindJob: (slot, b) => gates.bindJob(slot, b),
21099
+ setStep: (slot, step) => gates.setStep(slot, step),
20518
21100
  clearJob: (slot) => {
20519
21101
  gates.clearJob(slot);
20520
21102
  },
@@ -20565,7 +21147,7 @@ __export(run_exports, {
20565
21147
  });
20566
21148
  import { createClient } from "@supabase/supabase-js";
20567
21149
  import { randomUUID as randomUUID2 } from "node:crypto";
20568
- import { mkdtemp as mkdtemp4, mkdir as mkdir13, writeFile as writeFile13, rm as rm8, stat as stat8, chmod as chmod5 } from "node:fs/promises";
21150
+ import { mkdtemp as mkdtemp4, mkdir as mkdir14, writeFile as writeFile13, rm as rm8, stat as stat8, chmod as chmod6 } from "node:fs/promises";
20569
21151
  import { tmpdir as tmpdir4 } from "node:os";
20570
21152
  import { join as join24 } from "node:path";
20571
21153
  import { homedir as homedir8 } from "node:os";
@@ -20732,7 +21314,7 @@ async function buildGitEnv(credentials) {
20732
21314
  ].join("\n"),
20733
21315
  { mode: 448 }
20734
21316
  );
20735
- await chmod5(askpassPath, 448).catch(() => {
21317
+ await chmod6(askpassPath, 448).catch(() => {
20736
21318
  });
20737
21319
  const gitConfigEntries = [
20738
21320
  ["credential.helper", ""],
@@ -20793,12 +21375,12 @@ async function ensureCloneAndFetch(ctx, job, claimDeviceId) {
20793
21375
  const deviceId = claimDeviceId ?? ctx.deviceForOrg(job.org_id) ?? ctx.auth.getDeviceId();
20794
21376
  if (!deviceId) throw new Error("daemon is not signed in with a registered device");
20795
21377
  const credentials = await getRepoCredentials(ctx, job.id, deviceId);
20796
- await mkdir13(ctx.cloneBaseDir, { recursive: true });
21378
+ await mkdir14(ctx.cloneBaseDir, { recursive: true });
20797
21379
  const dir = cloneDirFor(ctx.cloneBaseDir, job.repo_owner, job.repo_name);
20798
21380
  const { env, cleanup: cleanupGitEnv } = await buildGitEnv(credentials);
20799
21381
  await cloneMutex.runExclusive(dir, async () => {
20800
21382
  if (!await exists(join24(dir, ".git"))) {
20801
- await mkdir13(join24(ctx.cloneBaseDir, job.repo_owner), { recursive: true });
21383
+ await mkdir14(join24(ctx.cloneBaseDir, job.repo_owner), { recursive: true });
20802
21384
  console.log(` cloning ${job.repo_owner}/${job.repo_name} \u2192 ${dir}`);
20803
21385
  await execa("git", ["clone", "--no-tags", credentials.https_url, dir], { env, reject: true });
20804
21386
  } else {
@@ -21441,7 +22023,7 @@ function sandboxDepsFor(ctx, job, deviceId, host) {
21441
22023
  settings: host.settings,
21442
22024
  parseResultLine: (stdout, nonce) => host.io.parseResultLine(stdout, nonce),
21443
22025
  agentBoxEnv: (gateway) => host.io.agentBoxEnv(gateway),
21444
- cloneBoxEnv: () => host.io.cloneBoxEnv(),
22026
+ cloneBoxEnv: (gateway) => host.io.cloneBoxEnv(gateway),
21445
22027
  prefetch: (j) => (prefetchOverride ?? prefetchJobContext)(ctx, j, deviceId),
21446
22028
  writeInputDir: (step, input) => writeInputDir(step, input),
21447
22029
  log: (msg) => console.log(`[${job.id}] ${msg}`),
@@ -21533,7 +22115,9 @@ async function spawnTriage(jobId, mcpConfigPath, cwd, opts) {
21533
22115
  model: opts.model,
21534
22116
  allowedTools: TRIAGE_ALLOWED_TOOLS,
21535
22117
  builtinTools: TRIAGE_BUILTIN_TOOLS,
21536
- jsonOutput: true
22118
+ jsonOutput: true,
22119
+ settingSources: "user"
22120
+ // the checkout is the customer's; see runJob's spawn
21537
22121
  });
21538
22122
  inFlight.set(jobId, proc);
21539
22123
  const result = await proc;
@@ -21585,8 +22169,13 @@ async function runJob(ctx, job, claimDeviceId) {
21585
22169
  const isLocalRepo = findLocalRepoForJob(await loadLocalRepos(), job) != null;
21586
22170
  const decision = shouldSandbox(sandboxHost.settings, job, { isLocalRepo });
21587
22171
  if (decision.reason === "local_repo") log("sandbox_skipped_local_repo: local-poll repo, running unsandboxed");
22172
+ const required = sandboxHost.settings.mode === "required";
22173
+ if (decision.reason === "local_repo" && required) log("sandbox: required mode keeps local-poll repos unboxed (\xA713)");
21588
22174
  const slot = decision.boxed ? sandboxHost.pool.lease() : null;
21589
- if (decision.boxed && !slot) log("sandbox_no_slot: no free slot, running unsandboxed");
22175
+ if (decision.boxed && !slot) {
22176
+ if (required) throw new Error("sandbox_no_slot: no free sandbox slot (ARCHME_SANDBOX=required); retrying later");
22177
+ log("sandbox_no_slot: no free slot, running unsandboxed");
22178
+ }
21590
22179
  if (slot) sandboxRun: {
21591
22180
  boxedJobs.add(job.id);
21592
22181
  log(`sandbox: running boxed on slot ${slot.index}`);
@@ -21597,7 +22186,7 @@ async function runJob(ctx, job, claimDeviceId) {
21597
22186
  timeouts: { claudeMs: CLAUDE_TIMEOUT_MS, largeDiffClaudeMs: CLAUDE_LARGE_DIFF_TIMEOUT_MS }
21598
22187
  });
21599
22188
  } catch (err4) {
21600
- if (err4 instanceof SandboxInfraError && !err4.agentStarted && !shuttingDown && !cancelled.has(job.id)) {
22189
+ if (err4 instanceof SandboxInfraError && !err4.agentStarted && !shuttingDown && !cancelled.has(job.id) && sandboxHost.settings.mode === "shadow") {
21601
22190
  log(`sandbox_infra_fallback: ${err4.message}, running unboxed`);
21602
22191
  boxedJobs.delete(job.id);
21603
22192
  break sandboxRun;
@@ -21890,7 +22479,10 @@ async function runJob(ctx, job, claimDeviceId) {
21890
22479
  // Single-object JSON so we can read the served model + usage/cost. stdout
21891
22480
  // is then one raw JSON blob — tee it SILENTLY (unreadable in `daemon -f`)
21892
22481
  // and log the parsed `.result` after exit instead.
21893
- jsonOutput: true
22482
+ jsonOutput: true,
22483
+ // The checkout is the customer's: never load its .claude/settings.json
22484
+ // (hooks there would run as shell commands on this host).
22485
+ settingSources: "user"
21894
22486
  });
21895
22487
  inFlight.set(job.id, proc);
21896
22488
  const getStdout = teeStream(proc.stdout, `[${job.id}] claude> `, { silent: true });
@@ -22057,6 +22649,7 @@ async function dispatch(sched) {
22057
22649
  }
22058
22650
  pauseUntilMs = null;
22059
22651
  }
22652
+ if (confinementGate && !confinementGate.allowsClaims()) return;
22060
22653
  sched.dispatching = true;
22061
22654
  try {
22062
22655
  let atCap = false;
@@ -22250,7 +22843,10 @@ async function runDaemon() {
22250
22843
  if (process.env.ARCHME_SANDBOX_SLOTS !== void 0) {
22251
22844
  console.log(" ARCHME_SANDBOX_SLOTS is no longer read; sandbox slots follow MAX_CONCURRENT_JOBS");
22252
22845
  }
22253
- console.log(` sandbox: ${sandboxSettings.mode}${sandboxSettings.mode === "shadow" ? ` (projects: ${sandboxSettings.projects.size ? [...sandboxSettings.projects].join(", ") : "all"}; ${sandboxSettings.slots} slot(s))` : ""}`);
22846
+ console.log(` sandbox: ${sandboxSettings.mode}${sandboxSettings.mode === "shadow" ? ` (projects: ${sandboxSettings.projects.size ? [...sandboxSettings.projects].join(", ") : "all"}; ${sandboxSettings.slots} slot(s))` : sandboxSettings.mode === "required" ? ` (every diff/PR job boxed; ${sandboxSettings.slots} slot(s))` : ""}`);
22847
+ if (sandboxSettings.mode === "required" && process.env.ARCHME_SANDBOX_PROJECTS?.trim()) {
22848
+ console.log(" ARCHME_SANDBOX_PROJECTS is ignored in required mode: every project's jobs run boxed");
22849
+ }
22254
22850
  const bearerCfg = {
22255
22851
  supabaseUrl: SUPABASE_URL,
22256
22852
  anonKey: SUPABASE_ANON_KEY,
@@ -22323,7 +22919,7 @@ async function runDaemon() {
22323
22919
  if (sandboxSettings.mode !== "off") {
22324
22920
  try {
22325
22921
  const { createSandboxParts: createSandboxParts2 } = await Promise.resolve().then(() => (init_parts(), parts_exports));
22326
- const { host } = await bootSandbox(sandboxSettings, createSandboxParts2(sandboxSettings), {
22922
+ const { host, monitor } = await bootSandbox(sandboxSettings, createSandboxParts2(sandboxSettings), {
22327
22923
  mcpBaseUrl: ARCHME_MCP_BASE_URL,
22328
22924
  // The MCP gatekeeper re-mints a job's token before it expires (§7.2),
22329
22925
  // with the device that claimed that job, as every job-scoped call must.
@@ -22337,6 +22933,8 @@ async function runDaemon() {
22337
22933
  log: (m) => console.log(m)
22338
22934
  });
22339
22935
  sandboxHost = host;
22936
+ monitor.start();
22937
+ confinementGate = monitor;
22340
22938
  } catch (err4) {
22341
22939
  const msg = err4 instanceof Error ? err4.message : String(err4);
22342
22940
  if (sandboxBootFailurePolicy(sandboxSettings.mode) === "run_unboxed") {
@@ -22465,6 +23063,7 @@ ${signal} received, shutting down`);
22465
23063
  for (const ctx of sessions) ctx.auth.clearRefreshTimer();
22466
23064
  clearInterval(sweepTimer);
22467
23065
  clearInterval(localPollTimer);
23066
+ confinementGate?.stop?.();
22468
23067
  for (const u2 of unsubscribers) u2();
22469
23068
  const SHUTDOWN_DEADLINE_MS = 3e4;
22470
23069
  const deadline = setTimeout(() => {
@@ -22507,7 +23106,7 @@ ${signal} received, shutting down`);
22507
23106
  process.on("SIGINT", () => shutdown("SIGINT"));
22508
23107
  process.on("SIGTERM", () => shutdown("SIGTERM"));
22509
23108
  }
22510
- var HEARTBEAT_INTERVAL_MS, CLAUDE_TIMEOUT_MS, CLAUDE_LARGE_DIFF_TIMEOUT_MS, SAFETY_SWEEP_MS, LOG_STDOUT_CAP_BYTES, LOG_STDERR_CAP_BYTES, FAILURE_DETAIL_MAX, AUTH_PAUSE_MS, ClaudeRunError, PRE_SPAWN_TOKEN_MIN_LIFETIME_MS, SUPABASE_URL, SUPABASE_ANON_KEY, ARCHME_DAEMON_CLIENT_ID, ARCHME_MCP_BASE_URL, CLONE_BASE_DIR, DAEMON_ID, LEASE_SECONDS, inFlight, cancelled, killReasons, activeCleanups, cloneMutex, shuttingDown, daemonVersion, pauseUntilMs, resumeTimer, currentSched, MAX_CONCURRENT_JOBS, ctxByJob, deviceByJob, sandboxHost, boxedJobs, leaseLost, prefetchOverride, DEFAULT_CLONE_BASE_DIR, JOB_TOKEN_PREFIX, INLINE_PATCH_BYTE_CAP, GIT_SHA_RE, CURATOR_PREAMBLE, CORE_PRINCIPLES, STORY_COVERAGE_GATE, NEW_STORY_GUIDANCE, STORY_DESCRIPTION_EDIT_RULE, PHASE_GUIDANCE, BASE_RULES, DIFF_RULES, finite, channelSeq, MultipleAccountsError, __testDispatch;
23109
+ var HEARTBEAT_INTERVAL_MS, CLAUDE_TIMEOUT_MS, CLAUDE_LARGE_DIFF_TIMEOUT_MS, SAFETY_SWEEP_MS, LOG_STDOUT_CAP_BYTES, LOG_STDERR_CAP_BYTES, FAILURE_DETAIL_MAX, AUTH_PAUSE_MS, ClaudeRunError, PRE_SPAWN_TOKEN_MIN_LIFETIME_MS, SUPABASE_URL, SUPABASE_ANON_KEY, ARCHME_DAEMON_CLIENT_ID, ARCHME_MCP_BASE_URL, CLONE_BASE_DIR, DAEMON_ID, LEASE_SECONDS, inFlight, cancelled, killReasons, activeCleanups, cloneMutex, shuttingDown, daemonVersion, pauseUntilMs, resumeTimer, currentSched, MAX_CONCURRENT_JOBS, ctxByJob, deviceByJob, sandboxHost, confinementGate, boxedJobs, leaseLost, prefetchOverride, DEFAULT_CLONE_BASE_DIR, JOB_TOKEN_PREFIX, INLINE_PATCH_BYTE_CAP, GIT_SHA_RE, CURATOR_PREAMBLE, CORE_PRINCIPLES, STORY_COVERAGE_GATE, NEW_STORY_GUIDANCE, STORY_DESCRIPTION_EDIT_RULE, PHASE_GUIDANCE, BASE_RULES, DIFF_RULES, finite, channelSeq, MultipleAccountsError, __testDispatch;
22511
23110
  var init_run = __esm({
22512
23111
  "src/daemon/run.ts"() {
22513
23112
  "use strict";
@@ -22587,6 +23186,7 @@ var init_run = __esm({
22587
23186
  ctxByJob = /* @__PURE__ */ new Map();
22588
23187
  deviceByJob = /* @__PURE__ */ new Map();
22589
23188
  sandboxHost = null;
23189
+ confinementGate = null;
22590
23190
  boxedJobs = /* @__PURE__ */ new Set();
22591
23191
  leaseLost = /* @__PURE__ */ new Set();
22592
23192
  prefetchOverride = null;
@@ -22767,6 +23367,9 @@ ${ANCHOR_VERIFICATION}`;
22767
23367
  sandboxHost = host;
22768
23368
  prefetchOverride = prefetch;
22769
23369
  },
23370
+ setConfinementGate(gate) {
23371
+ confinementGate = gate;
23372
+ },
22770
23373
  // Daemon-version hooks for tests / introspection. The real value is
22771
23374
  // populated at runDaemon boot from package.json; tests can stub.
22772
23375
  setDaemonVersion(v) {
@@ -22779,13 +23382,450 @@ ${ANCHOR_VERIFICATION}`;
22779
23382
  }
22780
23383
  });
22781
23384
 
23385
+ // src/daemon/sandbox/pfAnchor.ts
23386
+ function renderAnchor() {
23387
+ const slotLines = Array.from({ length: MAX_SLOTS }, (_, i2) => {
23388
+ const s = slotFor(i2);
23389
+ return `pass in quick inet proto tcp from ${s.subnet} to ${s.gateway} port ${PORTS} keep state`;
23390
+ });
23391
+ return [
23392
+ `# ${ANCHOR_NAME} \u2014 installed by \`archme daemon sandbox setup\` (L2 \xA78.2).`,
23393
+ "# DHCP inside each slot",
23394
+ "pass in quick inet proto udp from any port 68 to any port 67",
23395
+ "# each slot may reach only its own gateway, only on the gatekeeper ports",
23396
+ ...slotLines,
23397
+ "# everything else from a slot: host services on any host address, forwarding",
23398
+ `block drop in quick inet from ${SLOT_RANGE} to any`,
23399
+ "# the gatekeepers listen on the wildcard: nobody but a slot reaches them",
23400
+ `block drop in quick inet proto tcp from any to any port ${PORTS}`,
23401
+ ""
23402
+ ].join("\n");
23403
+ }
23404
+ function addAnchorToPfConf(text) {
23405
+ const lines = text.replace(/\n$/, "").split("\n");
23406
+ if (ANCHOR_LINES.every((l) => lines.some((x) => x.trim() === l))) return { text, changed: false };
23407
+ const kept = lines.filter((x) => !ANCHOR_LINES.includes(x.trim()));
23408
+ let at = -1;
23409
+ kept.forEach((l, i2) => {
23410
+ if (!l.trim().startsWith("#") && l.includes("com.apple")) at = i2;
23411
+ });
23412
+ const insertAt = at >= 0 ? at + 1 : kept.length;
23413
+ kept.splice(insertAt, 0, ...ANCHOR_LINES);
23414
+ return { text: `${kept.join("\n")}
23415
+ `, changed: true };
23416
+ }
23417
+ function renderPfLaunchDaemon() {
23418
+ return `<?xml version="1.0" encoding="UTF-8"?>
23419
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
23420
+ <plist version="1.0">
23421
+ <dict>
23422
+ <key>Label</key>
23423
+ <string>${PF_DAEMON_LABEL}</string>
23424
+ <key>ProgramArguments</key>
23425
+ <array>
23426
+ <string>/sbin/pfctl</string>
23427
+ <string>-E</string>
23428
+ <string>-f</string>
23429
+ <string>${PF_CONF_PATH}</string>
23430
+ </array>
23431
+ <key>RunAtLoad</key>
23432
+ <true/>
23433
+ </dict>
23434
+ </plist>
23435
+ `;
23436
+ }
23437
+ function parseDestination(dest) {
23438
+ const [addr, bitsStr] = dest.split("/");
23439
+ if (!addr || !/^\d+(\.\d+){0,3}$/.test(addr)) return null;
23440
+ const octets = addr.split(".");
23441
+ const bits = bitsStr !== void 0 ? Number(bitsStr) : octets.length * 8;
23442
+ while (octets.length < 4) octets.push("0");
23443
+ const base = parseIpv4(octets.join("."));
23444
+ if (base === null || !Number.isInteger(bits) || bits <= 0 || bits > 32) return null;
23445
+ return { base, bits };
23446
+ }
23447
+ function mask(bits) {
23448
+ return bits === 0 ? 0 : ~0 << 32 - bits >>> 0;
23449
+ }
23450
+ function overlaps(a2, b) {
23451
+ const m = mask(Math.min(a2.bits, b.bits));
23452
+ return (a2.base & m) >>> 0 === (b.base & m) >>> 0;
23453
+ }
23454
+ function within(inner, outer) {
23455
+ return inner.bits >= outer.bits && overlaps(inner, outer);
23456
+ }
23457
+ function routeCollisions(netstat, ourSlots2) {
23458
+ const range2 = parseDestination(SLOT_RANGE.replace(".0/21", "/21"));
23459
+ const ours = [...ourSlots2].map((i2) => parseDestination(slotFor(i2).subnet.replace(".0/24", "/24")));
23460
+ const out = [];
23461
+ for (const line of netstat.split("\n")) {
23462
+ const cols = line.trim().split(/\s+/);
23463
+ const net3 = parseDestination(cols[0] ?? "");
23464
+ if (!net3 || !overlaps(net3, range2)) continue;
23465
+ if (ours.some((o2) => within(net3, o2))) continue;
23466
+ out.push(`${cols[0]} via ${cols[3] ?? "?"}`);
23467
+ }
23468
+ return out;
23469
+ }
23470
+ function bakedUnitEnv(plist) {
23471
+ const m = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(plist);
23472
+ if (!m) return {};
23473
+ const out = {};
23474
+ const unescape2 = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
23475
+ for (const kv of m[1].matchAll(/<key>([^<]*)<\/key>\s*<string>([^<]*)<\/string>/g)) {
23476
+ out[unescape2(kv[1])] = unescape2(kv[2]);
23477
+ }
23478
+ return out;
23479
+ }
23480
+ var ANCHOR_NAME, ANCHOR_PATH, PF_CONF_PATH, PF_CONF_BACKUP_PATH, PF_DAEMON_LABEL, PF_DAEMON_PATH, SLOT_RANGE, PORTS, ANCHOR_LINES;
23481
+ var init_pfAnchor = __esm({
23482
+ "src/daemon/sandbox/pfAnchor.ts"() {
23483
+ "use strict";
23484
+ init_appleContainer();
23485
+ init_slotMap();
23486
+ init_types();
23487
+ ANCHOR_NAME = "com.archme.sandbox";
23488
+ ANCHOR_PATH = `/etc/pf.anchors/${ANCHOR_NAME}`;
23489
+ PF_CONF_PATH = "/etc/pf.conf";
23490
+ PF_CONF_BACKUP_PATH = "/etc/pf.conf.archme-backup";
23491
+ PF_DAEMON_LABEL = "com.archme.pf";
23492
+ PF_DAEMON_PATH = `/Library/LaunchDaemons/${PF_DAEMON_LABEL}.plist`;
23493
+ SLOT_RANGE = `192.168.${SLOT_RANGE_BASE_THIRD_OCTET}.0/21`;
23494
+ PORTS = `{ ${GATE_PORTS.anthropic} ${GATE_PORTS.mcp} ${GATE_PORTS.github} }`;
23495
+ ANCHOR_LINES = [
23496
+ `anchor "${ANCHOR_NAME}"`,
23497
+ `load anchor "${ANCHOR_NAME}" from "${ANCHOR_PATH}"`
23498
+ ];
23499
+ }
23500
+ });
23501
+
23502
+ // src/daemon/sandbox/setupCommand.ts
23503
+ var setupCommand_exports = {};
23504
+ __export(setupCommand_exports, {
23505
+ HARDENING_TEXT: () => HARDENING_TEXT,
23506
+ runSandboxSetup: () => runSandboxSetup,
23507
+ runSandboxStatus: () => runSandboxStatus
23508
+ });
23509
+ async function checkHost(io) {
23510
+ const ver = (await io.run("sw_vers", ["-productVersion"])).stdout.trim();
23511
+ const major = Number(ver.split(".")[0]);
23512
+ if (!(major >= 26)) return `macOS 26 or later is required (this is ${ver || "not macOS"})`;
23513
+ const arch = (await io.run("uname", ["-m"])).stdout.trim();
23514
+ if (arch !== "arm64") return `Apple Silicon is required (this is ${arch || "unknown"})`;
23515
+ if (!await runtimeUp(io)) return "Apple `container` is not running; start it with `container system start`";
23516
+ return null;
23517
+ }
23518
+ async function runtimeUp(io) {
23519
+ const s = await io.run("container", ["system", "status"]);
23520
+ return s.exitCode === 0 && /^status\s+running\b/m.test(s.stdout);
23521
+ }
23522
+ async function ourSlots(io) {
23523
+ const nets = await io.listNetworks();
23524
+ const ours = /* @__PURE__ */ new Set();
23525
+ for (let i2 = 0; i2 < MAX_SLOTS; i2++) {
23526
+ const s = slotFor(i2);
23527
+ if (nets.some((n2) => n2.name === s.network && n2.subnet === s.subnet)) ours.add(i2);
23528
+ }
23529
+ return ours;
23530
+ }
23531
+ async function currentMode(io) {
23532
+ const unit = await io.unitEnv();
23533
+ if (unit) return { mode: unit.ARCHME_SANDBOX?.trim() || "off", from: "installed service" };
23534
+ return { mode: io.env.ARCHME_SANDBOX?.trim() || "off", from: "this shell" };
23535
+ }
23536
+ async function runSandboxSetup(io, opts) {
23537
+ const fail2 = (msg) => {
23538
+ io.print(`\u2717 ${msg}`);
23539
+ return 1;
23540
+ };
23541
+ const host = await checkHost(io);
23542
+ if (host) return fail2(host);
23543
+ io.print("\u2713 macOS 26+, Apple Silicon, container running");
23544
+ if (!io.image) return fail2("no sandbox image: this install ships no sandbox-image.json, so set ARCHME_SANDBOX_IMAGE");
23545
+ if ((await io.run("container", ["image", "inspect", io.image])).exitCode !== 0) {
23546
+ io.print(`Pulling ${io.image}\u2026`);
23547
+ const pull = await io.run("container", ["image", "pull", io.image]);
23548
+ if (pull.exitCode !== 0) return fail2(`image pull failed: ${pull.stderr.trim()}`);
23549
+ }
23550
+ io.print(`\u2713 image ${io.image}`);
23551
+ const existing = await io.readToken().catch(() => null);
23552
+ const keep = existing && (opts.yes || await io.confirm(`A Claude token is stored (${existing.source}). Keep it?`));
23553
+ if (keep) {
23554
+ io.print(`\u2713 Claude token (${existing.source})`);
23555
+ } else {
23556
+ io.print("Run `claude setup-token` and paste the token it prints (input is hidden).");
23557
+ const stored = await io.storeToken(await io.promptSecret("Claude setup token: "));
23558
+ io.print(`\u2713 Claude token stored in ${stored.source === "keychain" ? "the macOS keychain" : "a 0600 file"}${stored.note ? ` (${stored.note})` : ""}`);
23559
+ }
23560
+ const netstat = await io.run("netstat", ["-rn", "-f", "inet"]);
23561
+ if (netstat.exitCode !== 0) return fail2(`netstat failed: ${netstat.stderr.trim()}`);
23562
+ const collisions = routeCollisions(netstat.stdout, await ourSlots(io));
23563
+ if (collisions.length) {
23564
+ return fail2(`the slot range 192.168.240.0/21 is already routed: ${collisions.join(", ")}. Free it (a VPN or another VM tool) and re-run.`);
23565
+ }
23566
+ await io.ensureSlots(MAX_SLOTS);
23567
+ io.print(`\u2713 ${MAX_SLOTS} slot networks`);
23568
+ const anchor = renderAnchor();
23569
+ const plist = renderPfLaunchDaemon();
23570
+ const pfConf = await io.readFile(PF_CONF_PATH);
23571
+ if (pfConf === null) return fail2(`cannot read ${PF_CONF_PATH}`);
23572
+ const conf = addAnchorToPfConf(pfConf);
23573
+ const anchorChanged = await io.readFile(ANCHOR_PATH) !== anchor;
23574
+ const plistChanged = await io.readFile(PF_DAEMON_PATH) !== plist;
23575
+ const pfUnchanged = !anchorChanged && !conf.changed && !plistChanged;
23576
+ const reload = [
23577
+ // Not loaded yet on a first run.
23578
+ { args: ["launchctl", "bootout", `system/${PF_DAEMON_LABEL}`], mayFail: true },
23579
+ // RunAtLoad: this applies the rules now.
23580
+ { args: ["launchctl", "bootstrap", "system", PF_DAEMON_PATH] }
23581
+ ];
23582
+ const runSudo = async (steps) => {
23583
+ for (const s of steps) {
23584
+ io.print(`$ sudo ${s.args.join(" ")}`);
23585
+ const r = await io.sudo(s.args);
23586
+ if (r.exitCode !== 0 && !s.mayFail) return `\`sudo ${s.args.join(" ")}\` failed (exit ${r.exitCode}); nothing after it ran`;
23587
+ }
23588
+ await io.sleep(2e3);
23589
+ return null;
23590
+ };
23591
+ if (pfUnchanged) {
23592
+ io.print("\u2713 pf: already installed");
23593
+ } else {
23594
+ io.print(`
23595
+ These rules go in ${ANCHOR_PATH}:
23596
+ `);
23597
+ for (const l of anchor.trimEnd().split("\n")) io.print(` ${l}`);
23598
+ if (conf.changed) {
23599
+ io.print(`
23600
+ ${PF_CONF_PATH} gains (a backup goes to ${PF_CONF_BACKUP_PATH}):
23601
+ `);
23602
+ const before = new Set(pfConf.split("\n"));
23603
+ for (const l of conf.text.split("\n")) if (l && !before.has(l)) io.print(` + ${l}`);
23604
+ }
23605
+ io.print(`
23606
+ ${PF_DAEMON_PATH} enables pf and loads ${PF_CONF_PATH} at every boot (${PF_DAEMON_LABEL}).
23607
+ `);
23608
+ if (!opts.yes && !await io.confirm("Install these with sudo?")) return fail2("pf not installed");
23609
+ const anchorTmp = await io.writeTemp("com.archme.sandbox", anchor);
23610
+ const confTmp = await io.writeTemp("pf.conf", conf.text);
23611
+ const plistTmp = await io.writeTemp(`${PF_DAEMON_LABEL}.plist`, plist);
23612
+ const steps = [
23613
+ { args: sudoInstall(anchorTmp, ANCHOR_PATH) },
23614
+ // Validate the new pf.conf (it loads the anchor just installed) before it replaces the old.
23615
+ { args: ["pfctl", "-nf", confTmp] },
23616
+ ...conf.changed ? [{ args: ["cp", PF_CONF_PATH, PF_CONF_BACKUP_PATH] }, { args: sudoInstall(confTmp, PF_CONF_PATH) }] : [],
23617
+ { args: sudoInstall(plistTmp, PF_DAEMON_PATH) },
23618
+ ...reload
23619
+ ];
23620
+ const err4 = await runSudo(steps);
23621
+ if (err4) return fail2(err4);
23622
+ io.print("\u2713 pf installed");
23623
+ }
23624
+ io.print("");
23625
+ let code = await runSandboxStatus(io);
23626
+ if (code !== 0 && pfUnchanged && (opts.yes || await io.confirm("Reload pf from the installed files with sudo?"))) {
23627
+ const err4 = await runSudo(reload);
23628
+ if (err4) return fail2(err4);
23629
+ io.print("");
23630
+ code = await runSandboxStatus(io);
23631
+ }
23632
+ const { mode, from } = await currentMode(io);
23633
+ io.print("");
23634
+ io.print(`ARCHME_SANDBOX is ${mode} (${from}); setup does not change it.`);
23635
+ io.print("To require confinement for every job: set ARCHME_SANDBOX=required in the daemon's environment, then `archme daemon restart`.");
23636
+ for (const l of HARDENING_TEXT) io.print(l);
23637
+ return code;
23638
+ }
23639
+ async function runSandboxStatus(io) {
23640
+ let ok = true;
23641
+ const line = (good, name, detail) => {
23642
+ if (!good) ok = false;
23643
+ io.print(`${good ? "\u2713" : "\u2717"} ${name.padEnd(9)} ${detail}`);
23644
+ };
23645
+ const up = await runtimeUp(io);
23646
+ line(up, "runtime", up ? "container running" : "container is not running");
23647
+ if (io.image) {
23648
+ const present = up && (await io.run("container", ["image", "inspect", io.image])).exitCode === 0;
23649
+ line(present, "image", `${io.image}${present ? "" : " (not present: run `archme daemon sandbox setup`)"}`);
23650
+ } else {
23651
+ line(false, "image", "none configured (no sandbox-image.json and no ARCHME_SANDBOX_IMAGE)");
23652
+ }
23653
+ try {
23654
+ const t = await io.readToken();
23655
+ line(true, "token", t.source === "keychain" ? "macOS keychain" : t.source === "file-override" ? "ARCHME_SANDBOX_CLAUDE_TOKEN_FILE" : "file");
23656
+ } catch (e) {
23657
+ line(false, "token", e.message);
23658
+ }
23659
+ const ours = up ? await ourSlots(io) : /* @__PURE__ */ new Set();
23660
+ const missing = Array.from({ length: MAX_SLOTS }, (_, i2) => i2).filter((i2) => !ours.has(i2)).map((i2) => slotFor(i2).network);
23661
+ line(missing.length === 0, "slots", missing.length ? `missing: ${missing.join(", ")}` : `${MAX_SLOTS} slot networks`);
23662
+ let probeSlot = null;
23663
+ if (up) {
23664
+ const busy = new Set((await io.listBoxes()).map((b) => b.labels[SLOT_LABEL]).filter((x) => !!x));
23665
+ for (let i2 = MAX_SLOTS - 1; i2 >= 0; i2--) {
23666
+ if (ours.has(i2) && !busy.has(String(i2))) {
23667
+ probeSlot = slotFor(i2);
23668
+ break;
23669
+ }
23670
+ }
23671
+ }
23672
+ if (!probeSlot) {
23673
+ line(false, "probe", "not run: no free slot network");
23674
+ } else {
23675
+ const r = await io.probe(probeSlot);
23676
+ line(r.ok, "probe", formatConfinementReport(r));
23677
+ for (const [id, o2] of Object.entries(r.outcomes)) io.print(` ${id.padEnd(15)} ${o2}`);
23678
+ }
23679
+ const { mode, from } = await currentMode(io);
23680
+ io.print(` mode ${mode} (${from})`);
23681
+ return ok ? 0 : 1;
23682
+ }
23683
+ var HARDENING_TEXT, sudoInstall;
23684
+ var init_setupCommand = __esm({
23685
+ "src/daemon/sandbox/setupCommand.ts"() {
23686
+ "use strict";
23687
+ init_appleContainer();
23688
+ init_confinement();
23689
+ init_pfAnchor();
23690
+ init_types();
23691
+ HARDENING_TEXT = [
23692
+ "Host hardening (L2 \xA78.5), by hand:",
23693
+ " - turn off SSH password authentication (PasswordAuthentication no in /etc/ssh/sshd_config)",
23694
+ " - keep AirPlay Receiver off (System Settings \u2192 General \u2192 AirDrop & Handoff)"
23695
+ ];
23696
+ sudoInstall = (from, to) => ["install", "-m", "644", "-o", "root", "-g", "wheel", from, to];
23697
+ }
23698
+ });
23699
+
23700
+ // src/daemon/sandbox/setupIo.ts
23701
+ var setupIo_exports = {};
23702
+ __export(setupIo_exports, {
23703
+ createSetupIo: () => createSetupIo
23704
+ });
23705
+ import { mkdtemp as mkdtemp5, readFile as readFile15, writeFile as writeFile14 } from "node:fs/promises";
23706
+ import { networkInterfaces as networkInterfaces2, tmpdir as tmpdir5 } from "node:os";
23707
+ import { join as join25 } from "node:path";
23708
+ import { createInterface as createInterface2 } from "node:readline";
23709
+ import { Writable as Writable4 } from "node:stream";
23710
+ function ask(question, hidden2 = false) {
23711
+ let muted = false;
23712
+ const output = new Writable4({
23713
+ write(chunk, _enc, cb) {
23714
+ if (!muted) process.stdout.write(chunk);
23715
+ cb();
23716
+ }
23717
+ });
23718
+ const rl = createInterface2({ input: process.stdin, output, terminal: true });
23719
+ return new Promise((resolve7) => {
23720
+ rl.question(question, (answer) => {
23721
+ rl.close();
23722
+ if (hidden2) process.stdout.write("\n");
23723
+ resolve7(answer);
23724
+ });
23725
+ muted = hidden2;
23726
+ });
23727
+ }
23728
+ function createSetupIo(env = process.env) {
23729
+ let image = null;
23730
+ try {
23731
+ image = resolveSandboxImage(env, () => readShippedImage());
23732
+ } catch {
23733
+ image = null;
23734
+ }
23735
+ const runtime = new AppleContainerRuntime({ image: image ?? void 0 });
23736
+ const token = sandboxTokenLocation(env);
23737
+ let tempDir = null;
23738
+ return {
23739
+ env,
23740
+ image,
23741
+ run: async (cmd, args) => {
23742
+ const r = await execa(cmd, args, { reject: false, stdin: "ignore" });
23743
+ return {
23744
+ exitCode: r.exitCode ?? -1,
23745
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
23746
+ stderr: typeof r.stderr === "string" ? r.stderr : r.message ?? ""
23747
+ };
23748
+ },
23749
+ // On the terminal: sudo may ask for a password.
23750
+ sudo: async (args) => {
23751
+ const r = await execa("sudo", args, { reject: false, stdio: "inherit" });
23752
+ return { exitCode: r.exitCode ?? -1, stdout: "", stderr: "" };
23753
+ },
23754
+ print: (line) => console.log(line),
23755
+ confirm: async (q) => !/^n/i.test((await ask(`${q} [Y/n] `)).trim()),
23756
+ promptSecret: (q) => ask(q, true),
23757
+ readFile: (p) => readFile15(p, "utf8").catch(() => null),
23758
+ writeTemp: async (name, content) => {
23759
+ tempDir ??= await mkdtemp5(join25(tmpdir5(), "archme-sandbox-setup-"));
23760
+ const p = join25(tempDir, name);
23761
+ await writeFile14(p, content, { mode: 420 });
23762
+ return p;
23763
+ },
23764
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
23765
+ listNetworks: () => runtime.listNetworks(),
23766
+ ensureSlots: async (n2) => {
23767
+ await runtime.ensureSlots(n2);
23768
+ },
23769
+ listBoxes: () => runtime.list(),
23770
+ readToken: () => readSandboxClaudeToken({ claudeTokenPath: token.path, claudeTokenOverride: token.override }),
23771
+ storeToken: (t) => storeSandboxClaudeToken(t, { path: token.path, override: token.override }),
23772
+ probe: async (slot) => {
23773
+ if (!image) return { ok: false, slot: slot.index, failures: ["no sandbox image"], outcomes: {}, ms: 0 };
23774
+ const gates = new GatekeeperSet({
23775
+ slots: Array.from({ length: MAX_SLOTS }, (_, i2) => slotFor(i2)),
23776
+ getClaudeToken: () => "status-probe-has-no-token",
23777
+ mcpUpstream: "http://127.0.0.1:9/functions/v1/mcp",
23778
+ remint: async () => {
23779
+ throw new Error("no job");
23780
+ },
23781
+ logger: () => {
23782
+ }
23783
+ });
23784
+ let own = false;
23785
+ try {
23786
+ await gates.start();
23787
+ own = true;
23788
+ } catch {
23789
+ console.log(" (the daemon's gatekeepers are answering)");
23790
+ }
23791
+ try {
23792
+ return await runConfinementProbe({ runtime, image }, slot, primaryLanIpv4(networkInterfaces2()));
23793
+ } finally {
23794
+ if (own) await gates.stop();
23795
+ }
23796
+ },
23797
+ unitEnv: async () => {
23798
+ if (process.platform !== "darwin") return null;
23799
+ const plist = await readFile15(resolveUnitPath("darwin", env), "utf8").catch(() => null);
23800
+ return plist === null ? null : bakedUnitEnv(plist);
23801
+ }
23802
+ };
23803
+ }
23804
+ var init_setupIo = __esm({
23805
+ "src/daemon/sandbox/setupIo.ts"() {
23806
+ "use strict";
23807
+ init_execa();
23808
+ init_paths();
23809
+ init_appleContainer();
23810
+ init_claudeToken();
23811
+ init_confinement();
23812
+ init_imageManifest();
23813
+ init_pfAnchor();
23814
+ init_setting();
23815
+ init_slots();
23816
+ init_types();
23817
+ }
23818
+ });
23819
+
22782
23820
  // src/daemon/service/dispatch.ts
22783
23821
  var dispatch_exports2 = {};
22784
23822
  __export(dispatch_exports2, {
22785
23823
  dispatchDaemon: () => dispatchDaemon
22786
23824
  });
22787
23825
  import { rm as rm9 } from "node:fs/promises";
22788
- async function dispatchDaemon(route2) {
23826
+ async function dispatchDaemon(route2, deps = {}) {
23827
+ const { platform: platform3, env } = deps;
23828
+ const controller = () => deps.controller ?? pickController(platform3, env);
22789
23829
  switch (route2.action) {
22790
23830
  case "help":
22791
23831
  printDaemonHelp();
@@ -22803,7 +23843,7 @@ async function dispatchDaemon(route2) {
22803
23843
  }
22804
23844
  case "start": {
22805
23845
  try {
22806
- const out = await runStartFlow({ noLogin: route2.noLogin });
23846
+ const out = await runStartFlow({ noLogin: route2.noLogin, controller: controller(), platform: platform3, env });
22807
23847
  console.log(
22808
23848
  `${out.result.actions.includes("installed") ? "Installing " : out.result.actions.includes("reinstalled") ? "Reinstalling " : ""}service\u2026`
22809
23849
  );
@@ -22816,58 +23856,82 @@ async function dispatchDaemon(route2) {
22816
23856
  console.error(String(err4.message));
22817
23857
  return 75;
22818
23858
  }
23859
+ if (err4 instanceof CredentialStoreUnreadableError) {
23860
+ console.error(err4.message);
23861
+ console.error(
23862
+ "The daemon service is NOT running (no service is installed, so nothing vouches for an earlier sign-in). Unlock the keychain and run `archme daemon start` again, or run it from the desktop session."
23863
+ );
23864
+ return 1;
23865
+ }
22819
23866
  console.error(String(err4));
22820
23867
  return 1;
22821
23868
  }
22822
23869
  }
22823
23870
  case "stop": {
22824
- const c3 = pickController();
23871
+ const c3 = controller();
22825
23872
  await orchestrateStop(c3);
22826
23873
  console.log("Stopped.");
22827
23874
  return 0;
22828
23875
  }
22829
23876
  case "restart": {
22830
23877
  try {
22831
- await ensureStartPrerequisites({ noLogin: route2.noLogin });
23878
+ await ensureStartPrerequisites({ noLogin: route2.noLogin, controller: controller(), platform: platform3, env });
22832
23879
  } catch (err4) {
22833
23880
  if (err4 instanceof NotSignedInError) {
22834
23881
  console.error(String(err4.message));
22835
23882
  console.error("(daemon left running unchanged)");
22836
23883
  return 75;
22837
23884
  }
22838
- console.error(String(err4));
23885
+ console.error(err4 instanceof CredentialStoreUnreadableError ? err4.message : String(err4));
22839
23886
  console.error("(daemon left running unchanged)");
22840
23887
  return 1;
22841
23888
  }
22842
- const c3 = pickController();
23889
+ const c3 = controller();
22843
23890
  await orchestrateStop(c3);
22844
- const out = await runStartFlow({ noLogin: route2.noLogin });
23891
+ const out = await runStartFlow({
23892
+ noLogin: route2.noLogin,
23893
+ controller: c3,
23894
+ platform: platform3,
23895
+ env,
23896
+ isSignedIn: async () => true
23897
+ });
22845
23898
  console.log(`Restarted (${out.result.actions.join(" \u2192 ")}).`);
22846
23899
  return 0;
22847
23900
  }
22848
23901
  case "status": {
22849
- const c3 = pickController();
22850
23902
  const status = await gatherStatus({
22851
- controller: c3,
23903
+ controller: controller(),
22852
23904
  loadCredentials: defaultLoadCredentials,
22853
23905
  loadSupabaseState: defaultLoadSupabaseState,
22854
- loadUptime: defaultLoadUptime
23906
+ loadUptime: defaultLoadUptime,
23907
+ platform: platform3,
23908
+ env
22855
23909
  });
22856
23910
  const out = renderStatus(status);
22857
- process.stdout.write(out.rendered);
23911
+ (deps.stdout ?? process.stdout).write(out.rendered);
22858
23912
  return out.exitCode;
22859
23913
  }
23914
+ case "sandbox-setup": {
23915
+ const { runSandboxSetup: runSandboxSetup2 } = await Promise.resolve().then(() => (init_setupCommand(), setupCommand_exports));
23916
+ const { createSetupIo: createSetupIo2 } = await Promise.resolve().then(() => (init_setupIo(), setupIo_exports));
23917
+ return await runSandboxSetup2(createSetupIo2(), { yes: route2.yes });
23918
+ }
23919
+ case "sandbox-status": {
23920
+ const { runSandboxStatus: runSandboxStatus2 } = await Promise.resolve().then(() => (init_setupCommand(), setupCommand_exports));
23921
+ const { createSetupIo: createSetupIo2 } = await Promise.resolve().then(() => (init_setupIo(), setupIo_exports));
23922
+ return await runSandboxStatus2(createSetupIo2());
23923
+ }
22860
23924
  case "logs":
22861
23925
  await tailLog({ lines: route2.lines, follow: route2.follow });
22862
23926
  return 0;
22863
23927
  case "enable": {
22864
- const c3 = pickController();
23928
+ const c3 = controller();
22865
23929
  await c3.enable();
22866
23930
  console.log("Enabled at login.");
22867
23931
  return 0;
22868
23932
  }
22869
23933
  case "disable": {
22870
- const c3 = pickController();
23934
+ const c3 = controller();
22871
23935
  await c3.disable();
22872
23936
  console.log("Disabled at login. Existing instance keeps running until `archme daemon stop`.");
22873
23937
  return 0;
@@ -22878,7 +23942,7 @@ async function dispatchDaemon(route2) {
22878
23942
  return 0;
22879
23943
  }
22880
23944
  case "uninstall": {
22881
- const c3 = pickController();
23945
+ const c3 = controller();
22882
23946
  await orchestrateStop(c3);
22883
23947
  await c3.uninstall();
22884
23948
  if (route2.purge) {
@@ -22900,7 +23964,13 @@ async function defaultLoadCredentials() {
22900
23964
  return null;
22901
23965
  }
22902
23966
  const { loadCredentials: loadCredentials2 } = await Promise.resolve().then(() => (init_credentials(), credentials_exports));
22903
- const { credentials } = await loadCredentials2(supabaseUrl);
23967
+ let credentials;
23968
+ try {
23969
+ ({ credentials } = await loadCredentials2(supabaseUrl));
23970
+ } catch (err4) {
23971
+ if (err4 instanceof CredentialStoreUnreadableError) return { unreadable: err4.message };
23972
+ throw err4;
23973
+ }
22904
23974
  if (!credentials) return null;
22905
23975
  const email = decodeJwtEmail(credentials.access_token) ?? credentials.user_id ?? null;
22906
23976
  return { email, deviceId: credentials.device_id ?? null };
@@ -22923,8 +23993,8 @@ async function defaultLoadSupabaseState(_deviceId) {
22923
23993
  async function defaultLoadUptime(pid) {
22924
23994
  if (process.platform === "linux") {
22925
23995
  try {
22926
- const { readFile: readFile19 } = await import("node:fs/promises");
22927
- const stat12 = await readFile19(`/proc/${pid}/stat`, "utf8");
23996
+ const { readFile: readFile20 } = await import("node:fs/promises");
23997
+ const stat12 = await readFile20(`/proc/${pid}/stat`, "utf8");
22928
23998
  const parts = stat12.split(") ").pop()?.split(" ") ?? [];
22929
23999
  const ticks = Number(parts[19]);
22930
24000
  if (!Number.isFinite(ticks)) return null;
@@ -22967,6 +24037,8 @@ Usage:
22967
24037
  archme daemon install [--no-login] Write unit file, do not load
22968
24038
  archme daemon uninstall [--purge] Unload + remove unit (logs kept; --purge removes them too)
22969
24039
  archme daemon run Foreground worker (what the service unit invokes)
24040
+ archme daemon sandbox setup [--yes] Once per machine: image, Claude token, slot networks, pf (sudo)
24041
+ archme daemon sandbox status Check the sandbox confinement holds (runs a probe box)
22970
24042
  `);
22971
24043
  }
22972
24044
  var init_dispatch2 = __esm({
@@ -22977,6 +24049,7 @@ var init_dispatch2 = __esm({
22977
24049
  init_logs();
22978
24050
  init_status();
22979
24051
  init_paths();
24052
+ init_credentials();
22980
24053
  }
22981
24054
  });
22982
24055
 
@@ -23035,6 +24108,7 @@ async function createAuthedClient(opts) {
23035
24108
  try {
23036
24109
  loaded = await loadStoredAndRefresh();
23037
24110
  } catch (err4) {
24111
+ if (err4 instanceof CredentialStoreError) throw err4;
23038
24112
  const msg = err4 instanceof Error ? err4.message : String(err4);
23039
24113
  throw new Error(`${msg}
23040
24114
  \u2192 run \`archme login\` first`);
@@ -23063,6 +24137,7 @@ var init_authedClient = __esm({
23063
24137
  "use strict";
23064
24138
  init_bearer();
23065
24139
  init_identity();
24140
+ init_credentials();
23066
24141
  SCAN_CLAUDE_TIMEOUT_MS = 45 * 60 * 1e3;
23067
24142
  SCAN_PRE_SPAWN_TOKEN_MIN_LIFETIME_MS = SCAN_CLAUDE_TIMEOUT_MS + 5 * 60 * 1e3;
23068
24143
  ORCHESTRATED_SCAN_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
@@ -23114,8 +24189,8 @@ __export(open_exports, {
23114
24189
  runOpen: () => runOpen,
23115
24190
  safeNextPath: () => safeNextPath
23116
24191
  });
23117
- import { readFile as readFile15 } from "node:fs/promises";
23118
- import { join as join25 } from "node:path";
24192
+ import { readFile as readFile16 } from "node:fs/promises";
24193
+ import { join as join26 } from "node:path";
23119
24194
  function safeNextPath(path7) {
23120
24195
  if (!path7 || !path7.startsWith("/") || path7.startsWith("//") || path7.startsWith("/\\")) return "/";
23121
24196
  return path7;
@@ -23202,7 +24277,7 @@ async function defaultOpenBrowser(url) {
23202
24277
  }
23203
24278
  async function defaultReadMcpJson(cwd) {
23204
24279
  try {
23205
- return await readFile15(join25(cwd, ".mcp.json"), "utf8");
24280
+ return await readFile16(join26(cwd, ".mcp.json"), "utf8");
23206
24281
  } catch {
23207
24282
  return null;
23208
24283
  }
@@ -23299,7 +24374,14 @@ function pickKey(keys, handle, active) {
23299
24374
  }
23300
24375
  async function runKey(cmd, deps) {
23301
24376
  const keys = await deps.listKeys();
23302
- const active = await deps.loadActive();
24377
+ let active;
24378
+ try {
24379
+ active = await deps.loadActive();
24380
+ } catch (err4) {
24381
+ if (!(err4 instanceof CredentialStoreError)) throw err4;
24382
+ deps.error(`${err4.message} Which key is signed in can't be shown.`);
24383
+ active = null;
24384
+ }
23303
24385
  if (cmd.action === "list") {
23304
24386
  if (keys.length === 0) {
23305
24387
  deps.log("No saved keys on this machine.");
@@ -23369,6 +24451,7 @@ Usage: archme key [list|show|forget] [--handle <handle>] [--yes]`);
23369
24451
  var init_key = __esm({
23370
24452
  "src/account/key.ts"() {
23371
24453
  "use strict";
24454
+ init_credentials();
23372
24455
  }
23373
24456
  });
23374
24457
 
@@ -23562,8 +24645,8 @@ var init_transcript_signals = __esm({
23562
24645
  });
23563
24646
 
23564
24647
  // src/scan/report.ts
23565
- import { mkdir as mkdir14, writeFile as writeFile14 } from "node:fs/promises";
23566
- import { dirname as dirname16 } from "node:path";
24648
+ import { mkdir as mkdir15, writeFile as writeFile15 } from "node:fs/promises";
24649
+ import { dirname as dirname17 } from "node:path";
23567
24650
  async function buildReport(input) {
23568
24651
  return {
23569
24652
  schema_version: REPORT_SCHEMA_VERSION,
@@ -23597,10 +24680,10 @@ async function buildReport(input) {
23597
24680
  };
23598
24681
  }
23599
24682
  async function writeReport(reportPath, orphansTxtPath, report, orphansFull) {
23600
- await mkdir14(dirname16(reportPath), { recursive: true });
23601
- await writeFile14(reportPath, JSON.stringify(report, null, 2) + "\n", "utf8");
23602
- await mkdir14(dirname16(orphansTxtPath), { recursive: true });
23603
- await writeFile14(
24683
+ await mkdir15(dirname17(reportPath), { recursive: true });
24684
+ await writeFile15(reportPath, JSON.stringify(report, null, 2) + "\n", "utf8");
24685
+ await mkdir15(dirname17(orphansTxtPath), { recursive: true });
24686
+ await writeFile15(
23604
24687
  orphansTxtPath,
23605
24688
  orphansFull.join("\n") + (orphansFull.length ? "\n" : ""),
23606
24689
  "utf8"
@@ -23644,8 +24727,8 @@ var init_claimDefaultProject = __esm({
23644
24727
  // src/scan/run-scan.ts
23645
24728
  import { randomUUID as randomUUID3 } from "node:crypto";
23646
24729
  import { createWriteStream as createWriteStream2 } from "node:fs";
23647
- import { mkdir as mkdir15, readFile as readFile16, writeFile as writeFile15 } from "node:fs/promises";
23648
- import { basename as basename2, dirname as dirname17, join as join26 } from "node:path";
24730
+ import { mkdir as mkdir16, readFile as readFile17, writeFile as writeFile16 } from "node:fs/promises";
24731
+ import { basename as basename2, dirname as dirname18, join as join27 } from "node:path";
23649
24732
  function modeInstruction(opts) {
23650
24733
  const line = modeLine(opts);
23651
24734
  if (opts.mode === "orchestrator" && opts.plan) {
@@ -23701,13 +24784,13 @@ Use scan-prompt variant \`${promptVariant}\` \u2014 pass \`variant: "${promptVar
23701
24784
  return base;
23702
24785
  }
23703
24786
  async function writeScanSession(repoPath, session) {
23704
- const path7 = join26(repoPath, SCAN_SESSION_FILE);
23705
- await mkdir15(dirname17(path7), { recursive: true });
23706
- await writeFile15(path7, JSON.stringify(session, null, 2) + "\n");
24787
+ const path7 = join27(repoPath, SCAN_SESSION_FILE);
24788
+ await mkdir16(dirname18(path7), { recursive: true });
24789
+ await writeFile16(path7, JSON.stringify(session, null, 2) + "\n");
23707
24790
  }
23708
24791
  async function readScanSession(repoPath) {
23709
- const path7 = join26(repoPath, SCAN_SESSION_FILE);
23710
- const raw = await readFile16(path7, "utf8").catch(() => null);
24792
+ const path7 = join27(repoPath, SCAN_SESSION_FILE);
24793
+ const raw = await readFile17(path7, "utf8").catch(() => null);
23711
24794
  if (raw === null) throw new Error(`no scan session to resume at ${path7} \u2014 launch it instead`);
23712
24795
  let parsed;
23713
24796
  try {
@@ -24098,9 +25181,9 @@ async function runScan(opts, deps = {}) {
24098
25181
  // Incompatible with an interactive TTY session.
24099
25182
  streamJson: !interactive
24100
25183
  });
24101
- const transcriptDir = join26(opts.repoPath, ".archme", "scan");
24102
- const transcriptPath = join26(transcriptDir, "transcript.log");
24103
- await mkdir15(transcriptDir, { recursive: true });
25184
+ const transcriptDir = join27(opts.repoPath, ".archme", "scan");
25185
+ const transcriptPath = join27(transcriptDir, "transcript.log");
25186
+ await mkdir16(transcriptDir, { recursive: true });
24104
25187
  const transcriptStream = createWriteStream2(transcriptPath, { flags: opts.resume ? "a" : "w" });
24105
25188
  let finalResultText = "";
24106
25189
  const echo = opts.echo ?? ((line) => process.stdout.write(line + "\n"));
@@ -24143,9 +25226,9 @@ async function runScan(opts, deps = {}) {
24143
25226
  await new Promise((resolve7, reject) => {
24144
25227
  transcriptStream.end((err4) => err4 ? reject(err4) : resolve7());
24145
25228
  });
24146
- const reportPath = join26(transcriptDir, "report.json");
24147
- const orphansTxtPath = join26(transcriptDir, "orphans.txt");
24148
- const transcript = await readFile16(transcriptPath, "utf8").catch(() => "");
25229
+ const reportPath = join27(transcriptDir, "report.json");
25230
+ const orphansTxtPath = join27(transcriptDir, "orphans.txt");
25231
+ const transcript = await readFile17(transcriptPath, "utf8").catch(() => "");
24149
25232
  signals2 = extractTranscriptSignals(transcript);
24150
25233
  const architecture = await fetchArchitectureDetail(supabase, resolved2.projectId, branchName2);
24151
25234
  const orphans = await classifyOrphans({
@@ -24299,7 +25382,7 @@ var init_scanOutcome = __esm({
24299
25382
 
24300
25383
  // src/init/repoSize.ts
24301
25384
  import { stat as stat9 } from "node:fs/promises";
24302
- import { extname, join as join27 } from "node:path";
25385
+ import { extname, join as join28 } from "node:path";
24303
25386
  async function measureRepo(repoPath) {
24304
25387
  const files = await gitListFiles(repoPath);
24305
25388
  if (!files) return { sourceFiles: 0, sourceBytes: 0, totalFiles: 0 };
@@ -24308,7 +25391,7 @@ async function measureRepo(repoPath) {
24308
25391
  for (const rel of files) {
24309
25392
  if (!SOURCE_EXTENSIONS.has(extname(rel).toLowerCase())) continue;
24310
25393
  try {
24311
- const s = await stat9(join27(repoPath, rel));
25394
+ const s = await stat9(join28(repoPath, rel));
24312
25395
  if (!s.isFile()) continue;
24313
25396
  sourceFiles++;
24314
25397
  sourceBytes += s.size;
@@ -24944,10 +26027,10 @@ __export(localWatch_exports, {
24944
26027
  watchLocalRepoRow: () => watchLocalRepoRow
24945
26028
  });
24946
26029
  import { homedir as homedir9 } from "node:os";
24947
- import { join as join28, resolve as resolve2 } from "node:path";
26030
+ import { join as join29, resolve as resolve2 } from "node:path";
24948
26031
  import { stat as stat10 } from "node:fs/promises";
24949
26032
  function mirrorPathFor(cloneBaseDir, owner, name) {
24950
- return join28(cloneBaseDir, owner, name);
26033
+ return join29(cloneBaseDir, owner, name);
24951
26034
  }
24952
26035
  function resolveInitTarget(input) {
24953
26036
  if (!input.originUrl) {
@@ -25119,7 +26202,7 @@ async function registerLocalWatch(args) {
25119
26202
  if (!watched.ok) return { ok: false, reason: watched.reason, exitCode: 3 };
25120
26203
  const { watchedRepoId } = watched;
25121
26204
  const mirrorPath = mirrorPathFor(cloneBaseDir, target.repoOwner, target.repoName);
25122
- const mirrorExists = await stat10(join28(mirrorPath, ".git")).then(() => true).catch(() => false);
26205
+ const mirrorExists = await stat10(join29(mirrorPath, ".git")).then(() => true).catch(() => false);
25123
26206
  if (mirrorExists) {
25124
26207
  log(` mirror: ${mirrorPath} (reusing)`);
25125
26208
  await setOriginUrl(mirrorPath, originUrl);
@@ -25266,7 +26349,7 @@ var init_localWatch = __esm({
25266
26349
  init_localWatchResolve();
25267
26350
  init_prompt();
25268
26351
  init_localPrSetup();
25269
- DEFAULT_CLONE_BASE_DIR2 = join28(homedir9(), ".archme-job-runner", "clones");
26352
+ DEFAULT_CLONE_BASE_DIR2 = join29(homedir9(), ".archme-job-runner", "clones");
25270
26353
  }
25271
26354
  });
25272
26355
 
@@ -25299,9 +26382,9 @@ var init_plan = __esm({
25299
26382
  });
25300
26383
 
25301
26384
  // src/init/run-init.ts
25302
- import { mkdtemp as mkdtemp5, rm as rm10, writeFile as writeFile16 } from "node:fs/promises";
25303
- import { tmpdir as tmpdir5 } from "node:os";
25304
- import { join as join29 } from "node:path";
26385
+ import { mkdtemp as mkdtemp6, rm as rm10, writeFile as writeFile17 } from "node:fs/promises";
26386
+ import { tmpdir as tmpdir6 } from "node:os";
26387
+ import { join as join30 } from "node:path";
25305
26388
  async function defaultStartDaemon() {
25306
26389
  const { runStartFlow: runStartFlow2 } = await Promise.resolve().then(() => (init_install(), install_exports));
25307
26390
  const out = await runStartFlow2({ noLogin: true });
@@ -25346,10 +26429,10 @@ async function defaultFetchInstallScript(url) {
25346
26429
  return await resp.text();
25347
26430
  }
25348
26431
  async function defaultRunInstallScript(script, cwd) {
25349
- const dir = await mkdtemp5(join29(tmpdir5(), "archme-init-"));
25350
- const scriptPath = join29(dir, "install-agent.sh");
26432
+ const dir = await mkdtemp6(join30(tmpdir6(), "archme-init-"));
26433
+ const scriptPath = join30(dir, "install-agent.sh");
25351
26434
  try {
25352
- await writeFile16(scriptPath, script, { mode: 448 });
26435
+ await writeFile17(scriptPath, script, { mode: 448 });
25353
26436
  await execa("sh", [scriptPath], { cwd, stdio: "inherit" });
25354
26437
  } finally {
25355
26438
  await rm10(dir, { recursive: true, force: true });
@@ -26895,11 +27978,11 @@ var init_dispatch3 = __esm({
26895
27978
  // src/init/prReviews.ts
26896
27979
  async function offerPrReviewsForInit(ctx, flag, deps = {}) {
26897
27980
  const readMode = deps.readMode ?? readPrReviewMode;
26898
- const ask = deps.ask ?? askForPrReviews;
27981
+ const ask2 = deps.ask ?? askForPrReviews;
26899
27982
  const setMode = deps.setMode ?? setPrReviewMode;
26900
27983
  const ghLogin = deps.ghLogin ?? ghLoginPrompt;
26901
27984
  const current = await readMode(ctx.supabase, ctx.watchedRepoId);
26902
- const want = await ask({ flag, interactive: ctx.interactive, current });
27985
+ const want = await ask2({ flag, interactive: ctx.interactive, current });
26903
27986
  if (want === null) {
26904
27987
  if (current === "auto") ctx.log(`\u2713 ${PR_REVIEWS_ALREADY_ON}`);
26905
27988
  return;
@@ -27325,7 +28408,7 @@ __export(run_uninstall_exports, {
27325
28408
  runUninstall: () => runUninstall
27326
28409
  });
27327
28410
  import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
27328
- import { join as join30 } from "node:path";
28411
+ import { join as join31 } from "node:path";
27329
28412
  function readIfExists(path7) {
27330
28413
  try {
27331
28414
  return existsSync3(path7) ? readFileSync6(path7, "utf8") : null;
@@ -27351,7 +28434,7 @@ async function runUninstall(opts) {
27351
28434
  const backend = opts.backend ?? await defaultBackend();
27352
28435
  const repoPath = await backend.gitRoot(opts.repoPath).catch(() => null) ?? opts.repoPath;
27353
28436
  if (repoPath !== opts.repoPath) log(`Using the repo root: ${repoPath}`);
27354
- const abs = (rel) => join30(repoPath, rel);
28437
+ const abs = (rel) => join31(repoPath, rel);
27355
28438
  const actions = [];
27356
28439
  const skipped = [];
27357
28440
  const mcpRaw = readIfExists(abs(".mcp.json"));
@@ -27931,7 +29014,7 @@ __export(run_exports3, {
27931
29014
  watchedElsewhere: () => watchedElsewhere
27932
29015
  });
27933
29016
  import { readdir as readdir3, stat as stat11 } from "node:fs/promises";
27934
- import { dirname as dirname18, join as join31, resolve as resolve6, sep as sep2 } from "node:path";
29017
+ import { dirname as dirname19, join as join32, resolve as resolve6, sep as sep2 } from "node:path";
27935
29018
  function isThisRunsWrite(metadata) {
27936
29019
  return metadata?.kind === "scan" || !!metadata?.repo_fold;
27937
29020
  }
@@ -27961,12 +29044,12 @@ async function listClones(dir) {
27961
29044
  const out = [];
27962
29045
  for (const e of entries) {
27963
29046
  if (!e.isDirectory() || e.name.startsWith(".")) continue;
27964
- if (await stat11(join31(dir, e.name, ".git")).then(() => true, () => false)) out.push(join31(dir, e.name));
29047
+ if (await stat11(join32(dir, e.name, ".git")).then(() => true, () => false)) out.push(join32(dir, e.name));
27965
29048
  }
27966
29049
  return out.sort();
27967
29050
  }
27968
29051
  function commonDir(paths) {
27969
- const split = paths.map((p) => dirname18(p).split(sep2));
29052
+ const split = paths.map((p) => dirname19(p).split(sep2));
27970
29053
  const common2 = [];
27971
29054
  for (let i2 = 0; split.every((s) => s[i2] !== void 0 && s[i2] === split[0][i2]); i2++) common2.push(split[0][i2]);
27972
29055
  return common2.join(sep2) || sep2;
@@ -28001,7 +29084,7 @@ function buildRunSheet(opts) {
28001
29084
  `- Clone: \`${r.root}\``,
28002
29085
  `- Launch: \`${launch(r)}\``,
28003
29086
  `- Resume: \`${launch(r)} --resume --message "<answer or correction>"\``,
28004
- `- Session file: \`${join31(r.root, SCAN_SESSION_FILE)}\``
29087
+ `- Session file: \`${join32(r.root, SCAN_SESSION_FILE)}\``
28005
29088
  ])
28006
29089
  ];
28007
29090
  if (opts.context.length > 0) {
@@ -28050,7 +29133,7 @@ async function runAddRepos(opts, deps = {}) {
28050
29133
  roots.push(found.root);
28051
29134
  continue;
28052
29135
  }
28053
- const dest = join31(cwd, arg.split("/")[1]);
29136
+ const dest = join32(cwd, arg.split("/")[1]);
28054
29137
  if (await (deps.isDirectory ?? isDirectory)(dest)) return fail2(78, `${dest} exists and is not a clone of ${arg} \u2014 move it, or pass the clone's path`);
28055
29138
  log(`cloning ${arg} into ${dest}`);
28056
29139
  const cloned = await (deps.cloneRepo ?? cloneFromGitHub)(arg, dest);
@@ -28422,8 +29505,8 @@ var init_dispatch7 = __esm({
28422
29505
  });
28423
29506
 
28424
29507
  // src/doctor/collect.ts
28425
- import { readFile as readFile17 } from "node:fs/promises";
28426
- import { join as join32 } from "node:path";
29508
+ import { readFile as readFile18 } from "node:fs/promises";
29509
+ import { join as join33 } from "node:path";
28427
29510
  function binaryPresent(name) {
28428
29511
  try {
28429
29512
  requireBinary(name);
@@ -28439,7 +29522,7 @@ async function git3(cwd, args) {
28439
29522
  async function readMcpPin(repoRoot) {
28440
29523
  const empty = { installed: false, pin: { project: null, branch: null } };
28441
29524
  try {
28442
- const raw = await readFile17(join32(repoRoot, ".mcp.json"), "utf8");
29525
+ const raw = await readFile18(join33(repoRoot, ".mcp.json"), "utf8");
28443
29526
  const parsed = JSON.parse(raw);
28444
29527
  const servers = parsed.mcpServers ?? {};
28445
29528
  const entry = Object.entries(servers).find(([name]) => /archme/i.test(name));
@@ -28813,9 +29896,9 @@ __export(dispatch_exports8, {
28813
29896
  parseDoctorArgs: () => parseDoctorArgs,
28814
29897
  printDoctorHelp: () => printDoctorHelp
28815
29898
  });
28816
- import { mkdtemp as mkdtemp6, rm as rm11, writeFile as writeFile17 } from "node:fs/promises";
28817
- import { tmpdir as tmpdir6 } from "node:os";
28818
- import { join as join33 } from "node:path";
29899
+ import { mkdtemp as mkdtemp7, rm as rm11, writeFile as writeFile18 } from "node:fs/promises";
29900
+ import { tmpdir as tmpdir7 } from "node:os";
29901
+ import { join as join34 } from "node:path";
28819
29902
  function parseDoctorArgs(argv) {
28820
29903
  const args = { plain: false, help: false };
28821
29904
  for (const a2 of argv) {
@@ -28876,9 +29959,9 @@ async function dispatchDoctor(argv) {
28876
29959
  );
28877
29960
  return EX_OK6;
28878
29961
  }
28879
- const dir = await mkdtemp6(join33(tmpdir6(), "archme-doctor-"));
28880
- const mcpConfigPath = join33(dir, "mcp.json");
28881
- await writeFile17(mcpConfigPath, JSON.stringify({ mcpServers: {} }), { mode: 384 });
29962
+ const dir = await mkdtemp7(join34(tmpdir7(), "archme-doctor-"));
29963
+ const mcpConfigPath = join34(dir, "mcp.json");
29964
+ await writeFile18(mcpConfigPath, JSON.stringify({ mcpServers: {} }), { mode: 384 });
28882
29965
  try {
28883
29966
  const result = await spawnClaude({
28884
29967
  prompt: buildDoctorPrompt(facts, issues),
@@ -29045,7 +30128,7 @@ var init_conciergeToken = __esm({
29045
30128
  });
29046
30129
 
29047
30130
  // src/daemon/sandboxJob/input.ts
29048
- import { readFile as readFile18 } from "node:fs/promises";
30131
+ import { readFile as readFile19 } from "node:fs/promises";
29049
30132
  function isObj2(v) {
29050
30133
  return typeof v === "object" && v !== null && !Array.isArray(v);
29051
30134
  }
@@ -29154,6 +30237,31 @@ function parseRepoScope(raw) {
29154
30237
  }
29155
30238
  fail("repoScope must be { kind: all | container | excluding }");
29156
30239
  }
30240
+ function parseProbe(data) {
30241
+ const timeoutMs = data.timeoutMs;
30242
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) fail("timeoutMs must be a positive integer");
30243
+ if (!Array.isArray(data.targets) || data.targets.length > 64) fail("targets must be an array of at most 64");
30244
+ const targets = data.targets.map((raw, i2) => {
30245
+ const where = `targets[${i2}]`;
30246
+ if (!isObj2(raw)) fail(`${where} must be an object`);
30247
+ const id = str3(raw, "id", where);
30248
+ if (!PROBE_ID_RE.test(id)) fail(`${where}.id must be [a-z0-9_]`);
30249
+ const kind = raw.kind;
30250
+ if (!PROBE_KINDS.has(kind)) fail(`${where}.kind is not a probe kind`);
30251
+ const host = str3(raw, "host", where);
30252
+ if (kind === "ipv6_socket" ? host !== "::" : !IPV4_RE2.test(host)) fail(`${where}.host must be an IPv4 address`);
30253
+ const port = raw.port;
30254
+ if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`${where}.port is not a port`);
30255
+ const target = { id, kind, host, port };
30256
+ if (raw.authority !== void 0) {
30257
+ const a2 = raw.authority;
30258
+ if (kind !== "connect" || typeof a2 !== "string" || !/^[A-Za-z0-9.\-[\]:]{1,300}$/.test(a2)) fail(`${where}.authority is not host:port`);
30259
+ target.authority = a2;
30260
+ }
30261
+ return target;
30262
+ });
30263
+ return { kind: "probe", targets, timeoutMs };
30264
+ }
29157
30265
  function parseInput(raw) {
29158
30266
  let data;
29159
30267
  try {
@@ -29162,6 +30270,7 @@ function parseInput(raw) {
29162
30270
  fail("not valid JSON");
29163
30271
  }
29164
30272
  if (!isObj2(data)) fail("must be a JSON object");
30273
+ if (data.kind === "probe") return parseProbe(data);
29165
30274
  const job = parseJob(data.job);
29166
30275
  if (data.kind === "clone") {
29167
30276
  const input = {
@@ -29192,9 +30301,9 @@ function parseInput(raw) {
29192
30301
  };
29193
30302
  return input;
29194
30303
  }
29195
- fail("kind must be clone or agent");
30304
+ fail("kind must be clone, agent or probe");
29196
30305
  }
29197
- async function readInput(path7 = INPUT_PATH, read = (p) => readFile18(p, "utf8")) {
30306
+ async function readInput(path7 = INPUT_PATH, read = (p) => readFile19(p, "utf8")) {
29198
30307
  let raw;
29199
30308
  try {
29200
30309
  raw = await read(path7);
@@ -29203,7 +30312,7 @@ async function readInput(path7 = INPUT_PATH, read = (p) => readFile18(p, "utf8")
29203
30312
  }
29204
30313
  return parseInput(raw);
29205
30314
  }
29206
- var InputSchemaError, SHA_RE3, REPO_PART_RE, IPV4_RE2, SOURCE_MODES;
30315
+ var InputSchemaError, SHA_RE3, REPO_PART_RE, IPV4_RE2, SOURCE_MODES, PROBE_KINDS, PROBE_ID_RE;
29207
30316
  var init_input = __esm({
29208
30317
  "src/daemon/sandboxJob/input.ts"() {
29209
30318
  "use strict";
@@ -29218,6 +30327,135 @@ var init_input = __esm({
29218
30327
  REPO_PART_RE = /^[A-Za-z0-9_.-]+$/;
29219
30328
  IPV4_RE2 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
29220
30329
  SOURCE_MODES = /* @__PURE__ */ new Set(["diff", "reconcile", "pr_update"]);
30330
+ PROBE_KINDS = /* @__PURE__ */ new Set(["tcp", "udp_dns", "http", "connect", "ipv6_socket"]);
30331
+ PROBE_ID_RE = /^[a-z0-9_]{1,64}$/;
30332
+ }
30333
+ });
30334
+
30335
+ // src/daemon/sandboxJob/probe.ts
30336
+ import dgram from "node:dgram";
30337
+ import http2 from "node:http";
30338
+ import net2 from "node:net";
30339
+ function tcp(t, timeoutMs) {
30340
+ return new Promise((resolve7) => {
30341
+ const s = net2.connect({ host: t.host, port: t.port });
30342
+ const finish = (outcome) => {
30343
+ s.destroy();
30344
+ resolve7(outcome);
30345
+ };
30346
+ s.setTimeout(timeoutMs, () => finish("timeout"));
30347
+ s.once("connect", () => finish("connected"));
30348
+ s.once("error", (e) => {
30349
+ finish(e.code === "ECONNREFUSED" ? "refused" : e.code === "ETIMEDOUT" ? "timeout" : "unreachable");
30350
+ });
30351
+ });
30352
+ }
30353
+ function udpDns(t, timeoutMs) {
30354
+ return new Promise((resolve7) => {
30355
+ const s = dgram.createSocket("udp4");
30356
+ let done = false;
30357
+ const finish = (outcome) => {
30358
+ if (done) return;
30359
+ done = true;
30360
+ clearTimeout(timer);
30361
+ s.close();
30362
+ resolve7(outcome);
30363
+ };
30364
+ const timer = setTimeout(() => finish("silent"), timeoutMs);
30365
+ s.on("message", () => finish("answered"));
30366
+ s.on("error", () => finish("silent"));
30367
+ s.send(DNS_QUERY, t.port, t.host, (err4) => {
30368
+ if (err4) finish("silent");
30369
+ });
30370
+ });
30371
+ }
30372
+ function httpStatus(t, timeoutMs, method) {
30373
+ return new Promise((resolve7) => {
30374
+ let done = false;
30375
+ const finish = (outcome) => {
30376
+ if (done) return;
30377
+ done = true;
30378
+ req.destroy();
30379
+ resolve7(outcome);
30380
+ };
30381
+ const req = http2.request({
30382
+ host: t.host,
30383
+ port: t.port,
30384
+ method,
30385
+ agent: false,
30386
+ timeout: timeoutMs,
30387
+ path: method === "CONNECT" ? t.authority ?? "github.com:443" : "/"
30388
+ });
30389
+ req.on("response", (res) => {
30390
+ res.resume();
30391
+ finish(String(res.statusCode ?? "none"));
30392
+ });
30393
+ req.on("connect", (res, socket) => {
30394
+ socket.destroy();
30395
+ finish(String(res.statusCode ?? "none"));
30396
+ });
30397
+ req.on("timeout", () => finish("none"));
30398
+ req.on("error", () => finish("none"));
30399
+ req.end();
30400
+ });
30401
+ }
30402
+ function ipv6Socket() {
30403
+ return new Promise((resolve7) => {
30404
+ const s = dgram.createSocket("udp6");
30405
+ s.once("error", () => {
30406
+ s.close();
30407
+ resolve7("unsupported");
30408
+ });
30409
+ s.bind(0, "::", () => {
30410
+ s.close();
30411
+ resolve7("created");
30412
+ });
30413
+ });
30414
+ }
30415
+ async function runProbe(input) {
30416
+ const results = await Promise.all(input.targets.map(async (t) => {
30417
+ switch (t.kind) {
30418
+ case "tcp":
30419
+ return [t.id, await tcp(t, input.timeoutMs)];
30420
+ case "udp_dns":
30421
+ return [t.id, await udpDns(t, input.timeoutMs)];
30422
+ case "http":
30423
+ return [t.id, await httpStatus(t, input.timeoutMs, "GET")];
30424
+ case "connect":
30425
+ return [t.id, await httpStatus(t, input.timeoutMs, "CONNECT")];
30426
+ case "ipv6_socket":
30427
+ return [t.id, await ipv6Socket()];
30428
+ }
30429
+ }));
30430
+ return Object.fromEntries(results);
30431
+ }
30432
+ var DNS_QUERY;
30433
+ var init_probe = __esm({
30434
+ "src/daemon/sandboxJob/probe.ts"() {
30435
+ "use strict";
30436
+ DNS_QUERY = Buffer.from([
30437
+ 18,
30438
+ 52,
30439
+ 1,
30440
+ 0,
30441
+ 0,
30442
+ 1,
30443
+ 0,
30444
+ 0,
30445
+ 0,
30446
+ 0,
30447
+ 0,
30448
+ 0,
30449
+ 7,
30450
+ ...Buffer.from("example"),
30451
+ 3,
30452
+ ...Buffer.from("com"),
30453
+ 0,
30454
+ 0,
30455
+ 1,
30456
+ 0,
30457
+ 1
30458
+ ]);
29221
30459
  }
29222
30460
  });
29223
30461
 
@@ -29227,7 +30465,7 @@ __export(main_exports, {
29227
30465
  readNonceLine: () => readNonceLine,
29228
30466
  runSandboxJob: () => runSandboxJob
29229
30467
  });
29230
- import { mkdir as mkdir16 } from "node:fs/promises";
30468
+ import { mkdir as mkdir17 } from "node:fs/promises";
29231
30469
  function readNonceLine(stream = process.stdin) {
29232
30470
  return new Promise((resolve7) => {
29233
30471
  let buf = "";
@@ -29252,6 +30490,10 @@ function readNonceLine(stream = process.stdin) {
29252
30490
  });
29253
30491
  }
29254
30492
  function emptyResult(step, error) {
30493
+ if (step === "probe") {
30494
+ const r2 = { kind: "probe", outcomes: {}, memoryPeakBytes: null, durations_ms: {}, error };
30495
+ return r2;
30496
+ }
29255
30497
  if (step === "clone") {
29256
30498
  const r2 = { kind: "clone", outcome: "diff", memoryPeakBytes: null, durations_ms: {}, error };
29257
30499
  return r2;
@@ -29299,8 +30541,8 @@ async function runSandboxJob(argv, deps = {}) {
29299
30541
  return code;
29300
30542
  };
29301
30543
  const step = argv[0];
29302
- if (step !== "clone" && step !== "agent") {
29303
- const msg = `unknown step ${JSON.stringify(step ?? "")}; expected clone or agent`;
30544
+ if (step !== "clone" && step !== "agent" && step !== "probe") {
30545
+ const msg = `unknown step ${JSON.stringify(step ?? "")}; expected clone, agent or probe`;
29304
30546
  log(msg);
29305
30547
  return emit2(emptyResult("clone", msg), EXIT_INPUT_SCHEMA);
29306
30548
  }
@@ -29314,9 +30556,19 @@ async function runSandboxJob(argv, deps = {}) {
29314
30556
  return emit2(emptyResult(step, msg), err4 instanceof InputSchemaError ? EXIT_INPUT_SCHEMA : 1);
29315
30557
  }
29316
30558
  mark("input");
30559
+ if (input.kind === "probe") {
30560
+ try {
30561
+ const outcomes = await (deps.probe ?? runProbe)(input);
30562
+ return emit2({ kind: "probe", outcomes, memoryPeakBytes: null, durations_ms: {} }, 0);
30563
+ } catch (err4) {
30564
+ const msg = err4 instanceof Error ? err4.message : String(err4);
30565
+ log(`failed: ${msg}`);
30566
+ return emit2(emptyResult("probe", msg), 1);
30567
+ }
30568
+ }
29317
30569
  log(`${step} job=${input.job.id} repo=${input.job.repo_owner}/${input.job.repo_name} after=${input.job.after_sha.slice(0, 8)}`);
29318
30570
  try {
29319
- await mkdir16(deps.homeDir ?? process.env.HOME ?? BOX_HOME, { recursive: true, mode: 448 });
30571
+ await mkdir17(deps.homeDir ?? process.env.HOME ?? BOX_HOME, { recursive: true, mode: 448 });
29320
30572
  if (input.kind === "clone") {
29321
30573
  const { result: result2, exitCode: exitCode2 } = await runCloneStep(input, { log, mark, ...deps.clone });
29322
30574
  return emit2({ ...result2, memoryPeakBytes: null, durations_ms: {} }, exitCode2);
@@ -29338,6 +30590,7 @@ var init_main2 = __esm({
29338
30590
  init_result2();
29339
30591
  init_clone();
29340
30592
  init_agent();
30593
+ init_probe();
29341
30594
  NONCE_LINE_MAX_BYTES = 256;
29342
30595
  }
29343
30596
  });
@@ -29416,7 +30669,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
29416
30669
  "doctor",
29417
30670
  // Hidden: operator-only (platform admins), deliberately absent from printHelp.
29418
30671
  "concierge-token",
29419
- // Hidden: the per-job sandbox image's entrypoint (`sandbox-job clone|agent`).
30672
+ // Hidden: the per-job sandbox image's entrypoint (`sandbox-job clone|agent|probe`).
29420
30673
  "sandbox-job"
29421
30674
  ]);
29422
30675
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["--version", "-v"]);
@@ -29439,7 +30692,8 @@ var DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set([
29439
30692
  "disable",
29440
30693
  "install",
29441
30694
  "uninstall",
29442
- "run"
30695
+ "run",
30696
+ "sandbox"
29443
30697
  ]);
29444
30698
  function routeDaemon(args) {
29445
30699
  const sub = args[0];
@@ -29473,6 +30727,11 @@ function routeDaemon(args) {
29473
30727
  return { action: "uninstall", purge: hasFlag("--purge") };
29474
30728
  case "run":
29475
30729
  return { action: "run" };
30730
+ case "sandbox": {
30731
+ if (rest[0] === "setup") return { action: "sandbox-setup", yes: rest.includes("--yes") || rest.includes("-y") };
30732
+ if (rest[0] === "status") return { action: "sandbox-status" };
30733
+ return { action: "unknown", arg: ["sandbox", ...rest.slice(0, 1)].join(" ") };
30734
+ }
29476
30735
  }
29477
30736
  return { action: "unknown", arg: sub };
29478
30737
  }
@@ -29613,4 +30872,10 @@ Run 'archme init --help', 'archme daemon --help', 'archme analyze --help', or
29613
30872
  }
29614
30873
 
29615
30874
  // src/bin.ts
29616
- main(process.argv.slice(2)).then((code) => process.exit(code));
30875
+ main(process.argv.slice(2)).then(
30876
+ (code) => process.exit(code),
30877
+ (err4) => {
30878
+ console.error(err4 instanceof Error ? err4.message : String(err4));
30879
+ process.exit(1);
30880
+ }
30881
+ );