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.
- package/CHANGELOG.md +22 -0
- package/dist/analysis/models.d.ts +2 -0
- package/dist/analysis/models.js +15 -0
- package/dist/analysis/openai-wire.d.ts +1 -1
- package/dist/analysis/openai-wire.js +36 -26
- package/dist/analysis/validation.js +1 -1
- package/dist/cli/commands/run-command.js +2 -1
- package/dist/cli/commands/study-remote.d.ts +37 -3
- package/dist/cli/commands/study-remote.js +118 -52
- package/dist/cli/commands/study-route-run.d.ts +3 -2
- package/dist/cli/commands/study-route-run.js +7 -2
- package/dist/cli/commands/study-run.js +19 -9
- package/dist/cli/io.d.ts +2 -0
- package/dist/keys/key-resolution.js +2 -2
- package/dist/remote/launch.d.ts +31 -1
- package/dist/remote/launch.js +77 -24
- package/dist/remote/plan.d.ts +2 -0
- package/dist/remote/plan.js +12 -0
- package/dist/remote/runner.d.ts +5 -0
- package/dist/remote/runner.js +13 -1
- package/dist/routes/terminal/credentials.js +3 -2
- package/dist/routes/terminal/live-sandbox.js +2 -2
- package/dist/routes/terminal/runtime-auth.d.ts +0 -12
- package/dist/routes/terminal/runtime-auth.js +0 -23
- package/dist/run/results.d.ts +9 -1
- package/dist/study/keys.d.ts +1 -0
- package/dist/study/keys.js +1 -0
- package/dist/study/parse/execution.js +5 -0
- package/dist/study/types.d.ts +6 -0
- package/dist/substrates/e2b/egress-keys.d.ts +22 -0
- package/dist/substrates/e2b/egress-keys.js +59 -0
- package/dist/tui/launch.js +3 -0
- package/docs/contracts/study-analysis.md +13 -0
- package/package.json +1 -1
- 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`. */
|
package/dist/analysis/models.js
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{ type: "input_text", text:
|
|
85
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
.
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 {
|
|
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() ===
|
|
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))
|
package/dist/remote/launch.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/remote/launch.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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 }).
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 (
|
|
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 });
|
package/dist/remote/plan.d.ts
CHANGED
|
@@ -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;
|
package/dist/remote/plan.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/remote/runner.d.ts
CHANGED
|
@@ -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. */
|
package/dist/remote/runner.js
CHANGED
|
@@ -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 = {
|
|
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
|
|
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:
|
|
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 {
|
|
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
|
-
?
|
|
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
|
-
}
|
package/dist/run/results.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/study/keys.d.ts
CHANGED
package/dist/study/keys.js
CHANGED
|
@@ -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;
|
package/dist/study/types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/tui/launch.js
CHANGED
|
@@ -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
package/skills/humanish/SKILL.md
CHANGED
|
@@ -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.
|