humanish 0.144.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ 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
+
11
22
  ## 0.144.0: Hosted studies run remotely by default (2026-10-22)
12
23
 
13
24
  humanish 0.144.0 runs a hosted computer-use study remotely by default. A study on
@@ -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
  };
@@ -1,5 +1,5 @@
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
3
  import { type RemotePlan } from "../../remote/plan.js";
4
4
  import type { RunResult } from "../../run/results.js";
5
5
  import type { StudyConfig } from "../../study/types.js";
@@ -40,6 +40,8 @@ export declare function runRemoteStudy(args: {
40
40
  studyPath: string;
41
41
  remote: RemotePlan;
42
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;
43
45
  /** The command's result and exit code for a remote run that ended, stopped or detached. */
44
46
  export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome, forwarded?: NonNullable<RunResult["remote"]>): void;
45
47
  export {};
@@ -1,7 +1,7 @@
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 { forwardedEnv, launchRemoteRun, runsFromPackage, } from "../../remote/launch.js";
4
+ import { launchRemoteRun, runnerKeys, runsFromPackage, } from "../../remote/launch.js";
5
5
  import { planRemoteRun } from "../../remote/plan.js";
6
6
  import { INSIDE_RUNNER_ENV } from "../../remote/runner.js";
7
7
  import { planStudy, resolveStudyDryRun } from "../../study/plan.js";
@@ -99,11 +99,12 @@ export async function runRemoteStudy(args) {
99
99
  refuseRemote(args.command, io, cwd, "--port must be an integer between 0 and 65535.");
100
100
  return;
101
101
  }
102
+ const keys = runnerKeys(args.remote, process.env);
102
103
  const forwarded = {
103
104
  provider: args.remote.provider,
104
- keys: Object.keys(forwardedEnv(args.remote, process.env)).sort(),
105
+ keys: [...keys.held, ...keys.sent].sort(),
105
106
  };
106
- io.writeErr(`The runner receives ${forwarded.keys.join(", ") || "no keys"} from this machine, in your own ${forwarded.provider === "modal" ? "Modal" : "E2B"} account. Run with --local to keep the study on this machine.\n`);
107
+ io.writeErr(`${runnerKeysLine(args.remote.provider, keys)}\n`);
107
108
  let outcome;
108
109
  try {
109
110
  outcome = await launchRemoteRun({
@@ -121,6 +122,20 @@ export async function runRemoteStudy(args) {
121
122
  }
122
123
  reportRemoteOutcome(args.command, io, cwd, outcome, forwarded);
123
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}`;
138
+ }
124
139
  /** The run options a remote run does not carry to the runner yet. */
125
140
  function localOnlyFlags(options) {
126
141
  return [
@@ -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. */
@@ -59,10 +60,27 @@ export interface Signals {
59
60
  * running; the runner ends it at its deadline.
60
61
  */
61
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
+ }
62
77
  /**
63
- * The names and values the runner's run receives: each of the plan's names that is set here, and of
64
- * names the run reads only the first of, only the first one set.
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.
65
83
  */
66
- export declare function forwardedEnv(remote: Pick<RemotePlan, "envNames" | "alternatives">, env: Readonly<Record<string, string | undefined>>): Record<string, string>;
84
+ export declare function runnerKeys(remote: Pick<RemotePlan, "provider" | "envNames" | "alternatives">, env: Readonly<Record<string, string | undefined>>): RunnerKeys;
67
85
  /** Whether this humanish runs from a built package, which a runner can install. */
68
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";
@@ -60,7 +61,8 @@ export async function launchRemoteRun(launch) {
60
61
  return { runId, exitCode: signals.exitCode, error: "stopped before the runner was created" };
61
62
  const where = remote.provider === "modal" ? "a Modal sandbox" : "an E2B sandbox";
62
63
  say(`Running this study on ${where} as run ${runId}; creating the runner.`);
63
- 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);
64
66
  if (signals.requested !== undefined)
65
67
  return await endRun(launch, runId, {
66
68
  exitCode: signals.exitCode,
@@ -95,7 +97,7 @@ export async function launchRemoteRun(launch) {
95
97
  study: launch.study,
96
98
  runId,
97
99
  ownerKey,
98
- env: forwardedEnv(remote, launch.env),
100
+ env: keys.env,
99
101
  });
100
102
  return await followRemoteRun({ launch, runner, paths, runId, signals, proxyUrl: proxy.url });
101
103
  }
@@ -112,7 +114,7 @@ export async function launchRemoteRun(launch) {
112
114
  await rm(tarballDir, { recursive: true, force: true });
113
115
  }
114
116
  }
115
- async function acquireRunner(launch, paths) {
117
+ async function acquireRunner(launch, paths, network) {
116
118
  const { provider, runnerMs } = launch.remote;
117
119
  const module = provider === "modal" ? await loadModalShellModule(launch.env) : await loadE2BBaseModule();
118
120
  // A Modal module takes its connection from launch.env and ignores these two.
@@ -125,7 +127,7 @@ async function acquireRunner(launch, paths) {
125
127
  ...account,
126
128
  timeoutMs: runnerMs,
127
129
  metadata: { participantId: "runner" },
128
- network: { allowPublicTraffic: false },
130
+ network,
129
131
  },
130
132
  receipt: { root: paths, participantId: "runner" },
131
133
  });
@@ -350,13 +352,30 @@ export function watchSignals() {
350
352
  };
351
353
  }
352
354
  /**
353
- * The names and values the runner's run receives: each of the plan's names that is set here, and of
354
- * names the run reads only the first of, only the first one set.
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.
355
360
  */
356
- export function forwardedEnv(remote, env) {
361
+ export function runnerKeys(remote, env) {
357
362
  const set = (name) => (env[name]?.trim() ?? "") !== "";
358
363
  const unread = new Set(remote.alternatives.flatMap((names) => names.filter(set).slice(1)));
359
- return Object.fromEntries(remote.envNames.flatMap((name) => (set(name) && !unread.has(name) ? [[name, env[name]]] : [])));
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
+ };
360
379
  }
361
380
  /** Whether this humanish runs from a built package, which a runner can install. */
362
381
  export function runsFromPackage() {
@@ -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
- }
@@ -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
+ }
@@ -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.144.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",
@@ -560,7 +560,8 @@ SIGTERM stops the run: humanish marks it interrupted and stops its sandboxes.
560
560
  If its terminal closes, the command exits 129 with `HUMANISH_REMOTE_DETACHED` and the run goes
561
561
  on; `observe --run <id>` copies it back. `--local` keeps one run on this machine,
562
562
  `execution.remote: false` every run of a study. The JSON result's `remote.keys` names the keys
563
- the runner received.
563
+ the run used. On E2B the OpenAI, Gemini and E2B keys stay in E2B's egress proxy and the runner
564
+ holds placeholders; a Modal runner receives them as environment variables.
564
565
  - Start a study that can outlive your shell call in tmux, for example
565
566
  `tmux new -d -s study 'npx humanish run <study> --json --no-open > run-result.json 2> run.log'`.
566
567
  `--detach` only skips the Observer server at the end; it does not move the run to the background.