humanish 0.0.1 → 0.15.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/AGENTS.md +77 -0
- package/CONTRIBUTING.md +39 -0
- package/LICENSE +21 -0
- package/README.md +435 -8
- package/SECURITY.md +34 -0
- package/dist/actor-contract.d.ts +107 -0
- package/dist/actor-contract.js +286 -0
- package/dist/actor-contract.js.map +1 -0
- package/dist/actor-registry.d.ts +73 -0
- package/dist/actor-registry.js +93 -0
- package/dist/actor-registry.js.map +1 -0
- package/dist/adapter-extension.d.ts +54 -0
- package/dist/adapter-extension.js +214 -0
- package/dist/adapter-extension.js.map +1 -0
- package/dist/argv.d.ts +1 -0
- package/dist/argv.js +8 -0
- package/dist/argv.js.map +1 -0
- package/dist/artifact-reference.d.ts +22 -0
- package/dist/artifact-reference.js +47 -0
- package/dist/artifact-reference.js.map +1 -0
- package/dist/browser-evidence-hygiene.d.ts +7 -0
- package/dist/browser-evidence-hygiene.js +40 -0
- package/dist/browser-evidence-hygiene.js.map +1 -0
- package/dist/claude-agent-sdk.d.ts +94 -0
- package/dist/claude-agent-sdk.js +356 -0
- package/dist/claude-agent-sdk.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex-app-server-ui.d.ts +39 -0
- package/dist/codex-app-server-ui.js +327 -0
- package/dist/codex-app-server-ui.js.map +1 -0
- package/dist/codex-app-server.d.ts +141 -0
- package/dist/codex-app-server.js +747 -0
- package/dist/codex-app-server.js.map +1 -0
- package/dist/command-failure.d.ts +29 -0
- package/dist/command-failure.js +54 -0
- package/dist/command-failure.js.map +1 -0
- package/dist/computer-use-actor.d.ts +52 -0
- package/dist/computer-use-actor.js +48 -0
- package/dist/computer-use-actor.js.map +1 -0
- package/dist/computer-use.d.ts +232 -0
- package/dist/computer-use.js +615 -0
- package/dist/computer-use.js.map +1 -0
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +27 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/concurrent-shared-world-lab.d.ts +115 -0
- package/dist/concurrent-shared-world-lab.js +1092 -0
- package/dist/concurrent-shared-world-lab.js.map +1 -0
- package/dist/core/git-state.d.ts +31 -0
- package/dist/core/git-state.js +142 -0
- package/dist/core/git-state.js.map +1 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/run-primitives.d.ts +66 -0
- package/dist/core/run-primitives.js +120 -0
- package/dist/core/run-primitives.js.map +1 -0
- package/dist/cua-actor-lab.d.ts +620 -0
- package/dist/cua-actor-lab.js +2834 -0
- package/dist/cua-actor-lab.js.map +1 -0
- package/dist/device-presets.d.ts +67 -0
- package/dist/device-presets.js +50 -0
- package/dist/device-presets.js.map +1 -0
- package/dist/e2b-desktop-executor.d.ts +101 -0
- package/dist/e2b-desktop-executor.js +309 -0
- package/dist/e2b-desktop-executor.js.map +1 -0
- package/dist/e2b-desktop-launch.d.ts +144 -0
- package/dist/e2b-desktop-launch.js +59 -0
- package/dist/e2b-desktop-launch.js.map +1 -0
- package/dist/e2b-detached.d.ts +53 -0
- package/dist/e2b-detached.js +130 -0
- package/dist/e2b-detached.js.map +1 -0
- package/dist/e2b-terminal-lab.d.ts +345 -0
- package/dist/e2b-terminal-lab.js +1467 -0
- package/dist/e2b-terminal-lab.js.map +1 -0
- package/dist/env-file.d.ts +14 -0
- package/dist/env-file.js +108 -0
- package/dist/env-file.js.map +1 -0
- package/dist/feedback.d.ts +51 -0
- package/dist/feedback.js +313 -0
- package/dist/feedback.js.map +1 -0
- package/dist/image-evidence.d.ts +2 -0
- package/dist/image-evidence.js +33 -0
- package/dist/image-evidence.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/init-templates.d.ts +12 -0
- package/dist/init-templates.js +369 -0
- package/dist/init-templates.js.map +1 -0
- package/dist/init.d.ts +26 -0
- package/dist/init.js +343 -0
- package/dist/init.js.map +1 -0
- package/dist/lab-app-runner.d.ts +78 -0
- package/dist/lab-app-runner.js +403 -0
- package/dist/lab-app-runner.js.map +1 -0
- package/dist/lab-config.d.ts +578 -0
- package/dist/lab-config.js +1751 -0
- package/dist/lab-config.js.map +1 -0
- package/dist/lab-engine.d.ts +77 -0
- package/dist/lab-engine.js +216 -0
- package/dist/lab-engine.js.map +1 -0
- package/dist/lab-preflight.d.ts +67 -0
- package/dist/lab-preflight.js +385 -0
- package/dist/lab-preflight.js.map +1 -0
- package/dist/labs.d.ts +53 -0
- package/dist/labs.js +176 -0
- package/dist/labs.js.map +1 -0
- package/dist/observer-assets.d.ts +2 -0
- package/dist/observer-assets.js +2630 -0
- package/dist/observer-assets.js.map +1 -0
- package/dist/observer-data.d.ts +63 -0
- package/dist/observer-data.js +145 -0
- package/dist/observer-data.js.map +1 -0
- package/dist/observer-static.d.ts +39 -0
- package/dist/observer-static.js +192 -0
- package/dist/observer-static.js.map +1 -0
- package/dist/observer.d.ts +46 -0
- package/dist/observer.js +353 -0
- package/dist/observer.js.map +1 -0
- package/dist/openai-responses-cu.d.ts +113 -0
- package/dist/openai-responses-cu.js +531 -0
- package/dist/openai-responses-cu.js.map +1 -0
- package/dist/oss-lab.d.ts +52 -0
- package/dist/oss-lab.js +299 -0
- package/dist/oss-lab.js.map +1 -0
- package/dist/oss-meta-lab-scoring.d.ts +15 -0
- package/dist/oss-meta-lab-scoring.js +145 -0
- package/dist/oss-meta-lab-scoring.js.map +1 -0
- package/dist/oss-meta-lab.d.ts +279 -0
- package/dist/oss-meta-lab.js +4889 -0
- package/dist/oss-meta-lab.js.map +1 -0
- package/dist/oss-remote-telemetry.d.ts +77 -0
- package/dist/oss-remote-telemetry.js +403 -0
- package/dist/oss-remote-telemetry.js.map +1 -0
- package/dist/persona.d.ts +35 -0
- package/dist/persona.js +133 -0
- package/dist/persona.js.map +1 -0
- package/dist/pi-agent-core.d.ts +77 -0
- package/dist/pi-agent-core.js +203 -0
- package/dist/pi-agent-core.js.map +1 -0
- package/dist/program.d.ts +54 -0
- package/dist/program.js +2347 -0
- package/dist/program.js.map +1 -0
- package/dist/redaction.d.ts +90 -0
- package/dist/redaction.js +292 -0
- package/dist/redaction.js.map +1 -0
- package/dist/run.d.ts +784 -0
- package/dist/run.js +4555 -0
- package/dist/run.js.map +1 -0
- package/dist/scripted-browser-actor.d.ts +294 -0
- package/dist/scripted-browser-actor.js +1286 -0
- package/dist/scripted-browser-actor.js.map +1 -0
- package/dist/scripted-browser-lab.d.ts +107 -0
- package/dist/scripted-browser-lab.js +738 -0
- package/dist/scripted-browser-lab.js.map +1 -0
- package/dist/shared-world-lab.d.ts +208 -0
- package/dist/shared-world-lab.js +1048 -0
- package/dist/shared-world-lab.js.map +1 -0
- package/dist/source-archive.d.ts +82 -0
- package/dist/source-archive.js +365 -0
- package/dist/source-archive.js.map +1 -0
- package/dist/stop-conditions.d.ts +26 -0
- package/dist/stop-conditions.js +66 -0
- package/dist/stop-conditions.js.map +1 -0
- package/dist/terminal-agent-actor.d.ts +36 -0
- package/dist/terminal-agent-actor.js +23 -0
- package/dist/terminal-agent-actor.js.map +1 -0
- package/docs/architecture/actor-contract.md +431 -0
- package/docs/architecture/github-feedback-loop.md +189 -0
- package/docs/architecture/local-codex-tui-actor.md +213 -0
- package/docs/architecture/observer.md +118 -0
- package/docs/architecture/oss-lab-poc.md +242 -0
- package/docs/architecture/project-layout.md +166 -0
- package/docs/architecture/state-driven-executor.md +203 -0
- package/docs/architecture/terminal-product-lane.md +148 -0
- package/docs/contracts/adapter-fixtures.md +80 -0
- package/docs/contracts/core.md +71 -0
- package/docs/contracts/feedback.md +145 -0
- package/docs/contracts/policy.md +305 -0
- package/docs/contracts/run-bundle.md +358 -0
- package/docs/contracts/schemas.md +984 -0
- package/docs/goals/current.md +409 -0
- package/docs/principles/invariants-and-defaults.md +135 -0
- package/docs/principles/self-driving-harness.md +129 -0
- package/docs/product/open-source-install-experience.md +229 -0
- package/docs/ramp/README.md +200 -0
- package/docs/release/open-source-readiness.md +208 -0
- package/docs/release/public-readiness-standard.md +205 -0
- package/docs/roadmap/world-class-open-source-v0.md +311 -0
- package/package.json +102 -8
- package/skills/humanish/SKILL.md +244 -0
- package/skills/humanish/agents/openai.yaml +7 -0
- package/index.js +0 -1
|
@@ -0,0 +1,1467 @@
|
|
|
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
|
|
3
|
+
// its non-interactive exec output (stdin disabled) as a redacted event stream + normalized
|
|
4
|
+
// transcript, capped at no-spend, emitting durable terminal/substrate/cost/no-spend/cleanup/
|
|
5
|
+
// intervention proof. Mirrors cua-actor-lab.ts / scripted-browser-lab.ts.
|
|
6
|
+
//
|
|
7
|
+
// SLICE 2 SCOPE: BOTH paths are now implemented.
|
|
8
|
+
// - DRY-RUN: a contract-only `humanish.run-bundle.v1`, honestly labeled (unchanged from SLICE 1).
|
|
9
|
+
// - LIVE: the real create -> inject (command-scoped) -> run `codex exec --json` -> capture
|
|
10
|
+
// (scrub+redact at the source) -> score (verdict-nonce marker) -> teardown (proven cleanup)
|
|
11
|
+
// orchestrator on the @e2b/desktop commands.run surface.
|
|
12
|
+
//
|
|
13
|
+
// THE SAFETY CONTRACT (docs/goals/terminal-product-lane/goal.md) is enforced BY CONSTRUCTION here
|
|
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).
|
|
19
|
+
// 2. FAIL-CLOSED CAP. The live key is never exercised without scenario.caps in force: maxUsd
|
|
20
|
+
// (default/require 0 = no-spend) + maxMinutes (wall-clock kill of the codex command).
|
|
21
|
+
// 3. PUBLIC SURFACES ONLY. The mission references only subject.product.publicSurfaces + the
|
|
22
|
+
// author mission. No clone, no private-source access — nothing is git-cloned in this lane.
|
|
23
|
+
// 4. DENY-BY-DEFAULT CREDENTIALS. The command envs are built from an ALLOWLIST of ONLY the
|
|
24
|
+
// declared runtime key; GITHUB_TOKEN/GH_TOKEN/payment/deploy/db/media keys are excluded by
|
|
25
|
+
// construction (a banned-name guard also fails closed if one is ever requested).
|
|
26
|
+
// 5. NO SECRET VALUES IN EVIDENCE. Every captured byte (event stream, transcript, command logs,
|
|
27
|
+
// agent report, metadata) passes scrubKnownValues (literal scrub of the runtime key + any
|
|
28
|
+
// provisioned values, >=4 chars, PRE-truncation) THEN redactText (shape patterns) BEFORE
|
|
29
|
+
// persisting. The transport is labeled HONESTLY (exec-stream/snapshot, NOT an interactive pty).
|
|
30
|
+
// 6. METADATA POSITIVE ALLOWLIST. buildSandboxMetadata(allowlist) is the ONLY way metadata is
|
|
31
|
+
// set; it carries solely non-secret labels (mode/tool/labId/simId/provider/runId).
|
|
32
|
+
// 7. STDIN DISABLED + INTERVENTIONS LEDGER. stdin is never wired to the codex command; the
|
|
33
|
+
// bundle ALWAYS carries an interventions ledger (empty array is valid + required-present).
|
|
34
|
+
// 8. PROVEN CLEANUP, BY ID, NEVER ACCOUNT-WIDE. Sandbox.kill(id) in a finally; the cleanup
|
|
35
|
+
// proof is BY EXACT ID: kill(id)'s own found-and-killed boolean, confirmed further by
|
|
36
|
+
// Sandbox.getInfo(id) when the SDK exposes it (a thrown SandboxNotFoundError means gone).
|
|
37
|
+
// humanish NEVER calls Sandbox.list to prove cleanup, so a shared operator key never reaches a
|
|
38
|
+
// sandbox it did not create. A live run that cannot prove teardown fails closed.
|
|
39
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
40
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { ACTOR_TRACE_SCHEMA, TERMINAL_AGENT_CAPABILITIES } from "./actor-contract.js";
|
|
43
|
+
import { actorRegistry, isTerminalActorDescriptor } from "./actor-registry.js";
|
|
44
|
+
import { toErrorMessage } from "./command-failure.js";
|
|
45
|
+
import { isSandboxNotFoundError, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
|
|
46
|
+
import { renderObserver } from "./observer.js";
|
|
47
|
+
import { digestText, redactedTail, redactText } from "./redaction.js";
|
|
48
|
+
import { buildRunSource, extractLocalActorVerdict, normalizeLocalActorTranscript, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA } from "./run.js";
|
|
49
|
+
import { TERMINAL_AGENT_NOT_IMPLEMENTED_CODE } from "./terminal-agent-actor.js";
|
|
50
|
+
/** Provider-neutral metadata constant: the lane's non-secret tag (mirrors CUA_ACTOR_LAB_PROVIDER_METADATA). */
|
|
51
|
+
export const TERMINAL_PRODUCT_LAB_PROVIDER_METADATA = {
|
|
52
|
+
mode: "terminal-product-lab",
|
|
53
|
+
tool: "humanish"
|
|
54
|
+
};
|
|
55
|
+
// The terminal-product ledger schemas the verifier asserts present on a LIVE bundle. They ride the
|
|
56
|
+
// existing terminal stream + events (humanish.run-bundle.v1 is unchanged); these constants name the
|
|
57
|
+
// artifact files so the producer and verifier cannot drift on the path.
|
|
58
|
+
export const TERMINAL_EVENTS_ARTIFACT = "terminal-events.ndjson";
|
|
59
|
+
export const TERMINAL_TRANSCRIPT_ARTIFACT = "terminal-transcript.txt";
|
|
60
|
+
export const TERMINAL_LEDGERS_ARTIFACT = "terminal-ledgers.json";
|
|
61
|
+
/** The in-sandbox working directory for the agent (a scratch dir; nothing is cloned into it). */
|
|
62
|
+
const SANDBOX_WORKDIR = "/home/user/study";
|
|
63
|
+
// Server-side reclamation buffer past the codex command's own wall-clock (caps.maxMinutes) kill.
|
|
64
|
+
const SANDBOX_TIMEOUT_BUFFER_MS = 5 * 60_000;
|
|
65
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
66
|
+
// The runtime-bootstrap step (ensure Node/npm) can run an apt-get install; the SDK's commands.run
|
|
67
|
+
// timeoutMs default (60s) is far too short for that, so this step gets an explicit generous budget.
|
|
68
|
+
const RUNTIME_BOOTSTRAP_TIMEOUT_MS = 300_000;
|
|
69
|
+
// UNKEYED (no envs) shell command that ensures Node/npm are present before the keyed codex exec.
|
|
70
|
+
// Reuses the oss-meta-lab.ts ensure_node() shape: check node's major version, else install
|
|
71
|
+
// Node 22 via NodeSource plus passwordless sudo (the stock @e2b/desktop image ships neither codex
|
|
72
|
+
// nor a recent Node, per issue #159). A final presence check makes the whole command exit non-zero
|
|
73
|
+
// (so the bootstrap step fails closed) if the install still leaves node/npm missing.
|
|
74
|
+
const RUNTIME_BOOTSTRAP_COMMAND = [
|
|
75
|
+
`node_major=0`,
|
|
76
|
+
`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`,
|
|
77
|
+
`if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && [ "$node_major" -ge 20 ]; then exit 0; fi`,
|
|
78
|
+
`sudo -n apt-get update`,
|
|
79
|
+
`sudo -n apt-get install -y ca-certificates curl gnupg`,
|
|
80
|
+
`curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -n -E bash -`,
|
|
81
|
+
`sudo -n apt-get install -y nodejs`,
|
|
82
|
+
`command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1`
|
|
83
|
+
].join(" && ");
|
|
84
|
+
// How much of a captured stream / log tail rides a (redacted) message field.
|
|
85
|
+
const TAIL_CHARS = 2000;
|
|
86
|
+
// Hard cap on the retained event-stream + transcript size, so a runaway agent cannot balloon the
|
|
87
|
+
// bundle. Redaction runs PRE-truncation so a cut can never split a secret past the scrubber.
|
|
88
|
+
const MAX_TRANSCRIPT_BYTES = 512 * 1024;
|
|
89
|
+
export const TERMINAL_PRODUCT_LAB_SCHEMA = "humanish.terminal-lab-result.v1";
|
|
90
|
+
export async function runTerminalProductLab(options) {
|
|
91
|
+
const { config, dryRun } = options;
|
|
92
|
+
const cwd = path.resolve(options.cwd);
|
|
93
|
+
const hooks = options.hooks ?? {};
|
|
94
|
+
const render = hooks.renderObserverFn ?? renderObserver;
|
|
95
|
+
const warnings = [];
|
|
96
|
+
const actorType = config.actors[0]?.type ?? "";
|
|
97
|
+
const product = config.subject.product;
|
|
98
|
+
const failed = (code, message, extras) => ({
|
|
99
|
+
schema: TERMINAL_PRODUCT_LAB_SCHEMA,
|
|
100
|
+
ok: false,
|
|
101
|
+
cwd,
|
|
102
|
+
labId: config.id,
|
|
103
|
+
actor: extras?.actor ?? actorType,
|
|
104
|
+
product: extras?.product ?? product?.name ?? "",
|
|
105
|
+
dryRun,
|
|
106
|
+
runId: options.runId ?? "not-created",
|
|
107
|
+
warnings,
|
|
108
|
+
error: { code, message }
|
|
109
|
+
});
|
|
110
|
+
// Resolve the actor through the registry — the parse layer already validated this, but the
|
|
111
|
+
// engine fails closed rather than trusting a config that arrived through another door
|
|
112
|
+
// (runTerminalProductLab is itself exported npm surface).
|
|
113
|
+
const descriptor = actorRegistry[actorType];
|
|
114
|
+
if (!descriptor || !isTerminalActorDescriptor(descriptor)) {
|
|
115
|
+
return failed("HUMANISH_TERMINAL_LAB_ACTOR_UNSUPPORTED", `actors[0].type "${actorType}" is not a registered terminal actor.`);
|
|
116
|
+
}
|
|
117
|
+
// Re-enforce the subject shape at the engine (the parser rejects these too, but this is exported
|
|
118
|
+
// npm surface). A terminal-product subject MUST declare product.name + public surfaces.
|
|
119
|
+
if (!product || !product.name || product.publicSurfaces.length === 0) {
|
|
120
|
+
return failed("HUMANISH_TERMINAL_LAB_SUBJECT_INVALID", "terminal-product subjects require `subject.product` with a name and at least one public surface URL.", { actor: descriptor.id });
|
|
121
|
+
}
|
|
122
|
+
// LIVE path: the real in-sandbox agent session. A separate orchestrator owns the
|
|
123
|
+
// create -> inject (command-scoped) -> run -> capture -> teardown lifecycle so the dry-run path
|
|
124
|
+
// below stays a pure contract builder. It enforces the safety contract by construction (the
|
|
125
|
+
// keyPlacement-routed command-scoped key, the deny-by-default allowlist, the fail-closed cap,
|
|
126
|
+
// the proven cleanup) and fails closed before any sandbox/key/spend on any precondition miss.
|
|
127
|
+
if (!dryRun) {
|
|
128
|
+
return runLiveTerminalSession({ options, cwd, config, descriptorId: descriptor.id, product, warnings, render, failed });
|
|
129
|
+
}
|
|
130
|
+
const mission = config.actors[0]?.mission ?? defaultMission(product.name);
|
|
131
|
+
const personaId = config.actors[0]?.persona ?? "autonomous-terminal-agent";
|
|
132
|
+
// The composed prompt = mission + persona + public-surface manifest. Only the AUTHOR mission
|
|
133
|
+
// goes plaintext into evidence (it is public-safe committed lab text); the full composed prompt
|
|
134
|
+
// is recorded as a DIGEST (the safety contract's mission ruling).
|
|
135
|
+
const composedPrompt = composePrompt({ mission, personaId, productName: product.name, publicSurfaces: product.publicSurfaces });
|
|
136
|
+
const promptDigest = digestText(composedPrompt);
|
|
137
|
+
const persona = { id: personaId, traitsApplied: [], promptDigest };
|
|
138
|
+
const runId = options.runId ?? makeTerminalRunId();
|
|
139
|
+
const artifactRoot = path.join(cwd, ".humanish", "runs", runId);
|
|
140
|
+
const createdAt = new Date().toISOString();
|
|
141
|
+
await mkdir(artifactRoot, { recursive: true });
|
|
142
|
+
const source = await buildRunSource({
|
|
143
|
+
capturedAt: createdAt,
|
|
144
|
+
cwd,
|
|
145
|
+
humanishSource: "present",
|
|
146
|
+
packageName: "humanish"
|
|
147
|
+
});
|
|
148
|
+
const bundle = buildTerminalProductBundle({
|
|
149
|
+
actorId: descriptor.id,
|
|
150
|
+
createdAt,
|
|
151
|
+
dryRun,
|
|
152
|
+
labId: config.id,
|
|
153
|
+
...(config.title ? { labTitle: config.title } : {}),
|
|
154
|
+
mission,
|
|
155
|
+
persona,
|
|
156
|
+
productName: product.name,
|
|
157
|
+
publicSurfaces: product.publicSurfaces,
|
|
158
|
+
...(config.scenario?.caps ? { caps: config.scenario.caps } : {}),
|
|
159
|
+
...(config.execution?.runtimeAuth ? { runtimeAuth: config.execution.runtimeAuth } : {}),
|
|
160
|
+
stdin: config.execution?.terminal?.stdin ?? "disabled",
|
|
161
|
+
policies: {
|
|
162
|
+
allowPrivateRepoAccess: config.policies?.allowPrivateRepoAccess ?? false,
|
|
163
|
+
allowProviderCredentials: config.policies?.allowProviderCredentials ?? false,
|
|
164
|
+
allowPaymentCredentials: config.policies?.allowPaymentCredentials ?? false,
|
|
165
|
+
allowGitHubMutation: config.policies?.allowGitHubMutation ?? false
|
|
166
|
+
},
|
|
167
|
+
runId,
|
|
168
|
+
source
|
|
169
|
+
});
|
|
170
|
+
await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
|
171
|
+
await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8");
|
|
172
|
+
await writeFile(path.join(artifactRoot, "review.md"), renderTerminalReviewMarkdown(bundle), "utf8");
|
|
173
|
+
await writeFile(path.join(artifactRoot, "events.ndjson"), `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");
|
|
174
|
+
// Keep `verify --run latest` honest: point it at THIS run (mirrors run.ts's RunPointer).
|
|
175
|
+
await writeFile(path.join(cwd, ".humanish", "runs", "latest.json"), `${JSON.stringify({
|
|
176
|
+
schema: "humanish.latest-run.v1",
|
|
177
|
+
runId,
|
|
178
|
+
path: path.join(".humanish", "runs", runId),
|
|
179
|
+
updatedAt: createdAt
|
|
180
|
+
}, null, 2)}\n`, "utf8");
|
|
181
|
+
const observer = await render(cwd, runId, { open: options.open === true });
|
|
182
|
+
const ok = observer.ok;
|
|
183
|
+
return {
|
|
184
|
+
schema: TERMINAL_PRODUCT_LAB_SCHEMA,
|
|
185
|
+
ok,
|
|
186
|
+
cwd,
|
|
187
|
+
labId: config.id,
|
|
188
|
+
actor: descriptor.id,
|
|
189
|
+
product: product.name,
|
|
190
|
+
dryRun,
|
|
191
|
+
runId,
|
|
192
|
+
observer,
|
|
193
|
+
warnings: [...warnings, ...observer.warnings],
|
|
194
|
+
...(ok
|
|
195
|
+
? {}
|
|
196
|
+
: {
|
|
197
|
+
error: {
|
|
198
|
+
code: "HUMANISH_TERMINAL_LAB_FAILED",
|
|
199
|
+
message: observer.error?.message ?? "Observer failed for the terminal-product lab run."
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** The four cost categories, in a fixed order so the ledger shape is stable across runs. */
|
|
205
|
+
const COST_CATEGORIES = ["product", "media", "payment", "provider"];
|
|
206
|
+
/**
|
|
207
|
+
* Build the spend ledger from the captured session. THE NULL DISCIPLINE (issue #154):
|
|
208
|
+
* - The `provider` line is populated from the actor trace's tokenUsage.costUsd when the trace
|
|
209
|
+
* CARRIES it (a measured value, incl. a measured 0). When the trace carries NO costUsd, the
|
|
210
|
+
* provider line is `null` = NOT MEASURED (never guessed to 0 just because no-spend was intended).
|
|
211
|
+
* - product/media/payment are `null` this slice: the lane has NO product-spend signal yet (that is
|
|
212
|
+
* the adapter's job in SLICE 4). The LEDGER SHAPE + the null discipline ship now; the signal does not.
|
|
213
|
+
* `injectedLines` lets a test (and, later, the SLICE-4 adapter) supply known spend for a category,
|
|
214
|
+
* exercising the fail-closed cap enforcement deterministically without a real billable run.
|
|
215
|
+
*/
|
|
216
|
+
function buildCostLedger(args) {
|
|
217
|
+
const providerLine = typeof args.tokenCostUsd === "number"
|
|
218
|
+
? {
|
|
219
|
+
usd: args.tokenCostUsd,
|
|
220
|
+
source: "provider-token-usage",
|
|
221
|
+
note: `Provider spend metered from the actor trace tokenUsage.costUsd (${args.tokenCostUsd} USD).`
|
|
222
|
+
}
|
|
223
|
+
: {
|
|
224
|
+
usd: null,
|
|
225
|
+
source: "unmeasured",
|
|
226
|
+
note: "Provider spend NOT MEASURED: the actor trace carried no tokenUsage.costUsd this run. Recorded null (not guessed to 0)."
|
|
227
|
+
};
|
|
228
|
+
const unmeasured = (category) => ({
|
|
229
|
+
usd: null,
|
|
230
|
+
count: null,
|
|
231
|
+
source: "unmeasured",
|
|
232
|
+
note: `${category} spend NOT MEASURED this slice: the terminal-product lane has no ${category}-spend signal yet (the adapter supplies it in SLICE 4). Recorded null (never guessed to 0).`
|
|
233
|
+
});
|
|
234
|
+
const lines = {
|
|
235
|
+
product: args.injectedLines?.product ?? unmeasured("product"),
|
|
236
|
+
media: args.injectedLines?.media ?? unmeasured("media"),
|
|
237
|
+
payment: args.injectedLines?.payment ?? unmeasured("payment"),
|
|
238
|
+
provider: args.injectedLines?.provider ?? providerLine
|
|
239
|
+
};
|
|
240
|
+
// knownTotalUsd sums ONLY the non-null lines. A null line contributes NOTHING — it is never
|
|
241
|
+
// coerced to 0 (that would let an unmeasured category masquerade as a measured zero).
|
|
242
|
+
let knownTotalUsd = 0;
|
|
243
|
+
let fullyMeasured = true;
|
|
244
|
+
for (const category of COST_CATEGORIES) {
|
|
245
|
+
const usd = lines[category].usd;
|
|
246
|
+
if (usd === null) {
|
|
247
|
+
fullyMeasured = false;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
knownTotalUsd += usd;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
schema: "humanish.terminal-cost-ledger.v1",
|
|
255
|
+
currency: "usd",
|
|
256
|
+
lines,
|
|
257
|
+
knownTotalUsd: roundUsd(knownTotalUsd),
|
|
258
|
+
fullyMeasured
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/** Derive the no-spend proof from the ledger. It is HONEST: it vouches for known-zero lines and
|
|
262
|
+
* explicitly lists the unmeasured (null) lines it cannot vouch for — never claiming zero on null. */
|
|
263
|
+
function buildNoSpendProof(ledger, maxUsd) {
|
|
264
|
+
const knownZeroLines = [];
|
|
265
|
+
const knownNonZeroLines = [];
|
|
266
|
+
const unmeasuredLines = [];
|
|
267
|
+
for (const category of COST_CATEGORIES) {
|
|
268
|
+
const usd = ledger.lines[category].usd;
|
|
269
|
+
if (usd === null)
|
|
270
|
+
unmeasuredLines.push(category);
|
|
271
|
+
else if (usd === 0)
|
|
272
|
+
knownZeroLines.push(category);
|
|
273
|
+
else
|
|
274
|
+
knownNonZeroLines.push(category);
|
|
275
|
+
}
|
|
276
|
+
// satisfied only when every KNOWN line is within the cap (for a no-spend run, maxUsd 0 => every
|
|
277
|
+
// known line must be exactly 0). Unmeasured lines do NOT make it satisfied — they are reported
|
|
278
|
+
// separately as the proof's honest blind spot.
|
|
279
|
+
const cap = maxUsd ?? 0;
|
|
280
|
+
const satisfied = knownNonZeroLines.length === 0 && ledger.knownTotalUsd <= cap;
|
|
281
|
+
const statement = [
|
|
282
|
+
satisfied
|
|
283
|
+
? `No-spend proof SATISFIED for maxUsd=${cap}: every MEASURED spend line is zero (known total ${ledger.knownTotalUsd} USD).`
|
|
284
|
+
: `No-spend proof NOT satisfied for maxUsd=${cap}: known spend total ${ledger.knownTotalUsd} USD${knownNonZeroLines.length > 0 ? ` (non-zero: ${knownNonZeroLines.join(", ")})` : ""}.`,
|
|
285
|
+
unmeasuredLines.length > 0
|
|
286
|
+
? `UNMEASURED (null, NOT claimed zero): ${unmeasuredLines.join(", ")}. The proof does not vouch for these — they carry no spend signal this slice.`
|
|
287
|
+
: "All applicable spend lines were measured."
|
|
288
|
+
].join(" ");
|
|
289
|
+
return {
|
|
290
|
+
schema: "humanish.terminal-no-spend-proof.v1",
|
|
291
|
+
maxUsd,
|
|
292
|
+
satisfied,
|
|
293
|
+
knownZeroLines,
|
|
294
|
+
knownNonZeroLines,
|
|
295
|
+
unmeasuredLines,
|
|
296
|
+
knownTotalUsd: ledger.knownTotalUsd,
|
|
297
|
+
statement
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Full caps enforcement (fail-closed, not advisory). Returns a structured violation when a KNOWN
|
|
302
|
+
* (measured) spend line exceeds maxUsd, or a known billable-job count exceeds maxJobs. Unknowns
|
|
303
|
+
* (`null`) NEVER trip the cap (we cannot claim a violation we did not measure) — but they also never
|
|
304
|
+
* grant a green pass: the no-spend proof reports them as unmeasured. maxMinutes is wall-clock and is
|
|
305
|
+
* enforced separately (runWithWallClock); it is not a ledger-derived cap.
|
|
306
|
+
*/
|
|
307
|
+
function evaluateCapsAgainstLedger(ledger, caps) {
|
|
308
|
+
if (caps.maxUsd !== undefined && ledger.knownTotalUsd > caps.maxUsd) {
|
|
309
|
+
const overLines = COST_CATEGORIES.filter((c) => ledger.lines[c].usd !== null && ledger.lines[c].usd > 0);
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
message: `Observed KNOWN spend ${ledger.knownTotalUsd} USD exceeds scenario.caps.maxUsd=${caps.maxUsd}${overLines.length > 0 ? ` (non-zero lines: ${overLines.join(", ")})` : ""}. The run fails closed: the cap is a fail-closed mechanism, not an advisory.`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (caps.maxJobs !== undefined) {
|
|
316
|
+
let knownJobs = 0;
|
|
317
|
+
for (const category of COST_CATEGORIES) {
|
|
318
|
+
const count = ledger.lines[category].count;
|
|
319
|
+
if (typeof count === "number")
|
|
320
|
+
knownJobs += count;
|
|
321
|
+
}
|
|
322
|
+
if (knownJobs > caps.maxJobs) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
message: `Observed KNOWN billable-job count ${knownJobs} exceeds scenario.caps.maxJobs=${caps.maxJobs}. The run fails closed.`
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { ok: true };
|
|
330
|
+
}
|
|
331
|
+
/** Round a USD sum to 6 decimals so a float-accumulated total never carries spurious precision. */
|
|
332
|
+
function roundUsd(value) {
|
|
333
|
+
return Math.round(value * 1_000_000) / 1_000_000;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Build the per-command runtime-auth env from a DENY-BY-DEFAULT ALLOWLIST containing ONLY the
|
|
337
|
+
* declared runtime key (safety contract item 4). The key NAME is derived from the actor's
|
|
338
|
+
* keyPlacement capability plus the declared runtimeAuth channel, not a hardcoded string the caller
|
|
339
|
+
* can widen. Banned credential names (GitHub/payment/deploy/db/media) are excluded by construction
|
|
340
|
+
* AND guarded: if a banned name is ever requested the lane fails closed. Returns the allowlisted
|
|
341
|
+
* env (values from `env`) and the resolved key name, or a structured failure.
|
|
342
|
+
*
|
|
343
|
+
* Engine-enforced placement (safety contract item 1): the key is only ever returned as a
|
|
344
|
+
* COMMAND-scoped env here; the caller passes it to commands.run({envs}), never Sandbox.create.
|
|
345
|
+
*/
|
|
346
|
+
function buildCommandScopedRuntimeEnv(args) {
|
|
347
|
+
// The "openai-env" channel accepts CODEX_API_KEY or OPENAI_API_KEY as the runtime key SOURCE
|
|
348
|
+
// name, read in this preference order. CODEX_API_KEY is preferred: the official Codex docs
|
|
349
|
+
// (developers.openai.com/codex/noninteractive) document it as the channel for a SINGLE codex exec
|
|
350
|
+
// invocation, which is exactly this lane's shape (no persisted auth.json/CODEX_HOME, per-command
|
|
351
|
+
// envs only). A dated in-repo receipt
|
|
352
|
+
// (docs/goals/humanish-recursive-proof-critical-point/receipts/actor-required-attempt.md) shows a
|
|
353
|
+
// job-wide OPENAI_API_KEY alone failing bearer auth for this same pinned-exec pattern. When the
|
|
354
|
+
// operator only exported OPENAI_API_KEY, its value is ALSO injected under CODEX_API_KEY below, so
|
|
355
|
+
// the documented exec auth channel is always populated regardless of which name the operator
|
|
356
|
+
// used. The ALLOWLIST is exactly these two names; everything else is denied by construction.
|
|
357
|
+
const ALLOWED_RUNTIME_KEY_NAMES = ["CODEX_API_KEY", "OPENAI_API_KEY"];
|
|
358
|
+
// Tripwire (safety contract item 4): if a FUTURE widening of ALLOWED_RUNTIME_KEY_NAMES ever
|
|
359
|
+
// added a clearly-non-runtime credential (a GitHub/payment/deploy/db secret), fail closed. The
|
|
360
|
+
// generic `*_KEY` shape is deliberately NOT a tripwire here, since a runtime key legitimately
|
|
361
|
+
// ends in _KEY (CODEX_API_KEY/OPENAI_API_KEY), so testing it against the generic shape would
|
|
362
|
+
// false-positive on the very key this lane exists to inject. The positive allowlist itself is
|
|
363
|
+
// the real boundary: the command env is built from exactly these names and nothing else (so
|
|
364
|
+
// GITHUB_TOKEN/payment/db keys present in the operator env are never forwarded, proven by the
|
|
365
|
+
// deterministic test).
|
|
366
|
+
if (ALLOWED_RUNTIME_KEY_NAMES.some((name) => isNonRuntimeCredentialName(name))) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
code: "HUMANISH_TERMINAL_LAB_CREDENTIAL_DENIED",
|
|
370
|
+
message: "Internal invariant violated: a runtime-key allowlist entry is a non-runtime credential (GitHub/payment/deploy/db)."
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const keyName = ALLOWED_RUNTIME_KEY_NAMES.find((name) => (args.env[name]?.trim() ?? "").length > 0);
|
|
374
|
+
if (!keyName) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
code: "HUMANISH_TERMINAL_LAB_RUNTIME_AUTH_MISSING",
|
|
378
|
+
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).`
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const keyValue = args.env[keyName];
|
|
382
|
+
// The command-scoped env is the ALLOWLIST: exactly the runtime key name(s), nothing else. No
|
|
383
|
+
// GITHUB_TOKEN/GH_TOKEN, no payment/deploy/db/media key, excluded by construction. When the
|
|
384
|
+
// SOURCE was OPENAI_API_KEY, the SAME value is also injected as CODEX_API_KEY so codex exec's
|
|
385
|
+
// documented single-invocation auth channel is populated either way (see the comment above).
|
|
386
|
+
const envs = keyName === "OPENAI_API_KEY" ? { CODEX_API_KEY: keyValue, OPENAI_API_KEY: keyValue } : { [keyName]: keyValue };
|
|
387
|
+
return {
|
|
388
|
+
ok: true,
|
|
389
|
+
envs,
|
|
390
|
+
keyName,
|
|
391
|
+
keyValue
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Clearly-non-runtime credential NAME shapes. Used as the runtime-key allowlist tripwire (a
|
|
395
|
+
// runtime key must never be one of these). Deliberately EXCLUDES the generic `*_KEY` shape: the
|
|
396
|
+
// runtime key this lane injects (CODEX_API_KEY/OPENAI_API_KEY) legitimately ends in _KEY, so the
|
|
397
|
+
// generic shape would false-positive on it. The positive allowlist, not a denylist, is what
|
|
398
|
+
// keeps every OTHER operator-env credential (GitHub/payment/deploy/db/media keys) out of the
|
|
399
|
+
// sandbox: the command env is built from exactly the allowlisted runtime key and nothing else.
|
|
400
|
+
const NON_RUNTIME_CREDENTIAL_NAME_PATTERNS = [
|
|
401
|
+
/^GITHUB_TOKEN$/i,
|
|
402
|
+
/^GH_TOKEN$/i,
|
|
403
|
+
/TOKEN$/i, // deploy tokens, write tokens
|
|
404
|
+
/SECRET/i, // *_SECRET, payment secrets
|
|
405
|
+
/PASSWORD/i,
|
|
406
|
+
/DATABASE_URL/i,
|
|
407
|
+
/(^|_)DSN$/i,
|
|
408
|
+
/STRIPE/i,
|
|
409
|
+
/AWS_/i
|
|
410
|
+
];
|
|
411
|
+
/** True when `name` is a clearly-non-runtime credential (cannot be a runtime-key allowlist entry). */
|
|
412
|
+
function isNonRuntimeCredentialName(name) {
|
|
413
|
+
return NON_RUNTIME_CREDENTIAL_NAME_PATTERNS.some((pattern) => pattern.test(name));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Build the sandbox metadata from a POSITIVE ALLOWLIST (safety contract item 6). This is the ONLY
|
|
417
|
+
* way metadata is set on the terminal lane — it carries solely non-secret labels and rejects any
|
|
418
|
+
* value that is not a plain short label. A verifier check asserts the persisted metadata has no
|
|
419
|
+
* prompt/token/secret shapes; this builder makes that true by construction.
|
|
420
|
+
*/
|
|
421
|
+
export function buildSandboxMetadata(allowlist) {
|
|
422
|
+
return {
|
|
423
|
+
mode: TERMINAL_PRODUCT_LAB_PROVIDER_METADATA.mode,
|
|
424
|
+
tool: TERMINAL_PRODUCT_LAB_PROVIDER_METADATA.tool,
|
|
425
|
+
provider: "codex",
|
|
426
|
+
labId: allowlist.labId,
|
|
427
|
+
simId: allowlist.simId,
|
|
428
|
+
// The run id is a harness-minted token (terminal-<ts>-<hex>), not user data.
|
|
429
|
+
runId: allowlist.runId
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The live in-sandbox agent session orchestrator (mirror of runCuaActorLab's E2B branch). Enforces
|
|
434
|
+
* the 8-point safety contract by construction; fails closed before any sandbox/key/spend on any
|
|
435
|
+
* precondition miss. Persists the substrate-lifecycle/command-log/interventions/cleanup ledgers,
|
|
436
|
+
* the redacted terminal event stream + normalized transcript, the agent report, and the
|
|
437
|
+
* provider-neutral actor trace; tears the sandbox down in a finally and proves the teardown.
|
|
438
|
+
*/
|
|
439
|
+
async function runLiveTerminalSession(args) {
|
|
440
|
+
const { options, cwd, config, descriptorId, product, warnings, render, failed } = args;
|
|
441
|
+
const hooks = options.hooks ?? {};
|
|
442
|
+
const env = hooks.env ?? process.env;
|
|
443
|
+
const now = hooks.now ?? (() => Date.now());
|
|
444
|
+
const nowIso = () => new Date(now()).toISOString();
|
|
445
|
+
// --- Safety contract item 1: ENGINE-ENFORCED command-scoped key placement. ---
|
|
446
|
+
// Drive the placement off the registered actor's keyPlacement CAPABILITY, not a code convention.
|
|
447
|
+
// A terminal actor that does not declare in-sandbox-command-scoped placement FAILS CLOSED here,
|
|
448
|
+
// before any sandbox exists — the engine refuses to guess where the key goes.
|
|
449
|
+
const descriptor = actorRegistry[descriptorId];
|
|
450
|
+
const keyPlacement = descriptor?.capabilities.keyPlacement;
|
|
451
|
+
if (keyPlacement !== "in-sandbox-command-scoped") {
|
|
452
|
+
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 });
|
|
453
|
+
}
|
|
454
|
+
// --- Safety contract item 2: a fail-closed cap MUST be in force before the live key runs. ---
|
|
455
|
+
const caps = config.scenario?.caps;
|
|
456
|
+
const maxUsd = caps?.maxUsd;
|
|
457
|
+
const maxMinutes = caps?.maxMinutes;
|
|
458
|
+
if (caps === undefined || maxUsd === undefined || maxMinutes === undefined || maxMinutes <= 0) {
|
|
459
|
+
return failed("HUMANISH_TERMINAL_LAB_CAPS_MISSING", "A live terminal-product run places a real key inside the sandbox 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 });
|
|
460
|
+
}
|
|
461
|
+
// SLICE 3: maxUsd is now ENFORCED fail-closed against the cost ledger (evaluateCapsAgainstLedger,
|
|
462
|
+
// after the session) — not advisory. A positive maxUsd is permitted, but the lane still has no
|
|
463
|
+
// PRODUCT-spend signal (product/media/payment lines are null = unmeasured; only the provider line
|
|
464
|
+
// is measurable, from tokenUsage). So a positive budget is honestly bounded by what is MEASURED:
|
|
465
|
+
// the known total (provider, when present) must stay <= maxUsd, and the no-spend proof reports the
|
|
466
|
+
// unmeasured lines rather than guessing them zero. Warn so the operator knows a positive budget is
|
|
467
|
+
// only as strong as the (currently provider-only) spend signal.
|
|
468
|
+
if (maxUsd > 0) {
|
|
469
|
+
warnings.push(`scenario.caps.maxUsd=${maxUsd} declares a non-zero spend budget. SLICE 3 enforces maxUsd fail-closed against the cost ledger, but the only spend signal this slice meters is the provider line (from tokenUsage); product/media/payment are recorded null (UNMEASURED, never guessed zero) until the SLICE-4 adapter supplies them. The no-spend proof reports the unmeasured lines honestly.`);
|
|
470
|
+
}
|
|
471
|
+
// --- Safety contract item 4: deny-by-default credentials; build the command-scoped allowlist. ---
|
|
472
|
+
const runtimeEnv = buildCommandScopedRuntimeEnv({ runtimeAuth: config.execution?.runtimeAuth, env });
|
|
473
|
+
if (!runtimeEnv.ok) {
|
|
474
|
+
return failed(runtimeEnv.code, runtimeEnv.message, { actor: descriptorId });
|
|
475
|
+
}
|
|
476
|
+
// Compose the prompt from PUBLIC surfaces + the author mission ONLY (safety contract item 3).
|
|
477
|
+
// Inject a per-run verdict nonce: the agent echoes HUMANISH_ACTOR_VERDICT=<status>
|
|
478
|
+
// HUMANISH_ACTOR_NONCE=<nonce>; the scorer verifies the nonce so replayed text cannot forge it.
|
|
479
|
+
const mission = config.actors[0]?.mission ?? defaultMission(product.name);
|
|
480
|
+
const personaId = config.actors[0]?.persona ?? "autonomous-terminal-agent";
|
|
481
|
+
const verdictNonce = randomUUID().slice(0, 12);
|
|
482
|
+
const composedPrompt = composeLivePrompt({
|
|
483
|
+
mission,
|
|
484
|
+
personaId,
|
|
485
|
+
productName: product.name,
|
|
486
|
+
publicSurfaces: product.publicSurfaces,
|
|
487
|
+
verdictNonce
|
|
488
|
+
});
|
|
489
|
+
const promptDigest = digestText(composedPrompt);
|
|
490
|
+
const persona = { id: personaId, traitsApplied: [], promptDigest };
|
|
491
|
+
// --- Safety contract item 5: literal-scrub EVERY known value, then pattern-redact, at the source. ---
|
|
492
|
+
// The runtime key value (+ any other provisioned value) is scrubbed by LITERAL match before
|
|
493
|
+
// anything persists (a key has no detectable "shape" if it is an arbitrary token); redactText is
|
|
494
|
+
// the second pass for secret-SHAPED content. Applied PRE-truncation so a cut can never split a
|
|
495
|
+
// value past the scrubber.
|
|
496
|
+
const knownSecretValues = [runtimeEnv.keyValue, env.E2B_API_KEY?.trim() ?? ""].filter((v) => v.length >= 4);
|
|
497
|
+
const scrubKnownValues = (text) => knownSecretValues.reduce((current, value) => current.split(value).join("[REDACTED_SECRET]"), text);
|
|
498
|
+
const sanitize = (text) => redactText(scrubKnownValues(text));
|
|
499
|
+
const runId = options.runId ?? makeTerminalRunId();
|
|
500
|
+
const artifactRoot = path.join(cwd, ".humanish", "runs", runId);
|
|
501
|
+
const createdAt = nowIso();
|
|
502
|
+
await mkdir(artifactRoot, { recursive: true });
|
|
503
|
+
const source = await buildRunSource({ capturedAt: createdAt, cwd, humanishSource: "present", packageName: "humanish" });
|
|
504
|
+
const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
|
|
505
|
+
// The ledgers + capture buffers, mutated through the live lifecycle.
|
|
506
|
+
const lifecycle = [];
|
|
507
|
+
const commandLog = [];
|
|
508
|
+
const terminalEvents = [];
|
|
509
|
+
const interventions = []; // ALWAYS empty this slice (no assisted-input path).
|
|
510
|
+
let transcriptBytes = 0;
|
|
511
|
+
let cleanup = { killed: false, remaining: -1, reason: "teardown not reached" };
|
|
512
|
+
const recordLifecycle = (event, message) => {
|
|
513
|
+
lifecycle.push({ at: nowIso(), event, message: sanitize(message) });
|
|
514
|
+
};
|
|
515
|
+
const appendTerminalChunk = (stream, raw) => {
|
|
516
|
+
if (transcriptBytes >= MAX_TRANSCRIPT_BYTES)
|
|
517
|
+
return;
|
|
518
|
+
transcriptBytes += Buffer.byteLength(raw, "utf8");
|
|
519
|
+
// Scrub THEN redact at the SOURCE — raw bytes never leave this function (safety contract item 5).
|
|
520
|
+
terminalEvents.push({ at: nowIso(), stream, chunk: sanitize(raw) });
|
|
521
|
+
};
|
|
522
|
+
let sandbox;
|
|
523
|
+
let sandboxModule;
|
|
524
|
+
let sandboxId;
|
|
525
|
+
let sessionStatus = "failed";
|
|
526
|
+
let completionReason = "harness_error";
|
|
527
|
+
let sessionReason = "live terminal-product session did not start";
|
|
528
|
+
let sessionError;
|
|
529
|
+
let timedOut = false;
|
|
530
|
+
recordLifecycle("terminal-lab.run.created", `Created live terminal-product run ${runId} (actor ${descriptorId}, product ${product.name}). Caps: maxUsd=${maxUsd}, maxMinutes=${maxMinutes}. Subject provenance UNPINNED (public surfaces only).`);
|
|
531
|
+
const requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
532
|
+
const wallClockMs = maxMinutes * 60_000;
|
|
533
|
+
const sandboxTimeoutMs = wallClockMs + SANDBOX_TIMEOUT_BUFFER_MS;
|
|
534
|
+
const metadata = buildSandboxMetadata({ labId: config.id, simId: "sim-001", runId });
|
|
535
|
+
try {
|
|
536
|
+
sandboxModule = await (hooks.loadModule ?? loadE2BDesktopModule)();
|
|
537
|
+
// SAFETY CONTRACT ITEM 1 (enforced HERE): Sandbox.create carries metadata (positive allowlist)
|
|
538
|
+
// + lifecycle kill-on-timeout, and DELIBERATELY NO `envs` — the runtime key is NEVER passed
|
|
539
|
+
// sandbox-global. It is injected ONLY into the per-command codex `envs` below.
|
|
540
|
+
sandbox = await sandboxModule.Sandbox.create({
|
|
541
|
+
apiKey: e2bApiKey,
|
|
542
|
+
requestTimeoutMs,
|
|
543
|
+
timeoutMs: sandboxTimeoutMs,
|
|
544
|
+
metadata,
|
|
545
|
+
lifecycle: { onTimeout: "kill" }
|
|
546
|
+
// NOTE: no `envs` key — see the credential boundary above. (A sandbox-global key would leak
|
|
547
|
+
// into every process in the sandbox; command-scoped bounds it to the codex invocation.)
|
|
548
|
+
});
|
|
549
|
+
sandboxId = sandbox.sandboxId;
|
|
550
|
+
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).`);
|
|
551
|
+
// Readiness: a tiny in-sandbox probe (no key) confirms the shell answers before the keyed run.
|
|
552
|
+
const ready = await sandbox.commands.run(`mkdir -p ${SANDBOX_WORKDIR} && echo HUMANISH_SHELL_READY`, { requestTimeoutMs });
|
|
553
|
+
recordLifecycle("terminal-lab.sandbox.ready", `Shell readiness probe exit=${ready.exitCode ?? "null"}; workdir ${SANDBOX_WORKDIR} prepared.`);
|
|
554
|
+
// --- Runtime bootstrap: ensure Node/npm are present (UNKEYED, no envs) before the keyed exec. ---
|
|
555
|
+
// The stock @e2b/desktop image does not ship a recent Node (issue #159); codex is now invoked
|
|
556
|
+
// via `npx` (buildCodexExecCommand), which needs Node/npm on PATH. Reuses the proven
|
|
557
|
+
// oss-meta-lab.ts ensure_node() shape: a node major-version check, else install Node 22 via
|
|
558
|
+
// NodeSource plus passwordless sudo. UNKEYED: no runtime key touches this step. An apt-get
|
|
559
|
+
// install can exceed the SDK's default 60s commands.run timeout, so this step gets an
|
|
560
|
+
// explicit, generous timeoutMs (requestTimeoutMs is passed through unchanged, as everywhere else).
|
|
561
|
+
const bootstrapStartedAt = now();
|
|
562
|
+
let bootstrapError;
|
|
563
|
+
try {
|
|
564
|
+
const bootstrap = await sandbox.commands.run(RUNTIME_BOOTSTRAP_COMMAND, {
|
|
565
|
+
requestTimeoutMs,
|
|
566
|
+
timeoutMs: RUNTIME_BOOTSTRAP_TIMEOUT_MS
|
|
567
|
+
});
|
|
568
|
+
if ((bootstrap.exitCode ?? 1) !== 0) {
|
|
569
|
+
bootstrapError = `runtime bootstrap exited ${bootstrap.exitCode ?? "null"}`;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
bootstrapError = toErrorMessage(error);
|
|
574
|
+
}
|
|
575
|
+
const bootstrapDurationMs = Math.max(0, now() - bootstrapStartedAt);
|
|
576
|
+
recordLifecycle("terminal-lab.runtime.bootstrapped", bootstrapError
|
|
577
|
+
? `Runtime bootstrap FAILED after ${bootstrapDurationMs}ms: ${bootstrapError}. codex exec runs via npx and needs Node/npm present; the lane fails closed rather than attempting an exec with no runtime.`
|
|
578
|
+
: `Runtime bootstrap ensured Node/npm present in ${bootstrapDurationMs}ms (codex exec runs via npx).`);
|
|
579
|
+
if (bootstrapError) {
|
|
580
|
+
// Fail closed as a structured lane status (never a raw throw): no codex exec is attempted
|
|
581
|
+
// without a proven runtime; this mirrors the exec-error status assignment below so the
|
|
582
|
+
// bundle and verify surface the failure the same way.
|
|
583
|
+
sessionStatus = "failed";
|
|
584
|
+
completionReason = "harness_error";
|
|
585
|
+
sessionError = sanitize(bootstrapError);
|
|
586
|
+
sessionReason = `runtime bootstrap could not ensure Node/npm before codex exec: ${sessionError}`;
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
// --- The keyed run: `codex exec --json` non-interactively (stdin disabled). ---
|
|
590
|
+
// The runtime key is injected ONLY here, command-scoped (safety contract item 1). stdin is
|
|
591
|
+
// never wired (safety contract item 7) — commands.run takes no stdin channel. The command's
|
|
592
|
+
// wall-clock is bounded by maxMinutes (safety contract item 2): commands.run timeoutMs +
|
|
593
|
+
// an injected-clock guard so a mock/real run that exceeds it is killed and fails closed.
|
|
594
|
+
const codexCommand = buildCodexExecCommand({ workdir: SANDBOX_WORKDIR, prompt: composedPrompt });
|
|
595
|
+
const commandDigest = digestText(codexCommand);
|
|
596
|
+
const startedAt = now();
|
|
597
|
+
recordLifecycle("terminal-lab.exec.started", `Launching codex exec (command-scoped runtime key ${runtimeEnv.keyName}); wall-clock bound ${wallClockMs}ms.`);
|
|
598
|
+
let exitCode;
|
|
599
|
+
let runError;
|
|
600
|
+
try {
|
|
601
|
+
const result = await runWithWallClock(sandbox.commands.run(codexCommand, {
|
|
602
|
+
envs: runtimeEnv.envs, // <-- THE command-scoped key channel. The ONLY place the key goes.
|
|
603
|
+
requestTimeoutMs,
|
|
604
|
+
timeoutMs: wallClockMs,
|
|
605
|
+
onStdout: (data) => appendTerminalChunk("stdout", data),
|
|
606
|
+
onStderr: (data) => appendTerminalChunk("stderr", data)
|
|
607
|
+
}), wallClockMs, now);
|
|
608
|
+
if (result.timedOut) {
|
|
609
|
+
timedOut = true;
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
exitCode = result.value.exitCode;
|
|
613
|
+
// Some SDK shapes return final stdout/stderr in the result too (not only via callbacks).
|
|
614
|
+
if (result.value.stdout)
|
|
615
|
+
appendTerminalChunk("stdout", result.value.stdout);
|
|
616
|
+
if (result.value.stderr)
|
|
617
|
+
appendTerminalChunk("stderr", result.value.stderr);
|
|
618
|
+
if (result.value.error)
|
|
619
|
+
runError = result.value.error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
runError = toErrorMessage(error);
|
|
624
|
+
}
|
|
625
|
+
const durationMs = Math.max(0, now() - startedAt);
|
|
626
|
+
commandLog.push({
|
|
627
|
+
at: nowIso(),
|
|
628
|
+
label: "codex-exec",
|
|
629
|
+
commandDigest,
|
|
630
|
+
envNames: Object.keys(runtimeEnv.envs), // NAMES only — the credential evidence (item 4).
|
|
631
|
+
...(exitCode === undefined ? {} : { exitCode }),
|
|
632
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
633
|
+
durationMs
|
|
634
|
+
});
|
|
635
|
+
// Score by the verdict-nonce marker over the SCRUBBED+REDACTED, NORMALIZED transcript — the
|
|
636
|
+
// exact same logic the local-actor lanes use (extractLocalActorVerdict/normalizeLocalActorTranscript).
|
|
637
|
+
const rawTranscript = terminalEvents.map((e) => e.chunk).join("");
|
|
638
|
+
const normalizedTranscript = normalizeLocalActorTranscript(rawTranscript);
|
|
639
|
+
const markerStatus = extractLocalActorVerdict(normalizedTranscript, verdictNonce);
|
|
640
|
+
if (timedOut) {
|
|
641
|
+
sessionStatus = "timed_out";
|
|
642
|
+
completionReason = "timed_out";
|
|
643
|
+
sessionReason = `codex exec exceeded the maxMinutes wall-clock (${maxMinutes}m); killed and failed closed.`;
|
|
644
|
+
recordLifecycle("terminal-lab.exec.timed_out", sessionReason);
|
|
645
|
+
}
|
|
646
|
+
else if (runError) {
|
|
647
|
+
sessionStatus = "failed";
|
|
648
|
+
completionReason = "harness_error";
|
|
649
|
+
sessionError = sanitize(runError);
|
|
650
|
+
sessionReason = `codex exec could not run: ${sessionError}`;
|
|
651
|
+
recordLifecycle("terminal-lab.exec.error", sessionReason);
|
|
652
|
+
}
|
|
653
|
+
else if (markerStatus) {
|
|
654
|
+
sessionStatus = markerStatus;
|
|
655
|
+
completionReason = markerStatus === "passed" ? "goal_satisfied" : markerStatus === "blocked" ? "blocked_approval" : "gave_up";
|
|
656
|
+
sessionReason = `agent reported ${markerStatus} verdict marker (nonce-verified)`;
|
|
657
|
+
recordLifecycle("terminal-lab.exec.completed", `codex exec exit=${exitCode ?? "null"}; ${sessionReason}.`);
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
// No nonce-verified verdict: the agent did not (credibly) report a terminal status. A run
|
|
661
|
+
// that exited 0 but printed no verified marker is BLOCKED evidence (the failure IS the
|
|
662
|
+
// evidence — still structurally verifiable), not a silent pass.
|
|
663
|
+
sessionStatus = "blocked";
|
|
664
|
+
completionReason = "gave_up";
|
|
665
|
+
sessionReason = `codex exec exit=${exitCode ?? "null"} but no nonce-verified HUMANISH_ACTOR_VERDICT marker was emitted; recorded as blocked (the missing verdict is the evidence).`;
|
|
666
|
+
recordLifecycle("terminal-lab.exec.blocked", sessionReason);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
sessionError = sanitize(toErrorMessage(error));
|
|
672
|
+
sessionStatus = "failed";
|
|
673
|
+
completionReason = "harness_error";
|
|
674
|
+
sessionReason = `live terminal-product session failed: ${sessionError}`;
|
|
675
|
+
recordLifecycle("terminal-lab.session.error", sessionReason);
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
// --- Safety contract item 8: PROVEN cleanup, BY EXACT ID, never Sandbox.list. ---
|
|
679
|
+
cleanup = await teardownSandbox({
|
|
680
|
+
sandboxModule,
|
|
681
|
+
sandbox,
|
|
682
|
+
requestTimeoutMs,
|
|
683
|
+
sanitize,
|
|
684
|
+
recordLifecycle,
|
|
685
|
+
warnings
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
// Build the actor trace FIRST (the cost ledger reads its tokenUsage).
|
|
689
|
+
const normalizedTranscript = normalizeLocalActorTranscript(terminalEvents.map((e) => e.chunk).join(""));
|
|
690
|
+
const trace = buildTerminalActorTrace({
|
|
691
|
+
persona,
|
|
692
|
+
productName: product.name,
|
|
693
|
+
status: sessionStatus,
|
|
694
|
+
completionReason,
|
|
695
|
+
reason: sanitize(sessionReason),
|
|
696
|
+
createdAt,
|
|
697
|
+
completedAt: nowIso(),
|
|
698
|
+
durationMs: commandLog[0]?.durationMs ?? 0,
|
|
699
|
+
terminalEvents,
|
|
700
|
+
commandLog,
|
|
701
|
+
transcriptTail: tailOf(normalizedTranscript)
|
|
702
|
+
});
|
|
703
|
+
// --- SLICE 3: the spend ledger + no-spend proof + FULL caps enforcement (fail-closed). ---
|
|
704
|
+
// The cost ledger is DERIVED, with the null discipline: provider spend from the trace's
|
|
705
|
+
// tokenUsage.costUsd when present (else null = NOT MEASURED), product/media/payment null this
|
|
706
|
+
// slice (no signal yet — SLICE 4). The costProbe hook lets the deterministic test inject KNOWN
|
|
707
|
+
// spend to exercise the fail-closed cap without a real billable run.
|
|
708
|
+
const injectedLines = hooks.costProbe?.({ ...(trace.tokenUsage?.costUsd === undefined ? {} : { tokenCostUsd: trace.tokenUsage.costUsd }) });
|
|
709
|
+
const cost = buildCostLedger({
|
|
710
|
+
...(trace.tokenUsage?.costUsd === undefined ? {} : { tokenCostUsd: trace.tokenUsage.costUsd }),
|
|
711
|
+
...(injectedLines ? { injectedLines } : {})
|
|
712
|
+
});
|
|
713
|
+
const noSpendProof = buildNoSpendProof(cost, maxUsd ?? null);
|
|
714
|
+
recordLifecycle("terminal-lab.cost.measured", `Cost ledger: known total ${cost.knownTotalUsd} USD${cost.fullyMeasured ? " (fully measured)" : ` (lower bound; unmeasured: ${noSpendProof.unmeasuredLines.join(", ") || "none"})`}. No-spend proof ${noSpendProof.satisfied ? "satisfied" : "NOT satisfied"} for maxUsd=${maxUsd ?? "null"}.`);
|
|
715
|
+
// FULL caps enforcement (fail-closed, NOT advisory): if a KNOWN spend line exceeds maxUsd (or a
|
|
716
|
+
// known job count exceeds maxJobs), the run fails closed — never a green pass. Unknowns (null) do
|
|
717
|
+
// NOT trip the cap (we cannot claim a violation we did not measure) but never grant a pass either
|
|
718
|
+
// (the no-spend proof reports them as unmeasured). maxMinutes is already wall-clock-enforced above.
|
|
719
|
+
const capCheck = evaluateCapsAgainstLedger(cost, caps);
|
|
720
|
+
let capsExceeded = false;
|
|
721
|
+
if (!capCheck.ok) {
|
|
722
|
+
capsExceeded = true;
|
|
723
|
+
sessionStatus = "failed";
|
|
724
|
+
completionReason = "harness_error";
|
|
725
|
+
sessionError = capCheck.message;
|
|
726
|
+
sessionReason = capCheck.message;
|
|
727
|
+
recordLifecycle("terminal-lab.caps.exceeded", capCheck.message);
|
|
728
|
+
// Reflect the fail-closed verdict in the trace the bundle/observer reads (so the run cannot show
|
|
729
|
+
// a passing agent verdict while the cap was blown).
|
|
730
|
+
trace.status = "failed";
|
|
731
|
+
trace.completionReason = "harness_error";
|
|
732
|
+
trace.reason = capCheck.message;
|
|
733
|
+
}
|
|
734
|
+
// Assemble + persist the ledgers (now carrying the cost block + no-spend proof), the redacted
|
|
735
|
+
// event stream, the normalized transcript, the actor trace, and the run bundle.
|
|
736
|
+
const ledgers = {
|
|
737
|
+
schema: "humanish.terminal-ledgers.v1",
|
|
738
|
+
lifecycle,
|
|
739
|
+
commandLog,
|
|
740
|
+
interventions, // ALWAYS present, ALWAYS empty (no assisted-input path this slice).
|
|
741
|
+
cleanup,
|
|
742
|
+
cost,
|
|
743
|
+
noSpendProof
|
|
744
|
+
};
|
|
745
|
+
await writeFile(path.join(artifactRoot, TERMINAL_EVENTS_ARTIFACT), `${terminalEvents.map((e) => JSON.stringify(e)).join("\n")}${terminalEvents.length > 0 ? "\n" : ""}`, "utf8");
|
|
746
|
+
await writeFile(path.join(artifactRoot, TERMINAL_TRANSCRIPT_ARTIFACT), `${normalizedTranscript}\n`, "utf8");
|
|
747
|
+
await writeFile(path.join(artifactRoot, TERMINAL_LEDGERS_ARTIFACT), `${JSON.stringify(ledgers, null, 2)}\n`, "utf8");
|
|
748
|
+
await writeFile(path.join(artifactRoot, "actor.json"), `${JSON.stringify(trace, null, 2)}\n`, "utf8");
|
|
749
|
+
const bundle = buildLiveTerminalProductBundle({
|
|
750
|
+
actorId: descriptorId,
|
|
751
|
+
createdAt,
|
|
752
|
+
labId: config.id,
|
|
753
|
+
...(config.title ? { labTitle: config.title } : {}),
|
|
754
|
+
mission,
|
|
755
|
+
persona,
|
|
756
|
+
productName: product.name,
|
|
757
|
+
publicSurfaces: product.publicSurfaces,
|
|
758
|
+
caps,
|
|
759
|
+
runtimeAuthKeyName: runtimeEnv.keyName,
|
|
760
|
+
policies: {
|
|
761
|
+
allowPrivateRepoAccess: config.policies?.allowPrivateRepoAccess ?? false,
|
|
762
|
+
allowProviderCredentials: config.policies?.allowProviderCredentials ?? false,
|
|
763
|
+
allowPaymentCredentials: config.policies?.allowPaymentCredentials ?? false,
|
|
764
|
+
allowGitHubMutation: config.policies?.allowGitHubMutation ?? false
|
|
765
|
+
},
|
|
766
|
+
runId,
|
|
767
|
+
source,
|
|
768
|
+
trace,
|
|
769
|
+
ledgers,
|
|
770
|
+
...(sandboxId ? { sandboxId } : {}),
|
|
771
|
+
...(sessionError ? { sessionError } : {}),
|
|
772
|
+
sessionReason: sanitize(sessionReason)
|
|
773
|
+
});
|
|
774
|
+
// --- THE LAYER-6 EXTENSION SEAM (issue #154 acceptance #8). ---
|
|
775
|
+
// When a thin adapter registered a scorer / feedback strategy, the lane calls it over the
|
|
776
|
+
// FULLY-ASSEMBLED, redacted evidence and attaches the results to the bundle WITHOUT knowing any
|
|
777
|
+
// product noun: the namespaced RunAdapterScore lands on bundle.adapterScore, and the derived
|
|
778
|
+
// feedback candidates (each carrying its own namespaced product-noun block) are appended to
|
|
779
|
+
// bundle.feedbackCandidates. Core's mission-based verdict (bundle.review) is left UNCHANGED — the
|
|
780
|
+
// adapter score is additive, not a replacement. The adapter payloads pass the same scrub+redact
|
|
781
|
+
// the rest of the bundle does (the adapter is trusted in-repo code, but the harness never relies
|
|
782
|
+
// on that for secret values) and are validated fail-closed by the bundle verifier downstream.
|
|
783
|
+
await applyAdapterExtensionSeam({ hooks, bundle, trace, ledgers, product: product.name, labId: config.id, runId, sanitize, warnings });
|
|
784
|
+
await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
|
785
|
+
await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8");
|
|
786
|
+
await writeFile(path.join(artifactRoot, "review.md"), renderTerminalReviewMarkdown(bundle), "utf8");
|
|
787
|
+
await writeFile(path.join(artifactRoot, "events.ndjson"), `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");
|
|
788
|
+
await writeFile(path.join(cwd, ".humanish", "runs", "latest.json"), `${JSON.stringify({ schema: "humanish.latest-run.v1", runId, path: path.join(".humanish", "runs", runId), updatedAt: createdAt }, null, 2)}\n`, "utf8");
|
|
789
|
+
const observer = await render(cwd, runId, { open: options.open === true });
|
|
790
|
+
// The lab's exit code: verified evidence AND no harness error AND proven cleanup. A blocked/
|
|
791
|
+
// timed-out agent run is STILL ok-as-evidence at the bundle level (the failure is the evidence),
|
|
792
|
+
// but the LAB result surfaces ok:false on a harness error or unproven teardown (fail-closed).
|
|
793
|
+
// remaining===0 is the by-id-confirmed-reclaimed state; remaining===1 (still present) and
|
|
794
|
+
// remaining===-1 (kill(id) itself failed) are both unproven by design.
|
|
795
|
+
const cleanupProven = cleanup.killed && cleanup.remaining === 0;
|
|
796
|
+
const ok = observer.ok && completionReason !== "harness_error" && cleanupProven;
|
|
797
|
+
return {
|
|
798
|
+
schema: TERMINAL_PRODUCT_LAB_SCHEMA,
|
|
799
|
+
ok,
|
|
800
|
+
cwd,
|
|
801
|
+
labId: config.id,
|
|
802
|
+
actor: descriptorId,
|
|
803
|
+
product: product.name,
|
|
804
|
+
dryRun: false,
|
|
805
|
+
runId,
|
|
806
|
+
session: { status: sessionStatus, completionReason, reason: sanitize(sessionReason) },
|
|
807
|
+
...(sandboxId
|
|
808
|
+
? { sandbox: { sandboxId, killed: cleanup.killed, remaining: cleanup.remaining } }
|
|
809
|
+
: {}),
|
|
810
|
+
cost: {
|
|
811
|
+
knownTotalUsd: cost.knownTotalUsd,
|
|
812
|
+
fullyMeasured: cost.fullyMeasured,
|
|
813
|
+
lines: {
|
|
814
|
+
product: cost.lines.product.usd,
|
|
815
|
+
media: cost.lines.media.usd,
|
|
816
|
+
payment: cost.lines.payment.usd,
|
|
817
|
+
provider: cost.lines.provider.usd
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
noSpend: {
|
|
821
|
+
satisfied: noSpendProof.satisfied,
|
|
822
|
+
maxUsd: noSpendProof.maxUsd,
|
|
823
|
+
knownZeroLines: noSpendProof.knownZeroLines,
|
|
824
|
+
unmeasuredLines: noSpendProof.unmeasuredLines
|
|
825
|
+
},
|
|
826
|
+
observer,
|
|
827
|
+
warnings: [...warnings, ...observer.warnings],
|
|
828
|
+
...(ok
|
|
829
|
+
? {}
|
|
830
|
+
: {
|
|
831
|
+
error: {
|
|
832
|
+
code: (!cleanupProven
|
|
833
|
+
? "HUMANISH_TERMINAL_LAB_CLEANUP_UNPROVEN"
|
|
834
|
+
: capsExceeded
|
|
835
|
+
? "HUMANISH_TERMINAL_LAB_CAPS_EXCEEDED"
|
|
836
|
+
: "HUMANISH_TERMINAL_LAB_FAILED"),
|
|
837
|
+
message: !cleanupProven
|
|
838
|
+
? `Live terminal-product run could not prove sandbox teardown (killed=${cleanup.killed}, remaining=${cleanup.remaining}): ${cleanup.reason}. A run that cannot prove teardown fails closed.`
|
|
839
|
+
: sessionError ?? observer.error?.message ?? sessionReason
|
|
840
|
+
}
|
|
841
|
+
})
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Run the layer-6 product-adapter extension seam (issue #154 acceptance #8) over the assembled
|
|
846
|
+
* evidence and attach its results to the bundle IN PLACE — without core knowing any product noun.
|
|
847
|
+
*
|
|
848
|
+
* - `score`: when present, the returned namespaced `RunAdapterScore` lands on `bundle.adapterScore`.
|
|
849
|
+
* Core's mission-based verdict (`bundle.review`) is UNCHANGED — the adapter score is additive.
|
|
850
|
+
* - `deriveFeedback`: when present, the returned candidates are appended to
|
|
851
|
+
* `bundle.feedbackCandidates`; each carries its own namespaced `adapter` product-noun block.
|
|
852
|
+
*
|
|
853
|
+
* Defense in depth: the adapter's namespaced payloads are re-serialized through the run's scrub +
|
|
854
|
+
* redact (the adapter is trusted in-repo code, but the harness never relies on that for secret
|
|
855
|
+
* values), and any candidate / score that does not satisfy core's exported shape is DROPPED with a
|
|
856
|
+
* warning (a malformed adapter output never poisons a verifiable bundle). The bundle verifier
|
|
857
|
+
* re-checks the surviving shapes downstream, so the seam stays fail-closed end to end.
|
|
858
|
+
*/
|
|
859
|
+
async function applyAdapterExtensionSeam(args) {
|
|
860
|
+
const { hooks, bundle, trace, ledgers, product, labId, runId, sanitize, warnings } = args;
|
|
861
|
+
if (!hooks.score && !hooks.deriveFeedback)
|
|
862
|
+
return;
|
|
863
|
+
const ctx = { bundle, trace, ledgers, product, labId, runId };
|
|
864
|
+
// Scrub + redact an arbitrary adapter payload by round-tripping it through the run's sanitizer.
|
|
865
|
+
// Strings are scrubbed individually so a planted secret in any nested string value is caught.
|
|
866
|
+
const scrubValue = (value) => JSON.parse(sanitize(JSON.stringify(value)));
|
|
867
|
+
if (hooks.score) {
|
|
868
|
+
try {
|
|
869
|
+
const score = await hooks.score(ctx);
|
|
870
|
+
const cleaned = scrubValue(score);
|
|
871
|
+
if (isAdapterScoreShape(cleaned)) {
|
|
872
|
+
bundle.adapterScore = cleaned;
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
warnings.push("terminalHooks.score returned a value that is not a well-formed humanish.adapter-score.v1 (non-empty namespace + status + numeric score + summary); dropped so the bundle stays verifiable.");
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
warnings.push(`terminalHooks.score threw (${sanitize(error instanceof Error ? error.message : String(error))}); dropped so the bundle stays verifiable.`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (hooks.deriveFeedback) {
|
|
883
|
+
try {
|
|
884
|
+
const candidates = await hooks.deriveFeedback(ctx);
|
|
885
|
+
const accepted = [];
|
|
886
|
+
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
|
887
|
+
const cleaned = scrubValue(candidate);
|
|
888
|
+
if (isAdapterFeedbackCandidateShape(cleaned))
|
|
889
|
+
accepted.push(cleaned);
|
|
890
|
+
else
|
|
891
|
+
warnings.push("terminalHooks.deriveFeedback returned a candidate that is not a well-formed humanish.feedback-candidate.v1 (or its adapter block lacked a non-empty namespace + data record); dropped so the bundle stays verifiable.");
|
|
892
|
+
}
|
|
893
|
+
if (accepted.length > 0) {
|
|
894
|
+
bundle.feedbackCandidates = [...bundle.feedbackCandidates, ...accepted];
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
warnings.push(`terminalHooks.deriveFeedback threw (${sanitize(error instanceof Error ? error.message : String(error))}); dropped so the bundle stays verifiable.`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/** Structural guard for an adapter-returned RunAdapterScore (mirrors run.ts isRunAdapterScore, kept
|
|
903
|
+
* local so the lane fails closed at the seam BEFORE the bundle verifier re-checks it). */
|
|
904
|
+
function isAdapterScoreShape(value) {
|
|
905
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
906
|
+
&& value.schema === "humanish.adapter-score.v1"
|
|
907
|
+
&& typeof value.namespace === "string"
|
|
908
|
+
&& value.namespace.trim().length > 0
|
|
909
|
+
&& ["pass", "partial", "fail"].includes(value.status)
|
|
910
|
+
&& typeof value.score === "number"
|
|
911
|
+
&& Number.isFinite(value.score)
|
|
912
|
+
&& typeof value.summary === "string";
|
|
913
|
+
}
|
|
914
|
+
/** Structural guard for an adapter-returned feedback candidate. Requires the core shape AND (when an
|
|
915
|
+
* adapter block is present) a non-empty namespace + data record — so a malformed product-noun block
|
|
916
|
+
* fails closed at the seam. */
|
|
917
|
+
function isAdapterFeedbackCandidateShape(value) {
|
|
918
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
919
|
+
return false;
|
|
920
|
+
const candidate = value;
|
|
921
|
+
const baseOk = candidate.schema === "humanish.feedback-candidate.v1"
|
|
922
|
+
&& typeof candidate.id === "string"
|
|
923
|
+
&& typeof candidate.summary === "string" && candidate.summary.trim().length > 0
|
|
924
|
+
&& Array.isArray(candidate.evidence)
|
|
925
|
+
&& typeof candidate.redaction === "object" && candidate.redaction !== null && candidate.redaction.status === "passed";
|
|
926
|
+
if (!baseOk)
|
|
927
|
+
return false;
|
|
928
|
+
if (candidate.adapter !== undefined) {
|
|
929
|
+
const adapter = candidate.adapter;
|
|
930
|
+
if (typeof adapter !== "object" || adapter === null
|
|
931
|
+
|| typeof adapter.namespace !== "string" || adapter.namespace.trim().length === 0
|
|
932
|
+
|| typeof adapter.data !== "object" || adapter.data === null || Array.isArray(adapter.data)) {
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Tear the sandbox down and PROVE it BY EXACT ID -- NEVER Sandbox.list (humanish must never
|
|
940
|
+
* enumerate the operator's E2B account; see docs/principles/invariants-and-defaults.md). After
|
|
941
|
+
* Sandbox.kill(id) resolves, its own boolean return ("found and killed", per the SDK) is the
|
|
942
|
+
* PRIMARY proof. Where the SDK exposes Sandbox.getInfo(id), a thrown SandboxNotFoundError is a
|
|
943
|
+
* second by-id confirmation that the exact sandbox is gone; a returned SandboxInfo with a live
|
|
944
|
+
* state means teardown is NOT confirmed. Never throws -- teardown failure is recorded, the
|
|
945
|
+
* caller fails closed on an unproven teardown.
|
|
946
|
+
*/
|
|
947
|
+
async function teardownSandbox(args) {
|
|
948
|
+
const { sandboxModule, sandbox, requestTimeoutMs, sanitize, recordLifecycle, warnings } = args;
|
|
949
|
+
if (!sandbox || !sandboxModule) {
|
|
950
|
+
recordLifecycle("terminal-lab.cleanup.skipped", "No sandbox was created; nothing to reclaim.");
|
|
951
|
+
return { killed: false, remaining: 0, reason: "no sandbox created" };
|
|
952
|
+
}
|
|
953
|
+
if (typeof sandboxModule.Sandbox.kill !== "function") {
|
|
954
|
+
return { killed: false, remaining: -1, reason: "installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the sandbox" };
|
|
955
|
+
}
|
|
956
|
+
let killResult = false;
|
|
957
|
+
try {
|
|
958
|
+
killResult = (await sandboxModule.Sandbox.kill(sandbox.sandboxId, { requestTimeoutMs })) === true;
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
const sanitizedError = sanitize(toErrorMessage(error));
|
|
962
|
+
warnings.push(`Sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${sanitizedError}`);
|
|
963
|
+
recordLifecycle("terminal-lab.cleanup.kill_error", `Sandbox ${sandbox.sandboxId} kill(id) failed: ${sanitizedError}`);
|
|
964
|
+
return { killed: false, remaining: -1, reason: `kill(id) failed: ${sanitizedError} (server-side kill-on-timeout will reclaim it)` };
|
|
965
|
+
}
|
|
966
|
+
// BY-ID verification only, from here down: NEVER Sandbox.list. A kill(id) call that RESOLVES is
|
|
967
|
+
// itself proof the exact sandbox is gone: kill(id) returns true when it found and killed the
|
|
968
|
+
// sandbox, and false ONLY on a 404 (the exact id was already gone, e.g. the server-side
|
|
969
|
+
// kill-on-timeout raced ahead). Both mean "this id is no longer running." Sandbox.getInfo(id),
|
|
970
|
+
// when the SDK exposes it, adds a second by-id confirmation; the only thing that overturns the
|
|
971
|
+
// kill proof is getInfo returning a LIVE sandbox for this exact id.
|
|
972
|
+
const killNote = killResult
|
|
973
|
+
? "kill(id) returned true (found and killed)"
|
|
974
|
+
: "kill(id) returned false (404: the exact sandbox was already gone)";
|
|
975
|
+
if (typeof sandboxModule.Sandbox.getInfo !== "function") {
|
|
976
|
+
recordLifecycle("terminal-lab.cleanup.killed", `Sandbox ${sandbox.sandboxId} reclaimed: ${killNote}; the installed SDK has no getInfo(id) to re-verify, so kill(id)'s own result is the proof.`);
|
|
977
|
+
return { killed: true, remaining: 0, reason: `reclaimed by id; ${killNote} and the installed SDK does not expose Sandbox.getInfo to re-verify` };
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
const info = await sandboxModule.Sandbox.getInfo(sandbox.sandboxId, { requestTimeoutMs });
|
|
981
|
+
const state = info.state ?? "unknown";
|
|
982
|
+
recordLifecycle("terminal-lab.cleanup.unconfirmed", `Sandbox ${sandbox.sandboxId} ${killNote}, but getInfo(id) still reports state=${state} (not confirmed reclaimed by id).`);
|
|
983
|
+
return { killed: true, remaining: 1, reason: `${killNote} but getInfo(id) still reports state=${state}; this sandbox's teardown is not confirmed by id` };
|
|
984
|
+
}
|
|
985
|
+
catch (error) {
|
|
986
|
+
if (isSandboxNotFoundError(error)) {
|
|
987
|
+
recordLifecycle("terminal-lab.cleanup.verified", `Sandbox ${sandbox.sandboxId} reclaimed; getInfo(id) confirms it no longer exists (SandboxNotFoundError) -- by exact id, never re-listed.`);
|
|
988
|
+
return { killed: true, remaining: 0, reason: `reclaimed by id; getInfo(id) confirms the exact sandbox no longer exists (SandboxNotFoundError)` };
|
|
989
|
+
}
|
|
990
|
+
// getInfo(id) failed for a reason OTHER than "not found" (e.g. a transient network error):
|
|
991
|
+
// no second by-id confirmation is available, so the RESOLVED kill(id) call stands as the proof
|
|
992
|
+
// of absence. Never fall back to Sandbox.list.
|
|
993
|
+
const sanitizedError = sanitize(toErrorMessage(error));
|
|
994
|
+
recordLifecycle("terminal-lab.cleanup.killed", `Sandbox ${sandbox.sandboxId} reclaimed: ${killNote}; getInfo(id) re-verification errored (${sanitizedError}), so kill(id)'s resolved result is the proof.`);
|
|
995
|
+
return { killed: true, remaining: 0, reason: `reclaimed by id; ${killNote} and getInfo(id) re-verification errored (${sanitizedError}), so kill(id)'s resolved result is the proof` };
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Race a commands.run promise against the maxMinutes wall-clock (safety contract item 2). The E2B
|
|
1000
|
+
* commands.run timeoutMs is the primary kill; this injected-clock guard is the belt-and-suspenders
|
|
1001
|
+
* backstop so a mock CLI (which ignores timeoutMs) is still bounded and fails closed in CI.
|
|
1002
|
+
*/
|
|
1003
|
+
async function runWithWallClock(promise, wallClockMs, now) {
|
|
1004
|
+
let timer;
|
|
1005
|
+
const start = now();
|
|
1006
|
+
const timeout = new Promise((resolve) => {
|
|
1007
|
+
timer = setTimeout(() => resolve({ timedOut: true }), wallClockMs);
|
|
1008
|
+
timer.unref?.();
|
|
1009
|
+
});
|
|
1010
|
+
const value = await Promise.race([
|
|
1011
|
+
promise.then((v) => ({ timedOut: false, value: v })),
|
|
1012
|
+
timeout
|
|
1013
|
+
]);
|
|
1014
|
+
if (timer)
|
|
1015
|
+
clearTimeout(timer);
|
|
1016
|
+
// Guard against a clock that advanced past the budget even if the race resolved on the promise.
|
|
1017
|
+
if (!value.timedOut && now() - start >= wallClockMs) {
|
|
1018
|
+
return { timedOut: true };
|
|
1019
|
+
}
|
|
1020
|
+
return value;
|
|
1021
|
+
}
|
|
1022
|
+
/** Build the in-sandbox `codex exec` command (non-interactive, JSON, stdin disabled by mechanism). */
|
|
1023
|
+
function buildCodexExecCommand(args) {
|
|
1024
|
+
// The prompt is passed via a heredoc on stdin of a wrapper? NO, stdin is DISABLED (item 7), so
|
|
1025
|
+
// the prompt rides as the final positional arg, shell-quoted. codex exec --json runs once and
|
|
1026
|
+
// exits (no interactive loop). --skip-git-repo-check: the workdir is a fresh scratch dir.
|
|
1027
|
+
// Pinned via npx (never an ambient/preinstalled `codex` binary, which the stock @e2b/desktop
|
|
1028
|
+
// image does not ship, per issue #159); npm_config_update_notifier=false silences npx's own
|
|
1029
|
+
// update check so it cannot leak into the captured stdout the scorer/redactor parse.
|
|
1030
|
+
const quotedPrompt = `'${args.prompt.replace(/'/g, "'\\''")}'`;
|
|
1031
|
+
// --dangerously-bypass-approvals-and-sandbox: codex's OWN inner sandbox is
|
|
1032
|
+
// redundant here and blocks the network/file access the study mission needs.
|
|
1033
|
+
// The E2B sandbox is the trust boundary (the disposable machine); the sibling
|
|
1034
|
+
// oss-meta-lab lane carries the same flag at both live call sites for the
|
|
1035
|
+
// same reason, and exec mode has no interactive approval channel at all.
|
|
1036
|
+
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}`;
|
|
1037
|
+
}
|
|
1038
|
+
/** Compose the live prompt: PUBLIC surfaces + author mission + the verdict-nonce marker contract. */
|
|
1039
|
+
function composeLivePrompt(args) {
|
|
1040
|
+
return [
|
|
1041
|
+
`persona: ${args.personaId}`,
|
|
1042
|
+
`product: ${args.productName}`,
|
|
1043
|
+
`public-surfaces: ${args.publicSurfaces.join(" ")}`,
|
|
1044
|
+
`mission: ${args.mission}`,
|
|
1045
|
+
"",
|
|
1046
|
+
"Work ONLY from the public surfaces above. Do NOT clone or inspect any private repository.",
|
|
1047
|
+
`When finished, print exactly one final machine-readable line in this format: HUMANISH_ACTOR_VERDICT=<status> HUMANISH_ACTOR_NONCE=${args.verdictNonce} where <status> is passed, blocked, or failed.`
|
|
1048
|
+
].join("\n");
|
|
1049
|
+
}
|
|
1050
|
+
/** Redacted, ellipsis-prefixed tail of a captured stream/log for a message field. */
|
|
1051
|
+
function tailOf(text) {
|
|
1052
|
+
return redactedTail(text, TAIL_CHARS);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Project the live terminal session into the provider-neutral humanish.actor-trace.v1 (lane
|
|
1056
|
+
* "terminal", protocol "terminal-exec"). counts.actions/messages drive the no-engagement honesty
|
|
1057
|
+
* guard (a real run bumps them; a no-op is caught). No screenshots on this lane.
|
|
1058
|
+
*/
|
|
1059
|
+
function buildTerminalActorTrace(args) {
|
|
1060
|
+
const items = [
|
|
1061
|
+
...args.commandLog.map((entry, index) => ({
|
|
1062
|
+
id: `command-${String(index + 1).padStart(3, "0")}`,
|
|
1063
|
+
kind: "command",
|
|
1064
|
+
lifecycle: "completed",
|
|
1065
|
+
...(entry.exitCode === undefined ? {} : { status: String(entry.exitCode) }),
|
|
1066
|
+
title: `${entry.label} (${entry.envNames.join(",") || "no command-scoped env"})`,
|
|
1067
|
+
command: {
|
|
1068
|
+
...(entry.exitCode === undefined ? {} : { exitCode: entry.exitCode }),
|
|
1069
|
+
outputTail: args.transcriptTail
|
|
1070
|
+
}
|
|
1071
|
+
})),
|
|
1072
|
+
// One message item carrying the (already-redacted) transcript tail so the trace shows the agent
|
|
1073
|
+
// narrated SOMETHING — the engagement signal the no-engagement guard reads.
|
|
1074
|
+
...(args.terminalEvents.length > 0
|
|
1075
|
+
? [{ id: "message-001", kind: "message", lifecycle: "completed", title: "agent terminal output", text: args.transcriptTail }]
|
|
1076
|
+
: [])
|
|
1077
|
+
];
|
|
1078
|
+
return {
|
|
1079
|
+
schema: ACTOR_TRACE_SCHEMA,
|
|
1080
|
+
provider: "codex",
|
|
1081
|
+
protocol: "terminal-exec",
|
|
1082
|
+
lane: "terminal",
|
|
1083
|
+
persona: args.persona,
|
|
1084
|
+
redaction: {
|
|
1085
|
+
status: "passed",
|
|
1086
|
+
screenshots: "n/a",
|
|
1087
|
+
notes: "Terminal exec output captured via commands.run onStdout/onStderr, scrubbed (literal known values) then redacted (shape patterns) AT THE SOURCE before persisting; no screenshots on this lane."
|
|
1088
|
+
},
|
|
1089
|
+
startedAt: args.createdAt,
|
|
1090
|
+
completedAt: args.completedAt,
|
|
1091
|
+
durationMs: args.durationMs,
|
|
1092
|
+
status: args.status,
|
|
1093
|
+
completionReason: args.completionReason,
|
|
1094
|
+
reason: args.reason,
|
|
1095
|
+
ids: { model: "codex" },
|
|
1096
|
+
counts: {
|
|
1097
|
+
commands: args.commandLog.length,
|
|
1098
|
+
// actions == executed commands; messages == 1 when the agent produced any output. The
|
|
1099
|
+
// no-engagement guard (run.ts) reads these: a real run bumps them, a no-op is caught.
|
|
1100
|
+
actions: args.commandLog.length,
|
|
1101
|
+
messages: args.terminalEvents.length > 0 ? 1 : 0,
|
|
1102
|
+
terminalEvents: args.terminalEvents.length
|
|
1103
|
+
},
|
|
1104
|
+
items,
|
|
1105
|
+
capabilities: TERMINAL_AGENT_CAPABILITIES
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Project the terminal-product lab run into a humanish.run-bundle.v1 (no schema change — a new
|
|
1110
|
+
* producer only). DRY-RUN: a contract bundle. The terminal stream is a contract placeholder
|
|
1111
|
+
* (stdin disabled, no captured tail — honest: nothing ran), the subject is declared UNPINNED, and
|
|
1112
|
+
* the caps/policies/runtime-auth declarations + empty ledgers are recorded so SLICE 2 has a stable
|
|
1113
|
+
* shape to fill. Exported for the bundle-builder tests.
|
|
1114
|
+
*/
|
|
1115
|
+
export function buildTerminalProductBundle(args) {
|
|
1116
|
+
const reason = "Contract bundle only: dry-run declared the terminal-product study contract without creating an E2B sandbox, injecting any key, or spending. The live in-sandbox agent session is SLICE 2.";
|
|
1117
|
+
const simulation = {
|
|
1118
|
+
id: "sim-001",
|
|
1119
|
+
index: 1,
|
|
1120
|
+
personaId: args.persona.id,
|
|
1121
|
+
scenarioId: `terminal-${args.labId}`,
|
|
1122
|
+
status: "contract_proof_only",
|
|
1123
|
+
streamKind: "terminal",
|
|
1124
|
+
mode: "cli-sim",
|
|
1125
|
+
progress: 100,
|
|
1126
|
+
currentStep: reason,
|
|
1127
|
+
summary: `Contract lane for the terminal agent (${args.actorId}) studying ${args.productName} from public surfaces.`,
|
|
1128
|
+
streamIds: ["stream-001"],
|
|
1129
|
+
startedAt: args.createdAt,
|
|
1130
|
+
updatedAt: args.createdAt
|
|
1131
|
+
};
|
|
1132
|
+
// The terminal stream is a CONTRACT PLACEHOLDER on the dry-run path: stdin is disabled and no
|
|
1133
|
+
// exec output was captured, so the tail is empty and transport stays "snapshot" — NOT "pty"
|
|
1134
|
+
// (captured non-interactive exec output is never an interactive PTY; invariant 6 + the PTY
|
|
1135
|
+
// ruling). SLICE 2 fills terminal.tail from the redacted exec-stream capture.
|
|
1136
|
+
const stream = {
|
|
1137
|
+
id: "stream-001",
|
|
1138
|
+
simId: "sim-001",
|
|
1139
|
+
kind: "terminal",
|
|
1140
|
+
label: `Terminal agent — ${args.labId}`,
|
|
1141
|
+
status: "contract_proof_only",
|
|
1142
|
+
transport: "snapshot",
|
|
1143
|
+
updatedAt: args.createdAt,
|
|
1144
|
+
embed: { kind: "placeholder", title: `Terminal agent (${args.productName})` },
|
|
1145
|
+
terminal: {
|
|
1146
|
+
title: `${args.actorId} exec (stdin ${args.stdin})`,
|
|
1147
|
+
format: "plain",
|
|
1148
|
+
stdin: args.stdin,
|
|
1149
|
+
tail: ""
|
|
1150
|
+
},
|
|
1151
|
+
ui: {
|
|
1152
|
+
intent: `Watch the terminal agent discover and use ${args.productName} from its public surfaces.`,
|
|
1153
|
+
state: reason
|
|
1154
|
+
},
|
|
1155
|
+
artifacts: [
|
|
1156
|
+
{ label: "run bundle", path: "run.json", kind: "bundle" },
|
|
1157
|
+
{ label: "review", path: "review.md", kind: "review" },
|
|
1158
|
+
{ label: "events", path: "events.ndjson", kind: "events" }
|
|
1159
|
+
]
|
|
1160
|
+
};
|
|
1161
|
+
const capsText = describeCaps(args.caps);
|
|
1162
|
+
const events = [
|
|
1163
|
+
{
|
|
1164
|
+
id: "event-000-created",
|
|
1165
|
+
at: args.createdAt,
|
|
1166
|
+
level: "info",
|
|
1167
|
+
type: "terminal-lab.run.created",
|
|
1168
|
+
message: `Created terminal-product lab run for ${args.labId} (actor ${args.actorId}, product ${args.productName}).`
|
|
1169
|
+
},
|
|
1170
|
+
{
|
|
1171
|
+
id: "event-001-subject",
|
|
1172
|
+
at: args.createdAt,
|
|
1173
|
+
level: "info",
|
|
1174
|
+
type: "terminal-lab.subject.declared",
|
|
1175
|
+
// Invariant 5: provenance recorded or its absence DECLARED. The agent drives PUBLIC surfaces,
|
|
1176
|
+
// not a clone, so the subject provenance is explicitly UNPINNED; evidence binds to the
|
|
1177
|
+
// composed-prompt digest. Public surfaces are recorded (they are public by declaration).
|
|
1178
|
+
message: `Subject product declared: ${args.productName}; public surfaces: ${args.publicSurfaces.join(", ")}. The lab did not provision/clone the product — subject provenance is UNPINNED (a public-surface study cannot be commit-pinned); evidence binds to the composed-prompt digest ${args.persona.promptDigest}.`,
|
|
1179
|
+
simId: "sim-001",
|
|
1180
|
+
streamId: "stream-001"
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
id: "event-002-credentials",
|
|
1184
|
+
at: args.createdAt,
|
|
1185
|
+
level: "info",
|
|
1186
|
+
type: "terminal-lab.credentials.declared",
|
|
1187
|
+
// Names-only evidence (invariant 1): the runtime-auth CHANNEL is declared; no value is ever
|
|
1188
|
+
// recorded. The deny-by-default policies are recorded so the credential posture is auditable.
|
|
1189
|
+
message: `Runtime auth channel: ${args.runtimeAuth ?? "none declared"} (names only; values never persist; command-scoped injection is SLICE 2). Credential policies (deny-by-default): allowPrivateRepoAccess=${args.policies.allowPrivateRepoAccess}, allowProviderCredentials=${args.policies.allowProviderCredentials}, allowPaymentCredentials=${args.policies.allowPaymentCredentials}, allowGitHubMutation=${args.policies.allowGitHubMutation}.`,
|
|
1190
|
+
simId: "sim-001",
|
|
1191
|
+
streamId: "stream-001"
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
id: "event-003-caps",
|
|
1195
|
+
at: args.createdAt,
|
|
1196
|
+
level: "info",
|
|
1197
|
+
type: "terminal-lab.caps.declared",
|
|
1198
|
+
message: `Spend/job/time caps: ${capsText}. The live key is never exercised without a fail-closed cap in force (SLICE 2); the no-spend proof is derived from a real ledger (SLICE 3).`,
|
|
1199
|
+
simId: "sim-001",
|
|
1200
|
+
streamId: "stream-001"
|
|
1201
|
+
},
|
|
1202
|
+
{
|
|
1203
|
+
id: "event-004-contract",
|
|
1204
|
+
at: args.createdAt,
|
|
1205
|
+
level: "info",
|
|
1206
|
+
type: "terminal-lab.contract.ready",
|
|
1207
|
+
message: "Dry-run contract bundle ready; the live in-sandbox agent session, the captured exec stream, and the credential boundary are SLICE 2. Switch scenario.mode to live once SLICE 2 lands.",
|
|
1208
|
+
simId: "sim-001",
|
|
1209
|
+
streamId: "stream-001"
|
|
1210
|
+
}
|
|
1211
|
+
];
|
|
1212
|
+
const review = {
|
|
1213
|
+
schema: REVIEW_SCHEMA,
|
|
1214
|
+
verdict: "contract_proof_only",
|
|
1215
|
+
summary: reason,
|
|
1216
|
+
gaps: [
|
|
1217
|
+
"Live in-sandbox agent session not yet run (dry-run contract only; SLICE 2).",
|
|
1218
|
+
"Captured exec-stream / transcript / substrate / cost / cleanup ledgers are placeholders this slice (SLICE 2/3 fill them)."
|
|
1219
|
+
]
|
|
1220
|
+
};
|
|
1221
|
+
return {
|
|
1222
|
+
schema: RUN_BUNDLE_SCHEMA,
|
|
1223
|
+
runId: args.runId,
|
|
1224
|
+
mode: args.dryRun ? "dry-run" : "live",
|
|
1225
|
+
simCount: 1,
|
|
1226
|
+
createdAt: args.createdAt,
|
|
1227
|
+
cwd: PUBLIC_TARGET_CWD,
|
|
1228
|
+
artifactRoot: path.join(".humanish", "runs", args.runId),
|
|
1229
|
+
source: args.source,
|
|
1230
|
+
persona: {
|
|
1231
|
+
id: args.persona.id,
|
|
1232
|
+
name: `Autonomous terminal agent (${args.persona.id})`,
|
|
1233
|
+
source: `lab:${args.labId}`,
|
|
1234
|
+
sourceDigest: args.persona.promptDigest
|
|
1235
|
+
},
|
|
1236
|
+
scenario: {
|
|
1237
|
+
id: `terminal-${args.labId}`,
|
|
1238
|
+
title: args.labTitle ?? `Terminal-product lab: ${args.labId}`,
|
|
1239
|
+
// The author mission is public-safe committed lab text — recorded plaintext as the goal,
|
|
1240
|
+
// redacted defensively before persisting (it never carries a secret, but the harness never
|
|
1241
|
+
// trusts that). The full composed prompt is bound by digest, not text.
|
|
1242
|
+
goal: redactText(args.mission),
|
|
1243
|
+
source: `lab:${args.labId}`,
|
|
1244
|
+
sourceDigest: args.persona.promptDigest
|
|
1245
|
+
},
|
|
1246
|
+
lifecycle: [
|
|
1247
|
+
{
|
|
1248
|
+
at: args.createdAt,
|
|
1249
|
+
event: "terminal-lab.run.created",
|
|
1250
|
+
message: `Created terminal-product lab run with one in-sandbox agent lane (actor ${args.actorId}, product ${args.productName}).`
|
|
1251
|
+
}
|
|
1252
|
+
],
|
|
1253
|
+
simulations: [simulation],
|
|
1254
|
+
streams: [stream],
|
|
1255
|
+
events,
|
|
1256
|
+
redaction: {
|
|
1257
|
+
status: "passed",
|
|
1258
|
+
notes: "Dry-run contract bundle: no sandbox ran, no key was injected, no exec output was captured. The author mission is public-safe committed lab text (redacted defensively); the composed prompt is bound by digest. Live capture (scrubKnownValues then redactText, at the source) is SLICE 2."
|
|
1259
|
+
},
|
|
1260
|
+
artifacts: {
|
|
1261
|
+
run: "run.json",
|
|
1262
|
+
reviewJson: "review.json",
|
|
1263
|
+
reviewMarkdown: "review.md",
|
|
1264
|
+
observerData: "observer/observer-data.json",
|
|
1265
|
+
events: "events.ndjson"
|
|
1266
|
+
},
|
|
1267
|
+
review,
|
|
1268
|
+
feedbackCandidates: []
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Build the LIVE terminal-product run bundle (mode "live") from the captured session: the actor
|
|
1273
|
+
* trace seam (stream.actor = trace), the substrate-lifecycle events, the terminal stream with the
|
|
1274
|
+
* redacted transcript tail, and references to the written evidence artifacts (terminal event
|
|
1275
|
+
* stream, transcript, ledgers, actor trace). verifyRun's terminal-product check (gated on
|
|
1276
|
+
* mode==="live") enforces the ledgers + proven cleanup + interventions-present over this bundle.
|
|
1277
|
+
*/
|
|
1278
|
+
export function buildLiveTerminalProductBundle(args) {
|
|
1279
|
+
const simStatus = args.trace.status === "passed"
|
|
1280
|
+
? "passed"
|
|
1281
|
+
: args.trace.status === "blocked"
|
|
1282
|
+
? "blocked"
|
|
1283
|
+
: args.trace.status === "timed_out"
|
|
1284
|
+
? "timed_out"
|
|
1285
|
+
: "failed";
|
|
1286
|
+
const messageItem = args.trace.items.find((item) => item.kind === "message");
|
|
1287
|
+
const tail = (messageItem?.text ?? args.trace.reason).slice(0, 2000);
|
|
1288
|
+
const simulation = {
|
|
1289
|
+
id: "sim-001",
|
|
1290
|
+
index: 1,
|
|
1291
|
+
personaId: args.persona.id,
|
|
1292
|
+
scenarioId: `terminal-${args.labId}`,
|
|
1293
|
+
status: simStatus,
|
|
1294
|
+
streamKind: "terminal",
|
|
1295
|
+
mode: "cli-sim",
|
|
1296
|
+
progress: 100,
|
|
1297
|
+
currentStep: args.sessionReason,
|
|
1298
|
+
summary: `Terminal agent (${args.actorId}) studied ${args.productName} from public surfaces (${args.trace.status}).`,
|
|
1299
|
+
streamIds: ["stream-001"],
|
|
1300
|
+
startedAt: args.createdAt,
|
|
1301
|
+
updatedAt: args.trace.completedAt
|
|
1302
|
+
};
|
|
1303
|
+
// transport "snapshot": the persisted tail is a redacted snapshot of the captured exec output,
|
|
1304
|
+
// NOT an interactive PTY (stdin disabled). The actor trace seam carries the structured evidence.
|
|
1305
|
+
const stream = {
|
|
1306
|
+
id: "stream-001",
|
|
1307
|
+
simId: "sim-001",
|
|
1308
|
+
kind: "terminal",
|
|
1309
|
+
label: `Terminal agent — ${args.labId}`,
|
|
1310
|
+
status: simStatus,
|
|
1311
|
+
transport: "snapshot",
|
|
1312
|
+
updatedAt: args.trace.completedAt,
|
|
1313
|
+
embed: { kind: "placeholder", title: `Terminal agent (${args.productName})` },
|
|
1314
|
+
terminal: {
|
|
1315
|
+
title: `${args.actorId} exec (stdin disabled)`,
|
|
1316
|
+
format: "plain",
|
|
1317
|
+
stdin: "disabled",
|
|
1318
|
+
tail
|
|
1319
|
+
},
|
|
1320
|
+
ui: {
|
|
1321
|
+
intent: `Watch the terminal agent discover and use ${args.productName} from its public surfaces.`,
|
|
1322
|
+
state: args.sessionReason
|
|
1323
|
+
},
|
|
1324
|
+
actor: args.trace,
|
|
1325
|
+
artifacts: [
|
|
1326
|
+
{ label: "run bundle", path: "run.json", kind: "bundle" },
|
|
1327
|
+
{ label: "review", path: "review.md", kind: "review" },
|
|
1328
|
+
{ label: "event log", path: "events.ndjson", kind: "events" },
|
|
1329
|
+
{ label: "actor trace", path: "actor.json", kind: "trace" },
|
|
1330
|
+
{ label: "terminal event stream", path: TERMINAL_EVENTS_ARTIFACT, kind: "log" },
|
|
1331
|
+
{ label: "terminal transcript", path: TERMINAL_TRANSCRIPT_ARTIFACT, kind: "log" },
|
|
1332
|
+
{ label: "terminal ledgers", path: TERMINAL_LEDGERS_ARTIFACT, kind: "log" }
|
|
1333
|
+
]
|
|
1334
|
+
};
|
|
1335
|
+
// Substrate-lifecycle ledger -> bundle events (each already sanitized when recorded).
|
|
1336
|
+
const lifecycleEvents = args.ledgers.lifecycle.map((record, index) => ({
|
|
1337
|
+
id: `event-${String(index).padStart(3, "0")}-${record.event}`,
|
|
1338
|
+
at: record.at,
|
|
1339
|
+
level: record.event.includes("error") || record.event.includes("timed_out") || record.event.includes("exceeded") ? "warn" : "info",
|
|
1340
|
+
type: record.event,
|
|
1341
|
+
message: record.message,
|
|
1342
|
+
simId: "sim-001",
|
|
1343
|
+
streamId: "stream-001"
|
|
1344
|
+
}));
|
|
1345
|
+
// Surface the no-spend proof as a first-class bundle event so the Observer/review can SHOW it.
|
|
1346
|
+
// It is DERIVED from the cost ledger (never asserted): it lists the known-zero lines it vouches
|
|
1347
|
+
// for AND the unmeasured (null) lines it explicitly cannot vouch for.
|
|
1348
|
+
const noSpend = args.ledgers.noSpendProof;
|
|
1349
|
+
lifecycleEvents.push({
|
|
1350
|
+
id: "event-cost-no-spend-proof",
|
|
1351
|
+
at: args.trace.completedAt,
|
|
1352
|
+
level: noSpend.satisfied ? "info" : "warn",
|
|
1353
|
+
type: "terminal-lab.no-spend.proof",
|
|
1354
|
+
message: noSpend.statement,
|
|
1355
|
+
simId: "sim-001",
|
|
1356
|
+
streamId: "stream-001"
|
|
1357
|
+
});
|
|
1358
|
+
const verdict = args.trace.status === "passed"
|
|
1359
|
+
? "pass"
|
|
1360
|
+
: args.trace.status === "blocked"
|
|
1361
|
+
? "blocked"
|
|
1362
|
+
: args.trace.status === "timed_out"
|
|
1363
|
+
? "timed_out"
|
|
1364
|
+
: "fail";
|
|
1365
|
+
const review = {
|
|
1366
|
+
schema: REVIEW_SCHEMA,
|
|
1367
|
+
verdict,
|
|
1368
|
+
summary: args.sessionReason,
|
|
1369
|
+
gaps: [
|
|
1370
|
+
...(args.trace.status === "passed" ? [] : [`Agent session ended ${args.trace.status}: ${args.sessionReason}`]),
|
|
1371
|
+
// Honesty gap: the no-spend proof always declares which spend lines it could NOT measure, so a
|
|
1372
|
+
// green run never silently over-claims a fully-proven $0.
|
|
1373
|
+
...(noSpend.unmeasuredLines.length > 0
|
|
1374
|
+
? [`No-spend proof is partial: ${noSpend.unmeasuredLines.join(", ")} spend was UNMEASURED this slice (recorded null, not claimed zero; the SLICE-4 adapter supplies these signals).`]
|
|
1375
|
+
: [])
|
|
1376
|
+
]
|
|
1377
|
+
};
|
|
1378
|
+
return {
|
|
1379
|
+
schema: RUN_BUNDLE_SCHEMA,
|
|
1380
|
+
runId: args.runId,
|
|
1381
|
+
mode: "live",
|
|
1382
|
+
simCount: 1,
|
|
1383
|
+
createdAt: args.createdAt,
|
|
1384
|
+
cwd: PUBLIC_TARGET_CWD,
|
|
1385
|
+
artifactRoot: path.join(".humanish", "runs", args.runId),
|
|
1386
|
+
source: args.source,
|
|
1387
|
+
persona: {
|
|
1388
|
+
id: args.persona.id,
|
|
1389
|
+
name: `Autonomous terminal agent (${args.persona.id})`,
|
|
1390
|
+
source: `lab:${args.labId}`,
|
|
1391
|
+
sourceDigest: args.persona.promptDigest
|
|
1392
|
+
},
|
|
1393
|
+
scenario: {
|
|
1394
|
+
id: `terminal-${args.labId}`,
|
|
1395
|
+
title: args.labTitle ?? `Terminal-product lab: ${args.labId}`,
|
|
1396
|
+
goal: redactText(args.mission),
|
|
1397
|
+
source: `lab:${args.labId}`,
|
|
1398
|
+
sourceDigest: args.persona.promptDigest
|
|
1399
|
+
},
|
|
1400
|
+
lifecycle: args.ledgers.lifecycle.map((record) => ({ at: record.at, event: record.event, message: record.message })),
|
|
1401
|
+
simulations: [simulation],
|
|
1402
|
+
streams: [stream],
|
|
1403
|
+
events: lifecycleEvents,
|
|
1404
|
+
redaction: {
|
|
1405
|
+
status: "passed",
|
|
1406
|
+
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).`
|
|
1407
|
+
},
|
|
1408
|
+
artifacts: {
|
|
1409
|
+
run: "run.json",
|
|
1410
|
+
reviewJson: "review.json",
|
|
1411
|
+
reviewMarkdown: "review.md",
|
|
1412
|
+
observerData: "observer/observer-data.json",
|
|
1413
|
+
events: "events.ndjson"
|
|
1414
|
+
},
|
|
1415
|
+
review,
|
|
1416
|
+
feedbackCandidates: []
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
function describeCaps(caps) {
|
|
1420
|
+
if (!caps)
|
|
1421
|
+
return "none declared (a live run REQUIRES caps in SLICE 2)";
|
|
1422
|
+
const parts = [];
|
|
1423
|
+
if (caps.maxUsd !== undefined)
|
|
1424
|
+
parts.push(`maxUsd=${caps.maxUsd}`);
|
|
1425
|
+
if (caps.maxJobs !== undefined)
|
|
1426
|
+
parts.push(`maxJobs=${caps.maxJobs}`);
|
|
1427
|
+
if (caps.maxMinutes !== undefined)
|
|
1428
|
+
parts.push(`maxMinutes=${caps.maxMinutes}`);
|
|
1429
|
+
return parts.length > 0 ? parts.join(", ") : "empty";
|
|
1430
|
+
}
|
|
1431
|
+
/** The default mission when the lab omits one. Public-safe, product-neutral author text. */
|
|
1432
|
+
function defaultMission(productName) {
|
|
1433
|
+
return `You are an autonomous agent. Discover ${productName} from its public surfaces and determine whether it can help with a durable real task. Stay within the declared no-spend caps. Leave feedback if the workflow is confusing.`;
|
|
1434
|
+
}
|
|
1435
|
+
/** Compose the full prompt the agent would run. Bound to evidence by DIGEST only. */
|
|
1436
|
+
function composePrompt(args) {
|
|
1437
|
+
return [
|
|
1438
|
+
`persona: ${args.personaId}`,
|
|
1439
|
+
`product: ${args.productName}`,
|
|
1440
|
+
`public-surfaces: ${args.publicSurfaces.join(" ")}`,
|
|
1441
|
+
`mission: ${args.mission}`
|
|
1442
|
+
].join("\n");
|
|
1443
|
+
}
|
|
1444
|
+
function renderTerminalReviewMarkdown(bundle) {
|
|
1445
|
+
const subject = bundle.events.find((event) => event.type === "terminal-lab.subject.declared");
|
|
1446
|
+
const credentials = bundle.events.find((event) => event.type === "terminal-lab.credentials.declared");
|
|
1447
|
+
const caps = bundle.events.find((event) => event.type === "terminal-lab.caps.declared");
|
|
1448
|
+
return [
|
|
1449
|
+
`# ${bundle.scenario.title}`,
|
|
1450
|
+
"",
|
|
1451
|
+
`- run: ${bundle.runId}`,
|
|
1452
|
+
`- mode: ${bundle.mode}`,
|
|
1453
|
+
`- verdict: ${bundle.review.verdict}`,
|
|
1454
|
+
`- summary: ${bundle.review.summary}`,
|
|
1455
|
+
`- mission: ${bundle.scenario.goal}`,
|
|
1456
|
+
...(subject ? [`- subject: ${subject.message}`] : []),
|
|
1457
|
+
...(credentials ? [`- credentials: ${credentials.message}`] : []),
|
|
1458
|
+
...(caps ? [`- caps: ${caps.message}`] : []),
|
|
1459
|
+
...(bundle.review.gaps.length > 0 ? ["", "## Gaps", ...bundle.review.gaps.map((gap) => `- ${gap}`)] : []),
|
|
1460
|
+
""
|
|
1461
|
+
].join("\n");
|
|
1462
|
+
}
|
|
1463
|
+
function makeTerminalRunId() {
|
|
1464
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1465
|
+
return `terminal-${stamp}-${randomBytes(4).toString("hex")}`;
|
|
1466
|
+
}
|
|
1467
|
+
//# sourceMappingURL=e2b-terminal-lab.js.map
|