humanish 0.110.1 → 0.110.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/ARCHITECTURE.md +11 -16
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +1 -1
  4. package/README.md +8 -7
  5. package/dist/actors/computer-use/actor.js +2 -2
  6. package/dist/actors/computer-use/loop/ending.js +1 -1
  7. package/dist/actors/local-agent/cli.d.ts +6 -0
  8. package/dist/actors/local-agent/cli.js +8 -0
  9. package/dist/actors/local-agent/readiness.js +2 -1
  10. package/dist/actors/registry.js +1 -1
  11. package/dist/analysis/automatic-completion.js +8 -0
  12. package/dist/cli/commands/observe.js +18 -4
  13. package/dist/cli/commands/run-signals.js +13 -4
  14. package/dist/cli/commands/runs.js +36 -13
  15. package/dist/cli/commands/setup.js +23 -14
  16. package/dist/cli/commands/study-route-computer-use.js +7 -3
  17. package/dist/cli/commands/study-route-open.js +1 -0
  18. package/dist/cli/commands/study-route-preview.js +1 -0
  19. package/dist/cli/commands/study-route-shared-world.js +4 -2
  20. package/dist/cli/commands/study-run.js +9 -19
  21. package/dist/cli/commands/watch.js +14 -4
  22. package/dist/cli/doctor.js +9 -6
  23. package/dist/cli/findings.d.ts +5 -0
  24. package/dist/cli/findings.js +28 -6
  25. package/dist/cli/first-run-path.d.ts +27 -7
  26. package/dist/cli/first-run-path.js +112 -75
  27. package/dist/cli/invocation.d.ts +7 -0
  28. package/dist/cli/invocation.js +32 -0
  29. package/dist/cli/io.d.ts +5 -2
  30. package/dist/cli/io.js +9 -16
  31. package/dist/cli/observer-follow.d.ts +24 -0
  32. package/dist/cli/observer-follow.js +48 -3
  33. package/dist/cli/orientation.js +16 -14
  34. package/dist/cli/version.d.ts +1 -0
  35. package/dist/cli/version.js +11 -0
  36. package/dist/evidence/encoded-text.d.ts +9 -6
  37. package/dist/evidence/encoded-text.js +84 -19
  38. package/dist/evidence/redaction.d.ts +2 -0
  39. package/dist/evidence/redaction.js +134 -21
  40. package/dist/feedback/export-bundle.js +2 -2
  41. package/dist/keys/key-resolution.js +6 -5
  42. package/dist/observer/exposure.js +1 -1
  43. package/dist/observer/library.js +3 -2
  44. package/dist/observer/render.js +2 -1
  45. package/dist/observer/run-routes.js +8 -1
  46. package/dist/observer/serve.js +2 -1
  47. package/dist/routes/computer-use/participant-runs.d.ts +6 -9
  48. package/dist/routes/computer-use/participant-runs.js +16 -36
  49. package/dist/routes/computer-use/plan.d.ts +2 -2
  50. package/dist/routes/computer-use/plan.js +6 -1
  51. package/dist/routes/computer-use/route.d.ts +5 -18
  52. package/dist/routes/computer-use/route.js +17 -78
  53. package/dist/routes/computer-use/setup.d.ts +3 -2
  54. package/dist/routes/computer-use/types.d.ts +6 -10
  55. package/dist/routes/scripted/route.d.ts +4 -17
  56. package/dist/routes/scripted/route.js +10 -42
  57. package/dist/routes/scripted/types.d.ts +1 -6
  58. package/dist/routes/shared-world/plan.js +5 -1
  59. package/dist/routes/shared-world/route.d.ts +5 -11
  60. package/dist/routes/shared-world/route.js +24 -63
  61. package/dist/routes/shared-world/types.d.ts +2 -6
  62. package/dist/routes/terminal/bundle.js +2 -1
  63. package/dist/routes/terminal/credentials.d.ts +5 -5
  64. package/dist/routes/terminal/credentials.js +14 -6
  65. package/dist/routes/terminal/plan.js +8 -0
  66. package/dist/routes/terminal/route.d.ts +3 -13
  67. package/dist/routes/terminal/route.js +24 -63
  68. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  69. package/dist/routes/terminal/runtime-auth.js +5 -0
  70. package/dist/routes/terminal/session.d.ts +2 -3
  71. package/dist/routes/terminal/session.js +2 -3
  72. package/dist/routes/terminal/types.d.ts +2 -7
  73. package/dist/run/dry-run.js +2 -1
  74. package/dist/run/judge.js +2 -1
  75. package/dist/run/local-only-files.d.ts +13 -0
  76. package/dist/run/local-only-files.js +35 -0
  77. package/dist/run/reclaim.d.ts +5 -0
  78. package/dist/run/reclaim.js +34 -2
  79. package/dist/run/route-shell.d.ts +60 -0
  80. package/dist/run/route-shell.js +51 -0
  81. package/dist/run/run-not-found.js +3 -2
  82. package/dist/run/run.d.ts +6 -1
  83. package/dist/run/run.js +11 -2
  84. package/dist/run/sandbox-creates.js +5 -3
  85. package/dist/run/sandbox-receipts.d.ts +6 -5
  86. package/dist/run/sandbox-receipts.js +12 -7
  87. package/dist/run/status.d.ts +2 -0
  88. package/dist/run/status.js +5 -0
  89. package/dist/study/agents-section.d.ts +21 -0
  90. package/dist/study/agents-section.js +86 -0
  91. package/dist/study/composition-rules.js +1 -1
  92. package/dist/study/doctor.js +5 -4
  93. package/dist/study/init-templates.d.ts +1 -1
  94. package/dist/study/init-templates.js +43 -129
  95. package/dist/study/init.js +32 -35
  96. package/dist/study/local-browser-host.d.ts +11 -0
  97. package/dist/study/local-browser-host.js +42 -0
  98. package/dist/study/parse/actors.js +9 -1
  99. package/dist/study/parse/subject.js +13 -1
  100. package/dist/study/parse/url-credentials.d.ts +9 -0
  101. package/dist/study/parse/url-credentials.js +50 -0
  102. package/dist/study/preflight-probes.js +2 -1
  103. package/dist/study/types.d.ts +8 -6
  104. package/dist/study/url-credentials.d.ts +6 -0
  105. package/dist/study/url-credentials.js +34 -0
  106. package/dist/substrates/e2b/sandbox.js +33 -15
  107. package/dist/substrates/local/firecracker-desktop.js +3 -2
  108. package/dist/substrates/local/runtime-host.js +5 -4
  109. package/dist/substrates/local/runtime.js +5 -4
  110. package/dist/tui/contract.js +5 -4
  111. package/dist/verify/liveness.js +2 -1
  112. package/dist/verify/verify.js +2 -1
  113. package/docs/architecture/actor-contract.md +1 -1
  114. package/docs/architecture/external-public-shared-world.md +1 -1
  115. package/docs/architecture/terminal-product-route.md +26 -24
  116. package/docs/contracts/run-bundle.md +20 -4
  117. package/docs/contracts/schemas.md +6 -5
  118. package/docs/contracts/study-analysis.md +5 -2
  119. package/docs/product/automatic-analysis.md +1 -1
  120. package/package.json +4 -2
  121. package/skills/humanish/SKILL.md +22 -18
@@ -2,6 +2,7 @@ import { normalizeExtraExcludeEntry } from "../../subject/local-tree-archive.js"
2
2
  import { ENV_NAME_PATTERN, invalid, posInt, str, strList } from "./values.js";
3
3
  import { parseEnvValues, parseState, subjectStateInvalidReason } from "./subject-state.js";
4
4
  import { isRecord } from "../../run/type-guards.js";
5
+ import { urlCredentialReason } from "./url-credentials.js";
5
6
  export function parseSubject(raw) {
6
7
  if (!isRecord(raw)) {
7
8
  return invalid("`subject` is required and must be an object.");
@@ -175,7 +176,7 @@ function parseServedApp(raw, subject) {
175
176
  return stateResult;
176
177
  }
177
178
  if (stateResult.value) {
178
- // Semantic validation is shared with the engine (runCuaActorStudy re-enforces it for
179
+ // Semantic validation is shared with the engine (planComputerUseStudy re-enforces it for
179
180
  // configs that arrive through the library API without the parser).
180
181
  const reason = subjectStateInvalidReason(stateResult.value, subject.env);
181
182
  if (reason) {
@@ -202,6 +203,9 @@ function parseAppSubject(raw, source, subject) {
202
203
  else if (!isHttpUrl(appUrl)) {
203
204
  return invalid("`subject.appUrl` must be an http(s) URL.");
204
205
  }
206
+ const credential = urlCredentialReason("subject.appUrl", appUrl);
207
+ if (credential)
208
+ return invalid(credential);
205
209
  subject.appUrl = appUrl;
206
210
  // publicTarget (external-public shared-world ownership attestation) is app-url-only. Shape it
207
211
  // here; its required-on-that-route semantics live in externalPublicSharedWorldValidationReason.
@@ -278,6 +282,11 @@ function parseProduct(raw) {
278
282
  if (badSurface) {
279
283
  return invalid(`subject.product.publicSurfaces entries must be http(s) URLs (got "${badSurface}").`);
280
284
  }
285
+ const surfaceCredential = publicSurfaces
286
+ .map((surface) => urlCredentialReason("subject.product.publicSurfaces", surface))
287
+ .find((reason) => reason !== undefined);
288
+ if (surfaceCredential)
289
+ return invalid(surfaceCredential);
281
290
  return {
282
291
  ok: true,
283
292
  value: {
@@ -332,6 +341,9 @@ function parseServe(raw) {
332
341
  if (!url || !isLoopbackUrl(url)) {
333
342
  return invalid("`subject.serve.url` must be a loopback http(s) URL (127.0.0.1 or localhost), because the app runs inside the sandbox.");
334
343
  }
344
+ const credential = urlCredentialReason("subject.serve.url", url);
345
+ if (credential)
346
+ return invalid(credential);
335
347
  const serve = { start, url };
336
348
  const install = str(raw.install);
337
349
  if (install)
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Why the URL declared at `field` cannot be used: it has userinfo, or its path, query or fragment
3
+ * holds a value verify flags as a secret, as written or encoded. Undefined for a URL with neither,
4
+ * and for text that is not a URL, which the field's own shape check refuses. `base` resolves a
5
+ * relative entry path.
6
+ */
7
+ export declare function urlCredentialReason(field: string, value: string, base?: string): string | undefined;
8
+ /** Why the participant entry at `field` cannot be used, resolved as a path on the subject. */
9
+ export declare function entryCredentialReason(field: string, entry: string): string | undefined;
@@ -0,0 +1,50 @@
1
+ // A URL a study declares is recorded in the run, shown in the participant's address bar and so in
2
+ // every screenshot and model request. It may not carry a credential. Ordinary query parameters,
3
+ // such as a filter or a page number, stay as they are.
4
+ import { scanEncodedText } from "../../evidence/encoded-text.js";
5
+ // A relative participant entry is resolved against this before it is read.
6
+ const ENTRY_BASE = "http://127.0.0.1/";
7
+ /**
8
+ * Why the URL declared at `field` cannot be used: it has userinfo, or its path, query or fragment
9
+ * holds a value verify flags as a secret, as written or encoded. Undefined for a URL with neither,
10
+ * and for text that is not a URL, which the field's own shape check refuses. `base` resolves a
11
+ * relative entry path.
12
+ */
13
+ export function urlCredentialReason(field, value, base) {
14
+ let url;
15
+ try {
16
+ url = new URL(value, base);
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ // The URL parser drops tabs and line breaks, which can split a key the scan would otherwise read.
22
+ if (/[\u0000-\u001f\u007f]/.test(value))
23
+ return `\`${field}\` has a tab, line break or other control character in it, which the URL parser drops. Write the URL without it.`;
24
+ if (url.username !== "" || url.password !== "")
25
+ return `\`${field}\` has a user name or password in it. humanish records the URL in the run, and the participant's browser shows it in every screenshot, so the credential would reach the bundle and the model. Remove it from the URL and give the participant a test account to sign in with.`;
26
+ if (urlPartsHoldSecret(value, url))
27
+ return `\`${field}\` carries a credential in its path, query or fragment, such as a token, a signature or a preview bypass parameter. humanish records the URL in the run, and the participant's browser shows it in every screenshot. Remove it from the URL. For a protected preview, make the deployment reachable without a token for the study, or build the app in the desktop with a clone subject.`;
28
+ return undefined;
29
+ }
30
+ /** Why the participant entry at `field` cannot be used, resolved as a path on the subject. */
31
+ export function entryCredentialReason(field, entry) {
32
+ return urlCredentialReason(field, entry, ENTRY_BASE);
33
+ }
34
+ /**
35
+ * Whether the URL's path, query or fragment holds a secret: read whole, as the parser normalized
36
+ * it and as written (`..` segments can drop a segment from the normalized path), and one segment,
37
+ * value or fragment piece at a time, so the text around an encoded key cannot shift its decoding.
38
+ */
39
+ function urlPartsHoldSecret(value, url) {
40
+ const written = value.replace(/^[a-z][a-z0-9+.-]{0,31}:\/\/[^/?#]*/i, "");
41
+ const normalized = `${url.pathname}${url.search}${url.hash}`;
42
+ const pieces = [
43
+ written,
44
+ normalized,
45
+ ...written.split(/[/?#&=;]/),
46
+ ...normalized.split(/[/?#&=;]/),
47
+ ];
48
+ return pieces.some((piece) => piece.length > 0 &&
49
+ scanEncodedText(piece, { secretsOnly: true, allowOpaqueBase64: true }).sensitive);
50
+ }
@@ -14,6 +14,7 @@ import { redactText } from "../evidence/redaction.js";
14
14
  import { digest, fail, finalize } from "./preflight-result.js";
15
15
  import { rosterOf } from "./parse/actors.js";
16
16
  import { plural } from "../run/text.js";
17
+ import { cli } from "../cli/invocation.js";
17
18
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
18
19
  // Room on the probe's lease for desktop boot and teardown around the work it does.
19
20
  const PREFLIGHT_LEASE_BUFFER_MS = 5 * 60_000;
@@ -240,7 +241,7 @@ async function settlePreflightJournal(ctx, journal, gone) {
240
241
  return;
241
242
  if (!gone) {
242
243
  await abandonPreflightJournal(journal).catch(() => undefined);
243
- ctx.warnings.push(`The preflight sandbox's receipt stays in .humanish/preflight/${journal.id}; run \`humanish reclaim --preflight\` to kill it by id.`);
244
+ ctx.warnings.push(`The preflight sandbox's receipt stays in .humanish/preflight/${journal.id}; run \`${cli("reclaim --preflight")}\` to kill it by id.`);
244
245
  return;
245
246
  }
246
247
  await discardPreflightJournal(journal).catch((error) => {
@@ -448,10 +448,11 @@ export interface StudyExecutionTerminal {
448
448
  stdin?: StudyTerminalStdin;
449
449
  }
450
450
  /**
451
- * The terminal agent's runtime-auth channel. "openai-env" (the default) passes the raw runtime
452
- * key command-scoped. "openai-egress" keeps it in an E2B outbound header transform for the default
453
- * OpenAI endpoint and passes an inert placeholder to Codex. The latter still gives every sandbox
454
- * process a spendable OpenAI proxy capability; it is not a spend cap or an egress restriction.
451
+ * The terminal agent's runtime-auth channel. "openai-egress" (the default) keeps the raw runtime
452
+ * key in an E2B outbound header transform for the default OpenAI endpoint and passes an inert
453
+ * placeholder to Codex. It still gives every sandbox process a spendable OpenAI proxy capability;
454
+ * it is not a spend cap or an egress restriction. "openai-env" passes the raw key command-scoped,
455
+ * where the agent and its child processes can read it.
455
456
  */
456
457
  export type StudyRuntimeAuth = "openai-env" | "openai-egress";
457
458
  export type StudyDesktopBrowser = "default" | "chrome" | "chromium" | "firefox";
@@ -559,8 +560,9 @@ export interface StudyExecution {
559
560
  caps?: StudyScenarioCaps;
560
561
  /** `terminal-product` route: the terminal transport + stdin posture. Consumed on that route. */
561
562
  terminal?: StudyExecutionTerminal;
562
- /** `terminal-product` route: runtime key placement, defaulting to openai-env. openai-egress
563
- * uses an external header transform; dry-runs record declarations only. Inert on other routes. */
563
+ /** `terminal-product` route: runtime key placement, defaulting to openai-egress, an external
564
+ * header transform. openai-env passes the key to the agent command. Dry-runs record declarations
565
+ * only. Inert on other routes. */
564
566
  runtimeAuth?: StudyRuntimeAuth;
565
567
  /** Terminal Codex package pin. Omit to resolve latest once, observe it, then execute that version. */
566
568
  runtime?: {
@@ -0,0 +1,6 @@
1
+ import type { StudyConfig } from "./types.js";
2
+ /**
3
+ * The first URL in `config` that carries a credential, with why: the subject's app, serve and
4
+ * public surface URLs, and each participant's target and entry.
5
+ */
6
+ export declare function studyUrlCredentialReason(config: StudyConfig): string | undefined;
@@ -0,0 +1,34 @@
1
+ // Every URL a study config declares, checked for a credential. Route planners call it, since a
2
+ // library caller's config skips the parser that refuses these first.
3
+ import { rosterOf } from "./parse/actors.js";
4
+ import { entryCredentialReason, urlCredentialReason } from "./parse/url-credentials.js";
5
+ /**
6
+ * The first URL in `config` that carries a credential, with why: the subject's app, serve and
7
+ * public surface URLs, and each participant's target and entry.
8
+ */
9
+ export function studyUrlCredentialReason(config) {
10
+ const { subject } = config;
11
+ const reasons = [
12
+ () => subject.appUrl === undefined
13
+ ? undefined
14
+ : urlCredentialReason("subject.appUrl", subject.appUrl),
15
+ () => subject.serve === undefined
16
+ ? undefined
17
+ : urlCredentialReason("subject.serve.url", subject.serve.url),
18
+ ...(subject.product?.publicSurfaces ?? []).map((surface) => () => urlCredentialReason("subject.product.publicSurfaces", surface)),
19
+ ...config.actors.flatMap((actor) => (rosterOf(actor) ?? []).flatMap((participant, index) => [
20
+ () => participant.target === undefined
21
+ ? undefined
22
+ : urlCredentialReason(`participants[${index}].target`, participant.target),
23
+ () => participant.entry === undefined
24
+ ? undefined
25
+ : entryCredentialReason(`participants[${index}].entry`, participant.entry),
26
+ ])),
27
+ ];
28
+ for (const reason of reasons) {
29
+ const found = reason();
30
+ if (found !== undefined)
31
+ return found;
32
+ }
33
+ return undefined;
34
+ }
@@ -4,8 +4,9 @@
4
4
  // provider error, capture of the exact id and kill authority, and a provider-qualified receipt in
5
5
  // the run directory before the caller gets the handle. The desktop startup guard
6
6
  // (guardDesktopSandboxCreate) is installed by loadE2BDesktopModule, so a create whose desktop
7
- // startup fails has already reclaimed its handle when the error reaches the retry here, and it
8
- // reports the id to the registry before desktop startup begins.
7
+ // startup fails has already reclaimed its handle when the error reaches the retry here. The guard
8
+ // reports the id before desktop startup begins, and the id goes to the registry and the receipt
9
+ // journal at that point, so a startup error that quotes it is scrubbed from every run file.
9
10
  import { redactText, toErrorMessage } from "../../evidence/redaction.js";
10
11
  import { beginSandboxCreate, recordSandboxOwnerOnce, SandboxCreateRefusedError, sandboxOwnerTags, } from "../../run/sandbox-creates.js";
11
12
  import { appendSandboxOwner, appendSandboxReceipt } from "../../run/sandbox-receipts.js";
@@ -24,6 +25,9 @@ async function acquire(request, template) {
24
25
  const target = request.receipt;
25
26
  // Refused before any provider call once the run is stopping.
26
27
  const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId);
28
+ // The receipts written when the guard reported an id, before desktop startup. A startup error
29
+ // can quote the id and the create may never return, so the journal holds it first.
30
+ const reported = new Map();
27
31
  try {
28
32
  // The owner tags let reclaim find this sandbox on E2B even if its id never reaches this
29
33
  // process: a create that throws after E2B allocated, or a process that dies mid-create. The
@@ -34,8 +38,26 @@ async function acquire(request, template) {
34
38
  const options = owner === undefined
35
39
  ? request.options
36
40
  : { ...request.options, metadata: { ...request.options.metadata, ...owner } };
41
+ const receipt = (sandboxId) => {
42
+ if (target === null)
43
+ return Promise.resolve(false);
44
+ const { root, participantId, now = Date.now } = target;
45
+ return appendSandboxReceipt(root, {
46
+ at: new Date(now()).toISOString(),
47
+ laneId: participantId,
48
+ provider: "e2b",
49
+ sandboxId,
50
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
51
+ });
52
+ };
37
53
  if (ticket !== undefined)
38
- observeDesktopAllocation(options, (sandboxId) => ticket.created(sandboxId));
54
+ observeDesktopAllocation(options, (sandboxId) => {
55
+ ticket.created(sandboxId);
56
+ // Started synchronously, so the run's writers know the id at once. A clock that throws
57
+ // fails only this write; the receipt after create throws it again.
58
+ if (!reported.has(sandboxId))
59
+ reported.set(sandboxId, new Promise((resolve) => resolve(receipt(sandboxId))).catch(() => false));
60
+ });
39
61
  const sandbox = await withOneRetryOnTransientE2BError(() => {
40
62
  if (ticket?.stopping())
41
63
  throw new SandboxCreateRefusedError();
@@ -47,18 +69,11 @@ async function acquire(request, template) {
47
69
  ticket?.created(sandbox.sandboxId);
48
70
  const allocation = ownE2BSandbox(module, sandbox.sandboxId, ticket);
49
71
  try {
50
- if (target !== null) {
51
- const { root, participantId, now = Date.now } = target;
52
- // Best effort by contract: a failed write leaves the owner tags and the TTL as the
53
- // backstops and never fails the participant.
54
- await appendSandboxReceipt(root, {
55
- at: new Date(now()).toISOString(),
56
- laneId: participantId,
57
- provider: "e2b",
58
- sandboxId: allocation.resourceId,
59
- ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
60
- });
61
- }
72
+ // Best effort by contract: a failed write leaves the owner tags and the TTL as the
73
+ // backstops and never fails the participant. One line per sandbox: a receipt the guard's
74
+ // report wrote is not written again, so reclaim counts the sandbox once.
75
+ if (!(await reported.get(allocation.resourceId)))
76
+ await receipt(allocation.resourceId);
62
77
  }
63
78
  catch (error) {
64
79
  // The caller never receives this sandbox, so release it here. A throwing or invalid injected
@@ -69,6 +84,9 @@ async function acquire(request, template) {
69
84
  return { sandbox, allocation };
70
85
  }
71
86
  finally {
87
+ // A create that threw settles once its reported receipts are on disk, as one that returned
88
+ // does. These writes never reject.
89
+ await Promise.all(reported.values());
72
90
  ticket?.settled();
73
91
  }
74
92
  }
@@ -9,6 +9,7 @@ import { ownDesktopAllocation, } from "../desktop-session.js";
9
9
  import { runtimeDocker, runtimeExec, usesLima } from "./runtime-host.js";
10
10
  import { openLimaTunnel } from "./runtime-ssh.js";
11
11
  import { guestMediaConfigSchema } from "../../guest/media-config.js";
12
+ import { cli } from "../../cli/invocation.js";
12
13
  const docker = async (args) => (await runtimeDocker(args, {}, 60_000)).stdout.trim();
13
14
  const readLogs = async (id) => {
14
15
  const result = await runtimeDocker(["logs", "--tail", "100", id], {}, 10_000);
@@ -18,12 +19,12 @@ function checkDesktopOptions(options) {
18
19
  if (options.media !== undefined) {
19
20
  guestMediaConfigSchema.parse(options.media);
20
21
  if (options.assets.media !== true)
21
- throw new Error("This local runtime does not include media. Run humanish runtime setup --media.");
22
+ throw new Error(`This local runtime does not include media. Run ${cli("runtime setup --media")}.`);
22
23
  }
23
24
  if (options.recording !== undefined) {
24
25
  desktopRecordingConfigSchema.parse(options.recording);
25
26
  if (options.assets.media !== true)
26
- throw new Error("This runtime does not include the recorder. Run humanish runtime setup --media.");
27
+ throw new Error(`This runtime does not include the recorder. Run ${cli("runtime setup --media")}.`);
27
28
  }
28
29
  let url;
29
30
  try {
@@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { cli } from "../../cli/invocation.js";
6
7
  const exec = promisify(execFile);
7
8
  export const LIMA_INSTANCE = "humanish-runtime";
8
9
  export const usesLima = (options = {}) => (options.platform ?? process.platform) === "darwin";
@@ -86,11 +87,11 @@ export async function limaStatus(options = {}) {
86
87
  version = (await hostExec("limactl", ["--version"], options)).stdout;
87
88
  }
88
89
  catch {
89
- throw new Error("Install Lima 2.2 or newer (brew install lima), then run humanish runtime setup. Docker Desktop is not required.");
90
+ throw new Error(`Install Lima 2.2 or newer (brew install lima), then run ${cli("runtime setup")}. Docker Desktop is not required.`);
90
91
  }
91
92
  const match = /\b(\d+)\.(\d+)\.\d+/.exec(version);
92
93
  if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 2))
93
- throw new Error("Update Lima to 2.2 or newer (brew upgrade lima), then run humanish runtime setup.");
94
+ throw new Error(`Update Lima to 2.2 or newer (brew upgrade lima), then run ${cli("runtime setup")}.`);
94
95
  const output = (await hostExec("limactl", ["list", "--json"], options)).stdout.trim();
95
96
  const instance = output
96
97
  .split("\n")
@@ -104,8 +105,8 @@ export async function limaStatus(options = {}) {
104
105
  ready: instance?.status === "Running",
105
106
  exists: !!instance,
106
107
  message: instance
107
- ? "The humanish Lima host is stopped. Run humanish runtime setup to start it."
108
- : "Run humanish runtime setup to create the humanish Lima host and install the browser runtime.",
108
+ ? `The humanish Lima host is stopped. Run ${cli("runtime setup")} to start it.`
109
+ : `Run ${cli("runtime setup")} to create the humanish Lima host and install the browser runtime.`,
109
110
  };
110
111
  }
111
112
  export async function prepareLima(options = {}, progress) {
@@ -7,6 +7,7 @@ import { Readable, Transform } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
8
  import { LOCAL_MEDIA_RUNTIME_RELEASES, LOCAL_RUNTIME_RELEASES } from "./runtime-release.js";
9
9
  import { limaStatus, loadRuntimeArchive, prepareLima, runtimeArchitecture, runtimeDocker, runtimeExec, usesLima, } from "./runtime-host.js";
10
+ import { cli } from "../../cli/invocation.js";
10
11
  const docker = async (args, options) => (await runtimeDocker(args, options)).stdout.trim();
11
12
  const runtimeRelease = (options) => options.release ??
12
13
  (options.media ? LOCAL_MEDIA_RUNTIME_RELEASES : LOCAL_RUNTIME_RELEASES)[runtimeArchitecture(options) ?? "amd64"];
@@ -80,7 +81,7 @@ export async function localRuntimeStatus(options = {}) {
80
81
  installed: false,
81
82
  message: lima
82
83
  ? "Docker is unavailable inside the humanish Lima host. Check limactl shell humanish-runtime -- sudo systemctl status docker; Docker Desktop is not used."
83
- : "Docker is unavailable. Install/start Docker Engine and give your account access, then run humanish runtime setup.",
84
+ : `Docker is unavailable. Install/start Docker Engine and give your account access, then run ${cli("runtime setup")}.`,
84
85
  };
85
86
  }
86
87
  const release = runtimeRelease(options);
@@ -108,7 +109,7 @@ export async function localRuntimeStatus(options = {}) {
108
109
  : {
109
110
  ok: true,
110
111
  installed: false,
111
- message: "Local runtime will download before the first live run. Run humanish runtime setup to prepare it now.",
112
+ message: `Local runtime will download before the first live run. Run ${cli("runtime setup")} to prepare it now.`,
112
113
  };
113
114
  }
114
115
  const image = images[0];
@@ -161,7 +162,7 @@ export async function prepareLocalRuntime(options = {}) {
161
162
  try {
162
163
  const response = await fetch(release.url, { signal });
163
164
  if (!response.ok || !response.body)
164
- throw new Error(`Runtime download failed (HTTP ${response.status}). Run humanish runtime setup to retry.`);
165
+ throw new Error(`Runtime download failed (HTTP ${response.status}). Run ${cli("runtime setup")} to retry.`);
165
166
  let bytes = 0;
166
167
  const hash = createHash("sha256");
167
168
  await pipeline(Readable.fromWeb(response.body), new Transform({
@@ -181,7 +182,7 @@ export async function prepareLocalRuntime(options = {}) {
181
182
  await loadRuntimeArchive(archive, { ...options, signal });
182
183
  const after = await localRuntimeStatus(options);
183
184
  if (!after.assets)
184
- throw new Error("Downloaded runtime did not provide the expected image. Run humanish runtime setup to retry.");
185
+ throw new Error(`Downloaded runtime did not provide the expected image. Run ${cli("runtime setup")} to retry.`);
185
186
  return after.assets;
186
187
  }
187
188
  finally {
@@ -12,6 +12,7 @@
12
12
  //
13
13
  // It also makes the UI testable without a terminal: a test hands `startTui` a fake index and reads
14
14
  // the frames back.
15
+ import { cli } from "../cli/invocation.js";
15
16
  /** Node version the Ink runtime requires (ink@7 declares `engines.node >= 22`). */
16
17
  export const TUI_MIN_NODE_MAJOR = 22;
17
18
  /**
@@ -41,12 +42,12 @@ export const TUI_BUNDLE_URL = new URL("../tui-app.js", import.meta.url);
41
42
  */
42
43
  export function terminalSurfaceMessage(state) {
43
44
  if (!state.supported) {
44
- return `\`humanish tui\` needs Node ${TUI_MIN_NODE_MAJOR}+ (this is ${state.nodeVersion}); every other command works here`;
45
+ return `\`${cli("tui")}\` needs Node ${TUI_MIN_NODE_MAJOR}+ (this is ${state.nodeVersion}); every other command works here`;
45
46
  }
46
47
  if (!state.bundlePresent) {
47
- return "`humanish tui` is not built in this checkout; run `pnpm build` (installed packages ship it prebuilt)";
48
+ return `\`${cli("tui")}\` is not built in this checkout; run \`pnpm build\` (installed packages ship it prebuilt)`;
48
49
  }
49
50
  return state.interactive
50
- ? "`humanish tui` opens the interactive surface for browsing studies and runs"
51
- : "`humanish tui` is the interactive screen a person uses to watch and start runs here. This shell is not a terminal, so pass it on to a person at one";
51
+ ? `\`${cli("tui")}\` opens the interactive surface for browsing studies and runs`
52
+ : `\`${cli("tui")}\` is the interactive screen a person uses to watch and start runs here. This shell is not a terminal, so pass it on to a person at one`;
52
53
  }
@@ -11,6 +11,7 @@ import { runLiveness } from "../run/run-index.js";
11
11
  import { parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
12
12
  import { RUN_STATUS_FILE } from "../run/status.js";
13
13
  import { plural } from "../run/text.js";
14
+ import { cli } from "../cli/invocation.js";
14
15
  /** The stable code every RUN_NOT_FINISHED warning starts with. */
15
16
  const RUN_NOT_FINISHED = "RUN_NOT_FINISHED";
16
17
  const GONE_STATES = new Set(["killed", "already-gone"]);
@@ -55,7 +56,7 @@ async function reclaimState(runPaths, runId) {
55
56
  // Matched by digest: a reclaim receipt names each sandbox by digest, and one written before it
56
57
  // did holds the raw id, which is digested here.
57
58
  const journaled = new Set((journal === null ? [] : parseSandboxReceipts(journal)).map((receipt) => sandboxIdDigest(receipt.sandboxId)));
58
- const command = `\`humanish reclaim --run ${runId}\``;
59
+ const command = `\`${cli(`reclaim --run ${runId}`)}\``;
59
60
  const receipt = reclaimReceipt(await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT), runId);
60
61
  if (receipt === undefined)
61
62
  return {
@@ -19,6 +19,7 @@ import { rerunLineageFindings } from "./rerun.js";
19
19
  import { sharedWorldEvidenceFindings } from "./shared-world.js";
20
20
  import { subjectStateFindings, undeclaredSubjectStateWarnings } from "./subject.js";
21
21
  import { plural } from "../run/text.js";
22
+ import { cli } from "../cli/invocation.js";
22
23
  export const VERIFY_SCHEMA = "humanish.verify-result.v1";
23
24
  // The UNSCANNED_ARTIFACT reason names this many paths and counts the rest.
24
25
  const MAX_LISTED_UNSCANNED = 10;
@@ -387,7 +388,7 @@ function buildShareSafety(args) {
387
388
  ];
388
389
  reasons.push({
389
390
  code: "RAW_SANDBOX_ID",
390
- message: `Raw sandbox ids appear in ${files.sort().join(", ")}. Runs from 0.110 keep them only in sandbox-receipts.ndjson; \`humanish export --format bundle --redact-screenshots\` writes a copy without them.`,
391
+ message: `Raw sandbox ids appear in ${files.sort().join(", ")}. Runs from 0.110 keep them only in sandbox-receipts.ndjson; \`${cli("export --format bundle --redact-screenshots")}\` writes a copy without them.`,
391
392
  });
392
393
  }
393
394
  const rawStreamIds = rawScreenshotStreamIds(args.bundle, args.redactedShapeFrames);
@@ -17,7 +17,7 @@ capture-vs-publish rule in
17
17
 
18
18
  `codex-exec` is a real dispatch key for terminal-product studies, but the exported
19
19
  descriptor `runSession` is a fail-closed compatibility entry. Live execution is
20
- owned by `runTerminalProductStudy`, which coordinates sandbox creation,
20
+ owned by `runLiveTerminalSession` (`src/routes/terminal/session.ts`), which coordinates sandbox creation,
21
21
  command-scoped runtime auth, evidence, caps, and by-id cleanup.
22
22
 
23
23
  The `pi-agent-core` and `claude-agent-sdk` descriptors, the `app` run kind and the
@@ -64,7 +64,7 @@ public `runComputerUseLoop` refuses them. The actor passes them through
64
64
  `runComputerUseLoopWithTaps`. A library caller wraps the executor's `observe` (for `url` and
65
65
  `screenshot`) or the provider's `nextTurn` (for `reasoning` and `message`).
66
66
 
67
- Flow, a host-first barrier inside `runConcurrentSharedWorld`'s fan-out:
67
+ Flow, a host-first barrier inside the shared-world route's fan-out:
68
68
 
69
69
  1. **Designated host.** Exactly one roster entry carries `host: true` (validated). Its mission = create
70
70
  the shared lobby; its browser opens `subject.appUrl`.
@@ -1,7 +1,7 @@
1
1
  # Terminal-product route
2
2
 
3
3
  The live terminal-product route has shipped since `0.8.0`, with the in-sandbox runtime,
4
- command-scoped credential placement, exact-id cleanup proof, an interventions ledger, a cost and
4
+ explicit credential placement (an E2B egress proxy by default, or command-scoped), exact-id cleanup proof, an interventions ledger, a cost and
5
5
  no-spend ledger, caps, and product scoring and feedback hooks. The
6
6
  [original plan](https://github.com/danielgwilson/humanish/blob/main/docs/history/goals/terminal-product-lane/goal.md)
7
7
  holds the build order and the safety contract.
@@ -24,19 +24,19 @@ fail-closed cross-validation, and forward-declared warnings.
24
24
 
25
25
  ## The composition
26
26
 
27
- | Axis | Value |
28
- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
- | `subject.source` | `terminal-product` |
30
- | `subject.product` | `{ name, publicSurfaces[], install?, workdir?, upload? }`: the only world the agent sees |
31
- | `execution.target` | `e2b-terminal` (or absent → implied) |
32
- | `execution.terminal` | `{ transport: exec-stream, stdin: disabled }` |
33
- | `execution.runtimeAuth` | `openai-env` (default) or opt-in `openai-egress`; names-only durable evidence |
34
- | `execution.runtime.version` | Optional exact `@openai/codex` version; observed before keyed execution |
35
- | `actor.model` / `reasoningEffort` | Passed to Codex as `--model` (default `gpt-5.6-sol`) and `-c model_reasoning_effort`; declarations, not observed provider identity |
36
- | `caps` | `{ maxUsd, maxJobs, maxMinutes }`: the blast-radius budget; `maxUsd > 0` is refused: no adopter cost source exists until issue 347 lands |
37
- | `policies` | `allowPrivateRepoAccess` / `allowProviderCredentials` / `allowPaymentCredentials` / `allowGitHubMutation`, all default false |
38
- | `actor.type` | `codex-exec`: a registered terminal actor (`keyPlacement: in-sandbox-command-scoped`) |
39
- | route | `terminal` → `runTerminalProductStudy` ([`src/routes/terminal/route.ts`](https://github.com/danielgwilson/humanish/blob/main/src/routes/terminal/route.ts)) |
27
+ | Axis | Value |
28
+ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | `subject.source` | `terminal-product` |
30
+ | `subject.product` | `{ name, publicSurfaces[], install?, workdir?, upload? }`: the only world the agent sees |
31
+ | `execution.target` | `e2b-terminal` (or absent → implied) |
32
+ | `execution.terminal` | `{ transport: exec-stream, stdin: disabled }` |
33
+ | `execution.runtimeAuth` | `openai-egress` (default) or opt-in `openai-env`; names-only durable evidence |
34
+ | `execution.runtime.version` | Optional exact `@openai/codex` version; observed before keyed execution |
35
+ | `actor.model` / `reasoningEffort` | Passed to Codex as `--model` (default `gpt-5.6-sol`) and `-c model_reasoning_effort`; declarations, not observed provider identity |
36
+ | `caps` | `{ maxUsd, maxJobs, maxMinutes }`: the blast-radius budget; `maxUsd > 0` is refused: no adopter cost source exists until issue 347 lands |
37
+ | `policies` | `allowPrivateRepoAccess` / `allowProviderCredentials` / `allowPaymentCredentials` / `allowGitHubMutation`, all default false |
38
+ | `actor.type` | `codex-exec`: a registered terminal actor (`keyPlacement: in-sandbox-command-scoped`) |
39
+ | route | `terminal` → `admitTerminalPlan` ([`src/routes/terminal/route.ts`](https://github.com/danielgwilson/humanish/blob/main/src/routes/terminal/route.ts)) |
40
40
 
41
41
  Routing is `routeOf` (`src/study/plan.ts`). It sends every `terminal-product` subject to
42
42
  this route, even with an unregistered actor, so this route refuses the actor.
@@ -86,11 +86,8 @@ product's no-spend boundary still needs separate interpretation.
86
86
 
87
87
  ## Runtime auth: raw-key placement and remaining provider access
88
88
 
89
- `execution.runtimeAuth: openai-env` remains the compatible default. It supplies
90
- `CODEX_API_KEY` command-scoped to Codex, with `OPENAI_API_KEY` also supplied when
91
- that was the host source. Child processes can read and use the raw key.
92
-
93
- Opt in to keeping the raw key outside the sandbox:
89
+ `execution.runtimeAuth: openai-egress` is the default: a study that declares no
90
+ `runtimeAuth` keeps the raw key outside the sandbox, as if it declared:
94
91
 
95
92
  ```yaml
96
93
  execution:
@@ -115,7 +112,7 @@ output and errors, including errors during sandbox creation.
115
112
  This mode supports the default OpenAI endpoint only. humanish explicitly sets Codex's
116
113
  built-in `openai` provider and `openai_base_url` to `https://api.openai.com/v1` for
117
114
  that invocation. It does not support a custom provider, proxy base URL, or regional
118
- endpoint under this mode. `openai-env` retains its existing command behavior. E2B
115
+ endpoint under this mode; a study that needs one declares `openai-env`. E2B
119
116
  header rules are a public-beta capability;
120
117
  `tests/routes/terminal/runtime-auth.test.ts` checks the local contract against the
121
118
  installed Desktop SDK (the lockfile has `@e2b/desktop` 2.4.0, resolving `e2b` 2.49.0).
@@ -136,6 +133,11 @@ requests can fail. E2B domain allowlists are routing controls rather than strict
136
133
  destination isolation on shared infrastructure. An existing exact OpenAI host
137
134
  rule is rejected instead of silently overwritten.
138
135
 
136
+ `execution.runtimeAuth: openai-env` is the opt-in alternative. It supplies
137
+ `CODEX_API_KEY` command-scoped to Codex, with `OPENAI_API_KEY` also supplied when
138
+ that was the host source. Child processes can read and use the raw key, and so can
139
+ anything the agent runs or a page it reads asks it to run.
140
+
139
141
  Evidence records the selected auth mode and the residual proxy capability.
140
142
  Resolved live actor traces use `keyPlacement: external` in `openai-egress`; the
141
143
  actor registry continues to describe the default `in-sandbox-command-scoped`
@@ -197,10 +199,10 @@ deadline.
197
199
 
198
200
  ## The original command-scoped safety contract
199
201
 
200
- The default mode **inverts** the credential-placement default of every other E2B route.
201
- On the computer-use route the model's key stays _outside_ the sandbox; here the
202
- agent-under-test runs _inside_ with a real `OPENAI_API_KEY`/`CODEX_API_KEY` and
203
- is **presumed exfiltratable**. The placement rule in invariants-and-defaults.md
202
+ The `openai-env` mode **inverts** the credential-placement default of every other E2B route.
203
+ On the computer-use route the model's key stays _outside_ the sandbox; under `openai-env` the
204
+ agent-under-test runs _inside_ with a real `OPENAI_API_KEY`/`CODEX_API_KEY` and is **presumed
205
+ exfiltratable**. The placement rule in invariants-and-defaults.md
204
206
  applies: _keys live where the keyed process runs, and nowhere else;
205
207
  blast radius is bounded by key scoping and budgets, not by hoping._
206
208
 
@@ -585,7 +585,9 @@ shareSafety:
585
585
  bundle is safe to promote into a public issue. Public promotion should branch on
586
586
  `shareSafety.status`:
587
587
 
588
- - `share_ready`: feedback draft commands may render public issue payloads;
588
+ - `share_ready`: feedback draft commands may render public issue payloads, and `observe --all --safe`
589
+ serves the run. The folder still holds `sandbox-receipts.ndjson` (raw sandbox ids) and `status.json`
590
+ (the recording pid); no Observer server serves them and bundle export omits them;
589
591
  - `local_only`: keep the run local; only supported redaction-only cases can produce a shareable derivative;
590
592
  `RAW_SANDBOX_ID` is one: a file other than `sandbox-receipts.ndjson` names one of the run's raw
591
593
  sandbox ids, which verify reads from the receipts and from any raw `providerResources[].id` in
@@ -658,6 +660,16 @@ trace references outside `screenshots/`. An unregistered
658
660
  `.mp4`, and a file or directory whose name contains `\`, are public-safety
659
661
  findings and block the run.
660
662
 
663
+ The patterns live in `src/evidence/redaction.ts`: provider key formats (OpenAI,
664
+ Anthropic, E2B, GitHub, GitLab, npm, Vercel, AWS, Google, Stripe, Slack,
665
+ SendGrid, Hugging Face), JWTs, bearer and basic `Authorization` values, private
666
+ key headers, database URLs with a password, the password in any URL's user info,
667
+ credential query and fragment parameters with a value of 16 characters or more,
668
+ credential-named upper-case variables set to a value of 16 characters or more
669
+ with a digit and a letter, an AWS secret access key next to its name, E2B URLs, and Linux,
670
+ macOS and Windows local paths. `tests/verify/secret-formats.test.ts` lists the
671
+ formats and the ordinary values that must not match.
672
+
661
673
  Before matching, the scan undoes JSON and JS escapes, percent-encoding and HTML
662
674
  character references, the decoding bundle export applies (`decodeEscapes`), and
663
675
  then JSON whitespace escapes and quoted-printable. It reads inside base64 runs of
@@ -673,9 +685,13 @@ wrapped across lines, and inside hex runs of 32 characters or more:
673
685
 
674
686
  `observer/index.html` is exempt from that base64 rule: serve renders it from
675
687
  `run.json`, export regenerates it, and it embeds the Observer's own base64 fonts.
676
- Its text is still scanned. Known limits: base64 split across separate strings,
677
- nesting deeper than three levels, and encodings a pattern scan cannot undo (such
678
- as encryption) still grade `share_ready`. `tests/verify/encoding-coverage.test.ts`
688
+ Its text is still scanned. A base64 run of up to 4096 characters is also read one
689
+ slash-separated piece at a time, and from just after each slash in its first 64
690
+ characters, so a URL path before an encoded key cannot shift its decoding. Known
691
+ limits: base64 split across separate strings, nesting deeper than three levels,
692
+ base64 that holds a slash itself and starts more than 64 characters into a longer
693
+ run, and encodings a pattern scan cannot undo (such as encryption) still grade
694
+ `share_ready`. `tests/verify/encoding-coverage.test.ts`
679
695
  records each case.
680
696
 
681
697
  Real email receiving adds `publication.restrictions: [real-communications]` and