@edgehero/pi-dispatch 1.0.0 → 1.2.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/src/processor.mjs CHANGED
@@ -41,7 +41,10 @@ export async function runJob(job, deps) {
41
41
  // this job names is on this host (image-preflight.mjs). Default admits everything, so a wiring that
42
42
  // omits it behaves exactly as before -- the container's own failure stays the backstop.
43
43
  imagePreflight = async () => ({ ok: true }),
44
- // (session, { piVersion }) => { promoted, reason, bytes }. Promotes this job's transcript back into
44
+ // REQ-EGRESS-ALLOWLIST. Default admits everything, so a wiring that omits it behaves exactly as a
45
+ // deployment with no egress policy does -- which is also what the real factory returns when unarmed.
46
+ egressPreflight = async () => ({ ok: true }),
47
+ // (session, { piVersion, context }) => { promoted, reason, bytes }. Promotes this job's transcript back into
45
48
  // the store, on a COMPLETED exit only. Never throws. The default is a no-op so a wiring that omits
46
49
  // it behaves exactly as before -- no store, no promotion, no session in the record.
47
50
  promoteSession = () => null,
@@ -184,6 +187,46 @@ export async function runJob(job, deps) {
184
187
  throw new InfraRetry("docker unavailable, image preflight could not run", { reason: "container-never-started", provider: job.provider ?? null, model: job.model ?? null });
185
188
  }
186
189
 
190
+ // REQ-EGRESS-ALLOWLIST. The egress policy this deployment claims must be able to serve this job
191
+ // BEFORE the job costs anything. It is one `docker inspect` when the policy is armed and ZERO spawns
192
+ // when it is not, so a deployment without one pays nothing at all.
193
+ //
194
+ // PLACEMENT, and it is the same ladder the image preflight sits at the top of. A missing proxy blocks
195
+ // EVERY job of EVERY kind on this host -- like a missing image -- and unlike a missing image it blocks
196
+ // them EXPENSIVELY: the container starts, the provider is unreachable, the runner exits 1, exit 1 is
197
+ // the retryable class, `attempts: 2`, and `releaseBudget` refunds only `container-never-started` --
198
+ // this container started. So each such job spends two job-count slots and buys nothing with either,
199
+ // and a cron-driven deployment empties its daily cap before anyone reads the first failure. That cost
200
+ // is what makes this a pre-spend gate rather than a doc: measured at three provider attempts,
201
+ // `Request timed out.`, exit 1, ~40 seconds, zero tokens (docs/egress.md).
202
+ //
203
+ // A RETURN, never a throw (CONST-RETRY-INFRA-ONLY): retrying never makes an absent proxy appear.
204
+ const egress = await egressPreflight(job);
205
+ if (egress.proxyMissing || egress.proxyStopped) {
206
+ const proxy = egress.proxyMissing ?? egress.proxyStopped;
207
+ const state = egress.proxyMissing ? "is not on this host" : "is not running";
208
+ await comment(job, `Refused: this deployment runs jobs behind an egress policy and its allowlist proxy "${proxy}" ${state}, so the job could not reach the provider and would burn its budget slot proving it. Start it with \`docker compose -f deploy/docker-compose.yml --profile egress up -d\`, or set PI_EGRESS=0 to run without an egress policy. Not run.`);
209
+ // The proxy's NAME is operator-authored deployment config, never payload -- the same PII class as
210
+ // the image ref on the refusal above.
211
+ log(egress.proxyMissing ? "refused_egress_proxy_missing" : "refused_egress_proxy_stopped", { proxy });
212
+ return {
213
+ outcome: "policy",
214
+ reason: egress.proxyMissing ? "egress-proxy-missing" : "egress-proxy-stopped",
215
+ exitCode: null,
216
+ turns: null,
217
+ tokens: null,
218
+ provider: job.provider ?? null,
219
+ model: job.model ?? null,
220
+ budgetReserved: false, // refused before reserveBudget, so no job-count slot was consumed
221
+ };
222
+ }
223
+ if (egress.unavailable) {
224
+ // The daemon did not answer, so this is indeterminate rather than a refusal -- the same
225
+ // determinate/indeterminate split the image preflight draws one gate up, and thrown for the same
226
+ // reason. Pre-reserve, so the refund below is a no-op and still honest if this gate ever moves.
227
+ throw new InfraRetry("docker unavailable, egress preflight could not run", { reason: "container-never-started", provider: job.provider ?? null, model: job.model ?? null });
228
+ }
229
+
187
230
  // REQ-RESUMABLE-SESSION's one fail-CLOSED case. Everything else in that feature fails OPEN and
188
231
  // NAMES itself -- absent, expired, too-large, unparseable, locked, promote-failed -- because a cold
189
232
  // start is a correct run. This one cannot be: with no `sessionsDir`, resolveSession returns null
@@ -308,7 +351,7 @@ export async function runJob(job, deps) {
308
351
  return { outcome: "policy", reason: budget.reason, exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: true }; // return => not retried
309
352
  }
310
353
 
311
- const { code, aborted, turns, tokens, session, usage } = await runContainer({ job, token, prepared });
354
+ const { code, aborted, turns, tokens, session, usage, context } = await runContainer({ job, token, prepared });
312
355
  log("container_exit", { exitCode: code, aborted });
313
356
 
314
357
  // Record token spend post-run (the check-AFTER half of the lagging token cap). The container ran,
@@ -345,7 +388,7 @@ export async function runJob(job, deps) {
345
388
  // (CONST-RETRY-INFRA-ONLY). Same completed-only rule INT-OUTBOX-CONTRACT already uses, and
346
389
  // it sits beside the chain collection for the same reason: both must happen before the
347
390
  // `finally` deletes jobDir. Never throws.
348
- const promoted = prepared.session ? promoteSession(prepared.session, { piVersion }) : null;
391
+ const promoted = prepared.session ? promoteSession(prepared.session, { piVersion, context }) : null;
349
392
  return {
350
393
  outcome: "completed",
351
394
  exitCode: code,
@@ -412,7 +455,8 @@ export async function runJob(job, deps) {
412
455
  * and with one number alone it is indistinguishable from an ordinary cold start.
413
456
  *
414
457
  * The runner's verdict WINS on `resumed`, because it is the one that observed the outcome. The host's
415
- * reason is kept when the runner has none to give (a container that died before its exit line).
458
+ * reason is kept when the runner has none to give (a container that died before its exit line), AND when
459
+ * the host itself refused -- see the second precedence rule below.
416
460
  *
417
461
  * PII-free by construction: a boolean, a fixed enum, an integer. The key and the branch name are
418
462
  * deliberately absent -- this record holds no attacker-chosen string, and a branch name is one.
@@ -420,12 +464,30 @@ export async function runJob(job, deps) {
420
464
  function mergeSession(prepared, fromRunner, promoted = null) {
421
465
  const host = prepared?.session;
422
466
  if (!host && !fromRunner) return null;
467
+ // A HOST GATE THAT REFUSED OUTRANKS THE RUNNER'S `absent`, and without this rule it never reached a
468
+ // record at all. A refused read stages a 0-byte file rather than nothing (session-store.mjs, where the
469
+ // reasoning is pi's EEXIST race), the container is handed that file either way, and pi opens it and
470
+ // finds no messages -- so the runner reports `absent` on EVERY host refusal. Letting that win overwrote
471
+ // the answer with a restatement of the question: `expired` and `pi-version-changed` reached no
472
+ // completed record in the feature's whole life, and `docs/sessions.md`'s promise that every cold start
473
+ // is nameable in the record was false for them.
474
+ //
475
+ // Narrow on purpose, `host.resume === false` and the runner's token exactly `absent`. When the host
476
+ // DID stage a transcript and the runner still reports `absent`, the two genuinely disagree, and that
477
+ // disagreement is the event this object exists to show; the runner keeps winning there. So does its
478
+ // `unparseable`, which reports a degrade the host could not see.
479
+ const hostRefused = host?.resume === false && typeof host.reason === "string";
423
480
  return {
424
481
  resumed: fromRunner ? fromRunner.resumed : false,
425
482
  // A promotion that was refused is the more useful reason to surface: "locked" or
426
483
  // "not-a-regular-file" says why the NEXT run will cold-start, which is the thing an operator
427
484
  // chasing "it never resumes" needs. It only ever replaces a reason on the completed path.
428
- reason: (promoted && !promoted.promoted ? promoted.reason : null) ?? fromRunner?.reason ?? host?.reason ?? null,
485
+ reason:
486
+ (promoted && !promoted.promoted ? promoted.reason : null) ??
487
+ (hostRefused && fromRunner?.reason === "absent" ? host.reason : null) ??
488
+ fromRunner?.reason ??
489
+ host?.reason ??
490
+ null,
429
491
  bytes: promoted?.bytes ?? host?.bytes ?? null,
430
492
  };
431
493
  }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { buildDockerRunArgs, CONTAINER_SESSION_FILE } from "./docker-run.mjs";
3
+ import { createJobNetwork, networkNameFor, removeJobNetwork } from "./egress.mjs";
3
4
  import { buildContainerEnv } from "./env-allowlist.mjs";
4
5
  import { resolveJobImage } from "./image-preflight.mjs";
5
6
  import { InfraRetry } from "./processor.mjs";
@@ -29,7 +30,7 @@ export function makeRunContainer({
29
30
  image, // the DEPLOYMENT default (PI_JOB_IMAGE); a trigger's own run.image overrides it per job
30
31
  hostEnv = process.env,
31
32
  onOutput = (c) => process.stdout.write(c),
32
- openJobLog = () => ({ write() {}, close: async () => ({ turns: null, tokens: null, session: null, usage: null }) }),
33
+ openJobLog = () => ({ write() {}, close: async () => ({ turns: null, tokens: null, session: null, usage: null, context: null }) }),
33
34
  spawnFn = spawn,
34
35
  globalPiDir = null, // REQ-GLOBAL-PI-OVERLAY: operator's global pi overlay dir, mounted :ro; null = off
35
36
  allowGlobalExtensions = true, // REQ-GLOBAL-PI-OVERLAY: the staged overlay's extensions load unless PI_GLOBAL_ALLOW_EXTENSIONS=0
@@ -40,11 +41,13 @@ export function makeRunContainer({
40
41
  forwardEnv = [],
41
42
  authFromPi = false, // fall back to ~/.pi/agent/auth.json for the provider key when the env has none
42
43
  forgeHosts = {}, // per-forge self-hosted instance URLs, so a forge CLI in the container talks to the right one
44
+ egress = false, // REQ-EGRESS-ALLOWLIST: put this job on its own --internal network behind the allowlist proxy
45
+ egressProxy, // the proxy component attached to that network; undefined = egress.mjs's default name
43
46
  }) {
44
47
  // async so a synchronous throw (e.g. buildContainerEnv on an unconfigured provider) surfaces as
45
48
  // a rejection, uniformly awaitable by the processor and by tests.
46
49
  return async function runContainer({ job, token, prepared, name, signal }) {
47
- if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null }; // killed before it could start
50
+ if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null }; // killed before it could start
48
51
 
49
52
  // Closed env allowlist: only the provider key + the declared PI_* vars. Throws (config) if
50
53
  // the provider is unconfigured -- the processor turns that into a pre-spend refusal.
@@ -59,6 +62,8 @@ export function makeRunContainer({
59
62
  forgeKind: job?.kind,
60
63
  forgeHosts,
61
64
  hostEnv,
65
+ egress, // REQ-EGRESS-ALLOWLIST: emits HTTPS_PROXY/HTTP_PROXY/NO_PROXY/NODE_USE_ENV_PROXY, or nothing
66
+ egressProxy,
62
67
  allowGlobalExtensions, // REQ-GLOBAL-PI-OVERLAY: false emits the explicit PI_GLOBAL_ALLOW_EXTENSIONS=0 opt-out
63
68
  // REQ-GLOBAL-PI-OVERLAY: the per-job value comes off `job` (like maxTurns), the staged set off
64
69
  // the closure (like allowGlobalExtensions) -- so a trigger can withhold what the operator staged.
@@ -84,6 +89,10 @@ export function makeRunContainer({
84
89
  authFromPi, // source the provider key from pi's auth.json when the env has none
85
90
  });
86
91
 
92
+ // `-net` on this container's own name (egress.mjs). null when no policy is armed, and docker-run's
93
+ // guard then omits the flag entirely, so the argv is byte-identical to one built before this feature.
94
+ const network = egress ? networkNameFor(name) : null;
95
+
87
96
  const args = buildDockerRunArgs({
88
97
  // Same split as packagePaths above: the per-job value off `job`, the deployment value off the closure,
89
98
  // so a trigger can name its own toolchain (INT-TRIGGERS-FILE-CONTRACT). Resolved through the SAME
@@ -99,13 +108,26 @@ export function makeRunContainer({
99
108
  sessionDir: prepared.session?.hostDir,
100
109
  globalPiDir, // undefined/null -> docker-run's guard skips the /opt/pi-global mount
101
110
  name,
111
+ network, // REQ-EGRESS-ALLOWLIST: null when no policy is armed, and the flag is then absent
102
112
  });
103
113
 
114
+ // REQ-EGRESS-ALLOWLIST. This job's own --internal network, created here rather than at boot because
115
+ // it holds exactly two endpoints -- this container and the proxy -- and that is what makes job-to-job
116
+ // traffic structurally impossible rather than merely discouraged. A shared network could not do it:
117
+ // `enable_icc=false` would block job-to-job AND job-to-proxy, since ICC governs every container pair
118
+ // on the bridge and the proxy is a container.
119
+ //
120
+ // A failure to build it is INFRA, not policy: nothing has been spent, a retry may well succeed, and
121
+ // `container-never-started` is literally true, so the reservation is given back (processor.mjs).
122
+ if (network && !(await createJobNetwork(spawnFn, { network, proxy: egressProxy }))) {
123
+ throw new InfraRetry("container-never-started", { reason: "container-never-started" });
124
+ }
125
+
104
126
  // Host-side per-job log sink, teed off `onOutput`. `name` is `pi-job-<jobId>`; the sink
105
127
  // sanitizes internally. No container mount, no env var -- the sink lives on this side only.
106
128
  const sink = openJobLog(name);
107
129
 
108
- return await new Promise((resolve, reject) => {
130
+ const run = new Promise((resolve, reject) => {
109
131
  const child = spawnFn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
110
132
  // A throwing sink.write is swallowed so a misbehaving sink cannot break the tee or hang the run.
111
133
  const tee = (chunk) => {
@@ -125,21 +147,35 @@ export function makeRunContainer({
125
147
  });
126
148
  child.on("close", async (code) => {
127
149
  const aborted = signal?.aborted === true; // capture BEFORE the await
128
- // A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage fall back to null.
150
+ // A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage/context fall back to null.
129
151
  let turns = null;
130
152
  let tokens = null;
131
153
  let session = null;
132
154
  let usage = null;
155
+ let context = null;
133
156
  try {
134
- ({ turns, tokens, session, usage } = await sink.close());
157
+ // `context = null` is a DEFAULT rather than a plain destructure: an injected sink that
158
+ // predates the field returns no such key, and `undefined` would then reach the record's
159
+ // shape where every other absence is spelled `null`.
160
+ ({ turns, tokens, session, usage, context = null } = await sink.close());
135
161
  } catch {
136
162
  turns = null;
137
163
  tokens = null;
138
164
  session = null;
139
165
  usage = null;
166
+ context = null;
140
167
  }
141
- resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage } : { code: code ?? 1, aborted: false, turns, tokens, session, usage });
168
+ resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context });
142
169
  });
143
170
  });
171
+
172
+ // The network outlives the container by exactly this `finally`. Best-effort and never throwing: the
173
+ // container has already exited, its code is the job's answer, and a teardown fault must not rewrite
174
+ // that answer. What a failure leaves behind is a memberless network, which the boot reaper sweeps.
175
+ try {
176
+ return await run;
177
+ } finally {
178
+ if (network) await removeJobNetwork(spawnFn, { network, proxy: egressProxy });
179
+ }
144
180
  };
145
181
  }
@@ -110,6 +110,43 @@ export function parseExitSession(text) {
110
110
  return null;
111
111
  }
112
112
 
113
+ /**
114
+ * The container's report of how full its context was when the run ended (issue #186). Shaped exactly
115
+ * like `parseExitSession` above and, like it, PASSED THROUGH rather than rebuilt: both integers are
116
+ * host-bounded numbers, so `parseExitUsage`'s revalidating rebuild is not what this needs -- that one
117
+ * exists because the ledger carries id STRINGS.
118
+ *
119
+ * Absent means ABSENT, never zero. A runner predating this field, a run pi could give no context window
120
+ * for, and a compaction that left the count unknown all produce no key at all, and the session store
121
+ * reads that as "no measurement" and passes rather than inventing a denominator.
122
+ */
123
+ export function parseExitContext(text) {
124
+ if (typeof text !== "string") return null;
125
+ const lines = text.split("\n");
126
+ for (let i = lines.length - 1; i >= 0; i--) {
127
+ const line = lines[i].trim();
128
+ if (line === "") continue;
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(line);
132
+ } catch {
133
+ continue; // docker/agent noise or a truncated final line
134
+ }
135
+ if (parsed?.event !== "exit") continue;
136
+ const c = parsed?.context;
137
+ // A window of 0 is not a denominator, and a negative count is not a measurement. SAFE integers
138
+ // specifically: `Number.isInteger` accepts up to ~1.8e308, and anything from 1e21 up stringifies to
139
+ // exponential notation, which the session store's own decimal round-trip then rejects on read --
140
+ // so a value in that range would be written into the store and be unreadable forever after, with
141
+ // the gate failing open on a measurement that said the context was full.
142
+ if (c && typeof c === "object" && !Array.isArray(c) && Number.isSafeInteger(c.tokens) && Number.isSafeInteger(c.window) && c.tokens >= 0 && c.window > 0) {
143
+ return { tokens: c.tokens, window: c.window };
144
+ }
145
+ return null;
146
+ }
147
+ return null;
148
+ }
149
+
113
150
  export function parseExitTokens(text) {
114
151
  if (typeof text !== "string") return null;
115
152
  const lines = text.split("\n");
@@ -246,7 +283,7 @@ function rebuildUsage(u) {
246
283
  * path embeds the operator's OS account name.
247
284
  *
248
285
  * `reason` is a fixed enum passthrough (worker-abort | over-budget | unprotected-branch |
249
- * runner-policy | job-image-missing | ...), never free-form or payload text. `exitCode`, `turns`, and `budgetReserved`
286
+ * runner-policy | job-image-missing | egress-proxy-missing | ...), never free-form or payload text. `exitCode`, `turns`, and `budgetReserved`
250
287
  * default to `null` when the outcome does not carry them, so the record shape is stable whether or not
251
288
  * the source reports those fields.
252
289
  */
@@ -389,11 +426,12 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
389
426
  }
390
427
 
391
428
  async function close({ timeoutMs = 2000 } = {}) {
392
- // Capture turns/tokens/session/usage from the tail first, so they survive even if the flush errors or times out.
429
+ // Capture turns/tokens/session/usage/context from the tail first, so they survive even if the flush errors or times out.
393
430
  const turns = parseExitTurns(tail);
394
431
  const tokens = parseExitTokens(tail);
395
432
  const session = parseExitSession(tail);
396
433
  const usage = parseExitUsage(tail);
434
+ const context = parseExitContext(tail);
397
435
  try {
398
436
  if (stream !== null) {
399
437
  const s = stream;
@@ -417,7 +455,7 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
417
455
  } catch (err) {
418
456
  log("log_sink_error", { jobId, reason: err?.message });
419
457
  }
420
- return { turns, tokens, session, usage };
458
+ return { turns, tokens, session, usage, context };
421
459
  }
422
460
 
423
461
  return { write, close };
@@ -1,6 +1,8 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { parseArgs } from "node:util";
2
3
  import { loadConfig } from "./config.mjs";
3
4
  import { sanitizeJobId } from "./run-history.mjs";
5
+ import { createJobNetwork, egressEnv, networkNameFor, removeJobNetwork } from "./egress.mjs";
4
6
  import { buildSandboxRunArgs, launchSandbox, listRunningSandboxes, parsePublish, resolveSandbox, sandboxContainerName } from "./sandbox.mjs";
5
7
  import { listSandboxes, pinSandbox } from "./sandbox-store.mjs";
6
8
 
@@ -22,6 +24,9 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
22
24
  isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY),
23
25
  running = listRunningSandboxes,
24
26
  launch = launchSandbox,
27
+ // The docker spawn used for this session's egress network, seamed like `launch` so the tests never
28
+ // touch a daemon. Not used when PI_EGRESS=0.
29
+ spawnNetwork = spawn,
25
30
  now = () => Date.now(),
26
31
  } = deps;
27
32
 
@@ -89,6 +94,10 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
89
94
  else err(`warning: could not pin ${jobId}: ${pinned.reason}\n`);
90
95
  }
91
96
 
97
+ // REQ-EGRESS-ALLOWLIST: this session's own network, exactly like a job's, named off its own container
98
+ // so the reaper's `pi-job-` filter never touches it -- a worker restart must not tear the network out
99
+ // from under a shell an operator is sitting in.
100
+ const network = config.egress ? networkNameFor(resolved.name) : null;
92
101
  const args = buildSandboxRunArgs({
93
102
  image: resolved.manifest.image,
94
103
  name: resolved.name,
@@ -97,15 +106,27 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
97
106
  publish,
98
107
  term: env.TERM,
99
108
  idleSeconds: config.sandboxIdleMinutes * 60,
109
+ network,
110
+ egressEnv: egressEnv({ proxy: config.egressProxy, armed: config.egress }),
100
111
  });
101
112
 
102
113
  out(`opening ${resolved.name} — image ${resolved.manifest.image}, workspace ${resolved.manifest.workspace}\n`);
103
114
  out("no credentials are set in this container. exit the shell to dispose of it.\n");
104
115
  if (publish.length > 0) out(`published: ${publish.filter((f) => f !== "-p").join(", ")}\n`);
105
116
 
106
- const { code, error } = await launch({ args });
107
- if (error) return fail(err, `could not start docker: ${error.message}`);
108
- return code ?? 0;
117
+ // No pre-spend gate here, deliberately: that is a MONEY gate and a sandbox spends nothing. A missing
118
+ // proxy fails at `docker run` with docker's own message, in front of an operator at a terminal, which
119
+ // is the one place a late failure is cheap.
120
+ if (network && !(await createJobNetwork(spawnNetwork, { network, proxy: config.egressProxy }))) {
121
+ return fail(err, `could not create the egress network ${network} -- is the proxy running? \`docker compose -f deploy/docker-compose.yml --profile egress up -d\``);
122
+ }
123
+ try {
124
+ const { code, error } = await launch({ args });
125
+ if (error) return fail(err, `could not start docker: ${error.message}`);
126
+ return code ?? 0;
127
+ } finally {
128
+ if (network) await removeJobNetwork(spawnNetwork, { network, proxy: config.egressProxy });
129
+ }
109
130
  }
110
131
 
111
132
  /**
package/src/sandbox.mjs CHANGED
@@ -79,8 +79,10 @@ function inPortRange(n) {
79
79
  * @param publish already-parsed `-p` flags
80
80
  * @param term the host's TERM, so the shell renders
81
81
  * @param idleSeconds bash's own TMOUT; 0 omits it
82
+ * @param network this session's own egress network (REQ-EGRESS-ALLOWLIST); null = the default bridge
83
+ * @param egressEnv the proxy variables that go with it, or {} when no policy is armed
82
84
  */
83
- export function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
85
+ export function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {} }) {
84
86
  return buildDockerRunArgs({
85
87
  image,
86
88
  name,
@@ -89,9 +91,20 @@ export function buildSandboxRunArgs({ image, name, workspace, jobDir, publish =
89
91
  // The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
90
92
  // forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
91
93
  // disabled idle timeout emits nothing rather than an empty string.
94
+ // A sandbox joins the SAME kind of network a job did, by the same builder, so the boundary cannot
95
+ // land on job containers and miss this one. Leaving sandboxes on the default bridge was the tempting
96
+ // alternative and it is the wrong one: it reads as a convenience (install a missing dependency while
97
+ // debugging) and it is a WIDER reach than the run the sandbox exists to reproduce. A shell that can
98
+ // go where the run could not is not reproducing the run. Nothing an operator wants is lost, because
99
+ // the forge and the registry are on the allowlist a job needed anyway.
100
+ network,
92
101
  env: {
93
102
  TERM: term || undefined,
94
103
  TMOUT: idleSeconds > 0 ? String(idleSeconds) : undefined,
104
+ // Still NO CREDENTIALS, and that clause is untouched: a proxy URL is not a credential, and
105
+ // buildContainerEnv is still not reused here. The env is two variables about the terminal and,
106
+ // when a policy is armed, three about the network.
107
+ ...proxyEnv,
95
108
  },
96
109
  // Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
97
110
  // the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets