humanish 0.0.1 → 0.15.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 (205) hide show
  1. package/AGENTS.md +77 -0
  2. package/CONTRIBUTING.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +437 -8
  5. package/SECURITY.md +34 -0
  6. package/dist/actor-contract.d.ts +107 -0
  7. package/dist/actor-contract.js +286 -0
  8. package/dist/actor-contract.js.map +1 -0
  9. package/dist/actor-registry.d.ts +73 -0
  10. package/dist/actor-registry.js +93 -0
  11. package/dist/actor-registry.js.map +1 -0
  12. package/dist/adapter-extension.d.ts +54 -0
  13. package/dist/adapter-extension.js +214 -0
  14. package/dist/adapter-extension.js.map +1 -0
  15. package/dist/argv.d.ts +1 -0
  16. package/dist/argv.js +8 -0
  17. package/dist/argv.js.map +1 -0
  18. package/dist/artifact-reference.d.ts +22 -0
  19. package/dist/artifact-reference.js +47 -0
  20. package/dist/artifact-reference.js.map +1 -0
  21. package/dist/browser-evidence-hygiene.d.ts +7 -0
  22. package/dist/browser-evidence-hygiene.js +40 -0
  23. package/dist/browser-evidence-hygiene.js.map +1 -0
  24. package/dist/claude-agent-sdk.d.ts +94 -0
  25. package/dist/claude-agent-sdk.js +363 -0
  26. package/dist/claude-agent-sdk.js.map +1 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +5 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/codex-app-server-ui.d.ts +39 -0
  31. package/dist/codex-app-server-ui.js +357 -0
  32. package/dist/codex-app-server-ui.js.map +1 -0
  33. package/dist/codex-app-server.d.ts +144 -0
  34. package/dist/codex-app-server.js +754 -0
  35. package/dist/codex-app-server.js.map +1 -0
  36. package/dist/command-failure.d.ts +29 -0
  37. package/dist/command-failure.js +54 -0
  38. package/dist/command-failure.js.map +1 -0
  39. package/dist/computer-use-actor.d.ts +52 -0
  40. package/dist/computer-use-actor.js +48 -0
  41. package/dist/computer-use-actor.js.map +1 -0
  42. package/dist/computer-use.d.ts +232 -0
  43. package/dist/computer-use.js +615 -0
  44. package/dist/computer-use.js.map +1 -0
  45. package/dist/concurrency.d.ts +1 -0
  46. package/dist/concurrency.js +27 -0
  47. package/dist/concurrency.js.map +1 -0
  48. package/dist/concurrent-shared-world-lab.d.ts +115 -0
  49. package/dist/concurrent-shared-world-lab.js +1094 -0
  50. package/dist/concurrent-shared-world-lab.js.map +1 -0
  51. package/dist/core/git-state.d.ts +33 -0
  52. package/dist/core/git-state.js +277 -0
  53. package/dist/core/git-state.js.map +1 -0
  54. package/dist/core/git-workspace.d.ts +31 -0
  55. package/dist/core/git-workspace.js +447 -0
  56. package/dist/core/git-workspace.js.map +1 -0
  57. package/dist/core/index.d.ts +4 -0
  58. package/dist/core/index.js +3 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/core/run-primitives.d.ts +66 -0
  61. package/dist/core/run-primitives.js +120 -0
  62. package/dist/core/run-primitives.js.map +1 -0
  63. package/dist/cua-actor-lab.d.ts +621 -0
  64. package/dist/cua-actor-lab.js +2845 -0
  65. package/dist/cua-actor-lab.js.map +1 -0
  66. package/dist/device-presets.d.ts +67 -0
  67. package/dist/device-presets.js +50 -0
  68. package/dist/device-presets.js.map +1 -0
  69. package/dist/e2b-desktop-executor.d.ts +101 -0
  70. package/dist/e2b-desktop-executor.js +309 -0
  71. package/dist/e2b-desktop-executor.js.map +1 -0
  72. package/dist/e2b-desktop-launch.d.ts +144 -0
  73. package/dist/e2b-desktop-launch.js +59 -0
  74. package/dist/e2b-desktop-launch.js.map +1 -0
  75. package/dist/e2b-detached.d.ts +53 -0
  76. package/dist/e2b-detached.js +130 -0
  77. package/dist/e2b-detached.js.map +1 -0
  78. package/dist/e2b-terminal-lab.d.ts +345 -0
  79. package/dist/e2b-terminal-lab.js +1532 -0
  80. package/dist/e2b-terminal-lab.js.map +1 -0
  81. package/dist/env-file.d.ts +14 -0
  82. package/dist/env-file.js +108 -0
  83. package/dist/env-file.js.map +1 -0
  84. package/dist/feedback.d.ts +51 -0
  85. package/dist/feedback.js +351 -0
  86. package/dist/feedback.js.map +1 -0
  87. package/dist/image-evidence.d.ts +2 -0
  88. package/dist/image-evidence.js +33 -0
  89. package/dist/image-evidence.js.map +1 -0
  90. package/dist/index.d.ts +63 -0
  91. package/dist/index.js +33 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/init-templates.d.ts +12 -0
  94. package/dist/init-templates.js +369 -0
  95. package/dist/init-templates.js.map +1 -0
  96. package/dist/init.d.ts +26 -0
  97. package/dist/init.js +429 -0
  98. package/dist/init.js.map +1 -0
  99. package/dist/lab-app-runner.d.ts +78 -0
  100. package/dist/lab-app-runner.js +403 -0
  101. package/dist/lab-app-runner.js.map +1 -0
  102. package/dist/lab-config.d.ts +583 -0
  103. package/dist/lab-config.js +1789 -0
  104. package/dist/lab-config.js.map +1 -0
  105. package/dist/lab-engine.d.ts +77 -0
  106. package/dist/lab-engine.js +216 -0
  107. package/dist/lab-engine.js.map +1 -0
  108. package/dist/lab-preflight.d.ts +67 -0
  109. package/dist/lab-preflight.js +385 -0
  110. package/dist/lab-preflight.js.map +1 -0
  111. package/dist/labs.d.ts +53 -0
  112. package/dist/labs.js +382 -0
  113. package/dist/labs.js.map +1 -0
  114. package/dist/observer-assets.d.ts +2 -0
  115. package/dist/observer-assets.js +2630 -0
  116. package/dist/observer-assets.js.map +1 -0
  117. package/dist/observer-data.d.ts +63 -0
  118. package/dist/observer-data.js +145 -0
  119. package/dist/observer-data.js.map +1 -0
  120. package/dist/observer-static.d.ts +39 -0
  121. package/dist/observer-static.js +306 -0
  122. package/dist/observer-static.js.map +1 -0
  123. package/dist/observer.d.ts +46 -0
  124. package/dist/observer.js +550 -0
  125. package/dist/observer.js.map +1 -0
  126. package/dist/openai-responses-cu.d.ts +113 -0
  127. package/dist/openai-responses-cu.js +545 -0
  128. package/dist/openai-responses-cu.js.map +1 -0
  129. package/dist/oss-lab.d.ts +52 -0
  130. package/dist/oss-lab.js +344 -0
  131. package/dist/oss-lab.js.map +1 -0
  132. package/dist/oss-meta-lab-scoring.d.ts +15 -0
  133. package/dist/oss-meta-lab-scoring.js +145 -0
  134. package/dist/oss-meta-lab-scoring.js.map +1 -0
  135. package/dist/oss-meta-lab.d.ts +277 -0
  136. package/dist/oss-meta-lab.js +4529 -0
  137. package/dist/oss-meta-lab.js.map +1 -0
  138. package/dist/oss-remote-telemetry.d.ts +77 -0
  139. package/dist/oss-remote-telemetry.js +403 -0
  140. package/dist/oss-remote-telemetry.js.map +1 -0
  141. package/dist/persona.d.ts +35 -0
  142. package/dist/persona.js +133 -0
  143. package/dist/persona.js.map +1 -0
  144. package/dist/pi-agent-core.d.ts +77 -0
  145. package/dist/pi-agent-core.js +203 -0
  146. package/dist/pi-agent-core.js.map +1 -0
  147. package/dist/program.d.ts +54 -0
  148. package/dist/program.js +2349 -0
  149. package/dist/program.js.map +1 -0
  150. package/dist/redaction.d.ts +90 -0
  151. package/dist/redaction.js +292 -0
  152. package/dist/redaction.js.map +1 -0
  153. package/dist/run-paths.d.ts +56 -0
  154. package/dist/run-paths.js +340 -0
  155. package/dist/run-paths.js.map +1 -0
  156. package/dist/run.d.ts +794 -0
  157. package/dist/run.js +4844 -0
  158. package/dist/run.js.map +1 -0
  159. package/dist/scripted-browser-actor.d.ts +297 -0
  160. package/dist/scripted-browser-actor.js +1399 -0
  161. package/dist/scripted-browser-actor.js.map +1 -0
  162. package/dist/scripted-browser-lab.d.ts +107 -0
  163. package/dist/scripted-browser-lab.js +788 -0
  164. package/dist/scripted-browser-lab.js.map +1 -0
  165. package/dist/selected-output-paths.d.ts +49 -0
  166. package/dist/selected-output-paths.js +354 -0
  167. package/dist/selected-output-paths.js.map +1 -0
  168. package/dist/shared-world-lab.d.ts +208 -0
  169. package/dist/shared-world-lab.js +1049 -0
  170. package/dist/shared-world-lab.js.map +1 -0
  171. package/dist/source-archive.d.ts +82 -0
  172. package/dist/source-archive.js +408 -0
  173. package/dist/source-archive.js.map +1 -0
  174. package/dist/stop-conditions.d.ts +26 -0
  175. package/dist/stop-conditions.js +66 -0
  176. package/dist/stop-conditions.js.map +1 -0
  177. package/dist/terminal-agent-actor.d.ts +36 -0
  178. package/dist/terminal-agent-actor.js +23 -0
  179. package/dist/terminal-agent-actor.js.map +1 -0
  180. package/docs/architecture/actor-contract.md +431 -0
  181. package/docs/architecture/github-feedback-loop.md +189 -0
  182. package/docs/architecture/local-codex-tui-actor.md +213 -0
  183. package/docs/architecture/observer.md +118 -0
  184. package/docs/architecture/oss-lab-poc.md +250 -0
  185. package/docs/architecture/project-layout.md +166 -0
  186. package/docs/architecture/state-driven-executor.md +203 -0
  187. package/docs/architecture/terminal-product-lane.md +148 -0
  188. package/docs/contracts/adapter-fixtures.md +80 -0
  189. package/docs/contracts/core.md +71 -0
  190. package/docs/contracts/feedback.md +145 -0
  191. package/docs/contracts/policy.md +311 -0
  192. package/docs/contracts/run-bundle.md +358 -0
  193. package/docs/contracts/schemas.md +984 -0
  194. package/docs/goals/current.md +428 -0
  195. package/docs/principles/invariants-and-defaults.md +135 -0
  196. package/docs/principles/self-driving-harness.md +129 -0
  197. package/docs/product/open-source-install-experience.md +237 -0
  198. package/docs/ramp/README.md +207 -0
  199. package/docs/release/open-source-readiness.md +208 -0
  200. package/docs/release/public-readiness-standard.md +205 -0
  201. package/docs/roadmap/world-class-open-source-v0.md +311 -0
  202. package/package.json +102 -8
  203. package/skills/humanish/SKILL.md +244 -0
  204. package/skills/humanish/agents/openai.yaml +7 -0
  205. package/index.js +0 -1
@@ -0,0 +1,1789 @@
1
+ // humanish.lab.v2 — a lab is a COMPOSITION over code primitives, not a hardcoded kind.
2
+ //
3
+ // HONEST SCOPE (read before trusting field names): the engine routes by
4
+ // subject.source × execution.target (disambiguated by the actor lane where both axes
5
+ // collide) and consumes a deliberately small set of fields:
6
+ // subject.source/repos/appUrl/serve/env/state/clone.{depth,fanout,keep}, actors[0].count,
7
+ // execution.target + execution.desktop.codexAppServer, scenario.mode,
8
+ // policies.redactRepos, defaults.open.
9
+ // On the computer-use routes (app-url × e2b-desktop, and clone × e2b-desktop with a
10
+ // computer-use actor), `actors[0].type` IS load-bearing: it must resolve to a registered
11
+ // computer-use actor, and that descriptor runs the session. Those routes also consume
12
+ // actors[0].{mission,persona,laneFocus.instruction,model}, execution.timeoutMs,
13
+ // execution.desktop.{browser,resolution,sandboxTimeoutMs}, and (clone)
14
+ // subject.{serve,env,state,clone.depth}.
15
+ // On the scripted-browser route (app-url × local-or-absent, or clone × e2b-desktop, with a
16
+ // registered scripted-browser actor), `actors[0].type` is equally load-bearing, and the route
17
+ // consumes scenario.ref (REQUIRED there — the committed scenario's browser steps ARE what the
18
+ // actor executes), actors[0].{persona,count}, and execution.timeoutMs. On the provisioned
19
+ // clone slice it also consumes subject.{repos,serve,env,state,exposure,clone.depth} and
20
+ // execution.desktop.template. actors[0].{mission,laneFocus,model} are inert on that route
21
+ // because no model runs, and most execution.desktop.* fields remain forward-declared (device
22
+ // presets belong to the cua route — scripted surfaces are the driver's own desktop/mobile
23
+ // viewports where isMobile/DSF genuinely RENDER via playwright emulation).
24
+ // On the other routes those fields remain FORWARD-DECLARED and NOT yet consumed —
25
+ // parseLabConfig emits a warning listing any such field that is set, so `lab inspect` shows
26
+ // the truth.
27
+ //
28
+ // NOTE on actors[0].count: it now carries ROUTE-SPECIFIC meanings — synthetic route: simCount;
29
+ // scripted-browser route: surface roster {1 = desktop, 2 = desktop + mobile}, default 1 (the
30
+ // defaults-table single-lane row governs; count: 2 is the declared override); computer-use
31
+ // E2B route: the HOMOGENEOUS fan-out lane count (N identical lanes, each its own E2B desktop),
32
+ // capped at 16; the in-process/local-app cua route stays single lane (no E2B to fan out).
33
+ //
34
+ // NOTE on actors[0].lanes / actors[0].roster (computer-use E2B route, this slice): a
35
+ // DIFFERENTIATED fan-out roster — each `{ id?, persona?, device?, instruction?, target? }` becomes one
36
+ // independent E2B desktop (per-lane worlds, the default topology). `roster[]` is parser sugar for
37
+ // repeated groups and is normalized into `lanes[]` before the engine sees it. `lanes|roster` XOR
38
+ // `count` (declare a differentiated roster OR a homogeneous count, never both); `lanes|roster`
39
+ // XOR `actors[0].laneFocus` (per-lane `instruction` is the roster's steer); `lanes[].device` XOR
40
+ // raw `execution.desktop.resolution`. `execution.concurrency` bounds in-flight lanes (default
41
+ // min(laneCount, 3); env HUMANISH_CUA_MAX_CONCURRENCY may only LOWER it — invariant 3). On every
42
+ // non-cua route normalized `lanes` are inert (warned). subject.clone.fanout is REJECTED on the cua
43
+ // route. `lanes[].target` is app-url × computer-use ONLY: an absolute browser URL this lane opens
44
+ // instead of `subject.appUrl`; it is the generic setup-produced-target handoff, not a service
45
+ // topology primitive.
46
+ //
47
+ // There is deliberately NO v1 compatibility: v1 had zero real users. Breaking schema changes
48
+ // bump the version honestly.
49
+ import { normalizeExtraExcludeEntry } from "./source-archive.js";
50
+ import { actorRegistry } from "./actor-registry.js";
51
+ import { DEVICE_PRESET_NAMES, isDevicePresetName } from "./device-presets.js";
52
+ export const LAB_CONFIG_SCHEMA = "humanish.lab.v2";
53
+ // Must start alphanumeric so an id never collides with the path-vs-id resolver heuristic
54
+ // (a leading "." or "/" is read as a file path; a leading "-" collides with CLI flags).
55
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
56
+ /**
57
+ * Validate a parsed YAML object into a LabConfig. Pure: the caller owns file IO. Structural
58
+ * validation only. Fields the engine does not yet consume are accepted but reported in
59
+ * `warnings` so `lab inspect` never silently swallows a setting that does nothing.
60
+ */
61
+ export function parseLabConfig(raw) {
62
+ if (!isRecord(raw)) {
63
+ return invalid("Lab manifest must be a YAML object.");
64
+ }
65
+ if (raw.schema !== LAB_CONFIG_SCHEMA) {
66
+ return invalid(`Lab schema must be ${LAB_CONFIG_SCHEMA}.`);
67
+ }
68
+ const id = str(raw.id);
69
+ if (!id || !ID_PATTERN.test(id)) {
70
+ return invalid("Lab id must be a public-safe token starting with a letter or digit (/^[A-Za-z0-9][A-Za-z0-9_.-]*$/).");
71
+ }
72
+ const subjectResult = parseSubject(raw.subject);
73
+ if (!subjectResult.ok) {
74
+ return subjectResult;
75
+ }
76
+ const actorsResult = parseActors(raw.actors);
77
+ if (!actorsResult.ok) {
78
+ return actorsResult;
79
+ }
80
+ const executionResult = parseExecution(raw.execution);
81
+ if (!executionResult.ok) {
82
+ return executionResult;
83
+ }
84
+ const config = {
85
+ schema: LAB_CONFIG_SCHEMA,
86
+ id,
87
+ ...optionalStr("title", raw.title),
88
+ ...optionalStr("description", raw.description),
89
+ subject: subjectResult.value,
90
+ actors: actorsResult.value,
91
+ ...(executionResult.value ? { execution: executionResult.value } : {})
92
+ };
93
+ const personas = parsePersonas(raw.personas);
94
+ if (personas)
95
+ config.personas = personas;
96
+ const scenarioResult = parseScenario(raw.scenario);
97
+ if (!scenarioResult.ok) {
98
+ return scenarioResult;
99
+ }
100
+ if (scenarioResult.value)
101
+ config.scenario = scenarioResult.value;
102
+ const policies = parsePolicies(raw.policies);
103
+ if (policies)
104
+ config.policies = policies;
105
+ const review = parseReview(raw.review);
106
+ if (review)
107
+ config.review = review;
108
+ const defaults = parseDefaults(raw.defaults);
109
+ if (defaults)
110
+ config.defaults = defaults;
111
+ // this-repo subjects run locally and dry-run only — there is no live execution target for the
112
+ // host repo (clone/app-url provide that). Reject the mis-configs rather than silently mishandle.
113
+ if (config.subject.source === "this-repo") {
114
+ if (config.execution?.target) {
115
+ return invalid("`execution.target` applies only to clone/app-url/local-app subjects; this-repo labs run locally.");
116
+ }
117
+ if (config.scenario?.mode === "live") {
118
+ return invalid("this-repo labs are dry-run only; use a clone or app-url subject for a live run.");
119
+ }
120
+ }
121
+ // local-app route: an already-running LOCAL dev server driven IN-PROCESS via a custom
122
+ // CuaExecutor (no clone, no E2B desktop). Parse-validated fail-closed: a computer-use actor
123
+ // only, execution.target local or absent (NEVER e2b-desktop — the whole point is to skip the
124
+ // desktop), and no public-target policy (it is always loopback; the loopback shape was already
125
+ // enforced in parseSubject). The actual "no buildExecutor hook supplied" case is inherently an
126
+ // engine-time decision (the parser cannot know whether a library caller will pass hooks), so
127
+ // it fails closed in runCuaActorLab with HUMANISH_CUA_LAB_LOCAL_APP_NO_EXECUTOR.
128
+ if (config.subject.source === "local-app") {
129
+ const type = config.actors[0]?.type ?? "";
130
+ if (config.execution?.target !== undefined && config.execution.target !== "local") {
131
+ return invalid("local-app subjects drive an in-process LOCAL dev server with NO E2B desktop — set `execution.target: local` or omit it (absent means local); `e2b-desktop` is rejected (use an app-url subject for the hosted-desktop route).");
132
+ }
133
+ if (!actorResolvesToComputerUse(type)) {
134
+ return invalid(`actors[0].type must be a registered computer-use actor for local-app subjects (one of: ${registeredComputerUseActors().join(", ")}); the caller's custom executor runs the computer-use loop. Got "${type}".`);
135
+ }
136
+ if (cuaLaneCount(config) > 1) {
137
+ return invalid("Multi-lane fan-out is not supported on the in-process/local-app route — fan-out provisions one independent E2B desktop per lane, which the in-process route deliberately skips; set actors[0].count to 1 and drop actors[0].lanes (use an app-url or clone subject on execution.target: e2b-desktop for fan-out).");
138
+ }
139
+ if (config.actors[0]?.lanes !== undefined) {
140
+ return invalid("`actors[0].lanes` (fan-out roster) is not supported on the in-process/local-app route — it provisions one E2B desktop per lane, which this route skips. Use an app-url or clone subject with execution.target: e2b-desktop.");
141
+ }
142
+ if (config.policies?.allowPublicTargets === true) {
143
+ return invalid("`policies.allowPublicTargets` is not supported on the local-app route — a local-app subject is always a loopback dev server; there is no public target to allow.");
144
+ }
145
+ }
146
+ // app-url routes: the actor type is a REAL dispatch key (registry-resolved). The actor LANE
147
+ // picks the substrate: a scripted-browser actor runs locally against the declared loopback
148
+ // app; a computer-use actor drives a hosted desktop browser. Fail closed on mis-configs.
149
+ if (config.subject.source === "app-url") {
150
+ const type = config.actors[0]?.type ?? "";
151
+ if (actorResolvesToScriptedBrowser(type)) {
152
+ // Scripted-browser route (all fail-closed: invariant 6 — a field that cannot act on
153
+ // this route is rejected, never silently ignored).
154
+ if (config.execution?.target !== undefined && config.execution.target !== "local") {
155
+ return invalid("scripted-browser actors run on the operator's machine — set `execution.target: local` or omit it (absent means local); in-sandbox scripted execution is a later slice.");
156
+ }
157
+ if (!config.scenario?.ref) {
158
+ return invalid("scripted-browser labs require `scenario.ref` — the committed scenario's browser steps are what this actor executes; there is no built-in fallback on the lab route.");
159
+ }
160
+ if ((config.actors[0]?.count ?? 1) > 2) {
161
+ return invalid("scripted-browser labs support actors[0].count of 1 (desktop surface) or 2 (desktop + mobile); larger fan-out is a later slice.");
162
+ }
163
+ if (config.policies?.redactScreenshots === true) {
164
+ return invalid("`policies.redactScreenshots: true` is not implemented on the scripted-browser route yet — screenshots persist raw in gitignored .humanish; a silently ignored redaction policy would be a safety lie, so it is rejected.");
165
+ }
166
+ if (config.policies?.allowPublicTargets === true) {
167
+ return invalid("`policies.allowPublicTargets` is not supported on the scripted-browser route — the scripted step driver enforces loopback at every navigation; public targets on this route are a later slice.");
168
+ }
169
+ if (!isLoopbackUrl(config.subject.appUrl ?? "")) {
170
+ return invalid("`subject.appUrl` must be a loopback URL (127.0.0.1/localhost) on the scripted-browser route.");
171
+ }
172
+ }
173
+ else {
174
+ if (config.execution?.target !== "e2b-desktop") {
175
+ return invalid("app-url subjects require `execution.target: e2b-desktop` with a registered computer-use actor (the actor drives a hosted desktop browser), or a registered scripted-browser actor for local execution.");
176
+ }
177
+ if (!actorResolvesToComputerUse(type)) {
178
+ return invalid(`actors[0].type must be a registered computer-use actor for app-url × e2b-desktop labs (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`);
179
+ }
180
+ // Multi-lane fan-out is CONSUMED on this route (per-lane worlds; the shared cua-lane
181
+ // cross-validation below enforces lanes/count XOR rules, the 16 cap, and the
182
+ // lane-level target gates, and the allowPublicTargets+N>1 rejection for ambiguous one-target
183
+ // fan-out).
184
+ // Loopback by default; an owner may declare a public/preview target via policies.
185
+ const laneTargets = declaredLaneTargets(config);
186
+ const declaredTargets = [config.subject.appUrl ?? "", ...laneTargets];
187
+ const unsafeTarget = declaredTargets.find((target) => !config.policies?.allowPublicTargets && !isLoopbackUrl(target));
188
+ if (unsafeTarget !== undefined) {
189
+ return invalid("`subject.appUrl` and `actors[0].lanes[].target` must be loopback URLs (127.0.0.1/localhost) unless `policies.allowPublicTargets: true` is set — set it to drive deployed/preview URLs you own.");
190
+ }
191
+ }
192
+ }
193
+ else if (actorResolvesToScriptedBrowser(config.actors[0]?.type)) {
194
+ if (config.subject.source !== "clone") {
195
+ return invalid("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).");
196
+ }
197
+ if (config.execution?.target !== "e2b-desktop") {
198
+ return invalid("clone subjects with scripted-browser actors require `execution.target: e2b-desktop` — the lab provisions the clone in E2B, exposes it with getHost, then drives deterministic browser steps.");
199
+ }
200
+ if (!config.subject.serve) {
201
+ return invalid("clone subjects with scripted-browser actors require `subject.serve` (start + url) — the lab serves the app in-sandbox before the scripted browser drives it.");
202
+ }
203
+ if ((config.subject.repos?.length ?? 0) !== 1) {
204
+ return invalid("clone scripted-browser labs require exactly one repo in subject.repos.");
205
+ }
206
+ const repo = config.subject.repos?.[0] ?? "";
207
+ if (!REPO_SLUG_PATTERN.test(repo)) {
208
+ return invalid(`subject.repos[0] must be an owner/repo slug (got "${repo}").`);
209
+ }
210
+ if (config.subject.topology !== undefined) {
211
+ return invalid("clone scripted-browser labs do not support `subject.topology` yet — this slice provisions one synthetic subject and one deterministic scripted actor roster, not a shared-world run.");
212
+ }
213
+ if (config.subject.clone?.fanout !== undefined || config.subject.clone?.keep === true) {
214
+ return invalid("clone scripted-browser labs do not support `subject.clone.fanout` or `subject.clone.keep` yet — the provisioned subject is always a single disposable E2B sandbox.");
215
+ }
216
+ if (!config.scenario?.ref) {
217
+ return invalid("scripted-browser labs require `scenario.ref` — the committed scenario's browser steps are what this actor executes; there is no built-in fallback on the lab route.");
218
+ }
219
+ if ((config.actors[0]?.count ?? 1) > 2) {
220
+ return invalid("scripted-browser labs support actors[0].count of 1 (desktop surface) or 2 (desktop + mobile); larger fan-out is a later slice.");
221
+ }
222
+ if (config.actors[0]?.lanes !== undefined) {
223
+ return invalid("`actors[0].lanes` is not supported on the scripted-browser route yet — use actors[0].count for the deterministic surface roster.");
224
+ }
225
+ if (config.policies?.redactScreenshots === true) {
226
+ return invalid("`policies.redactScreenshots: true` is not implemented on the scripted-browser route yet — screenshots persist raw in gitignored .humanish; a silently ignored redaction policy would be a safety lie, so it is rejected.");
227
+ }
228
+ if (config.policies?.allowPublicTargets === true) {
229
+ return invalid("`policies.allowPublicTargets` is not supported on the clone scripted-browser route — the only external host is the harness-minted getHost URL for a provisioned synthetic subject.");
230
+ }
231
+ if (config.subject.exposure !== "synthetic") {
232
+ return invalid("clone scripted-browser labs require `subject.exposure: synthetic` — the subject is exposed on an internet-reachable getHost URL for the run, so the author must attest it is synthetic seeded data.");
233
+ }
234
+ if (!config.subject.state?.seed || config.subject.state.seed.length === 0 || (config.subject.state.external?.length ?? 0) > 0) {
235
+ return invalid("clone scripted-browser labs require `subject.state.seed` and do not allow `subject.state.external` — getHost-exposed subjects must be synthetic seeded data, not external/unpinned state.");
236
+ }
237
+ if (!config.subject.serve.start.includes("0.0.0.0")) {
238
+ return invalid("clone scripted-browser labs require `subject.serve.start` to bind all interfaces (e.g. `-H 0.0.0.0` / `--host 0.0.0.0` / `HOST=0.0.0.0`) — getHost only routes to a 0.0.0.0-bound port; the readiness probe stays loopback.");
239
+ }
240
+ }
241
+ // clone × e2b-desktop disambiguates on the actor lane: a computer-use actor means the lab
242
+ // clones AND serves the subject in-sandbox, then drives it (the meta route otherwise).
243
+ if (config.subject.source === "clone" && config.execution?.target === "e2b-desktop"
244
+ && actorResolvesToComputerUse(config.actors[0]?.type)) {
245
+ if (!config.subject.serve) {
246
+ return invalid("clone subjects on the computer-use route require `subject.serve` (start + url) — the lab serves the app in-sandbox before the actor drives it.");
247
+ }
248
+ if ((config.subject.repos?.length ?? 0) !== 1) {
249
+ return invalid("computer-use clone labs run a single lane; declare exactly one repo in subject.repos.");
250
+ }
251
+ const repo = config.subject.repos?.[0] ?? "";
252
+ if (!REPO_SLUG_PATTERN.test(repo)) {
253
+ return invalid(`subject.repos[0] must be an owner/repo slug (got "${repo}").`);
254
+ }
255
+ // Fan-out is CONSUMED here: N lanes each clone the SAME single repo into their own E2B
256
+ // desktop (per-lane worlds). The shared cua-lane cross-validation below enforces the
257
+ // lanes/count rules and the 16 cap; the single-repo rule above is unchanged.
258
+ }
259
+ // local-tree route: packs and uploads the operator's own working tree, then serves it exactly
260
+ // like a computer-use clone subject. There is no smoke/meta/scripted equivalent for a packed
261
+ // working tree in this slice, so e2b-desktop + a computer-use actor are the ONLY combination
262
+ // this source supports. `subject.serve` is already required at parse time (parseSubject); the
263
+ // repos/clone rejection also already happened there (local-tree never carries git slugs).
264
+ if (config.subject.source === "local-tree") {
265
+ if (config.execution?.target !== "e2b-desktop") {
266
+ return invalid("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/smoke route for a local-tree subject.");
267
+ }
268
+ if (!actorResolvesToComputerUse(config.actors[0]?.type)) {
269
+ return invalid(`actors[0].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.actors[0]?.type ?? ""}".`);
270
+ }
271
+ }
272
+ // Shared computer-use fan-out cross-validation (per-lane worlds, the only topology this
273
+ // slice). Runs for every route that resolves to the cua backend (app-url, clone, local-app).
274
+ // The in-process/local-app route already forced a single lane above, so this is a no-op there
275
+ // beyond rejecting the same fields; on the E2B routes it enforces the roster contract.
276
+ if (routesToComputerUse(config)) {
277
+ const reason = cuaLaneValidationReason(config);
278
+ if (reason) {
279
+ return invalid(reason);
280
+ }
281
+ }
282
+ // Shared-world topology cross-validation (#164). Runs whenever shared-world is DECLARED (not just
283
+ // when it routes), so a half-declared shared-world fails closed with a precise reason rather than
284
+ // silently downgrading to a per-lane-worlds cua run. With `execution.concurrency > 1` the
285
+ // concurrent extras (synthetic-subject attestation, 0.0.0.0 serve bind, no clone.keep) also apply.
286
+ if (config.subject.topology === "shared-world") {
287
+ const reason = (config.execution?.concurrency ?? 1) > 1
288
+ ? concurrentSharedWorldValidationReason(config)
289
+ : sharedWorldValidationReason(config);
290
+ if (reason) {
291
+ return invalid(reason);
292
+ }
293
+ }
294
+ // terminal-product route: a real autonomous agent studies a CLI/product from PUBLIC surfaces
295
+ // inside an E2B shell. Fail-closed (invariant 6 — a field that cannot act on this route is an
296
+ // honest parse error): a registered terminal actor only, execution.target e2b-terminal or absent
297
+ // (absent defaults to e2b-terminal — the only honest target for an in-sandbox agent), single
298
+ // lane until fan-out lands.
299
+ if (config.subject.source === "terminal-product") {
300
+ const type = config.actors[0]?.type ?? "";
301
+ if (config.execution?.target !== undefined && config.execution.target !== "e2b-terminal") {
302
+ return invalid("terminal-product subjects run the agent inside an E2B shell — set `execution.target: e2b-terminal` or omit it (absent means e2b-terminal); `local`/`e2b-desktop` are rejected.");
303
+ }
304
+ if (!actorResolvesToTerminal(type)) {
305
+ return invalid(`actors[0].type must be a registered terminal actor for terminal-product subjects (one of: ${registeredTerminalActors().join(", ")}). Got "${type}".`);
306
+ }
307
+ if ((config.actors[0]?.count ?? 1) > 1) {
308
+ return invalid("Multi-lane terminal fan-out is not supported yet; set actors[0].count to 1.");
309
+ }
310
+ }
311
+ else if (config.execution?.target === "e2b-terminal") {
312
+ // e2b-terminal is the terminal-product substrate ONLY. Any other source declaring it is a
313
+ // mis-config — reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
314
+ return invalid("`execution.target: e2b-terminal` requires `subject.source: terminal-product` with a registered terminal actor.");
315
+ }
316
+ else if (actorResolvesToTerminal(config.actors[0]?.type)) {
317
+ // A registered terminal actor on a non-terminal-product subject: rejected, never ignored (the
318
+ // terminal agent only studies a declared terminal-product from public surfaces).
319
+ return invalid("terminal actors require `subject.source: terminal-product` (a CLI/product the agent studies from public surfaces); other subjects are not supported on this route.");
320
+ }
321
+ return { ok: true, config, warnings: forwardDeclaredWarnings(config) };
322
+ }
323
+ // The slug interpolates into an in-sandbox shell command; the strict shape is load-bearing.
324
+ const REPO_SLUG_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
325
+ const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
326
+ // A lane id interpolates into per-lane evidence paths (screenshots/<id>/, actors/<id>.json), so
327
+ // it must be a public-safe path token, same shape as a lab id.
328
+ const LANE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
329
+ const LANE_ID_MAX_CHARS = 40;
330
+ const LANE_METADATA_MAX_CHARS = 80;
331
+ // Hard cap on fan-out lanes (per the ratified design). No HUMANISH_MAX_LANES escape above this
332
+ // until a reference panel demands it — N concurrent paid desktops is real money.
333
+ export const MAX_CUA_LANES = 16;
334
+ function actorResolvesToComputerUse(type) {
335
+ if (!type)
336
+ return false;
337
+ const descriptor = actorRegistry[type];
338
+ return Boolean(descriptor?.capabilities.lanes.includes("computer-use"));
339
+ }
340
+ function registeredComputerUseActors() {
341
+ return Object.values(actorRegistry)
342
+ .filter((entry) => entry.capabilities.lanes.includes("computer-use"))
343
+ .map((entry) => entry.id);
344
+ }
345
+ function actorResolvesToScriptedBrowser(type) {
346
+ if (!type)
347
+ return false;
348
+ const descriptor = actorRegistry[type];
349
+ return Boolean(descriptor?.capabilities.lanes.includes("scripted-browser"));
350
+ }
351
+ function registeredScriptedBrowserActors() {
352
+ return Object.values(actorRegistry)
353
+ .filter((entry) => entry.capabilities.lanes.includes("scripted-browser"))
354
+ .map((entry) => entry.id);
355
+ }
356
+ /** True when `type` resolves to a registered terminal actor (the "terminal" lane). Exported so
357
+ * the engine + tests can resolve the dispatch the same way the parser does. */
358
+ export function actorResolvesToTerminal(type) {
359
+ if (!type)
360
+ return false;
361
+ const descriptor = actorRegistry[type];
362
+ return Boolean(descriptor?.capabilities.lanes.includes("terminal"));
363
+ }
364
+ function registeredTerminalActors() {
365
+ return Object.values(actorRegistry)
366
+ .filter((entry) => entry.capabilities.lanes.includes("terminal"))
367
+ .map((entry) => entry.id);
368
+ }
369
+ /**
370
+ * True when this config routes to the computer-use backend: an app-url subject whose first
371
+ * actor resolves to a registered computer-use actor, or a clone subject on a hosted desktop
372
+ * whose first actor does. Single source of truth — selectLabBackend and the warning logic
373
+ * both use it. (The app-url branch used to be unconditionally true; it narrowed when the
374
+ * scripted-browser lane arrived. Behavior-preserving for every parse-valid config —
375
+ * selectLabBackend keeps a bare app-url fallback to the cua backend so library-API configs
376
+ * with unknown actors still hit its fail-closed ACTOR_UNSUPPORTED.)
377
+ */
378
+ /**
379
+ * The declared fan-out lane count on the computer-use route: a `lanes[]` roster's length, else
380
+ * a homogeneous `count`, else 1. The single source of truth shared by the parser, the engine,
381
+ * and the pre-flight plan so the lane count is computed ONE way everywhere.
382
+ */
383
+ export function cuaLaneCount(config) {
384
+ const actor = config.actors[0];
385
+ if (actor?.lanes !== undefined) {
386
+ return actor.lanes.length;
387
+ }
388
+ return actor?.count ?? 1;
389
+ }
390
+ /**
391
+ * Cross-validate the computer-use fan-out declaration (per-lane worlds). Returns the failure
392
+ * message, or null when valid. Enforced at parse AND re-enforced in the engine (runCuaActorLab
393
+ * is itself exported npm surface). Structural lane shape (id/device validity, id uniqueness) is
394
+ * already checked in parseLanes; this is the route-scoped XOR/cap/policy layer.
395
+ */
396
+ export function cuaLaneValidationReason(config) {
397
+ const actor = config.actors[0];
398
+ const lanes = actor?.lanes;
399
+ const structuralReason = laneRosterStructuralValidationReason(config);
400
+ if (structuralReason) {
401
+ return structuralReason;
402
+ }
403
+ // clone.fanout is a DECLARED behavior change: rejected on the cua route (was inert-warned).
404
+ // Fan-out is declared via actors[0].count/lanes; subject.clone.fanout never applied here.
405
+ if (config.subject.clone?.fanout !== undefined) {
406
+ return "`subject.clone.fanout` is not used on the computer-use route — declare fan-out with actors[0].count (homogeneous) or actors[0].lanes (a per-lane roster). (clone.fanout drives the OSS smoke/meta routes only.)";
407
+ }
408
+ if (lanes !== undefined) {
409
+ if (actor?.count !== undefined) {
410
+ return "Declare EITHER actors[0].count (a homogeneous lane count) OR actors[0].lanes (a differentiated roster), not both.";
411
+ }
412
+ if (actor?.laneFocus !== undefined) {
413
+ return "actors[0].laneFocus and actors[0].lanes are mutually exclusive — a roster's per-lane `instruction` is the fan-out steer; laneFocus is the single-lane steer.";
414
+ }
415
+ if (config.execution?.desktop?.resolution !== undefined && lanes.some((lane) => lane.device !== undefined)) {
416
+ return "actors[0].lanes[].device and a raw execution.desktop.resolution are mutually exclusive — a per-lane device preset and a single hand-set resolution cannot both govern lane geometry.";
417
+ }
418
+ const targeted = lanes.filter((lane) => lane.target !== undefined);
419
+ if (targeted.length > 0) {
420
+ if (config.subject.source !== "app-url") {
421
+ return "actors[0].lanes[].target is supported only on app-url computer-use labs — clone/shared-world/local-app routes provision or own their entry URL by mechanism.";
422
+ }
423
+ if (lanes.some((lane) => lane.entry !== undefined)) {
424
+ return "actors[0].lanes[].target and actors[0].lanes[].entry are mutually exclusive — target is an app-url fan-out browser URL; entry is a shared-world same-origin seat path.";
425
+ }
426
+ if (targeted.length !== lanes.length) {
427
+ return "When any actors[0].lanes[].target is declared, every lane in the roster must declare target — this keeps the setup-produced target contract explicit and prevents accidental mixed worlds.";
428
+ }
429
+ }
430
+ }
431
+ const laneCount = cuaLaneCount(config);
432
+ if (laneCount > MAX_CUA_LANES) {
433
+ return `Computer-use fan-out is capped at ${MAX_CUA_LANES} lanes (declared ${laneCount}); N concurrent paid desktops is real spend — there is no override above the cap this slice.`;
434
+ }
435
+ // Public targets fan out into N independent worlds driving the SAME public app — that is an
436
+ // ambiguous shared-world-ish shape, not a per-lane target swarm. Permit N>1 public runs only when
437
+ // every roster lane declares its own target, making the adapter-owned topology explicit.
438
+ if (laneCount > 1 && config.policies?.allowPublicTargets === true && declaredLaneTargets(config).length === 0) {
439
+ return "policies.allowPublicTargets cannot be combined with multi-lane fan-out (N>1) — N lanes against one declared public target is the SHARED-WORLD topology (layer 7, #164), not per-lane worlds. Fan out against a loopback/provisioned subject, or run a single public-target lane.";
440
+ }
441
+ return null;
442
+ }
443
+ /**
444
+ * Engine-level path-token validation for configs supplied directly through the
445
+ * public TypeScript/JavaScript API instead of parseLabConfig.
446
+ */
447
+ export function laneRosterStructuralValidationReason(config) {
448
+ const lanes = config.actors[0]?.lanes;
449
+ const seenIds = new Set();
450
+ if (lanes !== undefined) {
451
+ if (!Array.isArray(lanes) || lanes.length === 0) {
452
+ return "actors[0].lanes must be a non-empty array when set.";
453
+ }
454
+ for (const [index, lane] of lanes.entries()) {
455
+ if (!lane || typeof lane !== "object" || Array.isArray(lane)) {
456
+ return `actors[0].lanes[${index}] must be an object.`;
457
+ }
458
+ const id = lane.id;
459
+ if (id === undefined) {
460
+ continue;
461
+ }
462
+ if (typeof id !== "string" || !LANE_ID_PATTERN.test(id) || id.length > LANE_ID_MAX_CHARS) {
463
+ return `actors[0].lanes[${index}].id must be a public-safe path token matching ${LANE_ID_PATTERN} and at most ${LANE_ID_MAX_CHARS} chars.`;
464
+ }
465
+ if (seenIds.has(id)) {
466
+ return `actors[0].lanes ids must be unique (duplicate "${id}").`;
467
+ }
468
+ seenIds.add(id);
469
+ }
470
+ }
471
+ return null;
472
+ }
473
+ function declaredLaneTargets(config) {
474
+ return (config.actors[0]?.lanes ?? [])
475
+ .map((lane) => lane.target)
476
+ .filter((target) => target !== undefined);
477
+ }
478
+ /**
479
+ * Cross-validate a `topology: shared-world` declaration (#164). Returns the failure message, or
480
+ * null when valid. Enforced at parse AND re-enforced in the engine (runSharedWorldLab is exported
481
+ * npm surface). The shared-world override REQUIRES: a clone or local-tree source + e2b-desktop
482
+ * target + a computer-use actor + a `subject.serve` block + an `actors[0].lanes` roster of ≥2 roles (the
483
+ * roster IS the role roster — no parallel roles[] field), and every role `entry` must resolve
484
+ * same-origin (loopback) with serve.url. Fail-closed: a half-declared shared-world is rejected,
485
+ * never silently downgraded.
486
+ */
487
+ export function sharedWorldValidationReason(config) {
488
+ const structuralReason = laneRosterStructuralValidationReason(config);
489
+ if (structuralReason) {
490
+ return structuralReason;
491
+ }
492
+ if (config.subject.source !== "clone" && config.subject.source !== "local-tree") {
493
+ return "`subject.topology: shared-world` requires `subject.source: clone` or `subject.source: local-tree` - the shared world is ONE provisioned, served, seeded plane (#164).";
494
+ }
495
+ if (config.execution?.target !== "e2b-desktop") {
496
+ return "`subject.topology: shared-world` requires `execution.target: e2b-desktop` — the role seats drive hosted desktop browsers against one in-sandbox app.";
497
+ }
498
+ if (!actorResolvesToComputerUse(config.actors[0]?.type)) {
499
+ return `\`subject.topology: shared-world\` requires a registered computer-use actor (one of: ${registeredComputerUseActors().join(", ")}) — each role seat runs a computer-use session.`;
500
+ }
501
+ const serve = config.subject.serve;
502
+ if (!serve) {
503
+ return "`subject.topology: shared-world` requires `subject.serve` (start + url) — the lab serves ONE shared app in-sandbox that every role drives.";
504
+ }
505
+ const lanes = config.actors[0]?.lanes;
506
+ if (!lanes || lanes.length < 2) {
507
+ return "`subject.topology: shared-world` requires an `actors[0].lanes` roster of at least 2 roles (the roster IS the role roster — declare ≥2 lanes; a single-role shared world proves no interaction).";
508
+ }
509
+ if (!config.subject.state?.checkpoint || config.subject.state.checkpoint.length === 0) {
510
+ return "`subject.topology: shared-world` requires `subject.state.checkpoint` (≥1 read-only digest probe) — the checkpoint timeline IS the interaction-attribution mechanism; without it the run cannot prove role B acted on role A's mutation.";
511
+ }
512
+ for (const lane of lanes) {
513
+ if (lane.entry !== undefined && resolveSeatUrl(serve.url, lane.entry) === null) {
514
+ return `actors[0].lanes role "${lane.id ?? "(unnamed)"}".entry must resolve same-origin (loopback) with subject.serve.url (${serve.url}); got "${lane.entry}".`;
515
+ }
516
+ }
517
+ return null;
518
+ }
519
+ export function routesToComputerUse(config) {
520
+ // local-app drives the cua loop in-process (a custom executor + a non-vision provider), so it
521
+ // routes to the cua backend exactly like an app-url subject with a computer-use actor.
522
+ if (config.subject.source === "app-url" || config.subject.source === "local-app") {
523
+ return actorResolvesToComputerUse(config.actors[0]?.type);
524
+ }
525
+ // local-tree packs+uploads the working tree, then serves it exactly like a computer-use clone
526
+ // subject: same e2b-desktop + computer-use-actor gate.
527
+ return (config.subject.source === "clone" || config.subject.source === "local-tree")
528
+ && config.execution?.target === "e2b-desktop"
529
+ && actorResolvesToComputerUse(config.actors[0]?.type);
530
+ }
531
+ /**
532
+ * True when this config routes to the SHARED-WORLD backend (#164): a clone or local-tree subject
533
+ * on a hosted desktop whose first actor resolves to a computer-use actor AND that declares the
534
+ * `shared-world` topology. Mirror of routesToComputerUse; the single source of truth shared by
535
+ * selectLabBackend (which checks it BEFORE the cua route) and the warning logic. The same
536
+ * clone/local-tree × e2b-desktop × computer-use composition WITHOUT `topology: shared-world` stays per-lane-worlds
537
+ * (the cua route) — the topology declaration is the override switch.
538
+ */
539
+ export function routesToSharedWorld(config) {
540
+ return (config.subject.source === "clone" || config.subject.source === "local-tree")
541
+ && config.subject.topology === "shared-world"
542
+ && config.execution?.target === "e2b-desktop"
543
+ && actorResolvesToComputerUse(config.actors[0]?.type);
544
+ }
545
+ /**
546
+ * True when this config routes to the CONCURRENT shared-world backend (#164 phase 2): a shared-world
547
+ * config with `execution.concurrency > 1` (N actor seats driving ONE getHost-exposed plane AT ONCE).
548
+ * `concurrency` absent or 1 stays the sequential PoC (`routesToSharedWorld` → the sequential
549
+ * backend). selectLabBackend checks this BEFORE routesToSharedWorld.
550
+ */
551
+ export function routesToConcurrentSharedWorld(config) {
552
+ return routesToSharedWorld(config) && (config.execution?.concurrency ?? 1) > 1;
553
+ }
554
+ /**
555
+ * Cross-validate a CONCURRENT shared-world declaration (#164 phase 2). Returns the failure message,
556
+ * or null when valid. Includes the base shared-world checks PLUS the concurrent extras: a synthetic
557
+ * subject attestation (FIX-3), a 0.0.0.0 serve bind (FIX-4 — getHost only routes to a port bound on
558
+ * all interfaces), and no `subject.clone.keep`/`subject.localTree.keep` (FIX-9 - either would
559
+ * orphan actor sandboxes). Enforced at parse AND re-enforced in the engine (runConcurrentSharedWorld
560
+ * is exported npm surface).
561
+ */
562
+ export function concurrentSharedWorldValidationReason(config) {
563
+ const base = sharedWorldValidationReason(config);
564
+ if (base) {
565
+ return base;
566
+ }
567
+ if ((config.execution?.concurrency ?? 1) <= 1) {
568
+ return "the concurrent shared-world route requires `execution.concurrency > 1` (N concurrent actor seats); concurrency 1 is the sequential PoC.";
569
+ }
570
+ if (config.subject.exposure !== "synthetic") {
571
+ return "the concurrent shared-world route requires `subject.exposure: synthetic` — the subject is exposed on an internet-reachable getHost URL for the run, so the author must attest it is synthetic seeded data (no real/external data behind a getHost URL).";
572
+ }
573
+ const serve = config.subject.serve;
574
+ if (!serve || !serve.start.includes("0.0.0.0")) {
575
+ return "the concurrent shared-world route requires `subject.serve.start` to bind all interfaces (e.g. `-H 0.0.0.0` / `--host 0.0.0.0` / `HOST=0.0.0.0`) — getHost only routes to a 0.0.0.0-bound port; a loopback-only bind 502s. (The readiness probe stays loopback.)";
576
+ }
577
+ if (config.subject.clone?.keep === true || config.subject.localTree?.keep === true) {
578
+ const keepField = config.subject.clone?.keep === true ? "subject.clone.keep" : "subject.localTree.keep";
579
+ return `\`${keepField}\` is not supported on the concurrent shared-world route - it would orphan the N actor sandboxes (reclaimed only by server-timeout, not by id). All N+1 sandboxes are torn down by id.`;
580
+ }
581
+ return null;
582
+ }
583
+ /**
584
+ * Resolve a shared-world seat's entry URL from `serve.url` + a role's `entry` (relative path or
585
+ * same-origin absolute URL). Returns null when the combination is not a same-origin loopback URL
586
+ * (the load-bearing public-safety boundary — a seat only ever drives the in-sandbox app).
587
+ */
588
+ export function resolveSeatUrl(serveUrl, entry) {
589
+ if (entry === undefined || entry === "") {
590
+ return isLoopbackUrl(serveUrl) ? serveUrl : null;
591
+ }
592
+ let base;
593
+ let resolved;
594
+ try {
595
+ base = new URL(serveUrl);
596
+ resolved = new URL(entry, serveUrl);
597
+ }
598
+ catch {
599
+ return null;
600
+ }
601
+ if (resolved.origin !== base.origin) {
602
+ return null;
603
+ }
604
+ const value = resolved.toString();
605
+ return isLoopbackUrl(value) ? value : null;
606
+ }
607
+ /**
608
+ * True when this config routes to the scripted-browser backend: an app-url subject whose
609
+ * first actor resolves to a registered scripted-browser actor (execution.target local or
610
+ * absent — the parse layer enforces that pairing). Mirror of routesToComputerUse; the single
611
+ * source of truth for selectLabBackend and the warning logic.
612
+ */
613
+ export function routesToScriptedBrowser(config) {
614
+ return routesToLocalScriptedBrowser(config) || routesToProvisionedScriptedBrowser(config);
615
+ }
616
+ export function routesToLocalScriptedBrowser(config) {
617
+ return config.subject.source === "app-url"
618
+ && actorResolvesToScriptedBrowser(config.actors[0]?.type);
619
+ }
620
+ export function routesToProvisionedScriptedBrowser(config) {
621
+ return config.subject.source === "clone"
622
+ && config.execution?.target === "e2b-desktop"
623
+ && actorResolvesToScriptedBrowser(config.actors[0]?.type);
624
+ }
625
+ /**
626
+ * True when this config routes to the terminal-product backend: a terminal-product subject whose
627
+ * first actor resolves to a registered terminal actor (execution.target e2b-terminal or absent —
628
+ * the parse layer enforces that pairing). Mirror of routesToComputerUse/routesToScriptedBrowser;
629
+ * the single source of truth for selectLabBackend and the warning logic.
630
+ */
631
+ export function routesToTerminalProduct(config) {
632
+ return config.subject.source === "terminal-product"
633
+ && actorResolvesToTerminal(config.actors[0]?.type);
634
+ }
635
+ // Report fields that are present but not yet consumed by the engine, so a user never trusts a
636
+ // setting that silently does nothing. Keeps the schema forward-correct AND honest.
637
+ function forwardDeclaredWarnings(config) {
638
+ const inert = [];
639
+ // The computer-use routes consume the actor prompt fields, execution.timeoutMs,
640
+ // execution.desktop.{resolution,sandboxTimeoutMs}, and (clone) subject.{serve,env,state,
641
+ // clone.depth}; the scripted-browser route consumes scenario.ref, actors[0].{persona,count},
642
+ // and execution.timeoutMs (mission/laneFocus/model are inert there: this actor runs no
643
+ // model); on every other route those fields are inert.
644
+ const routesToCua = routesToComputerUse(config);
645
+ const routesToScripted = routesToScriptedBrowser(config);
646
+ const routesToTerminal = routesToTerminalProduct(config);
647
+ const routesToShared = routesToSharedWorld(config);
648
+ const routesToConcurrent = routesToConcurrentSharedWorld(config);
649
+ const routesToHostedCuaBrowser = config.execution?.target === "e2b-desktop"
650
+ && routesToCua;
651
+ for (const [index, actor] of config.actors.entries()) {
652
+ // Shared-world ONLY fields on the roster: per-role `entry` is inert anywhere else (invariant 6).
653
+ if (actor.lanes?.some((lane) => lane.entry !== undefined) && !routesToShared) {
654
+ inert.push(`actors[${index}].lanes[].entry (the per-role loopback entry is a shared-world capability; needs subject.topology: shared-world)`);
655
+ }
656
+ if (routesToCua || routesToTerminal) {
657
+ // The cua + terminal routes consume mission/persona/model + laneFocus.instruction (they
658
+ // compose the agent prompt + bundle provenance); laneFocus.id/label remain inert. On the
659
+ // cua E2B route actors[0].lanes is CONSUMED (the fan-out roster).
660
+ if (actor.laneFocus?.id)
661
+ inert.push(`actors[${index}].laneFocus.id`);
662
+ if (actor.laneFocus?.label)
663
+ inert.push(`actors[${index}].laneFocus.label`);
664
+ if (routesToTerminal && actor.lanes)
665
+ inert.push(`actors[${index}].lanes (fan-out is a computer-use route capability; terminal fan-out is a later slice)`);
666
+ }
667
+ else if (routesToScripted) {
668
+ // persona and count are consumed (trace/bundle provenance; surface roster). The prompt
669
+ // fields can never act here — the scripted actor runs no model.
670
+ if (actor.mission)
671
+ inert.push(`actors[${index}].mission (the scripted-browser actor runs no model)`);
672
+ if (actor.laneFocus)
673
+ inert.push(`actors[${index}].laneFocus (the scripted-browser actor runs no model)`);
674
+ if (actor.model)
675
+ inert.push(`actors[${index}].model (the scripted-browser actor runs no model)`);
676
+ if (actor.lanes)
677
+ inert.push(`actors[${index}].lanes (the scripted-browser route fans out via actors[0].count, not a lane roster)`);
678
+ }
679
+ else {
680
+ if (actor.mission)
681
+ inert.push(`actors[${index}].mission`);
682
+ if (actor.laneFocus)
683
+ inert.push(`actors[${index}].laneFocus`);
684
+ if (actor.persona)
685
+ inert.push(`actors[${index}].persona`);
686
+ if (actor.model)
687
+ inert.push(`actors[${index}].model`);
688
+ if (actor.lanes)
689
+ inert.push(`actors[${index}].lanes`);
690
+ }
691
+ }
692
+ if (config.subject.clone?.depth !== undefined && !routesToCua && !routesToScripted)
693
+ inert.push("subject.clone.depth");
694
+ if (config.subject.serve && !routesToCua && !routesToScripted)
695
+ inert.push("subject.serve");
696
+ if (config.subject.env && !routesToCua && !routesToScripted)
697
+ inert.push("subject.env");
698
+ if (config.subject.state && !routesToCua && !routesToScripted)
699
+ inert.push("subject.state");
700
+ // topology + checkpoint act ONLY on the shared-world route (#164); a set-but-unconsumed value
701
+ // (incl. an explicit per-lane-worlds, which the cua route already is by mechanism) warns inert.
702
+ if (config.subject.topology !== undefined && !routesToShared) {
703
+ inert.push("subject.topology (drives behavior only on the shared-world route; needs subject.topology: shared-world + clone × e2b-desktop × a computer-use actor + a ≥2 lane roster)");
704
+ }
705
+ if (config.subject.state?.checkpoint !== undefined && !routesToShared) {
706
+ inert.push("subject.state.checkpoint (the shared-world state-checkpoint probe; needs subject.topology: shared-world)");
707
+ }
708
+ // exposure (the synthetic-subject attestation) acts ONLY on the CONCURRENT shared-world route
709
+ // (the getHost-exposed plane); inert on the sequential shared-world route (loopback) and elsewhere.
710
+ if (config.subject.exposure !== undefined && !routesToConcurrent && !(routesToScripted && config.subject.source === "clone")) {
711
+ inert.push("subject.exposure (the synthetic-subject attestation for a getHost-exposed plane; needs concurrent shared-world or clone × e2b-desktop × scripted-browser)");
712
+ }
713
+ // clone.keep IS consumed on the cua route (honored on FAILURE: the sandbox is left up to debug
714
+ // a failed install/boot; otherwise always killed). clone.fanout is REJECTED on the cua route
715
+ // (a hard parse error above), so it can never reach this warning list there.
716
+ if (!routesToCua && !routesToScripted && !routesToTerminal && config.execution?.timeoutMs !== undefined)
717
+ inert.push("execution.timeoutMs");
718
+ if (config.execution?.completionTimeoutMs !== undefined)
719
+ inert.push("execution.completionTimeoutMs");
720
+ // execution.concurrency is CONSUMED on the cua route (it bounds in-flight fan-out lanes);
721
+ // inert (warned) everywhere else.
722
+ if (config.execution?.concurrency !== undefined && !routesToCua)
723
+ inert.push("execution.concurrency");
724
+ // terminal-product consumes subject.product, scenario.caps, execution.{terminal,runtimeAuth}
725
+ // (recorded as evidence this slice — the agent prompt, the blast-radius budget, the transport +
726
+ // the names-only runtime-auth channel). On every OTHER route they are inert and must warn so a
727
+ // misplaced safety/budget field is never trusted to do something it cannot (invariant 6).
728
+ if (config.subject.product && !routesToTerminal)
729
+ inert.push("subject.product (needs subject.source: terminal-product + a registered terminal actor)");
730
+ if (config.scenario?.caps && !routesToTerminal)
731
+ inert.push("scenario.caps (needs subject.source: terminal-product + a registered terminal actor)");
732
+ if (config.execution?.terminal && !routesToTerminal)
733
+ inert.push("execution.terminal (needs subject.source: terminal-product + a registered terminal actor)");
734
+ if (config.execution?.runtimeAuth !== undefined && !routesToTerminal)
735
+ inert.push("execution.runtimeAuth (needs subject.source: terminal-product + a registered terminal actor)");
736
+ // execution.desktop.* stays inert on the scripted route by design: device presets belong to
737
+ // the cua desktop; scripted surfaces are the driver's fixed desktop/mobile viewports, where
738
+ // isMobile/DSF genuinely render via playwright emulation.
739
+ if (!routesToCua && config.execution?.desktop?.resolution)
740
+ inert.push("execution.desktop.resolution");
741
+ if (!routesToCua && config.execution?.desktop?.device !== undefined)
742
+ inert.push("execution.desktop.device");
743
+ if (!routesToHostedCuaBrowser && config.execution?.desktop?.browser !== undefined)
744
+ inert.push("execution.desktop.browser");
745
+ if (!routesToCua && config.execution?.desktop?.sandboxTimeoutMs !== undefined)
746
+ inert.push("execution.desktop.sandboxTimeoutMs");
747
+ // execution.desktop.template (the custom E2B desktop image) is consumed ONLY where a desktop is
748
+ // actually created via Sandbox.create — the e2b-desktop computer-use routes (cua/shared-world/
749
+ // concurrent). It is INERT on every other route (incl. the in-process local-app cua route, which
750
+ // creates no desktop, and the meta route): warn so an unconsumed template is never silently
751
+ // ignored (invariant 6).
752
+ const createsE2BDesktop = (routesToCua || (routesToScripted && config.subject.source === "clone"))
753
+ && config.execution?.target === "e2b-desktop";
754
+ if (config.execution?.desktop?.template !== undefined && !createsE2BDesktop) {
755
+ inert.push("execution.desktop.template (the custom E2B desktop image is consumed only on execution.target: e2b-desktop computer-use routes that create a desktop; needs a computer-use actor on e2b-desktop)");
756
+ }
757
+ // codexAppServer is consumed only on the e2b-desktop (meta) route; flag it when it cannot reach there.
758
+ const routesToDesktop = config.subject.source === "clone" && config.execution?.target === "e2b-desktop";
759
+ if (config.execution?.desktop?.codexAppServer !== undefined && !routesToDesktop) {
760
+ inert.push("execution.desktop.codexAppServer (needs subject.source: clone + execution.target: e2b-desktop)");
761
+ }
762
+ // scenario.ref is CONSUMED on the scripted-browser route (required there); forward-declared
763
+ // everywhere else.
764
+ if (config.scenario?.ref && !routesToScripted)
765
+ inert.push("scenario.ref");
766
+ if (config.scenario?.inline)
767
+ inert.push("scenario.inline");
768
+ if (config.review)
769
+ inert.push("review");
770
+ if (config.personas)
771
+ inert.push("personas");
772
+ return inert.length === 0
773
+ ? []
774
+ : [`Forward-declared fields are set but not yet consumed by the engine (planned for a later slice): ${inert.join(", ")}.`];
775
+ }
776
+ function parseSubject(raw) {
777
+ if (!isRecord(raw)) {
778
+ return invalid("Lab `subject` is required and must be an object.");
779
+ }
780
+ const source = str(raw.source);
781
+ if (source !== "this-repo" && source !== "clone" && source !== "app-url" && source !== "local-app" && source !== "terminal-product" && source !== "local-tree") {
782
+ return invalid("`subject.source` must be one of: this-repo, clone, app-url, local-app, terminal-product, local-tree.");
783
+ }
784
+ const subject = { source };
785
+ // topology is enum-validated everywhere; its SEMANTICS (shared-world requires clone × e2b-desktop
786
+ // × a ≥2 roster) are enforced in the shared-world cross-validation below, and a set-but-unconsumed
787
+ // topology warns as inert off the shared-world route (invariant 6).
788
+ if (raw.topology !== undefined) {
789
+ const topology = str(raw.topology);
790
+ if (topology !== "per-lane-worlds" && topology !== "shared-world") {
791
+ return invalid("`subject.topology` must be per-lane-worlds (the default) or shared-world.");
792
+ }
793
+ subject.topology = topology;
794
+ }
795
+ // exposure is enum-validated everywhere; it is REQUIRED on the concurrent shared-world route (the
796
+ // getHost synthetic-subject attestation) and warns inert elsewhere.
797
+ if (raw.exposure !== undefined) {
798
+ const exposure = str(raw.exposure);
799
+ if (exposure !== "synthetic") {
800
+ return invalid("`subject.exposure` must be `synthetic` (the author attestation that the getHost-exposed subject is synthetic seeded data).");
801
+ }
802
+ subject.exposure = exposure;
803
+ }
804
+ // `product` is terminal-product-only; reject it elsewhere (invariant 6: a field that cannot act
805
+ // on this route is an honest parse error, not silently dropped).
806
+ if (source !== "terminal-product" && raw.product !== undefined) {
807
+ return invalid("`subject.product` applies only to terminal-product subjects (the CLI/product the terminal agent studies from public surfaces).");
808
+ }
809
+ // appUrl is app-url/local-app-only; a terminal-product subject drives PUBLIC surfaces, not a
810
+ // single loopback app — reject appUrl on it.
811
+ if (source === "terminal-product" && raw.appUrl !== undefined) {
812
+ return invalid("`subject.appUrl` does not apply to terminal-product subjects — declare `subject.product.publicSurfaces` (the agent works from public surfaces, not one loopback app).");
813
+ }
814
+ // serve/env/state are shared between clone (cloned app) and local-tree (packed working
815
+ // tree): both routes serve a subject in-sandbox with the same install/build/start/url +
816
+ // env-name + seed/external/checkpoint shapes.
817
+ if (source !== "clone" && source !== "local-tree" && raw.serve !== undefined) {
818
+ return invalid("`subject.serve` applies only to clone subjects or local-tree subjects (the lab serves the cloned/packed app in-sandbox).");
819
+ }
820
+ if (source !== "clone" && source !== "local-tree" && raw.env !== undefined) {
821
+ return invalid("`subject.env` applies only to clone subjects or local-tree subjects (the served app's environment channel).");
822
+ }
823
+ if (source !== "clone" && source !== "local-tree" && raw.state !== undefined) {
824
+ return invalid("`subject.state` applies only to clone subjects or local-tree subjects (the lab seeds the state it serves).");
825
+ }
826
+ // repos/clone are clone-ONLY (a fresh-clone subject's git inputs). local-tree packs the
827
+ // resolution cwd itself, so it has no repo slug to clone and gets its own precise reasons
828
+ // rather than falling through to the generic clone-only message below.
829
+ if (source === "local-tree" && raw.repos !== undefined) {
830
+ return invalid("`subject.repos` does not apply to local-tree subjects. The local-tree route packs the lab resolution cwd itself; there is no owner/repo slug to clone.");
831
+ }
832
+ if (source === "local-tree" && raw.clone !== undefined) {
833
+ return invalid("`subject.clone` does not apply to local-tree subjects. Declare `subject.localTree` instead (keep/exclude/maxArchiveBytes).");
834
+ }
835
+ // Rejected, never silently dropped, on app-url/local-app/this-repo/terminal-product subjects
836
+ // too (invariant 6: a field that cannot act on this route is an honest parse error).
837
+ if (source !== "clone" && raw.repos !== undefined) {
838
+ return invalid("`subject.repos` applies only to clone subjects (the owner/repo slugs to clone).");
839
+ }
840
+ if (source !== "clone" && raw.clone !== undefined) {
841
+ return invalid("`subject.clone` applies only to clone subjects (clone depth/fanout/keep).");
842
+ }
843
+ // localTree is local-tree-ONLY (pack/upload knobs for the packed working tree).
844
+ if (source !== "local-tree" && raw.localTree !== undefined) {
845
+ return invalid("`subject.localTree` applies only to local-tree subjects (keep/exclude/maxArchiveBytes for packing the working tree).");
846
+ }
847
+ if (source === "clone") {
848
+ const repos = strList(raw.repos);
849
+ if (!repos || repos.length === 0) {
850
+ return invalid("`subject.repos` must list at least one owner/repo slug when source is clone.");
851
+ }
852
+ subject.repos = repos;
853
+ const clone = parseClone(raw.clone);
854
+ if (clone) {
855
+ subject.clone = clone;
856
+ }
857
+ const serveResult = parseServe(raw.serve);
858
+ if (!serveResult.ok) {
859
+ return serveResult;
860
+ }
861
+ if (serveResult.value)
862
+ subject.serve = serveResult.value;
863
+ if (raw.env !== undefined) {
864
+ const env = strList(raw.env);
865
+ if (!env || env.length === 0) {
866
+ return invalid("`subject.env` must be a non-empty list of env var NAMES when set.");
867
+ }
868
+ const badName = env.find((name) => !ENV_NAME_PATTERN.test(name));
869
+ if (badName) {
870
+ return invalid(`subject.env entries must be env var NAMES like DATABASE_URL (got "${badName}"); values come from the caller's environment and are never persisted.`);
871
+ }
872
+ subject.env = env;
873
+ }
874
+ const stateResult = parseState(raw.state);
875
+ if (!stateResult.ok) {
876
+ return stateResult;
877
+ }
878
+ if (stateResult.value) {
879
+ // Semantic validation is shared with the engine (runCuaActorLab re-enforces it for
880
+ // configs that arrive through the library API without the parser).
881
+ const reason = subjectStateInvalidReason(stateResult.value, subject.env);
882
+ if (reason) {
883
+ return invalid(reason);
884
+ }
885
+ subject.state = stateResult.value;
886
+ }
887
+ }
888
+ if (source === "local-tree") {
889
+ // A local-tree subject exists to be packed and served; there is no other way to boot it, so
890
+ // serve is REQUIRED here (unlike clone, where serve is optional for the smoke/meta routes).
891
+ if (raw.serve === undefined) {
892
+ return invalid("`subject.serve` is required when source is local-tree: a local-tree subject exists to be packed and served, so declare install/build/start/url exactly like the clone route.");
893
+ }
894
+ const serveResult = parseServe(raw.serve);
895
+ if (!serveResult.ok) {
896
+ return serveResult;
897
+ }
898
+ if (serveResult.value)
899
+ subject.serve = serveResult.value;
900
+ if (raw.env !== undefined) {
901
+ const env = strList(raw.env);
902
+ if (!env || env.length === 0) {
903
+ return invalid("`subject.env` must be a non-empty list of env var NAMES when set.");
904
+ }
905
+ const badName = env.find((name) => !ENV_NAME_PATTERN.test(name));
906
+ if (badName) {
907
+ return invalid(`subject.env entries must be env var NAMES like DATABASE_URL (got "${badName}"); values come from the caller's environment and are never persisted.`);
908
+ }
909
+ subject.env = env;
910
+ }
911
+ const stateResult = parseState(raw.state);
912
+ if (!stateResult.ok) {
913
+ return stateResult;
914
+ }
915
+ if (stateResult.value) {
916
+ // Semantic validation is shared with the engine (same helper the clone route uses).
917
+ const reason = subjectStateInvalidReason(stateResult.value, subject.env);
918
+ if (reason) {
919
+ return invalid(reason);
920
+ }
921
+ subject.state = stateResult.value;
922
+ }
923
+ const localTreeResult = parseLocalTree(raw.localTree);
924
+ if (!localTreeResult.ok) {
925
+ return localTreeResult;
926
+ }
927
+ if (localTreeResult.value) {
928
+ subject.localTree = localTreeResult.value;
929
+ }
930
+ }
931
+ if (source === "app-url" || source === "local-app") {
932
+ const appUrl = str(raw.appUrl);
933
+ if (!appUrl) {
934
+ return invalid(`\`subject.appUrl\` is required when source is ${source}.`);
935
+ }
936
+ // app-url: shape-only here; the loopback-vs-public-target gate is applied in the
937
+ // cross-validation block below, where policies.allowPublicTargets is available.
938
+ // local-app: an in-process local dev server — ALWAYS loopback (no public-target option),
939
+ // so the loopback wall is enforced right here at parse.
940
+ if (source === "local-app") {
941
+ if (!isLoopbackUrl(appUrl)) {
942
+ return invalid("`subject.appUrl` must be a loopback URL (127.0.0.1/localhost) on a local-app subject — it drives an already-running LOCAL dev server in-process; public targets are not supported on this route.");
943
+ }
944
+ }
945
+ else if (!isHttpUrl(appUrl)) {
946
+ return invalid("`subject.appUrl` must be an http(s) URL.");
947
+ }
948
+ subject.appUrl = appUrl;
949
+ }
950
+ if (source === "terminal-product") {
951
+ const productResult = parseProduct(raw.product);
952
+ if (!productResult.ok) {
953
+ return productResult;
954
+ }
955
+ subject.product = productResult.value;
956
+ }
957
+ return { ok: true, value: subject };
958
+ }
959
+ // The product name interpolates into evidence labels and the composed prompt; the public-safe
960
+ // token shape is the same load-bearing constraint as a lab id.
961
+ const PRODUCT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
962
+ function parseProduct(raw) {
963
+ if (!isRecord(raw)) {
964
+ return invalid("`subject.product` is required on terminal-product subjects and must be an object ({ name, publicSurfaces }).");
965
+ }
966
+ const name = str(raw.name);
967
+ if (!name || !PRODUCT_NAME_PATTERN.test(name)) {
968
+ return invalid("`subject.product.name` must be a public-safe token starting with a letter or digit (/^[A-Za-z0-9][A-Za-z0-9_.-]*$/).");
969
+ }
970
+ const publicSurfaces = strList(raw.publicSurfaces);
971
+ if (!publicSurfaces || publicSurfaces.length === 0) {
972
+ return invalid("`subject.product.publicSurfaces` must list at least one public surface URL.");
973
+ }
974
+ const badSurface = publicSurfaces.find((surface) => !isHttpUrl(surface));
975
+ if (badSurface) {
976
+ return invalid(`subject.product.publicSurfaces entries must be http(s) URLs (got "${badSurface}").`);
977
+ }
978
+ return { ok: true, value: { name, publicSurfaces } };
979
+ }
980
+ // Public-safe stance: a computer-use actor's ENTRY URL is always an app the lab owner runs on
981
+ // loopback (inside the sandbox), never an arbitrary public site. (The constraint binds the
982
+ // entry point; a navigation watchdog for mid-session escapes is a later slice.) Exported so
983
+ // the engine re-enforces the same boundary on configs that arrive through the library API.
984
+ export function isLoopbackUrl(value) {
985
+ let url;
986
+ try {
987
+ url = new URL(value);
988
+ }
989
+ catch {
990
+ return false;
991
+ }
992
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
993
+ return false;
994
+ }
995
+ const host = url.hostname.toLowerCase();
996
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
997
+ }
998
+ /** A well-formed http(s) URL (any host). Shape gate before the loopback/public-target policy. */
999
+ export function isHttpUrl(value) {
1000
+ try {
1001
+ const url = new URL(value);
1002
+ return url.protocol === "http:" || url.protocol === "https:";
1003
+ }
1004
+ catch {
1005
+ return false;
1006
+ }
1007
+ }
1008
+ function parseServe(raw) {
1009
+ if (raw === undefined) {
1010
+ return { ok: true, value: undefined };
1011
+ }
1012
+ if (!isRecord(raw)) {
1013
+ return invalid("`subject.serve` must be an object ({ install?, build?, start, url, readyTimeoutMs? }).");
1014
+ }
1015
+ const start = str(raw.start);
1016
+ if (!start) {
1017
+ return invalid("`subject.serve.start` is required when serve is set (the long-lived command that serves the app).");
1018
+ }
1019
+ const url = str(raw.url);
1020
+ if (!url || !isLoopbackUrl(url)) {
1021
+ return invalid("`subject.serve.url` must be a loopback http(s) URL (127.0.0.1 or localhost) — the app is served INSIDE the sandbox.");
1022
+ }
1023
+ const serve = { start, url };
1024
+ const install = str(raw.install);
1025
+ if (install)
1026
+ serve.install = install;
1027
+ const build = str(raw.build);
1028
+ if (build)
1029
+ serve.build = build;
1030
+ const readyTimeoutMs = posInt(raw.readyTimeoutMs);
1031
+ if (readyTimeoutMs !== undefined)
1032
+ serve.readyTimeoutMs = readyTimeoutMs;
1033
+ const installTimeoutMs = posInt(raw.installTimeoutMs);
1034
+ if (installTimeoutMs !== undefined)
1035
+ serve.installTimeoutMs = installTimeoutMs;
1036
+ const buildTimeoutMs = posInt(raw.buildTimeoutMs);
1037
+ if (buildTimeoutMs !== undefined)
1038
+ serve.buildTimeoutMs = buildTimeoutMs;
1039
+ return { ok: true, value: serve };
1040
+ }
1041
+ /**
1042
+ * Structural parse of `subject.state` into a candidate LabSubjectState. Deliberately keeps
1043
+ * unrecognized `when`/`timeoutMs` values in the candidate (instead of silently dropping
1044
+ * them) so subjectStateInvalidReason rejects them — a state declaration that silently does
1045
+ * less than it says would violate invariant 6.
1046
+ */
1047
+ function parseState(raw) {
1048
+ if (raw === undefined) {
1049
+ return { ok: true, value: undefined };
1050
+ }
1051
+ if (!isRecord(raw)) {
1052
+ return invalid("`subject.state` must be an object ({ seed?, external? }).");
1053
+ }
1054
+ const state = {};
1055
+ if (raw.seed !== undefined) {
1056
+ if (!Array.isArray(raw.seed) || !raw.seed.every(isRecord)) {
1057
+ return invalid("`subject.state.seed` must be an array of step objects ({ name, command, when?, timeoutMs? }).");
1058
+ }
1059
+ state.seed = raw.seed.map((entry) => ({
1060
+ name: typeof entry.name === "string" ? entry.name.trim() : "",
1061
+ command: typeof entry.command === "string" ? entry.command.trim() : "",
1062
+ ...(entry.when === undefined ? {} : { when: entry.when }),
1063
+ ...(entry.timeoutMs === undefined ? {} : { timeoutMs: (posInt(entry.timeoutMs) ?? entry.timeoutMs) })
1064
+ }));
1065
+ }
1066
+ if (raw.external !== undefined) {
1067
+ const external = strList(raw.external);
1068
+ if (!external) {
1069
+ return invalid("`subject.state.external` must be a non-empty list of env var NAMES when set.");
1070
+ }
1071
+ state.external = external;
1072
+ }
1073
+ if (raw.checkpoint !== undefined) {
1074
+ if (!Array.isArray(raw.checkpoint) || !raw.checkpoint.every(isRecord)) {
1075
+ return invalid("`subject.state.checkpoint` must be an array of probe objects ({ name, command, redact? }).");
1076
+ }
1077
+ state.checkpoint = raw.checkpoint.map((probe) => ({
1078
+ name: typeof probe.name === "string" ? probe.name.trim() : "",
1079
+ command: typeof probe.command === "string" ? probe.command.trim() : "",
1080
+ // Preserve the redact list verbatim (literal secret values may contain commas, so do NOT
1081
+ // run it through the comma-splitting strList); subjectStateInvalidReason validates the shape.
1082
+ ...(probe.redact === undefined ? {} : { redact: probe.redact })
1083
+ }));
1084
+ }
1085
+ return { ok: true, value: state };
1086
+ }
1087
+ // The step name interpolates into in-sandbox script/status/log paths (`subject-state-<name>`);
1088
+ // the strict shape is load-bearing, exactly like the repo slug.
1089
+ const STATE_STEP_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
1090
+ const STATE_STEP_NAME_MAX_CHARS = 40;
1091
+ const STATE_STEP_WHENS = ["before-build", "before-start", "after-ready"];
1092
+ /**
1093
+ * Semantic validation for `subject.state`, shared by parseLabConfig and the engine
1094
+ * (runCuaActorLab re-enforces it on configs that arrive through the library API). Returns
1095
+ * the failure message, or null when the declaration is valid. Reads the candidate
1096
+ * defensively — library callers can hand the engine arbitrarily-shaped objects.
1097
+ */
1098
+ export function subjectStateInvalidReason(state, env) {
1099
+ const seed = state.seed;
1100
+ const external = state.external;
1101
+ const checkpoint = state.checkpoint;
1102
+ if ((seed === undefined || seed.length === 0)
1103
+ && (external === undefined || external.length === 0)
1104
+ && (checkpoint === undefined || checkpoint.length === 0)) {
1105
+ return "`subject.state` must declare seed steps, external env names, and/or checkpoints (an empty state block would be inert).";
1106
+ }
1107
+ if (seed !== undefined) {
1108
+ if (!Array.isArray(seed) || seed.length === 0) {
1109
+ return "`subject.state.seed` must be a non-empty array of steps when set.";
1110
+ }
1111
+ const names = new Set();
1112
+ for (const [index, step] of seed.entries()) {
1113
+ const name = typeof step?.name === "string" ? step.name : "";
1114
+ if (!STATE_STEP_NAME_PATTERN.test(name) || name.length > STATE_STEP_NAME_MAX_CHARS) {
1115
+ return `subject.state.seed[${index}].name must match ${STATE_STEP_NAME_PATTERN} and be at most ${STATE_STEP_NAME_MAX_CHARS} chars (it names in-sandbox file paths); got "${name}".`;
1116
+ }
1117
+ if (names.has(name)) {
1118
+ return `subject.state.seed step names must be unique (duplicate "${name}").`;
1119
+ }
1120
+ names.add(name);
1121
+ if (typeof step.command !== "string" || step.command.trim().length === 0) {
1122
+ return `subject.state.seed[${index}].command is required (the in-sandbox shell command that seeds the state).`;
1123
+ }
1124
+ if (step.when !== undefined && !STATE_STEP_WHENS.includes(step.when)) {
1125
+ return `subject.state.seed[${index}].when must be one of: ${STATE_STEP_WHENS.join(", ")}.`;
1126
+ }
1127
+ if (step.timeoutMs !== undefined && !(typeof step.timeoutMs === "number" && Number.isSafeInteger(step.timeoutMs) && step.timeoutMs >= 1)) {
1128
+ return `subject.state.seed[${index}].timeoutMs must be a positive integer.`;
1129
+ }
1130
+ }
1131
+ }
1132
+ if (external !== undefined) {
1133
+ if (!Array.isArray(external) || external.length === 0) {
1134
+ return "`subject.state.external` must be a non-empty list of env var NAMES when set.";
1135
+ }
1136
+ for (const name of external) {
1137
+ if (typeof name !== "string" || !ENV_NAME_PATTERN.test(name)) {
1138
+ return "subject.state.external entries must be env var NAMES like DATABASE_URL; values come from the caller's environment and are never persisted.";
1139
+ }
1140
+ if (!env?.includes(name)) {
1141
+ return "subject.state.external names must also be declared in subject.env (the declaration must name a provisioned channel).";
1142
+ }
1143
+ }
1144
+ }
1145
+ if (checkpoint !== undefined) {
1146
+ if (!Array.isArray(checkpoint) || checkpoint.length === 0) {
1147
+ return "`subject.state.checkpoint` must be a non-empty array of probes when set.";
1148
+ }
1149
+ const names = new Set();
1150
+ for (const [index, probe] of checkpoint.entries()) {
1151
+ const name = typeof probe?.name === "string" ? probe.name : "";
1152
+ if (!STATE_STEP_NAME_PATTERN.test(name) || name.length > STATE_STEP_NAME_MAX_CHARS) {
1153
+ return `subject.state.checkpoint[${index}].name must match ${STATE_STEP_NAME_PATTERN} and be at most ${STATE_STEP_NAME_MAX_CHARS} chars (it names in-sandbox file paths); got "${name}".`;
1154
+ }
1155
+ if (names.has(name)) {
1156
+ return `subject.state.checkpoint names must be unique (duplicate "${name}").`;
1157
+ }
1158
+ names.add(name);
1159
+ if (typeof probe.command !== "string" || probe.command.trim().length === 0) {
1160
+ return `subject.state.checkpoint[${index}].command is required (the read-only digest probe command).`;
1161
+ }
1162
+ if (probe.redact !== undefined) {
1163
+ if (!Array.isArray(probe.redact) || !probe.redact.every((value) => typeof value === "string" && value.length > 0)) {
1164
+ return `subject.state.checkpoint[${index}].redact must be a list of non-empty literal strings when set.`;
1165
+ }
1166
+ }
1167
+ }
1168
+ }
1169
+ return null;
1170
+ }
1171
+ function parseClone(raw) {
1172
+ if (!isRecord(raw)) {
1173
+ return undefined;
1174
+ }
1175
+ const clone = {};
1176
+ const depth = posInt(raw.depth);
1177
+ if (depth !== undefined)
1178
+ clone.depth = depth;
1179
+ const fanout = posInt(raw.fanout);
1180
+ if (fanout !== undefined)
1181
+ clone.fanout = fanout;
1182
+ if (typeof raw.keep === "boolean")
1183
+ clone.keep = raw.keep;
1184
+ return Object.keys(clone).length > 0 ? clone : undefined;
1185
+ }
1186
+ /**
1187
+ * Structural parse of `subject.localTree`, mirroring parseClone. Unlike parseClone (which
1188
+ * silently drops an out-of-range depth/fanout), an invalid exclude/maxArchiveBytes value is
1189
+ * REJECTED, never silently dropped: a caller who typed an empty exclude entry or a non-positive
1190
+ * maxArchiveBytes almost certainly meant something, and the archive-size cap is a safety knob,
1191
+ * not a cosmetic default.
1192
+ */
1193
+ function parseLocalTree(raw) {
1194
+ if (raw === undefined) {
1195
+ return { ok: true, value: undefined };
1196
+ }
1197
+ if (!isRecord(raw)) {
1198
+ return invalid("`subject.localTree` must be an object ({ keep?, exclude?, maxArchiveBytes? }).");
1199
+ }
1200
+ const localTree = {};
1201
+ if (raw.keep !== undefined) {
1202
+ if (typeof raw.keep !== "boolean") {
1203
+ return invalid("`subject.localTree.keep` must be a boolean (YAML true/false, not a quoted string).");
1204
+ }
1205
+ localTree.keep = raw.keep;
1206
+ }
1207
+ if (raw.exclude !== undefined) {
1208
+ if (!Array.isArray(raw.exclude) || raw.exclude.some((item) => typeof item !== "string" || item.trim().length === 0)) {
1209
+ return invalid("`subject.localTree.exclude` must be a list of non-empty strings (extra archive excludes on top of the always-on denylist).");
1210
+ }
1211
+ const exclude = strList(raw.exclude);
1212
+ if (exclude) {
1213
+ // Normalize/validate each entry at parse time so a mis-shaped exclude the
1214
+ // author believed in can never silently no-op at packing time: absolute
1215
+ // paths and glob syntax are rejected with the packing boundary's own
1216
+ // reason; "./prefix" and "prefix/" normalize to the enumeration relPath
1217
+ // shape.
1218
+ const normalized = [];
1219
+ for (const entry of exclude) {
1220
+ try {
1221
+ normalized.push(normalizeExtraExcludeEntry(entry));
1222
+ }
1223
+ catch (error) {
1224
+ return invalid(`\`subject.localTree.exclude\`: ${error instanceof Error ? error.message : String(error)}`);
1225
+ }
1226
+ }
1227
+ localTree.exclude = normalized;
1228
+ }
1229
+ }
1230
+ if (raw.maxArchiveBytes !== undefined) {
1231
+ const maxArchiveBytes = posInt(raw.maxArchiveBytes);
1232
+ if (maxArchiveBytes === undefined) {
1233
+ return invalid("`subject.localTree.maxArchiveBytes` must be a positive integer number of bytes when set.");
1234
+ }
1235
+ localTree.maxArchiveBytes = maxArchiveBytes;
1236
+ }
1237
+ return { ok: true, value: Object.keys(localTree).length > 0 ? localTree : undefined };
1238
+ }
1239
+ function parseActors(raw) {
1240
+ if (!Array.isArray(raw) || raw.length === 0) {
1241
+ return invalid("Lab `actors` must be a non-empty array.");
1242
+ }
1243
+ // Multi-actor fan-out is not wired yet (only actors[0] is consumed). Fail closed rather than
1244
+ // silently ignore actors[1..]; multi-actor support lands in a later slice.
1245
+ if (raw.length > 1) {
1246
+ return invalid("Multiple actors are not supported yet (only the first actor runs); declare a single actor.");
1247
+ }
1248
+ const actors = [];
1249
+ for (const [index, entry] of raw.entries()) {
1250
+ if (!isRecord(entry)) {
1251
+ return invalid(`actors[${index}] must be an object.`);
1252
+ }
1253
+ const type = str(entry.type);
1254
+ if (!type) {
1255
+ return invalid(`actors[${index}].type is required.`);
1256
+ }
1257
+ const actor = { type };
1258
+ const count = posInt(entry.count);
1259
+ if (count !== undefined)
1260
+ actor.count = count;
1261
+ if (entry.lanes !== undefined && entry.roster !== undefined) {
1262
+ return invalid(`actors[${index}].lanes and actors[${index}].roster are mutually exclusive — use explicit lanes OR compact roster groups, not both.`);
1263
+ }
1264
+ if (entry.roster !== undefined && count !== undefined) {
1265
+ return invalid(`actors[${index}].roster and actors[${index}].count are mutually exclusive — use compact differentiated groups OR a homogeneous count, not both.`);
1266
+ }
1267
+ if (entry.roster !== undefined && entry.laneFocus !== undefined) {
1268
+ return invalid(`actors[${index}].roster and actors[${index}].laneFocus are mutually exclusive — a roster group's instruction is the per-lane steer.`);
1269
+ }
1270
+ const lanesResult = entry.roster !== undefined
1271
+ ? parseRosterGroups(entry.roster, index)
1272
+ : parseLanes(entry.lanes, index);
1273
+ if (!lanesResult.ok) {
1274
+ return lanesResult;
1275
+ }
1276
+ if (lanesResult.value)
1277
+ actor.lanes = lanesResult.value;
1278
+ const persona = str(entry.persona);
1279
+ if (persona)
1280
+ actor.persona = persona;
1281
+ const mission = str(entry.mission);
1282
+ if (mission)
1283
+ actor.mission = mission;
1284
+ const model = str(entry.model);
1285
+ if (model)
1286
+ actor.model = model;
1287
+ const stopWhenResult = parseStopWhen(entry.stopWhen, `actors[${index}].stopWhen`);
1288
+ if (!stopWhenResult.ok)
1289
+ return stopWhenResult;
1290
+ if (stopWhenResult.value !== undefined)
1291
+ actor.stopWhen = stopWhenResult.value;
1292
+ const laneFocus = parseLaneFocus(entry.laneFocus);
1293
+ if (laneFocus)
1294
+ actor.laneFocus = laneFocus;
1295
+ actors.push(actor);
1296
+ }
1297
+ return { ok: true, value: actors };
1298
+ }
1299
+ function parseLaneFocus(raw) {
1300
+ if (!isRecord(raw)) {
1301
+ return undefined;
1302
+ }
1303
+ const laneFocus = {};
1304
+ const id = str(raw.id);
1305
+ if (id)
1306
+ laneFocus.id = id;
1307
+ const label = str(raw.label);
1308
+ if (label)
1309
+ laneFocus.label = label;
1310
+ const instruction = str(raw.instruction);
1311
+ if (instruction)
1312
+ laneFocus.instruction = instruction;
1313
+ return Object.keys(laneFocus).length > 0 ? laneFocus : undefined;
1314
+ }
1315
+ /**
1316
+ * Parse `actors[index].roster` compact groups into concrete lanes. This is authoring sugar for
1317
+ * "N users of M adapter-owned types across S surfaces"; the runtime receives only `lanes[]`.
1318
+ */
1319
+ function parseRosterGroups(raw, actorIndex) {
1320
+ if (raw === undefined) {
1321
+ return { ok: true, value: undefined };
1322
+ }
1323
+ if (!Array.isArray(raw) || raw.length === 0) {
1324
+ return invalid(`actors[${actorIndex}].roster must be a non-empty array of group objects ({ id, count, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry? }) when set.`);
1325
+ }
1326
+ const expanded = [];
1327
+ const seenGroupIds = new Set();
1328
+ for (const [groupIndex, entry] of raw.entries()) {
1329
+ if (!isRecord(entry)) {
1330
+ return invalid(`actors[${actorIndex}].roster[${groupIndex}] must be an object ({ id, count, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry? }).`);
1331
+ }
1332
+ const groupId = str(entry.id);
1333
+ if (groupId === undefined) {
1334
+ return invalid(`actors[${actorIndex}].roster[${groupIndex}].id is required and must be a public-safe token matching ${LANE_ID_PATTERN}.`);
1335
+ }
1336
+ if (!LANE_ID_PATTERN.test(groupId) || groupId.length > LANE_ID_MAX_CHARS - 3) {
1337
+ return invalid(`actors[${actorIndex}].roster[${groupIndex}].id must be a public-safe token matching ${LANE_ID_PATTERN} and at most ${LANE_ID_MAX_CHARS - 3} chars (generated lanes use <id>-NN); got "${groupId}".`);
1338
+ }
1339
+ if (seenGroupIds.has(groupId)) {
1340
+ return invalid(`actors[${actorIndex}].roster group ids must be unique (duplicate "${groupId}").`);
1341
+ }
1342
+ seenGroupIds.add(groupId);
1343
+ const count = posInt(entry.count);
1344
+ if (count === undefined) {
1345
+ return invalid(`actors[${actorIndex}].roster[${groupIndex}].count is required and must be a positive integer.`);
1346
+ }
1347
+ const groupLaneInput = { ...entry };
1348
+ delete groupLaneInput.id;
1349
+ delete groupLaneInput.count;
1350
+ for (let i = 1; i <= count; i += 1) {
1351
+ expanded.push({
1352
+ ...groupLaneInput,
1353
+ id: `${groupId}-${String(i).padStart(2, "0")}`
1354
+ });
1355
+ }
1356
+ }
1357
+ return parseLanes(expanded, actorIndex);
1358
+ }
1359
+ /**
1360
+ * Parse `actors[index].lanes` into a fan-out roster (computer-use E2B route). Structural only:
1361
+ * each lane is `{ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry? }`.
1362
+ * Lane ids (when declared) must be public-safe path tokens and unique; lane grouping metadata
1363
+ * must be public-safe tokens; a lane device must be a known preset name. The
1364
+ * route-scoped cross-validation (lanes XOR count/laneFocus, device XOR raw resolution, cap 16)
1365
+ * runs in parseLabConfig where the route is known.
1366
+ */
1367
+ function parseLanes(raw, actorIndex) {
1368
+ if (raw === undefined) {
1369
+ return { ok: true, value: undefined };
1370
+ }
1371
+ if (!Array.isArray(raw) || raw.length === 0) {
1372
+ return invalid(`actors[${actorIndex}].lanes must be a non-empty array of lane objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry? }) when set.`);
1373
+ }
1374
+ const lanes = [];
1375
+ const seenIds = new Set();
1376
+ for (const [laneIndex, entry] of raw.entries()) {
1377
+ if (!isRecord(entry)) {
1378
+ return invalid(`actors[${actorIndex}].lanes[${laneIndex}] must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry? }).`);
1379
+ }
1380
+ const lane = {};
1381
+ const id = str(entry.id);
1382
+ if (id !== undefined) {
1383
+ if (!LANE_ID_PATTERN.test(id) || id.length > LANE_ID_MAX_CHARS) {
1384
+ return invalid(`actors[${actorIndex}].lanes[${laneIndex}].id must be a public-safe token matching ${LANE_ID_PATTERN} and at most ${LANE_ID_MAX_CHARS} chars (it names per-lane evidence paths); got "${id}".`);
1385
+ }
1386
+ if (seenIds.has(id)) {
1387
+ return invalid(`actors[${actorIndex}].lanes ids must be unique (duplicate "${id}").`);
1388
+ }
1389
+ seenIds.add(id);
1390
+ lane.id = id;
1391
+ }
1392
+ const device = str(entry.device);
1393
+ if (device !== undefined) {
1394
+ if (!isDevicePresetName(device)) {
1395
+ return invalid(`actors[${actorIndex}].lanes[${laneIndex}].device must be one of: ${DEVICE_PRESET_NAMES.join(", ")}.`);
1396
+ }
1397
+ lane.device = device;
1398
+ }
1399
+ const persona = str(entry.persona);
1400
+ if (persona !== undefined)
1401
+ lane.persona = persona;
1402
+ const actorType = parseLaneMetadata(entry.actorType, `actors[${actorIndex}].lanes[${laneIndex}].actorType`);
1403
+ if (!actorType.ok)
1404
+ return actorType;
1405
+ if (actorType.value !== undefined)
1406
+ lane.actorType = actorType.value;
1407
+ const surface = parseLaneMetadata(entry.surface, `actors[${actorIndex}].lanes[${laneIndex}].surface`);
1408
+ if (!surface.ok)
1409
+ return surface;
1410
+ if (surface.value !== undefined)
1411
+ lane.surface = surface.value;
1412
+ const caseGroup = parseLaneMetadata(entry.caseGroup, `actors[${actorIndex}].lanes[${laneIndex}].caseGroup`);
1413
+ if (!caseGroup.ok)
1414
+ return caseGroup;
1415
+ if (caseGroup.value !== undefined)
1416
+ lane.caseGroup = caseGroup.value;
1417
+ const instruction = str(entry.instruction);
1418
+ if (instruction !== undefined)
1419
+ lane.instruction = instruction;
1420
+ const stopWhenResult = parseStopWhen(entry.stopWhen, `actors[${actorIndex}].lanes[${laneIndex}].stopWhen`);
1421
+ if (!stopWhenResult.ok)
1422
+ return stopWhenResult;
1423
+ if (stopWhenResult.value !== undefined)
1424
+ lane.stopWhen = stopWhenResult.value;
1425
+ const target = str(entry.target);
1426
+ if (target !== undefined) {
1427
+ if (!isHttpUrl(target)) {
1428
+ return invalid(`actors[${actorIndex}].lanes[${laneIndex}].target must be an absolute http(s) URL.`);
1429
+ }
1430
+ lane.target = target;
1431
+ }
1432
+ // `entry` is shape-captured here; the same-origin-with-serve.url check needs serve context, so
1433
+ // it runs in sharedWorldValidationReason (where the route + serve.url are known).
1434
+ const laneEntry = str(entry.entry);
1435
+ if (laneEntry !== undefined)
1436
+ lane.entry = laneEntry;
1437
+ lanes.push(lane);
1438
+ }
1439
+ return { ok: true, value: lanes };
1440
+ }
1441
+ function parseStopWhen(raw, field) {
1442
+ if (raw === undefined) {
1443
+ return { ok: true, value: undefined };
1444
+ }
1445
+ if (!isRecord(raw)) {
1446
+ return invalid(`${field} must be an object ({ any: [{ id?, urlIncludes?, urlPathEquals?, textIncludes?, appStatePathEquals? }] }).`);
1447
+ }
1448
+ if (!Array.isArray(raw.any) || raw.any.length === 0) {
1449
+ return invalid(`${field}.any must be a non-empty array of stop condition rules.`);
1450
+ }
1451
+ const any = [];
1452
+ for (const [index, entry] of raw.any.entries()) {
1453
+ if (!isRecord(entry)) {
1454
+ return invalid(`${field}.any[${index}] must be an object ({ id?, urlIncludes?, urlPathEquals?, textIncludes?, appStatePathEquals? }).`);
1455
+ }
1456
+ const rule = {};
1457
+ const id = str(entry.id);
1458
+ if (id !== undefined) {
1459
+ if (!LANE_ID_PATTERN.test(id) || id.length > LANE_METADATA_MAX_CHARS) {
1460
+ return invalid(`${field}.any[${index}].id must be a public-safe token matching ${LANE_ID_PATTERN} and at most ${LANE_METADATA_MAX_CHARS} chars; got "${id}".`);
1461
+ }
1462
+ rule.id = id;
1463
+ }
1464
+ const urlIncludes = str(entry.urlIncludes);
1465
+ if (urlIncludes !== undefined) {
1466
+ rule.urlIncludes = urlIncludes;
1467
+ }
1468
+ const urlPathEquals = str(entry.urlPathEquals);
1469
+ if (urlPathEquals !== undefined) {
1470
+ if (!urlPathEquals.startsWith("/") || urlPathEquals.startsWith("//")) {
1471
+ return invalid(`${field}.any[${index}].urlPathEquals must be an absolute URL path starting with one slash.`);
1472
+ }
1473
+ rule.urlPathEquals = urlPathEquals;
1474
+ }
1475
+ const textIncludes = str(entry.textIncludes);
1476
+ if (textIncludes !== undefined) {
1477
+ rule.textIncludes = textIncludes;
1478
+ }
1479
+ if (entry.appStatePathEquals !== undefined) {
1480
+ const parsed = parseStopWhenAppStatePathEquals(entry.appStatePathEquals, `${field}.any[${index}].appStatePathEquals`);
1481
+ if (!parsed.ok)
1482
+ return parsed;
1483
+ rule.appStatePathEquals = parsed.value;
1484
+ }
1485
+ if (rule.urlIncludes === undefined && rule.urlPathEquals === undefined && rule.textIncludes === undefined && rule.appStatePathEquals === undefined) {
1486
+ return invalid(`${field}.any[${index}] must declare at least one condition: urlIncludes, urlPathEquals, textIncludes, or appStatePathEquals.`);
1487
+ }
1488
+ any.push(rule);
1489
+ }
1490
+ return { ok: true, value: { any } };
1491
+ }
1492
+ function parseStopWhenAppStatePathEquals(raw, field) {
1493
+ if (!isRecord(raw)) {
1494
+ return invalid(`${field} must be an object ({ path, equals }).`);
1495
+ }
1496
+ const pathValue = str(raw.path);
1497
+ if (pathValue === undefined) {
1498
+ return invalid(`${field}.path is required and must be a dot-separated public-safe path.`);
1499
+ }
1500
+ if (!/^[A-Za-z0-9_.-]+$/.test(pathValue)) {
1501
+ return invalid(`${field}.path must contain only letters, digits, underscore, dash, and dot.`);
1502
+ }
1503
+ if (!Object.prototype.hasOwnProperty.call(raw, "equals")) {
1504
+ return invalid(`${field}.equals is required.`);
1505
+ }
1506
+ const equals = raw.equals;
1507
+ if (equals !== null && typeof equals !== "string" && typeof equals !== "number" && typeof equals !== "boolean") {
1508
+ return invalid(`${field}.equals must be a string, number, boolean, or null.`);
1509
+ }
1510
+ return { ok: true, value: { path: pathValue, equals } };
1511
+ }
1512
+ function parseLaneMetadata(raw, field) {
1513
+ const value = str(raw);
1514
+ if (value === undefined) {
1515
+ return { ok: true, value: undefined };
1516
+ }
1517
+ if (!LANE_ID_PATTERN.test(value) || value.length > LANE_METADATA_MAX_CHARS) {
1518
+ return invalid(`${field} must be a public-safe token matching ${LANE_ID_PATTERN} and at most ${LANE_METADATA_MAX_CHARS} chars; got "${value}".`);
1519
+ }
1520
+ return { ok: true, value };
1521
+ }
1522
+ function parseExecution(raw) {
1523
+ if (raw === undefined) {
1524
+ return { ok: true, value: undefined };
1525
+ }
1526
+ if (!isRecord(raw)) {
1527
+ return invalid("`execution` must be an object.");
1528
+ }
1529
+ const execution = {};
1530
+ if (raw.target !== undefined) {
1531
+ const target = str(raw.target);
1532
+ if (target !== "local" && target !== "e2b-desktop" && target !== "e2b-terminal") {
1533
+ return invalid("`execution.target` must be local, e2b-desktop, or e2b-terminal.");
1534
+ }
1535
+ execution.target = target;
1536
+ }
1537
+ const timeoutMs = posInt(raw.timeoutMs);
1538
+ if (timeoutMs !== undefined)
1539
+ execution.timeoutMs = timeoutMs;
1540
+ const completionTimeoutMs = posInt(raw.completionTimeoutMs);
1541
+ if (completionTimeoutMs !== undefined)
1542
+ execution.completionTimeoutMs = completionTimeoutMs;
1543
+ const concurrency = posInt(raw.concurrency);
1544
+ if (concurrency !== undefined)
1545
+ execution.concurrency = concurrency;
1546
+ const desktopResult = parseDesktop(raw.desktop);
1547
+ if (!desktopResult.ok) {
1548
+ return desktopResult;
1549
+ }
1550
+ if (desktopResult.value)
1551
+ execution.desktop = desktopResult.value;
1552
+ const terminalResult = parseTerminal(raw.terminal);
1553
+ if (!terminalResult.ok) {
1554
+ return terminalResult;
1555
+ }
1556
+ if (terminalResult.value)
1557
+ execution.terminal = terminalResult.value;
1558
+ if (raw.runtimeAuth !== undefined) {
1559
+ const runtimeAuth = str(raw.runtimeAuth);
1560
+ if (runtimeAuth !== "openai-env") {
1561
+ return invalid("`execution.runtimeAuth` must be openai-env (the in-sandbox agent's command-scoped runtime-auth channel).");
1562
+ }
1563
+ execution.runtimeAuth = runtimeAuth;
1564
+ }
1565
+ return { ok: true, value: Object.keys(execution).length > 0 ? execution : undefined };
1566
+ }
1567
+ function parseTerminal(raw) {
1568
+ if (raw === undefined) {
1569
+ return { ok: true, value: undefined };
1570
+ }
1571
+ if (!isRecord(raw)) {
1572
+ return invalid("`execution.terminal` must be an object ({ transport?, stdin? }).");
1573
+ }
1574
+ const terminal = {};
1575
+ if (raw.transport !== undefined) {
1576
+ const transport = str(raw.transport);
1577
+ if (transport !== "exec-stream") {
1578
+ // "pty" is deliberately rejected: stdin is disabled, so the capture is a non-interactive
1579
+ // exec stream — an interactive-PTY label would overstate the mechanism (invariant 6 + the
1580
+ // goal packet's PTY ruling). True duplex PTY is the deferred SLICE 5.
1581
+ return invalid("`execution.terminal.transport` must be exec-stream — captured non-interactive exec output (stdin disabled) is not an interactive PTY; a true duplex PTY transport is a later slice.");
1582
+ }
1583
+ terminal.transport = transport;
1584
+ }
1585
+ if (raw.stdin !== undefined) {
1586
+ const stdin = str(raw.stdin);
1587
+ if (stdin !== "disabled" && stdin !== "planned" && stdin !== "sent") {
1588
+ return invalid("`execution.terminal.stdin` must be disabled, planned, or sent.");
1589
+ }
1590
+ if (stdin === "sent") {
1591
+ // Assisted input is forbidden until the interventions ledger + comparability flag + verify
1592
+ // check exist (safety contract item 7) — shipping it now would let an assisted run pose as
1593
+ // autonomous green proof.
1594
+ return invalid("`execution.terminal.stdin: sent` (assisted input) is not supported yet — the interventions ledger + non-comparable marker land in SLICE 2. stdin is disabled by default.");
1595
+ }
1596
+ terminal.stdin = stdin;
1597
+ }
1598
+ return { ok: true, value: Object.keys(terminal).length > 0 ? terminal : undefined };
1599
+ }
1600
+ function parseDesktop(raw) {
1601
+ if (!isRecord(raw)) {
1602
+ return { ok: true, value: undefined };
1603
+ }
1604
+ const desktop = {};
1605
+ if (raw.device !== undefined) {
1606
+ const device = str(raw.device);
1607
+ if (!device || !isDevicePresetName(device)) {
1608
+ return invalid(`\`execution.desktop.device\` must be one of: ${DEVICE_PRESET_NAMES.join(", ")}.`);
1609
+ }
1610
+ desktop.device = device;
1611
+ }
1612
+ if (raw.resolution !== undefined) {
1613
+ const resolution = raw.resolution;
1614
+ if (!Array.isArray(resolution) || resolution.length !== 2 || !resolution.every((value) => Number.isInteger(value) && value > 0)) {
1615
+ return invalid("`execution.desktop.resolution` must be two positive integers [width, height].");
1616
+ }
1617
+ desktop.resolution = [resolution[0], resolution[1]];
1618
+ }
1619
+ const sandboxTimeoutMs = posInt(raw.sandboxTimeoutMs);
1620
+ if (sandboxTimeoutMs !== undefined)
1621
+ desktop.sandboxTimeoutMs = sandboxTimeoutMs;
1622
+ if (raw.browser !== undefined) {
1623
+ const browser = str(raw.browser);
1624
+ if (browser !== "default" && browser !== "chrome" && browser !== "chromium" && browser !== "firefox") {
1625
+ return invalid("`execution.desktop.browser` must be default, chrome, chromium, or firefox.");
1626
+ }
1627
+ desktop.browser = browser;
1628
+ }
1629
+ // A custom E2B desktop template NAME or ID. Trimmed non-empty when present; deliberately NOT
1630
+ // allowlisted (any string is a valid template name/id — over-restricting would reject real
1631
+ // adopter images). An explicitly-set but blank/whitespace value is a mistake, not a template.
1632
+ if (raw.template !== undefined) {
1633
+ const template = str(raw.template);
1634
+ if (template === undefined) {
1635
+ return invalid("`execution.desktop.template` must be a non-empty E2B desktop template NAME or ID when set (any string is accepted; there is no allowlist).");
1636
+ }
1637
+ desktop.template = template;
1638
+ }
1639
+ if (typeof raw.codexAppServer === "boolean")
1640
+ desktop.codexAppServer = raw.codexAppServer;
1641
+ return { ok: true, value: Object.keys(desktop).length > 0 ? desktop : undefined };
1642
+ }
1643
+ function parsePersonas(raw) {
1644
+ if (!Array.isArray(raw)) {
1645
+ return undefined;
1646
+ }
1647
+ const personas = raw.filter(isRecord);
1648
+ return personas.length > 0 ? personas : undefined;
1649
+ }
1650
+ function parseScenario(raw) {
1651
+ if (!isRecord(raw)) {
1652
+ return { ok: true, value: undefined };
1653
+ }
1654
+ const scenario = {};
1655
+ const ref = str(raw.ref);
1656
+ if (ref)
1657
+ scenario.ref = ref;
1658
+ if (isRecord(raw.inline))
1659
+ scenario.inline = raw.inline;
1660
+ const mode = str(raw.mode);
1661
+ if (mode === "dry-run" || mode === "live")
1662
+ scenario.mode = mode;
1663
+ const capsResult = parseCaps(raw.caps);
1664
+ if (!capsResult.ok) {
1665
+ return capsResult;
1666
+ }
1667
+ if (capsResult.value)
1668
+ scenario.caps = capsResult.value;
1669
+ return { ok: true, value: Object.keys(scenario).length > 0 ? scenario : undefined };
1670
+ }
1671
+ /**
1672
+ * Parse `scenario.caps`. Returns a parse failure on a malformed value rather than silently
1673
+ * dropping a budget declaration (a cap that silently does nothing would be a safety lie —
1674
+ * invariant 6). Each cap must be a non-negative finite number.
1675
+ */
1676
+ function parseCaps(raw) {
1677
+ if (raw === undefined) {
1678
+ return { ok: true, value: undefined };
1679
+ }
1680
+ if (!isRecord(raw)) {
1681
+ return invalid("`scenario.caps` must be an object ({ maxUsd?, maxJobs?, maxMinutes? }).");
1682
+ }
1683
+ const caps = {};
1684
+ for (const key of ["maxUsd", "maxJobs", "maxMinutes"]) {
1685
+ if (raw[key] === undefined)
1686
+ continue;
1687
+ const value = nonNegNumber(raw[key]);
1688
+ if (value === undefined) {
1689
+ return invalid(`\`scenario.caps.${key}\` must be a non-negative number.`);
1690
+ }
1691
+ caps[key] = value;
1692
+ }
1693
+ return { ok: true, value: Object.keys(caps).length > 0 ? caps : undefined };
1694
+ }
1695
+ function parsePolicies(raw) {
1696
+ if (!isRecord(raw)) {
1697
+ return undefined;
1698
+ }
1699
+ const policies = {};
1700
+ if (typeof raw.redactRepos === "boolean")
1701
+ policies.redactRepos = raw.redactRepos;
1702
+ if (typeof raw.redactScreenshots === "boolean")
1703
+ policies.redactScreenshots = raw.redactScreenshots;
1704
+ if (typeof raw.allowPublicTargets === "boolean")
1705
+ policies.allowPublicTargets = raw.allowPublicTargets;
1706
+ if (typeof raw.allowPrivateRepoAccess === "boolean")
1707
+ policies.allowPrivateRepoAccess = raw.allowPrivateRepoAccess;
1708
+ if (typeof raw.allowProviderCredentials === "boolean")
1709
+ policies.allowProviderCredentials = raw.allowProviderCredentials;
1710
+ if (typeof raw.allowPaymentCredentials === "boolean")
1711
+ policies.allowPaymentCredentials = raw.allowPaymentCredentials;
1712
+ if (typeof raw.allowGitHubMutation === "boolean")
1713
+ policies.allowGitHubMutation = raw.allowGitHubMutation;
1714
+ return Object.keys(policies).length > 0 ? policies : undefined;
1715
+ }
1716
+ function parseReview(raw) {
1717
+ if (!isRecord(raw)) {
1718
+ return undefined;
1719
+ }
1720
+ const review = {};
1721
+ const scoring = str(raw.scoring);
1722
+ if (scoring)
1723
+ review.scoring = scoring;
1724
+ const milestones = str(raw.milestones);
1725
+ if (milestones)
1726
+ review.milestones = milestones;
1727
+ const vocabulary = str(raw.vocabulary);
1728
+ if (vocabulary)
1729
+ review.vocabulary = vocabulary;
1730
+ return Object.keys(review).length > 0 ? review : undefined;
1731
+ }
1732
+ function parseDefaults(raw) {
1733
+ if (!isRecord(raw)) {
1734
+ return undefined;
1735
+ }
1736
+ const defaults = {};
1737
+ if (typeof raw.open === "boolean")
1738
+ defaults.open = raw.open;
1739
+ return Object.keys(defaults).length > 0 ? defaults : undefined;
1740
+ }
1741
+ function invalid(message) {
1742
+ return { ok: false, error: { code: "HUMANISH_LAB_INVALID", message } };
1743
+ }
1744
+ function isRecord(value) {
1745
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1746
+ }
1747
+ function str(value) {
1748
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1749
+ }
1750
+ function optionalStr(key, value) {
1751
+ const parsed = str(value);
1752
+ return parsed === undefined ? {} : { [key]: parsed };
1753
+ }
1754
+ function strList(value) {
1755
+ if (typeof value === "string") {
1756
+ const items = value.split(",").map((item) => item.trim()).filter(Boolean);
1757
+ return items.length > 0 ? items : undefined;
1758
+ }
1759
+ if (!Array.isArray(value)) {
1760
+ return undefined;
1761
+ }
1762
+ const items = value.filter((item) => typeof item === "string")
1763
+ .map((item) => item.trim())
1764
+ .filter(Boolean);
1765
+ return items.length > 0 ? items : undefined;
1766
+ }
1767
+ function posInt(value) {
1768
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1) {
1769
+ return value;
1770
+ }
1771
+ if (typeof value === "string" && /^\d+$/.test(value)) {
1772
+ const parsed = Number.parseInt(value, 10);
1773
+ return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : undefined;
1774
+ }
1775
+ return undefined;
1776
+ }
1777
+ /** A non-negative finite number (0 allowed — caps default to 0 = no-spend). Accepts a numeric
1778
+ * string too, since YAML scalars can arrive as strings. */
1779
+ function nonNegNumber(value) {
1780
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
1781
+ return value;
1782
+ }
1783
+ if (typeof value === "string" && /^\d+(\.\d+)?$/.test(value.trim())) {
1784
+ const parsed = Number.parseFloat(value.trim());
1785
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
1786
+ }
1787
+ return undefined;
1788
+ }
1789
+ //# sourceMappingURL=lab-config.js.map