humanish 0.140.0 → 0.142.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 (155) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +2 -0
  4. package/dist/actors/codex/restricted-participant.js +1 -1
  5. package/dist/actors/computer-use/external-handoff.d.ts +22 -0
  6. package/dist/actors/computer-use/external-handoff.js +83 -0
  7. package/dist/actors/computer-use/loop/observation.d.ts +7 -0
  8. package/dist/actors/computer-use/loop/observation.js +17 -1
  9. package/dist/actors/computer-use/loop/types.d.ts +6 -0
  10. package/dist/analysis/admission.d.ts +8 -0
  11. package/dist/analysis/admission.js +15 -4
  12. package/dist/analysis/analysis-limits.d.ts +6 -0
  13. package/dist/analysis/analysis-limits.js +6 -0
  14. package/dist/analysis/analysis-lock.d.ts +8 -0
  15. package/dist/analysis/analysis-lock.js +129 -0
  16. package/dist/analysis/automatic-completion.js +7 -0
  17. package/dist/analysis/automatic-config.d.ts +5 -2
  18. package/dist/analysis/automatic-config.js +23 -8
  19. package/dist/analysis/automatic.js +13 -7
  20. package/dist/analysis/execute.d.ts +17 -6
  21. package/dist/analysis/execute.js +37 -19
  22. package/dist/analysis/gemini-wire.d.ts +8 -0
  23. package/dist/analysis/gemini-wire.js +165 -0
  24. package/dist/analysis/image-tokens.d.ts +4 -2
  25. package/dist/analysis/image-tokens.js +24 -3
  26. package/dist/analysis/job.d.ts +2 -0
  27. package/dist/analysis/job.js +7 -2
  28. package/dist/analysis/openai-wire.d.ts +5 -0
  29. package/dist/analysis/openai-wire.js +101 -0
  30. package/dist/analysis/provider.d.ts +15 -7
  31. package/dist/analysis/provider.js +21 -101
  32. package/dist/analysis/recording-ended.d.ts +16 -0
  33. package/dist/analysis/recording-ended.js +29 -0
  34. package/dist/analysis/responses.d.ts +3 -0
  35. package/dist/analysis/responses.js +19 -0
  36. package/dist/analysis/service.d.ts +1 -3
  37. package/dist/analysis/service.js +34 -53
  38. package/dist/analysis/source.d.ts +1 -1
  39. package/dist/analysis/types.d.ts +8 -2
  40. package/dist/analysis/validation.d.ts +1 -0
  41. package/dist/analysis/validation.js +13 -12
  42. package/dist/browser-control/protocol.d.ts +8 -0
  43. package/dist/browser-control/protocol.js +21 -0
  44. package/dist/cli/commands/analysis-signals.d.ts +10 -4
  45. package/dist/cli/commands/analysis-signals.js +28 -6
  46. package/dist/cli/commands/analyze.js +9 -7
  47. package/dist/cli/commands/observe.js +26 -1
  48. package/dist/cli/commands/run-command.d.ts +1 -1
  49. package/dist/cli/commands/run-command.js +12 -0
  50. package/dist/cli/commands/runs.js +8 -2
  51. package/dist/cli/commands/study-remote.d.ts +13 -0
  52. package/dist/cli/commands/study-remote.js +125 -0
  53. package/dist/cli/commands/study-route-computer-use.js +28 -4
  54. package/dist/cli/commands/study-route-scripted.js +3 -2
  55. package/dist/cli/commands/study-route-shared-world.js +3 -2
  56. package/dist/cli/commands/study-route-terminal.js +3 -2
  57. package/dist/cli/commands/study-run.js +34 -0
  58. package/dist/cli/commands/watch.js +5 -0
  59. package/dist/cli/doctor.js +7 -2
  60. package/dist/cli/findings.d.ts +30 -0
  61. package/dist/cli/findings.js +114 -15
  62. package/dist/cli/io.d.ts +8 -0
  63. package/dist/cli/io.js +3 -1
  64. package/dist/evidence/browser-hygiene.d.ts +12 -1
  65. package/dist/evidence/browser-hygiene.js +45 -2
  66. package/dist/feedback/export-bundle.js +1 -1
  67. package/dist/guest/external-links.d.ts +14 -0
  68. package/dist/guest/external-links.js +48 -0
  69. package/dist/guest/runtime-desktop.js +5 -2
  70. package/dist/keys/key-resolution.d.ts +10 -0
  71. package/dist/keys/key-resolution.js +33 -5
  72. package/dist/observer/data.d.ts +4 -0
  73. package/dist/observer/listen.d.ts +7 -2
  74. package/dist/observer/listen.js +10 -4
  75. package/dist/observer/render.d.ts +2 -0
  76. package/dist/observer/render.js +1 -1
  77. package/dist/observer/run-routes.js +12 -3
  78. package/dist/observer-app.html +2 -2
  79. package/dist/remote/launch.d.ts +53 -0
  80. package/dist/remote/launch.js +320 -0
  81. package/dist/remote/observer-proxy.d.ts +11 -0
  82. package/dist/remote/observer-proxy.js +66 -0
  83. package/dist/remote/plan.d.ts +35 -0
  84. package/dist/remote/plan.js +114 -0
  85. package/dist/remote/reattach.d.ts +17 -0
  86. package/dist/remote/reattach.js +81 -0
  87. package/dist/remote/runner.d.ts +68 -0
  88. package/dist/remote/runner.js +216 -0
  89. package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
  90. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  91. package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
  92. package/dist/routes/computer-use/in-process-desktop.js +2 -2
  93. package/dist/routes/computer-use/live-phase.js +1 -1
  94. package/dist/routes/computer-use/setup.js +2 -2
  95. package/dist/routes/computer-use/types.d.ts +2 -0
  96. package/dist/routes/scripted/types.d.ts +2 -0
  97. package/dist/routes/shared-world/route.js +1 -1
  98. package/dist/routes/shared-world/types.d.ts +2 -0
  99. package/dist/routes/terminal/types.d.ts +2 -0
  100. package/dist/run/costs.d.ts +2 -1
  101. package/dist/run/display.d.ts +6 -1
  102. package/dist/run/display.js +11 -0
  103. package/dist/run/host-suspension.js +4 -1
  104. package/dist/run/preflight-receipts.d.ts +3 -12
  105. package/dist/run/preflight-receipts.js +7 -67
  106. package/dist/run/pricing.d.ts +13 -1
  107. package/dist/run/pricing.js +50 -4
  108. package/dist/run/process-owner.d.ts +28 -0
  109. package/dist/run/process-owner.js +83 -0
  110. package/dist/run/projection.js +1 -1
  111. package/dist/run/reclaim.d.ts +6 -0
  112. package/dist/run/reclaim.js +32 -2
  113. package/dist/run/results.d.ts +1 -1
  114. package/dist/run/route-shell.js +1 -1
  115. package/dist/run/run-cost.d.ts +2 -1
  116. package/dist/run/run-cost.js +6 -1
  117. package/dist/run/run-index.d.ts +3 -1
  118. package/dist/run/run-index.js +23 -11
  119. package/dist/run/run.d.ts +29 -5
  120. package/dist/run/run.js +43 -17
  121. package/dist/run/sandbox-creates.d.ts +10 -3
  122. package/dist/run/sandbox-creates.js +48 -6
  123. package/dist/run/status.d.ts +57 -2
  124. package/dist/run/status.js +87 -1
  125. package/dist/run/stored-runs.d.ts +3 -0
  126. package/dist/run/stored-runs.js +9 -4
  127. package/dist/run-study.d.ts +5 -0
  128. package/dist/study/doctor.js +14 -1
  129. package/dist/study/requirements.d.ts +1 -1
  130. package/dist/study/requirements.js +8 -6
  131. package/dist/study/route-inputs.js +4 -0
  132. package/dist/study/run-study-events.d.ts +8 -2
  133. package/dist/study/run-study-events.js +9 -2
  134. package/dist/substrates/e2b/base-module.d.ts +3 -0
  135. package/dist/substrates/e2b/base-module.js +40 -0
  136. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  137. package/dist/substrates/e2b/desktop-browser.js +27 -7
  138. package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
  139. package/dist/substrates/e2b/desktop-executor.js +3 -0
  140. package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
  141. package/dist/substrates/e2b/desktop-handoff.js +86 -0
  142. package/dist/substrates/e2b/sdk.d.ts +24 -0
  143. package/dist/substrates/e2b/sdk.js +5 -1
  144. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  145. package/dist/substrates/e2b/shell-only.js +27 -0
  146. package/dist/substrates/local/runtime-release.js +8 -8
  147. package/dist/substrates/modal/sandbox.js +27 -28
  148. package/dist/substrates/modal/sdk.d.ts +7 -0
  149. package/dist/tui-app.js +115 -115
  150. package/docs/architecture/observer.md +7 -3
  151. package/docs/contracts/schemas.md +24 -3
  152. package/docs/contracts/study-analysis.md +52 -4
  153. package/docs/product/automatic-analysis.md +55 -9
  154. package/package.json +2 -2
  155. package/skills/humanish/SKILL.md +118 -5
@@ -1,4 +1,5 @@
1
1
  import type { AnalysisArtifact, LoadedAnalysis } from "../analysis/types.js";
2
+ import { type RunAnalysisPhase } from "../run/status.js";
2
3
  import { type RunStudyProvenance } from "../run/study-provenance.js";
3
4
  export declare const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
4
5
  type Finding = NonNullable<AnalysisArtifact["result"]>["findings"][number];
@@ -116,11 +117,35 @@ interface FindingsSource {
116
117
  liveStudy?: LiveStudy;
117
118
  /** The run's study, from its bundle. */
118
119
  study?: Pick<RunStudyProvenance, "id" | "path">;
120
+ /** The analyst the run's study gets, read only for an analysis skipped for a missing key. */
121
+ analyst?: Analyst;
119
122
  /**
120
123
  * Whether Codex is signed in with a ChatGPT account on this machine, read only for an analysis
121
124
  * skipped for a missing OpenAI key, whose next step it picks.
122
125
  */
123
126
  codexSignedIn?: boolean;
127
+ /**
128
+ * Where the run is before its findings, from status.json: its participants still running, or
129
+ * the automatic analysis its own process started. Undefined once neither applies.
130
+ */
131
+ progress?: {
132
+ stage: "participants";
133
+ }
134
+ /** The run ended while a participant stream was still running: nothing complete to analyze. */
135
+ | {
136
+ stage: "cut-off";
137
+ running: number;
138
+ streams: number;
139
+ } | {
140
+ stage: "analysis";
141
+ phase: RunAnalysisPhase;
142
+ /** status.json's last heartbeat. */
143
+ updatedAt: string;
144
+ /** The analyst the run's study gets, read only for an analysis whose process stopped. */
145
+ analyst?: Analyst;
146
+ };
147
+ /** When the view was built, for how long an analysis has run. */
148
+ nowMs?: number;
124
149
  }
125
150
  /** A study `run` can start live after a dry run. */
126
151
  interface LiveStudy {
@@ -133,6 +158,11 @@ interface LiveStudy {
133
158
  }
134
159
  /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
135
160
  export declare function cwdFlag(cwd: string): string;
161
+ /** An analysis provider and model, as a study or an analysis record names them. */
162
+ interface Analyst {
163
+ provider?: string | undefined;
164
+ model?: string;
165
+ }
136
166
  /** The findings view of a run's selected analysis, or why it has none. */
137
167
  export declare function analysisFindings(source: FindingsSource): AnalysisFindings;
138
168
  /**
@@ -5,11 +5,15 @@
5
5
  // starts an analysis.
6
6
  import path from "node:path";
7
7
  import { costRefusal } from "../analysis/admission.js";
8
- import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
8
+ import { DEFAULT_ANALYSIS_MAX_COST_USD, resolveAutomaticAnalysis, } from "../analysis/automatic-config.js";
9
+ import { ANALYSIS_USUALLY_TAKES } from "../analysis/analysis-limits.js";
9
10
  import { bySeverity } from "../analysis/finding-lead.js";
10
11
  import { loadAnalysis } from "../analysis/load.js";
11
12
  import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
12
- import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
13
+ import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
14
+ import { classifyRunStatus, isRunStatusRecord, RUN_STATUS_FILE, runAnalysisPhase, } from "../run/status.js";
15
+ import { TERMINAL_SIMULATION_STATUSES } from "../run/streams.js";
16
+ import { formatDuration } from "../run/projection.js";
13
17
  import { resolvePhysicalCwd, runIdOf } from "../run/paths.js";
14
18
  import { studyProvenanceOf } from "../run/study-provenance.js";
15
19
  import { resolveStudyManifest } from "../study/discover.js";
@@ -18,6 +22,7 @@ import { plural } from "../run/text.js";
18
22
  import { shellQuote } from "../substrates/shell.js";
19
23
  import { analysisOutcomeText } from "./io.js";
20
24
  import { cli } from "./invocation.js";
25
+ import { analysisKeyNames } from "../keys/key-resolution.js";
21
26
  export const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
22
27
  /** ` --cwd <dir>`, or "" when `cwd` is the current directory. */
23
28
  export function cwdFlag(cwd) {
@@ -25,11 +30,20 @@ export function cwdFlag(cwd) {
25
30
  return "";
26
31
  return ` --cwd ${/^[\w@%+=:,./-]+$/.test(cwd) ? cwd : shellQuote(cwd)}`;
27
32
  }
28
- function analyzeCommand(source, provider) {
33
+ /** The skip reasons for an analysis whose API key was missing, one per API analyst. */
34
+ const KEY_MISSING_REASONS = new Set([
35
+ "AUTOMATIC_ANALYSIS_KEY_MISSING",
36
+ "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING",
37
+ ]);
38
+ /** The `analyze` command that runs `analyst` on the run; undefined is the OpenAI default. */
39
+ function analyzeCommand(source, analyst) {
29
40
  const run = cli(`analyze --run ${source.runId}${source.cwdFlag}`);
30
- return provider === "codex"
31
- ? `${run} --provider codex`
32
- : `${run} --max-cost ${DEFAULT_ANALYSIS_MAX_COST_USD}`;
41
+ if (analyst?.provider === "codex")
42
+ return `${run} --provider codex`;
43
+ const gemini = analyst?.provider === "gemini"
44
+ ? ` --provider gemini${analyst.model === undefined ? "" : ` --model ${analyst.model}`}`
45
+ : "";
46
+ return `${run}${gemini} --max-cost ${DEFAULT_ANALYSIS_MAX_COST_USD}`;
33
47
  }
34
48
  /** The cited items the analysis evidence resolves, each once, in citation order. */
35
49
  function citedEvidence(ids, analysis, runRoot) {
@@ -153,16 +167,75 @@ function dryRunFindings(source) {
153
167
  return withoutFindings(source, "dry_run", null, `${opening} Findings come from live runs: ${howLive}`, cli(`run ${live.run}${source.cwdFlag}`));
154
168
  }
155
169
  /**
156
- * An analysis skipped because OPENAI_API_KEY is not set. With Codex signed in, the next step is the
170
+ * An analysis skipped because its API key is not set. For OpenAI with Codex signed in, the next step is the
157
171
  * Codex account analyst, named as an opt-in: the study chose the OpenAI analyst, and switching
158
172
  * the account that pays for it is the operator's choice.
159
173
  */
160
174
  function missingKeyFindings(source, reason) {
175
+ // The skip reason records which analyst's key was missing; the study file only adds its model.
176
+ if (reason === "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING")
177
+ return withoutFindings(source, "skipped", reason, `The automatic analysis was skipped because ${analysisKeyNames("gemini").join(" and ")} are not set. Store the key with ${cli("keys set gemini")}, then run the next command.`, analyzeCommand(source, {
178
+ provider: "gemini",
179
+ ...(source.analyst?.provider === "gemini" ? { model: source.analyst.model } : {}),
180
+ }));
161
181
  const opening = "The automatic analysis was skipped because OPENAI_API_KEY is not set.";
162
182
  if (source.codexSignedIn !== true)
163
183
  return withoutFindings(source, "skipped", reason, `${opening} Set it with ${cli("keys set openai")}, then run the next command.`, analyzeCommand(source, undefined));
164
184
  const file = source.study?.path === undefined ? "the study file" : source.study.path;
165
- return withoutFindings(source, "skipped", reason, `${opening} Codex is signed in on this machine, so its ChatGPT account can analyze the run instead, sending the evidence to OpenAI under that account. The next command does it once; review.analysis.provider: codex in ${file} makes it this study's analyst.`, analyzeCommand(source, "codex"));
185
+ return withoutFindings(source, "skipped", reason, `${opening} Codex is signed in on this machine, so its ChatGPT account can analyze the run instead, sending the evidence to OpenAI under that account. The next command does it once; review.analysis.provider: codex in ${file} makes it this study's analyst.`, analyzeCommand(source, { provider: "codex" }));
186
+ }
187
+ /**
188
+ * The view of a run whose findings are not written yet: participants still running, an analysis
189
+ * running, or an analysis whose process stopped before it finished. Undefined otherwise.
190
+ */
191
+ function pendingFindings(source, job) {
192
+ const again = cli(`review --run ${source.runId}${source.cwdFlag}`);
193
+ const progress = source.progress;
194
+ if (progress?.stage === "participants" && job === undefined)
195
+ return withoutFindings(source, "running", null, source.analysisOffIn === undefined
196
+ ? "Participants are still running in this run, so no analysis has started. Automatic analysis starts when they finish, and the findings follow it."
197
+ : "Participants are still running in this run. Analysis is turned off in its study, so none starts when they finish.", again);
198
+ if (progress?.stage === "cut-off" && job === undefined)
199
+ return withoutFindings(source, "skipped", "ANALYSIS_RUN_INCOMPLETE", `This run ended while ${progress.running} of ${progress.streams} participant streams were still running, so its recording is incomplete and no analysis ran. Run the study again with the next command.`, cli(`run ${source.study?.id ?? "<study>"}${source.cwdFlag}`));
200
+ const phase = progress?.stage === "analysis" ? progress.phase : undefined;
201
+ const jobPending = job?.state === "queued" || job?.state === "running";
202
+ if (jobPending || (job === undefined && phase?.state === "running")) {
203
+ const since = phase === undefined
204
+ ? ""
205
+ : ` It started at ${phase.startedAt}, ${formatDuration(Math.max(0, (source.nowMs ?? Date.now()) - Date.parse(phase.startedAt)))} ago.`;
206
+ return withoutFindings(source, "running", null, `The automatic analysis is running.${since} Findings usually take ${ANALYSIS_USUALLY_TAKES}; check again then.`, again);
207
+ }
208
+ const stopped = (job?.state === "unknown" && job.reason === "AUTOMATIC_ANALYSIS_STOPPED") ||
209
+ (job === undefined && phase?.state === "stopped");
210
+ if (!stopped)
211
+ return undefined;
212
+ const at = job?.updatedAt ?? (progress?.stage === "analysis" ? progress.updatedAt : undefined);
213
+ return withoutFindings(source, "failed", "AUTOMATIC_ANALYSIS_STOPPED", `The automatic analysis stopped before it wrote findings: the process running it stopped updating its record${at === undefined ? "" : ` at ${at}`}, most likely because it ended. Analyze the run again with the next command.`, analyzeCommand(source, progress?.stage === "analysis" ? progress.analyst : undefined));
214
+ }
215
+ /**
216
+ * A run that ended, by a signal, a process that left without publishing, or a heartbeat that
217
+ * stopped, while some participant stream was still running. Undefined for any other run.
218
+ */
219
+ function cutOff(bundle, record, nowMs) {
220
+ if (bundle === undefined || bundle.outcome?.state === "finished")
221
+ return undefined;
222
+ if (bundle.outcome === undefined &&
223
+ (record === null || classifyRunStatus(record, nowMs) !== "interrupted"))
224
+ return undefined;
225
+ const running = bundle.streams.filter((stream) => !TERMINAL_SIMULATION_STATUSES.has(stream.status) || stream.liveActor !== undefined).length;
226
+ return running === 0 ? undefined : { stage: "cut-off", running, streams: bundle.streams.length };
227
+ }
228
+ /** The analyst the run's study gets, when its file still names the same study. */
229
+ async function studyAnalyst(cwd, study) {
230
+ if (study?.path === undefined)
231
+ return undefined;
232
+ const resolved = await resolveStudyManifest(cwd, study.path).catch(() => null);
233
+ if (!resolved?.ok || resolved.config.id !== study.id)
234
+ return undefined;
235
+ const analysis = resolveAutomaticAnalysis(resolved.config.review?.analysis, resolved.config.actor);
236
+ return analysis.ok && analysis.config !== undefined
237
+ ? { provider: analysis.config.provider, model: analysis.config.model }
238
+ : undefined;
166
239
  }
167
240
  /** The findings view of a run's selected analysis, or why it has none. */
168
241
  export function analysisFindings(source) {
@@ -181,7 +254,9 @@ export function analysisFindings(source) {
181
254
  message: stale
182
255
  ? `The run's evidence changed after analysis ${analysis.id}, so its findings may not match the run.`
183
256
  : `Analysis ${analysis.id} is ${analysis.status}, with ${plural(result.findings.length, "finding")}.`,
184
- next: stale ? analyzeCommand(source, analysis.provider) : null,
257
+ next: stale
258
+ ? analyzeCommand(source, { provider: analysis.provider, model: analysis.config.model })
259
+ : null,
185
260
  analysisId: analysis.id,
186
261
  status: analysis.status,
187
262
  provider: analysis.provider,
@@ -200,19 +275,20 @@ export function analysisFindings(source) {
200
275
  if (loaded.warnings.includes("ANALYSIS_SENSITIVE_TEXT_QUARANTINED"))
201
276
  return withoutFindings(source, "unavailable", "ANALYSIS_SENSITIVE_TEXT_QUARANTINED", `The analysis text matched a sensitive-text pattern, so it is not printed. ${cli(`analyze show --run ${source.runId}${source.cwdFlag} --json`)} prints the raw record.`, null);
202
277
  const job = loaded.automatic;
203
- if (job?.state === "queued" || job?.state === "running")
204
- return withoutFindings(source, "running", null, "The automatic analysis is still running. Check again when it finishes.", cli(`review --run ${source.runId}${source.cwdFlag}`));
278
+ const pending = pendingFindings(source, job);
279
+ if (pending !== undefined)
280
+ return pending;
205
281
  if (analysis && analysis.result === null)
206
- return withoutFindings(source, "failed", analysis.error, `Analysis attempt ${analysis.id} ${analysis.status === "cancelled" ? "was cancelled" : "failed"}${analysis.error ? ` (${analysis.error})` : ""} and left no findings.`, analyzeCommand(source, analysis.provider));
282
+ return withoutFindings(source, "failed", analysis.error, `Analysis attempt ${analysis.id} ${analysis.status === "cancelled" ? "was cancelled" : "failed"}${analysis.error ? ` (${analysis.error})` : ""} and left no findings.`, analyzeCommand(source, { provider: analysis.provider, model: analysis.config.model }));
207
283
  if (job?.state === "failed" || job?.state === "cancelled") {
208
284
  const outcome = analysisOutcomeText(job);
209
- return withoutFindings(source, "failed", job.reason, `The automatic analysis ${outcome.startsWith("failed") ? "" : "was "}${outcome}.`, analyzeCommand(source, job.reason === "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE" ? "codex" : undefined));
285
+ return withoutFindings(source, "failed", job.reason, `The automatic analysis ${outcome.startsWith("failed") ? "" : "was "}${outcome}.`, analyzeCommand(source, job.reason === "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE" ? { provider: "codex" } : undefined));
210
286
  }
211
287
  if (job?.state === "skipped" && job.admission) {
212
288
  const refusal = costRefusal(job.admission, `--run ${source.runId}${source.cwdFlag}`, cli);
213
289
  return withoutFindings(source, "skipped", job.reason, `The automatic analysis was refused before it started. ${refusal.text}`, refusal.command);
214
290
  }
215
- if (job?.state === "skipped" && job.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING")
291
+ if (job?.state === "skipped" && job.reason !== null && KEY_MISSING_REASONS.has(job.reason))
216
292
  return missingKeyFindings(source, job.reason);
217
293
  if (job?.state === "skipped") {
218
294
  const refused = job.reason === "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED";
@@ -278,7 +354,27 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
278
354
  const analysisOffIn = mode === "live" ? await studyAnalysisOff(physical, study) : undefined;
279
355
  const liveStudy = mode !== undefined && mode !== "live" ? await liveStudyAfterDryRun(physical, study) : undefined;
280
356
  const loaded = await loadAnalysis(prepared, analysisId);
281
- const codexSignedIn = loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING"
357
+ const nowMs = Date.now();
358
+ const status = runJsonValue(await readRunJsonIfExists(prepared, RUN_STATUS_FILE));
359
+ const record = isRunStatusRecord(status) && status.runId === runIdOf(prepared) ? status : null;
360
+ const phase = record === null ? undefined : runAnalysisPhase(record, nowMs);
361
+ const stopped = phase?.state === "stopped" || loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_STOPPED";
362
+ const missingKeyReason = loaded.automatic?.reason ?? null;
363
+ const keyMissing = missingKeyReason !== null && KEY_MISSING_REASONS.has(missingKeyReason);
364
+ const analyst = stopped || keyMissing ? await studyAnalyst(physical, study) : undefined;
365
+ const progress = phase !== undefined && record !== null
366
+ ? {
367
+ stage: "analysis",
368
+ phase,
369
+ updatedAt: record.updatedAt,
370
+ ...(analyst === undefined ? {} : { analyst }),
371
+ }
372
+ : bundle?.bundle.outcome === undefined &&
373
+ record !== null &&
374
+ classifyRunStatus(record, nowMs) === "running"
375
+ ? { stage: "participants" }
376
+ : cutOff(bundle?.bundle, record, nowMs);
377
+ const codexSignedIn = missingKeyReason === "AUTOMATIC_ANALYSIS_KEY_MISSING"
282
378
  ? await (deps.codexSignedIn ?? codexSignedInHere)().catch(() => false)
283
379
  : undefined;
284
380
  return {
@@ -293,6 +389,9 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
293
389
  ? {}
294
390
  : { study: { id: study.id, ...(study.path === undefined ? {} : { path: study.path }) } }),
295
391
  ...(codexSignedIn === undefined ? {} : { codexSignedIn }),
392
+ ...(keyMissing && analyst !== undefined ? { analyst } : {}),
393
+ ...(progress === undefined ? {} : { progress }),
394
+ nowMs,
296
395
  };
297
396
  }
298
397
  async function codexSignedInHere() {
package/dist/cli/io.d.ts CHANGED
@@ -45,6 +45,14 @@ export interface StudyCommandOptions {
45
45
  port?: string | undefined;
46
46
  rerunFailedFrom?: string | undefined;
47
47
  runId?: string | undefined;
48
+ /** Run the study in a runner sandbox on its participants' provider. */
49
+ remote?: boolean | undefined;
50
+ /** Hidden: the run key the run's sandboxes carry, chosen by a remote run's launcher. */
51
+ ownerKey?: string | undefined;
52
+ /** Hidden, watch only: bind the served Observer on every interface (a remote run's runner). */
53
+ allInterfaces?: boolean | undefined;
54
+ /** Hidden, watch only: also write the run's result and exit code to this file as JSON. */
55
+ resultFile?: string | undefined;
48
56
  /** watch only: serve and follow the Observer even without an interactive terminal. */
49
57
  serve?: boolean | undefined;
50
58
  /** Repo-relative path to an adopter scorer module; overrides review.scorer.ref when set. */
package/dist/cli/io.js CHANGED
@@ -2,7 +2,7 @@ import { automaticAnalysisSucceeded, } from "../analysis/automatic-completion.js
2
2
  import { costRefusal, refusedCost } from "../analysis/admission.js";
3
3
  import { Command, Option } from "commander";
4
4
  import { loadEnvFile, recordDotenvNames } from "../keys/env-file.js";
5
- import { discoverProviderKeys } from "../keys/key-resolution.js";
5
+ import { analysisKeyNames, discoverProviderKeys } from "../keys/key-resolution.js";
6
6
  import { deriveRunFacts } from "./telemetry.js";
7
7
  import { warnAndQueue, withQueuedWarnings } from "./deprecations.js";
8
8
  import { forTerminal } from "../routes/terminal/encoding.js";
@@ -297,6 +297,7 @@ export function wantsJson(command) {
297
297
  const ANALYSIS_REASON_TEXT = {
298
298
  AUTOMATIC_ANALYSIS_DRY_RUN: "skipped for dry runs",
299
299
  AUTOMATIC_ANALYSIS_KEY_MISSING: "skipped because OPENAI_API_KEY is not set",
300
+ AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING: `skipped because ${analysisKeyNames("gemini").join(" and ")} are not set`,
300
301
  AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE: "skipped because no participant left evidence",
301
302
  AUTOMATIC_ANALYSIS_ACTOR_CANCELLED: "skipped because the run stopped before its participants finished",
302
303
  AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE: "skipped because the run's evidence could not be read",
@@ -315,6 +316,7 @@ const ANALYSIS_REASON_TEXT = {
315
316
  AUTOMATIC_ANALYSIS_CANCELLATION_UNAVAILABLE: "outcome unknown: a cancellation could not be recorded",
316
317
  AUTOMATIC_ANALYSIS_STORAGE_UNAVAILABLE: "outcome unknown: analysis storage is unavailable",
317
318
  AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN: "outcome unknown",
319
+ AUTOMATIC_ANALYSIS_STOPPED: "outcome unknown: the process running it stopped before it finished",
318
320
  };
319
321
  export function analysisOutcomeText(analysis) {
320
322
  if (analysis.reason === null)
@@ -4,4 +4,15 @@
4
4
  * place so browser-backed routes can prefer product pixels over browser UI.
5
5
  */
6
6
  export declare const CHROMIUM_EVIDENCE_HYGIENE_FLAGS: readonly ["--disable-background-networking", "--disable-component-update", "--disable-default-apps", "--disable-domain-reliability", "--disable-extensions", "--disable-sync", "--no-default-browser-check", "--no-first-run", "--password-store=basic", "--use-mock-keychain"];
7
- export declare function chromiumEvidenceProfilePreferencesJson(): string;
7
+ /**
8
+ * The profile preference that lets a web `targetUrl`'s origin open the external app schemes
9
+ * without Chrome's prompt: the "always allow" choice Chrome stores per origin, which needs no root
10
+ * and no managed policy. Links from other origins still prompt. Undefined for any other target.
11
+ */
12
+ export declare function externalAppPreferences(targetUrl: string | undefined): {
13
+ protocol_handler: {
14
+ allowed_origin_protocol_pairs: Record<string, Record<string, true>>;
15
+ };
16
+ } | undefined;
17
+ /** The profile's Preferences file, with externalAppPreferences for a web `targetUrl`. */
18
+ export declare function chromiumEvidenceProfilePreferencesJson(targetUrl?: string): string;
@@ -37,6 +37,49 @@ const CHROMIUM_EVIDENCE_PROFILE_PREFERENCES = {
37
37
  password_manager_enabled: false,
38
38
  },
39
39
  };
40
- export function chromiumEvidenceProfilePreferencesJson() {
41
- return JSON.stringify(CHROMIUM_EVIDENCE_PROFILE_PREFERENCES);
40
+ /**
41
+ * Schemes whose links Chrome hands to another app after asking "Open xdg-open?". The desktop's
42
+ * stand-ins answer them (src/substrates/e2b/desktop-handoff.ts), so the prompt is browser chrome
43
+ * a person on their own computer would not see for that app. Chrome never asks for mailto.
44
+ */
45
+ const EXTERNAL_APP_SCHEMES = [
46
+ "callto",
47
+ "facetime",
48
+ "facetime-audio",
49
+ "geo",
50
+ "maps",
51
+ "mms",
52
+ "msteams",
53
+ "sip",
54
+ "skype",
55
+ "slack",
56
+ "sms",
57
+ "tel",
58
+ "webcal",
59
+ "whatsapp",
60
+ "zoommtg",
61
+ ];
62
+ /**
63
+ * The profile preference that lets a web `targetUrl`'s origin open the external app schemes
64
+ * without Chrome's prompt: the "always allow" choice Chrome stores per origin, which needs no root
65
+ * and no managed policy. Links from other origins still prompt. Undefined for any other target.
66
+ */
67
+ export function externalAppPreferences(targetUrl) {
68
+ const target = targetUrl === undefined ? null : URL.parse(targetUrl);
69
+ if (target === null || !/^https?:$/.test(target.protocol))
70
+ return undefined;
71
+ return {
72
+ protocol_handler: {
73
+ allowed_origin_protocol_pairs: {
74
+ [target.origin]: Object.fromEntries(EXTERNAL_APP_SCHEMES.map((scheme) => [scheme, true])),
75
+ },
76
+ },
77
+ };
78
+ }
79
+ /** The profile's Preferences file, with externalAppPreferences for a web `targetUrl`. */
80
+ export function chromiumEvidenceProfilePreferencesJson(targetUrl) {
81
+ return JSON.stringify({
82
+ ...CHROMIUM_EVIDENCE_PROFILE_PREFERENCES,
83
+ ...externalAppPreferences(targetUrl),
84
+ });
42
85
  }
@@ -47,7 +47,7 @@ function omittedReason(relative) {
47
47
  return "automatic execution intent and liveness do not transfer to a derivative";
48
48
  if (isAnalysisRecordPath(relative))
49
49
  return "analysis records bind original evidence hashes; reanalyze the derivative";
50
- if (relative.startsWith(".analysis-lock/"))
50
+ if (relative.startsWith(".analysis-lock"))
51
51
  return "local analysis execution lock";
52
52
  return (OMITTED.get(relative) ??
53
53
  (["feedback/draft.json", "feedback/issue.md"].includes(relative)
@@ -0,0 +1,14 @@
1
+ import type { CuaExecutor } from "../actors/computer-use/loop.js";
2
+ /** Where the image keeps the stand-ins. */
3
+ export declare const GUEST_EXTERNAL_LINK_BIN = "/opt/humanish/control/bin";
4
+ /**
5
+ * Write the browser profile's Preferences before the first launch, so the initial app's origin
6
+ * opens tel:, sms: and the other app schemes without Chromium's prompt. No initial URL, no file.
7
+ */
8
+ export declare function prepareGuestBrowserProfile(profileDir: string, initialUrl: string | undefined): Promise<void>;
9
+ /**
10
+ * The executor with each observation carrying the links appended to `log` since the previous
11
+ * observation, at most EXTERNAL_HANDOFF_LIMITS.perObservation. A line still being written waits
12
+ * for the next observation. Every other property of the executor is kept.
13
+ */
14
+ export declare function withExternalHandoffs(executor: CuaExecutor, log: string): CuaExecutor;
@@ -0,0 +1,48 @@
1
+ // Links the guest browser hands to another app (mailto:, tel:, sms:). The guest has no email,
2
+ // phone or messaging app. The image's stand-ins for xdg-open and xdg-email, first on the guest's
3
+ // search path, append each such link to a log in the runtime directory, and every observation the
4
+ // executor returns carries the links appended since the previous one.
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { EXTERNAL_HANDOFF_LIMITS, externalHandoffOf, } from "../actors/computer-use/external-handoff.js";
7
+ import { externalAppPreferences } from "../evidence/browser-hygiene.js";
8
+ /** Where the image keeps the stand-ins. */
9
+ export const GUEST_EXTERNAL_LINK_BIN = "/opt/humanish/control/bin";
10
+ /**
11
+ * Write the browser profile's Preferences before the first launch, so the initial app's origin
12
+ * opens tel:, sms: and the other app schemes without Chromium's prompt. No initial URL, no file.
13
+ */
14
+ export async function prepareGuestBrowserProfile(profileDir, initialUrl) {
15
+ const preferences = externalAppPreferences(initialUrl);
16
+ if (preferences === undefined)
17
+ return;
18
+ await mkdir(`${profileDir}/Default`, { recursive: true, mode: 0o700 });
19
+ await writeFile(`${profileDir}/Default/Preferences`, JSON.stringify(preferences), {
20
+ mode: 0o600,
21
+ flag: "wx",
22
+ });
23
+ }
24
+ /**
25
+ * The executor with each observation carrying the links appended to `log` since the previous
26
+ * observation, at most EXTERNAL_HANDOFF_LIMITS.perObservation. A line still being written waits
27
+ * for the next observation. Every other property of the executor is kept.
28
+ */
29
+ export function withExternalHandoffs(executor, log) {
30
+ let consumed = 0;
31
+ return {
32
+ ...executor,
33
+ observe: async () => {
34
+ const observation = await executor.observe();
35
+ const text = await readFile(log, "utf8").catch(() => "");
36
+ const lines = text
37
+ .split("\n")
38
+ .slice(0, -1)
39
+ .slice(consumed, consumed + EXTERNAL_HANDOFF_LIMITS.perObservation);
40
+ consumed += lines.length;
41
+ const externalHandoffs = lines.flatMap((line) => {
42
+ const handoff = externalHandoffOf(line);
43
+ return handoff === undefined ? [] : [handoff];
44
+ });
45
+ return externalHandoffs.length === 0 ? observation : { ...observation, externalHandoffs };
46
+ },
47
+ };
48
+ }
@@ -14,6 +14,7 @@ import { GUEST_BOOTSTRAP_LIMITS, validateGuestInitialUrl } from "./bootstrap.js"
14
14
  import { startDesktopMedia, } from "./desktop-media.js";
15
15
  import { startDesktopRecorder } from "../evidence/desktop-recorder.js";
16
16
  import { createGuestProcesses, setupFailed } from "./runtime-processes.js";
17
+ import { GUEST_EXTERNAL_LINK_BIN, prepareGuestBrowserProfile, withExternalHandoffs, } from "./external-links.js";
17
18
  /** The guest image's fixed layout. Tests derive guest paths from it rather than spelling them. */
18
19
  export const GUEST_RUNTIME_PATHS = Object.freeze({
19
20
  root: "/opt/humanish/control",
@@ -21,7 +22,8 @@ export const GUEST_RUNTIME_PATHS = Object.freeze({
21
22
  home: "/home/humanish",
22
23
  });
23
24
  const GUEST_RUNTIME_ENV = Object.freeze({
24
- PATH: "/usr/bin:/bin",
25
+ // The stand-ins come first, so the browser hands a mailto: or tel: link to them.
26
+ PATH: `${GUEST_EXTERNAL_LINK_BIN}:/usr/bin:/bin`,
25
27
  HOME: "/home/humanish",
26
28
  USER: "humanish",
27
29
  LOGNAME: "humanish",
@@ -390,6 +392,7 @@ export async function createGuestRuntimeDesktop(options) {
390
392
  progress("layout");
391
393
  check();
392
394
  await verifyGuestLayout(check);
395
+ await prepareGuestBrowserProfile(`${GUEST_RUNTIME_PATHS.home}/browser`, options.initialUrl);
393
396
  progress("xauthority");
394
397
  await authorizeDisplay(processes, check);
395
398
  progress("display");
@@ -434,7 +437,7 @@ export async function createGuestRuntimeDesktop(options) {
434
437
  await openGuestFixture(browser.page, check);
435
438
  }
436
439
  return {
437
- executor: state.media?.wrap(executor) ?? executor,
440
+ executor: withExternalHandoffs(state.media?.wrap(executor) ?? executor, `${GUEST_RUNTIME_ENV.XDG_RUNTIME_DIR}/humanish-links.log`),
438
441
  close,
439
442
  ...(options.recording === undefined
440
443
  ? {}
@@ -1,3 +1,12 @@
1
+ /** The key each API analyst reads, under its first spelling. */
2
+ declare const ANALYSIS_KEYS: {
3
+ readonly openai: "OPENAI_API_KEY";
4
+ readonly gemini: "GEMINI_API_KEY";
5
+ };
6
+ /** The env names an API analyst's key may have, in the order they are read. */
7
+ export declare const analysisKeyNames: (vendor: keyof typeof ANALYSIS_KEYS) => readonly string[];
8
+ /** An API analyst's key from `env`: the first of its names with a non-blank value, else "". */
9
+ export declare function analysisApiKey(vendor: keyof typeof ANALYSIS_KEYS, env: Readonly<Record<string, string | undefined>>): string;
1
10
  /** The vendor aliases `humanish keys set` accepts. A raw ENV_NAME is also accepted. */
2
11
  export declare const KEY_VENDORS: string[];
3
12
  /** The keys `humanish keys` lists, in its order, each with what humanish uses it for. */
@@ -77,3 +86,4 @@ export declare function setUserKey(name: string, value: string, env: NodeJS.Proc
77
86
  export declare function unsetUserKey(name: string, env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): boolean;
78
87
  /** The names (never values) currently in the user store. */
79
88
  export declare function listUserKeys(env: NodeJS.ProcessEnv, deps?: KeyResolutionDeps): string[];
89
+ export {};
@@ -11,7 +11,7 @@
11
11
  // GH_TOKEN <- `gh auth token` (the gh CLI's credential chain);
12
12
  // 4. the humanish user-level store `$XDG_CONFIG_HOME/humanish/keys.env`, written only by
13
13
  // `humanish keys set` (0600, prompted or --stdin), the fallback for vendors that ship
14
- // no machine chain of their own (OpenAI, Anthropic).
14
+ // no machine chain of their own (OpenAI, Anthropic, Gemini).
15
15
  //
16
16
  // Every fill is announced by key name + source, never by value. The subject app's own
17
17
  // `.env`/`.env.local` are never read: those hold product credentials, a different class
@@ -34,7 +34,8 @@ import { OPENAI_EGRESS_PLACEHOLDER } from "../routes/terminal/runtime-auth.js";
34
34
  * full-file load). ANTHROPIC_API_KEY is not listed, and doctor does not report it: a Claude Code
35
35
  * participant runs on its own login and never receives it. CODEX_API_KEY is an alternative spelling
36
36
  * of the OpenAI key for terminal studies. Doctor reports AGENTMAIL_API_KEY only under `--study`, for
37
- * a study whose email connection names it, since a connection may name another variable.
37
+ * a study whose email connection names it, since a connection may name another variable, and
38
+ * GEMINI_API_KEY only for a study whose analysis runs on Gemini.
38
39
  */
39
40
  const PROVIDER_KEYS = [
40
41
  {
@@ -69,9 +70,33 @@ const PROVIDER_KEYS = [
69
70
  { name: "GITHUB_TOKEN", spellingOf: "GH_TOKEN" },
70
71
  { name: "CODEX_API_KEY", doctor: true },
71
72
  { name: "AGENTMAIL_API_KEY", vendor: "agentmail", listed: { order: 4, use: "email in studies" } },
73
+ { name: "GEMINI_API_KEY", vendor: "gemini", listed: { order: 5, use: "analysis on Gemini" } },
74
+ // Google's client libraries also read GOOGLE_API_KEY, and prefer it when both are set. humanish
75
+ // reads GEMINI_API_KEY first: a GOOGLE_API_KEY exported for Maps or Firebase must not take the
76
+ // place of the key `humanish keys set gemini` stored.
77
+ { name: "GOOGLE_API_KEY", spellingOf: "GEMINI_API_KEY" },
72
78
  ];
73
79
  const PROVIDER_KEY_SET = new Set(PROVIDER_KEYS.map((key) => key.name));
74
80
  const providerKey = (name) => PROVIDER_KEYS.find((key) => key.name === name);
81
+ /**
82
+ * Every env name of the key `name` is a spelling of, in the order humanish reads them: the key's
83
+ * own name, then its second spellings. Discovery treats them as one key.
84
+ */
85
+ function keyNames(name) {
86
+ const own = providerKey(name)?.spellingOf ?? name;
87
+ return [own, ...PROVIDER_KEYS.filter((key) => key.spellingOf === own).map((key) => key.name)];
88
+ }
89
+ /** The key each API analyst reads, under its first spelling. */
90
+ const ANALYSIS_KEYS = { openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" };
91
+ /** The env names an API analyst's key may have, in the order they are read. */
92
+ export const analysisKeyNames = (vendor) => keyNames(ANALYSIS_KEYS[vendor]);
93
+ /** An API analyst's key from `env`: the first of its names with a non-blank value, else "". */
94
+ export function analysisApiKey(vendor, env) {
95
+ for (const name of analysisKeyNames(vendor))
96
+ if (env[name]?.trim())
97
+ return env[name];
98
+ return "";
99
+ }
75
100
  /** The vendor aliases `humanish keys set` accepts. A raw ENV_NAME is also accepted. */
76
101
  export const KEY_VENDORS = PROVIDER_KEYS.flatMap((key) => key.vendor === undefined ? [] : [key.vendor]);
77
102
  /** The keys `humanish keys` lists, in its order, each with what humanish uses it for. */
@@ -212,8 +237,11 @@ export async function discoverProviderKeys(args) {
212
237
  const ignored = [];
213
238
  // Fill-only means presence wins, including an explicitly-set empty value: an operator who
214
239
  // exported OPENAI_API_KEY="" said "off", and a rung that "fixes" that has overridden them.
215
- // Only a truly-unset name is fillable.
216
- const fillable = (name) => env[name] === undefined;
240
+ // Only a truly-unset name is fillable, and only while no spelling of its key was set before
241
+ // discovery: a repo overlay's GOOGLE_API_KEY must not stand beside, and outrank, an exported
242
+ // GEMINI_API_KEY. Spellings that discovery itself fills may stand together.
243
+ const explicit = new Set(Object.keys(env).filter((name) => env[name] !== undefined));
244
+ const fillable = (name) => env[name] === undefined && keyNames(name).every((spelling) => !explicit.has(spelling));
217
245
  const fill = (name, value, source) => {
218
246
  env[name] = value;
219
247
  fills.push({ name, source });
@@ -259,7 +287,7 @@ export async function discoverProviderKeys(args) {
259
287
  // Rung 3c: the gh CLI's credential chain. Only consulted when neither GitHub env name is
260
288
  // present; fills GH_TOKEN (the name humanish reads first). gh runs without any provider key in
261
289
  // its environment, so the keys the rungs above just filled stay in this process.
262
- if (fillable("GH_TOKEN") && fillable("GITHUB_TOKEN")) {
290
+ if (keyNames("GH_TOKEN").every((name) => env[name] === undefined)) {
263
291
  const exec = deps.execText ?? defaultExecText;
264
292
  const token = await exec("gh", ["auth", "token"], 3_000, withoutProviderKeys(env));
265
293
  if (token !== null)
@@ -23,6 +23,10 @@ export interface ObserverData {
23
23
  state: "running" | "finished" | "interrupted" | "unknown";
24
24
  observedAt: string;
25
25
  source: "local-run-status";
26
+ /** Present while the run's own process analyzes it after the run ended: when it started. */
27
+ analysis?: {
28
+ startedAt: string;
29
+ };
26
30
  };
27
31
  run: {
28
32
  runId: string;
@@ -12,5 +12,10 @@ export declare class PortInUseError extends Error {
12
12
  * that cannot decide says "other", which is the answer that suggests the safer action.
13
13
  */
14
14
  export declare function probePortHolder(port: number, fetchFn?: typeof fetch): Promise<PortHolder>;
15
- /** Bind on loopback only; resolve the port that was actually bound; name a taken port. */
16
- export declare function listenOnLoopback(server: Server, port: number, probe?: (port: number) => Promise<PortHolder>): Promise<number>;
15
+ /**
16
+ * Bind on loopback; resolve the port that was actually bound; name a taken port. `allInterfaces`
17
+ * binds every interface instead, for the Observer of a remote run's runner sandbox: Modal's proxy
18
+ * cannot reach a loopback-only server, and only the provider's authenticated proxy reaches the
19
+ * sandbox's ports.
20
+ */
21
+ export declare function listenOnLoopback(server: Server, port: number, probe?: (port: number) => Promise<PortHolder>, allInterfaces?: boolean): Promise<number>;
@@ -61,8 +61,13 @@ async function probeRoutes(port, fetchFn) {
61
61
  }
62
62
  return "other";
63
63
  }
64
- /** Bind on loopback only; resolve the port that was actually bound; name a taken port. */
65
- export function listenOnLoopback(server, port, probe = probePortHolder) {
64
+ /**
65
+ * Bind on loopback; resolve the port that was actually bound; name a taken port. `allInterfaces`
66
+ * binds every interface instead, for the Observer of a remote run's runner sandbox: Modal's proxy
67
+ * cannot reach a loopback-only server, and only the provider's authenticated proxy reaches the
68
+ * sandbox's ports.
69
+ */
70
+ export function listenOnLoopback(server, port, probe = probePortHolder, allInterfaces = false) {
66
71
  return new Promise((resolve, reject) => {
67
72
  const onError = (error) => {
68
73
  if (error.code === "EADDRINUSE") {
@@ -72,8 +77,9 @@ export function listenOnLoopback(server, port, probe = probePortHolder) {
72
77
  reject(error);
73
78
  };
74
79
  server.once("error", onError);
75
- // Never beyond loopback; exposure only ever happens through a tunnel or proxy forwarding here.
76
- server.listen(port, "127.0.0.1", () => {
80
+ // Otherwise never beyond loopback; exposure only ever happens through a tunnel or proxy
81
+ // forwarding here.
82
+ server.listen(port, allInterfaces ? "0.0.0.0" : "127.0.0.1", () => {
77
83
  server.off("error", onError);
78
84
  const address = server.address();
79
85
  if (!address || typeof address === "string") {