humanish 0.80.0 → 0.81.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.
Files changed (45) hide show
  1. package/README.md +9 -3
  2. package/dist/actor-contract.d.ts +16 -0
  3. package/dist/actor-contract.js.map +1 -1
  4. package/dist/chrome-cdp-probe.js +13 -0
  5. package/dist/chrome-cdp-probe.js.map +1 -1
  6. package/dist/computer-use.d.ts +7 -1
  7. package/dist/computer-use.js +156 -13
  8. package/dist/computer-use.js.map +1 -1
  9. package/dist/cua-actor-lab.d.ts +7 -6
  10. package/dist/cua-actor-lab.js +95 -21
  11. package/dist/cua-actor-lab.js.map +1 -1
  12. package/dist/e2b-desktop-launch.d.ts +28 -1
  13. package/dist/e2b-desktop-launch.js +88 -1
  14. package/dist/e2b-desktop-launch.js.map +1 -1
  15. package/dist/e2b-desktop-screenshot-cleanup.d.ts +15 -0
  16. package/dist/e2b-desktop-screenshot-cleanup.js +67 -0
  17. package/dist/e2b-desktop-screenshot-cleanup.js.map +1 -0
  18. package/dist/e2b-terminal-lab.d.ts +3 -2
  19. package/dist/e2b-terminal-lab.js +176 -85
  20. package/dist/e2b-terminal-lab.js.map +1 -1
  21. package/dist/first-run-path.js +2 -2
  22. package/dist/first-run-path.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/lab-config.d.ts +11 -14
  26. package/dist/lab-config.js +2 -2
  27. package/dist/lab-config.js.map +1 -1
  28. package/dist/openai-responses-cu.js +76 -49
  29. package/dist/openai-responses-cu.js.map +1 -1
  30. package/dist/program.js +16 -7
  31. package/dist/program.js.map +1 -1
  32. package/dist/run.d.ts +1 -1
  33. package/dist/terminal-node-bootstrap.d.ts +4 -0
  34. package/dist/terminal-node-bootstrap.js +58 -0
  35. package/dist/terminal-node-bootstrap.js.map +1 -0
  36. package/dist/terminal-runtime-auth.d.ts +13 -0
  37. package/dist/terminal-runtime-auth.js +24 -0
  38. package/dist/terminal-runtime-auth.js.map +1 -0
  39. package/docs/architecture/actor-contract.md +22 -0
  40. package/docs/architecture/terminal-product-lane.md +108 -7
  41. package/docs/contracts/feedback.md +14 -0
  42. package/docs/contracts/schemas.md +1 -1
  43. package/docs/goals/current.md +45 -13
  44. package/docs/ramp/README.md +1 -1
  45. package/package.json +4 -2
@@ -1,5 +1,5 @@
1
1
  // The terminal-product lab backend: a real autonomous agent (Codex) studying a CLI/product from
2
- // PUBLIC SURFACES ONLY, running INSIDE an E2B shell with command-scoped runtime auth, capturing
2
+ // PUBLIC SURFACES ONLY, running INSIDE an E2B shell with explicit runtime-auth placement, capturing
3
3
  // its non-interactive exec output (stdin disabled) as a redacted event stream + normalized
4
4
  // transcript, capped at no-spend, emitting durable terminal/substrate/cost/no-spend/cleanup/
5
5
  // intervention proof. Mirrors cua-actor-lab.ts / scripted-browser-lab.ts.
@@ -12,10 +12,10 @@
12
12
  //
13
13
  // THE SAFETY CONTRACT (docs/goals/terminal-product-lane/goal.md) is enforced BY CONSTRUCTION here
14
14
  // and CHECKED by the verifier (run.ts validateTerminalProductEvidence):
15
- // 1. COMMAND-SCOPED KEY. The runtime LLM key is injected ONLY into the per-command `envs` of the
16
- // `codex exec` invocation (commands.run({envs})), NEVER Sandbox.create({envs}) driven off
17
- // the registered actor's keyPlacement: "in-sandbox-command-scoped" capability (engine-
18
- // enforced: a terminal actor lacking that metadata FAILS CLOSED before any sandbox exists).
15
+ // 1. EXPLICIT KEY PLACEMENT. openai-env (default) injects the raw runtime key command-scoped,
16
+ // NEVER Sandbox.create({envs}). Opt-in openai-egress sends it only in the host-side E2B
17
+ // header transform and passes an inert command placeholder. The proxy is spendable by every
18
+ // sandbox process from creation; this protects the raw key, not provider spending.
19
19
  // 2. FAIL-CLOSED CAP. The live key is never exercised without scenario.caps in force: maxUsd
20
20
  // (default/require 0 = no-spend) + maxMinutes (wall-clock kill of the codex command).
21
21
  // 3. PUBLIC SURFACES ONLY. The mission references only subject.product.publicSurfaces + the
@@ -36,7 +36,8 @@
36
36
  // Sandbox.getInfo(id) when the SDK exposes it (a thrown SandboxNotFoundError means gone).
37
37
  // humanish NEVER calls Sandbox.list to prove cleanup, so a shared operator key never reaches a
38
38
  // sandbox it did not create. A live run that cannot prove teardown fails closed.
39
- import { randomBytes, randomUUID } from "node:crypto";
39
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
40
+ import { TERMINAL_NODE_BOOTSTRAP_COMMAND } from "./terminal-node-bootstrap.js";
40
41
  import { describeTokenUsage, parseTerminalTokenUsage } from "./terminal-token-usage.js";
41
42
  import { readFile, realpath, stat } from "node:fs/promises";
42
43
  import path from "node:path";
@@ -45,6 +46,7 @@ import { beginRunStatus, withRunStatusScope } from "./run-status.js";
45
46
  import { ACTOR_TRACE_SCHEMA, TERMINAL_AGENT_CAPABILITIES } from "./actor-contract.js";
46
47
  import { actorRegistry, isTerminalActorDescriptor } from "./actor-registry.js";
47
48
  import { toErrorMessage } from "./command-failure.js";
49
+ import { buildOpenAiEgressNetwork, E2B_SYSTEM_CA_BUNDLE, OPENAI_EGRESS_PLACEHOLDER } from "./terminal-runtime-auth.js";
48
50
  import { isSandboxNotFoundError, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
49
51
  import { renderObserver } from "./observer.js";
50
52
  import { parseResolvedPersona, personaToDirectives, renderPersonaPromptSection } from "./persona.js";
@@ -74,24 +76,8 @@ const UPLOAD_MAX_BYTES = 64 * 1024 * 1024;
74
76
  // Server-side reclamation buffer past the codex command's own wall-clock (caps.maxMinutes) kill.
75
77
  const SANDBOX_TIMEOUT_BUFFER_MS = 5 * 60_000;
76
78
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
77
- // The runtime-bootstrap step (ensure Node/npm) can run an apt-get install; the SDK's commands.run
78
- // timeoutMs default (60s) is far too short for that, so this step gets an explicit generous budget.
79
+ // Allow the pinned runtime download and install enough time while retaining a finite deadline.
79
80
  const RUNTIME_BOOTSTRAP_TIMEOUT_MS = 300_000;
80
- // UNKEYED (no envs) shell command that ensures Node/npm are present before the keyed codex exec.
81
- // Reuses the oss-meta-lab.ts ensure_node() shape: check node's major version, else install
82
- // Node 22 via NodeSource plus passwordless sudo (the stock @e2b/desktop image ships neither codex
83
- // nor a recent Node, per issue #159). A final presence check makes the whole command exit non-zero
84
- // (so the bootstrap step fails closed) if the install still leaves node/npm missing.
85
- const RUNTIME_BOOTSTRAP_COMMAND = [
86
- `node_major=0`,
87
- `if command -v node >/dev/null 2>&1; then node_major=$(node -e 'console.log(Number(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0); fi`,
88
- `if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && [ "$node_major" -ge 20 ]; then exit 0; fi`,
89
- `sudo -n apt-get update`,
90
- `sudo -n apt-get install -y ca-certificates curl gnupg`,
91
- `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -n -E bash -`,
92
- `sudo -n apt-get install -y nodejs`,
93
- `command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1`
94
- ].join(" && ");
95
81
  // How much of a captured stream / log tail rides a (redacted) message field.
96
82
  const TAIL_CHARS = 2000;
97
83
  // Hard cap on the retained event-stream + transcript size, so a runaway agent cannot balloon the
@@ -416,17 +402,12 @@ function roundUsd(value) {
416
402
  return Math.round(value * 1_000_000) / 1_000_000;
417
403
  }
418
404
  /**
419
- * Build the per-command runtime-auth env from a DENY-BY-DEFAULT ALLOWLIST containing ONLY the
420
- * declared runtime key (safety contract item 4). The key NAME is derived from the actor's
421
- * keyPlacement capability plus the declared runtimeAuth channel, not a hardcoded string the caller
422
- * can widen. Banned credential names (GitHub/payment/deploy/db/media) are excluded by construction
423
- * AND guarded: if a banned name is ever requested the lane fails closed. Returns the allowlisted
424
- * env (values from `env`) and the resolved key name, or a structured failure.
425
- *
426
- * Engine-enforced placement (safety contract item 1): the key is only ever returned as a
427
- * COMMAND-scoped env here; the caller passes it to commands.run({envs}), never Sandbox.create.
405
+ * Resolve the runtime key on the host. Legacy openai-env passes it command-scoped; openai-egress
406
+ * returns an inert command env while retaining the actual value for the external transform and
407
+ * literal redaction. Only CODEX_API_KEY/OPENAI_API_KEY are accepted as sources. No other operator
408
+ * credential is forwarded. The real value must never be logged or persisted in either mode.
428
409
  */
429
- function buildCommandScopedRuntimeEnv(args) {
410
+ function buildRuntimeAuth(args) {
430
411
  // The "openai-env" channel accepts CODEX_API_KEY or OPENAI_API_KEY as the runtime key SOURCE
431
412
  // name, read in this preference order. CODEX_API_KEY is preferred: the official Codex docs
432
413
  // (developers.openai.com/codex/noninteractive) document it as the channel for a SINGLE codex exec
@@ -458,7 +439,7 @@ function buildCommandScopedRuntimeEnv(args) {
458
439
  return {
459
440
  ok: false,
460
441
  code: "HUMANISH_TERMINAL_LAB_RUNTIME_AUTH_MISSING",
461
- message: `Live terminal-product labs declare runtimeAuth "${String(args.runtimeAuth)}" and need ${ALLOWED_RUNTIME_KEY_NAMES.join(" or ")} in the environment (pass via --env-file; the value is injected ONLY into the command-scoped codex invocation and is never persisted).`
442
+ message: `Live terminal-product labs declare runtimeAuth "${String(args.runtimeAuth)}" and need ${ALLOWED_RUNTIME_KEY_NAMES.join(" or ")} in the environment (pass via --env-file; the selected auth mode places the value in command-scoped env or an external E2B header transform; the value is never persisted).`
462
443
  };
463
444
  }
464
445
  const keyValue = args.env[keyName];
@@ -466,9 +447,15 @@ function buildCommandScopedRuntimeEnv(args) {
466
447
  // GITHUB_TOKEN/GH_TOKEN, no payment/deploy/db/media key, excluded by construction. When the
467
448
  // SOURCE was OPENAI_API_KEY, the SAME value is also injected as CODEX_API_KEY so codex exec's
468
449
  // documented single-invocation auth channel is populated either way (see the comment above).
469
- const envs = keyName === "OPENAI_API_KEY" ? { CODEX_API_KEY: keyValue, OPENAI_API_KEY: keyValue } : { [keyName]: keyValue };
450
+ const mode = args.runtimeAuth ?? "openai-env";
451
+ const envs = mode === "openai-egress"
452
+ // Codex documents this verified-TLS trust channel. The stock image's default OpenSSL CA
453
+ // file can be absent even though E2B has installed its proxy CA in the system bundle.
454
+ ? { CODEX_API_KEY: OPENAI_EGRESS_PLACEHOLDER, CODEX_CA_CERTIFICATE: E2B_SYSTEM_CA_BUNDLE }
455
+ : keyName === "OPENAI_API_KEY" ? { CODEX_API_KEY: keyValue, OPENAI_API_KEY: keyValue } : { [keyName]: keyValue };
470
456
  return {
471
457
  ok: true,
458
+ mode,
472
459
  envs,
473
460
  keyName,
474
461
  keyValue
@@ -525,21 +512,20 @@ async function runLiveTerminalSession(args) {
525
512
  const env = hooks.env ?? process.env;
526
513
  const now = hooks.now ?? (() => Date.now());
527
514
  const nowIso = () => new Date(now()).toISOString();
528
- // --- Safety contract item 1: ENGINE-ENFORCED command-scoped key placement. ---
529
- // Drive the placement off the registered actor's keyPlacement CAPABILITY, not a code convention.
530
- // A terminal actor that does not declare in-sandbox-command-scoped placement FAILS CLOSED here,
531
- // before any sandbox exists — the engine refuses to guess where the key goes.
515
+ // Check the registered terminal actor's default placement contract before launching. The
516
+ // explicit openai-egress mode overrides the resolved trace's placement to external; registry
517
+ // metadata continues to describe the compatible openai-env default.
532
518
  const descriptor = actorRegistry[descriptorId];
533
519
  const keyPlacement = descriptor?.capabilities.keyPlacement;
534
520
  if (keyPlacement !== "in-sandbox-command-scoped") {
535
- return failed("HUMANISH_TERMINAL_LAB_KEYPLACEMENT_INVALID", `Terminal actor "${descriptorId}" must declare keyPlacement "in-sandbox-command-scoped" for the live lane (got "${String(keyPlacement)}"). The engine routes the runtime key by this capability; without it the lane cannot place the key safely and fails closed.`, { actor: descriptorId });
521
+ return failed("HUMANISH_TERMINAL_LAB_KEYPLACEMENT_INVALID", `Terminal actor "${descriptorId}" must declare keyPlacement "in-sandbox-command-scoped" for the live lane (got "${String(keyPlacement)}"). The engine requires this registered default before applying the declared runtime-auth mode.`, { actor: descriptorId });
536
522
  }
537
523
  // --- Safety contract item 2: a fail-closed cap MUST be in force before the live key runs. ---
538
524
  const caps = config.scenario?.caps;
539
525
  const maxUsd = caps?.maxUsd;
540
526
  const maxMinutes = caps?.maxMinutes;
541
527
  if (caps === undefined || maxUsd === undefined || maxMinutes === undefined || maxMinutes <= 0) {
542
- return failed("HUMANISH_TERMINAL_LAB_CAPS_MISSING", "A live terminal-product run passes a real key to the in-sandbox agent command and so REQUIRES a fail-closed cap: scenario.caps with maxUsd (0 = no-spend) and a positive maxMinutes (the codex command's wall-clock kill). The live key is never exercised without a cap in force.", { actor: descriptorId });
528
+ return failed("HUMANISH_TERMINAL_LAB_CAPS_MISSING", "A live terminal-product run grants provider access to the in-sandbox agent and so REQUIRES a fail-closed cap: scenario.caps with maxUsd (0 = no-spend) and a positive maxMinutes (the codex command's wall-clock kill). The live key is never exercised without a cap in force.", { actor: descriptorId });
543
529
  }
544
530
  // maxUsd is ENFORCED fail-closed against the cost ledger (evaluateCapsAgainstLedger after the
545
531
  // session), not advisory. A positive maxUsd is permitted, but core still has no
@@ -552,7 +538,7 @@ async function runLiveTerminalSession(args) {
552
538
  warnings.push(`scenario.caps.maxUsd=${maxUsd} declares a non-zero spend budget. maxUsd is enforced fail-closed against the cost ledger, but core meters only the provider line from tokenUsage; product/media/payment stay null (UNMEASURED, never guessed zero) unless an adapter supplies those signals through costProbe. The no-spend proof reports unmeasured lines honestly.`);
553
539
  }
554
540
  // --- Safety contract item 4: deny-by-default credentials; build the command-scoped allowlist. ---
555
- const runtimeEnv = buildCommandScopedRuntimeEnv({ runtimeAuth: config.execution?.runtimeAuth, env });
541
+ const runtimeEnv = buildRuntimeAuth({ runtimeAuth: config.execution?.runtimeAuth, env });
556
542
  if (!runtimeEnv.ok) {
557
543
  return failed(runtimeEnv.code, runtimeEnv.message, { actor: descriptorId });
558
544
  }
@@ -610,6 +596,10 @@ async function runLiveTerminalSession(args) {
610
596
  const lifecycle = [];
611
597
  const commandLog = [];
612
598
  const terminalEvents = [];
599
+ // Capture may stop inside a known key. Keep only enough following characters to finish the
600
+ // cross-chunk redaction below; this overlap is never added to terminal events/artifacts.
601
+ const discardedPrefixes = { stdout: "", stderr: "", combined: "" };
602
+ const maxDiscardedPrefixChars = Math.max(0, ...knownSecretValues.map((value) => value.length - 1));
613
603
  const interventions = []; // ALWAYS empty while no assisted-input path ships.
614
604
  let transcriptBytes = 0;
615
605
  let cleanup = { killed: false, remaining: -1, reason: "teardown not reached" };
@@ -617,12 +607,49 @@ async function runLiveTerminalSession(args) {
617
607
  lifecycle.push({ at: nowIso(), event, message: sanitize(message) });
618
608
  };
619
609
  const appendTerminalChunk = (stream, raw) => {
620
- if (transcriptBytes >= MAX_TRANSCRIPT_BYTES)
610
+ if (transcriptBytes >= MAX_TRANSCRIPT_BYTES) {
611
+ for (const order of [stream, "combined"]) {
612
+ const remaining = maxDiscardedPrefixChars - discardedPrefixes[order].length;
613
+ if (remaining > 0)
614
+ discardedPrefixes[order] += raw.slice(0, remaining);
615
+ }
621
616
  return;
617
+ }
622
618
  transcriptBytes += Buffer.byteLength(raw, "utf8");
623
619
  // Scrub THEN redact at the SOURCE — raw bytes never leave this function (safety contract item 5).
624
620
  terminalEvents.push({ at: nowIso(), stream, chunk: sanitize(raw) });
625
621
  };
622
+ // E2B can stream every byte through callbacks AND return the same complete output (#667).
623
+ // Track transport delivery, independently per stream, rather than deduplicating participant
624
+ // lines or equal usage records. Hash raw callback bytes before redaction/truncation so the
625
+ // comparison cannot confuse two values that redact identically or lose capped-away delivery.
626
+ // Delivery tracking retains only counts and hashes; payloads still pass the artifact sanitizer.
627
+ const streamedOutput = {
628
+ stdout: { bytes: 0, hash: createHash("sha256") },
629
+ stderr: { bytes: 0, hash: createHash("sha256") }
630
+ };
631
+ const recordStreamedTerminalChunk = (stream, raw) => {
632
+ streamedOutput[stream].bytes += Buffer.byteLength(raw, "utf8");
633
+ streamedOutput[stream].hash.update(raw, "utf8");
634
+ appendTerminalChunk(stream, raw);
635
+ };
636
+ const appendReturnedTerminalOutput = (stream, raw) => {
637
+ const delivered = streamedOutput[stream];
638
+ const returned = Buffer.from(raw, "utf8");
639
+ if (delivered.bytes > 0 && returned.length >= delivered.bytes) {
640
+ const returnedPrefixHash = createHash("sha256").update(returned.subarray(0, delivered.bytes)).digest("hex");
641
+ if (returnedPrefixHash === delivered.hash.copy().digest("hex")) {
642
+ // A complete replay adds nothing; a partly streamed prefix keeps only the unseen tail.
643
+ const suffix = returned.subarray(delivered.bytes).toString("utf8");
644
+ if (suffix)
645
+ appendTerminalChunk(stream, suffix);
646
+ return;
647
+ }
648
+ }
649
+ // Older/final-only SDK delivery, or output that does not match the streamed prefix: keep it.
650
+ // Guessing at overlap here could erase legitimate repeated participant text.
651
+ appendTerminalChunk(stream, raw);
652
+ };
626
653
  let sandbox;
627
654
  let sandboxModule;
628
655
  let sandboxId;
@@ -639,50 +666,48 @@ async function runLiveTerminalSession(args) {
639
666
  try {
640
667
  sandboxModule = await (hooks.loadModule ?? loadE2BDesktopModule)();
641
668
  await validatePreparedRunArtifactPaths(runPaths);
642
- // SAFETY CONTRACT ITEM 1 (enforced HERE): Sandbox.create carries metadata (positive allowlist)
643
- // + lifecycle kill-on-timeout, and DELIBERATELY NO `envs` the runtime key is NEVER passed
644
- // sandbox-global. It is injected ONLY into the per-command codex `envs` below.
669
+ // No sandbox-global env in either mode. In openai-egress, only this host-side SDK request
670
+ // carries the real runtime key; participant commands receive an inert placeholder. The proxy
671
+ // capability is available from sandbox creation, including during bootstrap/product setup.
672
+ const routing = egressAllow === undefined ? undefined : { allowOut: egressAllow, denyOut: ["0.0.0.0/0"] };
673
+ const network = runtimeEnv.mode === "openai-egress"
674
+ ? buildOpenAiEgressNetwork(runtimeEnv.keyValue, routing)
675
+ : routing;
645
676
  sandbox = await sandboxModule.Sandbox.create({
646
677
  apiKey: e2bApiKey,
647
678
  requestTimeoutMs,
648
679
  timeoutMs: sandboxTimeoutMs,
649
680
  metadata,
650
- // Egress allowlist when the lab declares one (#538). This is the ONE bound on the injected
651
- // runtime key that does not depend on the participant's cooperation: codex spawns the
652
- // participant's shell as a child, so it inherits that key and can spend it anywhere it can
653
- // reach. It cannot reach a host that is not on this list. Absent means unrestricted, the
654
- // historical default, because a wrong host list fails studies in confusing ways.
655
- ...(egressAllow === undefined
656
- ? {}
657
- : { network: { allowOut: egressAllow, denyOut: ["0.0.0.0/0"] } }),
681
+ ...(network === undefined ? {} : { network }),
658
682
  lifecycle: { onTimeout: "kill" }
659
- // NOTE: no `envs` key — see the credential boundary above. (A sandbox-global key would leak
660
- // into every process in the sandbox; command-scoped bounds it to the codex invocation.)
661
683
  });
662
684
  await validatePreparedRunArtifactPaths(runPaths);
663
685
  sandboxId = sandbox.sandboxId;
664
686
  // #358 salvage: durable id receipt the moment the sandbox exists (reclaim by exact id).
665
687
  await appendSandboxReceipt(runPaths, { at: nowIso(), laneId: "terminal", sandboxId, timeoutMs: sandboxTimeoutMs });
666
- recordLifecycle("terminal-lab.sandbox.created", `E2B shell sandbox ${sandboxId} created with positive-allowlist metadata and kill-on-timeout; NO sandbox-global env (runtime key is command-scoped).`);
688
+ recordLifecycle("terminal-lab.sandbox.created", `E2B shell sandbox ${sandboxId} created with positive-allowlist metadata and kill-on-timeout; NO sandbox-global env.`);
667
689
  // The allowlist is evidence: a reader of the ledger can see exactly what the participant was
668
690
  // able to reach, without the ledger carrying any secret.
669
691
  recordLifecycle("terminal-lab.egress.policy", egressAllow === undefined
670
- ? "Egress UNRESTRICTED (no execution.egressAllow declared): the injected runtime key can reach any host."
671
- : `Egress DENIED except ${egressAllow.length} declared host(s): ${egressAllow.join(", ")}.`);
672
- // Readiness: a tiny in-sandbox probe (no key) confirms the shell answers before the keyed run.
692
+ ? "Egress UNRESTRICTED (no execution.egressAllow declared)."
693
+ : `Egress routing allowlist: ${egressAllow.length} declared host(s): ${egressAllow.join(", ")}; deny-all fallback. Domain routing is not strict destination isolation on shared infrastructure.`);
694
+ recordLifecycle("terminal-lab.runtime-auth", runtimeEnv.mode === "openai-egress"
695
+ ? "Runtime auth openai-egress: raw key remains outside the sandbox in the api.openai.com HTTPS Authorization transform; Codex receives an inert CODEX_API_KEY placeholder and the default OpenAI endpoint. Every sandbox process, including bootstrap/setup, can spend via this proxy; no added routing restriction or provider spending limit."
696
+ : `Runtime auth openai-env: raw key from ${runtimeEnv.keyName} is passed command-scoped to Codex and inherited by its child processes.`);
697
+ if (runtimeEnv.mode === "openai-egress") {
698
+ warnings.push("openai-egress keeps the raw runtime key outside the sandbox, but every sandbox process can spend through the api.openai.com proxy from creation until teardown. It adds no egress restriction or provider-enforced budget; extra provider calls may be absent from the Codex usage ledger.");
699
+ }
700
+ // Readiness: a tiny probe receives no runtime env; openai-egress's proxy is already available.
673
701
  const ready = await sandbox.commands.run(`mkdir -p ${SANDBOX_WORKDIR} && echo HUMANISH_SHELL_READY`, { requestTimeoutMs });
674
702
  recordLifecycle("terminal-lab.sandbox.ready", `Shell readiness probe exit=${ready.exitCode ?? "null"}; workdir ${SANDBOX_WORKDIR} prepared.`);
675
- // --- Runtime bootstrap: ensure Node/npm are present (UNKEYED, no envs) before the keyed exec. ---
676
- // The stock @e2b/desktop image does not ship a recent Node (issue #159); codex is now invoked
677
- // via `npx` (buildCodexExecCommand), which needs Node/npm on PATH. Reuses the proven
678
- // oss-meta-lab.ts ensure_node() shape: a node major-version check, else install Node 22 via
679
- // NodeSource plus passwordless sudo. UNKEYED: no runtime key touches this step. An apt-get
680
- // install can exceed the SDK's default 60s commands.run timeout, so this step gets an
681
- // explicit, generous timeoutMs (requestTimeoutMs is passed through unchanged, as everywhere else).
703
+ // --- Runtime bootstrap: no runtime env; openai-egress proxy capability is already available. ---
704
+ // The stock desktop needs Node/npm on PATH before npx can run Codex. Reuse a working
705
+ // installation or install the pinned official binary after checksum verification (#674).
706
+ // No raw runtime key touches this step; the egress proxy, when selected, is already available.
682
707
  const bootstrapStartedAt = now();
683
708
  let bootstrapError;
684
709
  try {
685
- const bootstrap = await sandbox.commands.run(RUNTIME_BOOTSTRAP_COMMAND, {
710
+ const bootstrap = await sandbox.commands.run(TERMINAL_NODE_BOOTSTRAP_COMMAND, {
686
711
  requestTimeoutMs,
687
712
  timeoutMs: RUNTIME_BOOTSTRAP_TIMEOUT_MS
688
713
  });
@@ -707,7 +732,7 @@ async function runLiveTerminalSession(args) {
707
732
  sessionReason = `runtime bootstrap could not ensure Node/npm before codex exec: ${sessionError}`;
708
733
  }
709
734
  else if (await (async () => {
710
- // --- Optional product setup (UNKEYED, no envs), before the keyed exec. ---
735
+ // --- Optional product setup (no runtime env), before the Codex exec. ---
711
736
  // Same channel and same guarantees as the runtime bootstrap above: no runtime key touches it,
712
737
  // and a failure fails the lane closed rather than handing the agent a half-built world. It
713
738
  // exists so a study can put the participant IN a prepared project — asking an agent what
@@ -745,7 +770,7 @@ async function runLiveTerminalSession(args) {
745
770
  // lane that carries envs is the keyed codex exec, and that invariant is worth more than
746
771
  // the convenience of a second envs channel.
747
772
  uploadAssignment = `export HUMANISH_PRODUCT_UPLOAD='${destination.replace(/'/g, "'\\''")}'; `;
748
- recordLifecycle("terminal-lab.product.uploaded", `Uploaded ${info.size} bytes to the sandbox in ${Math.max(0, now() - uploadStartedAt)}ms (UNKEYED).`);
773
+ recordLifecycle("terminal-lab.product.uploaded", `Uploaded ${info.size} bytes to the sandbox in ${Math.max(0, now() - uploadStartedAt)}ms (no runtime env; declared egress auth may already be available).`);
749
774
  }
750
775
  catch (error) {
751
776
  sessionStatus = "failed";
@@ -775,7 +800,7 @@ async function runLiveTerminalSession(args) {
775
800
  }
776
801
  recordLifecycle("terminal-lab.product.prepared", setupError
777
802
  ? `Product setup FAILED after ${Math.max(0, now() - setupStartedAt)}ms: ${sanitize(setupError)}`
778
- : `Product setup completed in ${Math.max(0, now() - setupStartedAt)}ms (UNKEYED).`);
803
+ : `Product setup completed in ${Math.max(0, now() - setupStartedAt)}ms (no runtime env; declared egress auth may already be available).`);
779
804
  if (setupError) {
780
805
  sessionStatus = "failed";
781
806
  completionReason = "harness_error";
@@ -786,38 +811,38 @@ async function runLiveTerminalSession(args) {
786
811
  return true;
787
812
  })()) {
788
813
  // --- The keyed run: `codex exec --json` non-interactively (stdin disabled). ---
789
- // The runtime key is injected ONLY here, command-scoped (safety contract item 1). stdin is
814
+ // openai-env passes the real key here; openai-egress passes an inert placeholder. stdin is
790
815
  // never wired (safety contract item 7) — commands.run takes no stdin channel. The command's
791
816
  // wall-clock is bounded by maxMinutes (safety contract item 2): commands.run timeoutMs +
792
817
  // an injected-clock guard so a mock/real run that exceeds it is killed and fails closed.
793
- const codexCommand = buildCodexExecCommand({ workdir: SANDBOX_WORKDIR, prompt: composedPrompt });
818
+ const codexCommand = buildCodexExecCommand({ workdir: SANDBOX_WORKDIR, prompt: composedPrompt, runtimeAuth: runtimeEnv.mode });
794
819
  const commandDigest = digestText(codexCommand);
795
820
  const startedAt = now();
796
- recordLifecycle("terminal-lab.exec.started", `Launching codex exec (command-scoped runtime key ${runtimeEnv.keyName}); wall-clock bound ${wallClockMs}ms.`);
821
+ recordLifecycle("terminal-lab.exec.started", `Launching codex exec (runtime auth ${runtimeEnv.mode}; command env names: ${Object.keys(runtimeEnv.envs).join(", ")}); wall-clock bound ${wallClockMs}ms.`);
797
822
  let exitCode;
798
823
  let runError;
799
824
  try {
800
825
  const result = await runWithWallClock(sandbox.commands.run(codexCommand, {
801
- // <-- THE command-scoped key channel. The ONLY place the key goes. The participant
826
+ // The selected command env (raw key or inert placeholder). The participant
802
827
  // marker rides the same command: humanish telemetry from inside a study reads as a new
803
828
  // adopter otherwise. #546 added the flag and nothing set it; the 0.66.0 dogfood
804
829
  // participant's twelve commands arrived unmarked.
805
830
  envs: { ...runtimeEnv.envs, HUMANISH_STUDY_PARTICIPANT: "1" },
806
831
  requestTimeoutMs,
807
832
  timeoutMs: wallClockMs,
808
- onStdout: (data) => appendTerminalChunk("stdout", data),
809
- onStderr: (data) => appendTerminalChunk("stderr", data)
833
+ onStdout: (data) => recordStreamedTerminalChunk("stdout", data),
834
+ onStderr: (data) => recordStreamedTerminalChunk("stderr", data)
810
835
  }), wallClockMs, now);
811
836
  if (result.timedOut) {
812
837
  timedOut = true;
813
838
  }
814
839
  else {
815
840
  exitCode = result.value.exitCode;
816
- // Some SDK shapes return final stdout/stderr in the result too (not only via callbacks).
841
+ // Reconcile the SDK's returned aggregate against bytes already delivered by callbacks.
817
842
  if (result.value.stdout)
818
- appendTerminalChunk("stdout", result.value.stdout);
843
+ appendReturnedTerminalOutput("stdout", result.value.stdout);
819
844
  if (result.value.stderr)
820
- appendTerminalChunk("stderr", result.value.stderr);
845
+ appendReturnedTerminalOutput("stderr", result.value.stderr);
821
846
  if (result.value.error)
822
847
  runError = result.value.error;
823
848
  }
@@ -888,6 +913,10 @@ async function runLiveTerminalSession(args) {
888
913
  warnings
889
914
  });
890
915
  }
916
+ // Prefix reconciliation may cut through a known key. Scrub literal values across the retained
917
+ // chunks before any transcript/trace/event artifact is persisted. Check both each stream and
918
+ // the combined event order that the transcript uses; either view can assemble a split value.
919
+ scrubSplitKnownValues(terminalEvents, knownSecretValues, discardedPrefixes);
891
920
  // Build the actor trace FIRST (the cost ledger reads its tokenUsage).
892
921
  const normalizedTranscript = normalizeLocalActorTranscript(terminalEvents.map((e) => e.chunk).join(""));
893
922
  // Parsed from the FULL stream, not the tail: usage records arrive once per turn and the tail
@@ -905,6 +934,7 @@ async function runLiveTerminalSession(args) {
905
934
  terminalEvents,
906
935
  commandLog,
907
936
  transcriptTail: tailOf(normalizedTranscript),
937
+ runtimeAuth: runtimeEnv.mode,
908
938
  ...(terminalTokenUsage === undefined ? {} : { tokenUsage: terminalTokenUsage })
909
939
  });
910
940
  // --- Spend ledger + no-spend proof + full caps enforcement (fail-closed). ---
@@ -968,6 +998,7 @@ async function runLiveTerminalSession(args) {
968
998
  publicSurfaces: product.publicSurfaces,
969
999
  caps,
970
1000
  runtimeAuthKeyName: runtimeEnv.keyName,
1001
+ runtimeAuth: runtimeEnv.mode,
971
1002
  policies: {
972
1003
  allowPrivateRepoAccess: config.policies?.allowPrivateRepoAccess ?? false,
973
1004
  allowProviderCredentials: config.policies?.allowProviderCredentials ?? false,
@@ -1350,6 +1381,58 @@ async function runWithWallClock(promise, wallClockMs, now) {
1350
1381
  }
1351
1382
  return value;
1352
1383
  }
1384
+ /**
1385
+ * Per-chunk sanitization cannot recognize a value split across deliveries. Redact those complete
1386
+ * known values before persistence without collapsing events or changing stdout/stderr ordering.
1387
+ * Work backwards through matches so edits to later text leave earlier offsets valid.
1388
+ */
1389
+ function scrubSplitKnownValues(events, knownValues, discardedPrefixes) {
1390
+ for (const order of ["stdout", "stderr", "combined"]) {
1391
+ const chunks = order === "combined"
1392
+ ? [...events]
1393
+ : events.filter((event) => event.stream === order);
1394
+ // A virtual final chunk makes a key crossing the capture cap recognizable. Edits to retained
1395
+ // events redact evidence; the raw overlap and this virtual chunk are never persisted.
1396
+ if (discardedPrefixes[order])
1397
+ chunks.push({ chunk: discardedPrefixes[order] });
1398
+ for (const value of knownValues) {
1399
+ if (!value)
1400
+ continue;
1401
+ let offset = 0;
1402
+ const starts = chunks.map((event) => {
1403
+ const start = offset;
1404
+ offset += event.chunk.length;
1405
+ return start;
1406
+ });
1407
+ const text = chunks.map((event) => event.chunk).join("");
1408
+ const matches = [];
1409
+ for (let at = text.indexOf(value); at !== -1; at = text.indexOf(value, at + value.length))
1410
+ matches.push(at);
1411
+ for (const at of matches.reverse()) {
1412
+ let first = 0;
1413
+ while (first + 1 < starts.length && (starts[first + 1] ?? Infinity) <= at)
1414
+ first += 1;
1415
+ let last = first;
1416
+ while (last + 1 < starts.length && (starts[last + 1] ?? Infinity) < at + value.length)
1417
+ last += 1;
1418
+ const firstChunk = chunks[first];
1419
+ const lastChunk = chunks[last];
1420
+ if (!firstChunk || !lastChunk)
1421
+ continue;
1422
+ const before = firstChunk.chunk.slice(0, at - (starts[first] ?? 0));
1423
+ const after = lastChunk.chunk.slice(at + value.length - (starts[last] ?? 0));
1424
+ firstChunk.chunk = `${before}[REDACTED_SECRET]${first === last ? after : ""}`;
1425
+ for (let index = first + 1; index < last; index += 1) {
1426
+ const middle = chunks[index];
1427
+ if (middle)
1428
+ middle.chunk = "";
1429
+ }
1430
+ if (first !== last)
1431
+ lastChunk.chunk = after;
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1353
1436
  /** Build the in-sandbox `codex exec` command (non-interactive, JSON, stdin disabled by mechanism). */
1354
1437
  function buildCodexExecCommand(args) {
1355
1438
  // The prompt is passed via a heredoc on stdin of a wrapper? NO, stdin is DISABLED (item 7), so
@@ -1364,7 +1447,13 @@ function buildCodexExecCommand(args) {
1364
1447
  // The E2B sandbox is the trust boundary (the disposable machine); the sibling
1365
1448
  // oss-meta-lab lane carries the same flag at both live call sites for the
1366
1449
  // same reason, and exec mode has no interactive approval channel at all.
1367
- return `cd ${args.workdir} && npm_config_update_notifier=false npx -y @openai/codex@latest exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --json ${quotedPrompt}`;
1450
+ // The egress transform protects only the default OpenAI host. Pin the effective built-in
1451
+ // provider/base URL above config-file settings so setup-written custom endpoints cannot make
1452
+ // this invocation silently claim protection for another provider. openai-env is unchanged.
1453
+ const providerConfig = args.runtimeAuth === "openai-egress"
1454
+ ? ` -c 'model_provider="openai"' -c 'openai_base_url="https://api.openai.com/v1"'`
1455
+ : "";
1456
+ return `cd ${args.workdir} && npm_config_update_notifier=false npx -y @openai/codex@latest exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check${providerConfig} --json ${quotedPrompt}`;
1368
1457
  }
1369
1458
  /** Compose the live prompt: PUBLIC surfaces + author mission + the verdict-nonce marker contract. */
1370
1459
  function composeLivePrompt(args) {
@@ -1434,7 +1523,9 @@ function buildTerminalActorTrace(args) {
1434
1523
  terminalEvents: args.terminalEvents.length
1435
1524
  },
1436
1525
  items,
1437
- capabilities: TERMINAL_AGENT_CAPABILITIES
1526
+ capabilities: args.runtimeAuth === "openai-egress"
1527
+ ? { ...TERMINAL_AGENT_CAPABILITIES, keyPlacement: "external" }
1528
+ : TERMINAL_AGENT_CAPABILITIES
1438
1529
  };
1439
1530
  }
1440
1531
  /**
@@ -1518,7 +1609,7 @@ export function buildTerminalProductBundle(args) {
1518
1609
  type: "terminal-lab.credentials.declared",
1519
1610
  // Names-only evidence (invariant 1): the runtime-auth CHANNEL is declared; no value is ever
1520
1611
  // recorded. The deny-by-default policies are recorded so the credential posture is auditable.
1521
- message: `Runtime auth channel: ${args.runtimeAuth ?? "none declared"} (names only; values never persist; command-scoped injection is enforced by the shipped live engine, while this dry-run performs no injection). Credential policies (deny-by-default): allowPrivateRepoAccess=${args.policies.allowPrivateRepoAccess}, allowProviderCredentials=${args.policies.allowProviderCredentials}, allowPaymentCredentials=${args.policies.allowPaymentCredentials}, allowGitHubMutation=${args.policies.allowGitHubMutation}.`,
1612
+ message: `Runtime auth channel: ${args.runtimeAuth ?? "none declared"} (names only; values never persist; the live engine applies the selected key placement, while this dry-run performs no injection). Credential policies (deny-by-default): allowPrivateRepoAccess=${args.policies.allowPrivateRepoAccess}, allowProviderCredentials=${args.policies.allowProviderCredentials}, allowPaymentCredentials=${args.policies.allowPaymentCredentials}, allowGitHubMutation=${args.policies.allowGitHubMutation}.`,
1522
1613
  simId: "sim-001",
1523
1614
  streamId: "stream-001"
1524
1615
  },
@@ -1536,7 +1627,7 @@ export function buildTerminalProductBundle(args) {
1536
1627
  at: args.createdAt,
1537
1628
  level: "info",
1538
1629
  type: "terminal-lab.contract.ready",
1539
- message: "Dry-run contract bundle ready. Switch scenario.mode to live with the required runtime auth and caps to exercise the in-sandbox agent route, captured exec stream, and command-scoped credential boundary.",
1630
+ message: "Dry-run contract bundle ready. Switch scenario.mode to live with the required runtime auth and caps to exercise the in-sandbox agent route, captured exec stream, and declared runtime-auth placement.",
1540
1631
  simId: "sim-001",
1541
1632
  streamId: "stream-001"
1542
1633
  }
@@ -1737,7 +1828,7 @@ export function buildLiveTerminalProductBundle(args) {
1737
1828
  events: lifecycleEvents,
1738
1829
  redaction: {
1739
1830
  status: "passed",
1740
- notes: `Live terminal-product run: the in-sandbox agent's output was captured via commands.run onStdout/onStderr and scrubbed (literal known values incl. the runtime key) THEN redacted (shape patterns) AT THE SOURCE before persisting. The runtime key (${args.runtimeAuthKeyName}) was injected ONLY into the command-scoped codex invocation, never sandbox-global env or metadata; only its NAME appears in evidence. Subject provenance is UNPINNED (public-surface study).`
1831
+ notes: `Live terminal-product run: the in-sandbox agent's output was captured via commands.run onStdout/onStderr and scrubbed (literal known values incl. the runtime key) THEN redacted (shape patterns) AT THE SOURCE before persisting. ${args.runtimeAuth === "openai-egress" ? `Runtime auth openai-egress: the raw key from ${args.runtimeAuthKeyName} is reserved for E2B's external api.openai.com HTTPS header transform. ${args.ledgers.commandLog.some((command) => command.label === "codex-exec") ? "Codex received an inert CODEX_API_KEY placeholder." : "Codex was not launched."} Any created sandbox retains a spendable OpenAI proxy capability until teardown; additional provider calls may not appear in the Codex usage ledger.` : `Runtime auth openai-env: the runtime key (${args.runtimeAuthKeyName}) was injected ONLY into the command-scoped codex invocation, never sandbox-global env or metadata; only its NAME appears in evidence.`} Subject provenance is UNPINNED (public-surface study).`
1741
1832
  },
1742
1833
  artifacts: {
1743
1834
  run: "run.json",