humanish 0.110.4 → 0.111.1

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 (145) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTEXT.md +2 -2
  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/actors/local-agent/claude-session.js +1 -1
  23. package/dist/actors/local-agent/cli.js +1 -1
  24. package/dist/actors/scripted-browser/actor.d.ts +2 -2
  25. package/dist/browser-control/client.js +5 -5
  26. package/dist/browser-control/protocol.d.ts +30 -1
  27. package/dist/browser-control/protocol.js +19 -2
  28. package/dist/browser-control/transport.d.ts +3 -2
  29. package/dist/browser-control/transport.js +12 -7
  30. package/dist/cli/commands/study-route-computer-use.js +2 -2
  31. package/dist/cli/commands/study-route-shared-world.js +2 -2
  32. package/dist/cli/commands/study.js +1 -4
  33. package/dist/cli/doctor.js +11 -0
  34. package/dist/evidence/encoded-text.d.ts +3 -3
  35. package/dist/evidence/encoded-text.js +10 -5
  36. package/dist/evidence/redaction.d.ts +2 -2
  37. package/dist/evidence/redaction.js +18 -7
  38. package/dist/guest/browser-tools.js +5 -1
  39. package/dist/guest/chromium-text.js +42 -18
  40. package/dist/guest/desktop-executor.js +5 -1
  41. package/dist/keys/key-resolution.d.ts +3 -0
  42. package/dist/keys/key-resolution.js +15 -2
  43. package/dist/routes/computer-use/bundle-parts.d.ts +4 -0
  44. package/dist/routes/computer-use/bundle-parts.js +7 -2
  45. package/dist/routes/computer-use/fanout-bundle.js +4 -2
  46. package/dist/routes/computer-use/local-vm.js +1 -2
  47. package/dist/routes/computer-use/participant-model.js +7 -2
  48. package/dist/routes/computer-use/plan.js +7 -10
  49. package/dist/routes/computer-use/preflight.js +1 -1
  50. package/dist/routes/computer-use/result.js +1 -1
  51. package/dist/routes/computer-use/route.js +2 -3
  52. package/dist/routes/computer-use/setup.js +2 -2
  53. package/dist/routes/computer-use/types.d.ts +4 -4
  54. package/dist/routes/preview.js +1 -0
  55. package/dist/routes/scripted/plan.js +4 -5
  56. package/dist/routes/scripted/route.js +3 -3
  57. package/dist/routes/scripted/scenario.d.ts +1 -1
  58. package/dist/routes/scripted/scenario.js +3 -3
  59. package/dist/routes/scripted/types.d.ts +2 -2
  60. package/dist/routes/shared-world/handoff.js +1 -1
  61. package/dist/routes/shared-world/participant-specs.js +1 -1
  62. package/dist/routes/shared-world/plan.js +6 -8
  63. package/dist/routes/shared-world/route.js +2 -2
  64. package/dist/routes/shared-world/setup.js +1 -0
  65. package/dist/routes/shared-world/types.d.ts +1 -1
  66. package/dist/routes/terminal/bundle.d.ts +3 -3
  67. package/dist/routes/terminal/dry-run.js +1 -0
  68. package/dist/routes/terminal/ledger.d.ts +2 -2
  69. package/dist/routes/terminal/lifetime.js +1 -1
  70. package/dist/routes/terminal/live-finish.js +25 -6
  71. package/dist/routes/terminal/live-sandbox.js +5 -1
  72. package/dist/routes/terminal/plan.js +3 -4
  73. package/dist/routes/terminal/recorder.d.ts +6 -0
  74. package/dist/routes/terminal/recorder.js +61 -6
  75. package/dist/routes/terminal/route.js +2 -3
  76. package/dist/routes/terminal/session.js +2 -1
  77. package/dist/routes/terminal/token-usage.d.ts +3 -0
  78. package/dist/routes/terminal/token-usage.js +5 -0
  79. package/dist/routes/terminal/types.d.ts +3 -2
  80. package/dist/routes/terminal/types.js +2 -1
  81. package/dist/run/dry-run.js +1 -0
  82. package/dist/run/results.d.ts +3 -1
  83. package/dist/run/run.d.ts +2 -0
  84. package/dist/run/run.js +16 -2
  85. package/dist/run/terminal-contract.d.ts +10 -0
  86. package/dist/run/terminal-contract.js +27 -9
  87. package/dist/run-study.d.ts +5 -3
  88. package/dist/run-study.js +7 -11
  89. package/dist/study/adapter-scorer-loader.d.ts +1 -1
  90. package/dist/study/adapter-scorer-loader.js +3 -3
  91. package/dist/study/composition-rules.js +36 -27
  92. package/dist/study/config.d.ts +29 -13
  93. package/dist/study/config.js +174 -157
  94. package/dist/study/discover.d.ts +1 -1
  95. package/dist/study/discover.js +1 -1
  96. package/dist/study/doctor.js +4 -5
  97. package/dist/study/keys.d.ts +250 -2
  98. package/dist/study/keys.js +32 -62
  99. package/dist/study/migrate/convert.js +3 -4
  100. package/dist/study/migrate/v2.d.ts +22 -7
  101. package/dist/study/migrate/v2.js +173 -23
  102. package/dist/study/parse/actors.d.ts +11 -32
  103. package/dist/study/parse/actors.js +11 -156
  104. package/dist/study/parse/comms.js +6 -2
  105. package/dist/study/parse/execution.d.ts +9 -4
  106. package/dist/study/parse/execution.js +4 -39
  107. package/dist/study/parse/{study-v3.d.ts → front.d.ts} +6 -17
  108. package/dist/study/parse/{study-v3.js → front.js} +24 -112
  109. package/dist/study/parse/subject.d.ts +2 -0
  110. package/dist/study/parse/subject.js +15 -18
  111. package/dist/study/parse/url-credentials.d.ts +3 -3
  112. package/dist/study/parse/url-credentials.js +155 -13
  113. package/dist/study/persona-resolve.js +7 -9
  114. package/dist/study/plan-base.d.ts +1 -1
  115. package/dist/study/plan-base.js +5 -7
  116. package/dist/study/plan-participants.d.ts +2 -2
  117. package/dist/study/plan-participants.js +3 -3
  118. package/dist/study/plan-types.d.ts +7 -5
  119. package/dist/study/plan.js +12 -6
  120. package/dist/study/preflight-probes.js +2 -2
  121. package/dist/study/preflight.d.ts +1 -1
  122. package/dist/study/preflight.js +1 -1
  123. package/dist/study/routing.d.ts +5 -5
  124. package/dist/study/routing.js +16 -16
  125. package/dist/study/run-study-options.d.ts +1 -1
  126. package/dist/study/run-study-options.js +2 -2
  127. package/dist/study/study-deps.d.ts +1 -1
  128. package/dist/study/study-fields.d.ts +4 -25
  129. package/dist/study/study-fields.js +15 -44
  130. package/dist/study/summary.js +13 -8
  131. package/dist/study/types.d.ts +51 -128
  132. package/dist/study/types.js +0 -1
  133. package/dist/study/url-credentials.js +3 -4
  134. package/dist/study/validation.d.ts +2 -10
  135. package/dist/study/validation.js +23 -53
  136. package/dist/study/warnings.d.ts +11 -3
  137. package/dist/study/warnings.js +56 -110
  138. package/dist/substrates/local/runtime-config.js +11 -13
  139. package/docs/architecture/browser-control.md +12 -2
  140. package/docs/architecture/guest-desktop.md +7 -1
  141. package/docs/architecture/terminal-product-route.md +3 -2
  142. package/docs/contracts/schemas.md +39 -9
  143. package/package.json +1 -1
  144. package/dist/study/parse/study.d.ts +0 -38
  145. package/dist/study/parse/study.js +0 -225
@@ -33,14 +33,6 @@ export function parseExecution(raw) {
33
33
  }
34
34
  if (desktopResult.value)
35
35
  execution.desktop = desktopResult.value;
36
- // Reuse the terminal route's caps parser (same shape, not a fork); a malformed budget is a hard
37
- // error, never silently dropped (a cap that silently does nothing would be a safety lie).
38
- const capsResult = parseCaps(raw.caps);
39
- if (!capsResult.ok) {
40
- return capsResult;
41
- }
42
- if (capsResult.value)
43
- execution.caps = capsResult.value;
44
36
  const terminalResult = parseTerminal(raw.terminal);
45
37
  if (!terminalResult.ok) {
46
38
  return terminalResult;
@@ -217,40 +209,13 @@ function parseDesktop(raw) {
217
209
  }
218
210
  return { ok: true, value: Object.keys(desktop).length > 0 ? desktop : undefined };
219
211
  }
220
- export function parsePersonas(raw) {
221
- if (!Array.isArray(raw)) {
222
- return undefined;
223
- }
224
- const personas = raw.filter(isRecord);
225
- return personas.length > 0 ? personas : undefined;
226
- }
227
- export function parseScenario(raw) {
228
- if (!isRecord(raw)) {
229
- return { ok: true, value: undefined };
230
- }
231
- const scenario = {};
232
- const ref = str(raw.ref);
233
- if (ref)
234
- scenario.ref = ref;
235
- if (isRecord(raw.inline))
236
- scenario.inline = raw.inline;
237
- const mode = str(raw.mode);
238
- if (mode === "dry-run" || mode === "live")
239
- scenario.mode = mode;
240
- const capsResult = parseCaps(raw.caps);
241
- if (!capsResult.ok) {
242
- return capsResult;
243
- }
244
- if (capsResult.value)
245
- scenario.caps = capsResult.value;
246
- return { ok: true, value: Object.keys(scenario).length > 0 ? scenario : undefined };
247
- }
248
212
  /**
249
- * Parse `scenario.caps`. Returns a parse failure on a malformed value rather than silently
213
+ * Parse a `caps` block's values. Returns a parse failure on a malformed value rather than silently
250
214
  * dropping a budget declaration (a cap that silently does nothing would claim protection it
251
- * does not give). Each cap must be a non-negative finite number.
215
+ * does not give). Each cap must be a non-negative finite number. readStudyFront has checked the
216
+ * keys against the route; migrate checks a v2 file's two caps blocks with it.
252
217
  */
253
- function parseCaps(raw) {
218
+ export function parseCaps(raw) {
254
219
  if (raw === undefined) {
255
220
  return { ok: true, value: undefined };
256
221
  }
@@ -1,12 +1,5 @@
1
1
  import type { StudyRoute } from "../routing.js";
2
- import { type StudyParseFailure } from "../types.js";
3
- export interface StudyDocument {
4
- readonly route: StudyRoute;
5
- /** The same document in the humanish.lab.v2 spelling. */
6
- readonly v2: Record<string, unknown>;
7
- /** For each expanded participant, the index of the `participants` entry it came from. */
8
- readonly participantSource: readonly number[];
9
- }
2
+ import type { StudyParseFailure, StudySurfaces } from "../types.js";
10
3
  /** A document's `participants`, read before the participant entries parse. */
11
4
  export interface Participants {
12
5
  count?: number;
@@ -21,15 +14,16 @@ export interface StudyFront {
21
14
  readonly route: StudyRoute;
22
15
  readonly actor: Record<string, unknown>;
23
16
  readonly participants: Participants;
24
- /** The scripted surfaces as a count: 1 is desktop, 2 adds mobile. */
25
- readonly surfaces: number | undefined;
17
+ readonly surfaces: StudySurfaces | undefined;
26
18
  }
27
19
  type Parsed<T> = {
28
20
  ok: true;
29
21
  value: T;
30
22
  } | StudyParseFailure;
31
- /** Rewrite a humanish.study.v3 document into humanish.lab.v2, or refuse it. */
32
- export declare function studyToV2(raw: Record<string, unknown>): Parsed<StudyDocument>;
23
+ /** The refusal for a study with no `actor` object. */
24
+ export declare const ACTOR_REQUIRED = "A study needs `actor:`, an object with at least `type`.";
25
+ /** Why `route` is not a route a study can declare, or undefined when it is one. */
26
+ export declare function routeNameReason(route: unknown): string | undefined;
33
27
  /**
34
28
  * The checks a v3 document passes before its sections parse: no v2 key and no unknown key, a
35
29
  * route, a mode, an `actor` object, a scenario string, and the `participants`, `surfaces` and
@@ -39,9 +33,4 @@ export declare function studyToV2(raw: Record<string, unknown>): Parsed<StudyDoc
39
33
  * parser does.
40
34
  */
41
35
  export declare function readStudyFront(raw: Record<string, unknown>, anySharedWorldParticipants?: boolean): Parsed<StudyFront>;
42
- /**
43
- * Spell a v2 parser message the way the study file is written: `actors[0].lanes[3]` becomes the
44
- * `participants` entry it came from, `execution.caps` and `scenario.caps` become `caps`, and so on.
45
- */
46
- export declare function studySpelling(message: string, route: StudyRoute, participantSource: readonly number[]): string;
47
36
  export {};
@@ -1,10 +1,9 @@
1
- // humanish.study.v3, the study format. A study declares its route, one `actor`, its `participants`
2
- // and one `caps` block. studyToV2 rewrites a v3 document into the v2 spelling, so both formats run
3
- // through one parser and parse into the same StudyConfig. parseStudy then checks the declared
4
- // route against the one the config takes, and refuses any field that route does not read.
1
+ // What a humanish.study.v3 document declares before its sections parse: its route, one `actor`,
2
+ // its `participants` or `surfaces`, and the `caps` keys the route reads. config.ts parses the
3
+ // sections after these checks pass.
5
4
  import { isRecord } from "../../run/type-guards.js";
6
5
  import { findUnknownStudyKey } from "../keys.js";
7
- import { V2_SCHEMA } from "../types.js";
6
+ import { movedV2KeyReason } from "../migrate/v2.js";
8
7
  import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
9
8
  import { invalid, posInt } from "./values.js";
10
9
  // The values `route:` takes: every route routeOf can return.
@@ -24,53 +23,13 @@ const CAPS_KEYS = {
24
23
  terminal: ["maxUsd", "maxJobs", "maxMinutes"],
25
24
  scripted: [],
26
25
  };
27
- /** Rewrite a humanish.study.v3 document into humanish.lab.v2, or refuse it. */
28
- export function studyToV2(raw) {
29
- const front = readStudyFront(raw);
30
- if (!front.ok)
31
- return front;
32
- const { route } = front.value;
33
- const { count, instruction, entries, source } = front.value.participants;
34
- // The v2 actor keys that hold participants.
35
- const v2Participants = [
36
- ["count", count ?? front.value.surfaces],
37
- ["laneFocus", instruction === undefined ? undefined : { instruction }],
38
- ["lanes", entries],
39
- ];
40
- const actor = { ...front.value.actor };
41
- for (const [key, value] of v2Participants)
42
- if (value !== undefined)
43
- actor[key] = value;
44
- const caps = raw.caps;
45
- const scenario = {};
46
- if (raw.scenario !== undefined)
47
- scenario.ref = raw.scenario;
48
- if (raw.mode !== undefined)
49
- scenario.mode = raw.mode;
50
- if (caps !== undefined && route === "terminal")
51
- scenario.caps = caps;
52
- const execution = caps !== undefined && route !== "terminal"
53
- ? withKey(raw.execution, "caps", caps)
54
- : raw.execution;
55
- const subject = route === "shared-world" ? withKey(raw.subject, "topology", "shared-world") : raw.subject;
56
- const v2 = { schema: V2_SCHEMA };
57
- const fields = [
58
- ["id", raw.id],
59
- ["title", raw.title],
60
- ["description", raw.description],
61
- ["subject", subject],
62
- ["actors", [actor]],
63
- ["execution", execution],
64
- ["scenario", Object.keys(scenario).length > 0 ? scenario : undefined],
65
- ["policies", raw.policies],
66
- ["review", raw.review],
67
- ["defaults", raw.defaults],
68
- ["comms", raw.comms],
69
- ];
70
- for (const [key, value] of fields)
71
- if (value !== undefined)
72
- v2[key] = value;
73
- return { ok: true, value: { route, v2, participantSource: source } };
26
+ /** The refusal for a study with no `actor` object. */
27
+ export const ACTOR_REQUIRED = "A study needs `actor:`, an object with at least `type`.";
28
+ /** Why `route` is not a route a study can declare, or undefined when it is one. */
29
+ export function routeNameReason(route) {
30
+ return ROUTES.some((name) => name === route)
31
+ ? undefined
32
+ : `A study needs \`route:\`, one of ${ROUTES.join(", ")}.`;
74
33
  }
75
34
  /**
76
35
  * The checks a v3 document passes before its sections parse: no v2 key and no unknown key, a
@@ -81,35 +40,33 @@ export function studyToV2(raw) {
81
40
  * parser does.
82
41
  */
83
42
  export function readStudyFront(raw, anySharedWorldParticipants = false) {
84
- const moved = movedKeyReason(raw);
43
+ const moved = movedV2KeyReason(raw);
85
44
  if (moved)
86
45
  return invalid(moved);
87
46
  const unknownKey = findUnknownStudyKey(raw);
88
47
  if (unknownKey)
89
48
  return invalid(unknownKey);
90
49
  const route = ROUTES.find((name) => name === raw.route);
91
- if (route === undefined) {
92
- return invalid(`A study needs \`route:\`, one of ${ROUTES.join(", ")}.`);
93
- }
50
+ if (route === undefined)
51
+ return invalid(routeNameReason(raw.route));
94
52
  if (raw.mode !== undefined && raw.mode !== "dry-run" && raw.mode !== "live") {
95
53
  return invalid("`mode` must be `dry-run` or `live`. Leave it out for a dry run.");
96
54
  }
97
- if (!isRecord(raw.actor)) {
98
- return invalid("A study needs `actor:`, an object with at least `type`.");
99
- }
55
+ if (!isRecord(raw.actor))
56
+ return invalid(ACTOR_REQUIRED);
100
57
  if (raw.scenario !== undefined && typeof raw.scenario !== "string") {
101
58
  return invalid("`scenario` is a scenario id or a path to one, as a string.");
102
59
  }
103
60
  const participants = participantsOf(anySharedWorldParticipants && route === "shared-world" ? "computer-use" : route, raw.participants);
104
61
  if (!participants.ok)
105
62
  return participants;
106
- const surfaces = surfaceCount(route, raw.surfaces);
63
+ const surfaces = surfacesOf(route, raw.surfaces);
107
64
  if (!surfaces.ok)
108
65
  return surfaces;
109
66
  const capsReason = capsKeysReason(route, raw.caps);
110
67
  if (capsReason)
111
68
  return invalid(capsReason);
112
- // The v2 inert-field table does not list this one, but the terminal planner never reads it.
69
+ // The inert-field rows (warnings.ts) do not list this one; the terminal planner never reads it.
113
70
  if (route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined) {
114
71
  return invalid("route: terminal does not read `execution.timeoutMs`: `caps.maxMinutes` is the command deadline. Remove it.");
115
72
  }
@@ -118,44 +75,6 @@ export function readStudyFront(raw, anySharedWorldParticipants = false) {
118
75
  value: { route, actor: raw.actor, participants: participants.value, surfaces: surfaces.value },
119
76
  };
120
77
  }
121
- /**
122
- * Spell a v2 parser message the way the study file is written: `actors[0].lanes[3]` becomes the
123
- * `participants` entry it came from, `execution.caps` and `scenario.caps` become `caps`, and so on.
124
- */
125
- export function studySpelling(message, route, participantSource) {
126
- return (message
127
- .replaceAll(/actors\[0\]\.lanes\[(\d+)\]/g, (_match, index) => `participants[${participantSource[Number(index)] ?? index}]`)
128
- // "an `actors[0].lanes` roster" reads "a `participants` list" in a v3 file.
129
- .replaceAll(/\ban `actors\[0\]\.(lanes|roster)` roster\b/g, "a `participants` list")
130
- .replaceAll(/actors\[0\]\.(lanes|roster)\b/g, "participants")
131
- .replaceAll(/actors\[0\]\.laneFocus(\.instruction)?/g, "participants.instruction")
132
- .replaceAll(/actors\[0\]\.count\b/g, route === "scripted" ? "surfaces" : "participants")
133
- .replaceAll("actors[0]", "actor")
134
- .replaceAll(/\b(execution|scenario)\.caps\b/g, "caps")
135
- .replaceAll(/\bscenario\.mode\b/g, "mode")
136
- .replaceAll(/\bscenario\.ref\b/g, "scenario")
137
- .replaceAll("subject.topology: shared-world", "route: shared-world"));
138
- }
139
- // The v2 keys a hand-converted file is most likely to keep, each with where it went.
140
- function movedKeyReason(raw) {
141
- if (raw.actors !== undefined) {
142
- return "A study has one `actor:` object, and its participants go in `participants:`. `actors:` is the humanish.lab.v2 spelling.";
143
- }
144
- if (isRecord(raw.actor)) {
145
- for (const key of ["count", "lanes", "roster", "laneFocus"]) {
146
- if (raw.actor[key] !== undefined) {
147
- return `\`actor.${key}\` moved to \`participants:\`: a count, \`{ count, instruction }\`, or a list of participants.`;
148
- }
149
- }
150
- }
151
- if (isRecord(raw.subject) && raw.subject.topology !== undefined) {
152
- return "`subject.topology` moved to `route:`. A shared world is `route: shared-world`.";
153
- }
154
- if (isRecord(raw.execution) && raw.execution.caps !== undefined) {
155
- return "`execution.caps` moved to a top-level `caps:` block.";
156
- }
157
- return undefined;
158
- }
159
78
  function participantsOf(route, raw) {
160
79
  if (raw === undefined) {
161
80
  if (route === "shared-world")
@@ -212,7 +131,7 @@ function homogeneousOf(raw) {
212
131
  return { ok: true, value: participants };
213
132
  }
214
133
  // A list entry with `count: n` is a group: n participants `<id>-01` to `<id>-NN`, even when n is 1.
215
- // The v2 parser then checks every entry and the expanded ids for collisions.
134
+ // config.ts then checks every entry and the expanded ids for collisions.
216
135
  function entriesOf(raw) {
217
136
  const entries = [];
218
137
  const source = [];
@@ -241,17 +160,17 @@ function entriesOf(raw) {
241
160
  }
242
161
  return { ok: true, value: { entries, source } };
243
162
  }
244
- // Scripted surfaces become the v2 count: 1 is desktop, 2 adds mobile.
245
- function surfaceCount(route, raw) {
163
+ function surfacesOf(route, raw) {
246
164
  if (raw === undefined)
247
165
  return { ok: true, value: undefined };
248
166
  if (route !== "scripted") {
249
167
  return invalid(`\`surfaces\` belongs to route: scripted. This study's route is ${route}; remove it.`);
250
168
  }
251
- if (Array.isArray(raw) && raw.length === 1 && raw[0] === "desktop")
252
- return { ok: true, value: 1 };
169
+ if (Array.isArray(raw) && raw.length === 1 && raw[0] === "desktop") {
170
+ return { ok: true, value: ["desktop"] };
171
+ }
253
172
  if (Array.isArray(raw) && raw.length === 2 && raw[0] === "desktop" && raw[1] === "mobile") {
254
- return { ok: true, value: 2 };
173
+ return { ok: true, value: ["desktop", "mobile"] };
255
174
  }
256
175
  return invalid("`surfaces` must be [desktop] or [desktop, mobile].");
257
176
  }
@@ -268,10 +187,3 @@ function capsKeysReason(route, raw) {
268
187
  return undefined;
269
188
  return `route: ${route} reads only ${read.map((key) => `caps.${key}`).join(", ")}. Remove ${unread.map((key) => `caps.${key}`).join(", ")}.`;
270
189
  }
271
- // Add one key to a section. A section that is not an object passes through unchanged, so the v2
272
- // parser refuses it with its own message.
273
- function withKey(section, key, value) {
274
- if (section === undefined)
275
- return { [key]: value };
276
- return isRecord(section) ? { ...section, [key]: value } : section;
277
- }
@@ -1,4 +1,6 @@
1
1
  import type { StudyParseFailure, StudySubject } from "../types.js";
2
+ /** True when `raw` is a subject mapping whose `source` parseSubject accepts. */
3
+ export declare function hasSubjectSource(raw: unknown): boolean;
2
4
  export declare function parseSubject(raw: unknown): {
3
5
  ok: true;
4
6
  value: StudySubject;
@@ -3,31 +3,28 @@ 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
5
  import { urlCredentialReason } from "./url-credentials.js";
6
+ const SOURCES = [
7
+ "this-repo",
8
+ "clone",
9
+ "app-url",
10
+ "local-app",
11
+ "desktop-cli",
12
+ "terminal-product",
13
+ "local-tree",
14
+ ];
15
+ /** True when `raw` is a subject mapping whose `source` parseSubject accepts. */
16
+ export function hasSubjectSource(raw) {
17
+ return isRecord(raw) && SOURCES.some((name) => name === str(raw.source));
18
+ }
6
19
  export function parseSubject(raw) {
7
20
  if (!isRecord(raw)) {
8
21
  return invalid("`subject` is required and must be an object.");
9
22
  }
10
- const source = str(raw.source);
11
- if (source !== "this-repo" &&
12
- source !== "clone" &&
13
- source !== "app-url" &&
14
- source !== "local-app" &&
15
- source !== "desktop-cli" &&
16
- source !== "terminal-product" &&
17
- source !== "local-tree") {
23
+ const source = SOURCES.find((name) => name === str(raw.source));
24
+ if (source === undefined) {
18
25
  return invalid("`subject.source` must be one of: this-repo, clone, app-url, local-app, terminal-product, desktop-cli, local-tree.");
19
26
  }
20
27
  const subject = { source };
21
- // topology is enum-validated everywhere; its semantics (shared-world requires clone × e2b-desktop
22
- // × a ≥2 roster) are enforced in the shared-world cross-validation below, and a set-but-unconsumed
23
- // topology warns as inert off the shared-world route.
24
- if (raw.topology !== undefined) {
25
- const topology = str(raw.topology);
26
- if (topology !== "per-lane-worlds" && topology !== "shared-world") {
27
- return invalid("`subject.topology` must be per-lane-worlds (the default) or shared-world.");
28
- }
29
- subject.topology = topology;
30
- }
31
28
  // exposure is enum-validated everywhere; it is required on the concurrent shared-world route (the
32
29
  // getHost synthetic-subject attestation) and warns inert elsewhere.
33
30
  if (raw.exposure !== undefined) {
@@ -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,8 +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 { rosterOf } from "./parse/actors.js";
9
- import { actorsOf } from "./study-fields.js";
8
+ import { participantList } from "./study-fields.js";
10
9
  /** Persona ids are file-name segments, never paths: the same grammar the terminal route enforces. */
11
10
  const PERSONA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
12
11
  /** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
@@ -87,13 +86,12 @@ export async function resolveCommittedPersonas(projectRoot, personaIds) {
87
86
  */
88
87
  export function studyPersonaIds(config) {
89
88
  const ids = [];
90
- for (const actor of actorsOf(config)) {
91
- if (actor.persona)
92
- ids.push(actor.persona);
93
- for (const entry of rosterOf(actor) ?? []) {
94
- if (entry.persona)
95
- ids.push(entry.persona);
96
- }
89
+ const persona = config.actor?.persona;
90
+ if (persona)
91
+ ids.push(persona);
92
+ for (const entry of participantList(config) ?? []) {
93
+ if (entry.persona)
94
+ ids.push(entry.persona);
97
95
  }
98
96
  return [...new Set(ids)];
99
97
  }
@@ -2,7 +2,7 @@ import type { Brain, ComputerUsePlan, NonEmpty, PlannedAnalysis, ProvisionedSubj
2
2
  import type { StudyConfig } from "./types.js";
3
3
  export type Base = Omit<ComputerUsePlan, "route" | "actor" | "runner" | "concurrency" | "sessionBudgetMs" | "sandboxMs" | "caps" | "rerun">;
4
4
  export declare function isNonEmpty<T>(values: readonly T[]): values is NonEmpty<T>;
5
- export declare function planCaps(config: StudyConfig, route: "computer-use" | "shared-world"): ComputerUsePlan["caps"];
5
+ export declare function planCaps(config: StudyConfig): ComputerUsePlan["caps"];
6
6
  export declare function provisionedSubject(config: StudyConfig): ProvisionedSubject | undefined;
7
7
  /** Keys, env and local tools a live run checks right before it acquires anything. */
8
8
  export declare function desktopRequirements(config: StudyConfig, args: {