humanish 0.110.0 → 0.110.2

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 (178) hide show
  1. package/ARCHITECTURE.md +15 -12
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +8 -6
  4. package/README.md +103 -53
  5. package/dist/actors/computer-use/loop/ending.js +4 -4
  6. package/dist/actors/local-agent/claude-participant.d.ts +74 -0
  7. package/dist/actors/local-agent/claude-participant.js +243 -0
  8. package/dist/actors/local-agent/claude-session.d.ts +16 -3
  9. package/dist/actors/local-agent/claude-session.js +64 -23
  10. package/dist/actors/local-agent/cli.d.ts +17 -0
  11. package/dist/actors/local-agent/cli.js +111 -24
  12. package/dist/actors/local-agent/readiness.d.ts +2 -2
  13. package/dist/actors/local-agent/readiness.js +20 -12
  14. package/dist/cli/commands/analyze.js +21 -5
  15. package/dist/cli/commands/observe.js +15 -2
  16. package/dist/cli/commands/run-signals.d.ts +2 -0
  17. package/dist/cli/commands/run-signals.js +106 -45
  18. package/dist/cli/commands/runs.d.ts +5 -0
  19. package/dist/cli/commands/runs.js +113 -74
  20. package/dist/cli/commands/setup.js +23 -14
  21. package/dist/cli/commands/study-route-computer-use.js +9 -3
  22. package/dist/cli/commands/study-route-open.js +1 -0
  23. package/dist/cli/commands/study-route-preview.js +1 -0
  24. package/dist/cli/commands/study-route-scripted.js +2 -0
  25. package/dist/cli/commands/study-route-shared-world.js +6 -2
  26. package/dist/cli/commands/study-route-terminal.js +2 -0
  27. package/dist/cli/commands/watch.js +14 -4
  28. package/dist/cli/doctor.d.ts +4 -1
  29. package/dist/cli/doctor.js +51 -10
  30. package/dist/cli/findings.d.ts +103 -0
  31. package/dist/cli/findings.js +317 -0
  32. package/dist/cli/first-run-path.d.ts +32 -11
  33. package/dist/cli/first-run-path.js +113 -69
  34. package/dist/cli/invocation.d.ts +7 -0
  35. package/dist/cli/invocation.js +32 -0
  36. package/dist/cli/io.d.ts +3 -1
  37. package/dist/cli/io.js +8 -15
  38. package/dist/cli/observer-follow.d.ts +20 -0
  39. package/dist/cli/observer-follow.js +36 -1
  40. package/dist/cli/orientation.js +16 -14
  41. package/dist/cli/product-sentence.d.ts +3 -3
  42. package/dist/cli/product-sentence.js +3 -3
  43. package/dist/cli/version.d.ts +1 -0
  44. package/dist/cli/version.js +11 -0
  45. package/dist/evidence/encoded-text.d.ts +9 -6
  46. package/dist/evidence/encoded-text.js +84 -19
  47. package/dist/evidence/redaction.d.ts +2 -0
  48. package/dist/evidence/redaction.js +123 -21
  49. package/dist/feedback/export-bundle.js +2 -2
  50. package/dist/keys/env-file.d.ts +4 -0
  51. package/dist/keys/env-file.js +17 -0
  52. package/dist/keys/key-resolution.js +6 -5
  53. package/dist/observer/data.d.ts +14 -1
  54. package/dist/observer/data.js +19 -9
  55. package/dist/observer/exposure.js +1 -1
  56. package/dist/observer/library.d.ts +4 -0
  57. package/dist/observer/library.js +21 -17
  58. package/dist/observer/render.js +10 -3
  59. package/dist/observer/run-routes.d.ts +2 -0
  60. package/dist/observer/run-routes.js +51 -35
  61. package/dist/observer/serve.js +2 -1
  62. package/dist/observer-app.html +3 -3
  63. package/dist/routes/computer-use/bundle.js +3 -1
  64. package/dist/routes/computer-use/fanout-records.js +1 -1
  65. package/dist/routes/computer-use/participant-execution.js +3 -1
  66. package/dist/routes/computer-use/participant-model.js +5 -2
  67. package/dist/routes/computer-use/plan.js +9 -4
  68. package/dist/routes/computer-use/preflight.js +1 -1
  69. package/dist/routes/computer-use/result.js +17 -28
  70. package/dist/routes/computer-use/single-bundle.js +7 -1
  71. package/dist/routes/scripted/bundle.js +4 -2
  72. package/dist/routes/scripted/plan.js +2 -2
  73. package/dist/routes/scripted/result.d.ts +3 -5
  74. package/dist/routes/scripted/result.js +16 -22
  75. package/dist/routes/scripted/scenario.js +4 -4
  76. package/dist/routes/shared-world/bundle-records.js +6 -1
  77. package/dist/routes/shared-world/bundle.js +2 -0
  78. package/dist/routes/shared-world/plan.js +6 -2
  79. package/dist/routes/shared-world/result.js +22 -22
  80. package/dist/routes/terminal/bundle.js +6 -3
  81. package/dist/routes/terminal/credentials.d.ts +5 -5
  82. package/dist/routes/terminal/credentials.js +14 -6
  83. package/dist/routes/terminal/dry-run.js +8 -12
  84. package/dist/routes/terminal/ledger.d.ts +3 -3
  85. package/dist/routes/terminal/ledger.js +6 -6
  86. package/dist/routes/terminal/live-finish.js +15 -13
  87. package/dist/routes/terminal/plan.js +12 -4
  88. package/dist/routes/terminal/result.d.ts +6 -7
  89. package/dist/routes/terminal/result.js +6 -22
  90. package/dist/routes/terminal/route.js +5 -4
  91. package/dist/routes/terminal/runtime-auth.d.ts +6 -0
  92. package/dist/routes/terminal/runtime-auth.js +5 -0
  93. package/dist/routes/terminal/session.js +1 -1
  94. package/dist/run/bundle-shape.d.ts +3 -1
  95. package/dist/run/bundle-shape.js +25 -1
  96. package/dist/run/bundle.d.ts +24 -0
  97. package/dist/run/display.d.ts +59 -0
  98. package/dist/run/display.js +106 -0
  99. package/dist/run/dry-run.js +13 -8
  100. package/dist/run/judge.d.ts +3 -2
  101. package/dist/run/judge.js +12 -1
  102. package/dist/run/local-only-files.d.ts +13 -0
  103. package/dist/run/local-only-files.js +35 -0
  104. package/dist/run/pricing.js +1 -1
  105. package/dist/run/projection.d.ts +0 -2
  106. package/dist/run/projection.js +0 -11
  107. package/dist/run/reclaim-outcomes.d.ts +89 -0
  108. package/dist/run/reclaim-outcomes.js +213 -0
  109. package/dist/run/reclaim.d.ts +23 -17
  110. package/dist/run/reclaim.js +213 -114
  111. package/dist/run/results.d.ts +10 -20
  112. package/dist/run/results.js +2 -2
  113. package/dist/run/run-index.d.ts +11 -1
  114. package/dist/run/run-index.js +11 -0
  115. package/dist/run/run-not-found.js +3 -2
  116. package/dist/run/run.d.ts +34 -18
  117. package/dist/run/run.js +166 -67
  118. package/dist/run/sandbox-creates.d.ts +59 -0
  119. package/dist/run/sandbox-creates.js +140 -0
  120. package/dist/run/sandbox-receipts.d.ts +20 -5
  121. package/dist/run/sandbox-receipts.js +56 -7
  122. package/dist/run/stats.d.ts +5 -0
  123. package/dist/run/stats.js +8 -4
  124. package/dist/run/status.d.ts +6 -9
  125. package/dist/run/status.js +7 -23
  126. package/dist/run/stored-runs.d.ts +5 -14
  127. package/dist/run/stored-runs.js +31 -146
  128. package/dist/run/streams.d.ts +3 -2
  129. package/dist/study/agents-section.d.ts +21 -0
  130. package/dist/study/agents-section.js +86 -0
  131. package/dist/study/doctor.d.ts +1 -1
  132. package/dist/study/doctor.js +8 -4
  133. package/dist/study/init-templates.js +0 -95
  134. package/dist/study/init.js +45 -37
  135. package/dist/study/keys.d.ts +12 -0
  136. package/dist/study/keys.js +32 -0
  137. package/dist/study/local-browser-host.d.ts +11 -0
  138. package/dist/study/local-browser-host.js +42 -0
  139. package/dist/study/parse/actors.js +9 -1
  140. package/dist/study/parse/subject.js +12 -0
  141. package/dist/study/parse/url-credentials.d.ts +9 -0
  142. package/dist/study/parse/url-credentials.js +50 -0
  143. package/dist/study/persona.d.ts +4 -0
  144. package/dist/study/persona.js +4 -2
  145. package/dist/study/preflight-probes.js +2 -1
  146. package/dist/study/types.d.ts +8 -6
  147. package/dist/study/url-credentials.d.ts +6 -0
  148. package/dist/study/url-credentials.js +34 -0
  149. package/dist/substrates/e2b/peer-install.d.ts +51 -0
  150. package/dist/substrates/e2b/peer-install.js +161 -0
  151. package/dist/substrates/e2b/sandbox.d.ts +34 -6
  152. package/dist/substrates/e2b/sandbox.js +141 -30
  153. package/dist/substrates/e2b/sdk.d.ts +33 -0
  154. package/dist/substrates/e2b/sdk.js +21 -21
  155. package/dist/substrates/local/firecracker-desktop.js +3 -2
  156. package/dist/substrates/local/runtime-host.js +5 -4
  157. package/dist/substrates/local/runtime.js +5 -4
  158. package/dist/substrates/shell.d.ts +2 -0
  159. package/dist/substrates/shell.js +4 -0
  160. package/dist/tui/contract.js +5 -4
  161. package/dist/tui-app.js +133 -133
  162. package/dist/verify/liveness.d.ts +19 -3
  163. package/dist/verify/liveness.js +47 -25
  164. package/dist/verify/verify.d.ts +3 -0
  165. package/dist/verify/verify.js +8 -3
  166. package/docs/architecture/actor-contract.md +2 -1
  167. package/docs/architecture/observer.md +22 -7
  168. package/docs/architecture/task-protocol-support.md +13 -13
  169. package/docs/architecture/terminal-product-route.md +15 -13
  170. package/docs/contracts/run-bundle.md +95 -10
  171. package/docs/contracts/schemas.md +44 -26
  172. package/docs/contracts/study-analysis.md +45 -0
  173. package/docs/contracts/study-costs.md +8 -0
  174. package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
  175. package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
  176. package/docs/principles/invariants-and-defaults.md +11 -8
  177. package/package.json +6 -3
  178. package/skills/humanish/SKILL.md +38 -20
@@ -2,11 +2,13 @@ import path from "node:path";
2
2
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
3
3
  import { localRuntimeStatus } from "../substrates/local/runtime.js";
4
4
  import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
5
+ import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
5
6
  import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
6
7
  import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
7
8
  import { externalCatchHealthy } from "../comms/sandbox-catch.js";
8
9
  import { receivingRequiredKey } from "../comms/setup.js";
9
10
  import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
11
+ import { cli } from "../cli/invocation.js";
10
12
  /** A ready row's message, with the schema values the launch recorded. */
11
13
  function readyMessage(message, readiness) {
12
14
  const additions = protocolAdditionsWarning(readiness.cliVersion, readiness.protocolAdditions);
@@ -211,7 +213,7 @@ async function localBrowserChecks(config, args) {
211
213
  ok: healthy,
212
214
  message: healthy
213
215
  ? "Recipient inbox routes are ready. Point your app's email sends at this catch; delivery remains untested. No mailbox-provider credentials are needed, and this does not receive arbitrary internet mail."
214
- : "Captured inbox is unavailable or outdated. Start or upgrade and restart humanish comms catch, then check comms.email.external.catchBaseUrl (and inboxBaseUrl if set). No participant was allocated.",
216
+ : `Captured inbox is unavailable or outdated. Start or upgrade and restart ${cli("comms catch")}, then check comms.email.external.catchBaseUrl (and inboxBaseUrl if set). No participant was allocated.`,
215
217
  });
216
218
  }
217
219
  return checks;
@@ -228,7 +230,7 @@ async function realEmailCheck(connection, keys, args) {
228
230
  ? "The selected email connection is missing or invalid. Open Connections in the TUI."
229
231
  : !args.keyPresent(name)
230
232
  ? `Missing ${name} for the selected email connection. Provide it through process env or --dotenv. Authentication has not been checked.`
231
- : "Fresh hosted inbox per participant. Local presence only; run humanish comms check --online to authenticate. Provider permissions/capacity and delivery remain untested.",
233
+ : `Fresh hosted inbox per participant. Local presence only; run ${cli("comms check --online")} to authenticate. Provider permissions/capacity and delivery remain untested.`,
232
234
  };
233
235
  }
234
236
  /** How the participant authenticates: the terminal model key, or a local agent's login. */
@@ -254,12 +256,14 @@ async function participantChecks(config, route, keys, local, args, checkAccount)
254
256
  // that the operator's config, release and model admit a launch.
255
257
  if (choice === "codex" && agent?.authStatus === "authenticated")
256
258
  return [await hostedCodexParticipantCheck(args, config.actors[0])];
259
+ const outdated = agent === undefined ? undefined : localAgentVersionRefusal(agent);
257
260
  return [
258
261
  {
259
262
  name: "local participant authentication",
260
- ok: agent?.authStatus === "authenticated",
263
+ ok: agent !== undefined && localAgentReady(agent),
261
264
  message: agent?.authStatus === "authenticated"
262
- ? `${agent.label} reports authenticated on the host. E2B supplies the desktop; no OpenAI API key is required for this participant.`
265
+ ? (outdated ??
266
+ `${agent.label}${agent.version === undefined ? "" : ` ${agent.version}`} reports authenticated on the host. E2B supplies the desktop; no OpenAI API key is required for this participant.`)
263
267
  : agent
264
268
  ? `${agent.label} ${agent.authStatus === "unauthenticated" ? "reports not signed in" : "authentication could not be checked"}. Run \`${choice === "codex" ? "codex login status" : "claude auth status"}\`; sign in or update the CLI before running.`
265
269
  : `${choice} is not on this process's PATH. Install and sign in to that CLI, or choose openai-computer-use with OPENAI_API_KEY.`,
@@ -194,24 +194,6 @@ steps:
194
194
  expectation: The user sees a clear next state.
195
195
  - name: Capture review notes
196
196
  expectation: Notes are public-safe and evidence-backed.
197
- `,
198
- },
199
- {
200
- path: "humanish/scenarios/onboarding-regression.yaml",
201
- plane: "source",
202
- contents: `schema: humanish.scenario.v1
203
- id: onboarding-regression
204
- title: Onboarding regression
205
- persona: skeptical-power-user
206
- goal: Exercise onboarding friction using synthetic inputs and explicit recovery checks.
207
- mode: dry-run
208
- steps:
209
- - name: Start onboarding
210
- expectation: Required information is clear.
211
- - name: Use synthetic fixture data
212
- expectation: No real user data is entered.
213
- - name: Check recovery path
214
- expectation: The user can back out or retry safely.
215
197
  `,
216
198
  },
217
199
  {
@@ -308,83 +290,6 @@ execution:
308
290
  # allowPublicTargets: true # required to drive a non-loopback app-url (a deployment you own)
309
291
  defaults:
310
292
  open: true
311
- `,
312
- },
313
- {
314
- path: "humanish/studies/lobby-trivia-3player.yaml",
315
- plane: "source",
316
- contents: `schema: humanish.study.v3
317
- id: lobby-trivia-3player
318
- title: "Shared world on a public app: three mobile participants in one lobby"
319
- description: >-
320
- A worked example of the external-public shared-world route: three participants on mobile layouts
321
- play the same multiplayer lobby on a public deployment at the same time. The public site is the
322
- shared plane, so there is no clone, subject sandbox or seed. The host participant creates the
323
- lobby, humanish reads the session code from the host's page URL, and the other participants get
324
- it in their missions and join through the app's own join flow. Starts as a dry run, which writes
325
- one evidence bundle at no cost. A live run needs OPENAI_API_KEY, E2B_API_KEY and mode: live. Point appUrl and publicTarget at a public deployment you own or operate. Mobile layout:
326
- Chrome's physical window is at least 500px wide and has no touch emulation, desktop pixel ratio
327
- and a desktop user agent. Set execution.desktop.fidelity.mobileEmulation to true to request the
328
- preset's CSS viewport, touch, pixel ratio and mobile user agent on Chrome or Chromium; the bundle
329
- records the measured geometry and emulation fidelity. A run on a public site proves less than a
330
- provisioned one: its provenance is external-public, it carries no synthetic-data attestation and
331
- no authoritative shared-state proof, and concurrency rests on overlapping sessions and every
332
- participant reaching the same lobby.
333
- route: shared-world
334
- mode: dry-run # a live run opens three mobile-layout desktops against the public app
335
- subject:
336
- source: app-url # the public deployment is the shared plane: no clone and no getHost
337
- appUrl: https://your-public-app.example/ # a deployment you own or operate
338
- publicTarget: # required: your attestation that you own or operate this deployment
339
- owner: your-org/your-app
340
- authorized: true
341
- policies:
342
- allowPublicTargets: true # required: the shared plane is a public, non-loopback deployment
343
- actor:
344
- type: openai-computer-use
345
- mission: You are one of several users on the same shared session at once. Play your role, then stop.
346
- participants: # at least two participants, exactly one with host: true
347
- - id: host
348
- host: true # this participant creates the shared session
349
- device: mobile # 414x896 preset; physical window width floors to 500 (see emulation above)
350
- instruction: Create a shared lobby, wait for the others to join, then start and play.
351
- - id: player-2
352
- device: mobile
353
- instruction: Join the lobby you're told the code for, then play.
354
- - id: player-3
355
- device: small-mobile
356
- instruction: Join the lobby you're told the code for, then play.
357
- execution:
358
- target: e2b-desktop
359
- # Each participant's session budget, which also bounds the host's handoff deadline. It has to
360
- # cover provisioning, joining the shared session and play; 420000 ms (7 minutes) does.
361
- timeoutMs: 420000
362
- concurrency: 3 # all three participants at once, the default; lower it only to limit paid desktops
363
- # desktop:
364
- # fidelity: { mobileEmulation: true } # opt into mobile CSS viewport, touch, DPR and user agent
365
- defaults:
366
- open: true
367
- `,
368
- },
369
- {
370
- path: "humanish/coverage-map.md",
371
- plane: "source",
372
- contents: `# Coverage Map
373
-
374
- This file should enumerate screens, roles, states, and paths before scenarios are treated as complete.
375
-
376
- Current starter coverage is intentionally minimal and synthetic.
377
- `,
378
- },
379
- {
380
- path: "humanish/coverage-matrix.md",
381
- plane: "source",
382
- contents: `# Coverage Matrix
383
-
384
- | Area | Persona | Happy Path | Sad Path | Status |
385
- | --- | --- | --- | --- | --- |
386
- | First run | synthetic-new-user | planned | planned | starter |
387
- | Onboarding | skeptical-power-user | planned | planned | starter |
388
293
  `,
389
294
  },
390
295
  ];
@@ -1,10 +1,14 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { AGENTS_SECTION_MARKER, agentsSection, firstRunGuidance, starterActorFor, starterLocalAgentFor, } from "../cli/first-run-path.js";
3
+ import { agentsSection, firstRunGuidance, starterActorFor, starterLocalAgentFor, } from "../cli/first-run-path.js";
4
4
  import { detectLocalAgents } from "../actors/local-agent/cli.js";
5
+ import { humanishCommand } from "../cli/invocation.js";
6
+ import { replaceAgentsSection } from "./agents-section.js";
7
+ import { probeLocalBrowserHost } from "./local-browser-host.js";
5
8
  import { probeKeySources } from "../keys/key-resolution.js";
6
9
  import { DEFAULT_LOCAL_BROWSER_STARTER, runtimeDirectories, starterFilesFor, } from "./init-templates.js";
7
10
  import { assertPreparedSelectedOutputDirectory, prepareContainedOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, } from "../run/contained-output.js";
11
+ import { desktopPeerAdvice, humanishInstall, } from "../substrates/e2b/peer-install.js";
8
12
  import { validateCwd } from "../run/project.js";
9
13
  import { pathExists, readTextIfExists, validateInitProjectPaths } from "./init-paths.js";
10
14
  import { planGitignore, planPackageJson } from "./init-plan.js";
@@ -37,11 +41,11 @@ export async function runInit(options) {
37
41
  if (initialPathCheck) {
38
42
  return failed(initialPathCheck);
39
43
  }
40
- await planAgentsFile(preparedProjectRoot, cwd, plan);
41
44
  // The starter live study is written for the brain this machine can actually use. Shipping it as
42
45
  // openai-computer-use on a machine with no provider key but a signed-in Codex would hand someone
43
46
  // a file that asks for a credential they were just told they do not need.
44
47
  const machine = await firstRunEnvironment(options.env ?? process.env, requestedCwd);
48
+ await planAgentsFile(preparedProjectRoot, cwd, plan, humanishCommand());
45
49
  const starterActor = starterActorFor(machine);
46
50
  await planStarterFiles(preparedProjectRoot, cwd, starterFilesFor(starterActor, localBrowser.value, starterLocalAgentFor(machine)), options.localBrowser !== undefined, plan);
47
51
  await planRuntimeDirectories(preparedProjectRoot, cwd, plan);
@@ -87,47 +91,40 @@ export async function runInit(options) {
87
91
  // `humanish init` is a coding agent doing setup on someone's behalf. Without this, the agent
88
92
  // that arrives tomorrow finds a humanish/ directory and no idea what to do with it.
89
93
  //
90
- // Append-only and idempotent: an existing `AGENTS.md` is a file someone wrote, so humanish adds its
91
- // own section once and never rewrites theirs.
92
- async function planAgentsFile(preparedProjectRoot, cwd, plan) {
94
+ // An existing `AGENTS.md` is a file someone wrote. init appends its own section once, and on a
95
+ // rerun replaces only that section, between its two markers, so the commands match the installed
96
+ // humanish. Every byte outside the markers stays as it was.
97
+ async function planAgentsFile(preparedProjectRoot, cwd, plan, humanish) {
93
98
  const agentsPath = "AGENTS.md";
94
99
  const existingAgents = await readTextIfExists(preparedProjectRoot, agentsPath);
95
- const section = agentsSection();
96
- if (existingAgents === null) {
97
- plan.changes.push({
98
- path: agentsPath,
99
- action: "create",
100
- target: "source",
101
- reason: "how a coding agent runs humanish here",
102
- });
100
+ const section = agentsSection(humanish);
101
+ const write = (contents, action, reason) => {
102
+ plan.changes.push({ path: agentsPath, action, target: "source", reason });
103
103
  plan.writes.push({
104
104
  absolutePath: path.join(cwd, agentsPath),
105
105
  relativePath: agentsPath,
106
- contents: `# AGENTS.md\n${section}`,
106
+ contents,
107
107
  target: "source",
108
108
  });
109
+ };
110
+ const skip = (reason) => plan.changes.push({ path: agentsPath, action: "skip", target: "source", reason });
111
+ if (existingAgents === null) {
112
+ write(`# AGENTS.md\n${section}`, "create", "how a coding agent runs humanish here");
113
+ return;
109
114
  }
110
- else if (existingAgents.includes(AGENTS_SECTION_MARKER)) {
111
- plan.changes.push({
112
- path: agentsPath,
113
- action: "skip",
114
- target: "source",
115
- reason: "humanish section already present",
116
- });
115
+ const replaced = replaceAgentsSection(existingAgents, section);
116
+ if (replaced.kind === "absent") {
117
+ write(`${existingAgents.replace(/\s*$/, "")}\n${section}`, "update", "append how a coding agent runs humanish");
118
+ }
119
+ else if (replaced.kind === "unknown") {
120
+ skip("humanish section edited by hand");
121
+ plan.warnings.push(`Left the humanish section of ${agentsPath} unchanged: it has no end marker and does not match a version init wrote, so it may hold your edits. To get the current section, delete it from the "## humanish" heading to the end of the section and run init again.`);
122
+ }
123
+ else if (replaced.contents === existingAgents) {
124
+ skip("humanish section is current");
117
125
  }
118
126
  else {
119
- plan.changes.push({
120
- path: agentsPath,
121
- action: "update",
122
- target: "source",
123
- reason: "append how a coding agent runs humanish",
124
- });
125
- plan.writes.push({
126
- absolutePath: path.join(cwd, agentsPath),
127
- relativePath: agentsPath,
128
- contents: `${existingAgents.replace(/\s*$/, "")}\n${section}`,
129
- target: "source",
130
- });
127
+ write(replaced.contents, "update", "refresh the humanish section");
131
128
  }
132
129
  }
133
130
  /** Create each starter file that is missing; never overwrite one that exists. */
@@ -270,6 +267,18 @@ function validateLocalBrowserStarter(input) {
270
267
  return { ok: false, message: "--local-mission must be 4,000 characters or fewer." };
271
268
  return { ok: true, value };
272
269
  }
270
+ /**
271
+ * The first-run hint's peer install, ahead of `npx humanish`: beside humanish in its project, else
272
+ * humanish and the peer together here, where `npx humanish` then finds them. None where `npm i`
273
+ * would prune a node_modules that no package.json declares.
274
+ */
275
+ function initDesktopPeerCommand(install) {
276
+ if (install.kind === "project")
277
+ return desktopPeerAdvice(install).command;
278
+ if ((install.kind === "one-shot" || install.kind === "other") && install.unmanagedCwd)
279
+ return undefined;
280
+ return "npm i -D humanish @e2b/desktop";
281
+ }
273
282
  /**
274
283
  * What this machine can run, for the starter study and the next steps. Local CLI status is
275
284
  * classified without returning its output or reading its credential file. Provider keys go
@@ -297,18 +306,17 @@ async function firstRunEnvironment(env, cwd) {
297
306
  catch {
298
307
  hasDesktopSdk = false;
299
308
  }
300
- const { fileURLToPath } = await import("node:url");
301
- const here = fileURLToPath(import.meta.url);
302
309
  return {
303
- installedInProject: here.startsWith(path.join(process.cwd(), "node_modules") + path.sep),
310
+ desktopPeerCommand: initDesktopPeerCommand(humanishInstall()),
304
311
  hasDesktopSdk,
305
312
  hasE2bKey: present("E2B_API_KEY"),
306
313
  hasProviderKey: present("OPENAI_API_KEY"),
307
314
  localAgents: agents
308
315
  .filter((agent) => agent.authStatus === "authenticated")
309
316
  .map((agent) => ({ id: agent.id, label: agent.label })),
317
+ localBrowserHost: await probeLocalBrowserHost(),
310
318
  platform: process.platform,
311
- arch: process.arch,
319
+ humanish: humanishCommand(),
312
320
  };
313
321
  }
314
322
  function getMode(options) {
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Whether a dotted key path, such as `caps.maxUsd` or `participants[0].persona`, names a key a
3
+ * humanish.study.v3 file can set. A path below a leaf is not one: `scenario.ref`, where `scenario`
4
+ * is a string, and `review.analysis.question`, whose keys resolveAutomaticAnalysis checks.
5
+ */
6
+ export declare function isStudyKeyPath(path: string): boolean;
7
+ /**
8
+ * The dotted path of every key a humanish.study.v3 file can set, down to `depth` levels:
9
+ * `route`, `caps.maxUsd`, `participants.persona`. scripts/check-doc-study-fields.ts holds the
10
+ * study file reference to these paths.
11
+ */
12
+ export declare function studyKeyPaths(depth: number): string[];
1
13
  /** The first key in `raw` that V2_KEYS does not list, as an error message; undefined if none. */
2
14
  export declare function findUnknownV2Key(raw: unknown): string | undefined;
3
15
  /** The first key in a humanish.study.v3 document that the format does not have; undefined if none. */
@@ -230,6 +230,38 @@ const STUDY_KEYS = {
230
230
  defaults: V2_KEYS.defaults,
231
231
  comms: V2_KEYS.comms,
232
232
  };
233
+ /**
234
+ * Whether a dotted key path, such as `caps.maxUsd` or `participants[0].persona`, names a key a
235
+ * humanish.study.v3 file can set. A path below a leaf is not one: `scenario.ref`, where `scenario`
236
+ * is a string, and `review.analysis.question`, whose keys resolveAutomaticAnalysis checks.
237
+ */
238
+ export function isStudyKeyPath(path) {
239
+ let shape = STUDY_KEYS;
240
+ for (const segment of path.replaceAll(/\[\d*\]/g, "").split(".")) {
241
+ if (shape === true || !Object.hasOwn(shape, segment))
242
+ return false;
243
+ shape = shape[segment];
244
+ }
245
+ return true;
246
+ }
247
+ /**
248
+ * The dotted path of every key a humanish.study.v3 file can set, down to `depth` levels:
249
+ * `route`, `caps.maxUsd`, `participants.persona`. scripts/check-doc-study-fields.ts holds the
250
+ * study file reference to these paths.
251
+ */
252
+ export function studyKeyPaths(depth) {
253
+ const paths = [];
254
+ const visit = (shape, prefix, level) => {
255
+ for (const [key, child] of Object.entries(shape)) {
256
+ const path = prefix ? `${prefix}.${key}` : key;
257
+ paths.push(path);
258
+ if (child !== true && level < depth)
259
+ visit(child, path, level + 1);
260
+ }
261
+ };
262
+ visit(STUDY_KEYS, "", 1);
263
+ return paths;
264
+ }
233
265
  /** The first key in `raw` that V2_KEYS does not list, as an error message; undefined if none. */
234
266
  export function findUnknownV2Key(raw) {
235
267
  return walk(raw, V2_KEYS, "", "study");
@@ -0,0 +1,11 @@
1
+ import type { LocalBrowserHost } from "../cli/first-run-path.js";
2
+ export interface LocalBrowserHostProbe {
3
+ platform: NodeJS.Platform;
4
+ arch: string;
5
+ env: NodeJS.ProcessEnv;
6
+ /** Resolves when `file` can be opened with `mode`; rejects otherwise. */
7
+ access: (file: string, mode: number) => Promise<void>;
8
+ /** The Mac's chip name, as `sysctl -n machdep.cpu.brand_string` prints it. */
9
+ macChip: () => Promise<string>;
10
+ }
11
+ export declare function probeLocalBrowserHost(probe?: LocalBrowserHostProbe): Promise<LocalBrowserHost>;
@@ -0,0 +1,42 @@
1
+ // init's quick read of whether this host can run a local browser study, so its next steps never
2
+ // send someone to a route the host cannot run. It reads devices, `PATH` and the Mac chip name only;
3
+ // it starts no container and no VM. `doctor --study local-browser` runs the full check, including
4
+ // whether Docker is rootful and local.
5
+ import { execFile } from "node:child_process";
6
+ import { constants } from "node:fs";
7
+ import { access } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { promisify } from "node:util";
10
+ const exec = promisify(execFile);
11
+ const defaultProbe = () => ({
12
+ platform: process.platform,
13
+ arch: process.arch,
14
+ env: process.env,
15
+ access,
16
+ macChip: async () => (await exec("sysctl", ["-n", "machdep.cpu.brand_string"], { timeout: 5_000 })).stdout,
17
+ });
18
+ export async function probeLocalBrowserHost(probe = defaultProbe()) {
19
+ const can = async (file, mode) => probe.access(file, mode).then(() => true, () => false);
20
+ if (probe.platform === "linux" && probe.arch === "x64") {
21
+ // The same device checks doctor makes. The rootful Docker daemon opens these devices, so this
22
+ // user needs no access to them.
23
+ if (!(await can("/dev/kvm", constants.F_OK)))
24
+ return { ok: false, reason: "/dev/kvm is missing" };
25
+ if (!(await can("/dev/net/tun", constants.F_OK)))
26
+ return { ok: false, reason: "/dev/net/tun is missing" };
27
+ const dirs = (probe.env.PATH ?? "").split(path.delimiter).filter(Boolean);
28
+ for (const dir of dirs)
29
+ if (await can(path.join(dir, "docker"), constants.X_OK))
30
+ return { ok: true };
31
+ return { ok: false, reason: "the docker command is not on the search path" };
32
+ }
33
+ if (probe.platform === "darwin" && probe.arch === "arm64") {
34
+ // The local route needs nested virtualization, which Apple added with the M3.
35
+ const chip = await probe.macChip().catch(() => "");
36
+ const generation = /^Apple M(\d+)/.exec(chip.trim());
37
+ if (generation !== null && Number(generation[1]) >= 3)
38
+ return { ok: true };
39
+ return { ok: false, reason: "they need an M3 or newer Mac" };
40
+ }
41
+ return { ok: false, reason: "they need Linux x64 or an M3-or-newer Mac" };
42
+ }
@@ -2,6 +2,7 @@ import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
2
2
  import { isReasoningEffort, reasoningEffortNames } from "../../actors/reasoning-effort.js";
3
3
  import { isMaxOutputTokens } from "../../actors/output-token-limit.js";
4
4
  import { isHttpUrl } from "./subject.js";
5
+ import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js";
5
6
  import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
6
7
  import { invalid, posInt, str } from "./values.js";
7
8
  import { isRecord } from "../../run/type-guards.js";
@@ -265,13 +266,20 @@ function parseParticipantEntries(raw, actorIndex) {
265
266
  if (!isHttpUrl(target)) {
266
267
  return invalid(`actors[${actorIndex}].lanes[${entryIndex}].target must be an absolute http(s) URL.`);
267
268
  }
269
+ const credential = urlCredentialReason(`actors[${actorIndex}].lanes[${entryIndex}].target`, target);
270
+ if (credential)
271
+ return invalid(credential);
268
272
  parsedEntry.target = target;
269
273
  }
270
274
  // `entry` is shape-captured here; the same-origin-with-serve.url check needs serve context, so
271
275
  // it runs in sharedWorldValidationReason (where the route + serve.url are known).
272
276
  const entryPath = str(entry.entry);
273
- if (entryPath !== undefined)
277
+ if (entryPath !== undefined) {
278
+ const credential = entryCredentialReason(`actors[${actorIndex}].lanes[${entryIndex}].entry`, entryPath);
279
+ if (credential)
280
+ return invalid(credential);
274
281
  parsedEntry.entry = entryPath;
282
+ }
275
283
  // `host` marks the designated host on the external-public shared-world route; the
276
284
  // exactly-one-host check runs in externalPublicSharedWorldValidationReason (route context).
277
285
  if (entry.host !== undefined) {
@@ -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.");
@@ -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
+ }
@@ -26,6 +26,10 @@ export interface PersonaDirectives {
26
26
  /** Directives included in the prompt, not proof of behavioral adherence. */
27
27
  traitsApplied: string[];
28
28
  }
29
+ /** The fields a persona file can set; any other warns and is not sent. The study file reference lists each. */
30
+ export declare const PERSONA_FIELDS: Set<string>;
31
+ /** The traits under `traits:` that become directives; any other warns and is not sent. */
32
+ export declare const TRAIT_FIELDS: Set<string>;
29
33
  /** Legacy short fields keep their bounds, but every discarded value is diagnosed. */
30
34
  export declare function parseResolvedPersona(raw: unknown, fallback: {
31
35
  id: string;
@@ -9,7 +9,8 @@ export class PersonaConfigError extends Error {
9
9
  }
10
10
  }
11
11
  const LEVELS = ["low", "medium", "high"];
12
- const PERSONA_FIELDS = new Set([
12
+ /** The fields a persona file can set; any other warns and is not sent. The study file reference lists each. */
13
+ export const PERSONA_FIELDS = new Set([
13
14
  "schema",
14
15
  "id",
15
16
  "name",
@@ -18,7 +19,8 @@ const PERSONA_FIELDS = new Set([
18
19
  "traits",
19
20
  "constraints",
20
21
  ]);
21
- const TRAIT_FIELDS = new Set(["patience", "technical_confidence", "accessibility_needs"]);
22
+ /** The traits under `traits:` that become directives; any other warns and is not sent. */
23
+ export const TRAIT_FIELDS = new Set(["patience", "technical_confidence", "accessibility_needs"]);
22
24
  function cleanText(value) {
23
25
  return value
24
26
  .replace(/\r\n?/g, "\n")
@@ -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;