humanish 0.111.0 → 0.111.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTEXT.md +1 -1
  3. package/dist/actors/codex/restricted-participant-policy.d.ts +2 -0
  4. package/dist/actors/codex/restricted-participant-policy.js +30 -2
  5. package/dist/actors/codex/restricted-participant.js +1 -1
  6. package/dist/actors/computer-use/executor-diagnostic.d.ts +15 -0
  7. package/dist/actors/computer-use/executor-diagnostic.js +105 -0
  8. package/dist/actors/computer-use/executor-error.d.ts +18 -3
  9. package/dist/actors/computer-use/executor-error.js +30 -4
  10. package/dist/actors/computer-use/loop/actions.d.ts +6 -3
  11. package/dist/actors/computer-use/loop/actions.js +27 -7
  12. package/dist/actors/computer-use/loop/ending.js +9 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +14 -0
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +1 -1
  16. package/dist/actors/computer-use/openai-context.d.ts +8 -2
  17. package/dist/actors/computer-use/openai-context.js +91 -26
  18. package/dist/actors/computer-use/openai-provider.js +55 -12
  19. package/dist/actors/computer-use/openai-requests.d.ts +2 -1
  20. package/dist/actors/computer-use/openai-requests.js +4 -1
  21. package/dist/actors/contract.d.ts +7 -1
  22. package/dist/browser-control/client.js +5 -5
  23. package/dist/browser-control/protocol.d.ts +30 -1
  24. package/dist/browser-control/protocol.js +19 -2
  25. package/dist/browser-control/transport.d.ts +3 -2
  26. package/dist/browser-control/transport.js +12 -7
  27. package/dist/cli/commands/study-route-computer-use.js +2 -2
  28. package/dist/cli/commands/study-route-shared-world.js +2 -2
  29. package/dist/cli/commands/study.js +1 -2
  30. package/dist/cli/doctor.js +11 -0
  31. package/dist/evidence/encoded-text.d.ts +3 -3
  32. package/dist/evidence/encoded-text.js +10 -5
  33. package/dist/evidence/redaction.d.ts +2 -2
  34. package/dist/evidence/redaction.js +18 -7
  35. package/dist/guest/browser-tools.js +5 -1
  36. package/dist/guest/chromium-text.js +42 -18
  37. package/dist/guest/desktop-executor.js +5 -1
  38. package/dist/keys/key-resolution.d.ts +3 -0
  39. package/dist/keys/key-resolution.js +15 -2
  40. package/dist/routes/computer-use/bundle-parts.d.ts +4 -0
  41. package/dist/routes/computer-use/bundle-parts.js +7 -2
  42. package/dist/routes/computer-use/fanout-bundle.js +4 -2
  43. package/dist/routes/computer-use/local-vm.js +1 -2
  44. package/dist/routes/computer-use/participant-model.js +6 -1
  45. package/dist/routes/computer-use/plan.js +3 -4
  46. package/dist/routes/computer-use/route.js +1 -2
  47. package/dist/routes/computer-use/setup.js +2 -2
  48. package/dist/routes/preview.js +1 -0
  49. package/dist/routes/scripted/plan.js +4 -5
  50. package/dist/routes/scripted/route.js +2 -2
  51. package/dist/routes/shared-world/plan.js +4 -5
  52. package/dist/routes/shared-world/route.js +2 -2
  53. package/dist/routes/shared-world/setup.js +1 -0
  54. package/dist/routes/terminal/dry-run.js +1 -0
  55. package/dist/routes/terminal/live-finish.js +24 -5
  56. package/dist/routes/terminal/live-sandbox.js +5 -1
  57. package/dist/routes/terminal/plan.js +3 -4
  58. package/dist/routes/terminal/recorder.d.ts +6 -0
  59. package/dist/routes/terminal/recorder.js +61 -6
  60. package/dist/routes/terminal/route.js +1 -2
  61. package/dist/routes/terminal/session.js +2 -1
  62. package/dist/routes/terminal/token-usage.d.ts +3 -0
  63. package/dist/routes/terminal/token-usage.js +5 -0
  64. package/dist/routes/terminal/types.d.ts +2 -1
  65. package/dist/routes/terminal/types.js +2 -1
  66. package/dist/run/dry-run.js +1 -0
  67. package/dist/run/results.d.ts +2 -0
  68. package/dist/run/run.d.ts +2 -0
  69. package/dist/run/run.js +16 -2
  70. package/dist/run/terminal-contract.d.ts +10 -0
  71. package/dist/run/terminal-contract.js +27 -9
  72. package/dist/study/composition-rules.js +17 -18
  73. package/dist/study/doctor.js +4 -5
  74. package/dist/study/migrate/v2.d.ts +5 -0
  75. package/dist/study/migrate/v2.js +23 -0
  76. package/dist/study/parse/front.js +2 -21
  77. package/dist/study/parse/url-credentials.d.ts +3 -3
  78. package/dist/study/parse/url-credentials.js +155 -13
  79. package/dist/study/persona-resolve.js +2 -2
  80. package/dist/study/plan-base.js +3 -4
  81. package/dist/study/plan-participants.js +3 -3
  82. package/dist/study/plan-types.d.ts +2 -0
  83. package/dist/study/plan.js +11 -5
  84. package/dist/study/routing.js +11 -11
  85. package/dist/study/run-study-options.js +2 -2
  86. package/dist/study/study-fields.d.ts +1 -13
  87. package/dist/study/study-fields.js +0 -24
  88. package/dist/study/summary.js +7 -7
  89. package/dist/study/types.d.ts +1 -0
  90. package/dist/study/validation.js +11 -11
  91. package/dist/study/warnings.d.ts +8 -0
  92. package/dist/study/warnings.js +22 -7
  93. package/dist/substrates/local/runtime-config.js +6 -6
  94. package/docs/architecture/browser-control.md +12 -2
  95. package/docs/architecture/guest-desktop.md +7 -1
  96. package/docs/architecture/terminal-product-route.md +3 -2
  97. package/docs/contracts/schemas.md +16 -5
  98. package/package.json +1 -1
@@ -4,8 +4,8 @@ export declare function decodeEscapes(text: string): string;
4
4
  export interface EncodedTextScanOptions {
5
5
  /** Do not count an encoded binary run as opaque. */
6
6
  readonly allowOpaqueBase64?: boolean;
7
- /** Match secret shapes only, leaving local paths out, as a URL's path may look like one. */
8
- readonly secretsOnly?: boolean;
7
+ /** What counts as sensitive in the text and in each decoding of it; containsSensitive by default. */
8
+ readonly matches?: (text: string) => boolean;
9
9
  }
10
10
  export interface EncodedTextScan {
11
11
  /** A secret or private path, in the text or in a decoding of it. */
@@ -27,4 +27,4 @@ export declare function scanEncodedText(text: string, options?: EncodedTextScanO
27
27
  * scan of the same string would give. Repeated verifies of an unchanged file, as serve admission
28
28
  * and the Observer render do, skip the scan.
29
29
  */
30
- export declare function scanEncodedTextCached(text: string, options?: EncodedTextScanOptions): EncodedTextScan;
30
+ export declare function scanEncodedTextCached(text: string, options?: Pick<EncodedTextScanOptions, "allowOpaqueBase64">): EncodedTextScan;
@@ -4,7 +4,7 @@
4
4
  // judge the same decoded text.
5
5
  import { createHash } from "node:crypto";
6
6
  import { readPlainText } from "./plain-text.js";
7
- import { containsSecret, containsSensitive } from "./redaction.js";
7
+ import { containsSensitive } from "./redaction.js";
8
8
  // HTML5 named references that stand for printable ASCII. Letters and digits have only numeric
9
9
  // references, which decodeEscapes handles.
10
10
  const ASCII_ENTITIES = {
@@ -189,7 +189,7 @@ function decodeTransferEscapes(text) {
189
189
  .replace(/=\r?\n/g, "")
190
190
  .replace(/=([0-9A-F]{2})/g, (match, hex) => codePoint(Number.parseInt(hex, 16), match));
191
191
  }
192
- const matcherOf = (options) => options.secretsOnly === true ? containsSecret : containsSensitive;
192
+ const matcherOf = (options) => options.matches ?? containsSensitive;
193
193
  const CLEAN = Object.freeze({ sensitive: false, opaque: false });
194
194
  const SENSITIVE = Object.freeze({ sensitive: true, opaque: false });
195
195
  function inspectDecoded(bytes, run, options, depth) {
@@ -261,7 +261,13 @@ export function scanEncodedText(text, options = {}, depth = 0) {
261
261
  for (const bytes of slashStarts)
262
262
  if (inspectDecoded(bytes, "", { ...options, allowOpaqueBase64: true }, depth).sensitive)
263
263
  return SENSITIVE;
264
- for (const [match] of expanded.matchAll(HEX_RUN)) {
264
+ // Hex is read before transfer escapes too: the quoted-printable pass reads `=68` in `state=6874...`
265
+ // as one byte, which shifts every hex pair after it.
266
+ const hexRuns = new Set([
267
+ ...[...expanded.matchAll(HEX_RUN)].map(([match]) => match),
268
+ ...(expanded === decoded ? [] : [...decoded.matchAll(HEX_RUN)].map(([match]) => match)),
269
+ ]);
270
+ for (const match of hexRuns) {
265
271
  const plain = readPlainText(Buffer.from(match, "hex"));
266
272
  if (plain.ok && depth < MAX_DEPTH && scanEncodedText(plain.text, options, depth + 1).sensitive)
267
273
  return SENSITIVE;
@@ -271,7 +277,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
271
277
  // Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
272
278
  // cache lives in one process, so the version guards results across a hot reload or a test that
273
279
  // swaps the scanner.
274
- const ENCODED_SCAN_VERSION = 2;
280
+ const ENCODED_SCAN_VERSION = 3;
275
281
  // Distinct files one process verifies in a burst (a run's files, a serve library's runs).
276
282
  const SCAN_CACHE_LIMIT = 256;
277
283
  const scanCache = new Map();
@@ -286,7 +292,6 @@ export function scanEncodedTextCached(text, options = {}) {
286
292
  const key = [
287
293
  ENCODED_SCAN_VERSION,
288
294
  options.allowOpaqueBase64 === true ? "opaque-allowed" : "opaque-unscanned",
289
- options.secretsOnly === true ? "secrets-only" : "secrets-and-paths",
290
295
  createHash("sha256").update(Buffer.from(text, "utf16le")).digest("hex"),
291
296
  ].join(":");
292
297
  const cached = scanCache.get(key);
@@ -1,7 +1,7 @@
1
1
  /** Every pattern containsSensitive tests. A test pins each to ASCII, which scanEncodedText relies on. */
2
2
  export declare function sensitivePatterns(): readonly RegExp[];
3
- /** True if the text contains a secret-shaped token. Local paths do not count. */
4
- export declare function containsSecret(text: string): boolean;
3
+ /** True if the text contains a credential. Local paths and a URL on an E2B host alone do not count. */
4
+ export declare function containsCredential(text: string): boolean;
5
5
  /** True if the text contains any secret-shaped token or known local path. */
6
6
  export declare function containsSensitive(text: string): boolean;
7
7
  /**
@@ -68,6 +68,14 @@ const E2B_PUBLIC_PAGE = [
68
68
  String.raw `(?:www\.)?e2b\.dev\/dashboard\/?(?:\?tab=[a-z]{1,16})?`,
69
69
  String.raw `(?:docs\.e2b\.dev|(?:www\.)?e2b\.dev\/docs)(?:\/[a-z][a-z-]{0,39}){0,8}\/?`,
70
70
  ].join("|");
71
+ // Any URL on an E2B host: a sandbox host names its sandbox, and a stream URL carries its auth
72
+ // key. The slashes may be JSON-escaped (`https:\/\/`). User info runs to its last `@`, as a URL
73
+ // parser reads it, and may hold any character but a slash or space. The host part stops at a
74
+ // backslash, quote or angle bracket, so a URL followed by an escaped line break and an `E2B_...`
75
+ // variable in JSON text is not read as an E2B host. A public page followed by `)@` is user info.
76
+ // A stream URL's auth key rides its `password` parameter, 16 characters from the SDK, which the
77
+ // credential parameter pattern matches as well.
78
+ const SANDBOX_URL = new RegExp(String.raw `(?!https:\/\/(?:${E2B_PUBLIC_PAGE})[.,;:!?'")]*(?:\s|$))https?:\\?\/\\?\/(?:[^/\s@]*@)*[^/\s\\"'<>@]*e2b[^)\s]+`, "gi");
71
79
  // A pattern that matches a credential by its context puts the context in a `keep` group, which
72
80
  // redaction leaves in place: `?token=[REDACTED_SECRET]`. Context goes in a group and not in a
73
81
  // lookbehind, because a pattern that starts with a lookbehind is tried at every position of the
@@ -115,12 +123,7 @@ const SECRET_PATTERNS = [
115
123
  // such as humanish-egress-auth-placeholder and JSON numbers do not count. The lookaheads are
116
124
  // bounded, so a long run of `TOKEN=` costs linear time.
117
125
  new RegExp(String.raw `(?<keep>${CREDENTIAL_VARIABLE}${QUOTE}[ \t]{0,4}[:=][ \t]{0,4}${QUOTE})${notAPlaceholder(VARIABLE_END)}${NOT_A_NUMBER}(?=[^\s"'\\,;&]{0,255}[0-9])(?=[^\s"'\\,;&]{0,255}[A-Za-z])[^\s"'\\,;&]{16}[^\s"'\\,;&]*`, "g"),
118
- // Any URL on an E2B host: a sandbox host names its sandbox, and a stream URL carries its auth
119
- // key. The slashes may be JSON-escaped (`https:\/\/`). User info runs to its last `@`, as a URL
120
- // parser reads it, and may hold any character but a slash or space. The host part stops at a
121
- // backslash, quote or angle bracket, so a URL followed by an escaped line break and an `E2B_...`
122
- // variable in JSON text is not read as an E2B host. A public page followed by `)@` is user info.
123
- new RegExp(String.raw `(?!https:\/\/(?:${E2B_PUBLIC_PAGE})[.,;:!?'")]*(?:\s|$))https?:\\?\/\\?\/(?:[^/\s@]*@)*[^/\s\\"'<>@]*e2b[^)\s]+`, "gi"),
126
+ SANDBOX_URL,
124
127
  /BEGIN (RSA|OPENSSH|PRIVATE) KEY/gi,
125
128
  ];
126
129
  const LOCAL_PATH_PATTERNS = [
@@ -148,9 +151,17 @@ function matchesPattern(pattern, text) {
148
151
  return pattern.test(text);
149
152
  }
150
153
  /** True if the text contains a secret-shaped token. Local paths do not count. */
151
- export function containsSecret(text) {
154
+ function containsSecret(text) {
152
155
  return SECRET_PATTERNS.some((pattern) => matchesPattern(pattern, text));
153
156
  }
157
+ // Every secret pattern but the sandbox URL. A URL on an E2B host names a sandbox, which verify
158
+ // keeps out of a shared run, but the host is no credential: a key, token, password or signature in
159
+ // such a URL matches one of these patterns on its own.
160
+ const CREDENTIAL_PATTERNS = SECRET_PATTERNS.filter((pattern) => pattern !== SANDBOX_URL);
161
+ /** True if the text contains a credential. Local paths and a URL on an E2B host alone do not count. */
162
+ export function containsCredential(text) {
163
+ return CREDENTIAL_PATTERNS.some((pattern) => matchesPattern(pattern, text));
164
+ }
154
165
  /** True if the text contains any secret-shaped token or known local path. */
155
166
  export function containsSensitive(text) {
156
167
  return (containsSecret(text) || LOCAL_PATH_PATTERNS.some(([pattern]) => matchesPattern(pattern, text)));
@@ -60,7 +60,11 @@ export function createGuestBrowserTools(native, content) {
60
60
  }
61
61
  catch (error) {
62
62
  // Ctrl+L itself is input. No content fallback after that dispatch.
63
- throw new ComputerUseExecutorError(isComputerUseExecutorError(error) ? error.code : "execution_failed", "outcome_uncertain");
63
+ throw isComputerUseExecutorError(error)
64
+ ? new ComputerUseExecutorError(error.code, "outcome_uncertain", {
65
+ diagnostic: error.diagnostic,
66
+ })
67
+ : new ComputerUseExecutorError("execution_failed", "outcome_uncertain");
64
68
  }
65
69
  },
66
70
  async close() {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { ComputerUseExecutorError, isComputerUseExecutorError, } from "../actors/computer-use/executor-error.js";
3
+ import { classifyExecutorFailure, } from "../actors/computer-use/executor-diagnostic.js";
3
4
  const DEADLINE_MS = 5_000;
4
5
  // These expressions execute only in this port's isolated world. Actor text is
5
6
  // never interpolated into JavaScript, a selector, a world name, or a CDP method.
@@ -107,20 +108,26 @@ function checkScope(settings, state, expected) {
107
108
  if (state.generation !== expected)
108
109
  throw new ComputerUseExecutorError("session_revoked", "not_dispatched");
109
110
  const pages = context.pages();
110
- if (state.dialogSeen ||
111
- page.isClosed() ||
112
- page.context() !== context ||
113
- pages.length !== 1 ||
114
- pages[0] !== page)
111
+ if (state.dialogSeen || page.isClosed() || page.context() !== context || !pages.includes(page))
115
112
  throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
113
+ // Playwright emulates focus in every page it drives, so the focus probe passes in a background
114
+ // tab too and cannot say which tab is in front. The participant learns why, and can close the
115
+ // other tab.
116
+ if (pages.length !== 1)
117
+ throw new ComputerUseExecutorError("action_rejected", "not_dispatched", {
118
+ reason: "extra_tab",
119
+ });
116
120
  }
117
121
  /**
118
122
  * One bounded operation: a 5 s deadline, the caller's abort, and any interrupt race every step.
119
- * A failure after a dispatching step makes the port unusable and reports outcome_uncertain.
123
+ * A failure after a dispatching step makes the port unusable and reports outcome_uncertain. An
124
+ * undeclared error becomes transport_failed with a diagnostic naming the failing step and a
125
+ * category; its message is classified and dropped.
120
126
  */
121
127
  async function runOperation(settings, state, signal, expected, body) {
122
128
  let dispatched = false;
123
129
  let stopped;
130
+ let phase;
124
131
  const controller = new AbortController();
125
132
  let rejectStop;
126
133
  const stop = new Promise((_resolve, reject) => {
@@ -151,11 +158,13 @@ async function runOperation(settings, state, signal, expected, body) {
151
158
  return await body({
152
159
  signal: controller.signal,
153
160
  check,
154
- async step(call, dispatch = false) {
161
+ async step(name, call, dispatch = false) {
155
162
  check();
156
163
  if (dispatch)
157
164
  dispatched = true;
165
+ phase = name;
158
166
  const value = await Promise.race([call(), stop]);
167
+ phase = undefined;
159
168
  check();
160
169
  return value;
161
170
  },
@@ -165,8 +174,18 @@ async function runOperation(settings, state, signal, expected, body) {
165
174
  controller.abort();
166
175
  if (dispatched)
167
176
  state.unusable = true;
168
- const code = stopped ?? (isComputerUseExecutorError(error) ? error.code : "transport_failed");
169
- throw new ComputerUseExecutorError(code, dispatched ? "outcome_uncertain" : "not_dispatched");
177
+ const declared = isComputerUseExecutorError(error);
178
+ const code = stopped ?? (declared ? error.code : "transport_failed");
179
+ const reason = !dispatched && stopped === undefined && declared ? error.reason : undefined;
180
+ const diagnostic = stopped !== undefined
181
+ ? undefined
182
+ : declared
183
+ ? error.diagnostic
184
+ : classifyExecutorFailure(phase ?? "text_port", error);
185
+ throw new ComputerUseExecutorError(code, dispatched ? "outcome_uncertain" : "not_dispatched", {
186
+ reason,
187
+ diagnostic,
188
+ });
170
189
  }
171
190
  finally {
172
191
  clearTimeout(timer);
@@ -191,9 +210,11 @@ function sessionDetacher(state) {
191
210
  }),
192
211
  ]);
193
212
  }
194
- catch {
213
+ catch (error) {
195
214
  state.unusable = true;
196
- throw new ComputerUseExecutorError("transport_failed", "not_dispatched");
215
+ throw new ComputerUseExecutorError("transport_failed", "not_dispatched", {
216
+ diagnostic: classifyExecutorFailure("cdp_detach", error),
217
+ });
197
218
  }
198
219
  finally {
199
220
  clearTimeout(timer);
@@ -209,11 +230,11 @@ function sessionDetacher(state) {
209
230
  /** The owner's native window check, as one step of an operation. */
210
231
  async function assertOwnerReady(settings, op) {
211
232
  const { assertFocusedWindow } = settings;
212
- await op.step(() => assertFocusedWindow(op.signal));
233
+ await op.step("owner_window_check", () => assertFocusedWindow(op.signal));
213
234
  }
214
235
  /** One fixed probe in the isolated world; anything but a boolean true refuses the action. */
215
236
  async function probeElement(op, session, contextId, expression) {
216
- const result = await op.step(() => session.send("Runtime.evaluate", evaluateParams(expression, contextId)));
237
+ const result = await op.step("focus_probe", () => session.send("Runtime.evaluate", evaluateParams(expression, contextId)));
217
238
  if (!probeAccepted(result))
218
239
  throw new ComputerUseExecutorError("action_rejected", "not_dispatched");
219
240
  }
@@ -224,17 +245,17 @@ async function probeElement(op, session, contextId, expression) {
224
245
  */
225
246
  async function acquireIsolatedWorld(settings, state, detach, prepared, op) {
226
247
  await assertOwnerReady(settings, op);
227
- prepared.session = await op.step(() => settings.context.newCDPSession(settings.page).then((acquired) => {
248
+ prepared.session = await op.step("cdp_session", () => settings.context.newCDPSession(settings.page).then((acquired) => {
228
249
  state.sessions.add(acquired);
229
250
  if (op.signal.aborted || state.closed)
230
251
  void detach(acquired).catch(() => { });
231
252
  return acquired;
232
253
  }));
233
- const tree = await op.step(() => prepared.session.send("Page.getFrameTree"));
254
+ const tree = await op.step("frame_tree", () => prepared.session.send("Page.getFrameTree"));
234
255
  const frameId = ownedFrameId(tree);
235
256
  if (frameId === undefined)
236
257
  throw new ComputerUseExecutorError("invalid_response", "not_dispatched");
237
- const world = await op.step(() => prepared.session.send("Page.createIsolatedWorld", {
258
+ const world = await op.step("isolated_world", () => prepared.session.send("Page.createIsolatedWorld", {
238
259
  frameId,
239
260
  worldName: settings.worldName,
240
261
  }));
@@ -255,7 +276,10 @@ function disposePrepared(state, detach, prepared, self) {
255
276
  await detach(prepared.session);
256
277
  }
257
278
  catch (error) {
258
- throw new ComputerUseExecutorError(isComputerUseExecutorError(error) ? error.code : "transport_failed", prepared.dispatchedText ? "outcome_uncertain" : "not_dispatched");
279
+ const declared = isComputerUseExecutorError(error);
280
+ throw new ComputerUseExecutorError(declared ? error.code : "transport_failed", prepared.dispatchedText ? "outcome_uncertain" : "not_dispatched", {
281
+ diagnostic: declared ? error.diagnostic : classifyExecutorFailure("cdp_detach", error),
282
+ });
259
283
  }
260
284
  finally {
261
285
  state.busy = false;
@@ -281,7 +305,7 @@ function preparedHandle(settings, state, prepared, dispose) {
281
305
  await probeElement(op, prepared.session, prepared.contextId, RECHECK);
282
306
  await assertOwnerReady(settings, op);
283
307
  op.check();
284
- await op.step(() => {
308
+ await op.step("insert_text", () => {
285
309
  prepared.dispatchedText = true;
286
310
  return prepared.session.send("Input.insertText", { text: prepared.text });
287
311
  }, true);
@@ -171,7 +171,11 @@ export function failureEndsSession(progress, error, signal) {
171
171
  */
172
172
  export function failureError(dispatched, error, signal) {
173
173
  if (dispatched)
174
- return new ComputerUseExecutorError(isComputerUseExecutorError(error) ? error.code : "execution_failed", "outcome_uncertain");
174
+ return isComputerUseExecutorError(error)
175
+ ? new ComputerUseExecutorError(error.code, "outcome_uncertain", {
176
+ diagnostic: error.diagnostic,
177
+ })
178
+ : new ComputerUseExecutorError("execution_failed", "outcome_uncertain");
175
179
  if (signal.aborted)
176
180
  return new ComputerUseExecutorError("session_revoked", "not_dispatched");
177
181
  return isComputerUseExecutorError(error)
@@ -33,6 +33,9 @@ export interface KeySourceProbe {
33
33
  source: string | null;
34
34
  /** The command/path that would fill it when missing. */
35
35
  hint: string;
36
+ /** Set when the env holds the inert value a terminal study's sandbox gets in place of a key, and
37
+ * no other source supplies one. `source` is then null. */
38
+ placeholder?: true;
36
39
  }
37
40
  /** The nearest fill instruction for a missing key. Doctor rows use it, and it is appended to
38
41
  * *_KEYS_MISSING errors so the failure names the fix. */
@@ -22,6 +22,7 @@ import { homedir } from "node:os";
22
22
  import path from "node:path";
23
23
  import { loadEnvFile } from "./env-file.js";
24
24
  import { cli } from "../cli/invocation.js";
25
+ import { OPENAI_EGRESS_PLACEHOLDER } from "../routes/terminal/runtime-auth.js";
25
26
  /** The only names implicit discovery may fill (and `humanish keys set` may store). Everything
26
27
  * else in an overlay/store file is ignored-and-named: a repo-planted NODE_OPTIONS/LD_PRELOAD
27
28
  * must never enter process env off a file the operator did not explicitly pass (an explicit
@@ -263,7 +264,14 @@ export function describeMissingKeys(names, env) {
263
264
  * Runs the same chain against a scratch copy of env so nothing observable changes.
264
265
  */
265
266
  export async function probeKeySources(names, args) {
267
+ // A terminal study's sandbox sets CODEX_API_KEY to an inert placeholder so Codex starts; the key
268
+ // itself stays in the host-side egress proxy. The placeholder is no key, so it counts as unset and
269
+ // discovery may still find a real one.
270
+ const placeholder = (name) => args.env[name]?.trim() === OPENAI_EGRESS_PLACEHOLDER;
266
271
  const scratch = { ...args.env };
272
+ for (const name of names)
273
+ if (placeholder(name))
274
+ delete scratch[name];
267
275
  const fills = await discoverProviderKeys({
268
276
  cwd: args.cwd,
269
277
  env: scratch,
@@ -272,7 +280,7 @@ export async function probeKeySources(names, args) {
272
280
  });
273
281
  const bySource = new Map(fills.map((fill) => [fill.name, fill.source]));
274
282
  return names.map((name) => {
275
- const inEnv = args.env[name] !== undefined && args.env[name]?.trim() !== "";
283
+ const inEnv = args.env[name] !== undefined && args.env[name]?.trim() !== "" && !placeholder(name);
276
284
  // GH_TOKEN and GITHUB_TOKEN are one credential with two spellings; a doctor row that says
277
285
  // "missing" while GITHUB_TOKEN sits in the env would be wrong.
278
286
  const aliasInEnv = name === "GH_TOKEN" &&
@@ -285,7 +293,12 @@ export async function probeKeySources(names, args) {
285
293
  : aliasInEnv
286
294
  ? "process env (GITHUB_TOKEN)"
287
295
  : (bySource.get(name) ?? null);
288
- return { name, source, hint: missingKeyHint(name) };
296
+ return {
297
+ name,
298
+ source,
299
+ hint: missingKeyHint(name),
300
+ ...(source === null && placeholder(name) ? { placeholder: true } : {}),
301
+ };
289
302
  });
290
303
  }
291
304
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -43,6 +43,10 @@ export declare function providerResourcesForOutcome(args: {
43
43
  ids: ParticipantIds;
44
44
  participantId: string;
45
45
  }): RunProviderResource[];
46
+ /**
47
+ * The app URL as a run records it: the URL, or its digest when verify would flag it. verify reads the
48
+ * URL decoded, so an E2B URL percent-encoded in a parameter gets the digest too.
49
+ */
46
50
  export declare function publicSafeAppUrlLabel(url: string): string;
47
51
  /** Short id-safe suffix for a subject-phase RunEvent: drops the shared prefix/suffix so each
48
52
  * phase gets a distinct bundle event id (e.g. "clone", "state-before-build"). */
@@ -3,7 +3,8 @@
3
3
  // public-safe app URL label, the URL digest and the subject-phase event id suffix. The builders are bundle.ts (the dispatcher and the judge),
4
4
  // single-bundle.ts and fanout-bundle.ts.
5
5
  import { feedbackProofCommands } from "../../feedback/proof.js";
6
- import { containsSensitive, digestText, redactText } from "../../evidence/redaction.js";
6
+ import { scanEncodedText } from "../../evidence/encoded-text.js";
7
+ import { digestText, redactText } from "../../evidence/redaction.js";
7
8
  import {} from "../../run/bundle.js";
8
9
  import { participantResourceIds } from "../../run/participant-records.js";
9
10
  import { resolveSelfReportedFriction } from "./self-report.js";
@@ -169,8 +170,12 @@ export function providerResourcesForOutcome(args) {
169
170
  },
170
171
  ];
171
172
  }
173
+ /**
174
+ * The app URL as a run records it: the URL, or its digest when verify would flag it. verify reads the
175
+ * URL decoded, so an E2B URL percent-encoded in a parameter gets the digest too.
176
+ */
172
177
  export function publicSafeAppUrlLabel(url) {
173
- return containsSensitive(url) ? `[target-url:${digestUrl(url)}]` : url;
178
+ return scanEncodedText(url).sensitive ? `[target-url:${digestUrl(url)}]` : url;
174
179
  }
175
180
  /** Short id-safe suffix for a subject-phase RunEvent: drops the shared prefix/suffix so each
176
181
  * phase gets a distinct bundle event id (e.g. "clone", "state-before-build"). */
@@ -4,7 +4,7 @@ import { actorEnding } from "../../actors/stop-cause.js";
4
4
  import { redactText } from "../../evidence/redaction.js";
5
5
  import { REVIEW_SCHEMA, bundleArtifacts, bundleHead, } from "../../run/bundle.js";
6
6
  import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, tallyParticipantOutcomes, withCuaReviewProvenance, } from "../../run/outcomes.js";
7
- import { participantFeedbackCandidates, providerResourcesForOutcome } from "./bundle-parts.js";
7
+ import { participantFeedbackCandidates, providerResourcesForOutcome, publicSafeAppUrlLabel, } from "./bundle-parts.js";
8
8
  import { participantFactsOf } from "./participant-facts.js";
9
9
  import { participantPassed, participantStatus } from "../../run/judge.js";
10
10
  import { buildRunCostSummary, desktopSpanToMinutes } from "../../run/cost-summary.js";
@@ -72,10 +72,12 @@ function fanoutReview(args, streams) {
72
72
  verdict,
73
73
  ...(participants === undefined ? {} : { participants }),
74
74
  ...(runTasks === undefined ? {} : { tasks: runTasks }),
75
+ // The app URL is named as the participant records name it: one on an E2B host names a
76
+ // sandbox, so the summary carries its digest, which verify accepts in a shared run.
75
77
  summary: args.inProgress === true
76
78
  ? `Live computer-use fan-out is running (${specs.length} participants, one world each); terminal participant evidence has not been written yet.`
77
79
  : args.dryRun
78
- ? `${args.rerun ? `Rerun from ${args.rerun.sourceRunId}: ` : ""}Dry-run fan-out: ${specs.length} participants composed for ${args.descriptor.id} against ${args.appUrl}, one world each; no desktops launched, $0 spend.`
80
+ ? `${args.rerun ? `Rerun from ${args.rerun.sourceRunId}: ` : ""}Dry-run fan-out: ${specs.length} participants composed for ${args.descriptor.id} against ${publicSafeAppUrlLabel(args.appUrl)}, one world each; no desktops launched, $0 spend.`
79
81
  : `${args.rerun ? `Rerun from ${args.rerun.sourceRunId}: ` : ""}Computer-use fan-out (${specs.length} participants, one world each): ${passedParticipants}/${plural(specs.length, "participant")} reached a terminal, engaged verdict${participants ? `: ${formatParticipantOutcomes(participants, participantEndings)}` : ""}${runTasks ? `; tasks: ${formatRunTaskFunnel(runTasks)}` : ""}.`,
80
82
  gaps: args.inProgress === true
81
83
  ? ["Live fan-out session is still running."]
@@ -10,7 +10,6 @@ import { createRestrictedCodexParticipant } from "../../actors/codex/restricted-
10
10
  import { withCloseReport } from "./participant-model.js";
11
11
  import { guestMediaConfigSchema } from "../../guest/media-config.js";
12
12
  import { startLocalCapturedInbox } from "../../substrates/local/captured-inbox.js";
13
- import { actorOf } from "../../study/study-fields.js";
14
13
  function guestMedia(config) {
15
14
  const declaredMedia = config.execution?.desktop?.media;
16
15
  return declaredMedia === undefined
@@ -165,7 +164,7 @@ export function prepareLocalVmRun(options) {
165
164
  const media = guestMedia(config);
166
165
  // A caller-supplied provider replaces the Codex account participant, so the account is
167
166
  // neither checked nor used.
168
- const account = actorOf(config)?.type === "local-agent" && options.createProvider === undefined;
167
+ const account = config.actor?.type === "local-agent" && options.createProvider === undefined;
169
168
  const state = { sessions: [], participants: [], cleanupUnconfirmed: false };
170
169
  const context = {
171
170
  config,
@@ -228,10 +228,15 @@ export async function recordParticipantTrace(spec, deps, session, warnings) {
228
228
  * carried it on every request. It says why, and how many early turns became a text summary.
229
229
  */
230
230
  export function explicitContextWarning(conversation) {
231
+ const answer = conversation.rejection === "stored_item"
232
+ ? " (a stored item the request referenced was not found)"
233
+ : conversation.rejection === "previous_response"
234
+ ? " (the previous response was refused or not found)"
235
+ : "";
231
236
  const why = conversation.explicitReason === "zdr_rejection"
232
237
  ? `The OpenAI organization rejected server-side conversation state (zero data retention)${conversation.switchedAtRequest === undefined
233
238
  ? ""
234
- : ` on request ${conversation.switchedAtRequest}`}`
239
+ : ` on request ${conversation.switchedAtRequest}`}${answer}`
235
240
  : "zeroDataRetention is set";
236
241
  const summarized = conversation.summarizedTurns === 0
237
242
  ? ""
@@ -16,7 +16,6 @@ import { isLocalBrowserStudy } from "../../substrates/local/runtime-config.js";
16
16
  import { boundedConcurrency, defaultSessionTimeoutMs, resolveParticipantSandboxMs, } from "./participant-runs.js";
17
17
  import { MAX_SANDBOX_MS } from "../../substrates/e2b/lifetime.js";
18
18
  import {} from "./types.js";
19
- import { actorOf, declaresSharedWorld } from "../../study/study-fields.js";
20
19
  /** The registered descriptor for a planned actor id; planComputerUseStudy checked the registry. */
21
20
  export function cuaDescriptorOf(actor) {
22
21
  const descriptor = actorRegistry[actor];
@@ -87,7 +86,7 @@ function unsupportedDeclarationReason(config, hasRunSession, driving, { inProces
87
86
  const reason = desktopMediaValidationReason(config) || outputTokenLimitValidationReason(config);
88
87
  if (reason)
89
88
  return invalid(reason);
90
- if (actorOf(config)?.maxOutputTokens !== undefined &&
89
+ if (config.actor?.maxOutputTokens !== undefined &&
91
90
  (hasRunSession || driving.createProvider || driving.inProcess))
92
91
  return invalid("maxOutputTokens cannot be enforced by a custom runSession/provider/executor route.");
93
92
  const receivingReason = receivingEmailValidationReason(config);
@@ -219,7 +218,7 @@ export function planComputerUseStudy(config, input) {
219
218
  if (tasksReason)
220
219
  return refuse("before-scope", "HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason);
221
220
  // The parser checks the actor too; a library caller skips the parser.
222
- const actorType = actorOf(config)?.type ?? "";
221
+ const actorType = config.actor?.type ?? "";
223
222
  const descriptor = actorRegistry[actorType];
224
223
  if (!descriptor || !isCuaActorDescriptor(descriptor))
225
224
  return refuse("in-scope", "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered computer-use actor.`);
@@ -230,7 +229,7 @@ export function planComputerUseStudy(config, input) {
230
229
  // A shared world runs every participant against one app; this route would run them as separate
231
230
  // participants. It comes after the rules above, so a shared-world config that breaks one of them,
232
231
  // which runStudyWith sends here, still gets that rule's message.
233
- if (declaresSharedWorld(config))
232
+ if (config.route === "shared-world")
234
233
  return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", "route: shared-world studies run every participant against one shared app on the shared-world route; this route runs independent participants. The shared-world route takes subject.source clone or local-tree, or app-url with policies.allowPublicTargets: true, on execution.target e2b-desktop.", actor);
235
234
  // The in-process route drives subject.appUrl on this machine and creates no desktop, so it would
236
235
  // skip the subject a clone, local-tree or desktop-cli study declares.
@@ -32,7 +32,6 @@ import { runStudyParticipants } from "./live-phase.js";
32
32
  import { admitCuaRun, refuseCuaStudy, startCuaRun } from "./setup.js";
33
33
  import {} from "./types.js";
34
34
  import { studyResultIdentity } from "../../run/study-result.js";
35
- import { actorOf } from "../../study/study-fields.js";
36
35
  /**
37
36
  * A refused computer-use study's result, at the refusal's stage: a before-scope refusal has its own
38
37
  * envelope and no analysis record; the others come after the cwd checks, and the participant cap after
@@ -45,7 +44,7 @@ export async function computerUseStudyRefusal(options, refusal) {
45
44
  ...studyResultIdentity("computer-use", config.id),
46
45
  ok: false,
47
46
  cwd: path.resolve(options.cwd),
48
- actor: actorOf(config)?.type ?? "",
47
+ actor: config.actor?.type ?? "",
49
48
  dryRun,
50
49
  runId: options.runId ?? "not-created",
51
50
  appUrl: config.subject.appUrl ?? config.subject.serve?.url ?? "",
@@ -25,7 +25,6 @@ import { projectParticipantSubjects, subjectProvenanceArg } from "./subject-proj
25
25
  import { participantSubjectEnv, } from "./types.js";
26
26
  import { studyPersonaIds } from "../../study/persona-resolve.js";
27
27
  import { studyResultIdentity } from "../../run/study-result.js";
28
- import { actorOf } from "../../study/study-fields.js";
29
28
  /** The physical project, bound before any caller hook runs. */
30
29
  async function bindProject(cwd) {
31
30
  // Capture the physical project before reading or invoking any caller hook. A supported
@@ -48,7 +47,7 @@ export async function refuseCuaStudy(options, refusal) {
48
47
  ...studyResultIdentity("computer-use", config.id),
49
48
  ok: false,
50
49
  cwd: projectRoot.physicalPath,
51
- actor: refusal.actor ?? actorOf(config)?.type ?? "",
50
+ actor: refusal.actor ?? config.actor?.type ?? "",
52
51
  appUrl: declaredAppUrl(config),
53
52
  dryRun,
54
53
  runId: options.runId ?? "not-created",
@@ -214,6 +213,7 @@ export async function startCuaRun(plan, input, admitted, scope) {
214
213
  mintRunId: makeCuaRunId,
215
214
  mode: dryRun ? "dry-run" : "live",
216
215
  study: plan.study,
216
+ warnings: plan.warnings,
217
217
  renderReview: renderCuaReviewMarkdown,
218
218
  observer: { open: input.open === true, render: seams.renderObserver },
219
219
  });
@@ -30,6 +30,7 @@ export function admitPreviewPlan(plan, input) {
30
30
  function runPreviewPlan(plan, input) {
31
31
  return runDryRun({
32
32
  ...(plan.study === undefined ? {} : { study: plan.study }),
33
+ ...(plan.warnings === undefined ? {} : { warnings: plan.warnings }),
33
34
  cwd: input.cwd,
34
35
  dryRun: true,
35
36
  participantCount: plan.participantCount,
@@ -7,7 +7,6 @@ import { browserSurfaces } from "../../actors/scripted-browser/types.js";
7
7
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
8
8
  import { isNonEmpty, planBase, provisionedSubject } from "../../study/plan-base.js";
9
9
  import { cloneTargetValidationReason, desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
10
- import { actorOf, surfaceCount, scenarioRefOf } from "../../study/study-fields.js";
11
10
  // Default surface roster is 1 (desktop only): the defaults-table single-participant row governs;
12
11
  // `count: 2` is the declared override that adds the mobile surface.
13
12
  const DEFAULT_SURFACE_COUNT = 1;
@@ -43,7 +42,7 @@ export function planScriptedStudy(config, input) {
43
42
  const cloneTargetReason = cloneTargetValidationReason(config);
44
43
  if (cloneTargetReason)
45
44
  return refuse("HUMANISH_SCRIPTED_SUBJECT_UNSAFE", cloneTargetReason);
46
- const actorType = actorOf(config)?.type ?? "";
45
+ const actorType = config.actor?.type ?? "";
47
46
  const descriptor = actorRegistry[actorType];
48
47
  if (!descriptor || !isScriptedBrowserActorDescriptor(descriptor))
49
48
  return refuse("HUMANISH_SCRIPTED_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered scripted-browser actor.`);
@@ -64,7 +63,7 @@ export function planScriptedStudy(config, input) {
64
63
  subject = { kind: "loopback", appUrl };
65
64
  }
66
65
  // The steps are the actor, so there is no built-in journey to fall back on.
67
- const scenarioRef = scenarioRefOf(config);
66
+ const scenarioRef = config.scenario;
68
67
  if (!scenarioRef?.trim())
69
68
  return refuse("HUMANISH_SCRIPTED_SCENARIO_INVALID", "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.", { actor, appUrl: evidenceAppUrlOf(subject) });
70
69
  const requirements = [];
@@ -76,7 +75,7 @@ export function planScriptedStudy(config, input) {
76
75
  // The browser runs on this machine on both subjects; a clone's is pointed at its getHost URL.
77
76
  if (!input.dryRun && input.injectedBrowser !== true)
78
77
  requirements.push({ kind: "host-browser" });
79
- const persona = actorOf(config)?.persona;
78
+ const persona = config.actor?.persona;
80
79
  const timeoutMs = config.execution?.timeoutMs;
81
80
  return {
82
81
  ok: true,
@@ -89,7 +88,7 @@ export function planScriptedStudy(config, input) {
89
88
  actor,
90
89
  subject,
91
90
  scenarioRef,
92
- surfaces: browserSurfaces.slice(0, surfaceCount(config) ?? DEFAULT_SURFACE_COUNT),
91
+ surfaces: browserSurfaces.slice(0, config.surfaces?.length ?? DEFAULT_SURFACE_COUNT),
93
92
  ...(persona === undefined ? {} : { personaId: persona }),
94
93
  ...(timeoutMs === undefined ? {} : { sessionTimeoutMs: timeoutMs }),
95
94
  requirements,
@@ -31,7 +31,6 @@ import { ScriptedSubject } from "./subject.js";
31
31
  import { runScriptedSessions, writeSurfaceTraces } from "./surface-sessions.js";
32
32
  import {} from "./types.js";
33
33
  import { studyResultIdentity } from "../../run/study-result.js";
34
- import { actorOf } from "../../study/study-fields.js";
35
34
  /**
36
35
  * A refused scripted study's result, at the refusal's stage: a before-scope refusal has its own field
37
36
  * order and no analysis record; the others come after the output directory checks.
@@ -39,7 +38,7 @@ import { actorOf } from "../../study/study-fields.js";
39
38
  export async function scriptedStudyRefusal(options, refusal) {
40
39
  const { config, dryRun } = options;
41
40
  const cwd = path.resolve(options.cwd);
42
- const actorType = actorOf(config)?.type ?? "";
41
+ const actorType = config.actor?.type ?? "";
43
42
  if (refusal.beforeScope)
44
43
  return {
45
44
  ...studyResultIdentity("scripted", config.id),
@@ -95,6 +94,7 @@ async function runScriptedPlanInScope(plan, input, scope) {
95
94
  mintRunId: makeScriptedRunId,
96
95
  mode: dryRun ? "dry-run" : "live",
97
96
  study: plan.study,
97
+ warnings: plan.warnings,
98
98
  // Only a clone subject is served from a sandbox; an app-url run drives a local browser.
99
99
  sandboxes: setup.clone ? undefined : "none",
100
100
  renderReview: renderScriptedReviewMarkdown,