humanish 0.143.0 → 0.145.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 (35) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/analysis/models.d.ts +2 -0
  3. package/dist/analysis/models.js +15 -0
  4. package/dist/analysis/openai-wire.d.ts +1 -1
  5. package/dist/analysis/openai-wire.js +36 -26
  6. package/dist/analysis/validation.js +1 -1
  7. package/dist/cli/commands/run-command.js +2 -1
  8. package/dist/cli/commands/study-remote.d.ts +37 -3
  9. package/dist/cli/commands/study-remote.js +118 -52
  10. package/dist/cli/commands/study-route-run.d.ts +3 -2
  11. package/dist/cli/commands/study-route-run.js +7 -2
  12. package/dist/cli/commands/study-run.js +19 -9
  13. package/dist/cli/io.d.ts +2 -0
  14. package/dist/keys/key-resolution.js +2 -2
  15. package/dist/remote/launch.d.ts +31 -1
  16. package/dist/remote/launch.js +77 -24
  17. package/dist/remote/plan.d.ts +2 -0
  18. package/dist/remote/plan.js +12 -0
  19. package/dist/remote/runner.d.ts +5 -0
  20. package/dist/remote/runner.js +13 -1
  21. package/dist/routes/terminal/credentials.js +3 -2
  22. package/dist/routes/terminal/live-sandbox.js +2 -2
  23. package/dist/routes/terminal/runtime-auth.d.ts +0 -12
  24. package/dist/routes/terminal/runtime-auth.js +0 -23
  25. package/dist/run/results.d.ts +9 -1
  26. package/dist/study/keys.d.ts +1 -0
  27. package/dist/study/keys.js +1 -0
  28. package/dist/study/parse/execution.js +5 -0
  29. package/dist/study/types.d.ts +6 -0
  30. package/dist/substrates/e2b/egress-keys.d.ts +22 -0
  31. package/dist/substrates/e2b/egress-keys.js +59 -0
  32. package/dist/tui/launch.js +3 -0
  33. package/docs/contracts/study-analysis.md +13 -0
  34. package/package.json +1 -1
  35. package/skills/humanish/SKILL.md +9 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,28 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.145.0: A remote runner's keys stay in E2B's proxy (2026-10-22)
12
+
13
+ humanish 0.145.0 keeps the OpenAI, Gemini and E2B keys out of a remote E2B runner's environment.
14
+ E2B's egress proxy adds each key to the runner's requests on its fixed host, and the runner holds
15
+ placeholders; code in the runner can still read the keys back through the E2B API, and a Modal
16
+ runner still receives them as environment variables. Study analysis with `gpt-6-luna` sends low
17
+ text verbosity, which made it about four times faster on the benchmark recordings, and an
18
+ observation of what a participant said may now also cite the capture they spoke about.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.145.0)
21
+
22
+ ## 0.144.0: Hosted studies run remotely by default (2026-10-22)
23
+
24
+ humanish 0.144.0 runs a hosted computer-use study remotely by default. A study on
25
+ `e2b-desktop` or `modal-desktop` with a public `app-url` runs its own process in a sandbox in your
26
+ E2B or Modal account, so it keeps going when this machine sleeps; the command follows it, serves
27
+ the live Observer here and copies the evidence back. Before the runner starts, the command names
28
+ the keys it sends. Pass `--local` for one run, or set `execution.remote: false` in a study, to keep
29
+ it on this machine.
30
+
31
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.144.0)
32
+
11
33
  ## 0.143.0: Analysis costs about one seventh as much: gpt-6.1-sol at medium effort is the default (2026-10-22)
12
34
 
13
35
  humanish 0.143.0 analyzes studies with `gpt-6.1-sol` at medium reasoning effort by default, in
@@ -1,6 +1,8 @@
1
1
  import type { ReasoningEffort } from "../actors/reasoning-effort.js";
2
2
  import type { AnalysisApiVendor } from "./provider.js";
3
3
  import type { AnalysisConfig } from "./types.js";
4
+ /** The `text.verbosity` an OpenAI analysis with `model` asks for, or undefined for the default. */
5
+ export declare const openaiTextVerbosity: (model: string) => "low" | undefined;
4
6
  export declare const SUPPORTED_ANALYSIS_MODELS: readonly string[];
5
7
  export declare const GEMINI_ANALYSIS_MODELS: readonly string[];
6
8
  /** Whether the API analyst `provider` (omitted is OpenAI) analyzes with `model`. */
@@ -14,6 +14,21 @@ const OPENAI_MODELS = {
14
14
  "gpt-5.6-terra": ["none", "low", "medium", "high", "xhigh", "max"],
15
15
  "gpt-5.6-luna": ["none", "low", "medium", "high", "xhigh", "max"],
16
16
  };
17
+ /**
18
+ * The `text.verbosity` the OpenAI wire sends for a model; a model not listed gets the API default,
19
+ * `medium`. The Responses API takes `low`, `medium` or `high` beside a json_schema `text.format`
20
+ * (developers.openai.com/api/reference/resources/responses/methods/create, read 2026-10-22). On
21
+ * the benchmark recordings, gpt-6-luna at low verbosity decoded about three times faster and its
22
+ * analyses passed the evidence checks more often, with the same findings: see
23
+ * docs/evidence/benchmark/2026-10-22-luna-verbosity.md.
24
+ */
25
+ const OPENAI_TEXT_VERBOSITY = {
26
+ "gpt-6-luna": "low",
27
+ };
28
+ /** The `text.verbosity` an OpenAI analysis with `model` asks for, or undefined for the default. */
29
+ export const openaiTextVerbosity = (model) => Object.hasOwn(OPENAI_TEXT_VERBOSITY, model)
30
+ ? OPENAI_TEXT_VERBOSITY[model]
31
+ : undefined;
17
32
  /**
18
33
  * Gemini models that completed a live analysis, with the `thinkingLevel` values the thinking guide
19
34
  * lists for each (ai.google.dev/gemini-api/docs/generate-content/thinking, read 2026-10-21):
@@ -2,5 +2,5 @@ import type { AnalysisProviderResult, AnalysisWire } from "./provider.js";
2
2
  /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
3
3
  export declare function parseAnalysisResponse(raw: unknown): AnalysisProviderResult;
4
4
  /** One stateless request: no stored response, no tools, the analysis's reasoning effort, strict
5
- * schema. */
5
+ * schema, and the model's text verbosity from models.ts. */
6
6
  export declare const openaiWire: AnalysisWire;
@@ -2,6 +2,7 @@
2
2
  // a response becomes an AnalysisProviderResult. provider.ts owns the transport around it.
3
3
  import { OPENAI_RESPONSES_URL } from "../actors/openai-endpoint.js";
4
4
  import { isRecord } from "../run/type-guards.js";
5
+ import { openaiTextVerbosity } from "./models.js";
5
6
  const record = (value) => (isRecord(value) ? value : {});
6
7
  const count = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 1e12;
7
8
  function usageOf(raw) {
@@ -68,35 +69,44 @@ export function parseAnalysisResponse(raw) {
68
69
  }
69
70
  }
70
71
  /** One stateless request: no stored response, no tools, the analysis's reasoning effort, strict
71
- * schema. */
72
+ * schema, and the model's text verbosity from models.ts. */
72
73
  export const openaiWire = {
73
74
  url: () => OPENAI_RESPONSES_URL,
74
75
  headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }),
75
- body: (request) => ({
76
- model: request.model,
77
- instructions: request.instructions,
78
- input: [
79
- {
80
- role: "user",
81
- content: [
82
- { type: "input_text", text: request.evidence },
83
- ...request.images.flatMap((image) => [
84
- { type: "input_text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
85
- { type: "input_image", image_url: image.dataUrl, detail: "high" },
86
- ]),
87
- ],
76
+ body: (request) => {
77
+ const verbosity = openaiTextVerbosity(request.model);
78
+ return {
79
+ model: request.model,
80
+ instructions: request.instructions,
81
+ input: [
82
+ {
83
+ role: "user",
84
+ content: [
85
+ { type: "input_text", text: request.evidence },
86
+ ...request.images.flatMap((image) => [
87
+ { type: "input_text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
88
+ { type: "input_image", image_url: image.dataUrl, detail: "high" },
89
+ ]),
90
+ ],
91
+ },
92
+ ],
93
+ store: false,
94
+ tools: [],
95
+ tool_choice: "none",
96
+ truncation: "disabled",
97
+ service_tier: "default",
98
+ reasoning: { effort: request.reasoningEffort },
99
+ max_output_tokens: request.maxOutputTokens,
100
+ text: {
101
+ format: {
102
+ type: "json_schema",
103
+ name: "study_analysis",
104
+ strict: true,
105
+ schema: request.schema,
106
+ },
107
+ ...(verbosity === undefined ? {} : { verbosity }),
88
108
  },
89
- ],
90
- store: false,
91
- tools: [],
92
- tool_choice: "none",
93
- truncation: "disabled",
94
- service_tier: "default",
95
- reasoning: { effort: request.reasoningEffort },
96
- max_output_tokens: request.maxOutputTokens,
97
- text: {
98
- format: { type: "json_schema", name: "study_analysis", strict: true, schema: request.schema },
99
- },
100
- }),
109
+ };
110
+ },
101
111
  parse: parseAnalysisResponse,
102
112
  };
@@ -369,7 +369,7 @@ export function checkAnalysisResult(input, value) {
369
369
  !refs.some((ref) => ref &&
370
370
  ["ui_action", "command", "tool_call", "file_change", "approval"].includes(ref.kind)))
371
371
  errors.add("ANALYSIS_ACTION_SOURCE_INVALID");
372
- if (observation.basis === "participant_statement" && refs.some((ref) => !ref?.quoteEligible))
372
+ if (observation.basis === "participant_statement" && !refs.some((ref) => ref?.quoteEligible))
373
373
  errors.add("ANALYSIS_STATEMENT_SOURCE_INVALID");
374
374
  return new Set(refs.flatMap((ref) => (ref ? [ref.streamId] : [])));
375
375
  };
@@ -20,7 +20,8 @@ export function addRunOptions(command) {
20
20
  .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
21
21
  .addOption(envFileAliasOption())
22
22
  .option("--run-id <id>", "Explicit run id for deterministic fixture tests; refused when that run already exists.")
23
- .option("--remote", "Run the study's own process in a sandbox on its participants' provider (E2B or Modal), so it keeps going when this machine sleeps. This machine follows it, serves its live Observer and copies the run back. Computer-use studies with hosted desktops and an app-url subject.")
23
+ .addOption(new Option("--remote", "Require a remote run: the study's own process runs in a sandbox on its participants' provider, so it keeps going when this machine sleeps. A study that can is run this way by default; this refuses one that cannot, with the reason.").conflicts("local"))
24
+ .option("--local", "Run the study on this machine even when it could run remotely. It then stops if this machine sleeps.")
24
25
  .addOption(ownerKeyOption())
25
26
  .option("--json", JSON_OPTION_DESCRIPTION);
26
27
  }
@@ -1,13 +1,47 @@
1
1
  import type { Command } from "commander";
2
- import { type RemoteOutcome } from "../../remote/launch.js";
2
+ import { type RemoteOutcome, type RunnerKeys } from "../../remote/launch.js";
3
+ import { type RemotePlan } from "../../remote/plan.js";
4
+ import type { RunResult } from "../../run/results.js";
3
5
  import type { StudyConfig } from "../../study/types.js";
4
6
  import { type CliIo, type StudyCommandOptions } from "../io.js";
7
+ type RemoteErrorCode = "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED";
8
+ /** Where a study's run executes, decided before anything is created. */
9
+ export type RunPlacement = {
10
+ kind: "local";
11
+ note?: string;
12
+ } | {
13
+ kind: "remote";
14
+ plan: RemotePlan;
15
+ } | {
16
+ kind: "refused";
17
+ message: string;
18
+ };
19
+ /**
20
+ * Whether this run executes here or in a runner sandbox. A hosted study runs remotely by default
21
+ * when it can; `--local` or `execution.remote: false` keeps it here, and `--remote` or
22
+ * `execution.remote: true` requires a remote run, refused with the reason when the study cannot
23
+ * have one. `--local` and `--remote` win over the study's field.
24
+ */
25
+ export declare function placeRun(args: {
26
+ options: StudyCommandOptions;
27
+ config: StudyConfig;
28
+ studyPath: string;
29
+ env?: Readonly<Record<string, string | undefined>>;
30
+ /** Whether this humanish is a built package a runner can install; read from the module when absent. */
31
+ packaged?: boolean;
32
+ }): RunPlacement;
33
+ /** A refused placement as the command's result. */
34
+ export declare function refuseRemote(command: Command, io: CliIo, cwd: string, message: string, code?: RemoteErrorCode, forwarded?: NonNullable<RunResult["remote"]>): void;
35
+ /** Launch `remote` in a runner sandbox and report how it ended. */
5
36
  export declare function runRemoteStudy(args: {
6
37
  command: Command;
7
38
  io: CliIo;
8
39
  options: StudyCommandOptions;
9
- config: StudyConfig;
10
40
  studyPath: string;
41
+ remote: RemotePlan;
11
42
  }): Promise<void>;
43
+ /** The line that says, before the runner exists, where each of this machine's keys goes. */
44
+ export declare function runnerKeysLine(provider: RemotePlan["provider"], keys: Pick<RunnerKeys, "held" | "sent">): string;
12
45
  /** The command's result and exit code for a remote run that ended, stopped or detached. */
13
- export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome): void;
46
+ export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome, forwarded?: NonNullable<RunResult["remote"]>): void;
47
+ export {};
@@ -1,81 +1,140 @@
1
1
  // `--remote` on run and watch: plan here, refuse what cannot run remotely, then launch and follow
2
2
  // the run in a runner sandbox (src/remote/launch.ts). The result and exit code are the run's own.
3
3
  import path from "node:path";
4
- import { launchRemoteRun } from "../../remote/launch.js";
4
+ import { launchRemoteRun, runnerKeys, runsFromPackage, } from "../../remote/launch.js";
5
5
  import { planRemoteRun } from "../../remote/plan.js";
6
- import { planStudy } from "../../study/plan.js";
6
+ import { INSIDE_RUNNER_ENV } from "../../remote/runner.js";
7
+ import { planStudy, resolveStudyDryRun } from "../../study/plan.js";
7
8
  import { concurrentSandboxes, sandboxCeiling } from "../../substrates/e2b/lifetime.js";
8
9
  import { MODAL_MAX_SANDBOX_MS } from "../../substrates/modal/sdk.js";
9
10
  import { formatRunHuman, parseObserverPort, wantsJson, writeResult, } from "../io.js";
10
- export async function runRemoteStudy(args) {
11
- const { io, options, config } = args;
11
+ /**
12
+ * Whether this run executes here or in a runner sandbox. A hosted study runs remotely by default
13
+ * when it can; `--local` or `execution.remote: false` keeps it here, and `--remote` or
14
+ * `execution.remote: true` requires a remote run, refused with the reason when the study cannot
15
+ * have one. `--local` and `--remote` win over the study's field.
16
+ */
17
+ export function placeRun(args) {
18
+ const { options, config } = args;
19
+ if ((args.env ?? process.env)[INSIDE_RUNNER_ENV] === "1")
20
+ return options.remote === true
21
+ ? {
22
+ kind: "refused",
23
+ message: "This run is already inside a runner sandbox, so it runs here.",
24
+ }
25
+ : { kind: "local" };
26
+ const declared = config.execution?.remote;
27
+ if (options.local === true || (options.remote !== true && declared === false))
28
+ return { kind: "local" };
29
+ const required = options.remote === true || declared === true;
30
+ // A dry run creates no sandbox, so it never needs a runner, whatever asked for one.
31
+ if (resolveStudyDryRun(config, options.dryRun, true) !== false)
32
+ return required
33
+ ? { kind: "local", note: "A dry run creates no sandbox, so it runs here, not remotely." }
34
+ : { kind: "local" };
35
+ const local = (reason) => required
36
+ ? { kind: "refused", message: `This study cannot run remotely: ${reason}` }
37
+ : movable(config)
38
+ ? {
39
+ kind: "local",
40
+ note: `Not running remotely: ${reason} A run on this machine stops if it sleeps.`,
41
+ }
42
+ : { kind: "local" };
43
+ const flags = localOnlyFlags(options);
44
+ if (flags.length > 0)
45
+ return local(`${flags.join(", ")} ${flags.length === 1 ? "does" : "do"} not run remotely yet.`);
12
46
  const cwd = path.resolve(options.cwd);
13
- const refuse = (code, message, runId) => {
14
- const result = {
15
- schema: "humanish.run-result.v1",
16
- ok: false,
17
- cwd,
18
- ...(runId === undefined ? {} : { runId }),
19
- warnings: [],
20
- error: { code, message },
21
- };
22
- writeResult(args.command, io, result, formatRunHuman);
23
- io.setExitCode(2);
24
- };
25
- if (options.dryRun === true) {
26
- refuse("HUMANISH_REMOTE_REFUSED", "--remote runs live studies; a dry run needs no runner.");
27
- return;
28
- }
29
- const local = localOnlyFlags(options);
30
- if (local.length > 0) {
31
- refuse("HUMANISH_REMOTE_REFUSED", `${local.join(", ")} ${local.length === 1 ? "does" : "do"} not run remotely yet; run without --remote to use ${local.length === 1 ? "it" : "them"}.`);
32
- return;
33
- }
34
47
  const planned = planStudy(config, { cwd, dryRun: false });
35
- if (!planned.ok) {
36
- refuse("HUMANISH_REMOTE_REFUSED", planned.refusal.message);
37
- return;
38
- }
48
+ // A study that does not plan is refused by its route with the plan's own message.
49
+ if (!planned.ok)
50
+ return required ? { kind: "refused", message: planned.refusal.message } : { kind: "local" };
39
51
  const plan = planned.planned.plan;
40
- const e2bCeiling = sandboxCeiling(process.env);
41
52
  const provider = plan.route === "computer-use" && plan.runner.desktop === "e2b-desktop"
42
53
  ? plan.runner.provider
43
54
  : "e2b";
44
- const ceilingMs = provider === "modal" ? MODAL_MAX_SANDBOX_MS : e2bCeiling.ok ? e2bCeiling.ms : 0;
55
+ const e2bCeiling = sandboxCeiling(process.env);
45
56
  const sandboxes = concurrentSandboxes(process.env);
46
57
  const remote = planRemoteRun({
47
58
  config,
48
59
  plan,
49
60
  cwd,
50
61
  studyPath: args.studyPath,
51
- ceilingMs,
62
+ ceilingMs: provider === "modal" ? MODAL_MAX_SANDBOX_MS : e2bCeiling.ok ? e2bCeiling.ms : 0,
52
63
  concurrentSandboxes: sandboxes.ok ? sandboxes.count : 1,
53
64
  });
54
- if (!remote.ok) {
55
- refuse("HUMANISH_REMOTE_REFUSED", `This study cannot run remotely: ${remote.reason}`);
56
- return;
57
- }
65
+ if (!remote.ok)
66
+ return local(remote.reason);
67
+ // Checked last, so a study that could not run remotely anyway gives its own reason.
68
+ if (!(args.packaged ?? runsFromPackage()))
69
+ return local("this humanish runs from source, which a runner cannot install. Build it with pnpm build and run node dist/cli.js to run remotely.");
70
+ return { kind: "remote", plan: remote.plan };
71
+ }
72
+ /**
73
+ * Whether the study is of a kind that runs remotely, so a reason it does not this time is worth
74
+ * saying: a computer-use study on hosted desktops. Other routes never run remotely yet.
75
+ */
76
+ function movable(config) {
77
+ const target = config.execution?.target;
78
+ return (config.route === "computer-use" && (target === "e2b-desktop" || target === "modal-desktop"));
79
+ }
80
+ /** A refused placement as the command's result. */
81
+ export function refuseRemote(command, io, cwd, message, code = "HUMANISH_REMOTE_REFUSED", forwarded) {
82
+ const result = {
83
+ schema: "humanish.run-result.v1",
84
+ ok: false,
85
+ cwd: path.resolve(cwd),
86
+ warnings: [],
87
+ ...(forwarded === undefined ? {} : { remote: forwarded }),
88
+ error: { code, message },
89
+ };
90
+ writeResult(command, io, result, formatRunHuman);
91
+ io.setExitCode(2);
92
+ }
93
+ /** Launch `remote` in a runner sandbox and report how it ended. */
94
+ export async function runRemoteStudy(args) {
95
+ const { io, options } = args;
96
+ const cwd = path.resolve(options.cwd);
58
97
  const port = parseObserverPort(options.port ?? "0");
59
98
  if (port === null) {
60
- refuse("HUMANISH_REMOTE_REFUSED", "--port must be an integer between 0 and 65535.");
99
+ refuseRemote(args.command, io, cwd, "--port must be an integer between 0 and 65535.");
61
100
  return;
62
101
  }
102
+ const keys = runnerKeys(args.remote, process.env);
103
+ const forwarded = {
104
+ provider: args.remote.provider,
105
+ keys: [...keys.held, ...keys.sent].sort(),
106
+ };
107
+ io.writeErr(`${runnerKeysLine(args.remote.provider, keys)}\n`);
63
108
  let outcome;
64
109
  try {
65
110
  outcome = await launchRemoteRun({
66
111
  cwd,
67
112
  study: path.relative(cwd, path.resolve(cwd, args.studyPath)).split(path.sep).join("/"),
68
- remote: remote.plan,
113
+ remote: args.remote,
69
114
  env: process.env,
70
115
  port,
71
116
  say: (line) => io.writeErr(`${line}\n`),
72
117
  });
73
118
  }
74
119
  catch (error) {
75
- refuse("HUMANISH_REMOTE_FAILED", error instanceof Error ? error.message : String(error));
120
+ refuseRemote(args.command, io, cwd, error instanceof Error ? error.message : String(error), "HUMANISH_REMOTE_FAILED", forwarded);
76
121
  return;
77
122
  }
78
- reportRemoteOutcome(args.command, io, cwd, outcome);
123
+ reportRemoteOutcome(args.command, io, cwd, outcome, forwarded);
124
+ }
125
+ /** The line that says, before the runner exists, where each of this machine's keys goes. */
126
+ export function runnerKeysLine(provider, keys) {
127
+ const list = (names) => names.join(", ");
128
+ const local = "Run with --local to keep the study on this machine.";
129
+ if (provider === "modal")
130
+ return `The runner runs in your own Modal account and receives ${list(keys.sent) || "no keys"} as environment variables, since Modal has no egress proxy that could hold them. ${local}`;
131
+ const held = keys.held.length === 0
132
+ ? ""
133
+ : ` E2B's egress proxy holds ${list(keys.held)} and adds them to the runner's requests, so the runner's environment holds placeholders.`;
134
+ const sent = keys.sent.length === 0
135
+ ? ""
136
+ : ` The runner receives ${list(keys.sent)} as environment variables.`;
137
+ return `The runner runs in your own E2B account.${held}${sent} ${local}`;
79
138
  }
80
139
  /** The run options a remote run does not carry to the runner yet. */
81
140
  function localOnlyFlags(options) {
@@ -90,28 +149,35 @@ function localOnlyFlags(options) {
90
149
  ].flat();
91
150
  }
92
151
  /** The command's result and exit code for a remote run that ended, stopped or detached. */
93
- export function reportRemoteOutcome(command, io, cwd, outcome) {
94
- if (outcome.detached === true) {
95
- io.writeErr(`The terminal closed, so run ${outcome.runId} goes on in its runner without this machine. Watch it again with humanish observe --run ${outcome.runId}, or stop it and its sandboxes with humanish reclaim --run ${outcome.runId}\n`);
96
- return;
97
- }
98
- if (outcome.result === undefined || typeof outcome.result !== "object") {
152
+ export function reportRemoteOutcome(command, io, cwd, outcome, forwarded) {
153
+ const remote = forwarded === undefined ? {} : { remote: forwarded };
154
+ if (outcome.detached === true ||
155
+ outcome.result === undefined ||
156
+ typeof outcome.result !== "object") {
157
+ const detached = outcome.detached === true;
99
158
  const failed = {
100
159
  schema: "humanish.run-result.v1",
101
160
  ok: false,
102
161
  cwd,
103
162
  runId: outcome.runId,
104
163
  warnings: [],
105
- error: {
106
- code: "HUMANISH_REMOTE_FAILED",
107
- message: `Run ${outcome.runId} ${outcome.error ?? "ended without a result"}.`,
108
- },
164
+ ...remote,
165
+ error: detached
166
+ ? {
167
+ code: "HUMANISH_REMOTE_DETACHED",
168
+ message: `The terminal closed, so run ${outcome.runId} goes on in its runner without this machine. Follow it and copy it back with humanish observe --run ${outcome.runId}, or stop it and its sandboxes with humanish reclaim --run ${outcome.runId}.`,
169
+ }
170
+ : {
171
+ code: "HUMANISH_REMOTE_FAILED",
172
+ message: `Run ${outcome.runId} ${outcome.error ?? "ended without a result"}.`,
173
+ },
109
174
  };
110
175
  writeResult(command, io, failed, formatRunHuman);
111
- io.setExitCode(outcome.exitCode === 0 ? 2 : outcome.exitCode);
176
+ // A detached run exits as a hung-up local command does; it is not finished, so not 0.
177
+ io.setExitCode(detached ? 129 : outcome.exitCode === 0 ? 2 : outcome.exitCode);
112
178
  return;
113
179
  }
114
- const result = { ...outcome.result, cwd };
180
+ const result = { ...outcome.result, cwd, ...remote };
115
181
  if (outcome.error !== undefined)
116
182
  result.warnings = [...(result.warnings ?? []), outcome.error];
117
183
  if (wantsJson(command))
@@ -12,8 +12,9 @@ export interface RouteRun {
12
12
  * only for a plan that will run: it loads the scorer, or writes its own refusal and returns
13
13
  * undefined, so a refused study never imports the scorer's host code. `afterRun` runs once runStudyWith
14
14
  * has returned or thrown, before presentation, which may hold its own signal handlers (watch's
15
- * Observer and tunnel).
15
+ * Observer and tunnel). `elsewhere`, when given, runs an admitted study somewhere else instead of
16
+ * here: the route's own checks have refused anything this machine would refuse first.
16
17
  */
17
18
  export declare function runRoute(config: StudyConfig, run: RouteRun, beforeRun?: () => Promise<{
18
19
  scorer?: LoadedAdapterScorer;
19
- } | undefined>, afterRun?: () => void): Promise<void>;
20
+ } | undefined>, afterRun?: () => void, elsewhere?: () => Promise<void>): Promise<void>;
@@ -6,12 +6,17 @@ import { prepareStudy } from "../../run-study.js";
6
6
  * only for a plan that will run: it loads the scorer, or writes its own refusal and returns
7
7
  * undefined, so a refused study never imports the scorer's host code. `afterRun` runs once runStudyWith
8
8
  * has returned or thrown, before presentation, which may hold its own signal handlers (watch's
9
- * Observer and tunnel).
9
+ * Observer and tunnel). `elsewhere`, when given, runs an admitted study somewhere else instead of
10
+ * here: the route's own checks have refused anything this machine would refuse first.
10
11
  */
11
- export async function runRoute(config, run, beforeRun = () => Promise.resolve({}), afterRun = () => undefined) {
12
+ export async function runRoute(config, run, beforeRun = () => Promise.resolve({}), afterRun = () => undefined, elsewhere) {
12
13
  let outcome;
13
14
  try {
14
15
  const prepared = await prepareStudy(config, run.options);
16
+ if (prepared.ok && elsewhere !== undefined) {
17
+ await elsewhere();
18
+ return;
19
+ }
15
20
  if (prepared.ok) {
16
21
  const ready = await beforeRun();
17
22
  if (ready === undefined)
@@ -15,7 +15,7 @@ import { sharedWorldRouteRun } from "./study-route-shared-world.js";
15
15
  import { previewRouteRun } from "./study-route-preview.js";
16
16
  import { terminalRouteRun } from "./study-route-terminal.js";
17
17
  import { maybeLoadAdapterScorer } from "./study-scorer.js";
18
- import { runRemoteStudy } from "./study-remote.js";
18
+ import { placeRun, refuseRemote, runRemoteStudy } from "./study-remote.js";
19
19
  import { discoverCliKeys, formatRunHuman, noteRunFacts, writeResult, } from "../io.js";
20
20
  import { watchExposeRequested } from "../observer-follow.js";
21
21
  import { beginRunSignalPhase } from "./run-signals.js";
@@ -112,16 +112,13 @@ export async function runStudyCommand(args) {
112
112
  ...(announced === undefined ? {} : { announced }),
113
113
  });
114
114
  }
115
- if (args.options.remote === true) {
116
- await runRemoteStudy({
117
- command: args.command,
118
- io: args.io,
119
- options: args.options,
120
- config,
121
- studyPath: resolved.path,
122
- });
115
+ const placement = placeRun({ options: args.options, config, studyPath: resolved.path });
116
+ if (placement.kind === "refused") {
117
+ refuseRemote(args.command, args.io, args.options.cwd, placement.message);
123
118
  return;
124
119
  }
120
+ if (placement.kind === "local" && placement.note !== undefined)
121
+ args.io.writeErr(`${placement.note}\n`);
125
122
  // The route's CLI setup refuses bad options, runStudyWith's plan refuses bad labs, and the route's local
126
123
  // checks (keys, runtime auth, subject env, the local agent, caps) refuse this machine, all before
127
124
  // the scorer loads, so none imports the scorer's host code. The scripted route's checks and the
@@ -131,6 +128,19 @@ export async function runStudyCommand(args) {
131
128
  return;
132
129
  // Every route's runStudyWith options carry the study's provenance, from here only.
133
130
  const run = { ...routeRun, options: { ...routeRun.options, study } };
131
+ // A remote run passes the same checks here first, so a study this machine would refuse is refused
132
+ // before any runner exists. The launcher holds its own signal handlers.
133
+ if (placement.kind === "remote") {
134
+ const remote = placement.plan;
135
+ await runRoute(config, run, undefined, undefined, () => runRemoteStudy({
136
+ command: args.command,
137
+ io: args.io,
138
+ options: args.options,
139
+ studyPath: resolved.path,
140
+ remote,
141
+ }));
142
+ return;
143
+ }
134
144
  // From here a signal marks the run interrupted and reclaims its sandboxes (run-signals.ts).
135
145
  const signals = beginRunSignalPhase(args.io);
136
146
  try {
package/dist/cli/io.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface StudyCommandOptions {
47
47
  runId?: string | undefined;
48
48
  /** Run the study in a runner sandbox on its participants' provider. */
49
49
  remote?: boolean | undefined;
50
+ /** Run the study on this machine even when it could run remotely. */
51
+ local?: boolean | undefined;
50
52
  /** Hidden: the run key the run's sandboxes carry, chosen by a remote run's launcher. */
51
53
  ownerKey?: string | undefined;
52
54
  /** Hidden, watch only: bind the served Observer on every interface (a remote run's runner). */
@@ -25,7 +25,7 @@ import { parse as parseToml } from "smol-toml";
25
25
  import { loadEnvFile } from "./env-file.js";
26
26
  import { cli } from "../cli/invocation.js";
27
27
  import { humanishConfigFile } from "../cli/user-config.js";
28
- import { OPENAI_EGRESS_PLACEHOLDER } from "../routes/terminal/runtime-auth.js";
28
+ import { EGRESS_PLACEHOLDER } from "../substrates/e2b/egress-keys.js";
29
29
  /**
30
30
  * The provider keys, in the order `humanish keys set` names their vendors. Implicit discovery fills
31
31
  * only these names and the user store holds only these. Everything else in an overlay or store file
@@ -355,7 +355,7 @@ export async function probeKeySources(names, args) {
355
355
  // A terminal study's sandbox sets CODEX_API_KEY to an inert placeholder so Codex starts; the key
356
356
  // itself stays in the host-side egress proxy. The placeholder is no key, so it counts as unset and
357
357
  // discovery may still find a real one.
358
- const placeholder = (name) => args.env[name]?.trim() === OPENAI_EGRESS_PLACEHOLDER;
358
+ const placeholder = (name) => args.env[name]?.trim() === EGRESS_PLACEHOLDER;
359
359
  const scratch = { ...args.env };
360
360
  for (const name of names)
361
361
  if (placeholder(name))
@@ -1,4 +1,5 @@
1
1
  import { type PreparedRunArtifactPaths } from "../run/paths.js";
2
+ import type { E2BNetworkOptions } from "../substrates/e2b/sdk.js";
2
3
  import type { RemotePlan } from "./plan.js";
3
4
  import { type Runner } from "./runner.js";
4
5
  /** What following a remote run needs from its caller: the project, its env and a progress line. */
@@ -45,8 +46,13 @@ export declare function followRemoteRun(args: {
45
46
  export declare function joinJournals(...journals: string[]): string;
46
47
  export interface Signals {
47
48
  readonly requested: "stop" | "detach" | undefined;
48
- /** The exit code a local run gives for the stop signal received: 130 for SIGINT, 143 for SIGTERM. */
49
+ /**
50
+ * The exit code for the first signal received: 130 for SIGINT and 143 for SIGTERM, as a local
51
+ * run gives, and 129 for a closed terminal.
52
+ */
49
53
  readonly exitCode: number;
54
+ /** Settles when the first signal arrives, so a long step can stop waiting for itself. */
55
+ readonly received: Promise<void>;
50
56
  dispose(): void;
51
57
  }
52
58
  /**
@@ -54,3 +60,27 @@ export interface Signals {
54
60
  * running; the runner ends it at its deadline.
55
61
  */
56
62
  export declare function watchSignals(): Signals;
63
+ /** What the runner gets from this machine's env, and how each key reaches it. */
64
+ export interface RunnerKeys {
65
+ /** The run command's env: each forwarded name, with a placeholder for each key E2B holds. */
66
+ env: Record<string, string>;
67
+ /**
68
+ * The runner's network: its URLs answer only with its traffic token, and on E2B its header
69
+ * transforms carry the held keys. It holds key values: never log or persist it.
70
+ */
71
+ network: E2BNetworkOptions;
72
+ /** Names whose values stay in E2B's egress proxy; the runner sees a placeholder. */
73
+ held: string[];
74
+ /** Names whose values enter the runner as environment variables. */
75
+ sent: string[];
76
+ }
77
+ /**
78
+ * The keys the runner's run receives: each of the plan's names that is set here, and of names the
79
+ * run reads only the first of, only the first one set. On E2B, each key that travels as a header
80
+ * to a fixed host stays in an E2B header transform, and the run gets a placeholder and the system
81
+ * CA bundle that holds E2B's proxy CA, which Node does not read by default. Modal has no such
82
+ * transform, so a Modal runner receives every key as an environment variable.
83
+ */
84
+ export declare function runnerKeys(remote: Pick<RemotePlan, "provider" | "envNames" | "alternatives">, env: Readonly<Record<string, string | undefined>>): RunnerKeys;
85
+ /** Whether this humanish runs from a built package, which a runner can install. */
86
+ export declare function runsFromPackage(): boolean;
@@ -15,6 +15,7 @@ import { pointLatestRunAt } from "../run/run.js";
15
15
  import { appendSandboxOwner, SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
16
16
  import { loadE2BBaseModule } from "../substrates/e2b/base-module.js";
17
17
  import { e2bConnection } from "../substrates/e2b/connection.js";
18
+ import { E2B_SYSTEM_CA_BUNDLE, holdKeysInEgress } from "../substrates/e2b/egress-keys.js";
18
19
  import { acquireE2BShellSandbox } from "../substrates/e2b/sandbox.js";
19
20
  import { loadModalShellModule } from "../substrates/modal/sandbox.js";
20
21
  import { serveObserverProxy } from "./observer-proxy.js";
@@ -56,9 +57,12 @@ export async function launchRemoteRun(launch) {
56
57
  try {
57
58
  const tarball = await packThisHumanish();
58
59
  tarballDir = tarball.dir;
60
+ if (signals.requested !== undefined)
61
+ return { runId, exitCode: signals.exitCode, error: "stopped before the runner was created" };
59
62
  const where = remote.provider === "modal" ? "a Modal sandbox" : "an E2B sandbox";
60
63
  say(`Running this study on ${where} as run ${runId}; creating the runner.`);
61
- const runner = runnerOf((await acquireRunner(launch, paths)).sandbox);
64
+ const keys = runnerKeys(remote, launch.env);
65
+ const runner = runnerOf((await acquireRunner(launch, paths, keys.network)).sandbox);
62
66
  if (signals.requested !== undefined)
63
67
  return await endRun(launch, runId, {
64
68
  exitCode: signals.exitCode,
@@ -71,7 +75,17 @@ export async function launchRemoteRun(launch) {
71
75
  });
72
76
  // Bound before the run starts, so a taken --port fails before any participant exists.
73
77
  proxy = await serveObserverProxy(await runner.sandbox.portAccess(RUNNER_OBSERVER_PORT), launch.port);
74
- const version = await installHumanish(runner, tarball.bytes, [await peerSpec(remote.provider)]);
78
+ // Installing can take minutes; a signal stops waiting for it, and the reclaim below stops the
79
+ // runner, and the install with it.
80
+ const installing = installHumanish(runner, tarball.bytes, [await peerSpec(remote.provider)]);
81
+ // An install left behind by a signal fails once its runner is gone; that is expected.
82
+ installing.catch(() => undefined);
83
+ const version = await Promise.race([installing, signals.received.then(() => "")]);
84
+ if (signals.requested !== undefined)
85
+ return await endRun(launch, runId, {
86
+ exitCode: signals.exitCode,
87
+ error: "stopped before the run started",
88
+ });
75
89
  say(`Installed humanish ${version} in the runner; uploading the study.`);
76
90
  await uploadProject(runner, cwd, remote.uploads, remote.uploadDirectories);
77
91
  if (signals.requested !== undefined)
@@ -83,16 +97,15 @@ export async function launchRemoteRun(launch) {
83
97
  study: launch.study,
84
98
  runId,
85
99
  ownerKey,
86
- env: Object.fromEntries(remote.envNames.flatMap((name) => {
87
- const value = launch.env[name];
88
- return value === undefined || value === "" ? [] : [[name, value]];
89
- })),
100
+ env: keys.env,
90
101
  });
91
102
  return await followRemoteRun({ launch, runner, paths, runId, signals, proxyUrl: proxy.url });
92
103
  }
93
104
  catch (error) {
94
- await reclaimRunSandboxes(cwd, runId, { env: launch.env }).catch(() => undefined);
95
- throw error;
105
+ const reclaimed = await reclaimRunSandboxes(cwd, runId, { env: launch.env }).then((result) => result.ok, () => false);
106
+ if (reclaimed)
107
+ throw error;
108
+ throw new Error(`${message(error)}; its sandboxes were not confirmed stopped, so run humanish reclaim --run ${runId}`, { cause: error });
96
109
  }
97
110
  finally {
98
111
  signals.dispose();
@@ -101,7 +114,7 @@ export async function launchRemoteRun(launch) {
101
114
  await rm(tarballDir, { recursive: true, force: true });
102
115
  }
103
116
  }
104
- async function acquireRunner(launch, paths) {
117
+ async function acquireRunner(launch, paths, network) {
105
118
  const { provider, runnerMs } = launch.remote;
106
119
  const module = provider === "modal" ? await loadModalShellModule(launch.env) : await loadE2BBaseModule();
107
120
  // A Modal module takes its connection from launch.env and ignores these two.
@@ -114,7 +127,7 @@ async function acquireRunner(launch, paths) {
114
127
  ...account,
115
128
  timeoutMs: runnerMs,
116
129
  metadata: { participantId: "runner" },
117
- network: { allowPublicTraffic: false },
130
+ network,
118
131
  },
119
132
  receipt: { root: paths, participantId: "runner" },
120
133
  });
@@ -152,8 +165,10 @@ export async function followRemoteRun(args) {
152
165
  say("The run ended; copying its evidence back.");
153
166
  const copied = await copy().then((files) => ({ files }), (error) => ({ error }));
154
167
  // The run reclaimed its participants as it ended; only the runner is left, and it holds the
155
- // only whole copy of the evidence until a copy succeeds.
156
- if ("error" in copied && stopAt === undefined)
168
+ // only whole copy of the evidence until a copy succeeds. It is kept only then: a run that
169
+ // refused before its run directory existed has nothing to keep, and its own result says why.
170
+ // Its result is not reported as the run's own, since the evidence is not here.
171
+ if ("error" in copied && stopAt === undefined && run.state !== undefined)
157
172
  return {
158
173
  runId,
159
174
  exitCode: 2,
@@ -165,7 +180,9 @@ export async function followRemoteRun(args) {
165
180
  result: run.result.result,
166
181
  ...("files" in copied
167
182
  ? { files: copied.files }
168
- : { error: `copying the evidence back failed: ${message(copied.error)}` }),
183
+ : run.state === undefined
184
+ ? {}
185
+ : { error: `copying the evidence back failed: ${message(copied.error)}` }),
169
186
  });
170
187
  }
171
188
  if (signals.requested === "detach")
@@ -302,17 +319,20 @@ async function writeRemoteRecord(paths, record) {
302
319
  export function watchSignals() {
303
320
  let requested;
304
321
  let exitCode = 130;
305
- const interrupt = () => {
306
- requested ??= "stop";
307
- };
308
- const terminate = () => {
309
- if (requested === undefined)
310
- exitCode = 143;
311
- requested ??= "stop";
312
- };
313
- const detach = () => {
314
- requested ??= "detach";
322
+ let signalled;
323
+ const received = new Promise((resolve) => {
324
+ signalled = resolve;
325
+ });
326
+ const first = (kind, code) => {
327
+ if (requested !== undefined)
328
+ return;
329
+ requested = kind;
330
+ exitCode = code;
331
+ signalled();
315
332
  };
333
+ const interrupt = () => first("stop", 130);
334
+ const terminate = () => first("stop", 143);
335
+ const detach = () => first("detach", 129);
316
336
  process.on("SIGINT", interrupt);
317
337
  process.on("SIGTERM", terminate);
318
338
  process.on("SIGHUP", detach);
@@ -323,6 +343,7 @@ export function watchSignals() {
323
343
  get exitCode() {
324
344
  return exitCode;
325
345
  },
346
+ received,
326
347
  dispose() {
327
348
  process.off("SIGINT", interrupt);
328
349
  process.off("SIGTERM", terminate);
@@ -330,6 +351,38 @@ export function watchSignals() {
330
351
  },
331
352
  };
332
353
  }
354
+ /**
355
+ * The keys the runner's run receives: each of the plan's names that is set here, and of names the
356
+ * run reads only the first of, only the first one set. On E2B, each key that travels as a header
357
+ * to a fixed host stays in an E2B header transform, and the run gets a placeholder and the system
358
+ * CA bundle that holds E2B's proxy CA, which Node does not read by default. Modal has no such
359
+ * transform, so a Modal runner receives every key as an environment variable.
360
+ */
361
+ export function runnerKeys(remote, env) {
362
+ const set = (name) => (env[name]?.trim() ?? "") !== "";
363
+ const unread = new Set(remote.alternatives.flatMap((names) => names.filter(set).slice(1)));
364
+ const forwarded = Object.fromEntries(remote.envNames.flatMap((name) => (set(name) && !unread.has(name) ? [[name, env[name]]] : [])));
365
+ const routing = { allowPublicTraffic: false };
366
+ if (remote.provider === "modal")
367
+ return { env: forwarded, network: routing, held: [], sent: Object.keys(forwarded).sort() };
368
+ const held = holdKeysInEgress(forwarded, { domain: forwarded.E2B_DOMAIN, existing: routing });
369
+ return {
370
+ env: held.held.length === 0
371
+ ? held.env
372
+ : { ...held.env, NODE_EXTRA_CA_CERTS: E2B_SYSTEM_CA_BUNDLE },
373
+ network: held.network,
374
+ held: held.held,
375
+ sent: Object.keys(forwarded)
376
+ .filter((name) => !held.held.includes(name))
377
+ .sort(),
378
+ };
379
+ }
380
+ /** Whether this humanish runs from a built package, which a runner can install. */
381
+ export function runsFromPackage() {
382
+ const here = fileURLToPath(import.meta.url);
383
+ const root = path.resolve(path.dirname(here), "..", "..");
384
+ return path.relative(root, here).split(path.sep)[0] === "dist";
385
+ }
333
386
  /**
334
387
  * This machine's humanish as an npm tarball, so the runner runs exactly the same code. A run from
335
388
  * source has no built package to pack.
@@ -337,7 +390,7 @@ export function watchSignals() {
337
390
  async function packThisHumanish() {
338
391
  const here = fileURLToPath(import.meta.url);
339
392
  const root = path.resolve(path.dirname(here), "..", "..");
340
- if (path.relative(root, here).split(path.sep)[0] !== "dist")
393
+ if (!runsFromPackage())
341
394
  throw new Error("A remote run installs this humanish in the runner, and a run from source has no built package. Run `pnpm build`, then `node dist/cli.js`, or use an installed humanish.");
342
395
  const dir = await mkdtemp(path.join(tmpdir(), "humanish-pack-"));
343
396
  const { stdout } = await promisify(execFile)("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", dir, root], { maxBuffer: 16 * 1024 * 1024 });
@@ -11,6 +11,8 @@ export interface RemotePlan {
11
11
  readonly uploadDirectories: readonly string[];
12
12
  /** The environment names the runner's run may receive, and nothing else. */
13
13
  readonly envNames: readonly string[];
14
+ /** Names of which only the first one set is sent, as the run reads only that one. */
15
+ readonly alternatives: readonly (readonly string[])[];
14
16
  }
15
17
  export type RemotePlanResult = {
16
18
  ok: true;
@@ -2,6 +2,7 @@
2
2
  // long it must live, which files it must receive and which keys it may hold. A pure function of the
3
3
  // study and its plan, so a refusal happens before anything is created.
4
4
  import path from "node:path";
5
+ import { analysisKeyNames } from "../keys/key-resolution.js";
5
6
  import { keyNamesOf } from "../study/requirements.js";
6
7
  import { participantList } from "../study/study-fields.js";
7
8
  /** Time the runner needs before the study starts: create, Node and humanish install, uploads. */
@@ -46,6 +47,8 @@ export function planRemoteRun(args) {
46
47
  return refuse("it loads a scorer module, which does not run remotely yet.");
47
48
  if (plan.rerun !== undefined)
48
49
  return refuse("a rerun reads a run on this machine.");
50
+ if (config.comms !== undefined)
51
+ return refuse("its email setup reads files and connections on this machine.");
49
52
  const study = inside(cwd, args.studyPath);
50
53
  if (study === undefined)
51
54
  return refuse("the study file is outside the project directory.");
@@ -72,6 +75,7 @@ export function planRemoteRun(args) {
72
75
  envNames: [
73
76
  ...new Set([...keyNamesOf(plan), ...PROVIDER_KEYS[runner.provider], ...PLAN_SETTINGS]),
74
77
  ].sort(),
78
+ alternatives: analystKeyNames(plan),
75
79
  },
76
80
  };
77
81
  }
@@ -112,3 +116,11 @@ function servedHere(appUrl) {
112
116
  (a === 192 && b === 168) ||
113
117
  (a === 169 && b === 254));
114
118
  }
119
+ /** The analyst's key names, when it reads the first one set of several. */
120
+ function analystKeyNames(plan) {
121
+ const analyst = plan.analysis?.config.provider;
122
+ if (analyst === undefined || analyst === "codex")
123
+ return [];
124
+ const names = analysisKeyNames(analyst);
125
+ return names.length > 1 ? [names] : [];
126
+ }
@@ -1,5 +1,10 @@
1
1
  import type { E2BDesktopSandbox } from "../substrates/e2b/sdk.js";
2
2
  import { type Shell } from "../substrates/shell.js";
3
+ /**
4
+ * Set in the runner's run, so any humanish there runs locally even when its study could run
5
+ * remotely: a runner that launched a runner would repeat without end.
6
+ */
7
+ export declare const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
3
8
  /** The port the runner's Observer listens on. */
4
9
  export declare const RUNNER_OBSERVER_PORT = 7000;
5
10
  /** A runner sandbox and the shell into it. */
@@ -11,6 +11,11 @@ import { tailOf } from "../substrates/shell.js";
11
11
  const RUNNER_PROJECT = "project";
12
12
  /** Where the runner keeps the launcher's own files: the result file and the run's output. */
13
13
  const RUNNER_STATE = ".humanish-remote";
14
+ /**
15
+ * Set in the runner's run, so any humanish there runs locally even when its study could run
16
+ * remotely: a runner that launched a runner would repeat without end.
17
+ */
18
+ export const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
14
19
  /** The port the runner's Observer listens on. */
15
20
  export const RUNNER_OBSERVER_PORT = 7000;
16
21
  /**
@@ -115,13 +120,20 @@ export async function startRemoteRun(runner, start) {
115
120
  start.runId,
116
121
  "--owner-key",
117
122
  start.ownerKey,
123
+ // The runner's run is the remote run: it must not launch a runner of its own.
124
+ "--local",
118
125
  ];
119
126
  const line = [
120
127
  `cd "$HOME/${RUNNER_PROJECT}"`,
121
128
  `export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH"`,
122
129
  `exec ${args.map((arg) => (arg.startsWith("$HOME") ? `"${arg}"` : shellQuote(arg))).join(" ")} > "$HOME/${RUNNER_STATE}/watch.out" 2> "$HOME/${RUNNER_STATE}/watch.err"`,
123
130
  ].join("; ");
124
- const env = { ...start.env, DO_NOT_TRACK: "1", HUMANISH_TELEMETRY_DISABLED: "1" };
131
+ const env = {
132
+ ...start.env,
133
+ DO_NOT_TRACK: "1",
134
+ HUMANISH_TELEMETRY_DISABLED: "1",
135
+ [INSIDE_RUNNER_ENV]: "1",
136
+ };
125
137
  const result = await runner.shell.start(`bash -c ${shellQuote(line)}`, {
126
138
  env,
127
139
  timeoutMs: 60_000,
@@ -1,4 +1,5 @@
1
- import { DEFAULT_RUNTIME_AUTH, E2B_SYSTEM_CA_BUNDLE, OPENAI_EGRESS_PLACEHOLDER, } from "./runtime-auth.js";
1
+ import { DEFAULT_RUNTIME_AUTH } from "./runtime-auth.js";
2
+ import { E2B_SYSTEM_CA_BUNDLE, EGRESS_PLACEHOLDER } from "../../substrates/e2b/egress-keys.js";
2
3
  import { TERMINAL_PRODUCT_STUDY_PROVIDER_METADATA } from "./types.js";
3
4
  /**
4
5
  * Where the study's runtime auth puts the key, as a sentence for the missing-key refusal. Typed
@@ -71,7 +72,7 @@ export function buildRuntimeAuth(args) {
71
72
  const envs = mode === "openai-egress"
72
73
  ? // Codex documents this verified-TLS trust channel. The stock image's default OpenSSL CA
73
74
  // file can be absent even though E2B has installed its proxy CA in the system bundle.
74
- { CODEX_API_KEY: OPENAI_EGRESS_PLACEHOLDER, CODEX_CA_CERTIFICATE: E2B_SYSTEM_CA_BUNDLE }
75
+ { CODEX_API_KEY: EGRESS_PLACEHOLDER, CODEX_CA_CERTIFICATE: E2B_SYSTEM_CA_BUNDLE }
75
76
  : keyName === "OPENAI_API_KEY"
76
77
  ? { CODEX_API_KEY: keyValue, OPENAI_API_KEY: keyValue }
77
78
  : { [keyName]: keyValue };
@@ -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
- }
@@ -31,8 +31,16 @@ export interface RunResult {
31
31
  /** The Observer rendered for this run, when `RunOptions.observer` asked for one. */
32
32
  observer?: ObserverResult;
33
33
  warnings: string[];
34
+ /**
35
+ * Set when the run executed in a runner sandbox: the provider, and the names of the keys this
36
+ * machine sent to the runner, in the operator's own account.
37
+ */
38
+ remote?: {
39
+ provider: string;
40
+ keys: string[];
41
+ };
34
42
  error?: {
35
- code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
43
+ code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_REMOTE_DETACHED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
36
44
  message: string;
37
45
  };
38
46
  }
@@ -174,6 +174,7 @@ export declare const ACTOR: {
174
174
  };
175
175
  export declare const EXECUTION: {
176
176
  target: true;
177
+ remote: true;
177
178
  runtime: {
178
179
  version: true;
179
180
  };
@@ -112,6 +112,7 @@ export const ACTOR = {
112
112
  };
113
113
  export const EXECUTION = {
114
114
  target: true,
115
+ remote: true,
115
116
  runtime: { version: true },
116
117
  runtimeAuth: true,
117
118
  concurrency: true,
@@ -23,6 +23,11 @@ export function parseExecution(raw) {
23
23
  }
24
24
  execution.target = target;
25
25
  }
26
+ if (raw.remote !== undefined) {
27
+ if (typeof raw.remote !== "boolean")
28
+ return invalid("`execution.remote` must be true or false.");
29
+ execution.remote = raw.remote;
30
+ }
26
31
  const timeoutMs = posInt(raw.timeoutMs);
27
32
  if (timeoutMs !== undefined)
28
33
  execution.timeoutMs = timeoutMs;
@@ -581,6 +581,12 @@ export type StudyNetworkCapture = boolean | {
581
581
  };
582
582
  export interface StudyExecution {
583
583
  target?: StudyExecutionTarget;
584
+ /**
585
+ * Where the study's own process runs. Unset: in a runner sandbox on the participants' provider
586
+ * when the study can, so it outlives this machine; false: always on this machine; true: only
587
+ * remotely, refused when the study cannot. `--local` and `--remote` override it for one run.
588
+ */
589
+ remote?: boolean;
584
590
  /** Actor session wall-clock budget. Consumed on the app-url route. */
585
591
  timeoutMs?: number;
586
592
  /** Forward-declared: no route reads it yet, so a set value warns. */
@@ -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
+ }
@@ -104,6 +104,9 @@ export async function launchRun(options) {
104
104
  cwd,
105
105
  "--json",
106
106
  "--no-open",
107
+ // The TUI follows and stops a run through its local status record, which a remote run does
108
+ // not have until it is copied back.
109
+ "--local",
107
110
  ...(options.mode === "dry-run" ? ["--dry-run"] : []),
108
111
  // `--` ends option parsing, so the handle can only ever be read as the positional argument.
109
112
  "--",
@@ -461,6 +461,19 @@ that screen is a strong design finding. Validation is unchanged. A design findin
461
461
  least one cited capture, so an impression plus the capture of its screen makes a design finding,
462
462
  and an impression alone supports only a finding about what the participant said.
463
463
 
464
+ ## Participant statement citations
465
+
466
+ An observation with basis `participant_statement` must cite at least one quote-eligible entry
467
+ (`analysis_validation_failed_statement_source_invalid` otherwise). It may also cite captures or
468
+ actions, as an `action` observation may cite more than its one required action. The prompt still
469
+ asks for statements that cite only quote-eligible entries; validation accepts an analyst that adds
470
+ the capture the participant spoke about. Validation does not check that the observation's wording
471
+ matches the quote. Entries of kind `reasoning` are quote-eligible, so a claim drawn from a
472
+ participant's private reasoning also passes as a statement. Before this rule, any non-quote
473
+ citation failed the whole analysis, which caused nearly all `gpt-6-luna` rejections on the
474
+ 2026-10-22 benchmark recordings
475
+ ([participant statements that cite captures](https://github.com/danielgwilson/humanish/blob/main/docs/evidence/benchmark/2026-10-22-statement-rule.md)).
476
+
464
477
  ## Durable records
465
478
 
466
479
  The `humanish.observer-data.v3` schema is unchanged by analysis. A companion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.143.0",
3
+ "version": "0.145.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -553,6 +553,15 @@ has finished. A participant with `endAfterMs` can keep a study running for up to
553
553
  analysis usually adds 1 to 4 minutes after the last participant ends. A shell timeout that sends
554
554
  SIGTERM stops the run: humanish marks it interrupted and stops its sandboxes.
555
555
 
556
+ - A computer-use study on `e2b-desktop` or `modal-desktop` with a public `app-url` runs remotely
557
+ by default: its own process runs in a sandbox on that provider, so a sleeping machine does not
558
+ stop it. `run` still blocks and exits with the run's code; SIGTERM still stops the run. Until its
559
+ bundle is copied back it is not in `runs --json`; `npx humanish observe --run <id>` follows it.
560
+ If its terminal closes, the command exits 129 with `HUMANISH_REMOTE_DETACHED` and the run goes
561
+ on; `observe --run <id>` copies it back. `--local` keeps one run on this machine,
562
+ `execution.remote: false` every run of a study. The JSON result's `remote.keys` names the keys
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.
556
565
  - Start a study that can outlive your shell call in tmux, for example
557
566
  `tmux new -d -s study 'npx humanish run <study> --json --no-open > run-result.json 2> run.log'`.
558
567
  `--detach` only skips the Observer server at the end; it does not move the run to the background.