humanish 0.111.0 → 0.111.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 (99) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CONTEXT.md +1 -1
  3. package/dist/actors/codex/restricted-participant-policy.d.ts +2 -0
  4. package/dist/actors/codex/restricted-participant-policy.js +30 -2
  5. package/dist/actors/codex/restricted-participant.js +1 -1
  6. package/dist/actors/computer-use/executor-diagnostic.d.ts +15 -0
  7. package/dist/actors/computer-use/executor-diagnostic.js +105 -0
  8. package/dist/actors/computer-use/executor-error.d.ts +18 -3
  9. package/dist/actors/computer-use/executor-error.js +30 -4
  10. package/dist/actors/computer-use/loop/actions.d.ts +6 -3
  11. package/dist/actors/computer-use/loop/actions.js +27 -7
  12. package/dist/actors/computer-use/loop/ending.js +9 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +14 -0
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +1 -1
  16. package/dist/actors/computer-use/openai-context.d.ts +8 -2
  17. package/dist/actors/computer-use/openai-context.js +91 -26
  18. package/dist/actors/computer-use/openai-provider.js +55 -12
  19. package/dist/actors/computer-use/openai-requests.d.ts +2 -1
  20. package/dist/actors/computer-use/openai-requests.js +4 -1
  21. package/dist/actors/contract.d.ts +7 -1
  22. package/dist/browser-control/client.js +5 -5
  23. package/dist/browser-control/protocol.d.ts +30 -1
  24. package/dist/browser-control/protocol.js +19 -2
  25. package/dist/browser-control/transport.d.ts +3 -2
  26. package/dist/browser-control/transport.js +12 -7
  27. package/dist/cli/commands/study-route-computer-use.js +2 -2
  28. package/dist/cli/commands/study-route-shared-world.js +2 -2
  29. package/dist/cli/commands/study.js +1 -2
  30. package/dist/cli/doctor.js +11 -0
  31. package/dist/evidence/encoded-text.d.ts +3 -3
  32. package/dist/evidence/encoded-text.js +10 -5
  33. package/dist/evidence/redaction.d.ts +2 -2
  34. package/dist/evidence/redaction.js +18 -7
  35. package/dist/guest/browser-tools.js +5 -1
  36. package/dist/guest/chromium-text.js +42 -18
  37. package/dist/guest/desktop-executor.js +5 -1
  38. package/dist/keys/key-resolution.d.ts +3 -0
  39. package/dist/keys/key-resolution.js +15 -2
  40. package/dist/observer-app.html +6 -6
  41. package/dist/routes/computer-use/bundle-parts.d.ts +4 -0
  42. package/dist/routes/computer-use/bundle-parts.js +7 -2
  43. package/dist/routes/computer-use/fanout-bundle.js +4 -2
  44. package/dist/routes/computer-use/local-vm.js +1 -2
  45. package/dist/routes/computer-use/participant-model.js +6 -1
  46. package/dist/routes/computer-use/plan.js +3 -4
  47. package/dist/routes/computer-use/route.js +1 -2
  48. package/dist/routes/computer-use/setup.js +2 -2
  49. package/dist/routes/preview.js +1 -0
  50. package/dist/routes/scripted/plan.js +4 -5
  51. package/dist/routes/scripted/route.js +2 -2
  52. package/dist/routes/shared-world/plan.js +4 -5
  53. package/dist/routes/shared-world/route.js +2 -2
  54. package/dist/routes/shared-world/setup.js +1 -0
  55. package/dist/routes/terminal/dry-run.js +1 -0
  56. package/dist/routes/terminal/live-finish.js +24 -5
  57. package/dist/routes/terminal/live-sandbox.js +5 -1
  58. package/dist/routes/terminal/plan.js +3 -4
  59. package/dist/routes/terminal/recorder.d.ts +6 -0
  60. package/dist/routes/terminal/recorder.js +61 -6
  61. package/dist/routes/terminal/route.js +1 -2
  62. package/dist/routes/terminal/session.js +2 -1
  63. package/dist/routes/terminal/token-usage.d.ts +3 -0
  64. package/dist/routes/terminal/token-usage.js +5 -0
  65. package/dist/routes/terminal/types.d.ts +2 -1
  66. package/dist/routes/terminal/types.js +2 -1
  67. package/dist/run/dry-run.js +1 -0
  68. package/dist/run/results.d.ts +2 -0
  69. package/dist/run/run.d.ts +2 -0
  70. package/dist/run/run.js +16 -2
  71. package/dist/run/terminal-contract.d.ts +10 -0
  72. package/dist/run/terminal-contract.js +27 -9
  73. package/dist/study/composition-rules.js +17 -18
  74. package/dist/study/doctor.js +4 -5
  75. package/dist/study/migrate/v2.d.ts +5 -0
  76. package/dist/study/migrate/v2.js +23 -0
  77. package/dist/study/parse/front.js +2 -21
  78. package/dist/study/parse/url-credentials.d.ts +3 -3
  79. package/dist/study/parse/url-credentials.js +155 -13
  80. package/dist/study/persona-resolve.js +2 -2
  81. package/dist/study/plan-base.js +3 -4
  82. package/dist/study/plan-participants.js +3 -3
  83. package/dist/study/plan-types.d.ts +2 -0
  84. package/dist/study/plan.js +11 -5
  85. package/dist/study/routing.js +11 -11
  86. package/dist/study/run-study-options.js +2 -2
  87. package/dist/study/study-fields.d.ts +1 -13
  88. package/dist/study/study-fields.js +0 -24
  89. package/dist/study/summary.js +7 -7
  90. package/dist/study/types.d.ts +1 -0
  91. package/dist/study/validation.js +11 -11
  92. package/dist/study/warnings.d.ts +8 -0
  93. package/dist/study/warnings.js +22 -7
  94. package/dist/substrates/local/runtime-config.js +6 -6
  95. package/docs/architecture/browser-control.md +12 -2
  96. package/docs/architecture/guest-desktop.md +7 -1
  97. package/docs/architecture/terminal-product-route.md +3 -2
  98. package/docs/contracts/schemas.md +16 -5
  99. package/package.json +1 -1
@@ -67,6 +67,7 @@ async function runDryRunInScope(options, scope) {
67
67
  mintRunId: () => `dryrun-${createdAt.replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`,
68
68
  mode: options.dryRun ? "dry-run" : "live",
69
69
  study: options.study,
70
+ warnings: options.warnings,
70
71
  renderReview: renderReviewMarkdown,
71
72
  // The run starts at the time its id and source were stamped with.
72
73
  now: () => Date.parse(createdAt),
@@ -5,6 +5,8 @@ export declare const CLEANUP_SCHEMA = "humanish.cleanup-result.v1";
5
5
  export interface RunOptions {
6
6
  /** Which manifest produced this run. */
7
7
  study?: RunStudyProvenance;
8
+ /** Warnings about the study's own fields, recorded in the bundle as warn events. */
9
+ warnings?: readonly string[];
8
10
  cwd: string;
9
11
  dryRun?: boolean;
10
12
  runId?: string;
package/dist/run/run.d.ts CHANGED
@@ -22,6 +22,8 @@ interface StartRunOptions {
22
22
  };
23
23
  /** Clock for `createdAt` and the latest pointer. */
24
24
  now?: (() => number) | undefined;
25
+ /** Warnings about the study's own fields. Every bundle write records each as a warn event. */
26
+ warnings?: readonly string[] | undefined;
25
27
  }
26
28
  /** How a run ended as an execution, as run.json records it: the result's ok and execution outcome. */
27
29
  interface RecordedOutcome {
package/dist/run/run.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { redactText } from "../evidence/redaction.js";
6
6
  import { buildObserverData } from "../observer/data.js";
7
7
  import { renderObserver } from "../observer/render.js";
8
- import { RUN_BUNDLE_FILE, PUBLIC_TARGET_CWD } from "./bundle.js";
8
+ import { RUN_BUNDLE_FILE, PUBLIC_TARGET_CWD, } from "./bundle.js";
9
9
  import { judgeExecution, } from "./judge.js";
10
10
  import {} from "./results.js";
11
11
  import { createRunArtifactPaths, validatePreparedRunArtifactPaths, } from "./paths.js";
@@ -112,8 +112,20 @@ function observerTarget(options) {
112
112
  };
113
113
  }
114
114
  function runPublisher(args) {
115
- const { options, runId, paths, runStatus, now, admit, participantsRan } = args;
115
+ const { options, runId, createdAt, paths, runStatus, now, admit, participantsRan } = args;
116
116
  const observer = observerTarget(options);
117
+ // The study's warnings join whatever events the route wrote, once, in every write.
118
+ const studyWarnings = (options.warnings ?? []).map((message, index) => ({
119
+ id: `event-study-warning-${String(index + 1).padStart(3, "0")}`,
120
+ at: createdAt,
121
+ level: "warn",
122
+ type: "study.warning",
123
+ message,
124
+ }));
125
+ const withStudyWarnings = (events) => [
126
+ ...events.filter((event) => !studyWarnings.some((warning) => warning.id === event.id)),
127
+ ...studyWarnings,
128
+ ];
117
129
  let pointerWritten = false;
118
130
  // The bundle of the last write asked for, and the outcome every later write carries once a
119
131
  // signal stopped the run, so no write after the interrupt can drop it.
@@ -145,6 +157,7 @@ function runPublisher(args) {
145
157
  const recorded = interrupted ?? outcome;
146
158
  const publicBundle = await withPublicSandboxIds(paths, {
147
159
  ...evidence,
160
+ events: withStudyWarnings(evidence.events),
148
161
  cwd: PUBLIC_TARGET_CWD,
149
162
  ...(recorded === undefined ? {} : { outcome: recorded }),
150
163
  });
@@ -271,6 +284,7 @@ export async function runScope(fn) {
271
284
  const publisher = runPublisher({
272
285
  options,
273
286
  runId,
287
+ createdAt,
274
288
  paths,
275
289
  runStatus,
276
290
  now,
@@ -22,4 +22,14 @@ type ActorVerdict = "passed" | "blocked" | "failed";
22
22
  * divergent verdict logic would let the two routes disagree about what "passed" means.
23
23
  */
24
24
  export declare function extractLocalActorVerdict(transcript: string, verdictNonce: string): ActorVerdict | null;
25
+ /**
26
+ * The same search over text that arrives in pieces and is not kept: the terminal route reads
27
+ * output past its transcript cap through this. Each piece is normalized and loses its whitespace
28
+ * as the whole transcript does above, and the end of the previous piece is carried so a marker
29
+ * split between pieces is found. The first marker wins, as above.
30
+ */
31
+ export declare function createLocalActorVerdictScanner(verdictNonce: string): {
32
+ push(text: string): void;
33
+ verdict(): ActorVerdict | null;
34
+ };
25
35
  export {};
@@ -27,6 +27,9 @@ export function normalizeLocalActorTranscript(transcript) {
27
27
  .replace(/\r/g, "\n")
28
28
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
29
29
  }
30
+ // The nonce is mandatory: a bare HUMANISH_ACTOR_VERDICT=<status> marker echoed by an actor (or
31
+ // replayed from untrusted text) must never satisfy verdict extraction.
32
+ const verdictPattern = (verdictNonce) => new RegExp(`HUMANISH_ACTOR_VERDICT=(passed|blocked|failed)HUMANISH_ACTOR_NONCE=${escapeRegExp(verdictNonce)}`, "i");
30
33
  /**
31
34
  * Extract the per-run verdict from a normalized transcript: the agent must print exactly
32
35
  * `HUMANISH_ACTOR_VERDICT=<status> HUMANISH_ACTOR_NONCE=<nonce>`, and the nonce is mandatory so a
@@ -35,13 +38,28 @@ export function normalizeLocalActorTranscript(transcript) {
35
38
  * divergent verdict logic would let the two routes disagree about what "passed" means.
36
39
  */
37
40
  export function extractLocalActorVerdict(transcript, verdictNonce) {
38
- const compactTranscript = transcript.replace(/\s+/g, "");
39
- // The per-run nonce is mandatory: a bare HUMANISH_ACTOR_VERDICT=<status>
40
- // marker echoed by an actor (or replayed from untrusted text) must never
41
- // satisfy verdict extraction.
42
- const match = new RegExp(`HUMANISH_ACTOR_VERDICT=(passed|blocked|failed)HUMANISH_ACTOR_NONCE=${escapeRegExp(verdictNonce)}`, "i").exec(compactTranscript);
43
- if (!match) {
44
- return null;
45
- }
46
- return match[1]?.toLowerCase();
41
+ const match = verdictPattern(verdictNonce).exec(transcript.replace(/\s+/g, ""));
42
+ return match?.[1]?.toLowerCase() ?? null;
43
+ }
44
+ /**
45
+ * The same search over text that arrives in pieces and is not kept: the terminal route reads
46
+ * output past its transcript cap through this. Each piece is normalized and loses its whitespace
47
+ * as the whole transcript does above, and the end of the previous piece is carried so a marker
48
+ * split between pieces is found. The first marker wins, as above.
49
+ */
50
+ export function createLocalActorVerdictScanner(verdictNonce) {
51
+ const pattern = verdictPattern(verdictNonce);
52
+ const carried = `HUMANISH_ACTOR_VERDICT=blockedHUMANISH_ACTOR_NONCE=${verdictNonce}`.length - 1;
53
+ let tail = "";
54
+ let found = null;
55
+ return {
56
+ push(text) {
57
+ if (found)
58
+ return;
59
+ const window = tail + normalizeLocalActorTranscript(text).replace(/\s+/g, "");
60
+ found = pattern.exec(window)?.[1]?.toLowerCase() ?? null;
61
+ tail = window.slice(-carried);
62
+ },
63
+ verdict: () => found,
64
+ };
47
65
  }
@@ -10,7 +10,7 @@ import { REPO_SLUG_PATTERN } from "./parse/values.js";
10
10
  import { declaredTargets } from "./plan-participants.js";
11
11
  import { actorResolvesToComputerUse, actorResolvesToScriptedBrowser, actorResolvesToTerminal, computerUseParticipantCount, registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, isComputerUseComposition, } from "./routing.js";
12
12
  import { cloneTargetValidationReason, computerUseValidationReason, sharedWorldValidationReason, } from "./validation.js";
13
- import { actorOf, participantList, declaredParticipantCount, surfaceCount, modeOf, scenarioRefOf, declaresSharedWorld, } from "./study-fields.js";
13
+ import { participantList, declaredParticipantCount } from "./study-fields.js";
14
14
  /**
15
15
  * The count a scripted or terminal composition reads: its surfaces, or a participant count.
16
16
  * parse/front.ts refuses `participants` on route: scripted and route: terminal, and `surfaces` off
@@ -18,7 +18,7 @@ import { actorOf, participantList, declaredParticipantCount, surfaceCount, modeO
18
18
  * run before parseStudy compares the two. migrate's view of a v2 file reaches them the same way.
19
19
  */
20
20
  function participantOrSurfaceCount(config) {
21
- return declaredParticipantCount(config) ?? surfaceCount(config);
21
+ return declaredParticipantCount(config) ?? config.surfaces?.length;
22
22
  }
23
23
  /** The first composition rule a config breaks, in the parser's order, or null. */
24
24
  export function compositionReason(config) {
@@ -34,7 +34,7 @@ export function compositionReason(config) {
34
34
  (isComputerUseComposition(config) ? computerUseValidationReason(config) : null) ??
35
35
  // Whenever shared world is declared, not only when it routes, so a half-declared shared world
36
36
  // fails with a precise reason instead of running as independent participants.
37
- (declaresSharedWorld(config) ? sharedWorldValidationReason(config) : null) ??
37
+ (config.route === "shared-world" ? sharedWorldValidationReason(config) : null) ??
38
38
  desktopCliValidationReason(config) ??
39
39
  terminalValidationReason(config) ??
40
40
  cloneActorValidationReason(config));
@@ -48,7 +48,7 @@ function thisRepoValidationReason(config) {
48
48
  if (config.execution?.target) {
49
49
  return "`execution.target` applies only to clone/app-url/local-app subjects; this-repo studies run locally.";
50
50
  }
51
- if (modeOf(config) === "live") {
51
+ if (config.mode === "live") {
52
52
  return THIS_REPO_DRY_RUN_ONLY;
53
53
  }
54
54
  }
@@ -63,7 +63,7 @@ function thisRepoValidationReason(config) {
63
63
  // planComputerUseStudy refuses it with HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR.
64
64
  function localAppValidationReason(config) {
65
65
  if (config.subject.source === "local-app") {
66
- const type = actorOf(config)?.type ?? "";
66
+ const type = config.actor?.type ?? "";
67
67
  if (config.execution?.target !== undefined && config.execution.target !== "local") {
68
68
  return "A local-app subject drives a local dev server in this process, with no E2B desktop. Set `execution.target: local` or omit it. To run on a hosted desktop, use an app-url subject with `execution.target: e2b-desktop`.";
69
69
  }
@@ -87,14 +87,14 @@ function localAppValidationReason(config) {
87
87
  // app; a computer-use actor drives a hosted desktop browser. Fail closed on mis-configs.
88
88
  function appUrlValidationReason(config) {
89
89
  if (config.subject.source === "app-url") {
90
- const type = actorOf(config)?.type ?? "";
90
+ const type = config.actor?.type ?? "";
91
91
  if (actorResolvesToScriptedBrowser(type)) {
92
92
  // Scripted-browser route (all fail-closed, so claims match mechanism: a field that cannot act on
93
93
  // this route is rejected, never silently ignored).
94
94
  if (config.execution?.target !== undefined && config.execution.target !== "local") {
95
95
  return "A scripted-browser actor on an app-url subject runs on this machine. Set `execution.target: local` or omit it. To run scripted steps on a hosted desktop, use a clone subject.";
96
96
  }
97
- if (!scenarioRefOf(config)) {
97
+ if (!config.scenario) {
98
98
  return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
99
99
  }
100
100
  if ((participantOrSurfaceCount(config) ?? 1) > 2) {
@@ -132,8 +132,7 @@ function appUrlValidationReason(config) {
132
132
  }
133
133
  // Scripted-browser actors on any other subject: only a provisioned clone on e2b-desktop.
134
134
  function scriptedBrowserValidationReason(config) {
135
- if (config.subject.source !== "app-url" &&
136
- actorResolvesToScriptedBrowser(actorOf(config)?.type)) {
135
+ if (config.subject.source !== "app-url" && actorResolvesToScriptedBrowser(config.actor?.type)) {
137
136
  if (config.subject.source !== "clone") {
138
137
  return "scripted-browser actors require `subject.source: app-url` (a running app at a loopback URL) or `subject.source: clone` with `execution.target: e2b-desktop` (a provisioned synthetic subject).";
139
138
  }
@@ -152,13 +151,13 @@ function scriptedBrowserValidationReason(config) {
152
151
  }
153
152
  // migrate converts a v2 file's `subject.topology: shared-world` to `route: shared-world`, and
154
153
  // refuses it here with the v2 parser's message.
155
- if (declaresSharedWorld(config)) {
154
+ if (config.route === "shared-world") {
156
155
  return "A clone scripted-browser study does not support `subject.topology` yet: it runs one synthetic subject for its scripted actor. Remove `subject.topology`.";
157
156
  }
158
157
  if (config.subject.clone?.fanout !== undefined || config.subject.clone?.keep === true) {
159
158
  return "A clone scripted-browser study does not support `subject.clone.fanout` or `subject.clone.keep` yet: its subject is always one sandbox, removed after the run.";
160
159
  }
161
- if (!scenarioRefOf(config)) {
160
+ if (!config.scenario) {
162
161
  return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
163
162
  }
164
163
  if ((participantOrSurfaceCount(config) ?? 1) > 2) {
@@ -193,7 +192,7 @@ function scriptedBrowserValidationReason(config) {
193
192
  function cloneComputerUseValidationReason(config) {
194
193
  if (config.subject.source === "clone" &&
195
194
  config.execution?.target === "e2b-desktop" &&
196
- actorResolvesToComputerUse(actorOf(config)?.type)) {
195
+ actorResolvesToComputerUse(config.actor?.type)) {
197
196
  if (!config.subject.serve) {
198
197
  return "A clone subject on the computer-use route needs `subject.serve` (start and url): humanish starts the app in the sandbox before the participant opens it.";
199
198
  }
@@ -220,8 +219,8 @@ function localTreeValidationReason(config) {
220
219
  if (config.execution?.target !== "e2b-desktop") {
221
220
  return "local-tree subjects require `execution.target: e2b-desktop`: the packed working tree is provisioned and served inside a hosted desktop sandbox; there is no local route for a local-tree subject.";
222
221
  }
223
- if (!actorResolvesToComputerUse(actorOf(config)?.type)) {
224
- return `actor.type must be a registered computer-use actor for local-tree subjects (one of: ${registeredComputerUseActors().join(", ")}); the actor drives the hosted desktop that serves the packed working tree. Got "${actorOf(config)?.type ?? ""}".`;
222
+ if (!actorResolvesToComputerUse(config.actor?.type)) {
223
+ return `actor.type must be a registered computer-use actor for local-tree subjects (one of: ${registeredComputerUseActors().join(", ")}); the actor drives the hosted desktop that serves the packed working tree. Got "${config.actor?.type ?? ""}".`;
225
224
  }
226
225
  }
227
226
  return null;
@@ -248,7 +247,7 @@ function desktopCliValidationReason(config) {
248
247
  if (config.execution?.target !== undefined && config.execution.target !== "e2b-desktop") {
249
248
  return "A desktop-cli subject runs on a hosted desktop. Set `execution.target: e2b-desktop` or omit it.";
250
249
  }
251
- if (!actorResolvesToComputerUse(actorOf(config)?.type ?? "")) {
250
+ if (!actorResolvesToComputerUse(config.actor?.type ?? "")) {
252
251
  return "desktop-cli subjects need a registered computer-use actor: the participant reads the screen and types, which is what makes an interactive surface studiable at all.";
253
252
  }
254
253
  const install = config.subject.product.install;
@@ -265,7 +264,7 @@ function desktopCliValidationReason(config) {
265
264
  // fan-out lands.
266
265
  function terminalValidationReason(config) {
267
266
  if (config.subject.source === "terminal-product") {
268
- const type = actorOf(config)?.type ?? "";
267
+ const type = config.actor?.type ?? "";
269
268
  if (config.execution?.target !== undefined && config.execution.target !== "e2b-terminal") {
270
269
  return "A terminal-product subject runs its agent in an E2B shell. Set `execution.target: e2b-terminal` or omit it; `local` and `e2b-desktop` are not supported.";
271
270
  }
@@ -281,7 +280,7 @@ function terminalValidationReason(config) {
281
280
  // mis-config: reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
282
281
  return "`execution.target: e2b-terminal` requires `subject.source: terminal-product` with a registered terminal actor.";
283
282
  }
284
- else if (actorResolvesToTerminal(actorOf(config)?.type)) {
283
+ else if (actorResolvesToTerminal(config.actor?.type)) {
285
284
  // A registered terminal actor on a non-terminal-product subject: rejected, never ignored (the
286
285
  // terminal agent only studies a declared terminal-product from public surfaces).
287
286
  return "terminal actors require `subject.source: terminal-product` (a CLI/product the agent studies from public surfaces); other subjects are not supported on this route.";
@@ -293,7 +292,7 @@ function terminalValidationReason(config) {
293
292
  // Terminal actors were already refused above with their own message.
294
293
  function cloneActorValidationReason(config) {
295
294
  if (config.subject.source === "clone") {
296
- const type = actorOf(config)?.type ?? "";
295
+ const type = config.actor?.type ?? "";
297
296
  if (!actorResolvesToComputerUse(type) && !actorResolvesToScriptedBrowser(type)) {
298
297
  return `clone subjects need a registered computer-use actor (one of: ${registeredComputerUseActors().join(", ")}) or scripted-browser actor (one of: ${registeredScriptedBrowserActors().join(", ")}); humanish clones and serves the app for that participant to drive. Got "${type}".`;
299
298
  }
@@ -9,7 +9,6 @@ import { externalCatchHealthy } from "../comms/sandbox-catch.js";
9
9
  import { receivingRequiredKey } from "../comms/setup.js";
10
10
  import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
11
11
  import { cli } from "../cli/invocation.js";
12
- import { actorOf } from "./study-fields.js";
13
12
  /** A ready row's message, with the schema values the launch recorded. */
14
13
  function readyMessage(message, readiness) {
15
14
  const additions = protocolAdditionsWarning(readiness.cliVersion, readiness.protocolAdditions);
@@ -152,7 +151,7 @@ export async function studySetupChecks(args) {
152
151
  {
153
152
  name: "study route",
154
153
  ok: true,
155
- message: `${config.id}: ${actorOf(config)?.type ?? "synthetic"} / ${route} / ${dryRun ? "dry-run (no live participant)" : "live"}`,
154
+ message: `${config.id}: ${config.actor?.type ?? "synthetic"} / ${route} / ${dryRun ? "dry-run (no live participant)" : "live"}`,
156
155
  },
157
156
  ];
158
157
  if (dryRun)
@@ -247,16 +246,16 @@ async function participantChecks(config, route, keys, local, args, checkAccount)
247
246
  ];
248
247
  }
249
248
  if ((route !== "computer-use" && route !== "shared-world") ||
250
- actorOf(config)?.type !== "local-agent")
249
+ config.actor?.type !== "local-agent")
251
250
  return [];
252
- const choice = actorOf(config)?.localAgent ?? "codex";
251
+ const choice = config.actor?.localAgent ?? "codex";
253
252
  const agent = args.agents.find((entry) => entry.id === choice);
254
253
  if (local)
255
254
  return [await localCodexParticipantCheck({ env: args.env, readiness: checkAccount })];
256
255
  // A signed-in hosted Codex gets the operator handshake; its sign-in status alone does not show
257
256
  // that the operator's config, release and model admit a launch.
258
257
  if (choice === "codex" && agent?.authStatus === "authenticated")
259
- return [await hostedCodexParticipantCheck(args, actorOf(config))];
258
+ return [await hostedCodexParticipantCheck(args, config.actor)];
260
259
  const outdated = agent === undefined ? undefined : localAgentVersionRefusal(agent);
261
260
  return [
262
261
  {
@@ -7,6 +7,11 @@ export declare const V2_UNSUPPORTED_MESSAGE = "This is a humanish.lab.v2 study,
7
7
  type Raw = Record<string, unknown>;
8
8
  /** Where each humanish 0.110 StudyConfig field a config still sets went; empty when it sets none. */
9
9
  export declare function v2FieldMoves(config: unknown): string[];
10
+ /**
11
+ * The v2 key a hand-converted humanish.study.v3 file is most likely to keep, with where it went;
12
+ * undefined when the file keeps none. parse/front.ts refuses the file with it.
13
+ */
14
+ export declare function movedV2KeyReason(raw: Raw): string | undefined;
10
15
  type Read<T> = {
11
16
  ok: true;
12
17
  value: T;
@@ -57,6 +57,29 @@ export function v2FieldMoves(config) {
57
57
  return [];
58
58
  return V2_FIELDS.filter((field) => field.present(config)).map((field) => field.move);
59
59
  }
60
+ /**
61
+ * The v2 key a hand-converted humanish.study.v3 file is most likely to keep, with where it went;
62
+ * undefined when the file keeps none. parse/front.ts refuses the file with it.
63
+ */
64
+ export function movedV2KeyReason(raw) {
65
+ if (raw.actors !== undefined) {
66
+ return "A study has one `actor:` object, and its participants go in `participants:`. `actors:` is the humanish.lab.v2 spelling.";
67
+ }
68
+ if (isRecord(raw.actor)) {
69
+ for (const key of ["count", "lanes", "roster", "laneFocus"]) {
70
+ if (raw.actor[key] !== undefined) {
71
+ return `\`actor.${key}\` moved to \`participants:\`: a count, \`{ count, instruction }\`, or a list of participants.`;
72
+ }
73
+ }
74
+ }
75
+ if (isRecord(raw.subject) && raw.subject.topology !== undefined) {
76
+ return "`subject.topology` moved to `route:`. A shared world is `route: shared-world`.";
77
+ }
78
+ if (isRecord(raw.execution) && raw.execution.caps !== undefined) {
79
+ return "`execution.caps` moved to a top-level `caps:` block.";
80
+ }
81
+ return undefined;
82
+ }
60
83
  // The keys of a humanish.lab.v2 file, in the order its parser listed them: the v3 sections with
61
84
  // `subject.topology`, the actor list with its participant keys, `execution.caps`, `personas` and
62
85
  // the `scenario` object.
@@ -3,6 +3,7 @@
3
3
  // sections after these checks pass.
4
4
  import { isRecord } from "../../run/type-guards.js";
5
5
  import { findUnknownStudyKey } from "../keys.js";
6
+ import { movedV2KeyReason } from "../migrate/v2.js";
6
7
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
7
8
  import { invalid, posInt } from "./values.js";
8
9
  // The values `route:` takes: every route routeOf can return.
@@ -39,7 +40,7 @@ export function routeNameReason(route) {
39
40
  * parser does.
40
41
  */
41
42
  export function readStudyFront(raw, anySharedWorldParticipants = false) {
42
- const moved = movedKeyReason(raw);
43
+ const moved = movedV2KeyReason(raw);
43
44
  if (moved)
44
45
  return invalid(moved);
45
46
  const unknownKey = findUnknownStudyKey(raw);
@@ -74,26 +75,6 @@ export function readStudyFront(raw, anySharedWorldParticipants = false) {
74
75
  value: { route, actor: raw.actor, participants: participants.value, surfaces: surfaces.value },
75
76
  };
76
77
  }
77
- // The v2 keys a hand-converted file is most likely to keep, each with where it went.
78
- function movedKeyReason(raw) {
79
- if (raw.actors !== undefined) {
80
- return "A study has one `actor:` object, and its participants go in `participants:`. `actors:` is the humanish.lab.v2 spelling.";
81
- }
82
- if (isRecord(raw.actor)) {
83
- for (const key of ["count", "lanes", "roster", "laneFocus"]) {
84
- if (raw.actor[key] !== undefined) {
85
- return `\`actor.${key}\` moved to \`participants:\`: a count, \`{ count, instruction }\`, or a list of participants.`;
86
- }
87
- }
88
- }
89
- if (isRecord(raw.subject) && raw.subject.topology !== undefined) {
90
- return "`subject.topology` moved to `route:`. A shared world is `route: shared-world`.";
91
- }
92
- if (isRecord(raw.execution) && raw.execution.caps !== undefined) {
93
- return "`execution.caps` moved to a top-level `caps:` block.";
94
- }
95
- return undefined;
96
- }
97
78
  function participantsOf(route, raw) {
98
79
  if (raw === undefined) {
99
80
  if (route === "shared-world")
@@ -1,8 +1,8 @@
1
1
  /**
2
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.
3
+ * holds a credential verify flags, as written or encoded. Undefined for a URL with neither, and for
4
+ * text that is not a URL, which the field's own shape check refuses. `base` resolves a relative
5
+ * entry path.
6
6
  */
7
7
  export declare function urlCredentialReason(field: string, value: string, base?: string): string | undefined;
8
8
  /** Why the participant entry at `field` cannot be used, resolved as a path on the subject. */
@@ -1,14 +1,52 @@
1
1
  // A URL a study declares is recorded in the run, shown in the participant's address bar and so in
2
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.
3
+ // such as a filter or a page number, stay as they are. So does an E2B app URL in a parameter, such
4
+ // as a sign-in return address: its host names a sandbox, as the target's own host may, and holds no
5
+ // credential. verify keeps sandbox URLs out of a shared run.
4
6
  import { scanEncodedText } from "../../evidence/encoded-text.js";
7
+ import { readPlainText } from "../../evidence/plain-text.js";
8
+ import { containsCredential } from "../../evidence/redaction.js";
5
9
  // A relative participant entry is resolved against this before it is read.
6
10
  const ENTRY_BASE = "http://127.0.0.1/";
11
+ // A URL in a decoded parameter value, the fragment or a path segment, such as a sign-in return
12
+ // address, is read as the study URL is, this many levels deep. It is parsed, so its user info and
13
+ // its own parameters keep their boundaries; a scan of the decoded text alone would read the next
14
+ // parameter of the outer URL as part of it.
15
+ const MAX_NESTED_DEPTH = 4;
16
+ // A URL inside one decoded value. It runs to the next space, as the value is one parameter's.
17
+ const NESTED_URL = /[a-z][a-z0-9+.-]{0,31}:\/\/\S+/gi;
18
+ // A URL in text decoded from a whole value or from base64, which may join several values or be
19
+ // JSON: it runs to the next space, `&`, or character no URL may hold as written (`"`, `<`, `>`, `\`
20
+ // and a backtick), which ends a JSON string. An apostrophe may be part of user info.
21
+ const URL_IN_TEXT = /[a-z][a-z0-9+.-]{0,31}:\/\/[^\s&"<>\\`]+/gi;
22
+ // A raw `&` that ends a parameter value. One that starts an HTML character reference, such as
23
+ // `&#61;` for `=`, is part of the text around it.
24
+ const VALUE_END = /(?=&(?![a-z]+;|#))/i;
25
+ /** Whether a URL in the text, parsed, has a user name or password. */
26
+ function holdsUserInfo(text) {
27
+ return [...text.matchAll(URL_IN_TEXT)].some(([candidate]) => {
28
+ try {
29
+ const url = new URL(candidate);
30
+ return url.username !== "" || url.password !== "";
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ });
36
+ }
37
+ /**
38
+ * A credential verify flags, or a URL with user info, in text as written or in any decoding, and
39
+ * with tabs and line breaks dropped, as the URL parser drops them from a URL.
40
+ */
41
+ function holdsCredential(text) {
42
+ const joined = text.replace(/[\t\n\r]/g, "");
43
+ return containsCredential(text) || containsCredential(joined) || holdsUserInfo(joined);
44
+ }
7
45
  /**
8
46
  * 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.
47
+ * holds a credential verify flags, as written or encoded. Undefined for a URL with neither, and for
48
+ * text that is not a URL, which the field's own shape check refuses. `base` resolves a relative
49
+ * entry path.
12
50
  */
13
51
  export function urlCredentialReason(field, value, base) {
14
52
  let url;
@@ -23,7 +61,7 @@ export function urlCredentialReason(field, value, base) {
23
61
  return `\`${field}\` has a tab, line break or other control character in it, which the URL parser drops. Write the URL without it.`;
24
62
  if (url.username !== "" || url.password !== "")
25
63
  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))
64
+ if (urlPartsHoldCredential(value, url))
27
65
  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
66
  return undefined;
29
67
  }
@@ -32,19 +70,123 @@ export function entryCredentialReason(field, entry) {
32
70
  return urlCredentialReason(field, entry, ENTRY_BASE);
33
71
  }
34
72
  /**
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.
73
+ * Whether the URL's path, query or fragment holds a credential: as the parser normalized it and as
74
+ * written (`..` segments can drop a segment from the normalized path). Each is matched whole as it
75
+ * stands, then decoded up to each raw `&`, which ends a parameter value, so a decoded value is not
76
+ * read into the next parameter; then one segment, value or fragment piece at a time, so the text
77
+ * around an encoded key cannot shift its decoding. A URL nested in it is read the same way.
38
78
  */
39
- function urlPartsHoldSecret(value, url) {
79
+ function urlPartsHoldCredential(value, url, depth = 0) {
40
80
  const written = value.replace(/^[a-z][a-z0-9+.-]{0,31}:\/\/[^/?#]*/i, "");
41
81
  const normalized = `${url.pathname}${url.search}${url.hash}`;
42
82
  const pieces = [
43
- written,
44
- normalized,
83
+ ...written.split(VALUE_END),
84
+ ...normalized.split(VALUE_END),
45
85
  ...written.split(/[/?#&=;]/),
46
86
  ...normalized.split(/[/?#&=;]/),
47
87
  ];
48
- return pieces.some((piece) => piece.length > 0 &&
49
- scanEncodedText(piece, { secretsOnly: true, allowOpaqueBase64: true }).sensitive);
88
+ if (holdsCredential(written) ||
89
+ holdsCredential(normalized) ||
90
+ pieces.some((piece) => piece.length > 0 &&
91
+ scanEncodedText(piece, { matches: holdsCredential, allowOpaqueBase64: true }).sensitive))
92
+ return true;
93
+ return (depth < MAX_NESTED_DEPTH &&
94
+ nestedUrls(url).some((nested) => nested.url.username !== "" ||
95
+ nested.url.password !== "" ||
96
+ urlPartsHoldCredential(nested.text, nested.url, depth + 1)));
97
+ }
98
+ function decoded(text) {
99
+ try {
100
+ return decodeURIComponent(text);
101
+ }
102
+ catch {
103
+ return text;
104
+ }
105
+ }
106
+ // A hex, base64 or base64url value long enough to hold a URL, such as an OAuth `state`.
107
+ const HEX_VALUE = /^(?:[0-9a-f]{2}){16}(?:[0-9a-f]{2})*$/i;
108
+ const BASE64_VALUE = /^[A-Za-z0-9+/_-]{16}[A-Za-z0-9+/_-]*={0,2}$/;
109
+ // Hex or base64 inside hex or base64 is read this many levels deep, as verify's scan reads it.
110
+ const MAX_ENCODED_DEPTH = 2;
111
+ /** The text a hex, base64 or base64url value decodes to, when it is text, and what that decodes to. */
112
+ function encodedText(value, depth = 1) {
113
+ const bytes = [
114
+ ...(HEX_VALUE.test(value) ? [Buffer.from(value, "hex")] : []),
115
+ ...(BASE64_VALUE.test(value)
116
+ ? [Buffer.from(value, /[-_]/.test(value) ? "base64url" : "base64")]
117
+ : []),
118
+ ];
119
+ const texts = bytes.flatMap((decoded) => {
120
+ const plain = readPlainText(decoded);
121
+ return plain.ok ? [plain.text] : [];
122
+ });
123
+ return depth < MAX_ENCODED_DEPTH
124
+ ? [...texts, ...texts.flatMap((text) => encodedText(text.trim(), depth + 1))]
125
+ : texts;
126
+ }
127
+ /**
128
+ * The absolute URLs in `url`'s decoded parameter names and values, its fragment's and its path
129
+ * segments, each read as decoded once and twice and from hex or base64, so an encoded URL inside an
130
+ * encoded value is found. The fragment and the path are also read as written: a URL written there
131
+ * whole keeps its `://`, which splitting would cut. The fragment is cut at each `&`, so a value is
132
+ * not read into the next parameter.
133
+ */
134
+ function nestedUrls(url) {
135
+ const fragment = url.hash.slice(1);
136
+ // A fragment may be a route with its own query (`#/callback?next=...`) or a query itself. A
137
+ // fragment with neither `=` nor `&` reads as one parameter name.
138
+ const fragmentQuery = new URLSearchParams(fragment.slice(fragment.indexOf("?") + 1));
139
+ const once = [
140
+ ...[...url.searchParams, ...fragmentQuery].flat(),
141
+ ...url.pathname.split("/").map(decoded),
142
+ ...fragment.split("&"),
143
+ url.pathname,
144
+ ];
145
+ const twice = once.map(decoded);
146
+ const values = [...new Set([...once, ...twice])];
147
+ // The URL parser drops tabs and line breaks anywhere in a URL, so they are dropped before a URL is
148
+ // looked for: `pass<tab>word=` is read as the parser reads it.
149
+ const parsed = (pattern) => (text) => [...text.replace(/[\t\n\r]/g, "").matchAll(pattern)].flatMap(([candidate]) => {
150
+ try {
151
+ return [{ text: candidate, url: new URL(candidate) }];
152
+ }
153
+ catch {
154
+ return [];
155
+ }
156
+ });
157
+ // A JSON value, such as an OAuth `state`, is read string by string, so a URL in one string does
158
+ // not run into the next. Other text decoded from hex or base64 is one URL when it starts with a
159
+ // scheme, and may be a query otherwise, so its URLs end at `&`.
160
+ const read = (pattern) => (text) => jsonStrings(text)?.flatMap(parsed(NESTED_URL)) ?? parsed(pattern(text))(text);
161
+ const oneUrl = (text) => /^\s*[a-z][a-z0-9+.-]{0,31}:\/\//i.test(text) ? NESTED_URL : URL_IN_TEXT;
162
+ return [
163
+ ...values.flatMap(read(() => NESTED_URL)),
164
+ ...values.flatMap((value) => encodedText(value)).flatMap(read(oneUrl)),
165
+ ];
166
+ }
167
+ /** The strings in a JSON object or array, keys included, or undefined for text that is not one. */
168
+ function jsonStrings(text) {
169
+ if (!/^\s*[[{]/.test(text))
170
+ return undefined;
171
+ let value;
172
+ try {
173
+ value = JSON.parse(text);
174
+ }
175
+ catch {
176
+ return undefined;
177
+ }
178
+ const strings = [];
179
+ const visit = (node) => {
180
+ if (typeof node === "string")
181
+ strings.push(node);
182
+ else if (Array.isArray(node))
183
+ node.forEach(visit);
184
+ else if (node !== null && typeof node === "object")
185
+ for (const [key, child] of Object.entries(node)) {
186
+ strings.push(key);
187
+ visit(child);
188
+ }
189
+ };
190
+ visit(value);
191
+ return strings;
50
192
  }
@@ -5,7 +5,7 @@ import { parseResolvedPersona, PersonaConfigError } from "./persona.js";
5
5
  import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
6
6
  import { digestText, redactText } from "../evidence/redaction.js";
7
7
  import { realpath } from "node:fs/promises";
8
- import { actorOf, participantList } from "./study-fields.js";
8
+ import { participantList } from "./study-fields.js";
9
9
  /** Persona ids are file-name segments, never paths: the same grammar the terminal route enforces. */
10
10
  const PERSONA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
11
11
  /** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
@@ -86,7 +86,7 @@ export async function resolveCommittedPersonas(projectRoot, personaIds) {
86
86
  */
87
87
  export function studyPersonaIds(config) {
88
88
  const ids = [];
89
- const persona = actorOf(config)?.persona;
89
+ const persona = config.actor?.persona;
90
90
  if (persona)
91
91
  ids.push(persona);
92
92
  for (const entry of participantList(config) ?? []) {