humanish 0.111.0 → 0.111.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CONTEXT.md +1 -1
  3. package/dist/actors/codex/restricted-participant-policy.d.ts +2 -0
  4. package/dist/actors/codex/restricted-participant-policy.js +30 -2
  5. package/dist/actors/codex/restricted-participant.js +1 -1
  6. package/dist/actors/computer-use/executor-diagnostic.d.ts +15 -0
  7. package/dist/actors/computer-use/executor-diagnostic.js +105 -0
  8. package/dist/actors/computer-use/executor-error.d.ts +18 -3
  9. package/dist/actors/computer-use/executor-error.js +30 -4
  10. package/dist/actors/computer-use/loop/actions.d.ts +6 -3
  11. package/dist/actors/computer-use/loop/actions.js +27 -7
  12. package/dist/actors/computer-use/loop/ending.js +9 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +14 -0
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +1 -1
  16. package/dist/actors/computer-use/openai-context.d.ts +8 -2
  17. package/dist/actors/computer-use/openai-context.js +91 -26
  18. package/dist/actors/computer-use/openai-provider.js +55 -12
  19. package/dist/actors/computer-use/openai-requests.d.ts +2 -1
  20. package/dist/actors/computer-use/openai-requests.js +4 -1
  21. package/dist/actors/contract.d.ts +7 -1
  22. package/dist/browser-control/client.js +5 -5
  23. package/dist/browser-control/protocol.d.ts +30 -1
  24. package/dist/browser-control/protocol.js +19 -2
  25. package/dist/browser-control/transport.d.ts +3 -2
  26. package/dist/browser-control/transport.js +12 -7
  27. package/dist/cli/commands/study-route-computer-use.js +2 -2
  28. package/dist/cli/commands/study-route-shared-world.js +2 -2
  29. package/dist/cli/commands/study.js +1 -2
  30. package/dist/cli/doctor.js +11 -0
  31. package/dist/evidence/encoded-text.d.ts +3 -3
  32. package/dist/evidence/encoded-text.js +10 -5
  33. package/dist/evidence/redaction.d.ts +2 -2
  34. package/dist/evidence/redaction.js +18 -7
  35. package/dist/guest/browser-tools.js +5 -1
  36. package/dist/guest/chromium-text.js +42 -18
  37. package/dist/guest/desktop-executor.js +5 -1
  38. package/dist/keys/key-resolution.d.ts +3 -0
  39. package/dist/keys/key-resolution.js +15 -2
  40. package/dist/observer-app.html +6 -6
  41. package/dist/routes/computer-use/bundle-parts.d.ts +4 -0
  42. package/dist/routes/computer-use/bundle-parts.js +7 -2
  43. package/dist/routes/computer-use/fanout-bundle.js +4 -2
  44. package/dist/routes/computer-use/local-vm.js +1 -2
  45. package/dist/routes/computer-use/participant-model.js +6 -1
  46. package/dist/routes/computer-use/plan.js +3 -4
  47. package/dist/routes/computer-use/route.js +1 -2
  48. package/dist/routes/computer-use/setup.js +2 -2
  49. package/dist/routes/preview.js +1 -0
  50. package/dist/routes/scripted/plan.js +4 -5
  51. package/dist/routes/scripted/route.js +2 -2
  52. package/dist/routes/shared-world/plan.js +4 -5
  53. package/dist/routes/shared-world/route.js +2 -2
  54. package/dist/routes/shared-world/setup.js +1 -0
  55. package/dist/routes/terminal/dry-run.js +1 -0
  56. package/dist/routes/terminal/live-finish.js +24 -5
  57. package/dist/routes/terminal/live-sandbox.js +5 -1
  58. package/dist/routes/terminal/plan.js +3 -4
  59. package/dist/routes/terminal/recorder.d.ts +6 -0
  60. package/dist/routes/terminal/recorder.js +61 -6
  61. package/dist/routes/terminal/route.js +1 -2
  62. package/dist/routes/terminal/session.js +2 -1
  63. package/dist/routes/terminal/token-usage.d.ts +3 -0
  64. package/dist/routes/terminal/token-usage.js +5 -0
  65. package/dist/routes/terminal/types.d.ts +2 -1
  66. package/dist/routes/terminal/types.js +2 -1
  67. package/dist/run/dry-run.js +1 -0
  68. package/dist/run/results.d.ts +2 -0
  69. package/dist/run/run.d.ts +2 -0
  70. package/dist/run/run.js +16 -2
  71. package/dist/run/terminal-contract.d.ts +10 -0
  72. package/dist/run/terminal-contract.js +27 -9
  73. package/dist/study/composition-rules.js +17 -18
  74. package/dist/study/doctor.js +4 -5
  75. package/dist/study/migrate/v2.d.ts +5 -0
  76. package/dist/study/migrate/v2.js +23 -0
  77. package/dist/study/parse/front.js +2 -21
  78. package/dist/study/parse/url-credentials.d.ts +3 -3
  79. package/dist/study/parse/url-credentials.js +155 -13
  80. package/dist/study/persona-resolve.js +2 -2
  81. package/dist/study/plan-base.js +3 -4
  82. package/dist/study/plan-participants.js +3 -3
  83. package/dist/study/plan-types.d.ts +2 -0
  84. package/dist/study/plan.js +11 -5
  85. package/dist/study/routing.js +11 -11
  86. package/dist/study/run-study-options.js +2 -2
  87. package/dist/study/study-fields.d.ts +1 -13
  88. package/dist/study/study-fields.js +0 -24
  89. package/dist/study/summary.js +7 -7
  90. package/dist/study/types.d.ts +1 -0
  91. package/dist/study/validation.js +11 -11
  92. package/dist/study/warnings.d.ts +8 -0
  93. package/dist/study/warnings.js +22 -7
  94. package/dist/substrates/local/runtime-config.js +6 -6
  95. package/docs/architecture/browser-control.md +12 -2
  96. package/docs/architecture/guest-desktop.md +7 -1
  97. package/docs/architecture/terminal-product-route.md +3 -2
  98. package/docs/contracts/schemas.md +16 -5
  99. package/package.json +1 -1
@@ -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_]*$/;