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.
- package/README.md +9 -3
- package/dist/actor-contract.d.ts +16 -0
- package/dist/actor-contract.js.map +1 -1
- package/dist/chrome-cdp-probe.js +13 -0
- package/dist/chrome-cdp-probe.js.map +1 -1
- package/dist/computer-use.d.ts +7 -1
- package/dist/computer-use.js +156 -13
- package/dist/computer-use.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +7 -6
- package/dist/cua-actor-lab.js +95 -21
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/e2b-desktop-launch.d.ts +28 -1
- package/dist/e2b-desktop-launch.js +88 -1
- package/dist/e2b-desktop-launch.js.map +1 -1
- package/dist/e2b-desktop-screenshot-cleanup.d.ts +15 -0
- package/dist/e2b-desktop-screenshot-cleanup.js +67 -0
- package/dist/e2b-desktop-screenshot-cleanup.js.map +1 -0
- package/dist/e2b-terminal-lab.d.ts +3 -2
- package/dist/e2b-terminal-lab.js +176 -85
- package/dist/e2b-terminal-lab.js.map +1 -1
- package/dist/first-run-path.js +2 -2
- package/dist/first-run-path.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lab-config.d.ts +11 -14
- package/dist/lab-config.js +2 -2
- package/dist/lab-config.js.map +1 -1
- package/dist/openai-responses-cu.js +76 -49
- package/dist/openai-responses-cu.js.map +1 -1
- package/dist/program.js +16 -7
- package/dist/program.js.map +1 -1
- package/dist/run.d.ts +1 -1
- package/dist/terminal-node-bootstrap.d.ts +4 -0
- package/dist/terminal-node-bootstrap.js +58 -0
- package/dist/terminal-node-bootstrap.js.map +1 -0
- package/dist/terminal-runtime-auth.d.ts +13 -0
- package/dist/terminal-runtime-auth.js +24 -0
- package/dist/terminal-runtime-auth.js.map +1 -0
- package/docs/architecture/actor-contract.md +22 -0
- package/docs/architecture/terminal-product-lane.md +108 -7
- package/docs/contracts/feedback.md +14 -0
- package/docs/contracts/schemas.md +1 -1
- package/docs/goals/current.md +45 -13
- package/docs/ramp/README.md +1 -1
- package/package.json +4 -2
package/dist/e2b-terminal-lab.js
CHANGED
|
@@ -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
|
|
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.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
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
|
-
|
|
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
|
|
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)
|
|
671
|
-
: `Egress
|
|
672
|
-
|
|
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:
|
|
676
|
-
// The stock
|
|
677
|
-
//
|
|
678
|
-
//
|
|
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(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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) =>
|
|
809
|
-
onStderr: (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
|
-
//
|
|
841
|
+
// Reconcile the SDK's returned aggregate against bytes already delivered by callbacks.
|
|
817
842
|
if (result.value.stdout)
|
|
818
|
-
|
|
843
|
+
appendReturnedTerminalOutput("stdout", result.value.stdout);
|
|
819
844
|
if (result.value.stderr)
|
|
820
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
|
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
|
|
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.
|
|
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",
|