humanish 0.144.0 → 0.146.0

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 (52) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/analysis/admission.d.ts +8 -3
  3. package/dist/analysis/admission.js +7 -3
  4. package/dist/analysis/automatic-config.js +2 -1
  5. package/dist/analysis/automatic.js +2 -2
  6. package/dist/analysis/execute.d.ts +7 -10
  7. package/dist/analysis/execute.js +79 -49
  8. package/dist/analysis/models.d.ts +2 -0
  9. package/dist/analysis/models.js +15 -0
  10. package/dist/analysis/openai-wire.d.ts +1 -1
  11. package/dist/analysis/openai-wire.js +36 -26
  12. package/dist/analysis/output-limit.d.ts +12 -0
  13. package/dist/analysis/output-limit.js +34 -0
  14. package/dist/analysis/output-retry.d.ts +20 -0
  15. package/dist/analysis/output-retry.js +63 -0
  16. package/dist/analysis/provider.d.ts +1 -1
  17. package/dist/analysis/provider.js +3 -3
  18. package/dist/analysis/responses.js +1 -1
  19. package/dist/analysis/service.js +2 -2
  20. package/dist/analysis/validation.js +1 -1
  21. package/dist/cli/commands/analyze.js +1 -1
  22. package/dist/cli/commands/study-remote.d.ts +3 -1
  23. package/dist/cli/commands/study-remote.js +18 -3
  24. package/dist/cli/io.js +2 -0
  25. package/dist/cli/run-findings.js +10 -2
  26. package/dist/keys/key-resolution.js +2 -2
  27. package/dist/observer-app.html +1 -1
  28. package/dist/remote/launch.d.ts +21 -3
  29. package/dist/remote/launch.js +111 -52
  30. package/dist/remote/runner.d.ts +47 -14
  31. package/dist/remote/runner.js +199 -64
  32. package/dist/routes/computer-use/e2b-desktop/desktop.js +47 -7
  33. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +4 -2
  34. package/dist/routes/computer-use/e2b-desktop/prepare.js +4 -2
  35. package/dist/routes/computer-use/e2b-desktop/start.d.ts +14 -6
  36. package/dist/routes/computer-use/e2b-desktop/start.js +72 -34
  37. package/dist/routes/terminal/credentials.js +3 -2
  38. package/dist/routes/terminal/live-sandbox.js +2 -2
  39. package/dist/routes/terminal/runtime-auth.d.ts +0 -12
  40. package/dist/routes/terminal/runtime-auth.js +0 -23
  41. package/dist/substrates/e2b/cdp-probe.d.ts +16 -1
  42. package/dist/substrates/e2b/cdp-probe.js +67 -5
  43. package/dist/substrates/e2b/desktop-browser.js +4 -2
  44. package/dist/substrates/e2b/desktop-cdp.d.ts +9 -2
  45. package/dist/substrates/e2b/desktop-cdp.js +26 -4
  46. package/dist/substrates/e2b/egress-keys.d.ts +22 -0
  47. package/dist/substrates/e2b/egress-keys.js +59 -0
  48. package/docs/contracts/schemas.md +1 -1
  49. package/docs/contracts/study-analysis.md +75 -9
  50. package/docs/product/automatic-analysis.md +9 -6
  51. package/package.json +5 -5
  52. package/skills/humanish/SKILL.md +2 -1
@@ -15,7 +15,7 @@ import { loadModalShellModule } from "../../substrates/modal/sandbox.js";
15
15
  import { e2bAccount } from "../../substrates/e2b/connection.js";
16
16
  import { E2BDesktopStartupError, loadE2BDesktopModule, } from "../../substrates/e2b/sdk.js";
17
17
  import { shellQuote } from "../../substrates/shell.js";
18
- import { buildOpenAiEgressNetwork } from "./runtime-auth.js";
18
+ import { holdKeysInEgress } from "../../substrates/e2b/egress-keys.js";
19
19
  import { TERMINAL_RUNTIME_VERSION_TIMEOUT_MS, buildRuntimeExecPrefix, buildRuntimeVersionCommand, parseTerminalRuntimeVersion, } from "./runtime.js";
20
20
  import { runWithWallClock, teardownSandbox } from "./sandbox.js";
21
21
  import { DEFAULT_REQUEST_TIMEOUT_MS, PRODUCT_SETUP_TIMEOUT_MS, SANDBOX_WORKDIR, UPLOAD_MAX_BYTES, } from "./types.js";
@@ -73,7 +73,7 @@ export class LiveTerminalSandbox {
73
73
  ? undefined
74
74
  : { allowOut: [...egressAllow], denyOut: ["0.0.0.0/0"] };
75
75
  const network = runtimeEnv.mode === "openai-egress"
76
- ? buildOpenAiEgressNetwork(runtimeEnv.keyValue, routing)
76
+ ? holdKeysInEgress({ OPENAI_API_KEY: runtimeEnv.keyValue }, { existing: routing }).network
77
77
  : routing;
78
78
  const options = {
79
79
  apiKey: e2bApiKey,
@@ -1,18 +1,6 @@
1
1
  import type { StudyRuntimeAuth } from "../../study/types.js";
2
- import type { E2BNetworkOptions } from "../../substrates/e2b/sdk.js";
3
2
  /**
4
3
  * The runtime auth a study gets when it declares none. A shell agent can read its own command env,
5
4
  * so the raw key stays in the host-side E2B request unless a study declares openai-env.
6
5
  */
7
6
  export declare const DEFAULT_RUNTIME_AUTH: StudyRuntimeAuth;
8
- /** E2B envd installs the sandbox-specific proxy CA into this system bundle before routing. */
9
- export declare const E2B_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
10
- /** An inert nonsecret value satisfies Codex exec's auth prerequisite. E2B replaces the header. */
11
- export declare const OPENAI_EGRESS_PLACEHOLDER = "humanish-egress-auth-placeholder";
12
- /**
13
- * Keep the runtime key in the host-side E2B request, outside sandbox env/files. The installed
14
- * E2B SDK declares that transform headers override existing request headers. This adds no routing
15
- * restrictions: every sandbox process can still spend via the provider proxy. Never log the
16
- * returned options. An adopter's existing exact-host rule must not be silently overwritten.
17
- */
18
- export declare function buildOpenAiEgressNetwork(keyValue: string, existing?: E2BNetworkOptions): E2BNetworkOptions;
@@ -3,26 +3,3 @@
3
3
  * so the raw key stays in the host-side E2B request unless a study declares openai-env.
4
4
  */
5
5
  export const DEFAULT_RUNTIME_AUTH = "openai-egress";
6
- const OPENAI_EGRESS_HOST = "api.openai.com";
7
- /** E2B envd installs the sandbox-specific proxy CA into this system bundle before routing. */
8
- export const E2B_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
9
- /** An inert nonsecret value satisfies Codex exec's auth prerequisite. E2B replaces the header. */
10
- export const OPENAI_EGRESS_PLACEHOLDER = "humanish-egress-auth-placeholder";
11
- /**
12
- * Keep the runtime key in the host-side E2B request, outside sandbox env/files. The installed
13
- * E2B SDK declares that transform headers override existing request headers. This adds no routing
14
- * restrictions: every sandbox process can still spend via the provider proxy. Never log the
15
- * returned options. An adopter's existing exact-host rule must not be silently overwritten.
16
- */
17
- export function buildOpenAiEgressNetwork(keyValue, existing) {
18
- if (Object.keys(existing?.rules ?? {}).some((host) => host.toLowerCase().replace(/\.$/, "") === OPENAI_EGRESS_HOST)) {
19
- throw new Error("openai-egress conflicts with an existing api.openai.com network rule; refusing to overwrite it.");
20
- }
21
- return {
22
- ...existing,
23
- rules: {
24
- ...existing?.rules,
25
- [OPENAI_EGRESS_HOST]: [{ transform: { headers: { Authorization: `Bearer ${keyValue}` } } }],
26
- },
27
- };
28
- }
@@ -30,8 +30,11 @@ export interface ChromeCdpProbeArgs {
30
30
  * pausing it; it applies `emulation` (how a participant keeps the overrides for its whole life,
31
31
  * with a reload on each page) and `network` capture, either or both. "fidelity": read
32
32
  * back what the page reports about itself (UA, DPR, viewport, touch), the proof for the bundle.
33
+ * "load": poll the page until it has loaded and painted, or until `deadlineMs`.
33
34
  */
34
- mode: "state" | "geometry" | "port" | "emulate" | "hold" | "fidelity";
35
+ mode: "state" | "geometry" | "port" | "emulate" | "hold" | "fidelity" | "load";
36
+ /** For "load": how long to poll before reporting the page as not loaded. */
37
+ deadlineMs?: number;
35
38
  /** For "emulate" and "hold": what to apply. */
36
39
  emulation?: ChromeMobileEmulationRequest;
37
40
  /** For "hold": capture each held page's requests to a sandbox file. */
@@ -41,6 +44,16 @@ export interface ChromeCdpProbeArgs {
41
44
  export interface ChromeMobileEmulationRequest extends MobileEmulationRequest {
42
45
  platform?: string;
43
46
  }
47
+ /**
48
+ * What the "load" mode saw. `loaded` is true once the page reports document.readyState complete,
49
+ * is visible and has a first contentful paint. `seen` describes the last read in words that name
50
+ * no URL.
51
+ */
52
+ export interface ChromePageLoad {
53
+ loaded: boolean;
54
+ waitedMs: number;
55
+ seen: string;
56
+ }
44
57
  /** What the page reports after emulation: the proof, never copied from the request. */
45
58
  interface ChromeFidelityRead {
46
59
  userAgent: string;
@@ -80,6 +93,8 @@ export interface ChromeCdpProbeResult {
80
93
  } | {
81
94
  unavailable: string;
82
95
  };
96
+ /** "load": whether the page loaded and painted before the deadline, and what the last read saw. */
97
+ load?: ChromePageLoad;
83
98
  /**
84
99
  * Seconds since the epoch on the sandbox clock when the probe printed, on every answer including
85
100
  * an unavailable one. It is the clock CDP `wallTime` uses, so a turn mark compares with request
@@ -213,6 +213,10 @@ GEOMETRY_EXPRESSION = (
213
213
  "({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, "
214
214
  "viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })"
215
215
  )
216
+ LOAD_EXPRESSION = (
217
+ "({ readyState: document.readyState, visible: document.visibilityState === 'visible', "
218
+ "painted: performance.getEntriesByType('paint').some(function (entry) { return entry.name === 'first-contentful-paint'; }) })"
219
+ )
216
220
  FIDELITY_EXPRESSION = (
217
221
  "({ userAgent: navigator.userAgent, devicePixelRatio: window.devicePixelRatio, "
218
222
  "innerWidth: window.innerWidth, innerHeight: window.innerHeight, "
@@ -405,6 +409,50 @@ def follow_targets(ws, launch_id, request, log):
405
409
  if params.get("waitingForDebugger"):
406
410
  ws.send("Runtime.runIfWaitingForDebugger", {}, new_session)
407
411
 
412
+ def page_ws_url(port, page):
413
+ # Chrome omits webSocketDebuggerUrl from /json while another DevTools client is attached to the
414
+ # page, or for a moment after one detaches; the page socket URL is still /devtools/page/<id>.
415
+ return page.get("webSocketDebuggerUrl") or (
416
+ "ws://127.0.0.1:%d/devtools/page/%s" % (port, page.get("id")) if page.get("id") else None
417
+ )
418
+
419
+ def wait_for_load(port, args):
420
+ """Poll the page until document.readyState is complete, the page is visible and Chrome has
421
+ recorded its first contentful paint: the page has loaded and something of it is drawn. Stops at
422
+ deadlineMs and reports what the last read saw."""
423
+ started = time.monotonic()
424
+ deadline_ms = int(args.get("deadlineMs") or 0)
425
+ seen = "no http page yet"
426
+ page = None
427
+ loaded = False
428
+ while True:
429
+ try:
430
+ page = select_page(list_pages(port), args)
431
+ seen = "no http page yet" if page is None else seen
432
+ except Exception as error:
433
+ page, seen = None, "the DevTools endpoint did not answer (%s)" % type(error).__name__
434
+ ws_url = page_ws_url(port, page) if page is not None else None
435
+ state = evaluate(ws_url, LOAD_EXPRESSION) if ws_url else None
436
+ if isinstance(state, dict):
437
+ ready = str(state.get("readyState") or "unknown")
438
+ seen = "document.readyState %s, %s, %s" % (
439
+ ready,
440
+ "visible" if state.get("visible") else "hidden",
441
+ "first contentful paint recorded" if state.get("painted") else "no first contentful paint yet",
442
+ )
443
+ loaded = ready == "complete" and bool(state.get("visible")) and bool(state.get("painted"))
444
+ if loaded:
445
+ break
446
+ elif ws_url:
447
+ seen = "the page did not answer Runtime.evaluate"
448
+ if (time.monotonic() - started) * 1000 >= deadline_ms:
449
+ break
450
+ time.sleep(0.25)
451
+ answer = {"load": {"loaded": loaded, "waitedMs": int((time.monotonic() - started) * 1000), "seen": seen}}
452
+ if page is not None:
453
+ answer["targetId"] = str(page.get("id") or "")
454
+ emit(answer)
455
+
408
456
  def emit(answer):
409
457
  """Print one answer line stamped with this sandbox's clock. CDP wallTime uses the same clock, so
410
458
  a turn mark taken from it compares with request times without host skew, and the reading needs
@@ -419,6 +467,9 @@ def main():
419
467
  if mode == "port":
420
468
  emit({"cdpPort": port})
421
469
  return
470
+ if mode == "load":
471
+ wait_for_load(port, args)
472
+ return
422
473
  try:
423
474
  pages = list_pages(port)
424
475
  except Exception as error:
@@ -441,11 +492,7 @@ def main():
441
492
  if page is None:
442
493
  emit({"unavailable": "no http page among %d CDP targets on 127.0.0.1:%d" % (len(pages), port)})
443
494
  return
444
- # Chrome omits webSocketDebuggerUrl from /json while another DevTools client is attached to the
445
- # page, or for a moment after one detaches; the page socket URL is still /devtools/page/<id>.
446
- ws_url = page.get("webSocketDebuggerUrl") or (
447
- "ws://127.0.0.1:%d/devtools/page/%s" % (port, page.get("id")) if page.get("id") else None
448
- )
495
+ ws_url = page_ws_url(port, page)
449
496
  if mode == "emulate":
450
497
  applied, failure = emulate(ws_url, args.get("emulation") or {}) if ws_url else (None, "the page has no socket")
451
498
  if failure is not None:
@@ -502,6 +549,8 @@ export function chromeCdpProbeCommand(args) {
502
549
  payload.emulation = args.emulation;
503
550
  if (args.network !== undefined)
504
551
  payload.network = args.network;
552
+ if (args.deadlineMs !== undefined)
553
+ payload.deadlineMs = args.deadlineMs;
505
554
  return `python3 -c ${shellQuote(CHROME_CDP_PROBE_PY)} ${shellQuote(JSON.stringify(payload))}`;
506
555
  }
507
556
  /**
@@ -529,6 +578,7 @@ export function parseChromeCdpProbeOutput(stdout) {
529
578
  const numberOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
530
579
  const now = numberOr(record.now);
531
580
  const network = networkOf(record.network);
581
+ const load = loadOf(record.load);
532
582
  if (typeof record.unavailable === "string") {
533
583
  return {
534
584
  unavailable: record.unavailable,
@@ -602,9 +652,21 @@ export function parseChromeCdpProbeOutput(stdout) {
602
652
  ...(browserWindow === undefined ? {} : { browserWindow }),
603
653
  ...(viewport === undefined ? {} : { viewport }),
604
654
  ...(network === undefined ? {} : { network }),
655
+ ...(load === undefined ? {} : { load }),
605
656
  ...(now === undefined ? {} : { now }),
606
657
  };
607
658
  }
659
+ function loadOf(value) {
660
+ if (!value || typeof value !== "object")
661
+ return undefined;
662
+ const source = value;
663
+ if (typeof source.loaded !== "boolean" ||
664
+ typeof source.waitedMs !== "number" ||
665
+ !Number.isFinite(source.waitedMs) ||
666
+ typeof source.seen !== "string")
667
+ return undefined;
668
+ return { loaded: source.loaded, waitedMs: source.waitedMs, seen: source.seen };
669
+ }
608
670
  function networkOf(value) {
609
671
  if (!value || typeof value !== "object")
610
672
  return undefined;
@@ -7,8 +7,10 @@ import { runDetachedStep } from "../detached.js";
7
7
  import { shellQuote } from "../shell.js";
8
8
  import { externalLinkHandlerSetup } from "./desktop-handoff.js";
9
9
  import { e2bShell } from "./shell.js";
10
- // Settle after opening the browser or the terminal, before the first screenshot: long enough for
11
- // a cold browser and page load to paint (2 s captured a blank desktop; the render needs 6-9 s).
10
+ // The wait before the first screenshot. After a terminal opens, or a browser without a DevTools
11
+ // read, it is a fixed sleep: long enough for a cold browser and page load to paint (2 s captured a
12
+ // blank desktop; the render needs 6-9 s). A Chromium participant waits for its page to load and
13
+ // paint (waitForChromePageLoad) and gives up after this long.
12
14
  export const DESKTOP_SETTLE_MS = 8_000;
13
15
  /** The DevTools port every Chromium participant launches with; see `chrome_debug_flags` below. */
14
16
  export const CHROME_DEVTOOLS_PORT = 9222;
@@ -1,5 +1,5 @@
1
1
  import type { ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
2
- import { type ChromeCdpProbeResult, type ChromeMobileEmulationRequest } from "./cdp-probe.js";
2
+ import { type ChromeCdpProbeResult, type ChromeMobileEmulationRequest, type ChromePageLoad } from "./cdp-probe.js";
3
3
  import type { RunDesktopGeometry } from "../../run/streams.js";
4
4
  import type { E2BDesktopSandbox } from "./sdk.js";
5
5
  /**
@@ -85,6 +85,13 @@ export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, request
85
85
  /** The holder's capture status, when `network` was passed and the holder said. */
86
86
  network?: ChromeCdpProbeResult["network"];
87
87
  }>;
88
+ /**
89
+ * Wait until the participant's launch page has loaded and painted, read over DevTools in the
90
+ * sandbox, for at most `deadlineMs`. Resolves with what the wait saw, or undefined when it could
91
+ * not run (no python3, a failed command, output that is not the probe's), so the caller can fall
92
+ * back to a fixed wait.
93
+ */
94
+ export declare function waitForChromePageLoad(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, deadlineMs: number): Promise<ChromePageLoad | undefined>;
88
95
  /**
89
96
  * Start a holder that records the launch page's requests, and every later tab's, to
90
97
  * `network.eventsPath`, for a participant without mobile emulation; applyMobileEmulation passes
@@ -94,7 +101,7 @@ export declare function applyMobileEmulation(desktop: E2BDesktopSandbox, request
94
101
  export declare function startNetworkCapture(desktop: E2BDesktopSandbox, requestTimeoutMs: number, endpoint: ChromeCdpEndpoint, network: ChromeNetworkCaptureRequest): Promise<string>;
95
102
  /**
96
103
  * What the capture holder announced: when capture started on the launch page, on the sandbox
97
- * clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
104
+ * clock, or why it did not. Reads the holder's log up to 22 times, half a second apart by
98
105
  * `desktop.wait`, and reports a holder that printed nothing as unavailable.
99
106
  */
100
107
  export declare function readNetworkCaptureStart(desktop: E2BDesktopSandbox, requestTimeoutMs: number, holderName: string): Promise<NonNullable<ChromeCdpProbeResult["network"]>>;
@@ -213,10 +213,32 @@ export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint,
213
213
  ...capture,
214
214
  };
215
215
  }
216
+ /**
217
+ * Wait until the participant's launch page has loaded and painted, read over DevTools in the
218
+ * sandbox, for at most `deadlineMs`. Resolves with what the wait saw, or undefined when it could
219
+ * not run (no python3, a failed command, output that is not the probe's), so the caller can fall
220
+ * back to a fixed wait.
221
+ */
222
+ export async function waitForChromePageLoad(desktop, requestTimeoutMs, endpoint, deadlineMs) {
223
+ const result = await e2bShell(desktop)
224
+ .run(chromeCdpProbeCommand({ ...endpoint, prefer: "pinned", mode: "load", deadlineMs }), {
225
+ requestTimeoutMs,
226
+ // One poll can take 3.5 s past the deadline: a 2 s listing and a 1.5 s evaluate.
227
+ timeoutMs: deadlineMs + 10_000,
228
+ })
229
+ .catch(() => undefined);
230
+ if (result === undefined || result.exitCode !== 0)
231
+ return undefined;
232
+ return parseChromeCdpProbeOutput(result.stdout).load;
233
+ }
216
234
  /** The holder stops writing its events file at 32 MiB, so a read stops there too. */
217
235
  const NETWORK_EVENTS_MAX_BYTES = 32 * 1024 * 1024;
218
- /** How many times the capture holder's log is read for its announce, and how far apart. */
219
- const NETWORK_ANNOUNCE_READS = 6;
236
+ /**
237
+ * How many times the capture holder's log is read for its announce, and how far apart. The reads
238
+ * start after the page wait, which can end within a second of the holder's start, so they cover
239
+ * the 11 s a holder had when a fixed 8 s settle came before 3 s of reads.
240
+ */
241
+ const NETWORK_ANNOUNCE_READS = 22;
220
242
  const NETWORK_ANNOUNCE_EVERY_MS = 500;
221
243
  /**
222
244
  * Start a holder that records the launch page's requests, and every later tab's, to
@@ -235,7 +257,7 @@ export async function startNetworkCapture(desktop, requestTimeoutMs, endpoint, n
235
257
  }
236
258
  /**
237
259
  * What the capture holder announced: when capture started on the launch page, on the sandbox
238
- * clock, or why it did not. Reads the holder's log up to 6 times, half a second apart by
260
+ * clock, or why it did not. Reads the holder's log up to 22 times, half a second apart by
239
261
  * `desktop.wait`, and reports a holder that printed nothing as unavailable.
240
262
  */
241
263
  export async function readNetworkCaptureStart(desktop, requestTimeoutMs, holderName) {
@@ -253,7 +275,7 @@ export async function readNetworkCaptureStart(desktop, requestTimeoutMs, holderN
253
275
  });
254
276
  }
255
277
  return {
256
- unavailable: `the in-sandbox holder printed nothing within ${(NETWORK_ANNOUNCE_READS * NETWORK_ANNOUNCE_EVERY_MS) / 1000} s of the desktop settling`,
278
+ unavailable: `the in-sandbox holder printed nothing within ${(NETWORK_ANNOUNCE_READS * NETWORK_ANNOUNCE_EVERY_MS) / 1000} s of the page wait`,
257
279
  };
258
280
  }
259
281
  /**
@@ -0,0 +1,22 @@
1
+ import type { E2BNetworkOptions } from "./sdk.js";
2
+ /** An inert nonsecret value that satisfies a client's "key is set" check. E2B replaces the header. */
3
+ export declare const EGRESS_PLACEHOLDER = "humanish-egress-auth-placeholder";
4
+ /** E2B envd installs the sandbox-specific proxy CA into this system bundle before routing. */
5
+ export declare const E2B_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
6
+ /**
7
+ * Hold every key in `keys` that travels as a header to a fixed host in an E2B header transform on
8
+ * `existing`, and give the env a placeholder for it. Other names pass through unchanged. The
9
+ * installed E2B SDK declares that transform headers override the request's own. The returned
10
+ * network carries the key values: never log or persist it. E2B's sandbox info endpoint returns
11
+ * the transforms as given, so a process that can call the E2B API for this sandbox can read them.
12
+ * An existing rule for a held key's host is refused, never overwritten.
13
+ */
14
+ export declare function holdKeysInEgress(keys: Readonly<Record<string, string>>, options?: {
15
+ /** The account's E2B_DOMAIN; the E2B API host is `api.<domain>`. */
16
+ domain?: string | undefined;
17
+ existing?: E2BNetworkOptions | undefined;
18
+ }): {
19
+ network: E2BNetworkOptions;
20
+ env: Record<string, string>;
21
+ held: string[];
22
+ };
@@ -0,0 +1,59 @@
1
+ /** An inert nonsecret value that satisfies a client's "key is set" check. E2B replaces the header. */
2
+ export const EGRESS_PLACEHOLDER = "humanish-egress-auth-placeholder";
3
+ /** E2B envd installs the sandbox-specific proxy CA into this system bundle before routing. */
4
+ export const E2B_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
5
+ const GEMINI = {
6
+ host: "generativelanguage.googleapis.com",
7
+ header: "x-goog-api-key",
8
+ value: (key) => key,
9
+ };
10
+ /**
11
+ * Where each key name humanish sends as a header goes. Every host is fixed in the code that calls
12
+ * it: src/actors/openai-endpoint.ts, src/analysis/gemini-wire.ts, and the E2B SDK's
13
+ * `https://api.<E2B_DOMAIN>` with its `X-API-KEY` header.
14
+ */
15
+ function headerKey(name, domain) {
16
+ if (name === "OPENAI_API_KEY")
17
+ return { host: "api.openai.com", header: "Authorization", value: (key) => `Bearer ${key}` };
18
+ if (name === "GEMINI_API_KEY" || name === "GOOGLE_API_KEY")
19
+ return GEMINI;
20
+ if (name === "E2B_API_KEY")
21
+ return { host: `api.${domain}`, header: "X-API-Key", value: (key) => key };
22
+ return undefined;
23
+ }
24
+ const sameHost = (a, b) => a.toLowerCase().replace(/\.$/, "") === b.toLowerCase().replace(/\.$/, "");
25
+ /**
26
+ * Hold every key in `keys` that travels as a header to a fixed host in an E2B header transform on
27
+ * `existing`, and give the env a placeholder for it. Other names pass through unchanged. The
28
+ * installed E2B SDK declares that transform headers override the request's own. The returned
29
+ * network carries the key values: never log or persist it. E2B's sandbox info endpoint returns
30
+ * the transforms as given, so a process that can call the E2B API for this sandbox can read them.
31
+ * An existing rule for a held key's host is refused, never overwritten.
32
+ */
33
+ export function holdKeysInEgress(keys, options = {}) {
34
+ const { existing } = options;
35
+ const domain = options.domain?.trim() || "e2b.app";
36
+ const rules = {};
37
+ const env = {};
38
+ const held = [];
39
+ for (const [name, value] of Object.entries(keys)) {
40
+ const placed = headerKey(name, domain);
41
+ if (placed === undefined) {
42
+ env[name] = value;
43
+ continue;
44
+ }
45
+ const taken = [...Object.keys(existing?.rules ?? {}), ...Object.keys(rules)].some((host) => sameHost(host, placed.host));
46
+ if (taken)
47
+ throw new Error(`${name} would be held in a header transform on ${placed.host}, which already has a network rule; refusing to overwrite it.`);
48
+ rules[placed.host] = [{ transform: { headers: { [placed.header]: placed.value(value) } } }];
49
+ env[name] = EGRESS_PLACEHOLDER;
50
+ held.push(name);
51
+ }
52
+ if (held.length === 0)
53
+ return { network: { ...existing }, env, held };
54
+ return {
55
+ network: { ...existing, rules: { ...existing?.rules, ...rules } },
56
+ env,
57
+ held: held.sort(),
58
+ };
59
+ }
@@ -212,7 +212,7 @@ The rest of this section names keys by their v3 spelling:
212
212
  browser target; `entry` is a shared-world participant's same-origin path;
213
213
  - `participants[].files` (computer-use on `execution.target: e2b-desktop` only):
214
214
  1 to 20 `{ from, as }` entries, each a file copied onto that participant's desktop
215
- before its browser opens. `from` is relative to the folder that holds the study file
215
+ before its session starts. `from` is relative to the folder that holds the study file
216
216
  (`cwd` for a library config); `as` is relative to the desktop user's home folder.
217
217
  The parser refuses an absolute path, a `.`, `..` or empty part, a backslash, an `as`
218
218
  that starts with `~`, lands in `subject/` or on a name starting with `.humanish`, looks
@@ -27,8 +27,10 @@ cap; a refused dry run says why, and names the `--max-cost` that admits it.
27
27
 
28
28
  The default provider is `openai`; its default model is `gpt-6.1-sol`, at medium reasoning effort
29
29
  ([reasoning effort](#reasoning-effort)). A request sends selected retained text and
30
- captures to OpenAI, without tools, redirects, provider-side response storage, or
31
- automatic retries. `--question` adds a reviewer question; it never changes the
30
+ captures to OpenAI, without tools, redirects or provider-side response storage. A request that
31
+ runs out of output is sent once more while the cap admits it
32
+ ([a request that runs out of output](#a-request-that-runs-out-of-output)); no other failure is
33
+ sent again. `--question` adds a reviewer question; it never changes the
32
34
  participant assignment. For OpenAI API analysis, `--max-cost` is required, including for dry-run
33
35
  admission. Admission refuses the request when both its worst case and its expected cost plus a
34
36
  10% margin are over it; the provider bill is not limited by it.
@@ -60,12 +62,39 @@ times for those billed $1 or more.
60
62
  A refusal's message gives the expected cost, the worst case, the cap, and
61
63
  `humanish analyze --run <id> --max-cost <n>` with `n` the worst case rounded up.
62
64
 
63
- The default deadline is ten minutes for each request. With no explicit output-token limit, analysis
64
- uses 32,768 tokens if admission fits the declared budget, or retains the prior
65
- 16,384-token allowance otherwise. Explicit limits are never adjusted. Selection
66
- happens before dispatch; `admission.outputTokenAllowance` exposes it in dry-run,
67
- and the saved configuration, digest and automatic job bind the selected allowance.
68
- This selection never increases the spending limit or starts a retry.
65
+ The default deadline is ten minutes for each request. With no explicit output-token limit,
66
+ `src/analysis/output-limit.ts` sizes the allowance to the packet. An OpenAI analysis at none, low
67
+ or medium effort of up to 16 participants gets 12,288 tokens plus 2,048 per participant, at most
68
+ 32,768: 14,336 for one participant and 18,432 for three. A high-effort analysis, a Gemini
69
+ analysis and a run analysed in cohorts get 32,768. If admission refuses an allowance above
70
+ 16,384 under the declared budget, the analysis keeps 16,384. Explicit limits are never adjusted.
71
+ Selection happens before dispatch; `admission.outputTokenAllowance` exposes it in dry-run, and
72
+ the saved configuration, digest and automatic job bind the selected allowance. This selection
73
+ never increases the spending limit.
74
+
75
+ The sizes come from the finished reports on the 2026-10-22 and 2026-10-23 benchmark recordings:
76
+ the Taskly analyses listed in `docs/evidence/benchmark/2026-10-2*.json`, one participant with 8 to
77
+ 14 captures, and the movie-game analyses of
78
+ [analysis models on fixed recordings](https://github.com/danielgwilson/humanish/blob/main/docs/evidence/benchmark/2026-10-22-analysis-models.md), three
79
+ participants with 40 to 49 captures. Each analysis's output tokens are read from its retained
80
+ artifact.
81
+
82
+ | Analyst and effort | One participant: passed, most output | Three participants: passed, most output |
83
+ | -------------------- | ------------------------------------ | --------------------------------------- |
84
+ | `gpt-6.1-sol` low | 24, 4,308 | 6, 6,391 |
85
+ | `gpt-6.1-sol` medium | 142, 5,659 | 36, 9,000 |
86
+ | `gpt-6-luna` medium | 27, 8,354 | 2, 11,313 |
87
+ | `gpt-6.1-sol` high | 22, 10,086 | 6, 15,797 |
88
+ | `gpt-6-luna` high | 78, 19,880 | 9, 26,439 |
89
+ | `gemini-3.8-flash` | 23, 29,739 | 1, 26,154 |
90
+
91
+ At low and medium effort the sized limit is 1.7 times the most output of a finished report of
92
+ one participant and 1.6 times that of three; for `gpt-6.1-sol` at medium it is 2.5 and 2.0 times.
93
+ At high effort a finished `gpt-6-luna` report of three participants used 26,439 tokens, so 32,768
94
+ has no room to lower. A `gpt-6.1-sol` analysis at medium that ran out of output on a
95
+ one-participant run had used all 32,768 tokens in 374 seconds and cost $0.39; at 14,336 tokens the
96
+ same request stops at 44% of that output.
97
+
69
98
  The configured wall-clock deadline covers the entire request, including waiting
70
99
  for response headers and reading the body. A request-scoped dispatcher prevents
71
100
  Node's separate fetch timeout from cutting a longer configured deadline short;
@@ -77,6 +106,29 @@ success. Findings keep reported concerns and observed recovery distinct across
77
106
  participants. Other supported models can be selected explicitly, but evidence
78
107
  reference validation does not certify their interpretation of small visual details.
79
108
 
109
+ ## A request that runs out of output
110
+
111
+ When an OpenAI or Gemini request ends with `output_incomplete`, humanish sends the same request
112
+ once more, with a fresh deadline. It does so only when the spend so far, plus what admission
113
+ holds for the attempt's requests still running and admission's figure for one more request (its
114
+ expected cost plus 10%, or its worst case when that is lower), is within `maxCostUsd`. The spend
115
+ so far counts every response of the attempt, the one that ran out included. When a response did
116
+ not report its usage, the spend is unknown and the request is not sent again. A request is sent
117
+ again at most once; when the second one also runs out, the attempt fails with
118
+ `analysis_output_incomplete`. A cohort request and the merge request are each sent again the same
119
+ way. Codex account analysis has no dollar cap to check and is never sent again. A timeout, an HTTP
120
+ error and a rejected report are not sent again: a timed-out request has already used the
121
+ ten-minute deadline.
122
+
123
+ The attempt keeps one execution start, one receipt and one `analysis.json`. Their `usage` is the
124
+ sum of every response, both requests of a retry included, and `estimatedCostUsd` prices each
125
+ response as its own request. The schemas are unchanged. A warning in `warnings` (and in the human
126
+ output of `analyze` and of a live run, under its `analysis:` line) names the request, the input
127
+ and output tokens and the cost of the response that ran out, and says whether humanish sent it
128
+ again; a refused retry gives the spend, the held amount and the cap. The result is partial with
129
+ `analysis_admission_estimate_exceeded` only when the bill passes admission's worst case plus the
130
+ worst case of each request sent again, or the cap.
131
+
80
132
  ## Runs with more than 16 participants
81
133
 
82
134
  One analysis covers up to 128 participants, the most streams it reads from a run
@@ -104,7 +156,8 @@ whole packet as a single request's report does.
104
156
  The attempt has one execution start, one receipt and one `analysis.json`, with the
105
157
  run's participants, coverage and evidence and the summed usage of every request
106
158
  sent. The artifact schema is unchanged. Its stored limits grew to hold eight
107
- cohorts: 6,400 evidence items, 320 captures and 16 MiB. If a cohort request fails
159
+ cohorts: 6,400 evidence items, 320 captures and 16 MiB. A cohort request or the merge request that
160
+ runs out of output is sent once more under the rule above. If a cohort request fails
108
161
  or is rejected, no further request starts, requests already sent finish, no merge
109
162
  request is sent and the attempt fails with that request's code, with no findings;
110
163
  a warning names the cohort's size. A failed merge request fails the attempt the same way. The usage
@@ -461,6 +514,19 @@ that screen is a strong design finding. Validation is unchanged. A design findin
461
514
  least one cited capture, so an impression plus the capture of its screen makes a design finding,
462
515
  and an impression alone supports only a finding about what the participant said.
463
516
 
517
+ ## Participant statement citations
518
+
519
+ An observation with basis `participant_statement` must cite at least one quote-eligible entry
520
+ (`analysis_validation_failed_statement_source_invalid` otherwise). It may also cite captures or
521
+ actions, as an `action` observation may cite more than its one required action. The prompt still
522
+ asks for statements that cite only quote-eligible entries; validation accepts an analyst that adds
523
+ the capture the participant spoke about. Validation does not check that the observation's wording
524
+ matches the quote. Entries of kind `reasoning` are quote-eligible, so a claim drawn from a
525
+ participant's private reasoning also passes as a statement. Before this rule, any non-quote
526
+ citation failed the whole analysis, which caused nearly all `gpt-6-luna` rejections on the
527
+ 2026-10-22 benchmark recordings
528
+ ([participant statements that cite captures](https://github.com/danielgwilson/humanish/blob/main/docs/evidence/benchmark/2026-10-22-statement-rule.md)).
529
+
464
530
  ## Durable records
465
531
 
466
532
  The `humanish.observer-data.v3` schema is unchanged by analysis. A companion
@@ -16,15 +16,17 @@ expected cost and a 600-second timeout. A study or command that names a model bu
16
16
  that model at high; only the default analyst, with no model named, runs at medium. On the same
17
17
  recordings it matched `gpt-6-astra` at high, the earlier default, at about one seventh of the cost
18
18
  ([analysis models on fixed recordings](https://github.com/danielgwilson/humanish/blob/main/docs/evidence/benchmark/2026-10-22-analysis-models.md)). When no output limit is specified,
19
- humanish selects 32,768 tokens if the exact input's expected cost still fits that
20
- cap; otherwise it keeps the established 16,384-token allowance. This preserves
21
- previously admitted studies without increasing their spending limit. To customize it:
19
+ humanish sizes it to the analysis: at low or medium effort, 12,288 tokens plus 2,048 per
20
+ participant (14,336 for one), and 32,768 at high effort, on Gemini and past 16 participants. An
21
+ allowance above 16,384 that the cap would refuse falls back to 16,384, so a study its cap admitted
22
+ before stays admitted ([output limit](../contracts/study-analysis.md#invocation)). A
23
+ request that runs out of output is sent once more while the cap admits it. To customize it:
22
24
 
23
25
  ```yaml
24
26
  review:
25
27
  analysis:
26
28
  maxCostUsd: 10
27
- # Optional overrides. Omit maxOutputTokens for budget-aware selection.
29
+ # Optional overrides. Omit maxOutputTokens for a limit sized to the analysis.
28
30
  # A named model without reasoningEffort runs at high.
29
31
  model: gpt-6.1-sol
30
32
  reasoningEffort: medium
@@ -57,8 +59,9 @@ effort; on 24 Taskly analyses `gpt-6.1-sol` at medium wrote a mean 3,747 and cos
57
59
  evidence to inspect the expected cost, the worst case and the selected token
58
60
  allowance before deliberately choosing a larger budget. Explicit
59
61
  `maxOutputTokens` and `--max-output-tokens` limits are honored exactly. The output allowance
60
- includes reasoning as well as the report; exhausting it does not produce a usable
61
- report and never starts an automatic retry. Analysis
62
+ includes reasoning as well as the report; a request that exhausts it has no usable report. It
63
+ is sent once more when the spend so far and admission's figure for one more request fit the cap,
64
+ and the CLI says so under the analysis line; the attempt's usage counts both requests. Analysis
62
65
  sends selected retained text and captures to OpenAI using `OPENAI_API_KEY`, or to Google with
63
66
  `provider: gemini`.
64
67
  Analysis runs in the humanish runner using its credentials. This setting adds no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.144.0",
3
+ "version": "0.146.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -120,13 +120,13 @@
120
120
  "@types/pngjs": "^6.0.5",
121
121
  "axe-core": "4.14.0",
122
122
  "fast-check": "catalog:",
123
- "knip": "6.41.0",
123
+ "knip": "6.42.1",
124
124
  "modal": "^0.11.1",
125
125
  "oxc-parser": "catalog:",
126
- "oxfmt": "0.73.0",
127
- "oxlint": "1.88.0",
126
+ "oxfmt": "0.74.0",
127
+ "oxlint": "1.89.0",
128
128
  "oxlint-tsgolint": "7.0.2004",
129
- "skills": "1.7.2",
129
+ "skills": "1.7.4",
130
130
  "tsx": "^4.23.15",
131
131
  "typescript": "catalog:",
132
132
  "vitest": "catalog:"
@@ -560,7 +560,8 @@ SIGTERM stops the run: humanish marks it interrupted and stops its sandboxes.
560
560
  If its terminal closes, the command exits 129 with `HUMANISH_REMOTE_DETACHED` and the run goes
561
561
  on; `observe --run <id>` copies it back. `--local` keeps one run on this machine,
562
562
  `execution.remote: false` every run of a study. The JSON result's `remote.keys` names the keys
563
- the runner received.
563
+ the run used. On E2B the OpenAI, Gemini and E2B keys stay in E2B's egress proxy and the runner
564
+ holds placeholders; a Modal runner receives them as environment variables.
564
565
  - Start a study that can outlive your shell call in tmux, for example
565
566
  `tmux new -d -s study 'npx humanish run <study> --json --no-open > run-result.json 2> run.log'`.
566
567
  `--detach` only skips the Observer server at the end; it does not move the run to the background.