@archme/cli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -16391,15 +16457,23 @@ var init_sessionAuth = __esm({
16391
16457
  this.userId = resolveUserId(userInfo2, ctx.fallbackUserId);
16392
16458
  this.lastUserInfo = userInfo2;
16393
16459
  }
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
- });
16460
+ let saved = null;
16461
+ try {
16462
+ saved = await saveCredentials({
16463
+ kind: this.kind,
16464
+ refresh_token: nextRefreshToken,
16465
+ access_token: this.accessToken,
16466
+ org_id: ctx.orgId,
16467
+ device_id: ctx.deviceId,
16468
+ user_id: this.userId ?? void 0,
16469
+ supabase_url: this.cfg.supabaseUrl
16470
+ });
16471
+ } catch (err4) {
16472
+ if (!(err4 instanceof CredentialStoreError)) throw err4;
16473
+ console.error(
16474
+ `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()}`
16475
+ );
16476
+ }
16403
16477
  return { userInfo: userInfo2, saved, nextRefreshToken };
16404
16478
  }
16405
16479
  // The timer closure reads the LIVE this.refreshToken/deviceId/orgId at fire
@@ -16492,11 +16566,11 @@ var init_sessionAuth = __esm({
16492
16566
  const refreshed = await this.refreshOrRecover(refreshToken, this.userId);
16493
16567
  if (!refreshed.ok) {
16494
16568
  if (refreshed.permanent) {
16495
- await clearCredential(this.cfg.supabaseUrl, deviceId);
16569
+ const notCleared = await this.clearStoredCredential(deviceId);
16496
16570
  this.clearTokensOnly();
16497
16571
  throw new LoadStoredAndRefreshError(
16498
16572
  "invalid_grant",
16499
- `${errorPrefix} failed permanently: ${refreshed.error}. ${this.reloginHint()}`
16573
+ `${errorPrefix} failed permanently: ${refreshed.error}. ${this.reloginHint()}${notCleared}`
16500
16574
  );
16501
16575
  }
16502
16576
  throw new LoadStoredAndRefreshError("transient", `${errorPrefix} failed: ${refreshed.error}`);
@@ -16507,7 +16581,7 @@ var init_sessionAuth = __esm({
16507
16581
  orgId,
16508
16582
  fallbackUserId: this.userId ?? void 0
16509
16583
  });
16510
- console.log(`auth: persisted refreshed token to ${describeCredentialSource(saved)}`);
16584
+ if (saved) console.log(`auth: persisted refreshed token to ${describeCredentialSource(saved)}`);
16511
16585
  })();
16512
16586
  this.refreshInFlight = p;
16513
16587
  return p.finally(() => {
@@ -16520,6 +16594,18 @@ var init_sessionAuth = __esm({
16520
16594
  if (refreshed.ok || !refreshed.permanent) return refreshed;
16521
16595
  return this.signInWithSavedKey(userId, refreshed.error);
16522
16596
  }
16597
+ // Remove a dead login from the store. The token is revoked whether or not
16598
+ // this works, so a store that refuses the delete must not turn a permanent
16599
+ // failure into a transient one (which would retry a dead token forever).
16600
+ async clearStoredCredential(deviceId) {
16601
+ try {
16602
+ await clearCredential(this.cfg.supabaseUrl, deviceId);
16603
+ return "";
16604
+ } catch (err4) {
16605
+ if (!(err4 instanceof CredentialStoreError)) throw err4;
16606
+ return ` (${err4.message})`;
16607
+ }
16608
+ }
16523
16609
  reloginHint() {
16524
16610
  return this.kind === "key" ? "Run `archme login --key` to sign in with your key again." : "Run `archme login` to pair again.";
16525
16611
  }
@@ -16564,10 +16650,10 @@ var init_sessionAuth = __esm({
16564
16650
  const refreshed = await this.refreshOrRecover(stored.refresh_token, stored.user_id ?? null);
16565
16651
  if (!refreshed.ok) {
16566
16652
  if (refreshed.permanent) {
16567
- await clearCredential(this.cfg.supabaseUrl, stored.device_id);
16653
+ const notCleared = await this.clearStoredCredential(stored.device_id);
16568
16654
  throw new LoadStoredAndRefreshError(
16569
16655
  "invalid_grant",
16570
- `stored archme credentials are invalid or revoked: ${refreshed.error}. ${this.reloginHint()}`
16656
+ `stored archme credentials are invalid or revoked: ${refreshed.error}. ${this.reloginHint()}${notCleared}`
16571
16657
  );
16572
16658
  }
16573
16659
  throw new LoadStoredAndRefreshError("transient", `token refresh failed (transient): ${refreshed.error}`);
@@ -16941,12 +17027,12 @@ function defaultReadFile(path7) {
16941
17027
  return readFileSync4(path7, "utf8");
16942
17028
  }
16943
17029
  function findPackageRoot2(startDir, opts = {}) {
16944
- const readFile19 = opts.readFile ?? defaultReadFile;
17030
+ const readFile20 = opts.readFile ?? defaultReadFile;
16945
17031
  const stopAt = parse(startDir).root;
16946
17032
  let dir = startDir;
16947
17033
  for (; ; ) {
16948
17034
  try {
16949
- const parsed = JSON.parse(readFile19(join16(dir, "package.json")));
17035
+ const parsed = JSON.parse(readFile20(join16(dir, "package.json")));
16950
17036
  if (parsed.name === PACKAGE_NAME) return dir;
16951
17037
  } catch {
16952
17038
  }
@@ -18343,6 +18429,26 @@ function expandHome(p, home2) {
18343
18429
  if (p.startsWith("~/")) return join20(home2, p.slice(2));
18344
18430
  return p;
18345
18431
  }
18432
+ function sandboxTokenLocation(env, home2 = homedir6()) {
18433
+ const raw = (env.ARCHME_SANDBOX_CLAUDE_TOKEN_FILE ?? "").trim();
18434
+ return raw ? { path: expandHome(raw, home2), override: true } : { path: join20(home2, ".archme", "sandbox-claude-token"), override: false };
18435
+ }
18436
+ function resolveSandboxImage(env, shippedImage) {
18437
+ let image = (env.ARCHME_SANDBOX_IMAGE ?? "").trim();
18438
+ if (!image) {
18439
+ try {
18440
+ image = shippedImage() ?? "";
18441
+ } catch (e) {
18442
+ throw new SandboxSettingsError(`the shipped sandbox image is unusable: ${e.message}`);
18443
+ }
18444
+ }
18445
+ if (!image) {
18446
+ throw new SandboxSettingsError(
18447
+ "no sandbox image: this install ships no sandbox-image.json, so set ARCHME_SANDBOX_IMAGE"
18448
+ );
18449
+ }
18450
+ return image;
18451
+ }
18346
18452
  function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage = () => null) {
18347
18453
  const modeRaw = (env.ARCHME_SANDBOX ?? "").trim().toLowerCase() || "off";
18348
18454
  if (modeRaw !== "off" && modeRaw !== "shadow" && modeRaw !== "required") {
@@ -18358,14 +18464,10 @@ function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage
18358
18464
  boxCpus: SANDBOX_DEFAULTS.boxCpus,
18359
18465
  volumeGiB: SANDBOX_DEFAULTS.volumeGiB,
18360
18466
  maxModelRequests: SANDBOX_DEFAULTS.maxModelRequests,
18361
- claudeTokenPath: join20(home2, ".archme", "sandbox-claude-token")
18467
+ claudeTokenPath: sandboxTokenLocation({}, home2).path,
18468
+ claudeTokenOverride: false
18362
18469
  };
18363
18470
  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
18471
  if (!Number.isInteger(concurrency)) {
18370
18472
  throw new SandboxSettingsError(`MAX_CONCURRENT_JOBS must be a whole number (got ${concurrency})`);
18371
18473
  }
@@ -18378,23 +18480,11 @@ function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage
18378
18480
  if (runtime !== "apple-container") {
18379
18481
  throw new SandboxSettingsError(`ARCHME_SANDBOX_RUNTIME must be apple-container (got "${runtime}")`);
18380
18482
  }
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(
18483
+ const image = resolveSandboxImage(env, shippedImage);
18484
+ const projects = mode === "required" ? /* @__PURE__ */ new Set() : new Set(
18395
18485
  (env.ARCHME_SANDBOX_PROJECTS ?? "").split(",").map((s) => s.trim()).filter(Boolean)
18396
18486
  );
18397
- const tokenRaw = (env.ARCHME_SANDBOX_CLAUDE_TOKEN_FILE ?? "").trim();
18487
+ const token = sandboxTokenLocation(env, home2);
18398
18488
  return {
18399
18489
  mode,
18400
18490
  projects,
@@ -18407,7 +18497,8 @@ function readSandboxSettings(env, concurrency, home2 = homedir6(), shippedImage
18407
18497
  boxCpus: SANDBOX_DEFAULTS.boxCpus,
18408
18498
  volumeGiB: intEnv(env, "ARCHME_SANDBOX_VOLUME_GIB", SANDBOX_DEFAULTS.volumeGiB, { min: 1 }),
18409
18499
  maxModelRequests: intEnv(env, "ARCHME_SANDBOX_MAX_MODEL_REQUESTS", SANDBOX_DEFAULTS.maxModelRequests, { min: 1 }),
18410
- claudeTokenPath: tokenRaw ? expandHome(tokenRaw, home2) : base.claudeTokenPath
18500
+ claudeTokenPath: token.path,
18501
+ claudeTokenOverride: token.override
18411
18502
  };
18412
18503
  }
18413
18504
  function shouldSandbox(settings, job, { isLocalRepo }) {
@@ -18483,6 +18574,132 @@ var init_imageManifest = __esm({
18483
18574
  }
18484
18575
  });
18485
18576
 
18577
+ // src/daemon/sandboxJob/result.ts
18578
+ import { readFile as readFile13 } from "node:fs/promises";
18579
+ function isValidNonce(nonce) {
18580
+ return typeof nonce === "string" && RESULT_NONCE_RE.test(nonce);
18581
+ }
18582
+ function encode(result, nonce) {
18583
+ return `${RESULT_PREFIX}${nonce} ${Buffer.from(JSON.stringify(result), "utf8").toString("base64")}`;
18584
+ }
18585
+ function fits(line, cap) {
18586
+ return Buffer.byteLength(line, "utf8") <= cap;
18587
+ }
18588
+ function withTruncatedError(r) {
18589
+ return { ...r, error: r.error ? `${r.error}; ${RESULT_TRUNCATED}` : RESULT_TRUNCATED };
18590
+ }
18591
+ function minimal(r) {
18592
+ if (r.kind === "probe") {
18593
+ const m2 = { kind: "probe", outcomes: {}, memoryPeakBytes: r.memoryPeakBytes, durations_ms: {}, error: RESULT_TRUNCATED };
18594
+ return m2;
18595
+ }
18596
+ if (r.kind === "clone") {
18597
+ const m2 = { kind: "clone", outcome: r.outcome, memoryPeakBytes: r.memoryPeakBytes, durations_ms: {}, error: RESULT_TRUNCATED };
18598
+ return m2;
18599
+ }
18600
+ const m = {
18601
+ kind: "agent",
18602
+ exitCode: r.exitCode,
18603
+ signal: r.signal,
18604
+ claudeStdout: "",
18605
+ affected: null,
18606
+ promptBytes: r.promptBytes,
18607
+ memoryPeakBytes: r.memoryPeakBytes,
18608
+ durations_ms: {},
18609
+ error: RESULT_TRUNCATED
18610
+ };
18611
+ return m;
18612
+ }
18613
+ function formatResultLine(result, nonce, cap = RESULT_MAX_BYTES) {
18614
+ if (!isValidNonce(nonce)) throw new Error("result: invalid nonce");
18615
+ const encode_ = (r) => encode(r, nonce);
18616
+ let line = encode_(result);
18617
+ if (fits(line, cap)) return line;
18618
+ if (result.kind === "agent") {
18619
+ const noAffected = { ...result, affected: null };
18620
+ line = encode_(withTruncatedError(noAffected));
18621
+ if (fits(line, cap)) return line;
18622
+ line = encode_(withTruncatedError({ ...noAffected, claudeStdout: "" }));
18623
+ if (fits(line, cap)) return line;
18624
+ }
18625
+ return encode_(minimal(result));
18626
+ }
18627
+ function isObj(v) {
18628
+ return typeof v === "object" && v !== null && !Array.isArray(v);
18629
+ }
18630
+ function isNumOrNull(v) {
18631
+ return v === null || typeof v === "number" && Number.isFinite(v);
18632
+ }
18633
+ function shapeError(o2) {
18634
+ if (!isNumOrNull(o2.memoryPeakBytes)) return "memoryPeakBytes";
18635
+ if (!isObj(o2.durations_ms) || !Object.values(o2.durations_ms).every((v) => typeof v === "number")) return "durations_ms";
18636
+ if (o2.error !== void 0 && typeof o2.error !== "string") return "error";
18637
+ if (o2.kind === "clone") {
18638
+ if (o2.outcome !== "diff" && o2.outcome !== "skip" && o2.outcome !== "rejected") return "outcome";
18639
+ if (o2.diff !== void 0 && !isObj(o2.diff)) return "diff";
18640
+ return null;
18641
+ }
18642
+ if (o2.kind === "probe") {
18643
+ if (!isObj(o2.outcomes) || !Object.values(o2.outcomes).every((v) => typeof v === "string")) return "outcomes";
18644
+ return null;
18645
+ }
18646
+ if (o2.kind === "agent") {
18647
+ if (!isNumOrNull(o2.exitCode)) return "exitCode";
18648
+ if (o2.signal !== null && typeof o2.signal !== "string") return "signal";
18649
+ if (typeof o2.claudeStdout !== "string") return "claudeStdout";
18650
+ if (o2.affected !== null && !(isObj(o2.affected) && ["stories", "components", "unresolvedFiles"].every((k) => Array.isArray(o2.affected[k])))) {
18651
+ return "affected";
18652
+ }
18653
+ if (typeof o2.promptBytes !== "number") return "promptBytes";
18654
+ return null;
18655
+ }
18656
+ return "kind";
18657
+ }
18658
+ function parseResultLine(stdout, nonce, cap = RESULT_MAX_BYTES) {
18659
+ if (!isValidNonce(nonce)) return { ok: false, reason: "no result line" };
18660
+ const prefix = `${RESULT_PREFIX}${nonce} `;
18661
+ const lines = stdout.split("\n");
18662
+ let line;
18663
+ for (let i2 = lines.length - 1; i2 >= 0; i2--) {
18664
+ const l = lines[i2].replace(/\r$/, "");
18665
+ if (l.startsWith(prefix)) {
18666
+ line = l;
18667
+ break;
18668
+ }
18669
+ }
18670
+ if (line === void 0) return { ok: false, reason: "no result line" };
18671
+ if (Buffer.byteLength(line, "utf8") > cap) return { ok: false, reason: "result line exceeds cap" };
18672
+ const b64 = line.slice(prefix.length).trim();
18673
+ if (b64 === "" || b64.length % 4 !== 0 || !BASE64_RE.test(b64)) return { ok: false, reason: "result is not base64" };
18674
+ let data;
18675
+ try {
18676
+ data = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
18677
+ } catch {
18678
+ return { ok: false, reason: "result is not JSON" };
18679
+ }
18680
+ if (!isObj(data)) return { ok: false, reason: "result is not an object" };
18681
+ const bad = shapeError(data);
18682
+ if (bad) return { ok: false, reason: `result has a malformed ${bad}` };
18683
+ return { ok: true, result: data };
18684
+ }
18685
+ async function readMemoryPeak(path7 = "/sys/fs/cgroup/memory.peak", read = (p) => readFile13(p, "utf8")) {
18686
+ try {
18687
+ const n2 = Number.parseInt((await read(path7)).trim(), 10);
18688
+ return Number.isFinite(n2) && n2 >= 0 ? n2 : null;
18689
+ } catch {
18690
+ return null;
18691
+ }
18692
+ }
18693
+ var RESULT_TRUNCATED, BASE64_RE;
18694
+ var init_result2 = __esm({
18695
+ "src/daemon/sandboxJob/result.ts"() {
18696
+ "use strict";
18697
+ init_types();
18698
+ RESULT_TRUNCATED = "result truncated";
18699
+ BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
18700
+ }
18701
+ });
18702
+
18486
18703
  // src/daemon/sandbox/inputDir.ts
18487
18704
  import { mkdtemp as mkdtemp3, readdir as readdir2, rm as rm7, writeFile as writeFile10, chmod as chmod4 } from "node:fs/promises";
18488
18705
  import { tmpdir as tmpdir3 } from "node:os";
@@ -18514,893 +18731,1272 @@ var init_inputDir = __esm({
18514
18731
  }
18515
18732
  });
18516
18733
 
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
- );
18734
+ // src/daemon/sandbox/sweep.ts
18735
+ async function sweepJobs(deps) {
18736
+ const now = deps.now ?? Date.now;
18737
+ const t0 = now();
18738
+ const failures = [];
18739
+ const boxes = [];
18740
+ const volumes = [];
18741
+ const jobBoxes = (await deps.list()).filter(isJobBox);
18742
+ await Promise.all(jobBoxes.map(async (b) => {
18743
+ try {
18744
+ await deps.kill(b.id).catch(() => void 0);
18745
+ await deps.remove(b.id);
18746
+ boxes.push(b.id);
18747
+ } catch (e) {
18748
+ failures.push(`box ${b.id}: ${e.message}`);
18540
18749
  }
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
- );
18750
+ }));
18751
+ const jobVolumes = (await deps.listVolumes()).filter(isJobVolume);
18752
+ for (const v of jobVolumes) {
18753
+ try {
18754
+ await deps.removeVolume(v.name);
18755
+ volumes.push(v.name);
18756
+ } catch (e) {
18757
+ failures.push(`volume ${v.name}: ${e.message}`);
18547
18758
  }
18548
- return token;
18549
- } finally {
18550
- await fh.close();
18551
18759
  }
18760
+ const report = { boxes: boxes.sort(), volumes, ms: now() - t0 };
18761
+ if (failures.length > 0) throw new SweepError(report, failures);
18762
+ return report;
18552
18763
  }
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"() {
18764
+ var JOB_VOLUME_PREFIX, SweepError, isJobBox, isJobVolume;
18765
+ var init_sweep = __esm({
18766
+ "src/daemon/sandbox/sweep.ts"() {
18591
18767
  "use strict";
18592
- init_inputDir();
18593
- SandboxBootError = class extends Error {
18594
- constructor(message2) {
18595
- super(message2);
18596
- this.name = "SandboxBootError";
18768
+ init_types();
18769
+ JOB_VOLUME_PREFIX = "archme-job-";
18770
+ SweepError = class extends Error {
18771
+ constructor(report, failures) {
18772
+ super(`sandbox sweep left ${failures.length} item(s): ${failures.join("; ")}`);
18773
+ this.report = report;
18774
+ this.failures = failures;
18775
+ this.name = "SweepError";
18597
18776
  }
18598
18777
  };
18778
+ isJobBox = (b) => JOB_LABEL in b.labels;
18779
+ isJobVolume = (v) => v.name.startsWith(JOB_VOLUME_PREFIX) || JOB_LABEL in v.labels;
18599
18780
  }
18600
18781
  });
18601
18782
 
18602
- // src/daemon/sandbox/runSandboxedJob.ts
18603
- function sandboxJobOf(job) {
18783
+ // src/daemon/sandbox/appleContainer.ts
18784
+ import { randomBytes as randomBytes2 } from "node:crypto";
18785
+ import { homedir as homedir7 } from "node:os";
18786
+ import { isAbsolute, normalize } from "node:path";
18787
+ function assertName(kind, value) {
18788
+ if (!NAME_RE.test(value)) throw new Error(`sandbox: invalid ${kind} name ${JSON.stringify(value)}`);
18789
+ }
18790
+ function assertImage(image) {
18791
+ if (!image || image.startsWith("-") || /\s/.test(image)) {
18792
+ throw new Error(`sandbox: invalid image ${JSON.stringify(image)}`);
18793
+ }
18794
+ }
18795
+ function labelArgs(labels) {
18796
+ const out = [];
18797
+ for (const [k, v] of Object.entries(labels)) {
18798
+ if (!LABEL_KEY_RE.test(k)) throw new Error(`sandbox: invalid label key ${JSON.stringify(k)}`);
18799
+ if (CONTROL_RE.test(v)) throw new Error(`sandbox: label ${k} value contains a control character`);
18800
+ out.push("--label", `${k}=${v}`);
18801
+ }
18802
+ return out;
18803
+ }
18804
+ function assertInputDir(dir, home2 = homedir7()) {
18805
+ if (!isAbsolute(dir)) throw new Error(`sandbox: inputDir must be absolute: ${dir}`);
18806
+ const strip2 = (p) => p.replace(/\/+$/, "") || "/";
18807
+ const n2 = strip2(normalize(dir));
18808
+ if (n2 !== strip2(dir)) throw new Error(`sandbox: inputDir must be normalized: ${dir}`);
18809
+ if (/[:,]/.test(n2) || CONTROL_RE.test(n2)) throw new Error(`sandbox: inputDir has a forbidden character: ${dir}`);
18810
+ const homeN = strip2(normalize(home2));
18811
+ if (n2 === "/" || n2 === homeN || /^\/(Users|home)(\/[^/]+)?$/.test(n2) || n2 === "/root" || n2 === "/var/root") {
18812
+ throw new Error(`sandbox: inputDir must not be / or a home directory: ${dir}`);
18813
+ }
18814
+ }
18815
+ function envArgs(env) {
18816
+ const out = [];
18817
+ for (const [k, v] of Object.entries(env)) {
18818
+ if (!ENV_KEY_RE.test(k)) throw new Error(`sandbox: invalid env key ${JSON.stringify(k)}`);
18819
+ if (CONTROL_RE.test(v)) throw new Error(`sandbox: env ${k} value contains a newline or NUL`);
18820
+ out.push("-e", `${k}=${v}`);
18821
+ }
18822
+ return out;
18823
+ }
18824
+ function slotFor(index) {
18825
+ if (!Number.isInteger(index) || index < 0 || index >= MAX_SLOTS) {
18826
+ throw new Error(`sandbox: slot index out of range: ${index}`);
18827
+ }
18828
+ const third = SLOT_RANGE_BASE_THIRD_OCTET + index;
18604
18829
  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
18830
+ index,
18831
+ network: `archme-slot-${index}`,
18832
+ subnet: `192.168.${third}.0/24`,
18833
+ gateway: `192.168.${third}.1`
18620
18834
  };
18621
18835
  }
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],
18836
+ function slotNetwork(slot) {
18837
+ if (!slot || typeof slot !== "object") throw new Error("sandbox: a step needs a slot network");
18838
+ const want = slotFor(slot.index);
18839
+ if (slot.network !== want.network) throw new Error(`sandbox: ${slot.network} is not slot ${slot.index}'s network`);
18840
+ return want.network;
18841
+ }
18842
+ function buildRunArgs(spec, home2) {
18843
+ assertName("box", spec.name);
18844
+ if (spec.volume !== void 0) assertName("volume", spec.volume);
18845
+ assertImage(spec.image);
18846
+ assertInputDir(spec.inputDir, home2);
18847
+ if (!spec.labels[JOB_LABEL]) throw new Error(`sandbox: step labels must include ${JOB_LABEL}`);
18848
+ const { cpus, memoryMiB } = spec.limits;
18849
+ if (!Number.isInteger(cpus) || cpus < 1) throw new Error(`sandbox: invalid cpus ${cpus}`);
18850
+ if (!Number.isInteger(memoryMiB) || memoryMiB < MIN_MEMORY_MIB) {
18851
+ throw new Error(`sandbox: memoryMiB must be an integer \u2265 ${MIN_MEMORY_MIB}: ${memoryMiB}`);
18852
+ }
18853
+ const network = slotNetwork(spec.network);
18854
+ return [
18855
+ // `-i`: stdin carries the per-step result nonce (one line, then EOF) to
18856
+ // the wrapper — the only channel that is neither a file, env nor argv.
18857
+ "run",
18858
+ "-i",
18859
+ "--rm",
18860
+ "--init",
18861
+ "--read-only",
18862
+ "--cap-drop",
18863
+ "ALL",
18864
+ "--user",
18865
+ `${BOX_UID}:${BOX_UID}`,
18866
+ "--tmpfs",
18867
+ "/tmp",
18868
+ "--no-dns",
18869
+ "--kernel-arg",
18870
+ "ipv6.disable=1",
18871
+ // Progress lines otherwise land on stderr ahead of the box's own output.
18872
+ "--progress",
18873
+ "none",
18874
+ "--cpus",
18875
+ String(cpus),
18876
+ "--memory",
18877
+ `${memoryMiB}M`,
18878
+ "--name",
18879
+ spec.name,
18880
+ ...labelArgs(spec.labels),
18881
+ "--network",
18716
18882
  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);
18883
+ // Verified: `:ro` on a directory bind is a read-only virtiofs mount — even
18884
+ // root in the box gets EROFS on create/mkdir.
18885
+ "-v",
18886
+ `${spec.inputDir}:/job:ro`,
18887
+ ...spec.volume !== void 0 ? ["-v", `${spec.volume}:/work`] : [],
18888
+ ...envArgs(spec.env),
18889
+ spec.image,
18890
+ ...spec.args
18891
+ ];
18892
+ }
18893
+ function buildInitVolumeArgs(volume, uid, labels, image, slot) {
18894
+ assertName("volume", volume);
18895
+ assertImage(image);
18896
+ const network = slotNetwork(slot);
18897
+ if (!Number.isInteger(uid) || uid <= 0) throw new Error(`sandbox: invalid uid ${uid}`);
18898
+ return [
18899
+ "run",
18900
+ "--rm",
18901
+ "--init",
18902
+ "--read-only",
18903
+ "--cap-drop",
18904
+ "ALL",
18905
+ "--cap-add",
18906
+ "CHOWN",
18907
+ "--user",
18908
+ "0:0",
18909
+ "--no-dns",
18910
+ "--kernel-arg",
18911
+ "ipv6.disable=1",
18912
+ "--progress",
18913
+ "none",
18914
+ "--network",
18915
+ network,
18916
+ "--memory",
18917
+ `${INIT_MEMORY_MIB}M`,
18918
+ ...labelArgs(labels),
18919
+ "-v",
18920
+ `${volume}:/work`,
18921
+ "--entrypoint",
18922
+ "chown",
18923
+ image,
18924
+ `${uid}:${uid}`,
18925
+ "/work"
18926
+ ];
18927
+ }
18928
+ function buildCreateVolumeArgs(name, sizeGiB, labels) {
18929
+ assertName("volume", name);
18930
+ if (!Number.isInteger(sizeGiB) || sizeGiB < 1) throw new Error(`sandbox: invalid volume size ${sizeGiB}`);
18931
+ return ["volume", "create", "-s", `${sizeGiB}G`, ...labelArgs(labels), name];
18932
+ }
18933
+ function buildEnsureNetworkArgs(slot) {
18934
+ return [
18935
+ "network",
18936
+ "create",
18937
+ "--internal",
18938
+ "--subnet",
18939
+ slot.subnet,
18940
+ "--label",
18941
+ `${SLOT_LABEL}=${slot.index}`,
18942
+ slot.network
18943
+ ];
18944
+ }
18945
+ function labelsOf(v) {
18946
+ const out = {};
18947
+ for (const [k, x] of Object.entries(obj(v))) if (typeof x === "string") out[k] = x;
18948
+ return out;
18949
+ }
18950
+ function parseArray(json, what) {
18951
+ const parsed = JSON.parse(json.trim() || "[]");
18952
+ if (!Array.isArray(parsed)) throw new Error(`sandbox: ${what} is not a JSON array`);
18953
+ return parsed;
18954
+ }
18955
+ function parseBoxList(json) {
18956
+ return parseArray(json, "container ls").flatMap((raw) => {
18957
+ const r = obj(raw);
18958
+ const id = str(r.id) ?? str(obj(r.configuration).id);
18959
+ if (!id) return [];
18960
+ return [{ id, state: str(obj(r.status).state) ?? "unknown", labels: labelsOf(obj(r.configuration).labels) }];
18961
+ });
18962
+ }
18963
+ function parseVolumeList(json) {
18964
+ return parseArray(json, "container volume ls").flatMap((raw) => {
18965
+ const r = obj(raw);
18966
+ const name = str(obj(r.configuration).name) ?? str(r.id);
18967
+ if (!name) return [];
18968
+ return [{ name, labels: labelsOf(obj(r.configuration).labels) }];
18969
+ });
18970
+ }
18971
+ function parseNetworkList(json) {
18972
+ return parseArray(json, "container network ls").flatMap((raw) => {
18973
+ const r = obj(raw);
18974
+ const c3 = obj(r.configuration);
18975
+ const name = str(c3.name) ?? str(r.id);
18976
+ if (!name) return [];
18977
+ return [{
18978
+ name,
18979
+ subnet: str(c3.ipv4Subnet) ?? str(obj(r.status).ipv4Subnet),
18980
+ mode: str(c3.mode),
18981
+ labels: labelsOf(c3.labels)
18982
+ }];
18983
+ });
18984
+ }
18985
+ function isBoxNotFound(stderr) {
18986
+ return /notFound: "container with ID .* not found"/.test(stderr);
18987
+ }
18988
+ function isBoxStopped(stderr) {
18989
+ return /invalidState: "no runtime client exists: container is stopped"/.test(stderr);
18990
+ }
18991
+ function defaultExec2(bin = "container") {
18992
+ return async (args) => {
18993
+ const r = await execa(bin, args, { reject: false, stdin: "ignore" });
18994
+ const stderr = typeof r.stderr === "string" ? r.stderr : "";
18806
18995
  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
18996
+ exitCode: r.exitCode ?? -1,
18997
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
18998
+ stderr: r.exitCode === void 0 ? `${stderr}
18999
+ ${r.message}`.trim() : stderr
18814
19000
  };
18815
19001
  };
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
- }
19002
+ }
19003
+ function defaultSpawn(bin = "container") {
19004
+ return (args) => {
19005
+ const p = execa(bin, args, { reject: false, stdin: "pipe", buffer: false });
19006
+ return {
19007
+ stdin: p.stdin,
19008
+ stdout: p.stdout,
19009
+ stderr: p.stderr,
19010
+ exited: p.then((r) => ({
19011
+ exitCode: r.exitCode ?? null,
19012
+ signal: r.signal ?? null,
19013
+ error: r.exitCode === void 0 && !r.signal ? r.message : void 0
19014
+ }))
18831
19015
  };
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
- }
19016
+ };
18840
19017
  }
18841
- var DEFAULT_SANDBOX_TIMEOUTS, SandboxInfraError;
18842
- var init_runSandboxedJob = __esm({
18843
- "src/daemon/sandbox/runSandboxedJob.ts"() {
19018
+ 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;
19019
+ var init_appleContainer = __esm({
19020
+ "src/daemon/sandbox/appleContainer.ts"() {
18844
19021
  "use strict";
19022
+ init_execa();
19023
+ init_sweep();
18845
19024
  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)` : "")
19025
+ MIN_MEMORY_MIB = 200;
19026
+ STDOUT_CAP_BYTES = 4 * 1024 * 1024;
19027
+ STDERR_CAP_BYTES = 1024 * 1024;
19028
+ INIT_MEMORY_MIB = 256;
19029
+ NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/;
19030
+ ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
19031
+ LABEL_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19032
+ CONTROL_RE = /[\u0000\n\r]/;
19033
+ obj = (v) => v && typeof v === "object" ? v : {};
19034
+ str = (v) => typeof v === "string" ? v : null;
19035
+ TailBuffer = class {
19036
+ constructor(cap) {
19037
+ this.cap = cap;
19038
+ }
19039
+ chunks = [];
19040
+ size = 0;
19041
+ truncated = false;
19042
+ push(chunk) {
19043
+ this.chunks.push(chunk);
19044
+ this.size += chunk.length;
19045
+ while (this.size > this.cap && this.chunks.length > 0) {
19046
+ const head = this.chunks[0];
19047
+ const over = this.size - this.cap;
19048
+ this.truncated = true;
19049
+ if (head.length <= over) {
19050
+ this.chunks.shift();
19051
+ this.size -= head.length;
19052
+ } else {
19053
+ this.chunks[0] = head.subarray(over);
19054
+ this.size -= over;
19055
+ }
19056
+ }
19057
+ }
19058
+ toString() {
19059
+ let buf = Buffer.concat(this.chunks);
19060
+ if (this.truncated) {
19061
+ let i2 = 0;
19062
+ while (i2 < buf.length && i2 < 3 && (buf[i2] & 192) === 128) i2++;
19063
+ buf = buf.subarray(i2);
19064
+ }
19065
+ return buf.toString("utf8");
19066
+ }
19067
+ };
19068
+ AppleContainerRuntime = class {
19069
+ constructor(opts = {}) {
19070
+ this.opts = opts;
19071
+ this.exec = opts.exec ?? defaultExec2(opts.bin);
19072
+ this.spawn = opts.spawn ?? defaultSpawn(opts.bin);
19073
+ }
19074
+ name = "apple-container";
19075
+ exec;
19076
+ spawn;
19077
+ /** Boxes `kill()` was called for while their step was running. */
19078
+ killedNames = /* @__PURE__ */ new Set();
19079
+ running = /* @__PURE__ */ new Set();
19080
+ async must(args, what) {
19081
+ const r = await this.exec(args);
19082
+ if (r.exitCode !== 0) throw new Error(`sandbox: ${what} failed (exit ${r.exitCode}): ${r.stderr.trim()}`);
19083
+ return r;
19084
+ }
19085
+ async preflight() {
19086
+ const checks = [];
19087
+ const status = await this.exec(["system", "status"]);
19088
+ const up = status.exitCode === 0 && /^status\s+running\b/m.test(status.stdout);
19089
+ checks.push({ name: "runtime_status", ok: up, detail: up ? void 0 : (status.stderr || status.stdout).trim() });
19090
+ if (this.opts.image) {
19091
+ let img = await this.exec(["image", "inspect", this.opts.image]);
19092
+ if (img.exitCode !== 0 && up && this.opts.image.includes("@sha256:")) {
19093
+ const pull = await this.exec(["image", "pull", this.opts.image]);
19094
+ img = pull.exitCode === 0 ? await this.exec(["image", "inspect", this.opts.image]) : pull;
19095
+ }
19096
+ checks.push({ name: "image_present", ok: img.exitCode === 0, detail: img.exitCode === 0 ? void 0 : img.stderr.trim() });
19097
+ } else {
19098
+ checks.push({ name: "image_present", ok: false, detail: "no image configured" });
19099
+ }
19100
+ const want = this.opts.slots ?? 0;
19101
+ if (up) {
19102
+ const nets = await this.listNetworks();
19103
+ const missing = [];
19104
+ for (let i2 = 0; i2 < want; i2++) {
19105
+ const s = slotFor(i2);
19106
+ const n2 = nets.find((x) => x.name === s.network);
19107
+ if (!n2 || n2.subnet !== s.subnet) missing.push(n2 ? `${s.network} (subnet ${n2.subnet})` : s.network);
19108
+ }
19109
+ checks.push({ name: "slot_networks", ok: missing.length === 0, detail: missing.length ? `missing/wrong: ${missing.join(", ")}` : void 0 });
19110
+ } else {
19111
+ checks.push({ name: "slot_networks", ok: false, detail: "runtime not running" });
19112
+ }
19113
+ return { ok: checks.every((c3) => c3.ok), checks };
19114
+ }
19115
+ async listNetworks() {
19116
+ const r = await this.must(["network", "ls", "--format", "json"], "network ls");
19117
+ return parseNetworkList(r.stdout);
19118
+ }
19119
+ async ensureSlots(n2) {
19120
+ if (!Number.isInteger(n2) || n2 < 1 || n2 > MAX_SLOTS) throw new Error(`sandbox: slot count out of range: ${n2}`);
19121
+ const slots = Array.from({ length: n2 }, (_, i2) => slotFor(i2));
19122
+ let nets = await this.listNetworks();
19123
+ for (const s of slots) {
19124
+ const existing = nets.find((x) => x.name === s.network);
19125
+ if (existing) {
19126
+ if (existing.subnet !== s.subnet) {
19127
+ throw new Error(`sandbox: ${s.network} exists with subnet ${existing.subnet}, expected ${s.subnet}`);
19128
+ }
19129
+ continue;
19130
+ }
19131
+ const r = await this.exec(buildEnsureNetworkArgs(s));
19132
+ if (r.exitCode !== 0) {
19133
+ nets = await this.listNetworks();
19134
+ const now = nets.find((x) => x.name === s.network);
19135
+ if (!now || now.subnet !== s.subnet) {
19136
+ throw new Error(`sandbox: creating ${s.network} failed: ${r.stderr.trim()}`);
19137
+ }
19138
+ }
19139
+ }
19140
+ return slots;
19141
+ }
19142
+ async createVolume(name, sizeGiB, labels) {
19143
+ await this.must(buildCreateVolumeArgs(name, sizeGiB, labels), `volume create ${name}`);
19144
+ }
19145
+ async initVolume(name, uid, labels, image, slot) {
19146
+ await this.must(buildInitVolumeArgs(name, uid, labels, image, slot), `volume init ${name}`);
19147
+ }
19148
+ async runStep(spec) {
19149
+ const args = buildRunArgs(spec, this.opts.homeDir);
19150
+ const name = spec.name;
19151
+ this.running.add(name);
19152
+ this.killedNames.delete(name);
19153
+ const out = new TailBuffer(STDOUT_CAP_BYTES);
19154
+ const err4 = new TailBuffer(STDERR_CAP_BYTES);
19155
+ const nonce = randomBytes2(16).toString("hex");
19156
+ const proc = this.spawn(args);
19157
+ if (proc.stdin) {
19158
+ proc.stdin.on("error", () => {
19159
+ });
19160
+ proc.stdin.end(`${nonce}
19161
+ `);
19162
+ }
19163
+ const tee = (stream, buf, which) => new Promise((resolve7) => {
19164
+ if (!stream) return resolve7();
19165
+ stream.on("data", (c3) => {
19166
+ const b = typeof c3 === "string" ? Buffer.from(c3) : c3;
19167
+ buf.push(b);
19168
+ this.opts.onOutput?.(name, which, b);
19169
+ });
19170
+ stream.on("end", () => resolve7());
19171
+ stream.on("close", () => resolve7());
19172
+ stream.on("error", () => resolve7());
19173
+ });
19174
+ const done = (async () => {
19175
+ const [exit] = await Promise.all([proc.exited, tee(proc.stdout, out, "stdout"), tee(proc.stderr, err4, "stderr")]);
19176
+ const killed = this.killedNames.has(name);
19177
+ this.killedNames.delete(name);
19178
+ this.running.delete(name);
19179
+ const stderr = err4.toString();
19180
+ return {
19181
+ exitCode: exit.exitCode,
19182
+ signal: exit.signal,
19183
+ stdout: out.toString(),
19184
+ stderr: exit.error ? `${stderr}
19185
+ ${exit.error}`.trim() : stderr,
19186
+ killed,
19187
+ nonce
19188
+ };
19189
+ })();
19190
+ return { name, done };
19191
+ }
19192
+ /** Not-found and already-stopped both count as success: the box is not running. */
19193
+ async kill(name) {
19194
+ assertName("box", name);
19195
+ if (this.running.has(name)) this.killedNames.add(name);
19196
+ const r = await this.exec(["kill", name]);
19197
+ if (r.exitCode !== 0 && !isBoxNotFound(r.stderr) && !isBoxStopped(r.stderr)) {
19198
+ throw new Error(`sandbox: kill ${name} failed: ${r.stderr.trim()}`);
19199
+ }
19200
+ }
19201
+ async stop(name, graceSeconds) {
19202
+ assertName("box", name);
19203
+ const t = Math.max(0, Math.floor(graceSeconds));
19204
+ const r = await this.exec(["stop", "-t", String(t), name]);
19205
+ if (r.exitCode !== 0 && !isBoxNotFound(r.stderr) && !isBoxStopped(r.stderr)) {
19206
+ throw new Error(`sandbox: stop ${name} failed: ${r.stderr.trim()}`);
19207
+ }
19208
+ }
19209
+ /** `container rm`; not-found counts as removed (a `--rm` box is gone once stopped). */
19210
+ async remove(name) {
19211
+ assertName("box", name);
19212
+ const r = await this.exec(["rm", name]);
19213
+ if (r.exitCode !== 0 && !isBoxNotFound(r.stderr)) {
19214
+ throw new Error(`sandbox: rm ${name} failed: ${r.stderr.trim()}`);
19215
+ }
19216
+ }
19217
+ async removeVolume(name) {
19218
+ assertName("volume", name);
19219
+ const r = await this.exec(["volume", "rm", name]);
19220
+ if (r.exitCode === 0) return;
19221
+ const still = (await this.listVolumes()).some((v) => v.name === name);
19222
+ if (still) throw new Error(`sandbox: volume rm ${name} failed: ${r.stderr.trim()}`);
19223
+ }
19224
+ async list() {
19225
+ const r = await this.must(["ls", "-a", "--format", "json"], "ls");
19226
+ return parseBoxList(r.stdout);
19227
+ }
19228
+ async listVolumes() {
19229
+ const r = await this.must(["volume", "ls", "--format", "json"], "volume ls");
19230
+ return parseVolumeList(r.stdout);
19231
+ }
19232
+ sweep() {
19233
+ return sweepJobs(this);
19234
+ }
18870
19235
  };
18871
19236
  }
18872
- if (stepFailure == null && exitCode !== null && exitCode !== 0 && counters?.last_upstream_status === 429) {
18873
- return { kind: "upstream_rate_limit" };
19237
+ });
19238
+
19239
+ // src/daemon/sandbox/slotMap.ts
19240
+ function parseIpv4(addr) {
19241
+ const m = IPV4_RE.exec(addr);
19242
+ if (!m) return null;
19243
+ let v = 0;
19244
+ for (let i2 = 1; i2 <= 4; i2++) {
19245
+ const o2 = Number(m[i2]);
19246
+ if (o2 > 255) return null;
19247
+ v = v * 256 + o2;
18874
19248
  }
18875
- return { kind: "normal" };
19249
+ return v >>> 0;
18876
19250
  }
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" };
19251
+ function parseCidr(cidr) {
19252
+ const [ip, bits, extra] = cidr.split("/");
19253
+ if (ip === void 0 || bits === void 0 || extra !== void 0 || !/^\d{1,2}$/.test(bits)) return null;
19254
+ const n2 = Number(bits);
19255
+ const base = parseIpv4(ip);
19256
+ if (base === null || n2 > 32) return null;
19257
+ const mask2 = n2 === 0 ? 0 : ~0 << 32 - n2 >>> 0;
19258
+ return { base: (base & mask2) >>> 0, mask: mask2 };
18888
19259
  }
18889
- var SANDBOX_REQUEST_CAP_REASON, SandboxExitError;
18890
- var init_classifySandboxExit = __esm({
18891
- "src/daemon/sandbox/classifySandboxExit.ts"() {
19260
+ function stripMapped(addr) {
19261
+ return (addr ?? "").replace(/^::ffff:/i, "");
19262
+ }
19263
+ var IPV4_RE, SlotMap;
19264
+ var init_slotMap = __esm({
19265
+ "src/daemon/sandbox/slotMap.ts"() {
18892
19266
  "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";
19267
+ init_appleContainer();
19268
+ IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
19269
+ SlotMap = class {
19270
+ byIndex = /* @__PURE__ */ new Map();
19271
+ byGateway = /* @__PURE__ */ new Map();
19272
+ allowGatewayAsPeer;
19273
+ constructor(slots, opts = {}) {
19274
+ this.allowGatewayAsPeer = opts.allowGatewayAsPeer ?? false;
19275
+ for (const slot of slots) {
19276
+ const gateway = parseIpv4(slot.gateway);
19277
+ const cidr = parseCidr(slot.subnet);
19278
+ if (gateway === null) throw new Error(`slot ${slot.index}: gateway is not an IPv4 address`);
19279
+ if (cidr === null) throw new Error(`slot ${slot.index}: subnet is not an IPv4 CIDR`);
19280
+ if (this.byIndex.has(slot.index)) throw new Error(`slot ${slot.index}: duplicate index`);
19281
+ if (this.byGateway.has(slot.gateway)) throw new Error(`slot ${slot.index}: duplicate gateway`);
19282
+ const entry = { slot, gateway, cidr, binding: null };
19283
+ this.byIndex.set(slot.index, entry);
19284
+ this.byGateway.set(slot.gateway, entry);
19285
+ }
19286
+ }
19287
+ /**
19288
+ * The slot a connection belongs to, or null (drop it). `local` must be a slot
19289
+ * gateway; `remote` must be a plain IPv4 inside THAT slot's subnet — never
19290
+ * IPv6, never another slot's range.
19291
+ */
19292
+ resolve(localAddress, remoteAddress) {
19293
+ const entry = this.byGateway.get(stripMapped(localAddress));
19294
+ if (!entry) return null;
19295
+ const remoteStr = stripMapped(remoteAddress);
19296
+ const remote = parseIpv4(remoteStr);
19297
+ if (remote === null) return null;
19298
+ if ((remote & entry.cidr.mask) >>> 0 !== entry.cidr.base) return null;
19299
+ if (remote === entry.gateway && !this.allowGatewayAsPeer) return null;
19300
+ return entry.slot;
19301
+ }
19302
+ slots() {
19303
+ return [...this.byIndex.values()].map((e) => e.slot);
19304
+ }
19305
+ slot(index) {
19306
+ return this.byIndex.get(index)?.slot ?? null;
19307
+ }
19308
+ bind(index, binding) {
19309
+ const entry = this.entry(index);
19310
+ if (entry.binding && entry.binding.jobId !== binding.jobId) {
19311
+ throw new Error(`slot ${index} is bound to another job`);
19312
+ }
19313
+ entry.binding = binding;
19314
+ }
19315
+ clear(index) {
19316
+ this.entry(index).binding = null;
19317
+ }
19318
+ binding(index) {
19319
+ return this.byIndex.get(index)?.binding ?? null;
19320
+ }
19321
+ bindingForJob(jobId) {
19322
+ for (const e of this.byIndex.values()) {
19323
+ if (e.binding?.jobId === jobId) return { slot: e.slot, binding: e.binding };
19324
+ }
19325
+ return null;
19326
+ }
19327
+ entry(index) {
19328
+ const e = this.byIndex.get(index);
19329
+ if (!e) throw new Error(`unknown slot ${index}`);
19330
+ return e;
18901
19331
  }
18902
19332
  };
18903
19333
  }
18904
19334
  });
18905
19335
 
18906
- // src/daemon/sandbox/sweep.ts
18907
- async function sweepJobs(deps) {
18908
- const now = deps.now ?? Date.now;
18909
- const t0 = now();
19336
+ // src/daemon/sandbox/confinement.ts
19337
+ import { randomBytes as randomBytes3 } from "node:crypto";
19338
+ function probeTargets(slot, lanIp) {
19339
+ const gw = slot.gateway;
19340
+ return [
19341
+ ...HOST_SERVICE_PORTS.map((port) => ({ id: `host_${port}`, kind: "tcp", host: gw, port })),
19342
+ ...lanIp ? LAN_PORTS.map((port) => ({ id: `lan_${port}`, kind: "tcp", host: lanIp, port })) : [],
19343
+ { id: "public_443", kind: "tcp", host: PUBLIC_PROBE_IP, port: 443 },
19344
+ { id: "public_dns", kind: "udp_dns", host: PUBLIC_PROBE_IP, port: 53 },
19345
+ { id: "ipv6", kind: "ipv6_socket", host: "::", port: 0 },
19346
+ { id: "gate_anthropic", kind: "http", host: gw, port: GATE_PORTS.anthropic },
19347
+ { id: "gate_mcp", kind: "http", host: gw, port: GATE_PORTS.mcp },
19348
+ { id: "gate_github", kind: "connect", host: gw, port: GATE_PORTS.github }
19349
+ ];
19350
+ }
19351
+ function expectation(id) {
19352
+ if (id.startsWith("host_") || id.startsWith("lan_")) return { want: "timeout", ok: (o2) => o2 === "timeout" };
19353
+ if (id === "public_dns") return { want: "silent", ok: (o2) => o2 === "silent" };
19354
+ if (id.startsWith("public_")) return { want: "not connected", ok: (o2) => o2 !== "connected" };
19355
+ if (id === "ipv6") return { want: "unsupported", ok: (o2) => o2 === "unsupported" };
19356
+ if (id.startsWith("gate_")) return { want: "an HTTP status", ok: (o2) => /^\d{3}$/.test(o2) };
19357
+ return { want: "a known target", ok: () => false };
19358
+ }
19359
+ function judgeProbe(outcomes, targets) {
19360
+ const ids = targets ? targets.map((t) => t.id) : Object.keys(outcomes);
19361
+ for (const id of ["host_22", "public_443", "public_dns", "ipv6", "gate_anthropic", "gate_mcp", "gate_github"]) {
19362
+ if (!ids.includes(id)) ids.push(id);
19363
+ }
18910
19364
  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}`);
19365
+ for (const id of ids) {
19366
+ const o2 = outcomes[id];
19367
+ const e = expectation(id);
19368
+ if (o2 === void 0) failures.push(`${id}: no outcome (want: ${e.want})`);
19369
+ else if (!e.ok(o2)) failures.push(`${id}: ${o2} (want: ${e.want})`);
19370
+ }
19371
+ return { ok: failures.length === 0, failures };
19372
+ }
19373
+ function primaryLanIpv4(ifaces) {
19374
+ const lo = parseIpv4(`192.168.${SLOT_RANGE_BASE_THIRD_OCTET}.0`);
19375
+ for (const list2 of Object.values(ifaces)) {
19376
+ for (const i2 of list2 ?? []) {
19377
+ if (i2.internal || i2.family !== "IPv4") continue;
19378
+ const v = parseIpv4(i2.address);
19379
+ if (v === null || (v & 4294965248) >>> 0 === lo) continue;
19380
+ return i2.address;
18921
19381
  }
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}`);
19382
+ }
19383
+ return null;
19384
+ }
19385
+ async function runProbeBox(deps, slot, targets, targetTimeoutMs = TARGET_TIMEOUT_MS) {
19386
+ const id = randomBytes3(6).toString("hex");
19387
+ const name = `archme-probe-${id}`;
19388
+ const input = { kind: "probe", targets, timeoutMs: targetTimeoutMs };
19389
+ let cleanup = null;
19390
+ let timer;
19391
+ try {
19392
+ const dir = await (deps.writeInputDir ?? writeInputDir)("probe", input);
19393
+ cleanup = dir.cleanup;
19394
+ const handle = await deps.runtime.runStep({
19395
+ name,
19396
+ image: deps.image,
19397
+ args: ["probe"],
19398
+ network: slot,
19399
+ env: {},
19400
+ inputDir: dir.dir,
19401
+ limits: { cpus: 1, memoryMiB: 256 },
19402
+ labels: { [JOB_LABEL]: `probe-${id}`, [SLOT_LABEL]: String(slot.index) }
19403
+ });
19404
+ const timeout = new Promise((r) => {
19405
+ timer = setTimeout(() => r("timeout"), deps.boxTimeoutMs ?? 6e4);
19406
+ });
19407
+ const exit = await Promise.race([handle.done, timeout]);
19408
+ if (exit === "timeout") return { outcomes: {}, error: "probe box timed out" };
19409
+ const parsed = (deps.parseResultLine ?? parseResultLine)(exit.stdout, exit.nonce);
19410
+ if (!parsed.ok || parsed.result.kind !== "probe") {
19411
+ return { outcomes: {}, error: `probe result: ${parsed.ok ? `unexpected kind ${parsed.result.kind}` : parsed.reason}` };
18930
19412
  }
19413
+ return { outcomes: parsed.result.outcomes, error: parsed.result.error ? `probe box: ${parsed.result.error}` : null };
19414
+ } catch (e) {
19415
+ return { outcomes: {}, error: `probe box failed: ${e.message}` };
19416
+ } finally {
19417
+ clearTimeout(timer);
19418
+ await deps.runtime.kill(name).catch((e) => deps.log?.(`kill ${name} failed: ${e.message}`));
19419
+ await cleanup?.();
18931
19420
  }
18932
- const report = { boxes: boxes.sort(), volumes, ms: now() - t0 };
18933
- if (failures.length > 0) throw new SweepError(report, failures);
18934
- return report;
18935
19421
  }
18936
- var JOB_VOLUME_PREFIX, SweepError, isJobBox, isJobVolume;
18937
- var init_sweep = __esm({
18938
- "src/daemon/sandbox/sweep.ts"() {
19422
+ async function runConfinementProbe(deps, slot, lanIp) {
19423
+ const now = deps.now ?? Date.now;
19424
+ const started = now();
19425
+ const targets = probeTargets(slot, lanIp);
19426
+ const { outcomes, error } = await runProbeBox(deps, slot, targets);
19427
+ const j = judgeProbe(outcomes, targets);
19428
+ const failures = error ? [error, ...Object.keys(outcomes).length ? j.failures : []] : j.failures;
19429
+ return { ok: !error && j.ok, slot: slot.index, failures, outcomes, ms: now() - started };
19430
+ }
19431
+ function formatConfinementReport(r) {
19432
+ return r.ok ? `confinement probe passed on slot ${r.slot} (${r.ms}ms)` : `confinement probe failed on slot ${r.slot}: ${r.failures.join("; ")}`;
19433
+ }
19434
+ var PUBLIC_PROBE_IP, HOST_SERVICE_PORTS, LAN_PORTS, TARGET_TIMEOUT_MS, CONFINEMENT_PROBE_INTERVAL_MS, CONFINEMENT_RETRY_MS, realTimers, ConfinementMonitor;
19435
+ var init_confinement = __esm({
19436
+ "src/daemon/sandbox/confinement.ts"() {
18939
19437
  "use strict";
19438
+ init_result2();
19439
+ init_inputDir();
19440
+ init_slotMap();
18940
19441
  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";
19442
+ PUBLIC_PROBE_IP = "1.1.1.1";
19443
+ HOST_SERVICE_PORTS = [22, 88, 5e3, 7e3];
19444
+ LAN_PORTS = [22, 5e3];
19445
+ TARGET_TIMEOUT_MS = 2e3;
19446
+ CONFINEMENT_PROBE_INTERVAL_MS = 60 * 60 * 1e3;
19447
+ CONFINEMENT_RETRY_MS = 5 * 60 * 1e3;
19448
+ realTimers = {
19449
+ setTimeout: (fn, ms) => {
19450
+ const t = setTimeout(fn, ms);
19451
+ t.unref?.();
19452
+ return t;
19453
+ },
19454
+ clearTimeout: (h2) => clearTimeout(h2)
19455
+ };
19456
+ ConfinementMonitor = class {
19457
+ constructor(opts) {
19458
+ this.opts = opts;
19459
+ this.timers = opts.timers ?? realTimers;
19460
+ }
19461
+ timers;
19462
+ handle = null;
19463
+ failing = false;
19464
+ probing = false;
19465
+ stopped = false;
19466
+ /** The boot probe already ran; the next is an hour out. */
19467
+ start() {
19468
+ this.stopped = false;
19469
+ this.schedule(CONFINEMENT_PROBE_INTERVAL_MS);
19470
+ }
19471
+ stop() {
19472
+ this.stopped = true;
19473
+ if (this.handle !== null) this.timers.clearTimeout(this.handle);
19474
+ this.handle = null;
19475
+ }
19476
+ allowsClaims() {
19477
+ return this.opts.mode !== "required" || !this.failing && !this.probing;
19478
+ }
19479
+ schedule(ms) {
19480
+ if (this.stopped) return;
19481
+ if (this.handle !== null) this.timers.clearTimeout(this.handle);
19482
+ this.handle = this.timers.setTimeout(() => {
19483
+ this.handle = null;
19484
+ void this.runOnce();
19485
+ }, ms);
19486
+ }
19487
+ async runOnce() {
19488
+ const slot = this.opts.pool.lease();
19489
+ if (!slot) {
19490
+ this.opts.log("confinement probe: no free slot, retrying in 5 minutes");
19491
+ return this.schedule(CONFINEMENT_RETRY_MS);
19492
+ }
19493
+ this.probing = true;
19494
+ let report;
19495
+ try {
19496
+ report = await this.opts.probe(slot);
19497
+ } catch (e) {
19498
+ report = { ok: false, slot: slot.index, failures: [`probe threw: ${e.message}`], outcomes: {}, ms: 0 };
19499
+ } finally {
19500
+ this.opts.pool.free(slot.index);
19501
+ this.probing = false;
19502
+ }
19503
+ this.opts.log(formatConfinementReport(report));
19504
+ if (report.ok) {
19505
+ if (this.failing) this.opts.log("confinement holds again; claiming resumes");
19506
+ this.failing = false;
19507
+ return this.schedule(CONFINEMENT_PROBE_INTERVAL_MS);
19508
+ }
19509
+ if (this.opts.mode === "required") {
19510
+ if (!this.failing) this.opts.log("ARCHME_SANDBOX=required: not claiming jobs until confinement holds (re-probing every 5 minutes)");
19511
+ this.failing = true;
19512
+ return this.schedule(CONFINEMENT_RETRY_MS);
19513
+ }
19514
+ this.schedule(CONFINEMENT_PROBE_INTERVAL_MS);
18948
19515
  }
18949
19516
  };
18950
- isJobBox = (b) => JOB_LABEL in b.labels;
18951
- isJobVolume = (v) => v.name.startsWith(JOB_VOLUME_PREFIX) || JOB_LABEL in v.labels;
18952
19517
  }
18953
19518
  });
18954
19519
 
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)}`);
19520
+ // src/daemon/sandbox/claudeToken.ts
19521
+ import { constants as fsConstants } from "node:fs";
19522
+ import { chmod as chmod5, mkdir as mkdir11, open as open3 } from "node:fs/promises";
19523
+ import { dirname as dirname15 } from "node:path";
19524
+ function assertTokenShape(raw, where) {
19525
+ const token = raw.trim();
19526
+ if (!token) throw new SandboxBootError(`sandbox Claude token ${where} is empty`);
19527
+ if (!/^[\x21-\x7e]+$/.test(token)) {
19528
+ throw new SandboxBootError(`sandbox Claude token ${where} contains whitespace or control characters; save it as one line`);
18965
19529
  }
19530
+ return token;
18966
19531
  }
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}`);
19532
+ async function readClaudeTokenFile(path7) {
19533
+ let fh;
19534
+ try {
19535
+ fh = await open3(path7, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
19536
+ } catch (e) {
19537
+ throw new SandboxBootError(`cannot read the sandbox Claude token at ${path7}: ${e.message}`);
18973
19538
  }
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}`);
19539
+ try {
19540
+ const st = await fh.stat();
19541
+ if (!st.isFile()) throw new SandboxBootError(`sandbox Claude token ${path7} is not a regular file`);
19542
+ if ((st.mode & 63) !== 0) {
19543
+ throw new SandboxBootError(
19544
+ `sandbox Claude token ${path7} is readable by group or others (mode ${(st.mode & 511).toString(8)}); chmod 600 it`
19545
+ );
19546
+ }
19547
+ return assertTokenShape(await fh.readFile("utf8"), path7);
19548
+ } finally {
19549
+ await fh.close();
18985
19550
  }
18986
19551
  }
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}`);
19552
+ async function readSandboxClaudeToken(settings, deps = {}) {
19553
+ if (settings.claudeTokenOverride) {
19554
+ return { token: await readClaudeTokenFile(settings.claudeTokenPath), source: "file-override" };
18993
19555
  }
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}`);
19556
+ const keytar = await (deps.loadKeytar ?? loadKeytar)();
19557
+ const stored = keytar ? await keytar.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).catch(() => null) : null;
19558
+ if (stored) return { token: assertTokenShape(stored, "in the keychain"), source: "keychain" };
19559
+ try {
19560
+ return { token: await readClaudeTokenFile(settings.claudeTokenPath), source: "file" };
19561
+ } catch (e) {
19562
+ throw new SandboxBootError(
19563
+ `${e.message}; no token in the keychain either \u2014 run \`archme daemon sandbox setup\` to store one`
19564
+ );
18999
19565
  }
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
19566
  }
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}`);
19567
+ async function storeSandboxClaudeToken(raw, opts) {
19568
+ const token = assertTokenShape(raw, "(entered)");
19569
+ let note;
19570
+ const keytar = opts.override ? null : await (opts.loadKeytar ?? loadKeytar)();
19571
+ if (opts.override) {
19572
+ note = `ARCHME_SANDBOX_CLAUDE_TOKEN_FILE is set: ${opts.path}`;
19573
+ } else if (keytar) {
19574
+ try {
19575
+ await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, token);
19576
+ return { source: "keychain" };
19577
+ } catch (e) {
19578
+ note = `the keychain refused it (${e.message})`;
19579
+ }
19580
+ } else {
19581
+ note = "no keychain access on this machine";
19018
19582
  }
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
- });
19583
+ await mkdir11(dirname15(opts.path), { recursive: true, mode: 448 });
19584
+ const fh = await open3(
19585
+ opts.path,
19586
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW,
19587
+ 384
19588
+ );
19589
+ try {
19590
+ await fh.writeFile(`${token}
19591
+ `);
19592
+ } finally {
19593
+ await fh.close();
19594
+ }
19595
+ await chmod5(opts.path, 384);
19596
+ return { source: "file", note };
19137
19597
  }
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
- }];
19598
+ var SandboxBootError, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT;
19599
+ var init_claudeToken = __esm({
19600
+ "src/daemon/sandbox/claudeToken.ts"() {
19601
+ "use strict";
19602
+ init_credentials();
19603
+ SandboxBootError = class extends Error {
19604
+ constructor(message2) {
19605
+ super(message2);
19606
+ this.name = "SandboxBootError";
19607
+ }
19608
+ };
19609
+ KEYCHAIN_SERVICE = "archme-daemon";
19610
+ KEYCHAIN_ACCOUNT = "sandbox-claude-token";
19611
+ }
19612
+ });
19613
+
19614
+ // src/daemon/sandbox/host.ts
19615
+ import { networkInterfaces } from "node:os";
19616
+ function sandboxBootFailurePolicy(mode) {
19617
+ return mode === "shadow" ? "run_unboxed" : "refuse";
19618
+ }
19619
+ function shutdownKillTargets(boxedJobs2, registry) {
19620
+ return [...boxedJobs2].filter((id) => registry.hasLiveBoxes(id));
19621
+ }
19622
+ async function bootSandbox(settings, parts, deps) {
19623
+ const sweep = await parts.runtime.sweep();
19624
+ const inputDirs = await (deps.sweepInputDirs ?? sweepInputDirs)();
19625
+ deps.log(
19626
+ ` sandbox: swept ${sweep.boxes.length} box(es), ${sweep.volumes.length} volume(s) in ${sweep.ms}ms, ${inputDirs} input dir(s)`
19627
+ );
19628
+ const slots = await parts.runtime.ensureSlots(settings.slots);
19629
+ const pool = parts.makePool(slots);
19630
+ const { token: claudeToken, source } = await (deps.readClaudeToken ?? ((s) => readSandboxClaudeToken(s)))(settings);
19631
+ deps.log(` sandbox: Claude token from ${source === "keychain" ? "the macOS keychain" : source === "file-override" ? `ARCHME_SANDBOX_CLAUDE_TOKEN_FILE (${settings.claudeTokenPath})` : settings.claudeTokenPath}`);
19632
+ const allSlots = Array.from({ length: MAX_SLOTS }, (_, i2) => slotFor(i2));
19633
+ const gates = await parts.startGates(allSlots, {
19634
+ mcpBaseUrl: deps.mcpBaseUrl,
19635
+ claudeToken,
19636
+ remint: deps.remint,
19637
+ log: deps.log
19150
19638
  });
19639
+ let preflight;
19640
+ try {
19641
+ preflight = await parts.runtime.preflight();
19642
+ } catch (e) {
19643
+ await gates.close().catch(() => {
19644
+ });
19645
+ throw e;
19646
+ }
19647
+ if (!preflight.ok) {
19648
+ await gates.close().catch(() => {
19649
+ });
19650
+ throw new SandboxBootError(`sandbox preflight failed: ${failedChecks(preflight)}`);
19651
+ }
19652
+ const probe = deps.probe ?? ((slot) => runConfinementProbe({ runtime: parts.runtime, image: settings.image, log: deps.log }, slot, primaryLanIpv4(networkInterfaces())));
19653
+ let report;
19654
+ try {
19655
+ report = await probe(slots[0]);
19656
+ } catch (e) {
19657
+ report = { ok: false, slot: 0, failures: [`probe threw: ${e.message}`], outcomes: {}, ms: 0 };
19658
+ }
19659
+ preflight = {
19660
+ ok: preflight.ok && report.ok,
19661
+ checks: [...preflight.checks, { name: "confinement_probe", ok: report.ok, detail: report.ok ? void 0 : report.failures.join("; ") }]
19662
+ };
19663
+ deps.log(` sandbox: ${formatConfinementReport(report)}`);
19664
+ if (!report.ok) {
19665
+ if (settings.mode === "required") {
19666
+ await gates.close().catch(() => {
19667
+ });
19668
+ throw new SandboxBootError(`sandbox preflight failed: ${failedChecks(preflight)}`);
19669
+ }
19670
+ deps.log(" sandbox: shadow mode boots anyway; run `archme daemon sandbox setup` to install the confinement");
19671
+ }
19672
+ const monitor = new ConfinementMonitor({ mode: settings.mode, probe, pool, log: deps.log });
19673
+ return {
19674
+ host: { settings, runtime: parts.runtime, registry: parts.registry, gates, pool, io: parts.io },
19675
+ sweep,
19676
+ preflight,
19677
+ monitor
19678
+ };
19151
19679
  }
19152
- function isBoxNotFound(stderr) {
19153
- return /notFound: "container with ID .* not found"/.test(stderr);
19680
+ function failedChecks(p) {
19681
+ return p.checks.filter((c3) => !c3.ok).map((c3) => `${c3.name}${c3.detail ? ` (${c3.detail})` : ""}`).join(", ") || "unknown";
19154
19682
  }
19155
- function isBoxStopped(stderr) {
19156
- return /invalidState: "no runtime client exists: container is stopped"/.test(stderr);
19683
+ var init_host = __esm({
19684
+ "src/daemon/sandbox/host.ts"() {
19685
+ "use strict";
19686
+ init_confinement();
19687
+ init_appleContainer();
19688
+ init_claudeToken();
19689
+ init_inputDir();
19690
+ init_types();
19691
+ init_claudeToken();
19692
+ }
19693
+ });
19694
+
19695
+ // src/daemon/sandbox/runSandboxedJob.ts
19696
+ function sandboxJobOf(job) {
19697
+ return {
19698
+ id: job.id,
19699
+ project_id: job.project_id,
19700
+ repo_owner: job.repo_owner,
19701
+ repo_name: job.repo_name,
19702
+ repo_branch: job.repo_branch,
19703
+ archme_branch: job.archme_branch,
19704
+ before_sha: job.before_sha,
19705
+ after_sha: job.after_sha,
19706
+ forced: job.forced,
19707
+ pr_number: job.pr_number ?? null,
19708
+ source_mode: job.source_mode,
19709
+ commit_message: job.commit_message ?? null,
19710
+ head_commit_timestamp: job.head_commit_timestamp ?? null,
19711
+ author_name: job.author_name ?? null,
19712
+ author_login: job.author_login ?? null
19713
+ };
19157
19714
  }
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
19715
+ async function runSandboxedJob(deps, job, opts) {
19716
+ const { slot } = opts;
19717
+ const t = { ...DEFAULT_SANDBOX_TIMEOUTS, ...opts.timeouts };
19718
+ const pollMs = opts.pollMs ?? 1e3;
19719
+ const now = deps.now ?? Date.now;
19720
+ const { settings } = deps;
19721
+ const volume = `archme-job-${job.id}`;
19722
+ const labels = { [JOB_LABEL]: job.id, [SLOT_LABEL]: String(slot.index) };
19723
+ const sjob = sandboxJobOf(job);
19724
+ const sandbox = {
19725
+ slot: slot.index,
19726
+ memory_peak_bytes: { clone: null, agent: null },
19727
+ model_requests: 0,
19728
+ upstream_429: 0,
19729
+ last_upstream_status: null,
19730
+ cap_hits: 0,
19731
+ mcp_requests: 0,
19732
+ remints: 0,
19733
+ egress_denied: 0,
19734
+ first_egress_denied: null,
19735
+ clone_ms: null,
19736
+ box_ms: 0
19737
+ };
19738
+ const durations = {};
19739
+ const metrics = { sandbox, durations_ms: durations };
19740
+ const liveBoxes = /* @__PURE__ */ new Set();
19741
+ const inputCleanups = [];
19742
+ let volumeTouched = false;
19743
+ let agentStarted = false;
19744
+ const readCounters = () => {
19745
+ const c3 = deps.gates.countersFor(slot);
19746
+ if (!c3) return;
19747
+ sandbox.model_requests = c3.model_requests;
19748
+ sandbox.upstream_429 = c3.upstream_429;
19749
+ sandbox.last_upstream_status = c3.last_upstream_status;
19750
+ sandbox.cap_hits = c3.cap_hits;
19751
+ sandbox.mcp_requests = c3.mcp_requests;
19752
+ sandbox.remints = c3.remints;
19753
+ sandbox.egress_denied = c3.egress_denied;
19754
+ sandbox.first_egress_denied = c3.first_egress_denied;
19755
+ };
19756
+ const failed = (step, reason, stderr = "") => {
19757
+ deps.log(`sandbox ${step} step failed: ${reason}`);
19758
+ return { kind: "step_failed", step, reason, stderr, metrics };
19759
+ };
19760
+ const currentKillCause = () => {
19761
+ if (deps.isCancelled(job.id)) return "cancel";
19762
+ if (deps.killReason(job.id) === "shutdown") return "shutdown";
19763
+ if (deps.isLeaseLost(job.id)) return "lease_lost";
19764
+ return null;
19765
+ };
19766
+ const writeInput = async (step, input) => {
19767
+ const w = await deps.writeInputDir(step, input);
19768
+ let done = false;
19769
+ const cleanup = async () => {
19770
+ if (done) return;
19771
+ done = true;
19772
+ await w.cleanup().catch(() => {
19773
+ });
19167
19774
  };
19775
+ inputCleanups.push(cleanup);
19776
+ return { dir: w.dir, cleanup };
19168
19777
  };
19169
- }
19170
- function defaultSpawn(bin = "container") {
19171
- return (args) => {
19172
- const p = execa(bin, args, { reject: false, stdin: "pipe", buffer: false });
19778
+ const runBox = async (spec2, timeoutMs) => {
19779
+ if (deps.isCancelled(job.id)) return { kind: "refused", reason: "cancel" };
19780
+ if (!await deps.registry.beginStep(job.id, spec2.name)) return { kind: "refused", reason: "shutting_down" };
19781
+ liveBoxes.add(spec2.name);
19782
+ const started = now();
19783
+ let killCause = null;
19784
+ const kill = (cause) => {
19785
+ if (killCause) return;
19786
+ killCause = cause;
19787
+ deps.log(`killing ${spec2.name} (${cause})`);
19788
+ if (cause === "lease_lost") deps.gates.clearJob(slot);
19789
+ void deps.runtime.kill(spec2.name).catch((e) => deps.log(`kill ${spec2.name} failed: ${e.message}`));
19790
+ };
19791
+ if (spec2.args[0] === "agent") agentStarted = true;
19792
+ const handle = await deps.runtime.runStep(spec2);
19793
+ const timer = setTimeout(() => kill("timeout"), timeoutMs);
19794
+ const poll = setInterval(() => {
19795
+ const c3 = currentKillCause();
19796
+ if (c3) kill(c3);
19797
+ }, pollMs);
19798
+ try {
19799
+ const exit = await handle.done;
19800
+ liveBoxes.delete(spec2.name);
19801
+ deps.registry.endStep(job.id, spec2.name);
19802
+ if (!killCause && exit.killed) killCause = currentKillCause() ?? "shutdown";
19803
+ return { kind: "ran", exit, killCause, ms: now() - started };
19804
+ } finally {
19805
+ clearTimeout(timer);
19806
+ clearInterval(poll);
19807
+ }
19808
+ };
19809
+ const spec = (step, env, inputDir) => ({
19810
+ name: `archme-job-${job.id}-${step}`,
19811
+ image: settings.image,
19812
+ args: [step],
19813
+ network: slot,
19814
+ env,
19815
+ inputDir,
19816
+ volume,
19817
+ limits: { cpus: settings.boxCpus, memoryMiB: settings.boxMemoryMiB },
19818
+ labels: { ...labels }
19819
+ });
19820
+ const runBoxes = async (pre) => {
19821
+ deps.registry.setVolume(job.id, volume);
19822
+ volumeTouched = true;
19823
+ await deps.runtime.createVolume(volume, settings.volumeGiB, labels);
19824
+ await deps.runtime.initVolume(volume, BOX_UID, labels, settings.image, slot);
19825
+ deps.gates.bindJob(slot, {
19826
+ jobId: job.id,
19827
+ maxModelRequests: settings.maxModelRequests,
19828
+ mcp: {
19829
+ token: pre.jobToken.token,
19830
+ expiresAt: pre.jobToken.expires_at ? Date.parse(pre.jobToken.expires_at) || null : null,
19831
+ projectSlug: pre.projectSlug,
19832
+ branch: job.archme_branch
19833
+ }
19834
+ });
19835
+ const cloneIn = await writeInput("clone", {
19836
+ kind: "clone",
19837
+ job: sjob,
19838
+ marker: pre.marker,
19839
+ credentials: {
19840
+ https_url: pre.credentials.https_url,
19841
+ username: pre.credentials.username,
19842
+ password: pre.credentials.password
19843
+ }
19844
+ });
19845
+ deps.gates.setStep(slot, "clone");
19846
+ const clone = await runBox(spec("clone", deps.cloneBoxEnv(slot.gateway), cloneIn.dir), t.cloneMs);
19847
+ await cloneIn.cleanup();
19848
+ if (clone.kind === "refused") return failed("clone", clone.reason);
19849
+ sandbox.clone_ms = clone.ms;
19850
+ sandbox.box_ms += clone.ms;
19851
+ durations.sandbox_clone = clone.ms;
19852
+ if (clone.killCause) return failed("clone", clone.killCause, clone.exit.stderr);
19853
+ const cp = deps.parseResultLine(clone.exit.stdout, clone.exit.nonce);
19854
+ if (!cp.ok || cp.result.kind !== "clone") {
19855
+ deps.log(`clone result line: ${cp.ok ? `unexpected kind ${cp.result.kind}` : cp.reason}`);
19856
+ return failed("clone", "result_malformed", clone.exit.stderr);
19857
+ }
19858
+ const cr = cp.result;
19859
+ sandbox.memory_peak_bytes.clone = cr.memoryPeakBytes;
19860
+ for (const [k, v] of Object.entries(cr.durations_ms ?? {})) durations[`clone_${k}`] = v;
19861
+ if (cr.outcome === "skip") return { kind: "skip", metrics };
19862
+ if (cr.outcome === "rejected") return { kind: "rejected", message: cr.error ?? "job rejected by the clone step", metrics };
19863
+ if (cr.error || clone.exit.exitCode !== 0 || !cr.diff) {
19864
+ return failed("clone", cr.error ?? `clone step exited ${clone.exit.exitCode}`, clone.exit.stderr);
19865
+ }
19866
+ Object.assign(metrics, {
19867
+ mode: job.source_mode === "pr_update" ? "pr_update" : "diff",
19868
+ files: cr.diff.files,
19869
+ bytes: cr.diff.bytes,
19870
+ materialized: cr.diff.materialized,
19871
+ range: cr.diff.rangeLabel,
19872
+ model: pre.model
19873
+ });
19874
+ const claudeMs = cr.diff.materialized ? t.largeDiffClaudeMs : t.claudeMs;
19875
+ const agentIn = await writeInput("agent", {
19876
+ kind: "agent",
19877
+ job: sjob,
19878
+ projectSlug: pre.projectSlug,
19879
+ seedArch: pre.seedArch,
19880
+ repoScope: pre.repoScope,
19881
+ repoContainer: pre.repoContainer,
19882
+ bugsFolderId: pre.bugsFolderId,
19883
+ model: pre.model,
19884
+ timeoutMs: claudeMs,
19885
+ gateway: slot.gateway
19886
+ });
19887
+ deps.gates.setStep(slot, "agent");
19888
+ const agent = await runBox(spec("agent", deps.agentBoxEnv(slot.gateway), agentIn.dir), claudeMs + t.graceMs);
19889
+ await agentIn.cleanup();
19890
+ if (agent.kind === "refused") return failed("agent", agent.reason);
19891
+ sandbox.box_ms += agent.ms;
19892
+ durations.sandbox_agent = agent.ms;
19893
+ readCounters();
19894
+ if (agent.killCause) return failed("agent", agent.killCause, agent.exit.stderr);
19895
+ const ap = deps.parseResultLine(agent.exit.stdout, agent.exit.nonce);
19896
+ if (!ap.ok || ap.result.kind !== "agent") {
19897
+ deps.log(`agent result line: ${ap.ok ? `unexpected kind ${ap.result.kind}` : ap.reason}`);
19898
+ return failed("agent", "result_malformed", agent.exit.stderr);
19899
+ }
19900
+ const ar = ap.result;
19901
+ sandbox.memory_peak_bytes.agent = ar.memoryPeakBytes;
19902
+ for (const [k, v] of Object.entries(ar.durations_ms ?? {})) durations[`agent_${k}`] = v;
19903
+ if (ar.seed) metrics.seed = ar.seed;
19904
+ if (ar.error) return failed("agent", ar.error, agent.exit.stderr);
19173
19905
  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
- }))
19906
+ kind: "claude",
19907
+ exitCode: ar.exitCode,
19908
+ signal: ar.signal,
19909
+ claudeStdout: ar.claudeStdout,
19910
+ stderr: agent.exit.stderr,
19911
+ affected: ar.affected,
19912
+ metrics
19182
19913
  };
19183
19914
  };
19915
+ try {
19916
+ const pre = await deps.prefetch(job);
19917
+ try {
19918
+ return await runBoxes(pre);
19919
+ } catch (e) {
19920
+ if (e instanceof SandboxInfraError) throw e;
19921
+ throw new SandboxInfraError(e instanceof Error ? e.message : String(e), agentStarted);
19922
+ }
19923
+ } finally {
19924
+ const step = async (what, fn) => {
19925
+ try {
19926
+ await fn();
19927
+ } catch (e) {
19928
+ deps.log(`sandbox cleanup (${what}) failed: ${e.message}`);
19929
+ }
19930
+ };
19931
+ for (const name of liveBoxes) await step(`kill ${name}`, () => deps.runtime.kill(name));
19932
+ if (volumeTouched) await step("remove volume", () => deps.runtime.removeVolume(volume));
19933
+ await step("counters", readCounters);
19934
+ await step("clear gates", () => deps.gates.clearJob(slot));
19935
+ await step("free slot", () => deps.pool.free(slot.index));
19936
+ for (const c3 of inputCleanups) await step("input dir", c3);
19937
+ await step("release", () => deps.registry.release(job.id));
19938
+ }
19184
19939
  }
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"() {
19940
+ var DEFAULT_SANDBOX_TIMEOUTS, SandboxInfraError;
19941
+ var init_runSandboxedJob = __esm({
19942
+ "src/daemon/sandbox/runSandboxedJob.ts"() {
19188
19943
  "use strict";
19189
- init_execa();
19190
- init_sweep();
19191
19944
  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);
19945
+ DEFAULT_SANDBOX_TIMEOUTS = {
19946
+ claudeMs: 30 * 60 * 1e3,
19947
+ largeDiffClaudeMs: 45 * 60 * 1e3,
19948
+ graceMs: 2 * 60 * 1e3,
19949
+ cloneMs: 10 * 60 * 1e3
19950
+ };
19951
+ SandboxInfraError = class extends Error {
19952
+ constructor(message2, agentStarted) {
19953
+ super(message2);
19954
+ this.agentStarted = agentStarted;
19955
+ this.name = "SandboxInfraError";
19401
19956
  }
19402
- sweep() {
19403
- return sweepJobs(this);
19957
+ };
19958
+ }
19959
+ });
19960
+
19961
+ // src/daemon/sandbox/classifySandboxExit.ts
19962
+ function classifySandboxExit(input) {
19963
+ const { counters, exitCode, stderr, stepFailure } = input;
19964
+ if ((counters?.cap_hits ?? 0) > 0 || stderr.includes(SANDBOX_CAP_BODY)) {
19965
+ return {
19966
+ kind: "terminal",
19967
+ reason: SANDBOX_REQUEST_CAP_REASON,
19968
+ message: `${SANDBOX_REQUEST_CAP_REASON}: the job exceeded the sandbox model-request cap` + (counters ? ` (${counters.model_requests} requests, ${counters.cap_hits} refused)` : "")
19969
+ };
19970
+ }
19971
+ if (stepFailure == null && exitCode !== null && exitCode !== 0 && counters?.last_upstream_status === 429) {
19972
+ return { kind: "upstream_rate_limit" };
19973
+ }
19974
+ return { kind: "normal" };
19975
+ }
19976
+ function routeSandboxFailure(verdict, planFor, nowMs = Date.now()) {
19977
+ if (verdict?.kind === "terminal") return { kind: "terminal", message: verdict.message };
19978
+ const plan = planFor();
19979
+ if (plan) return { kind: "defer", plan };
19980
+ if (verdict?.kind === "upstream_rate_limit") {
19981
+ return {
19982
+ kind: "defer",
19983
+ plan: { parsed: false, pauseUntil: new Date(nowMs + RATE_LIMIT_TENTATIVE_PAUSE_MS), info: null }
19984
+ };
19985
+ }
19986
+ return { kind: "fail" };
19987
+ }
19988
+ var SANDBOX_REQUEST_CAP_REASON, SandboxExitError;
19989
+ var init_classifySandboxExit = __esm({
19990
+ "src/daemon/sandbox/classifySandboxExit.ts"() {
19991
+ "use strict";
19992
+ init_rateLimit();
19993
+ init_types();
19994
+ SANDBOX_REQUEST_CAP_REASON = "sandbox_request_cap";
19995
+ SandboxExitError = class extends Error {
19996
+ constructor(message2, verdict) {
19997
+ super(message2);
19998
+ this.verdict = verdict;
19999
+ this.name = "SandboxExitError";
19404
20000
  }
19405
20001
  };
19406
20002
  }
@@ -19540,6 +20136,47 @@ function createGateServer(opts) {
19540
20136
  fail2(e);
19541
20137
  }
19542
20138
  });
20139
+ if (opts.connect) {
20140
+ const connect = opts.connect;
20141
+ server.on("connect", (req, socket, head) => {
20142
+ const target = (req.url ?? "").slice(0, 300);
20143
+ const slot = opts.slotMap.resolve(req.socket.localAddress, req.socket.remoteAddress);
20144
+ socket.on("error", () => {
20145
+ });
20146
+ if (!slot) {
20147
+ log(`[${opts.name}] CONNECT ${target} drop job=-`);
20148
+ socket.destroy();
20149
+ return;
20150
+ }
20151
+ const binding = opts.slotMap.binding(slot.index);
20152
+ if (!binding) {
20153
+ log(`[${opts.name}] CONNECT ${target} 403 job=- slot=${slot.index} idle`);
20154
+ socket.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
20155
+ return;
20156
+ }
20157
+ let logged = false;
20158
+ const ctx = {
20159
+ slot,
20160
+ binding,
20161
+ path: target,
20162
+ done(status, note) {
20163
+ if (logged) return;
20164
+ logged = true;
20165
+ log(`[${opts.name}] CONNECT ${target} ${status} job=${binding.jobId}${note ? ` ${note}` : ""}`);
20166
+ }
20167
+ };
20168
+ const fail2 = (e) => {
20169
+ ctx.done(502, `handler error: ${e?.name ?? "error"}`);
20170
+ if (socket.writable) socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
20171
+ else socket.destroy();
20172
+ };
20173
+ try {
20174
+ Promise.resolve(connect(req, socket, head, ctx)).catch(fail2);
20175
+ } catch (e) {
20176
+ fail2(e);
20177
+ }
20178
+ });
20179
+ }
19543
20180
  server.requestTimeout = 0;
19544
20181
  server.headersTimeout = 6e4;
19545
20182
  return {
@@ -19706,12 +20343,121 @@ var init_credentialGate = __esm({
19706
20343
  }
19707
20344
  });
19708
20345
 
20346
+ // src/daemon/sandbox/githubGate.ts
20347
+ import net from "node:net";
20348
+ function parseConnectTarget(authority) {
20349
+ const i2 = authority.lastIndexOf(":");
20350
+ if (i2 <= 0) return { ok: false };
20351
+ const portStr = authority.slice(i2 + 1);
20352
+ let host = authority.slice(0, i2).toLowerCase();
20353
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
20354
+ if (host.endsWith(".")) host = host.slice(0, -1);
20355
+ if (portStr !== "443" || net.isIP(host) !== 0 || !GITHUB_ALLOWED_HOSTS.has(host)) return { ok: false };
20356
+ return { ok: true, host, port: 443 };
20357
+ }
20358
+ var GITHUB_ALLOWED_HOSTS, FORBIDDEN, BAD_GATEWAY, GithubGate;
20359
+ var init_githubGate = __esm({
20360
+ "src/daemon/sandbox/githubGate.ts"() {
20361
+ "use strict";
20362
+ init_gateServer();
20363
+ init_types();
20364
+ GITHUB_ALLOWED_HOSTS = /* @__PURE__ */ new Set(["github.com", "codeload.github.com"]);
20365
+ FORBIDDEN = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
20366
+ BAD_GATEWAY = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n";
20367
+ GithubGate = class {
20368
+ gate;
20369
+ dial;
20370
+ /** Open tunnels per slot: a clone-step tunnel must not outlive the clone step. */
20371
+ tunnels = /* @__PURE__ */ new Map();
20372
+ constructor(opts) {
20373
+ this.dial = opts.dial ?? ((host, port) => net.connect({ host, port }));
20374
+ this.gate = createGateServer({
20375
+ name: "github-gate",
20376
+ slotMap: opts.slotMap,
20377
+ host: opts.host,
20378
+ port: opts.port ?? GATE_PORTS.github,
20379
+ logger: opts.logger,
20380
+ handler: (_req, res, ctx) => {
20381
+ ctx.done(404);
20382
+ res.writeHead(404).end();
20383
+ },
20384
+ connect: (req, socket, head, ctx) => this.tunnel(req, socket, head, ctx)
20385
+ });
20386
+ }
20387
+ listen() {
20388
+ return this.gate.listen();
20389
+ }
20390
+ async close() {
20391
+ for (const index of [...this.tunnels.keys()]) this.closeTunnels(index);
20392
+ await this.gate.close();
20393
+ }
20394
+ /** The slot's step moved (clone → agent): its tunnels go. */
20395
+ onStepChange(index) {
20396
+ this.closeTunnels(index);
20397
+ }
20398
+ onClear(index) {
20399
+ this.closeTunnels(index);
20400
+ }
20401
+ closeTunnels(index) {
20402
+ const set = this.tunnels.get(index);
20403
+ if (!set) return;
20404
+ this.tunnels.delete(index);
20405
+ for (const destroy of set) destroy();
20406
+ }
20407
+ tunnel(_req, socket, head, ctx) {
20408
+ const { counters } = ctx.binding;
20409
+ const refuse = (note) => {
20410
+ counters.egress_denied++;
20411
+ counters.first_egress_denied ??= ctx.path;
20412
+ ctx.done(403, note);
20413
+ socket.end(FORBIDDEN);
20414
+ };
20415
+ if (ctx.binding.step !== "clone") return refuse(`step=${ctx.binding.step ?? "none"}`);
20416
+ const target = parseConnectTarget(ctx.path);
20417
+ if (!target.ok) return refuse("not allowed");
20418
+ const upstream = this.dial(target.host, target.port);
20419
+ let set = this.tunnels.get(ctx.slot.index);
20420
+ if (!set) this.tunnels.set(ctx.slot.index, set = /* @__PURE__ */ new Set());
20421
+ const destroy = () => {
20422
+ upstream.destroy();
20423
+ socket.destroy();
20424
+ };
20425
+ set.add(destroy);
20426
+ let open4 = false;
20427
+ const cleanup = () => {
20428
+ set.delete(destroy);
20429
+ destroy();
20430
+ };
20431
+ upstream.once("connect", () => {
20432
+ open4 = true;
20433
+ ctx.done(200);
20434
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
20435
+ if (head.length) upstream.write(head);
20436
+ upstream.pipe(socket);
20437
+ socket.pipe(upstream);
20438
+ });
20439
+ upstream.on("error", () => {
20440
+ if (open4) return;
20441
+ ctx.done(502, "dial failed");
20442
+ set.delete(destroy);
20443
+ socket.end(BAD_GATEWAY);
20444
+ });
20445
+ upstream.on("close", () => {
20446
+ if (open4) cleanup();
20447
+ });
20448
+ socket.on("close", cleanup);
20449
+ socket.on("error", cleanup);
20450
+ }
20451
+ };
20452
+ }
20453
+ });
20454
+
19709
20455
  // src/daemon/sandbox/mcpGate.ts
19710
20456
  function redact(err4) {
19711
20457
  const msg = err4 instanceof Error ? err4.message : "error";
19712
20458
  return msg.replace(/\b(amj_|am_|sk-ant-|eyJ)[A-Za-z0-9._-]*/g, "[redacted]").slice(0, 200);
19713
20459
  }
19714
- var MCP_PATH, REMINT_LEAD_MS, MAX_TIMER_MS, realTimers, McpGate;
20460
+ var MCP_PATH, REMINT_LEAD_MS, MAX_TIMER_MS, realTimers2, McpGate;
19715
20461
  var init_mcpGate = __esm({
19716
20462
  "src/daemon/sandbox/mcpGate.ts"() {
19717
20463
  "use strict";
@@ -19720,7 +20466,7 @@ var init_mcpGate = __esm({
19720
20466
  MCP_PATH = "/functions/v1/mcp";
19721
20467
  REMINT_LEAD_MS = 10 * 60 * 1e3;
19722
20468
  MAX_TIMER_MS = 2 ** 31 - 1;
19723
- realTimers = {
20469
+ realTimers2 = {
19724
20470
  now: () => Date.now(),
19725
20471
  setTimeout: (fn, ms) => {
19726
20472
  const t = setTimeout(fn, ms);
@@ -19733,7 +20479,7 @@ var init_mcpGate = __esm({
19733
20479
  constructor(opts) {
19734
20480
  this.opts = opts;
19735
20481
  this.upstream = new URL(opts.upstream);
19736
- this.timers = opts.timers ?? realTimers;
20482
+ this.timers = opts.timers ?? realTimers2;
19737
20483
  this.log = opts.logger ?? ((line) => console.log(line));
19738
20484
  this.gate = createGateServer({
19739
20485
  name: "mcp-gate",
@@ -19776,176 +20522,79 @@ var init_mcpGate = __esm({
19776
20522
  const s = this.schedules.get(index);
19777
20523
  if (s) {
19778
20524
  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;
20525
+ this.schedules.delete(index);
20526
+ }
20527
+ this.generation++;
19935
20528
  }
19936
- binding(index) {
19937
- return this.byIndex.get(index)?.binding ?? null;
20529
+ schedule(index, jobId, expiresAt) {
20530
+ const generation = ++this.generation;
20531
+ const arm = (fireAt) => {
20532
+ const delay = Math.max(0, fireAt - this.timers.now());
20533
+ const handle = this.timers.setTimeout(() => {
20534
+ if (this.schedules.get(index)?.generation !== generation) return;
20535
+ if (fireAt - this.timers.now() > 0) arm(fireAt);
20536
+ else void this.remint(index, jobId, generation);
20537
+ }, Math.min(delay, MAX_TIMER_MS));
20538
+ this.schedules.set(index, { jobId, handle, generation });
20539
+ };
20540
+ arm(expiresAt - REMINT_LEAD_MS);
19938
20541
  }
19939
- bindingForJob(jobId) {
19940
- for (const e of this.byIndex.values()) {
19941
- if (e.binding?.jobId === jobId) return { slot: e.slot, binding: e.binding };
20542
+ async remint(index, jobId, generation) {
20543
+ let next;
20544
+ try {
20545
+ next = await this.opts.remint(jobId);
20546
+ } catch (err4) {
20547
+ if (this.schedules.get(index)?.generation === generation) this.schedules.delete(index);
20548
+ this.log(`[mcp-gate] remint failed job=${jobId}: ${redact(err4)}`);
20549
+ return;
19942
20550
  }
19943
- return null;
20551
+ const binding = this.opts.slotMap.binding(index);
20552
+ if (this.schedules.get(index)?.generation !== generation || binding?.jobId !== jobId || !binding.mcp) {
20553
+ this.log(`[mcp-gate] remint discarded job=${jobId}: slot moved on`);
20554
+ return;
20555
+ }
20556
+ binding.mcp = { ...binding.mcp, token: next.token, expiresAt: next.expiresAt };
20557
+ binding.counters.remints++;
20558
+ this.schedules.delete(index);
20559
+ this.log(`[mcp-gate] remint ok job=${jobId}`);
20560
+ if (next.expiresAt === null) return;
20561
+ if (next.expiresAt - REMINT_LEAD_MS <= this.timers.now()) {
20562
+ this.log(`[mcp-gate] reminted token expires inside the lead window job=${jobId}; not rescheduling`);
20563
+ return;
20564
+ }
20565
+ this.schedule(index, jobId, next.expiresAt);
19944
20566
  }
19945
- entry(index) {
19946
- const e = this.byIndex.get(index);
19947
- if (!e) throw new Error(`unknown slot ${index}`);
19948
- return e;
20567
+ handle(req, res, ctx) {
20568
+ if (ctx.path !== MCP_PATH) {
20569
+ ctx.done(404);
20570
+ res.writeHead(404).end();
20571
+ return;
20572
+ }
20573
+ if (req.method === "GET") {
20574
+ ctx.done(405);
20575
+ res.writeHead(405, { allow: "POST" }).end();
20576
+ return;
20577
+ }
20578
+ if (req.method !== "POST") {
20579
+ ctx.done(404);
20580
+ res.writeHead(404).end();
20581
+ return;
20582
+ }
20583
+ const mcp = ctx.binding.mcp;
20584
+ if (!mcp) {
20585
+ ctx.done(403, "no-mcp");
20586
+ res.writeHead(403).end();
20587
+ return;
20588
+ }
20589
+ ctx.binding.counters.mcp_requests++;
20590
+ const headers = filterHeaders(
20591
+ req.headers,
20592
+ (k) => k === "authorization" || k === "apikey" || k.startsWith("x-archme-source-")
20593
+ );
20594
+ headers.authorization = `Bearer ${mcp.token}`;
20595
+ const target = new URL(this.upstream.href);
20596
+ target.search = new URLSearchParams({ project: mcp.projectSlug, branch: mcp.branch }).toString();
20597
+ void forward(req, res, target, headers).then((r) => ctx.done(r.status));
19949
20598
  }
19950
20599
  };
19951
20600
  }
@@ -19953,7 +20602,16 @@ var init_slotMap = __esm({
19953
20602
 
19954
20603
  // src/daemon/sandbox/slots.ts
19955
20604
  function zeroCounters() {
19956
- return { model_requests: 0, upstream_429: 0, last_upstream_status: null, cap_hits: 0, mcp_requests: 0, remints: 0 };
20605
+ return {
20606
+ model_requests: 0,
20607
+ upstream_429: 0,
20608
+ last_upstream_status: null,
20609
+ cap_hits: 0,
20610
+ mcp_requests: 0,
20611
+ remints: 0,
20612
+ egress_denied: 0,
20613
+ first_egress_denied: null
20614
+ };
19957
20615
  }
19958
20616
  function indexOf(slot) {
19959
20617
  return typeof slot === "number" ? slot : slot.index;
@@ -19963,6 +20621,7 @@ var init_slots = __esm({
19963
20621
  "src/daemon/sandbox/slots.ts"() {
19964
20622
  "use strict";
19965
20623
  init_credentialGate();
20624
+ init_githubGate();
19966
20625
  init_mcpGate();
19967
20626
  init_slotMap();
19968
20627
  init_types();
@@ -19970,6 +20629,7 @@ var init_slots = __esm({
19970
20629
  slotMap;
19971
20630
  credential;
19972
20631
  mcp;
20632
+ github;
19973
20633
  boundPorts = null;
19974
20634
  constructor(opts) {
19975
20635
  this.slotMap = new SlotMap(opts.slots, opts.slotMapOptions);
@@ -19990,21 +20650,32 @@ var init_slots = __esm({
19990
20650
  logger: opts.logger,
19991
20651
  timers: opts.timers
19992
20652
  });
20653
+ this.github = new GithubGate({
20654
+ slotMap: this.slotMap,
20655
+ host: opts.host,
20656
+ port: opts.ports?.github ?? GATE_PORTS.github,
20657
+ logger: opts.logger,
20658
+ dial: opts.githubDial
20659
+ });
19993
20660
  }
19994
- /** Binds both gates; resolves with the ports actually bound. */
20661
+ /** Binds all three gates; resolves with the ports actually bound. */
19995
20662
  async start() {
19996
- const anthropic = await this.credential.listen();
20663
+ const started = [];
19997
20664
  try {
20665
+ const anthropic = await this.credential.listen();
20666
+ started.push(this.credential);
19998
20667
  const mcp = await this.mcp.listen();
19999
- this.boundPorts = { anthropic, mcp };
20668
+ started.push(this.mcp);
20669
+ const github = await this.github.listen();
20670
+ this.boundPorts = { anthropic, mcp, github };
20000
20671
  return this.boundPorts;
20001
20672
  } catch (err4) {
20002
- await this.credential.close();
20673
+ await Promise.all(started.map((g) => g.close()));
20003
20674
  throw err4;
20004
20675
  }
20005
20676
  }
20006
20677
  async stop() {
20007
- await Promise.all([this.credential.close(), this.mcp.close()]);
20678
+ await Promise.all([this.credential.close(), this.mcp.close(), this.github.close()]);
20008
20679
  this.boundPorts = null;
20009
20680
  }
20010
20681
  ports() {
@@ -20013,177 +20684,70 @@ var init_slots = __esm({
20013
20684
  /** Throws if the slot is unknown or already serving another job. */
20014
20685
  bindJob(slot, input) {
20015
20686
  const index = indexOf(slot);
20016
- 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}$/;
20687
+ this.slotMap.bind(index, {
20688
+ jobId: input.jobId,
20689
+ maxModelRequests: input.maxModelRequests,
20690
+ mcp: input.mcp ? { ...input.mcp } : null,
20691
+ step: null,
20692
+ counters: zeroCounters()
20693
+ });
20694
+ this.mcp.onBind(index);
20695
+ }
20696
+ /** Which step's box is running on the slot; set before each box starts.
20697
+ * Leaving the clone step closes the slot's GitHub tunnels. */
20698
+ setStep(slot, step) {
20699
+ const index = indexOf(slot);
20700
+ const b = this.slotMap.binding(index);
20701
+ if (!b) throw new Error(`slot ${index} is not bound`);
20702
+ b.step = step;
20703
+ this.github.onStepChange(index);
20704
+ }
20705
+ /** Unbinds the slot (it answers 403 again) and returns the job's final counters. */
20706
+ clearJob(slot) {
20707
+ const index = indexOf(slot);
20708
+ const counters = this.countersFor(index);
20709
+ this.mcp.onClear(index);
20710
+ this.github.onClear(index);
20711
+ this.slotMap.clear(index);
20712
+ return counters;
20713
+ }
20714
+ /** A snapshot of the slot's counters, or null when idle. */
20715
+ countersFor(slot) {
20716
+ const b = this.slotMap.binding(indexOf(slot));
20717
+ return b ? { ...b.counters } : null;
20718
+ }
20719
+ };
20720
+ SlotPool = class {
20721
+ free_ = /* @__PURE__ */ new Set();
20722
+ byIndex = /* @__PURE__ */ new Map();
20723
+ constructor(slots) {
20724
+ for (const s of slots) {
20725
+ this.byIndex.set(s.index, s);
20726
+ this.free_.add(s.index);
20727
+ }
20728
+ }
20729
+ /** The lowest free slot, or null when every slot is busy. */
20730
+ lease() {
20731
+ let best = null;
20732
+ for (const i2 of this.free_) if (best === null || i2 < best) best = i2;
20733
+ if (best === null) return null;
20734
+ this.free_.delete(best);
20735
+ return this.byIndex.get(best) ?? null;
20736
+ }
20737
+ /** Idempotent; unknown indices are ignored. */
20738
+ free(index) {
20739
+ if (this.byIndex.has(index)) this.free_.add(index);
20740
+ }
20741
+ available() {
20742
+ return this.free_.size;
20743
+ }
20744
+ };
20181
20745
  }
20182
20746
  });
20183
20747
 
20184
20748
  // 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";
20749
+ import { mkdir as mkdir12, writeFile as writeFile11 } from "node:fs/promises";
20750
+ import { dirname as dirname16 } from "node:path";
20187
20751
  function asArchmeJob(job) {
20188
20752
  return job;
20189
20753
  }
@@ -20208,7 +20772,7 @@ async function runCloneStep(input, deps = {}) {
20208
20772
  const { env: gitEnv, cleanup } = await buildGitEnv(input.credentials);
20209
20773
  const restoreEnv = applyEnv(gitEnv);
20210
20774
  try {
20211
- await mkdir11(repoDir, { recursive: true });
20775
+ await mkdir12(repoDir, { recursive: true });
20212
20776
  const must = async (args, cwd) => {
20213
20777
  const r = await git4(args, { cwd, env: gitEnv });
20214
20778
  if (r.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${r.stderr.trim()}`);
@@ -20260,7 +20824,7 @@ async function runCloneStep(input, deps = {}) {
20260
20824
  after: diff.after,
20261
20825
  ...diff.provenance ? { provenance: diff.provenance } : {}
20262
20826
  };
20263
- await mkdir11(dirname15(diffPath), { recursive: true });
20827
+ await mkdir12(dirname16(diffPath), { recursive: true });
20264
20828
  await writeFile11(diffPath, JSON.stringify(persisted), { mode: 384 });
20265
20829
  const bytes = Buffer.byteLength(diff.patch, "utf8");
20266
20830
  log(`diff ${diff.rangeLabel}: ${diff.changedFiles.length} file(s), ${bytes} bytes${diff.materialized ? " (materialized)" : ""}`);
@@ -20316,7 +20880,7 @@ var init_clone = __esm({
20316
20880
  });
20317
20881
 
20318
20882
  // src/daemon/sandboxJob/agent.ts
20319
- import { mkdir as mkdir12, readFile as readFile14, writeFile as writeFile12 } from "node:fs/promises";
20883
+ import { mkdir as mkdir13, readFile as readFile14, writeFile as writeFile12 } from "node:fs/promises";
20320
20884
  import { join as join23 } from "node:path";
20321
20885
  function agentBoxEnv(gateway) {
20322
20886
  return {
@@ -20329,10 +20893,16 @@ function agentBoxEnv(gateway) {
20329
20893
  DISABLE_ERROR_REPORTING: "1"
20330
20894
  };
20331
20895
  }
20332
- function cloneBoxEnv() {
20896
+ function cloneBoxEnv(gateway) {
20897
+ const proxy = `http://${gateway}:${GATE_PORTS.github}`;
20333
20898
  return {
20334
20899
  HOME: BOX_HOME,
20335
- GIT_TERMINAL_PROMPT: "0"
20900
+ GIT_TERMINAL_PROMPT: "0",
20901
+ https_proxy: proxy,
20902
+ HTTPS_PROXY: proxy,
20903
+ GIT_CONFIG_COUNT: "1",
20904
+ GIT_CONFIG_KEY_0: "http.proxy",
20905
+ GIT_CONFIG_VALUE_0: proxy
20336
20906
  };
20337
20907
  }
20338
20908
  function boxMcpConfig(gateway) {
@@ -20343,7 +20913,7 @@ function boxMcpConfig(gateway) {
20343
20913
  };
20344
20914
  }
20345
20915
  async function writeBoxMcpConfig(gateway, dir = BOX_MCP_DIR) {
20346
- await mkdir12(dir, { recursive: true, mode: 448 });
20916
+ await mkdir13(dir, { recursive: true, mode: 448 });
20347
20917
  const path7 = join23(dir, "mcp.json");
20348
20918
  await writeFile12(path7, JSON.stringify(boxMcpConfig(gateway), null, 2), { mode: 384 });
20349
20919
  return path7;
@@ -20411,7 +20981,7 @@ async function runAgentStep(input, deps = {}) {
20411
20981
  );
20412
20982
  const promptBytes = Buffer.byteLength(prompt, "utf8");
20413
20983
  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 });
20984
+ for (const d of deps.homeDirs ?? [BOX_HOME, BOX_CLAUDE_CONFIG_DIR]) await mkdir13(d, { recursive: true, mode: 448 });
20415
20985
  log(`spawning claude (model ${input.model}, prompt ${promptBytes} bytes)`);
20416
20986
  const proc = spawn2({
20417
20987
  prompt,
@@ -20515,6 +21085,7 @@ function createSandboxParts(settings) {
20515
21085
  await gates.start();
20516
21086
  return {
20517
21087
  bindJob: (slot, b) => gates.bindJob(slot, b),
21088
+ setStep: (slot, step) => gates.setStep(slot, step),
20518
21089
  clearJob: (slot) => {
20519
21090
  gates.clearJob(slot);
20520
21091
  },
@@ -20565,7 +21136,7 @@ __export(run_exports, {
20565
21136
  });
20566
21137
  import { createClient } from "@supabase/supabase-js";
20567
21138
  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";
21139
+ import { mkdtemp as mkdtemp4, mkdir as mkdir14, writeFile as writeFile13, rm as rm8, stat as stat8, chmod as chmod6 } from "node:fs/promises";
20569
21140
  import { tmpdir as tmpdir4 } from "node:os";
20570
21141
  import { join as join24 } from "node:path";
20571
21142
  import { homedir as homedir8 } from "node:os";
@@ -20732,7 +21303,7 @@ async function buildGitEnv(credentials) {
20732
21303
  ].join("\n"),
20733
21304
  { mode: 448 }
20734
21305
  );
20735
- await chmod5(askpassPath, 448).catch(() => {
21306
+ await chmod6(askpassPath, 448).catch(() => {
20736
21307
  });
20737
21308
  const gitConfigEntries = [
20738
21309
  ["credential.helper", ""],
@@ -20793,12 +21364,12 @@ async function ensureCloneAndFetch(ctx, job, claimDeviceId) {
20793
21364
  const deviceId = claimDeviceId ?? ctx.deviceForOrg(job.org_id) ?? ctx.auth.getDeviceId();
20794
21365
  if (!deviceId) throw new Error("daemon is not signed in with a registered device");
20795
21366
  const credentials = await getRepoCredentials(ctx, job.id, deviceId);
20796
- await mkdir13(ctx.cloneBaseDir, { recursive: true });
21367
+ await mkdir14(ctx.cloneBaseDir, { recursive: true });
20797
21368
  const dir = cloneDirFor(ctx.cloneBaseDir, job.repo_owner, job.repo_name);
20798
21369
  const { env, cleanup: cleanupGitEnv } = await buildGitEnv(credentials);
20799
21370
  await cloneMutex.runExclusive(dir, async () => {
20800
21371
  if (!await exists(join24(dir, ".git"))) {
20801
- await mkdir13(join24(ctx.cloneBaseDir, job.repo_owner), { recursive: true });
21372
+ await mkdir14(join24(ctx.cloneBaseDir, job.repo_owner), { recursive: true });
20802
21373
  console.log(` cloning ${job.repo_owner}/${job.repo_name} \u2192 ${dir}`);
20803
21374
  await execa("git", ["clone", "--no-tags", credentials.https_url, dir], { env, reject: true });
20804
21375
  } else {
@@ -21441,7 +22012,7 @@ function sandboxDepsFor(ctx, job, deviceId, host) {
21441
22012
  settings: host.settings,
21442
22013
  parseResultLine: (stdout, nonce) => host.io.parseResultLine(stdout, nonce),
21443
22014
  agentBoxEnv: (gateway) => host.io.agentBoxEnv(gateway),
21444
- cloneBoxEnv: () => host.io.cloneBoxEnv(),
22015
+ cloneBoxEnv: (gateway) => host.io.cloneBoxEnv(gateway),
21445
22016
  prefetch: (j) => (prefetchOverride ?? prefetchJobContext)(ctx, j, deviceId),
21446
22017
  writeInputDir: (step, input) => writeInputDir(step, input),
21447
22018
  log: (msg) => console.log(`[${job.id}] ${msg}`),
@@ -21585,8 +22156,13 @@ async function runJob(ctx, job, claimDeviceId) {
21585
22156
  const isLocalRepo = findLocalRepoForJob(await loadLocalRepos(), job) != null;
21586
22157
  const decision = shouldSandbox(sandboxHost.settings, job, { isLocalRepo });
21587
22158
  if (decision.reason === "local_repo") log("sandbox_skipped_local_repo: local-poll repo, running unsandboxed");
22159
+ const required = sandboxHost.settings.mode === "required";
22160
+ if (decision.reason === "local_repo" && required) log("sandbox: required mode keeps local-poll repos unboxed (\xA713)");
21588
22161
  const slot = decision.boxed ? sandboxHost.pool.lease() : null;
21589
- if (decision.boxed && !slot) log("sandbox_no_slot: no free slot, running unsandboxed");
22162
+ if (decision.boxed && !slot) {
22163
+ if (required) throw new Error("sandbox_no_slot: no free sandbox slot (ARCHME_SANDBOX=required); retrying later");
22164
+ log("sandbox_no_slot: no free slot, running unsandboxed");
22165
+ }
21590
22166
  if (slot) sandboxRun: {
21591
22167
  boxedJobs.add(job.id);
21592
22168
  log(`sandbox: running boxed on slot ${slot.index}`);
@@ -21597,7 +22173,7 @@ async function runJob(ctx, job, claimDeviceId) {
21597
22173
  timeouts: { claudeMs: CLAUDE_TIMEOUT_MS, largeDiffClaudeMs: CLAUDE_LARGE_DIFF_TIMEOUT_MS }
21598
22174
  });
21599
22175
  } catch (err4) {
21600
- if (err4 instanceof SandboxInfraError && !err4.agentStarted && !shuttingDown && !cancelled.has(job.id)) {
22176
+ if (err4 instanceof SandboxInfraError && !err4.agentStarted && !shuttingDown && !cancelled.has(job.id) && sandboxHost.settings.mode === "shadow") {
21601
22177
  log(`sandbox_infra_fallback: ${err4.message}, running unboxed`);
21602
22178
  boxedJobs.delete(job.id);
21603
22179
  break sandboxRun;
@@ -22057,6 +22633,7 @@ async function dispatch(sched) {
22057
22633
  }
22058
22634
  pauseUntilMs = null;
22059
22635
  }
22636
+ if (confinementGate && !confinementGate.allowsClaims()) return;
22060
22637
  sched.dispatching = true;
22061
22638
  try {
22062
22639
  let atCap = false;
@@ -22250,7 +22827,10 @@ async function runDaemon() {
22250
22827
  if (process.env.ARCHME_SANDBOX_SLOTS !== void 0) {
22251
22828
  console.log(" ARCHME_SANDBOX_SLOTS is no longer read; sandbox slots follow MAX_CONCURRENT_JOBS");
22252
22829
  }
22253
- console.log(` sandbox: ${sandboxSettings.mode}${sandboxSettings.mode === "shadow" ? ` (projects: ${sandboxSettings.projects.size ? [...sandboxSettings.projects].join(", ") : "all"}; ${sandboxSettings.slots} slot(s))` : ""}`);
22830
+ 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))` : ""}`);
22831
+ if (sandboxSettings.mode === "required" && process.env.ARCHME_SANDBOX_PROJECTS?.trim()) {
22832
+ console.log(" ARCHME_SANDBOX_PROJECTS is ignored in required mode: every project's jobs run boxed");
22833
+ }
22254
22834
  const bearerCfg = {
22255
22835
  supabaseUrl: SUPABASE_URL,
22256
22836
  anonKey: SUPABASE_ANON_KEY,
@@ -22323,7 +22903,7 @@ async function runDaemon() {
22323
22903
  if (sandboxSettings.mode !== "off") {
22324
22904
  try {
22325
22905
  const { createSandboxParts: createSandboxParts2 } = await Promise.resolve().then(() => (init_parts(), parts_exports));
22326
- const { host } = await bootSandbox(sandboxSettings, createSandboxParts2(sandboxSettings), {
22906
+ const { host, monitor } = await bootSandbox(sandboxSettings, createSandboxParts2(sandboxSettings), {
22327
22907
  mcpBaseUrl: ARCHME_MCP_BASE_URL,
22328
22908
  // The MCP gatekeeper re-mints a job's token before it expires (§7.2),
22329
22909
  // with the device that claimed that job, as every job-scoped call must.
@@ -22337,6 +22917,8 @@ async function runDaemon() {
22337
22917
  log: (m) => console.log(m)
22338
22918
  });
22339
22919
  sandboxHost = host;
22920
+ monitor.start();
22921
+ confinementGate = monitor;
22340
22922
  } catch (err4) {
22341
22923
  const msg = err4 instanceof Error ? err4.message : String(err4);
22342
22924
  if (sandboxBootFailurePolicy(sandboxSettings.mode) === "run_unboxed") {
@@ -22465,6 +23047,7 @@ ${signal} received, shutting down`);
22465
23047
  for (const ctx of sessions) ctx.auth.clearRefreshTimer();
22466
23048
  clearInterval(sweepTimer);
22467
23049
  clearInterval(localPollTimer);
23050
+ confinementGate?.stop?.();
22468
23051
  for (const u2 of unsubscribers) u2();
22469
23052
  const SHUTDOWN_DEADLINE_MS = 3e4;
22470
23053
  const deadline = setTimeout(() => {
@@ -22507,7 +23090,7 @@ ${signal} received, shutting down`);
22507
23090
  process.on("SIGINT", () => shutdown("SIGINT"));
22508
23091
  process.on("SIGTERM", () => shutdown("SIGTERM"));
22509
23092
  }
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;
23093
+ 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
23094
  var init_run = __esm({
22512
23095
  "src/daemon/run.ts"() {
22513
23096
  "use strict";
@@ -22587,6 +23170,7 @@ var init_run = __esm({
22587
23170
  ctxByJob = /* @__PURE__ */ new Map();
22588
23171
  deviceByJob = /* @__PURE__ */ new Map();
22589
23172
  sandboxHost = null;
23173
+ confinementGate = null;
22590
23174
  boxedJobs = /* @__PURE__ */ new Set();
22591
23175
  leaseLost = /* @__PURE__ */ new Set();
22592
23176
  prefetchOverride = null;
@@ -22767,6 +23351,9 @@ ${ANCHOR_VERIFICATION}`;
22767
23351
  sandboxHost = host;
22768
23352
  prefetchOverride = prefetch;
22769
23353
  },
23354
+ setConfinementGate(gate) {
23355
+ confinementGate = gate;
23356
+ },
22770
23357
  // Daemon-version hooks for tests / introspection. The real value is
22771
23358
  // populated at runDaemon boot from package.json; tests can stub.
22772
23359
  setDaemonVersion(v) {
@@ -22779,13 +23366,450 @@ ${ANCHOR_VERIFICATION}`;
22779
23366
  }
22780
23367
  });
22781
23368
 
23369
+ // src/daemon/sandbox/pfAnchor.ts
23370
+ function renderAnchor() {
23371
+ const slotLines = Array.from({ length: MAX_SLOTS }, (_, i2) => {
23372
+ const s = slotFor(i2);
23373
+ return `pass in quick inet proto tcp from ${s.subnet} to ${s.gateway} port ${PORTS} keep state`;
23374
+ });
23375
+ return [
23376
+ `# ${ANCHOR_NAME} \u2014 installed by \`archme daemon sandbox setup\` (L2 \xA78.2).`,
23377
+ "# DHCP inside each slot",
23378
+ "pass in quick inet proto udp from any port 68 to any port 67",
23379
+ "# each slot may reach only its own gateway, only on the gatekeeper ports",
23380
+ ...slotLines,
23381
+ "# everything else from a slot: host services on any host address, forwarding",
23382
+ `block drop in quick inet from ${SLOT_RANGE} to any`,
23383
+ "# the gatekeepers listen on the wildcard: nobody but a slot reaches them",
23384
+ `block drop in quick inet proto tcp from any to any port ${PORTS}`,
23385
+ ""
23386
+ ].join("\n");
23387
+ }
23388
+ function addAnchorToPfConf(text) {
23389
+ const lines = text.replace(/\n$/, "").split("\n");
23390
+ if (ANCHOR_LINES.every((l) => lines.some((x) => x.trim() === l))) return { text, changed: false };
23391
+ const kept = lines.filter((x) => !ANCHOR_LINES.includes(x.trim()));
23392
+ let at = -1;
23393
+ kept.forEach((l, i2) => {
23394
+ if (!l.trim().startsWith("#") && l.includes("com.apple")) at = i2;
23395
+ });
23396
+ const insertAt = at >= 0 ? at + 1 : kept.length;
23397
+ kept.splice(insertAt, 0, ...ANCHOR_LINES);
23398
+ return { text: `${kept.join("\n")}
23399
+ `, changed: true };
23400
+ }
23401
+ function renderPfLaunchDaemon() {
23402
+ return `<?xml version="1.0" encoding="UTF-8"?>
23403
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
23404
+ <plist version="1.0">
23405
+ <dict>
23406
+ <key>Label</key>
23407
+ <string>${PF_DAEMON_LABEL}</string>
23408
+ <key>ProgramArguments</key>
23409
+ <array>
23410
+ <string>/sbin/pfctl</string>
23411
+ <string>-E</string>
23412
+ <string>-f</string>
23413
+ <string>${PF_CONF_PATH}</string>
23414
+ </array>
23415
+ <key>RunAtLoad</key>
23416
+ <true/>
23417
+ </dict>
23418
+ </plist>
23419
+ `;
23420
+ }
23421
+ function parseDestination(dest) {
23422
+ const [addr, bitsStr] = dest.split("/");
23423
+ if (!addr || !/^\d+(\.\d+){0,3}$/.test(addr)) return null;
23424
+ const octets = addr.split(".");
23425
+ const bits = bitsStr !== void 0 ? Number(bitsStr) : octets.length * 8;
23426
+ while (octets.length < 4) octets.push("0");
23427
+ const base = parseIpv4(octets.join("."));
23428
+ if (base === null || !Number.isInteger(bits) || bits <= 0 || bits > 32) return null;
23429
+ return { base, bits };
23430
+ }
23431
+ function mask(bits) {
23432
+ return bits === 0 ? 0 : ~0 << 32 - bits >>> 0;
23433
+ }
23434
+ function overlaps(a2, b) {
23435
+ const m = mask(Math.min(a2.bits, b.bits));
23436
+ return (a2.base & m) >>> 0 === (b.base & m) >>> 0;
23437
+ }
23438
+ function within(inner, outer) {
23439
+ return inner.bits >= outer.bits && overlaps(inner, outer);
23440
+ }
23441
+ function routeCollisions(netstat, ourSlots2) {
23442
+ const range2 = parseDestination(SLOT_RANGE.replace(".0/21", "/21"));
23443
+ const ours = [...ourSlots2].map((i2) => parseDestination(slotFor(i2).subnet.replace(".0/24", "/24")));
23444
+ const out = [];
23445
+ for (const line of netstat.split("\n")) {
23446
+ const cols = line.trim().split(/\s+/);
23447
+ const net3 = parseDestination(cols[0] ?? "");
23448
+ if (!net3 || !overlaps(net3, range2)) continue;
23449
+ if (ours.some((o2) => within(net3, o2))) continue;
23450
+ out.push(`${cols[0]} via ${cols[3] ?? "?"}`);
23451
+ }
23452
+ return out;
23453
+ }
23454
+ function bakedUnitEnv(plist) {
23455
+ const m = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(plist);
23456
+ if (!m) return {};
23457
+ const out = {};
23458
+ const unescape2 = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
23459
+ for (const kv of m[1].matchAll(/<key>([^<]*)<\/key>\s*<string>([^<]*)<\/string>/g)) {
23460
+ out[unescape2(kv[1])] = unescape2(kv[2]);
23461
+ }
23462
+ return out;
23463
+ }
23464
+ var ANCHOR_NAME, ANCHOR_PATH, PF_CONF_PATH, PF_CONF_BACKUP_PATH, PF_DAEMON_LABEL, PF_DAEMON_PATH, SLOT_RANGE, PORTS, ANCHOR_LINES;
23465
+ var init_pfAnchor = __esm({
23466
+ "src/daemon/sandbox/pfAnchor.ts"() {
23467
+ "use strict";
23468
+ init_appleContainer();
23469
+ init_slotMap();
23470
+ init_types();
23471
+ ANCHOR_NAME = "com.archme.sandbox";
23472
+ ANCHOR_PATH = `/etc/pf.anchors/${ANCHOR_NAME}`;
23473
+ PF_CONF_PATH = "/etc/pf.conf";
23474
+ PF_CONF_BACKUP_PATH = "/etc/pf.conf.archme-backup";
23475
+ PF_DAEMON_LABEL = "com.archme.pf";
23476
+ PF_DAEMON_PATH = `/Library/LaunchDaemons/${PF_DAEMON_LABEL}.plist`;
23477
+ SLOT_RANGE = `192.168.${SLOT_RANGE_BASE_THIRD_OCTET}.0/21`;
23478
+ PORTS = `{ ${GATE_PORTS.anthropic} ${GATE_PORTS.mcp} ${GATE_PORTS.github} }`;
23479
+ ANCHOR_LINES = [
23480
+ `anchor "${ANCHOR_NAME}"`,
23481
+ `load anchor "${ANCHOR_NAME}" from "${ANCHOR_PATH}"`
23482
+ ];
23483
+ }
23484
+ });
23485
+
23486
+ // src/daemon/sandbox/setupCommand.ts
23487
+ var setupCommand_exports = {};
23488
+ __export(setupCommand_exports, {
23489
+ HARDENING_TEXT: () => HARDENING_TEXT,
23490
+ runSandboxSetup: () => runSandboxSetup,
23491
+ runSandboxStatus: () => runSandboxStatus
23492
+ });
23493
+ async function checkHost(io) {
23494
+ const ver = (await io.run("sw_vers", ["-productVersion"])).stdout.trim();
23495
+ const major = Number(ver.split(".")[0]);
23496
+ if (!(major >= 26)) return `macOS 26 or later is required (this is ${ver || "not macOS"})`;
23497
+ const arch = (await io.run("uname", ["-m"])).stdout.trim();
23498
+ if (arch !== "arm64") return `Apple Silicon is required (this is ${arch || "unknown"})`;
23499
+ if (!await runtimeUp(io)) return "Apple `container` is not running; start it with `container system start`";
23500
+ return null;
23501
+ }
23502
+ async function runtimeUp(io) {
23503
+ const s = await io.run("container", ["system", "status"]);
23504
+ return s.exitCode === 0 && /^status\s+running\b/m.test(s.stdout);
23505
+ }
23506
+ async function ourSlots(io) {
23507
+ const nets = await io.listNetworks();
23508
+ const ours = /* @__PURE__ */ new Set();
23509
+ for (let i2 = 0; i2 < MAX_SLOTS; i2++) {
23510
+ const s = slotFor(i2);
23511
+ if (nets.some((n2) => n2.name === s.network && n2.subnet === s.subnet)) ours.add(i2);
23512
+ }
23513
+ return ours;
23514
+ }
23515
+ async function currentMode(io) {
23516
+ const unit = await io.unitEnv();
23517
+ if (unit) return { mode: unit.ARCHME_SANDBOX?.trim() || "off", from: "installed service" };
23518
+ return { mode: io.env.ARCHME_SANDBOX?.trim() || "off", from: "this shell" };
23519
+ }
23520
+ async function runSandboxSetup(io, opts) {
23521
+ const fail2 = (msg) => {
23522
+ io.print(`\u2717 ${msg}`);
23523
+ return 1;
23524
+ };
23525
+ const host = await checkHost(io);
23526
+ if (host) return fail2(host);
23527
+ io.print("\u2713 macOS 26+, Apple Silicon, container running");
23528
+ if (!io.image) return fail2("no sandbox image: this install ships no sandbox-image.json, so set ARCHME_SANDBOX_IMAGE");
23529
+ if ((await io.run("container", ["image", "inspect", io.image])).exitCode !== 0) {
23530
+ io.print(`Pulling ${io.image}\u2026`);
23531
+ const pull = await io.run("container", ["image", "pull", io.image]);
23532
+ if (pull.exitCode !== 0) return fail2(`image pull failed: ${pull.stderr.trim()}`);
23533
+ }
23534
+ io.print(`\u2713 image ${io.image}`);
23535
+ const existing = await io.readToken().catch(() => null);
23536
+ const keep = existing && (opts.yes || await io.confirm(`A Claude token is stored (${existing.source}). Keep it?`));
23537
+ if (keep) {
23538
+ io.print(`\u2713 Claude token (${existing.source})`);
23539
+ } else {
23540
+ io.print("Run `claude setup-token` and paste the token it prints (input is hidden).");
23541
+ const stored = await io.storeToken(await io.promptSecret("Claude setup token: "));
23542
+ io.print(`\u2713 Claude token stored in ${stored.source === "keychain" ? "the macOS keychain" : "a 0600 file"}${stored.note ? ` (${stored.note})` : ""}`);
23543
+ }
23544
+ const netstat = await io.run("netstat", ["-rn", "-f", "inet"]);
23545
+ if (netstat.exitCode !== 0) return fail2(`netstat failed: ${netstat.stderr.trim()}`);
23546
+ const collisions = routeCollisions(netstat.stdout, await ourSlots(io));
23547
+ if (collisions.length) {
23548
+ 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.`);
23549
+ }
23550
+ await io.ensureSlots(MAX_SLOTS);
23551
+ io.print(`\u2713 ${MAX_SLOTS} slot networks`);
23552
+ const anchor = renderAnchor();
23553
+ const plist = renderPfLaunchDaemon();
23554
+ const pfConf = await io.readFile(PF_CONF_PATH);
23555
+ if (pfConf === null) return fail2(`cannot read ${PF_CONF_PATH}`);
23556
+ const conf = addAnchorToPfConf(pfConf);
23557
+ const anchorChanged = await io.readFile(ANCHOR_PATH) !== anchor;
23558
+ const plistChanged = await io.readFile(PF_DAEMON_PATH) !== plist;
23559
+ const pfUnchanged = !anchorChanged && !conf.changed && !plistChanged;
23560
+ const reload = [
23561
+ // Not loaded yet on a first run.
23562
+ { args: ["launchctl", "bootout", `system/${PF_DAEMON_LABEL}`], mayFail: true },
23563
+ // RunAtLoad: this applies the rules now.
23564
+ { args: ["launchctl", "bootstrap", "system", PF_DAEMON_PATH] }
23565
+ ];
23566
+ const runSudo = async (steps) => {
23567
+ for (const s of steps) {
23568
+ io.print(`$ sudo ${s.args.join(" ")}`);
23569
+ const r = await io.sudo(s.args);
23570
+ if (r.exitCode !== 0 && !s.mayFail) return `\`sudo ${s.args.join(" ")}\` failed (exit ${r.exitCode}); nothing after it ran`;
23571
+ }
23572
+ await io.sleep(2e3);
23573
+ return null;
23574
+ };
23575
+ if (pfUnchanged) {
23576
+ io.print("\u2713 pf: already installed");
23577
+ } else {
23578
+ io.print(`
23579
+ These rules go in ${ANCHOR_PATH}:
23580
+ `);
23581
+ for (const l of anchor.trimEnd().split("\n")) io.print(` ${l}`);
23582
+ if (conf.changed) {
23583
+ io.print(`
23584
+ ${PF_CONF_PATH} gains (a backup goes to ${PF_CONF_BACKUP_PATH}):
23585
+ `);
23586
+ const before = new Set(pfConf.split("\n"));
23587
+ for (const l of conf.text.split("\n")) if (l && !before.has(l)) io.print(` + ${l}`);
23588
+ }
23589
+ io.print(`
23590
+ ${PF_DAEMON_PATH} enables pf and loads ${PF_CONF_PATH} at every boot (${PF_DAEMON_LABEL}).
23591
+ `);
23592
+ if (!opts.yes && !await io.confirm("Install these with sudo?")) return fail2("pf not installed");
23593
+ const anchorTmp = await io.writeTemp("com.archme.sandbox", anchor);
23594
+ const confTmp = await io.writeTemp("pf.conf", conf.text);
23595
+ const plistTmp = await io.writeTemp(`${PF_DAEMON_LABEL}.plist`, plist);
23596
+ const steps = [
23597
+ { args: sudoInstall(anchorTmp, ANCHOR_PATH) },
23598
+ // Validate the new pf.conf (it loads the anchor just installed) before it replaces the old.
23599
+ { args: ["pfctl", "-nf", confTmp] },
23600
+ ...conf.changed ? [{ args: ["cp", PF_CONF_PATH, PF_CONF_BACKUP_PATH] }, { args: sudoInstall(confTmp, PF_CONF_PATH) }] : [],
23601
+ { args: sudoInstall(plistTmp, PF_DAEMON_PATH) },
23602
+ ...reload
23603
+ ];
23604
+ const err4 = await runSudo(steps);
23605
+ if (err4) return fail2(err4);
23606
+ io.print("\u2713 pf installed");
23607
+ }
23608
+ io.print("");
23609
+ let code = await runSandboxStatus(io);
23610
+ if (code !== 0 && pfUnchanged && (opts.yes || await io.confirm("Reload pf from the installed files with sudo?"))) {
23611
+ const err4 = await runSudo(reload);
23612
+ if (err4) return fail2(err4);
23613
+ io.print("");
23614
+ code = await runSandboxStatus(io);
23615
+ }
23616
+ const { mode, from } = await currentMode(io);
23617
+ io.print("");
23618
+ io.print(`ARCHME_SANDBOX is ${mode} (${from}); setup does not change it.`);
23619
+ io.print("To require confinement for every job: set ARCHME_SANDBOX=required in the daemon's environment, then `archme daemon restart`.");
23620
+ for (const l of HARDENING_TEXT) io.print(l);
23621
+ return code;
23622
+ }
23623
+ async function runSandboxStatus(io) {
23624
+ let ok = true;
23625
+ const line = (good, name, detail) => {
23626
+ if (!good) ok = false;
23627
+ io.print(`${good ? "\u2713" : "\u2717"} ${name.padEnd(9)} ${detail}`);
23628
+ };
23629
+ const up = await runtimeUp(io);
23630
+ line(up, "runtime", up ? "container running" : "container is not running");
23631
+ if (io.image) {
23632
+ const present = up && (await io.run("container", ["image", "inspect", io.image])).exitCode === 0;
23633
+ line(present, "image", `${io.image}${present ? "" : " (not present: run `archme daemon sandbox setup`)"}`);
23634
+ } else {
23635
+ line(false, "image", "none configured (no sandbox-image.json and no ARCHME_SANDBOX_IMAGE)");
23636
+ }
23637
+ try {
23638
+ const t = await io.readToken();
23639
+ line(true, "token", t.source === "keychain" ? "macOS keychain" : t.source === "file-override" ? "ARCHME_SANDBOX_CLAUDE_TOKEN_FILE" : "file");
23640
+ } catch (e) {
23641
+ line(false, "token", e.message);
23642
+ }
23643
+ const ours = up ? await ourSlots(io) : /* @__PURE__ */ new Set();
23644
+ const missing = Array.from({ length: MAX_SLOTS }, (_, i2) => i2).filter((i2) => !ours.has(i2)).map((i2) => slotFor(i2).network);
23645
+ line(missing.length === 0, "slots", missing.length ? `missing: ${missing.join(", ")}` : `${MAX_SLOTS} slot networks`);
23646
+ let probeSlot = null;
23647
+ if (up) {
23648
+ const busy = new Set((await io.listBoxes()).map((b) => b.labels[SLOT_LABEL]).filter((x) => !!x));
23649
+ for (let i2 = MAX_SLOTS - 1; i2 >= 0; i2--) {
23650
+ if (ours.has(i2) && !busy.has(String(i2))) {
23651
+ probeSlot = slotFor(i2);
23652
+ break;
23653
+ }
23654
+ }
23655
+ }
23656
+ if (!probeSlot) {
23657
+ line(false, "probe", "not run: no free slot network");
23658
+ } else {
23659
+ const r = await io.probe(probeSlot);
23660
+ line(r.ok, "probe", formatConfinementReport(r));
23661
+ for (const [id, o2] of Object.entries(r.outcomes)) io.print(` ${id.padEnd(15)} ${o2}`);
23662
+ }
23663
+ const { mode, from } = await currentMode(io);
23664
+ io.print(` mode ${mode} (${from})`);
23665
+ return ok ? 0 : 1;
23666
+ }
23667
+ var HARDENING_TEXT, sudoInstall;
23668
+ var init_setupCommand = __esm({
23669
+ "src/daemon/sandbox/setupCommand.ts"() {
23670
+ "use strict";
23671
+ init_appleContainer();
23672
+ init_confinement();
23673
+ init_pfAnchor();
23674
+ init_types();
23675
+ HARDENING_TEXT = [
23676
+ "Host hardening (L2 \xA78.5), by hand:",
23677
+ " - turn off SSH password authentication (PasswordAuthentication no in /etc/ssh/sshd_config)",
23678
+ " - keep AirPlay Receiver off (System Settings \u2192 General \u2192 AirDrop & Handoff)"
23679
+ ];
23680
+ sudoInstall = (from, to) => ["install", "-m", "644", "-o", "root", "-g", "wheel", from, to];
23681
+ }
23682
+ });
23683
+
23684
+ // src/daemon/sandbox/setupIo.ts
23685
+ var setupIo_exports = {};
23686
+ __export(setupIo_exports, {
23687
+ createSetupIo: () => createSetupIo
23688
+ });
23689
+ import { mkdtemp as mkdtemp5, readFile as readFile15, writeFile as writeFile14 } from "node:fs/promises";
23690
+ import { networkInterfaces as networkInterfaces2, tmpdir as tmpdir5 } from "node:os";
23691
+ import { join as join25 } from "node:path";
23692
+ import { createInterface as createInterface2 } from "node:readline";
23693
+ import { Writable as Writable4 } from "node:stream";
23694
+ function ask(question, hidden2 = false) {
23695
+ let muted = false;
23696
+ const output = new Writable4({
23697
+ write(chunk, _enc, cb) {
23698
+ if (!muted) process.stdout.write(chunk);
23699
+ cb();
23700
+ }
23701
+ });
23702
+ const rl = createInterface2({ input: process.stdin, output, terminal: true });
23703
+ return new Promise((resolve7) => {
23704
+ rl.question(question, (answer) => {
23705
+ rl.close();
23706
+ if (hidden2) process.stdout.write("\n");
23707
+ resolve7(answer);
23708
+ });
23709
+ muted = hidden2;
23710
+ });
23711
+ }
23712
+ function createSetupIo(env = process.env) {
23713
+ let image = null;
23714
+ try {
23715
+ image = resolveSandboxImage(env, () => readShippedImage());
23716
+ } catch {
23717
+ image = null;
23718
+ }
23719
+ const runtime = new AppleContainerRuntime({ image: image ?? void 0 });
23720
+ const token = sandboxTokenLocation(env);
23721
+ let tempDir = null;
23722
+ return {
23723
+ env,
23724
+ image,
23725
+ run: async (cmd, args) => {
23726
+ const r = await execa(cmd, args, { reject: false, stdin: "ignore" });
23727
+ return {
23728
+ exitCode: r.exitCode ?? -1,
23729
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
23730
+ stderr: typeof r.stderr === "string" ? r.stderr : r.message ?? ""
23731
+ };
23732
+ },
23733
+ // On the terminal: sudo may ask for a password.
23734
+ sudo: async (args) => {
23735
+ const r = await execa("sudo", args, { reject: false, stdio: "inherit" });
23736
+ return { exitCode: r.exitCode ?? -1, stdout: "", stderr: "" };
23737
+ },
23738
+ print: (line) => console.log(line),
23739
+ confirm: async (q) => !/^n/i.test((await ask(`${q} [Y/n] `)).trim()),
23740
+ promptSecret: (q) => ask(q, true),
23741
+ readFile: (p) => readFile15(p, "utf8").catch(() => null),
23742
+ writeTemp: async (name, content) => {
23743
+ tempDir ??= await mkdtemp5(join25(tmpdir5(), "archme-sandbox-setup-"));
23744
+ const p = join25(tempDir, name);
23745
+ await writeFile14(p, content, { mode: 420 });
23746
+ return p;
23747
+ },
23748
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
23749
+ listNetworks: () => runtime.listNetworks(),
23750
+ ensureSlots: async (n2) => {
23751
+ await runtime.ensureSlots(n2);
23752
+ },
23753
+ listBoxes: () => runtime.list(),
23754
+ readToken: () => readSandboxClaudeToken({ claudeTokenPath: token.path, claudeTokenOverride: token.override }),
23755
+ storeToken: (t) => storeSandboxClaudeToken(t, { path: token.path, override: token.override }),
23756
+ probe: async (slot) => {
23757
+ if (!image) return { ok: false, slot: slot.index, failures: ["no sandbox image"], outcomes: {}, ms: 0 };
23758
+ const gates = new GatekeeperSet({
23759
+ slots: Array.from({ length: MAX_SLOTS }, (_, i2) => slotFor(i2)),
23760
+ getClaudeToken: () => "status-probe-has-no-token",
23761
+ mcpUpstream: "http://127.0.0.1:9/functions/v1/mcp",
23762
+ remint: async () => {
23763
+ throw new Error("no job");
23764
+ },
23765
+ logger: () => {
23766
+ }
23767
+ });
23768
+ let own = false;
23769
+ try {
23770
+ await gates.start();
23771
+ own = true;
23772
+ } catch {
23773
+ console.log(" (the daemon's gatekeepers are answering)");
23774
+ }
23775
+ try {
23776
+ return await runConfinementProbe({ runtime, image }, slot, primaryLanIpv4(networkInterfaces2()));
23777
+ } finally {
23778
+ if (own) await gates.stop();
23779
+ }
23780
+ },
23781
+ unitEnv: async () => {
23782
+ if (process.platform !== "darwin") return null;
23783
+ const plist = await readFile15(resolveUnitPath("darwin", env), "utf8").catch(() => null);
23784
+ return plist === null ? null : bakedUnitEnv(plist);
23785
+ }
23786
+ };
23787
+ }
23788
+ var init_setupIo = __esm({
23789
+ "src/daemon/sandbox/setupIo.ts"() {
23790
+ "use strict";
23791
+ init_execa();
23792
+ init_paths();
23793
+ init_appleContainer();
23794
+ init_claudeToken();
23795
+ init_confinement();
23796
+ init_imageManifest();
23797
+ init_pfAnchor();
23798
+ init_setting();
23799
+ init_slots();
23800
+ init_types();
23801
+ }
23802
+ });
23803
+
22782
23804
  // src/daemon/service/dispatch.ts
22783
23805
  var dispatch_exports2 = {};
22784
23806
  __export(dispatch_exports2, {
22785
23807
  dispatchDaemon: () => dispatchDaemon
22786
23808
  });
22787
23809
  import { rm as rm9 } from "node:fs/promises";
22788
- async function dispatchDaemon(route2) {
23810
+ async function dispatchDaemon(route2, deps = {}) {
23811
+ const { platform: platform3, env } = deps;
23812
+ const controller = () => deps.controller ?? pickController(platform3, env);
22789
23813
  switch (route2.action) {
22790
23814
  case "help":
22791
23815
  printDaemonHelp();
@@ -22803,7 +23827,7 @@ async function dispatchDaemon(route2) {
22803
23827
  }
22804
23828
  case "start": {
22805
23829
  try {
22806
- const out = await runStartFlow({ noLogin: route2.noLogin });
23830
+ const out = await runStartFlow({ noLogin: route2.noLogin, controller: controller(), platform: platform3, env });
22807
23831
  console.log(
22808
23832
  `${out.result.actions.includes("installed") ? "Installing " : out.result.actions.includes("reinstalled") ? "Reinstalling " : ""}service\u2026`
22809
23833
  );
@@ -22816,58 +23840,82 @@ async function dispatchDaemon(route2) {
22816
23840
  console.error(String(err4.message));
22817
23841
  return 75;
22818
23842
  }
23843
+ if (err4 instanceof CredentialStoreUnreadableError) {
23844
+ console.error(err4.message);
23845
+ console.error(
23846
+ "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."
23847
+ );
23848
+ return 1;
23849
+ }
22819
23850
  console.error(String(err4));
22820
23851
  return 1;
22821
23852
  }
22822
23853
  }
22823
23854
  case "stop": {
22824
- const c3 = pickController();
23855
+ const c3 = controller();
22825
23856
  await orchestrateStop(c3);
22826
23857
  console.log("Stopped.");
22827
23858
  return 0;
22828
23859
  }
22829
23860
  case "restart": {
22830
23861
  try {
22831
- await ensureStartPrerequisites({ noLogin: route2.noLogin });
23862
+ await ensureStartPrerequisites({ noLogin: route2.noLogin, controller: controller(), platform: platform3, env });
22832
23863
  } catch (err4) {
22833
23864
  if (err4 instanceof NotSignedInError) {
22834
23865
  console.error(String(err4.message));
22835
23866
  console.error("(daemon left running unchanged)");
22836
23867
  return 75;
22837
23868
  }
22838
- console.error(String(err4));
23869
+ console.error(err4 instanceof CredentialStoreUnreadableError ? err4.message : String(err4));
22839
23870
  console.error("(daemon left running unchanged)");
22840
23871
  return 1;
22841
23872
  }
22842
- const c3 = pickController();
23873
+ const c3 = controller();
22843
23874
  await orchestrateStop(c3);
22844
- const out = await runStartFlow({ noLogin: route2.noLogin });
23875
+ const out = await runStartFlow({
23876
+ noLogin: route2.noLogin,
23877
+ controller: c3,
23878
+ platform: platform3,
23879
+ env,
23880
+ isSignedIn: async () => true
23881
+ });
22845
23882
  console.log(`Restarted (${out.result.actions.join(" \u2192 ")}).`);
22846
23883
  return 0;
22847
23884
  }
22848
23885
  case "status": {
22849
- const c3 = pickController();
22850
23886
  const status = await gatherStatus({
22851
- controller: c3,
23887
+ controller: controller(),
22852
23888
  loadCredentials: defaultLoadCredentials,
22853
23889
  loadSupabaseState: defaultLoadSupabaseState,
22854
- loadUptime: defaultLoadUptime
23890
+ loadUptime: defaultLoadUptime,
23891
+ platform: platform3,
23892
+ env
22855
23893
  });
22856
23894
  const out = renderStatus(status);
22857
- process.stdout.write(out.rendered);
23895
+ (deps.stdout ?? process.stdout).write(out.rendered);
22858
23896
  return out.exitCode;
22859
23897
  }
23898
+ case "sandbox-setup": {
23899
+ const { runSandboxSetup: runSandboxSetup2 } = await Promise.resolve().then(() => (init_setupCommand(), setupCommand_exports));
23900
+ const { createSetupIo: createSetupIo2 } = await Promise.resolve().then(() => (init_setupIo(), setupIo_exports));
23901
+ return await runSandboxSetup2(createSetupIo2(), { yes: route2.yes });
23902
+ }
23903
+ case "sandbox-status": {
23904
+ const { runSandboxStatus: runSandboxStatus2 } = await Promise.resolve().then(() => (init_setupCommand(), setupCommand_exports));
23905
+ const { createSetupIo: createSetupIo2 } = await Promise.resolve().then(() => (init_setupIo(), setupIo_exports));
23906
+ return await runSandboxStatus2(createSetupIo2());
23907
+ }
22860
23908
  case "logs":
22861
23909
  await tailLog({ lines: route2.lines, follow: route2.follow });
22862
23910
  return 0;
22863
23911
  case "enable": {
22864
- const c3 = pickController();
23912
+ const c3 = controller();
22865
23913
  await c3.enable();
22866
23914
  console.log("Enabled at login.");
22867
23915
  return 0;
22868
23916
  }
22869
23917
  case "disable": {
22870
- const c3 = pickController();
23918
+ const c3 = controller();
22871
23919
  await c3.disable();
22872
23920
  console.log("Disabled at login. Existing instance keeps running until `archme daemon stop`.");
22873
23921
  return 0;
@@ -22878,7 +23926,7 @@ async function dispatchDaemon(route2) {
22878
23926
  return 0;
22879
23927
  }
22880
23928
  case "uninstall": {
22881
- const c3 = pickController();
23929
+ const c3 = controller();
22882
23930
  await orchestrateStop(c3);
22883
23931
  await c3.uninstall();
22884
23932
  if (route2.purge) {
@@ -22900,7 +23948,13 @@ async function defaultLoadCredentials() {
22900
23948
  return null;
22901
23949
  }
22902
23950
  const { loadCredentials: loadCredentials2 } = await Promise.resolve().then(() => (init_credentials(), credentials_exports));
22903
- const { credentials } = await loadCredentials2(supabaseUrl);
23951
+ let credentials;
23952
+ try {
23953
+ ({ credentials } = await loadCredentials2(supabaseUrl));
23954
+ } catch (err4) {
23955
+ if (err4 instanceof CredentialStoreUnreadableError) return { unreadable: err4.message };
23956
+ throw err4;
23957
+ }
22904
23958
  if (!credentials) return null;
22905
23959
  const email = decodeJwtEmail(credentials.access_token) ?? credentials.user_id ?? null;
22906
23960
  return { email, deviceId: credentials.device_id ?? null };
@@ -22923,8 +23977,8 @@ async function defaultLoadSupabaseState(_deviceId) {
22923
23977
  async function defaultLoadUptime(pid) {
22924
23978
  if (process.platform === "linux") {
22925
23979
  try {
22926
- const { readFile: readFile19 } = await import("node:fs/promises");
22927
- const stat12 = await readFile19(`/proc/${pid}/stat`, "utf8");
23980
+ const { readFile: readFile20 } = await import("node:fs/promises");
23981
+ const stat12 = await readFile20(`/proc/${pid}/stat`, "utf8");
22928
23982
  const parts = stat12.split(") ").pop()?.split(" ") ?? [];
22929
23983
  const ticks = Number(parts[19]);
22930
23984
  if (!Number.isFinite(ticks)) return null;
@@ -22967,6 +24021,8 @@ Usage:
22967
24021
  archme daemon install [--no-login] Write unit file, do not load
22968
24022
  archme daemon uninstall [--purge] Unload + remove unit (logs kept; --purge removes them too)
22969
24023
  archme daemon run Foreground worker (what the service unit invokes)
24024
+ archme daemon sandbox setup [--yes] Once per machine: image, Claude token, slot networks, pf (sudo)
24025
+ archme daemon sandbox status Check the sandbox confinement holds (runs a probe box)
22970
24026
  `);
22971
24027
  }
22972
24028
  var init_dispatch2 = __esm({
@@ -22977,6 +24033,7 @@ var init_dispatch2 = __esm({
22977
24033
  init_logs();
22978
24034
  init_status();
22979
24035
  init_paths();
24036
+ init_credentials();
22980
24037
  }
22981
24038
  });
22982
24039
 
@@ -23035,6 +24092,7 @@ async function createAuthedClient(opts) {
23035
24092
  try {
23036
24093
  loaded = await loadStoredAndRefresh();
23037
24094
  } catch (err4) {
24095
+ if (err4 instanceof CredentialStoreError) throw err4;
23038
24096
  const msg = err4 instanceof Error ? err4.message : String(err4);
23039
24097
  throw new Error(`${msg}
23040
24098
  \u2192 run \`archme login\` first`);
@@ -23063,6 +24121,7 @@ var init_authedClient = __esm({
23063
24121
  "use strict";
23064
24122
  init_bearer();
23065
24123
  init_identity();
24124
+ init_credentials();
23066
24125
  SCAN_CLAUDE_TIMEOUT_MS = 45 * 60 * 1e3;
23067
24126
  SCAN_PRE_SPAWN_TOKEN_MIN_LIFETIME_MS = SCAN_CLAUDE_TIMEOUT_MS + 5 * 60 * 1e3;
23068
24127
  ORCHESTRATED_SCAN_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
@@ -23114,8 +24173,8 @@ __export(open_exports, {
23114
24173
  runOpen: () => runOpen,
23115
24174
  safeNextPath: () => safeNextPath
23116
24175
  });
23117
- import { readFile as readFile15 } from "node:fs/promises";
23118
- import { join as join25 } from "node:path";
24176
+ import { readFile as readFile16 } from "node:fs/promises";
24177
+ import { join as join26 } from "node:path";
23119
24178
  function safeNextPath(path7) {
23120
24179
  if (!path7 || !path7.startsWith("/") || path7.startsWith("//") || path7.startsWith("/\\")) return "/";
23121
24180
  return path7;
@@ -23202,7 +24261,7 @@ async function defaultOpenBrowser(url) {
23202
24261
  }
23203
24262
  async function defaultReadMcpJson(cwd) {
23204
24263
  try {
23205
- return await readFile15(join25(cwd, ".mcp.json"), "utf8");
24264
+ return await readFile16(join26(cwd, ".mcp.json"), "utf8");
23206
24265
  } catch {
23207
24266
  return null;
23208
24267
  }
@@ -23299,7 +24358,14 @@ function pickKey(keys, handle, active) {
23299
24358
  }
23300
24359
  async function runKey(cmd, deps) {
23301
24360
  const keys = await deps.listKeys();
23302
- const active = await deps.loadActive();
24361
+ let active;
24362
+ try {
24363
+ active = await deps.loadActive();
24364
+ } catch (err4) {
24365
+ if (!(err4 instanceof CredentialStoreError)) throw err4;
24366
+ deps.error(`${err4.message} Which key is signed in can't be shown.`);
24367
+ active = null;
24368
+ }
23303
24369
  if (cmd.action === "list") {
23304
24370
  if (keys.length === 0) {
23305
24371
  deps.log("No saved keys on this machine.");
@@ -23369,6 +24435,7 @@ Usage: archme key [list|show|forget] [--handle <handle>] [--yes]`);
23369
24435
  var init_key = __esm({
23370
24436
  "src/account/key.ts"() {
23371
24437
  "use strict";
24438
+ init_credentials();
23372
24439
  }
23373
24440
  });
23374
24441
 
@@ -23562,8 +24629,8 @@ var init_transcript_signals = __esm({
23562
24629
  });
23563
24630
 
23564
24631
  // src/scan/report.ts
23565
- import { mkdir as mkdir14, writeFile as writeFile14 } from "node:fs/promises";
23566
- import { dirname as dirname16 } from "node:path";
24632
+ import { mkdir as mkdir15, writeFile as writeFile15 } from "node:fs/promises";
24633
+ import { dirname as dirname17 } from "node:path";
23567
24634
  async function buildReport(input) {
23568
24635
  return {
23569
24636
  schema_version: REPORT_SCHEMA_VERSION,
@@ -23597,10 +24664,10 @@ async function buildReport(input) {
23597
24664
  };
23598
24665
  }
23599
24666
  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(
24667
+ await mkdir15(dirname17(reportPath), { recursive: true });
24668
+ await writeFile15(reportPath, JSON.stringify(report, null, 2) + "\n", "utf8");
24669
+ await mkdir15(dirname17(orphansTxtPath), { recursive: true });
24670
+ await writeFile15(
23604
24671
  orphansTxtPath,
23605
24672
  orphansFull.join("\n") + (orphansFull.length ? "\n" : ""),
23606
24673
  "utf8"
@@ -23644,8 +24711,8 @@ var init_claimDefaultProject = __esm({
23644
24711
  // src/scan/run-scan.ts
23645
24712
  import { randomUUID as randomUUID3 } from "node:crypto";
23646
24713
  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";
24714
+ import { mkdir as mkdir16, readFile as readFile17, writeFile as writeFile16 } from "node:fs/promises";
24715
+ import { basename as basename2, dirname as dirname18, join as join27 } from "node:path";
23649
24716
  function modeInstruction(opts) {
23650
24717
  const line = modeLine(opts);
23651
24718
  if (opts.mode === "orchestrator" && opts.plan) {
@@ -23701,13 +24768,13 @@ Use scan-prompt variant \`${promptVariant}\` \u2014 pass \`variant: "${promptVar
23701
24768
  return base;
23702
24769
  }
23703
24770
  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");
24771
+ const path7 = join27(repoPath, SCAN_SESSION_FILE);
24772
+ await mkdir16(dirname18(path7), { recursive: true });
24773
+ await writeFile16(path7, JSON.stringify(session, null, 2) + "\n");
23707
24774
  }
23708
24775
  async function readScanSession(repoPath) {
23709
- const path7 = join26(repoPath, SCAN_SESSION_FILE);
23710
- const raw = await readFile16(path7, "utf8").catch(() => null);
24776
+ const path7 = join27(repoPath, SCAN_SESSION_FILE);
24777
+ const raw = await readFile17(path7, "utf8").catch(() => null);
23711
24778
  if (raw === null) throw new Error(`no scan session to resume at ${path7} \u2014 launch it instead`);
23712
24779
  let parsed;
23713
24780
  try {
@@ -24098,9 +25165,9 @@ async function runScan(opts, deps = {}) {
24098
25165
  // Incompatible with an interactive TTY session.
24099
25166
  streamJson: !interactive
24100
25167
  });
24101
- const transcriptDir = join26(opts.repoPath, ".archme", "scan");
24102
- const transcriptPath = join26(transcriptDir, "transcript.log");
24103
- await mkdir15(transcriptDir, { recursive: true });
25168
+ const transcriptDir = join27(opts.repoPath, ".archme", "scan");
25169
+ const transcriptPath = join27(transcriptDir, "transcript.log");
25170
+ await mkdir16(transcriptDir, { recursive: true });
24104
25171
  const transcriptStream = createWriteStream2(transcriptPath, { flags: opts.resume ? "a" : "w" });
24105
25172
  let finalResultText = "";
24106
25173
  const echo = opts.echo ?? ((line) => process.stdout.write(line + "\n"));
@@ -24143,9 +25210,9 @@ async function runScan(opts, deps = {}) {
24143
25210
  await new Promise((resolve7, reject) => {
24144
25211
  transcriptStream.end((err4) => err4 ? reject(err4) : resolve7());
24145
25212
  });
24146
- const reportPath = join26(transcriptDir, "report.json");
24147
- const orphansTxtPath = join26(transcriptDir, "orphans.txt");
24148
- const transcript = await readFile16(transcriptPath, "utf8").catch(() => "");
25213
+ const reportPath = join27(transcriptDir, "report.json");
25214
+ const orphansTxtPath = join27(transcriptDir, "orphans.txt");
25215
+ const transcript = await readFile17(transcriptPath, "utf8").catch(() => "");
24149
25216
  signals2 = extractTranscriptSignals(transcript);
24150
25217
  const architecture = await fetchArchitectureDetail(supabase, resolved2.projectId, branchName2);
24151
25218
  const orphans = await classifyOrphans({
@@ -24299,7 +25366,7 @@ var init_scanOutcome = __esm({
24299
25366
 
24300
25367
  // src/init/repoSize.ts
24301
25368
  import { stat as stat9 } from "node:fs/promises";
24302
- import { extname, join as join27 } from "node:path";
25369
+ import { extname, join as join28 } from "node:path";
24303
25370
  async function measureRepo(repoPath) {
24304
25371
  const files = await gitListFiles(repoPath);
24305
25372
  if (!files) return { sourceFiles: 0, sourceBytes: 0, totalFiles: 0 };
@@ -24308,7 +25375,7 @@ async function measureRepo(repoPath) {
24308
25375
  for (const rel of files) {
24309
25376
  if (!SOURCE_EXTENSIONS.has(extname(rel).toLowerCase())) continue;
24310
25377
  try {
24311
- const s = await stat9(join27(repoPath, rel));
25378
+ const s = await stat9(join28(repoPath, rel));
24312
25379
  if (!s.isFile()) continue;
24313
25380
  sourceFiles++;
24314
25381
  sourceBytes += s.size;
@@ -24944,10 +26011,10 @@ __export(localWatch_exports, {
24944
26011
  watchLocalRepoRow: () => watchLocalRepoRow
24945
26012
  });
24946
26013
  import { homedir as homedir9 } from "node:os";
24947
- import { join as join28, resolve as resolve2 } from "node:path";
26014
+ import { join as join29, resolve as resolve2 } from "node:path";
24948
26015
  import { stat as stat10 } from "node:fs/promises";
24949
26016
  function mirrorPathFor(cloneBaseDir, owner, name) {
24950
- return join28(cloneBaseDir, owner, name);
26017
+ return join29(cloneBaseDir, owner, name);
24951
26018
  }
24952
26019
  function resolveInitTarget(input) {
24953
26020
  if (!input.originUrl) {
@@ -25119,7 +26186,7 @@ async function registerLocalWatch(args) {
25119
26186
  if (!watched.ok) return { ok: false, reason: watched.reason, exitCode: 3 };
25120
26187
  const { watchedRepoId } = watched;
25121
26188
  const mirrorPath = mirrorPathFor(cloneBaseDir, target.repoOwner, target.repoName);
25122
- const mirrorExists = await stat10(join28(mirrorPath, ".git")).then(() => true).catch(() => false);
26189
+ const mirrorExists = await stat10(join29(mirrorPath, ".git")).then(() => true).catch(() => false);
25123
26190
  if (mirrorExists) {
25124
26191
  log(` mirror: ${mirrorPath} (reusing)`);
25125
26192
  await setOriginUrl(mirrorPath, originUrl);
@@ -25266,7 +26333,7 @@ var init_localWatch = __esm({
25266
26333
  init_localWatchResolve();
25267
26334
  init_prompt();
25268
26335
  init_localPrSetup();
25269
- DEFAULT_CLONE_BASE_DIR2 = join28(homedir9(), ".archme-job-runner", "clones");
26336
+ DEFAULT_CLONE_BASE_DIR2 = join29(homedir9(), ".archme-job-runner", "clones");
25270
26337
  }
25271
26338
  });
25272
26339
 
@@ -25299,9 +26366,9 @@ var init_plan = __esm({
25299
26366
  });
25300
26367
 
25301
26368
  // 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";
26369
+ import { mkdtemp as mkdtemp6, rm as rm10, writeFile as writeFile17 } from "node:fs/promises";
26370
+ import { tmpdir as tmpdir6 } from "node:os";
26371
+ import { join as join30 } from "node:path";
25305
26372
  async function defaultStartDaemon() {
25306
26373
  const { runStartFlow: runStartFlow2 } = await Promise.resolve().then(() => (init_install(), install_exports));
25307
26374
  const out = await runStartFlow2({ noLogin: true });
@@ -25346,10 +26413,10 @@ async function defaultFetchInstallScript(url) {
25346
26413
  return await resp.text();
25347
26414
  }
25348
26415
  async function defaultRunInstallScript(script, cwd) {
25349
- const dir = await mkdtemp5(join29(tmpdir5(), "archme-init-"));
25350
- const scriptPath = join29(dir, "install-agent.sh");
26416
+ const dir = await mkdtemp6(join30(tmpdir6(), "archme-init-"));
26417
+ const scriptPath = join30(dir, "install-agent.sh");
25351
26418
  try {
25352
- await writeFile16(scriptPath, script, { mode: 448 });
26419
+ await writeFile17(scriptPath, script, { mode: 448 });
25353
26420
  await execa("sh", [scriptPath], { cwd, stdio: "inherit" });
25354
26421
  } finally {
25355
26422
  await rm10(dir, { recursive: true, force: true });
@@ -26895,11 +27962,11 @@ var init_dispatch3 = __esm({
26895
27962
  // src/init/prReviews.ts
26896
27963
  async function offerPrReviewsForInit(ctx, flag, deps = {}) {
26897
27964
  const readMode = deps.readMode ?? readPrReviewMode;
26898
- const ask = deps.ask ?? askForPrReviews;
27965
+ const ask2 = deps.ask ?? askForPrReviews;
26899
27966
  const setMode = deps.setMode ?? setPrReviewMode;
26900
27967
  const ghLogin = deps.ghLogin ?? ghLoginPrompt;
26901
27968
  const current = await readMode(ctx.supabase, ctx.watchedRepoId);
26902
- const want = await ask({ flag, interactive: ctx.interactive, current });
27969
+ const want = await ask2({ flag, interactive: ctx.interactive, current });
26903
27970
  if (want === null) {
26904
27971
  if (current === "auto") ctx.log(`\u2713 ${PR_REVIEWS_ALREADY_ON}`);
26905
27972
  return;
@@ -27325,7 +28392,7 @@ __export(run_uninstall_exports, {
27325
28392
  runUninstall: () => runUninstall
27326
28393
  });
27327
28394
  import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
27328
- import { join as join30 } from "node:path";
28395
+ import { join as join31 } from "node:path";
27329
28396
  function readIfExists(path7) {
27330
28397
  try {
27331
28398
  return existsSync3(path7) ? readFileSync6(path7, "utf8") : null;
@@ -27351,7 +28418,7 @@ async function runUninstall(opts) {
27351
28418
  const backend = opts.backend ?? await defaultBackend();
27352
28419
  const repoPath = await backend.gitRoot(opts.repoPath).catch(() => null) ?? opts.repoPath;
27353
28420
  if (repoPath !== opts.repoPath) log(`Using the repo root: ${repoPath}`);
27354
- const abs = (rel) => join30(repoPath, rel);
28421
+ const abs = (rel) => join31(repoPath, rel);
27355
28422
  const actions = [];
27356
28423
  const skipped = [];
27357
28424
  const mcpRaw = readIfExists(abs(".mcp.json"));
@@ -27931,7 +28998,7 @@ __export(run_exports3, {
27931
28998
  watchedElsewhere: () => watchedElsewhere
27932
28999
  });
27933
29000
  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";
29001
+ import { dirname as dirname19, join as join32, resolve as resolve6, sep as sep2 } from "node:path";
27935
29002
  function isThisRunsWrite(metadata) {
27936
29003
  return metadata?.kind === "scan" || !!metadata?.repo_fold;
27937
29004
  }
@@ -27961,12 +29028,12 @@ async function listClones(dir) {
27961
29028
  const out = [];
27962
29029
  for (const e of entries) {
27963
29030
  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));
29031
+ if (await stat11(join32(dir, e.name, ".git")).then(() => true, () => false)) out.push(join32(dir, e.name));
27965
29032
  }
27966
29033
  return out.sort();
27967
29034
  }
27968
29035
  function commonDir(paths) {
27969
- const split = paths.map((p) => dirname18(p).split(sep2));
29036
+ const split = paths.map((p) => dirname19(p).split(sep2));
27970
29037
  const common2 = [];
27971
29038
  for (let i2 = 0; split.every((s) => s[i2] !== void 0 && s[i2] === split[0][i2]); i2++) common2.push(split[0][i2]);
27972
29039
  return common2.join(sep2) || sep2;
@@ -28001,7 +29068,7 @@ function buildRunSheet(opts) {
28001
29068
  `- Clone: \`${r.root}\``,
28002
29069
  `- Launch: \`${launch(r)}\``,
28003
29070
  `- Resume: \`${launch(r)} --resume --message "<answer or correction>"\``,
28004
- `- Session file: \`${join31(r.root, SCAN_SESSION_FILE)}\``
29071
+ `- Session file: \`${join32(r.root, SCAN_SESSION_FILE)}\``
28005
29072
  ])
28006
29073
  ];
28007
29074
  if (opts.context.length > 0) {
@@ -28050,7 +29117,7 @@ async function runAddRepos(opts, deps = {}) {
28050
29117
  roots.push(found.root);
28051
29118
  continue;
28052
29119
  }
28053
- const dest = join31(cwd, arg.split("/")[1]);
29120
+ const dest = join32(cwd, arg.split("/")[1]);
28054
29121
  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
29122
  log(`cloning ${arg} into ${dest}`);
28056
29123
  const cloned = await (deps.cloneRepo ?? cloneFromGitHub)(arg, dest);
@@ -28422,8 +29489,8 @@ var init_dispatch7 = __esm({
28422
29489
  });
28423
29490
 
28424
29491
  // src/doctor/collect.ts
28425
- import { readFile as readFile17 } from "node:fs/promises";
28426
- import { join as join32 } from "node:path";
29492
+ import { readFile as readFile18 } from "node:fs/promises";
29493
+ import { join as join33 } from "node:path";
28427
29494
  function binaryPresent(name) {
28428
29495
  try {
28429
29496
  requireBinary(name);
@@ -28439,7 +29506,7 @@ async function git3(cwd, args) {
28439
29506
  async function readMcpPin(repoRoot) {
28440
29507
  const empty = { installed: false, pin: { project: null, branch: null } };
28441
29508
  try {
28442
- const raw = await readFile17(join32(repoRoot, ".mcp.json"), "utf8");
29509
+ const raw = await readFile18(join33(repoRoot, ".mcp.json"), "utf8");
28443
29510
  const parsed = JSON.parse(raw);
28444
29511
  const servers = parsed.mcpServers ?? {};
28445
29512
  const entry = Object.entries(servers).find(([name]) => /archme/i.test(name));
@@ -28813,9 +29880,9 @@ __export(dispatch_exports8, {
28813
29880
  parseDoctorArgs: () => parseDoctorArgs,
28814
29881
  printDoctorHelp: () => printDoctorHelp
28815
29882
  });
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";
29883
+ import { mkdtemp as mkdtemp7, rm as rm11, writeFile as writeFile18 } from "node:fs/promises";
29884
+ import { tmpdir as tmpdir7 } from "node:os";
29885
+ import { join as join34 } from "node:path";
28819
29886
  function parseDoctorArgs(argv) {
28820
29887
  const args = { plain: false, help: false };
28821
29888
  for (const a2 of argv) {
@@ -28876,9 +29943,9 @@ async function dispatchDoctor(argv) {
28876
29943
  );
28877
29944
  return EX_OK6;
28878
29945
  }
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 });
29946
+ const dir = await mkdtemp7(join34(tmpdir7(), "archme-doctor-"));
29947
+ const mcpConfigPath = join34(dir, "mcp.json");
29948
+ await writeFile18(mcpConfigPath, JSON.stringify({ mcpServers: {} }), { mode: 384 });
28882
29949
  try {
28883
29950
  const result = await spawnClaude({
28884
29951
  prompt: buildDoctorPrompt(facts, issues),
@@ -29045,7 +30112,7 @@ var init_conciergeToken = __esm({
29045
30112
  });
29046
30113
 
29047
30114
  // src/daemon/sandboxJob/input.ts
29048
- import { readFile as readFile18 } from "node:fs/promises";
30115
+ import { readFile as readFile19 } from "node:fs/promises";
29049
30116
  function isObj2(v) {
29050
30117
  return typeof v === "object" && v !== null && !Array.isArray(v);
29051
30118
  }
@@ -29154,6 +30221,31 @@ function parseRepoScope(raw) {
29154
30221
  }
29155
30222
  fail("repoScope must be { kind: all | container | excluding }");
29156
30223
  }
30224
+ function parseProbe(data) {
30225
+ const timeoutMs = data.timeoutMs;
30226
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) fail("timeoutMs must be a positive integer");
30227
+ if (!Array.isArray(data.targets) || data.targets.length > 64) fail("targets must be an array of at most 64");
30228
+ const targets = data.targets.map((raw, i2) => {
30229
+ const where = `targets[${i2}]`;
30230
+ if (!isObj2(raw)) fail(`${where} must be an object`);
30231
+ const id = str3(raw, "id", where);
30232
+ if (!PROBE_ID_RE.test(id)) fail(`${where}.id must be [a-z0-9_]`);
30233
+ const kind = raw.kind;
30234
+ if (!PROBE_KINDS.has(kind)) fail(`${where}.kind is not a probe kind`);
30235
+ const host = str3(raw, "host", where);
30236
+ if (kind === "ipv6_socket" ? host !== "::" : !IPV4_RE2.test(host)) fail(`${where}.host must be an IPv4 address`);
30237
+ const port = raw.port;
30238
+ if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`${where}.port is not a port`);
30239
+ const target = { id, kind, host, port };
30240
+ if (raw.authority !== void 0) {
30241
+ const a2 = raw.authority;
30242
+ if (kind !== "connect" || typeof a2 !== "string" || !/^[A-Za-z0-9.\-[\]:]{1,300}$/.test(a2)) fail(`${where}.authority is not host:port`);
30243
+ target.authority = a2;
30244
+ }
30245
+ return target;
30246
+ });
30247
+ return { kind: "probe", targets, timeoutMs };
30248
+ }
29157
30249
  function parseInput(raw) {
29158
30250
  let data;
29159
30251
  try {
@@ -29162,6 +30254,7 @@ function parseInput(raw) {
29162
30254
  fail("not valid JSON");
29163
30255
  }
29164
30256
  if (!isObj2(data)) fail("must be a JSON object");
30257
+ if (data.kind === "probe") return parseProbe(data);
29165
30258
  const job = parseJob(data.job);
29166
30259
  if (data.kind === "clone") {
29167
30260
  const input = {
@@ -29192,9 +30285,9 @@ function parseInput(raw) {
29192
30285
  };
29193
30286
  return input;
29194
30287
  }
29195
- fail("kind must be clone or agent");
30288
+ fail("kind must be clone, agent or probe");
29196
30289
  }
29197
- async function readInput(path7 = INPUT_PATH, read = (p) => readFile18(p, "utf8")) {
30290
+ async function readInput(path7 = INPUT_PATH, read = (p) => readFile19(p, "utf8")) {
29198
30291
  let raw;
29199
30292
  try {
29200
30293
  raw = await read(path7);
@@ -29203,7 +30296,7 @@ async function readInput(path7 = INPUT_PATH, read = (p) => readFile18(p, "utf8")
29203
30296
  }
29204
30297
  return parseInput(raw);
29205
30298
  }
29206
- var InputSchemaError, SHA_RE3, REPO_PART_RE, IPV4_RE2, SOURCE_MODES;
30299
+ var InputSchemaError, SHA_RE3, REPO_PART_RE, IPV4_RE2, SOURCE_MODES, PROBE_KINDS, PROBE_ID_RE;
29207
30300
  var init_input = __esm({
29208
30301
  "src/daemon/sandboxJob/input.ts"() {
29209
30302
  "use strict";
@@ -29218,6 +30311,135 @@ var init_input = __esm({
29218
30311
  REPO_PART_RE = /^[A-Za-z0-9_.-]+$/;
29219
30312
  IPV4_RE2 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
29220
30313
  SOURCE_MODES = /* @__PURE__ */ new Set(["diff", "reconcile", "pr_update"]);
30314
+ PROBE_KINDS = /* @__PURE__ */ new Set(["tcp", "udp_dns", "http", "connect", "ipv6_socket"]);
30315
+ PROBE_ID_RE = /^[a-z0-9_]{1,64}$/;
30316
+ }
30317
+ });
30318
+
30319
+ // src/daemon/sandboxJob/probe.ts
30320
+ import dgram from "node:dgram";
30321
+ import http2 from "node:http";
30322
+ import net2 from "node:net";
30323
+ function tcp(t, timeoutMs) {
30324
+ return new Promise((resolve7) => {
30325
+ const s = net2.connect({ host: t.host, port: t.port });
30326
+ const finish = (outcome) => {
30327
+ s.destroy();
30328
+ resolve7(outcome);
30329
+ };
30330
+ s.setTimeout(timeoutMs, () => finish("timeout"));
30331
+ s.once("connect", () => finish("connected"));
30332
+ s.once("error", (e) => {
30333
+ finish(e.code === "ECONNREFUSED" ? "refused" : e.code === "ETIMEDOUT" ? "timeout" : "unreachable");
30334
+ });
30335
+ });
30336
+ }
30337
+ function udpDns(t, timeoutMs) {
30338
+ return new Promise((resolve7) => {
30339
+ const s = dgram.createSocket("udp4");
30340
+ let done = false;
30341
+ const finish = (outcome) => {
30342
+ if (done) return;
30343
+ done = true;
30344
+ clearTimeout(timer);
30345
+ s.close();
30346
+ resolve7(outcome);
30347
+ };
30348
+ const timer = setTimeout(() => finish("silent"), timeoutMs);
30349
+ s.on("message", () => finish("answered"));
30350
+ s.on("error", () => finish("silent"));
30351
+ s.send(DNS_QUERY, t.port, t.host, (err4) => {
30352
+ if (err4) finish("silent");
30353
+ });
30354
+ });
30355
+ }
30356
+ function httpStatus(t, timeoutMs, method) {
30357
+ return new Promise((resolve7) => {
30358
+ let done = false;
30359
+ const finish = (outcome) => {
30360
+ if (done) return;
30361
+ done = true;
30362
+ req.destroy();
30363
+ resolve7(outcome);
30364
+ };
30365
+ const req = http2.request({
30366
+ host: t.host,
30367
+ port: t.port,
30368
+ method,
30369
+ agent: false,
30370
+ timeout: timeoutMs,
30371
+ path: method === "CONNECT" ? t.authority ?? "github.com:443" : "/"
30372
+ });
30373
+ req.on("response", (res) => {
30374
+ res.resume();
30375
+ finish(String(res.statusCode ?? "none"));
30376
+ });
30377
+ req.on("connect", (res, socket) => {
30378
+ socket.destroy();
30379
+ finish(String(res.statusCode ?? "none"));
30380
+ });
30381
+ req.on("timeout", () => finish("none"));
30382
+ req.on("error", () => finish("none"));
30383
+ req.end();
30384
+ });
30385
+ }
30386
+ function ipv6Socket() {
30387
+ return new Promise((resolve7) => {
30388
+ const s = dgram.createSocket("udp6");
30389
+ s.once("error", () => {
30390
+ s.close();
30391
+ resolve7("unsupported");
30392
+ });
30393
+ s.bind(0, "::", () => {
30394
+ s.close();
30395
+ resolve7("created");
30396
+ });
30397
+ });
30398
+ }
30399
+ async function runProbe(input) {
30400
+ const results = await Promise.all(input.targets.map(async (t) => {
30401
+ switch (t.kind) {
30402
+ case "tcp":
30403
+ return [t.id, await tcp(t, input.timeoutMs)];
30404
+ case "udp_dns":
30405
+ return [t.id, await udpDns(t, input.timeoutMs)];
30406
+ case "http":
30407
+ return [t.id, await httpStatus(t, input.timeoutMs, "GET")];
30408
+ case "connect":
30409
+ return [t.id, await httpStatus(t, input.timeoutMs, "CONNECT")];
30410
+ case "ipv6_socket":
30411
+ return [t.id, await ipv6Socket()];
30412
+ }
30413
+ }));
30414
+ return Object.fromEntries(results);
30415
+ }
30416
+ var DNS_QUERY;
30417
+ var init_probe = __esm({
30418
+ "src/daemon/sandboxJob/probe.ts"() {
30419
+ "use strict";
30420
+ DNS_QUERY = Buffer.from([
30421
+ 18,
30422
+ 52,
30423
+ 1,
30424
+ 0,
30425
+ 0,
30426
+ 1,
30427
+ 0,
30428
+ 0,
30429
+ 0,
30430
+ 0,
30431
+ 0,
30432
+ 0,
30433
+ 7,
30434
+ ...Buffer.from("example"),
30435
+ 3,
30436
+ ...Buffer.from("com"),
30437
+ 0,
30438
+ 0,
30439
+ 1,
30440
+ 0,
30441
+ 1
30442
+ ]);
29221
30443
  }
29222
30444
  });
29223
30445
 
@@ -29227,7 +30449,7 @@ __export(main_exports, {
29227
30449
  readNonceLine: () => readNonceLine,
29228
30450
  runSandboxJob: () => runSandboxJob
29229
30451
  });
29230
- import { mkdir as mkdir16 } from "node:fs/promises";
30452
+ import { mkdir as mkdir17 } from "node:fs/promises";
29231
30453
  function readNonceLine(stream = process.stdin) {
29232
30454
  return new Promise((resolve7) => {
29233
30455
  let buf = "";
@@ -29252,6 +30474,10 @@ function readNonceLine(stream = process.stdin) {
29252
30474
  });
29253
30475
  }
29254
30476
  function emptyResult(step, error) {
30477
+ if (step === "probe") {
30478
+ const r2 = { kind: "probe", outcomes: {}, memoryPeakBytes: null, durations_ms: {}, error };
30479
+ return r2;
30480
+ }
29255
30481
  if (step === "clone") {
29256
30482
  const r2 = { kind: "clone", outcome: "diff", memoryPeakBytes: null, durations_ms: {}, error };
29257
30483
  return r2;
@@ -29299,8 +30525,8 @@ async function runSandboxJob(argv, deps = {}) {
29299
30525
  return code;
29300
30526
  };
29301
30527
  const step = argv[0];
29302
- if (step !== "clone" && step !== "agent") {
29303
- const msg = `unknown step ${JSON.stringify(step ?? "")}; expected clone or agent`;
30528
+ if (step !== "clone" && step !== "agent" && step !== "probe") {
30529
+ const msg = `unknown step ${JSON.stringify(step ?? "")}; expected clone, agent or probe`;
29304
30530
  log(msg);
29305
30531
  return emit2(emptyResult("clone", msg), EXIT_INPUT_SCHEMA);
29306
30532
  }
@@ -29314,9 +30540,19 @@ async function runSandboxJob(argv, deps = {}) {
29314
30540
  return emit2(emptyResult(step, msg), err4 instanceof InputSchemaError ? EXIT_INPUT_SCHEMA : 1);
29315
30541
  }
29316
30542
  mark("input");
30543
+ if (input.kind === "probe") {
30544
+ try {
30545
+ const outcomes = await (deps.probe ?? runProbe)(input);
30546
+ return emit2({ kind: "probe", outcomes, memoryPeakBytes: null, durations_ms: {} }, 0);
30547
+ } catch (err4) {
30548
+ const msg = err4 instanceof Error ? err4.message : String(err4);
30549
+ log(`failed: ${msg}`);
30550
+ return emit2(emptyResult("probe", msg), 1);
30551
+ }
30552
+ }
29317
30553
  log(`${step} job=${input.job.id} repo=${input.job.repo_owner}/${input.job.repo_name} after=${input.job.after_sha.slice(0, 8)}`);
29318
30554
  try {
29319
- await mkdir16(deps.homeDir ?? process.env.HOME ?? BOX_HOME, { recursive: true, mode: 448 });
30555
+ await mkdir17(deps.homeDir ?? process.env.HOME ?? BOX_HOME, { recursive: true, mode: 448 });
29320
30556
  if (input.kind === "clone") {
29321
30557
  const { result: result2, exitCode: exitCode2 } = await runCloneStep(input, { log, mark, ...deps.clone });
29322
30558
  return emit2({ ...result2, memoryPeakBytes: null, durations_ms: {} }, exitCode2);
@@ -29338,6 +30574,7 @@ var init_main2 = __esm({
29338
30574
  init_result2();
29339
30575
  init_clone();
29340
30576
  init_agent();
30577
+ init_probe();
29341
30578
  NONCE_LINE_MAX_BYTES = 256;
29342
30579
  }
29343
30580
  });
@@ -29416,7 +30653,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
29416
30653
  "doctor",
29417
30654
  // Hidden: operator-only (platform admins), deliberately absent from printHelp.
29418
30655
  "concierge-token",
29419
- // Hidden: the per-job sandbox image's entrypoint (`sandbox-job clone|agent`).
30656
+ // Hidden: the per-job sandbox image's entrypoint (`sandbox-job clone|agent|probe`).
29420
30657
  "sandbox-job"
29421
30658
  ]);
29422
30659
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["--version", "-v"]);
@@ -29439,7 +30676,8 @@ var DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set([
29439
30676
  "disable",
29440
30677
  "install",
29441
30678
  "uninstall",
29442
- "run"
30679
+ "run",
30680
+ "sandbox"
29443
30681
  ]);
29444
30682
  function routeDaemon(args) {
29445
30683
  const sub = args[0];
@@ -29473,6 +30711,11 @@ function routeDaemon(args) {
29473
30711
  return { action: "uninstall", purge: hasFlag("--purge") };
29474
30712
  case "run":
29475
30713
  return { action: "run" };
30714
+ case "sandbox": {
30715
+ if (rest[0] === "setup") return { action: "sandbox-setup", yes: rest.includes("--yes") || rest.includes("-y") };
30716
+ if (rest[0] === "status") return { action: "sandbox-status" };
30717
+ return { action: "unknown", arg: ["sandbox", ...rest.slice(0, 1)].join(" ") };
30718
+ }
29476
30719
  }
29477
30720
  return { action: "unknown", arg: sub };
29478
30721
  }
@@ -29613,4 +30856,10 @@ Run 'archme init --help', 'archme daemon --help', 'archme analyze --help', or
29613
30856
  }
29614
30857
 
29615
30858
  // src/bin.ts
29616
- main(process.argv.slice(2)).then((code) => process.exit(code));
30859
+ main(process.argv.slice(2)).then(
30860
+ (code) => process.exit(code),
30861
+ (err4) => {
30862
+ console.error(err4 instanceof Error ? err4.message : String(err4));
30863
+ process.exit(1);
30864
+ }
30865
+ );