humanish 0.141.0 → 0.143.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 (99) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +3 -1
  4. package/dist/analysis/admission.d.ts +8 -0
  5. package/dist/analysis/admission.js +15 -4
  6. package/dist/analysis/automatic-config.d.ts +17 -3
  7. package/dist/analysis/automatic-config.js +56 -12
  8. package/dist/analysis/automatic.js +13 -7
  9. package/dist/analysis/execute.d.ts +6 -5
  10. package/dist/analysis/execute.js +27 -26
  11. package/dist/analysis/gemini-wire.d.ts +9 -0
  12. package/dist/analysis/gemini-wire.js +166 -0
  13. package/dist/analysis/image-tokens.d.ts +4 -2
  14. package/dist/analysis/image-tokens.js +24 -3
  15. package/dist/analysis/job.d.ts +1 -0
  16. package/dist/analysis/job.js +1 -0
  17. package/dist/analysis/models.d.ts +12 -0
  18. package/dist/analysis/models.js +47 -0
  19. package/dist/analysis/openai-wire.d.ts +6 -0
  20. package/dist/analysis/openai-wire.js +102 -0
  21. package/dist/analysis/provider.d.ts +19 -7
  22. package/dist/analysis/provider.js +21 -101
  23. package/dist/analysis/responses.d.ts +3 -0
  24. package/dist/analysis/responses.js +19 -0
  25. package/dist/analysis/service.js +11 -3
  26. package/dist/analysis/types.d.ts +12 -2
  27. package/dist/analysis/validation.d.ts +1 -0
  28. package/dist/analysis/validation.js +17 -15
  29. package/dist/cli/commands/analyze.js +11 -7
  30. package/dist/cli/commands/observe.js +26 -1
  31. package/dist/cli/commands/run-command.d.ts +1 -1
  32. package/dist/cli/commands/run-command.js +12 -0
  33. package/dist/cli/commands/study-remote.d.ts +13 -0
  34. package/dist/cli/commands/study-remote.js +125 -0
  35. package/dist/cli/commands/study-route-computer-use.js +26 -2
  36. package/dist/cli/commands/study-route-scripted.js +1 -0
  37. package/dist/cli/commands/study-route-shared-world.js +1 -0
  38. package/dist/cli/commands/study-route-terminal.js +1 -0
  39. package/dist/cli/commands/study-run.js +34 -0
  40. package/dist/cli/commands/watch.js +5 -0
  41. package/dist/cli/doctor.js +7 -2
  42. package/dist/cli/findings.d.ts +9 -1
  43. package/dist/cli/findings.js +62 -17
  44. package/dist/cli/io.d.ts +8 -0
  45. package/dist/cli/io.js +2 -1
  46. package/dist/keys/key-resolution.d.ts +10 -0
  47. package/dist/keys/key-resolution.js +33 -5
  48. package/dist/observer/listen.d.ts +7 -2
  49. package/dist/observer/listen.js +10 -4
  50. package/dist/observer/render.d.ts +2 -0
  51. package/dist/observer/render.js +1 -1
  52. package/dist/observer-app.html +1 -1
  53. package/dist/remote/launch.d.ts +56 -0
  54. package/dist/remote/launch.js +365 -0
  55. package/dist/remote/observer-proxy.d.ts +11 -0
  56. package/dist/remote/observer-proxy.js +66 -0
  57. package/dist/remote/plan.d.ts +35 -0
  58. package/dist/remote/plan.js +114 -0
  59. package/dist/remote/reattach.d.ts +17 -0
  60. package/dist/remote/reattach.js +81 -0
  61. package/dist/remote/runner.d.ts +84 -0
  62. package/dist/remote/runner.js +241 -0
  63. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  64. package/dist/routes/computer-use/types.d.ts +2 -0
  65. package/dist/routes/scripted/types.d.ts +2 -0
  66. package/dist/routes/shared-world/types.d.ts +2 -0
  67. package/dist/routes/terminal/types.d.ts +2 -0
  68. package/dist/run/costs.d.ts +2 -1
  69. package/dist/run/pricing.d.ts +13 -1
  70. package/dist/run/pricing.js +50 -4
  71. package/dist/run/results.d.ts +1 -1
  72. package/dist/run/run-cost.d.ts +2 -1
  73. package/dist/run/run-cost.js +6 -1
  74. package/dist/run/run.d.ts +13 -0
  75. package/dist/run/run.js +18 -7
  76. package/dist/run/sandbox-creates.d.ts +10 -3
  77. package/dist/run/sandbox-creates.js +48 -6
  78. package/dist/run-study.d.ts +5 -0
  79. package/dist/study/doctor.js +14 -1
  80. package/dist/study/requirements.d.ts +1 -1
  81. package/dist/study/requirements.js +8 -6
  82. package/dist/study/route-inputs.js +4 -0
  83. package/dist/study/run-study-events.d.ts +1 -1
  84. package/dist/study/run-study-events.js +9 -2
  85. package/dist/substrates/e2b/base-module.d.ts +3 -0
  86. package/dist/substrates/e2b/base-module.js +40 -0
  87. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  88. package/dist/substrates/e2b/desktop-browser.js +24 -6
  89. package/dist/substrates/e2b/sdk.d.ts +24 -0
  90. package/dist/substrates/e2b/sdk.js +5 -1
  91. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  92. package/dist/substrates/e2b/shell-only.js +27 -0
  93. package/dist/substrates/modal/sandbox.js +27 -28
  94. package/dist/substrates/modal/sdk.d.ts +7 -0
  95. package/dist/tui-app.js +46 -46
  96. package/docs/contracts/study-analysis.md +85 -4
  97. package/docs/product/automatic-analysis.md +42 -8
  98. package/package.json +2 -2
  99. package/skills/humanish/SKILL.md +18 -1
@@ -77,8 +77,13 @@ function estimateText(usd, complete) {
77
77
  function analysesNoun(requests) {
78
78
  return requests === 1 ? "analysis" : `${requests} analyses`;
79
79
  }
80
+ const BILLED_BY = {
81
+ openai: "OpenAI API key",
82
+ gemini: "Gemini API key",
83
+ codex: "Codex account",
84
+ };
80
85
  function billedBy(providers) {
81
- const names = providers.map((provider) => provider === "openai" ? "OpenAI API key" : "Codex account");
86
+ const names = providers.map((provider) => BILLED_BY[provider]);
82
87
  return names.length === 0 ? "" : ` (${names.join(" and ")})`;
83
88
  }
84
89
  /**
package/dist/run/run.d.ts CHANGED
@@ -11,6 +11,12 @@ interface StartRunOptions {
11
11
  cwd: string;
12
12
  /** Caller-supplied id (`--run-id`, library `runId`); `mintRunId` runs when it is absent. */
13
13
  runId?: string | undefined;
14
+ /**
15
+ * The run key every sandbox of this run carries (16 lowercase hex), in place of the one derived
16
+ * from the run directory. A remote run's launcher chooses it, so its own directory and the
17
+ * remote one name the same sandboxes.
18
+ */
19
+ ownerKey?: string | undefined;
14
20
  mintRunId: () => string;
15
21
  mode: "dry-run" | "live";
16
22
  study?: RunStudyProvenance | undefined;
@@ -44,6 +50,8 @@ interface StartRunPlan {
44
50
  interface StartRunInput {
45
51
  /** Caller-supplied id (`--run-id`, library `runId`); one is minted from the prefix when absent. */
46
52
  readonly runId?: string | undefined;
53
+ /** The run key its sandboxes carry; see StartRunOptions.ownerKey. */
54
+ readonly ownerKey?: string | undefined;
47
55
  /** Open the Observer page once it renders. */
48
56
  readonly open?: boolean | undefined;
49
57
  /**
@@ -215,4 +223,9 @@ export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>, hooks?:
215
223
  result: T;
216
224
  finished: FinishedRun | undefined;
217
225
  }>;
226
+ /**
227
+ * Make `runId` the project's latest run. A published run does it as it finishes, and a remote run's
228
+ * launcher once the run is copied back; this is the only writer of the pointer.
229
+ */
230
+ export declare function pointLatestRunAt(paths: PreparedRunArtifactPaths, runId: string, now?: () => number): Promise<void>;
218
231
  export {};
package/dist/run/run.js CHANGED
@@ -20,6 +20,7 @@ import { RunSecrets } from "./secrets.js";
20
20
  import { RUN_STATUS_FILE, beginRunStatus, runStatusOutcome, } from "./status.js";
21
21
  import { readRunJsonIfExists, runJsonValue } from "./locate.js";
22
22
  import { hostSuspensionEvents, systemHostClock, } from "./host-suspension.js";
23
+ import { pinRunOwnerKey } from "./sandbox-creates.js";
23
24
  import { splitStreamTraces } from "./stream-trace.js";
24
25
  /** An entry point called in a state that does not admit it. */
25
26
  class RunLifecycleError extends Error {
@@ -203,13 +204,7 @@ function runPublisher(args) {
203
204
  return written;
204
205
  };
205
206
  const writePointer = async () => {
206
- const pointer = {
207
- schema: "humanish.latest-run.v1",
208
- runId,
209
- path: paths.relativeRunRoot,
210
- updatedAt: new Date(now()).toISOString(),
211
- };
212
- await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
207
+ await pointLatestRunAt(paths, runId, now);
213
208
  pointerWritten = true;
214
209
  };
215
210
  // Each running participant's items so far, appended to its live file as the flushes arrive.
@@ -326,6 +321,8 @@ export async function runScope(fn, hooks = {}) {
326
321
  const created = await createRunArtifactPaths(options.cwd, runId);
327
322
  if (!created.ok)
328
323
  return created;
324
+ if (options.ownerKey !== undefined)
325
+ await pinRunOwnerKey(created.paths, options.ownerKey);
329
326
  // beginRunStatus reads only the mode, the study provenance, the sandboxes field and the
330
327
  // clock from the run's options.
331
328
  const runStatus = beginRunStatus(created.paths, { ...options, runId });
@@ -435,11 +432,25 @@ export async function runScope(fn, hooks = {}) {
435
432
  }
436
433
  return { result: outcome.result, finished };
437
434
  }
435
+ /**
436
+ * Make `runId` the project's latest run. A published run does it as it finishes, and a remote run's
437
+ * launcher once the run is copied back; this is the only writer of the pointer.
438
+ */
439
+ export async function pointLatestRunAt(paths, runId, now = Date.now) {
440
+ const pointer = {
441
+ schema: "humanish.latest-run.v1",
442
+ runId,
443
+ path: paths.relativeRunRoot,
444
+ updatedAt: new Date(now()).toISOString(),
445
+ };
446
+ await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
447
+ }
438
448
  /** Start a recorded route's run through `start`, the scope's own form, then capture its source. */
439
449
  async function startRecordedRun(start, plan, input, route) {
440
450
  const started = await start({
441
451
  cwd: route.cwd,
442
452
  runId: input.runId,
453
+ ownerKey: input.ownerKey,
443
454
  mintRunId: () => mintRunId(route.prefix),
444
455
  mode: plan.dryRun ? "dry-run" : "live",
445
456
  study: plan.study,
@@ -1,4 +1,4 @@
1
- import type { PreparedOutputRoot } from "./contained-output.js";
1
+ import { type PreparedOutputRoot } from "./contained-output.js";
2
2
  import type { SandboxOwnerTags, SandboxProviderId } from "./sandbox-receipts.js";
3
3
  /** Refuses a sandbox create for a run directory whose process is stopping. */
4
4
  export declare class SandboxCreateRefusedError extends Error {
@@ -17,10 +17,17 @@ export interface CreatedSandbox {
17
17
  * The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
18
18
  * directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
19
19
  * inode and creation time, so two projects that both use `--run-id smoke` never match each other's
20
- * sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
21
- * route already sets.
20
+ * sandboxes, and a renamed project directory still matches its own. A directory whose run was given
21
+ * a key (pinRunOwnerKey) uses that key instead, so two directories on different machines, a remote
22
+ * run's and its launcher's, name the same sandboxes. `tool` is the owner tag every route already
23
+ * sets.
22
24
  */
23
25
  export declare function sandboxOwnerTags(root: PreparedOutputRoot): SandboxOwnerTags;
26
+ /**
27
+ * Give a new run directory the run key `key` (16 lowercase hex characters) before it creates any
28
+ * sandbox. The file is created once and never replaced.
29
+ */
30
+ export declare function pinRunOwnerKey(root: PreparedOutputRoot, key: string): Promise<void>;
24
31
  /** A create in progress; the acquire step in src/substrates/e2b/sandbox.ts holds one per create. */
25
32
  export interface SandboxCreateTicket {
26
33
  /** Whether the directory's process has started stopping; checked before each attempt. */
@@ -8,7 +8,9 @@
8
8
  // sandbox as soon as its id arrives. The tags let `humanish reclaim` find a sandbox whose id
9
9
  // never reached this process at all.
10
10
  import { createHash } from "node:crypto";
11
+ import { closeSync, constants, openSync, readSync } from "node:fs";
11
12
  import path from "node:path";
13
+ import { createContainedOutputFile } from "./contained-output.js";
12
14
  /** Refuses a sandbox create for a run directory whose process is stopping. */
13
15
  export class SandboxCreateRefusedError extends Error {
14
16
  constructor() {
@@ -59,21 +61,61 @@ function idle(entry) {
59
61
  entry.inFlight.size === 0 &&
60
62
  [...entry.created.values()].every((sandbox) => sandbox.released));
61
63
  }
64
+ /** The file in a run directory that holds a run key its launcher chose. */
65
+ const OWNER_KEY_FILE = "owner-key";
66
+ const OWNER_KEY = /^[0-9a-f]{16}$/;
62
67
  /**
63
68
  * The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
64
69
  * directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
65
70
  * inode and creation time, so two projects that both use `--run-id smoke` never match each other's
66
- * sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
67
- * route already sets.
71
+ * sandboxes, and a renamed project directory still matches its own. A directory whose run was given
72
+ * a key (pinRunOwnerKey) uses that key instead, so two directories on different machines, a remote
73
+ * run's and its launcher's, name the same sandboxes. `tool` is the owner tag every route already
74
+ * sets.
68
75
  */
69
76
  export function sandboxOwnerTags(root) {
70
77
  const { where, identity } = identityOf(root);
71
- const runKey = createHash("sha256")
72
- .update(`${identity.ino}:${identity.birthtimeNs}`)
73
- .digest("hex")
74
- .slice(0, 16);
78
+ const runKey = pinnedOwnerKey(where) ??
79
+ createHash("sha256")
80
+ .update(`${identity.ino}:${identity.birthtimeNs}`)
81
+ .digest("hex")
82
+ .slice(0, 16);
75
83
  return { tool: "humanish", runId: path.basename(where), runKey };
76
84
  }
85
+ /**
86
+ * The key pinned in the run directory at `where`, if any. The file is opened without following a
87
+ * link, as every other file in a run directory is; a missing or malformed file pins nothing.
88
+ */
89
+ function pinnedOwnerKey(where) {
90
+ let fd;
91
+ try {
92
+ fd = openSync(path.join(where, OWNER_KEY_FILE), constants.O_RDONLY | constants.O_NOFOLLOW);
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ try {
98
+ const buffer = Buffer.alloc(64);
99
+ const length = readSync(fd, buffer, 0, buffer.length, 0);
100
+ const key = buffer.subarray(0, length).toString("utf8").trim();
101
+ return OWNER_KEY.test(key) ? key : undefined;
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ finally {
107
+ closeSync(fd);
108
+ }
109
+ }
110
+ /**
111
+ * Give a new run directory the run key `key` (16 lowercase hex characters) before it creates any
112
+ * sandbox. The file is created once and never replaced.
113
+ */
114
+ export async function pinRunOwnerKey(root, key) {
115
+ if (!OWNER_KEY.test(key))
116
+ throw new Error("A run's owner key is 16 lowercase hexadecimal characters.");
117
+ await createContainedOutputFile(root, OWNER_KEY_FILE, `${key}\n`);
118
+ }
77
119
  /** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
78
120
  export function beginSandboxCreate(root, participantId, provider) {
79
121
  const directory = directoryFor(root);
@@ -81,6 +81,11 @@ interface RunStudyInternals {
81
81
  scorerProvenance?: RunScorerProvenance;
82
82
  /** The local VM study's desktop, analysis gate and signal, for a local browser study. */
83
83
  localVm?: LocalVmInput;
84
+ /**
85
+ * The run key its sandboxes carry, chosen by the process that launched this run on a remote
86
+ * machine (the CLI's hidden `--owner-key`). Requires `runId`.
87
+ */
88
+ ownerKey?: string;
84
89
  }
85
90
  /** A run's result and the route it ran on. */
86
91
  interface RouteOutcome<R extends StudyRoute, T> {
@@ -12,6 +12,7 @@ import { externalCatchHealthy } from "../comms/sandbox-catch.js";
12
12
  import { receivingRequiredKey } from "../comms/setup.js";
13
13
  import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
14
14
  import { cli } from "../cli/invocation.js";
15
+ import { analysisKeyNames } from "../keys/key-resolution.js";
15
16
  /** A ready row's message, with the schema values the launch recorded. */
16
17
  function readyMessage(message, readiness) {
17
18
  const additions = protocolAdditionsWarning(readiness.cliVersion, readiness.protocolAdditions);
@@ -365,7 +366,7 @@ function subjectEnvChecks(names, args) {
365
366
  : "missing declared subject environment variable; provide it with --dotenv",
366
367
  }));
367
368
  }
368
- /** The post-run analysis: the Codex account, or the OpenAI key and the analysis cost limit. */
369
+ /** The post-run analysis: the Codex account, or the OpenAI or Gemini key and the cost limit. */
369
370
  async function analysisCheck(analysis, args, checkAccount) {
370
371
  if (analysis.provider === "codex") {
371
372
  const readiness = await checkAccount();
@@ -382,6 +383,18 @@ async function analysisCheck(analysis, args, checkAccount) {
382
383
  : `Codex account analysis is unavailable (${readiness.errorCode ?? "codex_unavailable"}). ${recovery} No API fallback is used; participant readiness is independent.`,
383
384
  };
384
385
  }
386
+ if (analysis.provider === "gemini") {
387
+ const names = analysisKeyNames("gemini");
388
+ const geminiKeyed = names.some(args.keyPresent);
389
+ return {
390
+ name: "post-run analysis",
391
+ ok: true,
392
+ ...(geminiKeyed ? {} : { status: "note" }),
393
+ message: geminiKeyed
394
+ ? `A Gemini API key is present for the separate automatic analysis request, which sends the evidence and captures to Google; model access and quota are not tested. The analysis is refused before it starts if ${admissionRule(`$${analysis.maxCostUsd}`)}; this is not a billing cap. Participant readiness is independent.`
395
+ : `Will be skipped: ${names.join(" and ")} are missing. The participant may run, but there will be no automatic findings report. Add it with ${cli("keys set gemini")}, or set review.analysis: false deliberately.`,
396
+ };
397
+ }
385
398
  const keyed = args.keyPresent("OPENAI_API_KEY");
386
399
  return {
387
400
  name: "post-run analysis",
@@ -10,7 +10,7 @@ export declare function requiredSubjectEnv(requirements: readonly Requirement[])
10
10
  /**
11
11
  * The provider keys a live run of `plan` is known to read: its `key` and `key-one-of` names, its
12
12
  * subject env, the external catch's token variable, ANTHROPIC_API_KEY for a Claude Code
13
- * participant, and OPENAI_API_KEY when automatic analysis runs on OpenAI. `run` prints a
13
+ * participant, and the analyst's key when automatic analysis runs on OpenAI or Gemini. `run` prints a
14
14
  * key-source line for these only. Discovery still fills every key it finds: other readers, such as
15
15
  * a declared scorer's host code, are not in the plan.
16
16
  */
@@ -6,8 +6,8 @@
6
6
  // key (`key-one-of`) stays with buildRuntimeAuth at run time, which also picks its placement.
7
7
  import { detectLocalAgents } from "../actors/local-agent/cli.js";
8
8
  import { localAgentRefusal } from "../actors/local-agent/readiness.js";
9
- import { describeMissingKeys } from "../keys/key-resolution.js";
10
- import { MODEL_RATES, unpricedCapMessage } from "../run/pricing.js";
9
+ import { analysisKeyNames, describeMissingKeys } from "../keys/key-resolution.js";
10
+ import { participantRate, unpricedCapMessage } from "../run/pricing.js";
11
11
  /** Provider keys in the order the routes name them in a refusal. */
12
12
  const KEY_ORDER = [
13
13
  "OPENAI_API_KEY",
@@ -45,7 +45,7 @@ export function requiredSubjectEnv(requirements) {
45
45
  /**
46
46
  * The provider keys a live run of `plan` is known to read: its `key` and `key-one-of` names, its
47
47
  * subject env, the external catch's token variable, ANTHROPIC_API_KEY for a Claude Code
48
- * participant, and OPENAI_API_KEY when automatic analysis runs on OpenAI. `run` prints a
48
+ * participant, and the analyst's key when automatic analysis runs on OpenAI or Gemini. `run` prints a
49
49
  * key-source line for these only. Discovery still fills every key it finds: other readers, such as
50
50
  * a declared scorer's host code, are not in the plan.
51
51
  */
@@ -63,8 +63,10 @@ export function keyNamesOf(plan) {
63
63
  const catchToken = plan.residual.comms?.email?.external?.authTokenEnv;
64
64
  if (catchToken !== undefined)
65
65
  names.add(catchToken);
66
- if (plan.analysis !== undefined && plan.analysis.config.provider !== "codex")
67
- names.add("OPENAI_API_KEY");
66
+ const analyst = plan.analysis?.config.provider;
67
+ if (plan.analysis !== undefined && analyst !== "codex")
68
+ for (const name of analysisKeyNames(analyst ?? "openai"))
69
+ names.add(name);
68
70
  return names;
69
71
  }
70
72
  /**
@@ -140,7 +142,7 @@ export function unpricedCapCheck(args) {
140
142
  const { caps, model } = args;
141
143
  if (caps.maxUsd === undefined && caps.maxTotalUsd === undefined)
142
144
  return undefined;
143
- if (MODEL_RATES[model.trim().toLowerCase()])
145
+ if (participantRate(model))
144
146
  return undefined;
145
147
  return { code: args.code, message: unpricedCapMessage(model) };
146
148
  }
@@ -10,6 +10,7 @@ export function computerUseInput(options, deps, emit) {
10
10
  ...(options.open === undefined ? {} : { open: options.open }),
11
11
  ...(options.onObserverReady === undefined ? {} : { onObserverReady: options.onObserverReady }),
12
12
  ...(options.runId === undefined ? {} : { runId: options.runId }),
13
+ ...(options.ownerKey === undefined ? {} : { ownerKey: options.ownerKey }),
13
14
  ...(options.rerun === undefined ? {} : { rerun: options.rerun }),
14
15
  ...(options.env === undefined ? {} : { env: options.env }),
15
16
  ...(options.prepareDesktop === undefined ? {} : { prepareDesktop: options.prepareDesktop }),
@@ -28,6 +29,7 @@ export function scriptedInput(options, deps, emit) {
28
29
  cwd: options.cwd,
29
30
  ...(options.open === undefined ? {} : { open: options.open }),
30
31
  ...(options.runId === undefined ? {} : { runId: options.runId }),
32
+ ...(options.ownerKey === undefined ? {} : { ownerKey: options.ownerKey }),
31
33
  ...(options.env === undefined ? {} : { env: options.env }),
32
34
  ...(options.prepareDesktop === undefined ? {} : { prepareDesktop: options.prepareDesktop }),
33
35
  deps,
@@ -40,6 +42,7 @@ export function terminalInput(options, deps, emit) {
40
42
  cwd: options.cwd,
41
43
  ...(options.open === undefined ? {} : { open: options.open }),
42
44
  ...(options.runId === undefined ? {} : { runId: options.runId }),
45
+ ...(options.ownerKey === undefined ? {} : { ownerKey: options.ownerKey }),
43
46
  ...(options.env === undefined ? {} : { env: options.env }),
44
47
  deps,
45
48
  ...scorerOf(options, terminalRouteScorer),
@@ -52,6 +55,7 @@ export function sharedWorldInput(options, deps, emit) {
52
55
  ...(options.open === undefined ? {} : { open: options.open }),
53
56
  ...(options.onObserverReady === undefined ? {} : { onObserverReady: options.onObserverReady }),
54
57
  ...(options.runId === undefined ? {} : { runId: options.runId }),
58
+ ...(options.ownerKey === undefined ? {} : { ownerKey: options.ownerKey }),
55
59
  ...(options.env === undefined ? {} : { env: options.env }),
56
60
  ...(options.prepareDesktop === undefined ? {} : { prepareDesktop: options.prepareDesktop }),
57
61
  ...observersOf(options, emit),
@@ -54,7 +54,7 @@ export type StudyEvent = {
54
54
  /**
55
55
  * The literal values to scrub from an onEvent warning: the provider keys and the declared subject
56
56
  * env from every env the run could read (the `env` option and process.env), and the
57
- * analysis API key. A callback can hold any of them, and its warning is appended after the route
57
+ * analysis API keys, OpenAI's and Gemini's. A callback can hold any of them, and its warning is appended after the route
58
58
  * sanitized its own.
59
59
  */
60
60
  export declare function knownSecrets(config: StudyConfig, options: InternalRunStudyOptions, forwardedEnv: Readonly<Record<string, string | undefined>> | undefined): RunSecrets;
@@ -1,17 +1,24 @@
1
1
  // What a run reports through RunStudyOptions.onEvent, and how a callback failure becomes a run
2
2
  // warning. normalizeRunStudyOptions (run-study-options.ts) builds the emitter and hands it to the
3
3
  // route hook bags it maps; the event values are built here.
4
+ import { analysisKeyNames } from "../keys/key-resolution.js";
4
5
  import { redactText, toErrorMessage } from "../evidence/redaction.js";
5
6
  import { RunSecrets } from "../run/secrets.js";
6
7
  /**
7
8
  * The literal values to scrub from an onEvent warning: the provider keys and the declared subject
8
9
  * env from every env the run could read (the `env` option and process.env), and the
9
- * analysis API key. A callback can hold any of them, and its warning is appended after the route
10
+ * analysis API keys, OpenAI's and Gemini's. A callback can hold any of them, and its warning is appended after the route
10
11
  * sanitized its own.
11
12
  */
12
13
  export function knownSecrets(config, options, forwardedEnv) {
13
14
  const sources = [forwardedEnv, options.env, process.env];
14
- const names = ["OPENAI_API_KEY", "E2B_API_KEY", "CODEX_API_KEY", ...(config.subject.env ?? [])];
15
+ const names = [
16
+ "OPENAI_API_KEY",
17
+ "E2B_API_KEY",
18
+ "CODEX_API_KEY",
19
+ ...analysisKeyNames("gemini"),
20
+ ...(config.subject.env ?? []),
21
+ ];
15
22
  const secrets = new RunSecrets([]);
16
23
  for (const env of sources)
17
24
  for (const name of names)
@@ -0,0 +1,3 @@
1
+ import { type E2BDesktopModule } from "./sdk.js";
2
+ /** A sandbox port that creates and connects to E2B base-template sandboxes. */
3
+ export declare function loadE2BBaseModule(): Promise<E2BDesktopModule>;
@@ -0,0 +1,40 @@
1
+ // E2B's base sandbox, the `base` template with no desktop, behind the same sandbox port as the
2
+ // desktop, so acquire(), receipts, release and reclaim work on it unchanged. A sandbox that runs
3
+ // humanish itself needs commands, files and one authenticated port; the desktop template would
4
+ // start an X session nobody uses and bill its 8 vCPU.
5
+ //
6
+ // @e2b/desktop re-exports the e2b SDK but replaces its Sandbox with the desktop subclass, and a
7
+ // strict install does not let humanish import `e2b`, which is only @e2b/desktop's dependency. The
8
+ // base class is the desktop class's parent.
9
+ import { guardDesktopSandboxCreate, importE2BDesktop, } from "./sdk.js";
10
+ import { shellOnlySurface } from "./shell-only.js";
11
+ /** The header E2B's proxy reads a sandbox's traffic access token from (spike, 2026-10-21). */
12
+ const TRAFFIC_TOKEN_HEADER = "e2b-traffic-access-token";
13
+ class E2BDesktopOnlyError extends Error {
14
+ constructor(what) {
15
+ super(`${what} needs a desktop sandbox; this E2B sandbox runs the base template.`);
16
+ this.name = "E2BDesktopOnlyError";
17
+ }
18
+ }
19
+ /** A sandbox port that creates and connects to E2B base-template sandboxes. */
20
+ export async function loadE2BBaseModule() {
21
+ return guardDesktopSandboxCreate({ Sandbox: baseSandbox(await importE2BDesktop()) });
22
+ }
23
+ function baseSandbox(desktop) {
24
+ const Base = Object.getPrototypeOf(desktop.Sandbox);
25
+ if (typeof Base.create !== "function")
26
+ throw new Error("The installed @e2b/desktop does not extend the e2b SDK's Sandbox; update @e2b/desktop.");
27
+ class BaseSandbox extends Base {
28
+ constructor(...args) {
29
+ super(...args);
30
+ Object.assign(this, shellOnlySurface((what) => new E2BDesktopOnlyError(what)));
31
+ }
32
+ async portAccess(port) {
33
+ const token = this.trafficAccessToken;
34
+ if (token === undefined || token === "")
35
+ throw new Error("This E2B sandbox accepts public traffic, so it has no traffic access token. Create it with network.allowPublicTraffic set to false.");
36
+ return { url: `https://${this.getHost(port)}`, headers: { [TRAFFIC_TOKEN_HEADER]: token } };
37
+ }
38
+ }
39
+ return BaseSandbox;
40
+ }
@@ -81,4 +81,4 @@ extraChromiumFlags?: readonly string[], environment?: Readonly<Record<string, st
81
81
  * from it run in that zone.
82
82
  */
83
83
  export declare function openDesktopTerminal(desktop: E2BDesktopSandbox, requestTimeoutMs: number, workdir: string | undefined, timeZone?: string): Promise<void>;
84
- export declare function startDesktopStream(desktop: E2BDesktopSandbox, browserWindowId: string | undefined): Promise<void>;
84
+ export declare function startDesktopStream(desktop: E2BDesktopSandbox, browserWindowId: string | undefined, requestTimeoutMs: number): Promise<void>;
@@ -402,15 +402,33 @@ export async function openDesktopTerminal(desktop, requestTimeoutMs, workdir, ti
402
402
  throw new Error("desktop-cli participant could not open a terminal on this desktop template");
403
403
  }
404
404
  }
405
- export async function startDesktopStream(desktop, browserWindowId) {
405
+ export async function startDesktopStream(desktop, browserWindowId, requestTimeoutMs) {
406
406
  if (!browserWindowId) {
407
407
  await desktop.stream.start({ requireAuth: true });
408
- return;
409
408
  }
410
- try {
411
- await desktop.stream.start({ requireAuth: true, windowId: browserWindowId });
409
+ else {
410
+ try {
411
+ await desktop.stream.start({ requireAuth: true, windowId: browserWindowId });
412
+ }
413
+ catch {
414
+ await desktop.stream.start({ requireAuth: true });
415
+ }
412
416
  }
413
- catch {
414
- await desktop.stream.start({ requireAuth: true });
417
+ await makeStreamViewOnly(desktop, requestTimeoutMs);
418
+ }
419
+ /**
420
+ * Switches the stream's VNC server to view-only and confirms it. Both providers start x11vnc
421
+ * without `-viewonly`, and the URL's `view_only` is a noVNC client setting, so anyone holding the
422
+ * URL could otherwise type into the participant's desktop. x11vnc takes the change over its X
423
+ * property channel and answers the query with `ans=viewonly:1`; anything else throws, and the
424
+ * caller treats the stream as unavailable.
425
+ */
426
+ async function makeStreamViewOnly(desktop, requestTimeoutMs) {
427
+ const shell = e2bShell(desktop);
428
+ const options = { timeoutMs: 20_000, requestTimeoutMs };
429
+ await shell.run("x11vnc -display :0 -sync -R viewonly", options);
430
+ const query = await shell.run("x11vnc -display :0 -Q viewonly", options);
431
+ if (!/^ans=viewonly:1$/m.test(query.stdout)) {
432
+ throw new Error("the live stream could not be made view-only");
415
433
  }
416
434
  }
@@ -30,6 +30,12 @@ export interface E2BDesktopModule {
30
30
  * (2.38 and later) returns a paginator.
31
31
  */
32
32
  list?(options?: E2BSandboxListOptions): E2BSandboxPaginator;
33
+ /**
34
+ * A handle on a sandbox that exists, by exact id, with the same calls a create returns. Throws
35
+ * a SandboxNotFoundError-shaped error when the id is gone. A process that did not create the
36
+ * sandbox uses it to run commands there and reach its ports.
37
+ */
38
+ connect?(sandboxId: string, options?: E2BRequestOptions): Promise<E2BDesktopSandbox>;
33
39
  };
34
40
  }
35
41
  /**
@@ -84,6 +90,17 @@ export interface E2BNetworkOptions {
84
90
  headers?: Record<string, string>;
85
91
  };
86
92
  }[]>;
93
+ /**
94
+ * False makes every URL `getHost` gives answer only requests that carry the sandbox's traffic
95
+ * access token. E2B's own default is true.
96
+ */
97
+ allowPublicTraffic?: boolean;
98
+ }
99
+ /** A URL that reaches a port inside a sandbox, and the headers the provider requires on each request. */
100
+ export interface SandboxPortAccess {
101
+ url: string;
102
+ /** Credentials: never persist or log them. */
103
+ headers: Record<string, string>;
87
104
  }
88
105
  /**
89
106
  * A create names its account. e2bAccount (connection.ts) gives every later call on the sandbox the
@@ -172,6 +189,11 @@ export interface E2BDesktopSandbox {
172
189
  * the concurrent backend fails closed when it is absent rather than calling a missing method.
173
190
  */
174
191
  getHost?(port: number): string;
192
+ /**
193
+ * The URL and credential headers that reach `port`, for a sandbox whose provider authenticates
194
+ * port traffic: an E2B sandbox created with `allowPublicTraffic: false`, or any Modal sandbox.
195
+ */
196
+ portAccess?(port: number): Promise<SandboxPortAccess>;
175
197
  screenshot(format?: "bytes"): Promise<Uint8Array>;
176
198
  wait(ms: number): Promise<void>;
177
199
  leftClick(x?: number, y?: number): Promise<void>;
@@ -202,6 +224,8 @@ export interface E2BDesktopSandbox {
202
224
  };
203
225
  }
204
226
  export declare function loadE2BDesktopModule(): Promise<E2BDesktopModule>;
227
+ /** The optional @e2b/desktop peer as it is, or an error that says how to install it. */
228
+ export declare function importE2BDesktop(): Promise<E2BDesktopModule>;
205
229
  export declare const DESKTOP_CREATE_CLEANUP_TIMEOUT_MS = 10000;
206
230
  /**
207
231
  * Call `observer` with the sandbox id as soon as the guarded SDK constructs the handle for a
@@ -8,8 +8,12 @@
8
8
  import { protectDesktopScreenshotCleanup } from "./desktop-screenshot-cleanup.js";
9
9
  import { desktopPeerAdvice, humanishInstall } from "./peer-install.js";
10
10
  export async function loadE2BDesktopModule() {
11
+ return guardDesktopSandboxCreate(await importE2BDesktop());
12
+ }
13
+ /** The optional @e2b/desktop peer as it is, or an error that says how to install it. */
14
+ export async function importE2BDesktop() {
11
15
  try {
12
- return guardDesktopSandboxCreate((await import("@e2b/desktop")));
16
+ return (await import("@e2b/desktop"));
13
17
  }
14
18
  catch (error) {
15
19
  if (isMissingE2BDesktopDependency(error)) {
@@ -0,0 +1,5 @@
1
+ import type { E2BDesktopSandbox } from "./sdk.js";
2
+ type DesktopSurface = Pick<E2BDesktopSandbox, "wait" | "launch" | "screenshot" | "leftClick" | "rightClick" | "middleClick" | "doubleClick" | "moveMouse" | "scroll" | "write" | "press" | "drag" | "stream">;
3
+ /** Desktop calls that reject with `unsupported(what)`; `wait` still waits. */
4
+ export declare function shellOnlySurface(unsupported: (what: string) => Error): DesktopSurface;
5
+ export {};
@@ -0,0 +1,27 @@
1
+ /** Desktop calls that reject with `unsupported(what)`; `wait` still waits. */
2
+ export function shellOnlySurface(unsupported) {
3
+ const reject = (what) => () => Promise.reject(unsupported(what));
4
+ return {
5
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
6
+ launch: reject("A desktop application launch"),
7
+ screenshot: reject("A desktop screenshot"),
8
+ leftClick: reject("Desktop input"),
9
+ rightClick: reject("Desktop input"),
10
+ middleClick: reject("Desktop input"),
11
+ doubleClick: reject("Desktop input"),
12
+ moveMouse: reject("Desktop input"),
13
+ scroll: reject("Desktop input"),
14
+ write: reject("Desktop input"),
15
+ press: reject("Desktop input"),
16
+ drag: reject("Desktop input"),
17
+ stream: {
18
+ getAuthKey: () => {
19
+ throw unsupported("A desktop stream");
20
+ },
21
+ getUrl: () => {
22
+ throw unsupported("A desktop stream");
23
+ },
24
+ start: reject("A desktop stream"),
25
+ },
26
+ };
27
+ }