humanish 0.137.0 → 0.138.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 +11 -0
- package/dist/cli/commands/runs.js +2 -2
- package/dist/cli/doctor.js +24 -0
- package/dist/keys/key-resolution.js +53 -2
- package/dist/routes/terminal/live-sandbox.d.ts +2 -0
- package/dist/routes/terminal/live-sandbox.js +8 -3
- package/dist/routes/terminal/plan.js +49 -6
- package/dist/routes/terminal/session.js +8 -1
- package/dist/run/bundle.d.ts +2 -1
- package/dist/run/cost-summary.js +9 -4
- package/dist/run/pricing.d.ts +11 -0
- package/dist/run/pricing.js +18 -5
- package/dist/run/reclaim-outcomes.d.ts +4 -1
- package/dist/run/reclaim-outcomes.js +8 -6
- package/dist/run/reclaim.d.ts +4 -1
- package/dist/run/reclaim.js +81 -34
- package/dist/run/sandbox-creates.d.ts +7 -4
- package/dist/run/sandbox-creates.js +17 -9
- package/dist/run/sandbox-receipts.d.ts +13 -5
- package/dist/run/sandbox-receipts.js +18 -6
- package/dist/study/composition-rules.js +11 -10
- package/dist/study/doctor.js +1 -1
- package/dist/study/parse/execution.js +5 -2
- package/dist/study/plan-types.d.ts +4 -1
- package/dist/study/requirements.js +7 -2
- package/dist/study/routing.d.ts +1 -1
- package/dist/study/routing.js +1 -1
- package/dist/study/types.d.ts +1 -1
- package/dist/substrates/e2b/desktop-resources.d.ts +4 -2
- package/dist/substrates/e2b/desktop-resources.js +2 -2
- package/dist/substrates/e2b/peer-install.js +1 -1
- package/dist/substrates/e2b/sandbox.d.ts +3 -0
- package/dist/substrates/e2b/sandbox.js +5 -4
- package/dist/substrates/modal/image.d.ts +9 -0
- package/dist/substrates/modal/image.js +36 -0
- package/dist/substrates/modal/sandbox.d.ts +26 -0
- package/dist/substrates/modal/sandbox.js +367 -0
- package/dist/substrates/modal/sdk.d.ts +112 -0
- package/dist/substrates/modal/sdk.js +61 -0
- package/docs/architecture/terminal-product-route.md +1 -1
- package/docs/contracts/run-bundle.md +4 -2
- package/docs/contracts/schemas.md +6 -5
- package/package.json +8 -2
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.138.0: A terminal study can run on Modal (2026-10-19)
|
|
12
|
+
|
|
13
|
+
humanish 0.138.0 can run a terminal study's shell on Modal as well as E2B. Set
|
|
14
|
+
`execution.target: modal-terminal` and `execution.runtimeAuth: openai-env`, sign in with
|
|
15
|
+
`modal token new`, and install the optional `modal` package beside humanish. The sandbox runs as a
|
|
16
|
+
non-root user with sudo, as on E2B, lives up to Modal's 24 hours, is journaled and reclaimed by id
|
|
17
|
+
like an E2B sandbox, and is priced at Modal's sandbox rate. Desktop studies still run on E2B or a
|
|
18
|
+
local VM.
|
|
19
|
+
|
|
20
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.138.0)
|
|
21
|
+
|
|
11
22
|
## 0.137.0: Each browser participant can record its requests (2026-10-16)
|
|
12
23
|
|
|
13
24
|
humanish 0.137.0 can record the requests each hosted browser participant makes. With
|
|
@@ -203,12 +203,12 @@ export function registerRuntimeCommands(parent, io) {
|
|
|
203
203
|
export function registerReclaimCommand(parent, io) {
|
|
204
204
|
parent
|
|
205
205
|
.command("reclaim")
|
|
206
|
-
.description("Kill an interrupted run's sandboxes: the exact ids journaled in its sandbox-receipts.ndjson, and any sandbox
|
|
206
|
+
.description("Kill an interrupted run's sandboxes: the exact ids journaled in its sandbox-receipts.ndjson, and any sandbox the run's provider lists with this run's owner tags (a sandbox whose id never reached a receipt). It lists only sandboxes that match every tag of this run. --check asks the provider whether each still exists and kills nothing. Needs E2B_API_KEY, or MODAL_TOKEN_ID and MODAL_TOKEN_SECRET for a run on Modal, in the environment or in --dotenv.")
|
|
207
207
|
.summary("Stop or check an interrupted run's sandboxes.")
|
|
208
208
|
.option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
|
|
209
209
|
.option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
|
|
210
210
|
.addOption(new Option("--preflight", "Reclaim sandboxes left by interrupted `humanish study check` probes (journaled in .humanish/preflight) instead of a run's.").conflicts("run"))
|
|
211
|
-
.option("--check", "Ask
|
|
211
|
+
.option("--check", "Ask the provider whether each sandbox still exists; kill nothing and write nothing. Exits 0 only when all are gone.")
|
|
212
212
|
.option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
|
|
213
213
|
.addOption(envFileAliasOption())
|
|
214
214
|
.option("--json", JSON_OPTION_DESCRIPTION)
|
package/dist/cli/doctor.js
CHANGED
|
@@ -96,6 +96,7 @@ export async function doctor(cwdInput, options = {}) {
|
|
|
96
96
|
const checks = [
|
|
97
97
|
...(await projectChecks(projectRoot)),
|
|
98
98
|
await desktopSdkCheck(setup),
|
|
99
|
+
...(setup?.keys.includes("MODAL_TOKEN_ID") === true ? [await modalSdkCheck()] : []),
|
|
99
100
|
sandboxCeilingCheck(env),
|
|
100
101
|
concurrentSandboxesCheck(env),
|
|
101
102
|
terminalSurfaceCheck(),
|
|
@@ -239,6 +240,25 @@ async function desktopSdkCheck(setup) {
|
|
|
239
240
|
: missingDesktopSdkMessage(humanishInstall()),
|
|
240
241
|
};
|
|
241
242
|
}
|
|
243
|
+
/** The optional `modal` peer, reported only for a study that runs on Modal, which needs it. */
|
|
244
|
+
async function modalSdkCheck() {
|
|
245
|
+
const present = await safeCheck(async () => {
|
|
246
|
+
try {
|
|
247
|
+
await import("modal");
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
name: "modal sdk",
|
|
256
|
+
ok: present,
|
|
257
|
+
message: present
|
|
258
|
+
? "optional peer modal is installed; provider access is not tested"
|
|
259
|
+
: `optional peer modal is absent, and this study runs on Modal. ${desktopPeerAdvice(humanishInstall(), "modal").advice}`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
242
262
|
/**
|
|
243
263
|
* The stakeholder surface. Reported as capability, never as a gate: the TUI is optional,
|
|
244
264
|
* and `doctor` is itself mostly run by agents through a pipe, where a TTY requirement says
|
|
@@ -288,6 +308,10 @@ async function probeDoctorKeys(cwd, env, study, keyDeps, dotenv) {
|
|
|
288
308
|
if (study) {
|
|
289
309
|
const { resolveStudyManifest } = await import("../study/discover.js");
|
|
290
310
|
const resolved = await resolveStudyManifest(cwd, study);
|
|
311
|
+
// A study on Modal reads the Modal token pair, which no other study needs.
|
|
312
|
+
if (resolved.ok && resolved.config.execution?.target === "modal-terminal")
|
|
313
|
+
for (const name of ["MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"])
|
|
314
|
+
keyNames.add(name);
|
|
291
315
|
if (resolved.ok && resolved.config.comms?.email?.kind === "real") {
|
|
292
316
|
const { receivingRequiredKey } = await import("../comms/setup.js");
|
|
293
317
|
receivingKey = await receivingRequiredKey(cwd, resolved.config.comms.email.connection);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// examples document;
|
|
8
8
|
// 3. the owning vendor's native store, where one exists:
|
|
9
9
|
// E2B_API_KEY <- ~/.e2b/config.json (written by `e2b auth login`);
|
|
10
|
+
// MODAL_TOKEN_ID, MODAL_TOKEN_SECRET <- ~/.modal.toml (written by `modal token new`);
|
|
10
11
|
// GH_TOKEN <- `gh auth token` (the gh CLI's credential chain);
|
|
11
12
|
// 4. the humanish user-level store `$XDG_CONFIG_HOME/humanish/keys.env`, written only by
|
|
12
13
|
// `humanish keys set` (0600, prompted or --stdin), the fallback for vendors that ship
|
|
@@ -20,6 +21,7 @@ import { spawn } from "node:child_process";
|
|
|
20
21
|
import { chmodSync, closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, writeSync, } from "node:fs";
|
|
21
22
|
import { homedir } from "node:os";
|
|
22
23
|
import path from "node:path";
|
|
24
|
+
import { parse as parseToml } from "smol-toml";
|
|
23
25
|
import { loadEnvFile } from "./env-file.js";
|
|
24
26
|
import { cli } from "../cli/invocation.js";
|
|
25
27
|
import { humanishConfigFile } from "../cli/user-config.js";
|
|
@@ -48,6 +50,14 @@ const PROVIDER_KEYS = [
|
|
|
48
50
|
listed: { order: 1, use: "hosted desktops" },
|
|
49
51
|
doctor: true,
|
|
50
52
|
},
|
|
53
|
+
{
|
|
54
|
+
name: "MODAL_TOKEN_ID",
|
|
55
|
+
vendor: "modal",
|
|
56
|
+
login: "modal token new",
|
|
57
|
+
},
|
|
58
|
+
{ name: "MODAL_TOKEN_SECRET", login: "modal token new" },
|
|
59
|
+
// Which Modal environment sandboxes are created in; not a secret, but it names the account.
|
|
60
|
+
{ name: "MODAL_ENVIRONMENT" },
|
|
51
61
|
{ name: "ANTHROPIC_API_KEY", vendor: "anthropic" },
|
|
52
62
|
{
|
|
53
63
|
name: "GH_TOKEN",
|
|
@@ -81,6 +91,36 @@ export function userKeyStorePath(env, deps = {}) {
|
|
|
81
91
|
function e2bConfigPath(deps) {
|
|
82
92
|
return path.join(deps.homeDir ?? homedir(), ".e2b", "config.json");
|
|
83
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The token pair and environment of the Modal CLI's profile, as Modal picks it: MODAL_PROFILE
|
|
96
|
+
* when set, else the profile marked `active`, else the only profile. Null when there is none.
|
|
97
|
+
*/
|
|
98
|
+
function readModalProfile(env, deps) {
|
|
99
|
+
try {
|
|
100
|
+
const file = env.MODAL_CONFIG_PATH?.trim() || path.join(deps.homeDir ?? homedir(), ".modal.toml");
|
|
101
|
+
if (!isRegularFile(file))
|
|
102
|
+
return null;
|
|
103
|
+
const profiles = [];
|
|
104
|
+
for (const [name, values] of Object.entries(parseToml(readFileSync(file, "utf8"))))
|
|
105
|
+
if (values !== null && typeof values === "object" && !Array.isArray(values))
|
|
106
|
+
profiles.push([name, values]);
|
|
107
|
+
const named = env.MODAL_PROFILE?.trim();
|
|
108
|
+
const profile = named
|
|
109
|
+
? profiles.find(([name]) => name === named)?.[1]
|
|
110
|
+
: (profiles.find(([, values]) => values.active === true)?.[1] ??
|
|
111
|
+
(profiles.length === 1 ? profiles[0]?.[1] : undefined));
|
|
112
|
+
const text = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
113
|
+
const tokenId = text(profile?.token_id);
|
|
114
|
+
const tokenSecret = text(profile?.token_secret);
|
|
115
|
+
if (tokenId === undefined || tokenSecret === undefined)
|
|
116
|
+
return null;
|
|
117
|
+
const environment = text(profile?.environment);
|
|
118
|
+
return { tokenId, tokenSecret, ...(environment === undefined ? {} : { environment }) };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
84
124
|
/** Default exec: bounded, quiet, stdin closed; null on any failure. Never throws. */
|
|
85
125
|
function defaultExecText(command, args, timeoutMs, env) {
|
|
86
126
|
return new Promise((resolve) => {
|
|
@@ -205,7 +245,18 @@ export async function discoverProviderKeys(args) {
|
|
|
205
245
|
if (teamKey !== null)
|
|
206
246
|
fill("E2B_API_KEY", teamKey, "~/.e2b/config.json (e2b auth login)");
|
|
207
247
|
}
|
|
208
|
-
// Rung 3b: the
|
|
248
|
+
// Rung 3b: the modal CLI's profile. Both tokens come from the same profile or neither does.
|
|
249
|
+
if (fillable("MODAL_TOKEN_ID") && fillable("MODAL_TOKEN_SECRET")) {
|
|
250
|
+
const profile = readModalProfile(env, deps);
|
|
251
|
+
if (profile !== null) {
|
|
252
|
+
const source = "~/.modal.toml (modal token new)";
|
|
253
|
+
fill("MODAL_TOKEN_ID", profile.tokenId, source);
|
|
254
|
+
fill("MODAL_TOKEN_SECRET", profile.tokenSecret, source);
|
|
255
|
+
if (profile.environment !== undefined && fillable("MODAL_ENVIRONMENT"))
|
|
256
|
+
fill("MODAL_ENVIRONMENT", profile.environment, source);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Rung 3c: the gh CLI's credential chain. Only consulted when neither GitHub env name is
|
|
209
260
|
// present; fills GH_TOKEN (the name humanish reads first). gh runs without any provider key in
|
|
210
261
|
// its environment, so the keys the rungs above just filled stay in this process.
|
|
211
262
|
if (fillable("GH_TOKEN") && fillable("GITHUB_TOKEN")) {
|
|
@@ -265,7 +316,7 @@ export function describeMissingKeys(names, env) {
|
|
|
265
316
|
const strict = env.HUMANISH_STRICT_KEYS?.trim() === "1";
|
|
266
317
|
const chain = strict
|
|
267
318
|
? "key discovery is disabled (HUMANISH_STRICT_KEYS=1); only process env and --dotenv are read"
|
|
268
|
-
: `also checked ${PROJECT_OVERLAY_RELATIVE}, ~/.e2b/config.json, gh auth token, and ${path.join("~", ".config", "humanish", "keys.env")}`;
|
|
319
|
+
: `also checked ${PROJECT_OVERLAY_RELATIVE}, ~/.e2b/config.json, ~/.modal.toml, gh auth token, and ${path.join("~", ".config", "humanish", "keys.env")}`;
|
|
269
320
|
return `${chain}. Fill: ${hints}.`;
|
|
270
321
|
}
|
|
271
322
|
/**
|
|
@@ -26,6 +26,8 @@ export interface LiveSandboxInputs {
|
|
|
26
26
|
verdictNonce: string;
|
|
27
27
|
maxMinutes: number;
|
|
28
28
|
e2bApiKey: string;
|
|
29
|
+
/** The run's env; a Modal run reads its Modal credentials from it. */
|
|
30
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
29
31
|
runPaths: StartedRun["paths"];
|
|
30
32
|
metadata: ReturnType<typeof buildSandboxMetadata>;
|
|
31
33
|
/** The run's warnings. Steps append to them. */
|
|
@@ -11,6 +11,7 @@ import { extractLocalActorVerdict, normalizeLocalActorTranscript, } from "../../
|
|
|
11
11
|
import { NODE_BOOTSTRAP_COMMAND, NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
|
|
12
12
|
import { observeDesktopResources, } from "../../substrates/e2b/desktop-resources.js";
|
|
13
13
|
import { acquireE2BShellSandbox } from "../../substrates/e2b/sandbox.js";
|
|
14
|
+
import { loadModalShellModule } from "../../substrates/modal/sandbox.js";
|
|
14
15
|
import { e2bAccount } from "../../substrates/e2b/connection.js";
|
|
15
16
|
import { E2BDesktopStartupError, loadE2BDesktopModule, } from "../../substrates/e2b/sdk.js";
|
|
16
17
|
import { shellQuote } from "../../substrates/shell.js";
|
|
@@ -60,7 +61,10 @@ export class LiveTerminalSandbox {
|
|
|
60
61
|
const { requestTimeoutMs, sandboxTimeoutMs } = this;
|
|
61
62
|
// Declared egress allowlist, or undefined for the historical unrestricted default.
|
|
62
63
|
const egressAllow = plan.egressAllow;
|
|
63
|
-
const
|
|
64
|
+
const provider = plan.sandboxProvider;
|
|
65
|
+
const providerName = provider === "modal" ? "Modal" : "E2B";
|
|
66
|
+
const sandboxModule = await (deps.desktopModule ??
|
|
67
|
+
(provider === "modal" ? () => loadModalShellModule(this.inputs.env) : loadE2BDesktopModule))();
|
|
64
68
|
await validatePreparedRunArtifactPaths(runPaths);
|
|
65
69
|
// No sandbox-global env in either mode. In openai-egress, only this host-side SDK request
|
|
66
70
|
// carries the real runtime key; participant commands receive an inert placeholder. The proxy
|
|
@@ -83,6 +87,7 @@ export class LiveTerminalSandbox {
|
|
|
83
87
|
this.account = e2bAccount(sandboxModule, options);
|
|
84
88
|
const acquired = await acquireE2BShellSandbox({
|
|
85
89
|
module: sandboxModule,
|
|
90
|
+
provider,
|
|
86
91
|
options,
|
|
87
92
|
retry: {
|
|
88
93
|
// A failed first attempt may have allocated a sandbox whose id never reached this run;
|
|
@@ -103,8 +108,8 @@ export class LiveTerminalSandbox {
|
|
|
103
108
|
this.sandboxId = sandboxId;
|
|
104
109
|
this.createdAtMs = now();
|
|
105
110
|
await validatePreparedRunArtifactPaths(runPaths);
|
|
106
|
-
recordLifecycle("terminal-lab.sandbox.created",
|
|
107
|
-
const sandboxResources = await observeDesktopResources(sandbox);
|
|
111
|
+
recordLifecycle("terminal-lab.sandbox.created", `${providerName} shell sandbox ${sandboxId} created with allowlisted metadata and kill-on-timeout, and with no sandbox-wide environment variables.`);
|
|
112
|
+
const sandboxResources = await observeDesktopResources(sandbox, undefined, provider === "modal" ? "modal.create" : "e2b.getInfo");
|
|
108
113
|
this.resources = sandboxResources;
|
|
109
114
|
if ("reason" in sandboxResources) {
|
|
110
115
|
warnings.push(`Sandbox resource size unavailable (${sandboxResources.reason}); its compute cost remains unpriced.`);
|
|
@@ -9,6 +9,7 @@ import { isNonEmpty, planBase } from "../../study/plan-base.js";
|
|
|
9
9
|
import { studyUrlCredentialReason } from "../../study/url-credentials.js";
|
|
10
10
|
import { desktopMediaValidationReason, networkCaptureValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
|
|
11
11
|
import { ceilingAdvice } from "../../substrates/e2b/lifetime.js";
|
|
12
|
+
import { MODAL_MAX_SANDBOX_MS, MODAL_TOKEN_ID_ENV, MODAL_TOKEN_SECRET_ENV, } from "../../substrates/modal/sdk.js";
|
|
12
13
|
import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
|
|
13
14
|
import { terminalSandboxTimeoutMs } from "./lifetime.js";
|
|
14
15
|
import { isExactRuntimeVersion, TERMINAL_RUNTIME_VERSION_TIMEOUT_MS } from "./runtime.js";
|
|
@@ -34,6 +35,10 @@ export function planTerminalStudy(config, input) {
|
|
|
34
35
|
const runtimeAuth = config.execution?.runtimeAuth;
|
|
35
36
|
if (runtimeAuth !== undefined && runtimeAuth !== "openai-egress" && runtimeAuth !== "openai-env")
|
|
36
37
|
return refuse("HUMANISH_TERMINAL_CREDENTIAL_DENIED", "`execution.runtimeAuth` must be openai-egress or openai-env. Leave it out for openai-egress, which keeps the OpenAI key outside the sandbox.");
|
|
38
|
+
const sandboxProvider = config.execution?.target === "modal-terminal" ? "modal" : "e2b";
|
|
39
|
+
const modal = sandboxProvider === "modal" ? modalRefusal(config) : undefined;
|
|
40
|
+
if (modal !== undefined)
|
|
41
|
+
return refuse(modal.code, modal.message);
|
|
37
42
|
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
38
43
|
if (!analysis.ok)
|
|
39
44
|
return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
|
|
@@ -64,6 +69,7 @@ export function planTerminalStudy(config, input) {
|
|
|
64
69
|
const shared = {
|
|
65
70
|
...base,
|
|
66
71
|
route: "terminal",
|
|
72
|
+
sandboxProvider,
|
|
67
73
|
actor: descriptor.id,
|
|
68
74
|
product: { ...product, publicSurfaces: surfaces },
|
|
69
75
|
...(actor?.persona === undefined ? {} : { personaId: actor.persona }),
|
|
@@ -107,14 +113,16 @@ export function planTerminalStudy(config, input) {
|
|
|
107
113
|
// The sandbox's timeout covers the steps before the codex command, maxMinutes and the teardown
|
|
108
114
|
// buffer, and E2B refuses a sandbox past the plan's ceiling. Refuse here, with the arithmetic,
|
|
109
115
|
// rather than after the plan prints, from a provider 400 that names neither knob.
|
|
110
|
-
const ceiling =
|
|
116
|
+
const ceiling = sandboxProvider === "modal"
|
|
117
|
+
? { ok: true, ms: MODAL_MAX_SANDBOX_MS, source: "default" }
|
|
118
|
+
: input.sandboxCeiling;
|
|
111
119
|
if (!ceiling.ok)
|
|
112
120
|
return refuse("HUMANISH_TERMINAL_CAPS_INVALID", ceiling.message, descriptor.id);
|
|
113
121
|
const productInstall = shared.product.install !== undefined;
|
|
114
122
|
const sandboxTimeoutMs = terminalSandboxTimeoutMs({ maxMinutes, productInstall });
|
|
115
123
|
if (sandboxTimeoutMs > ceiling.ms) {
|
|
116
124
|
const headroomMinutes = (sandboxTimeoutMs - maxMinutes * 60_000) / 60_000;
|
|
117
|
-
return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${ceiling.ms / 60_000}m. The deadline is maxMinutes plus ${headroomMinutes}m: the Node bootstrap (${NODE_BOOTSTRAP_TIMEOUT_MS / 60_000}m), the runtime version check (${TERMINAL_RUNTIME_VERSION_TIMEOUT_MS / 60_000}m)${productInstall ? `, the product setup (${PRODUCT_SETUP_TIMEOUT_MS / 60_000}m)` : ""} and the teardown buffer (${TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS / 60_000}m). Lower caps.maxMinutes to at most ${ceiling.ms / 60_000 - headroomMinutes}.${ceilingAdvice(sandboxTimeoutMs)} No sandbox was created and the runtime key was not used.`, descriptor.id);
|
|
125
|
+
return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${ceiling.ms / 60_000}m. The deadline is maxMinutes plus ${headroomMinutes}m: the Node bootstrap (${NODE_BOOTSTRAP_TIMEOUT_MS / 60_000}m), the runtime version check (${TERMINAL_RUNTIME_VERSION_TIMEOUT_MS / 60_000}m)${productInstall ? `, the product setup (${PRODUCT_SETUP_TIMEOUT_MS / 60_000}m)` : ""} and the teardown buffer (${TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS / 60_000}m). Lower caps.maxMinutes to at most ${ceiling.ms / 60_000 - headroomMinutes}.${sandboxProvider === "modal" ? " Modal ends every sandbox after 24 hours." : ceilingAdvice(sandboxTimeoutMs)} No sandbox was created and the runtime key was not used.`, descriptor.id);
|
|
118
126
|
}
|
|
119
127
|
return {
|
|
120
128
|
ok: true,
|
|
@@ -122,10 +130,45 @@ export function planTerminalStudy(config, input) {
|
|
|
122
130
|
...shared,
|
|
123
131
|
dryRun: false,
|
|
124
132
|
caps: { ...caps, maxUsd, maxMinutes },
|
|
125
|
-
requirements:
|
|
126
|
-
{ kind: "key", name: "E2B_API_KEY" },
|
|
127
|
-
{ kind: "key-one-of", names: ["CODEX_API_KEY", "OPENAI_API_KEY"] },
|
|
128
|
-
],
|
|
133
|
+
requirements: liveRequirements(sandboxProvider),
|
|
129
134
|
},
|
|
130
135
|
};
|
|
131
136
|
}
|
|
137
|
+
/** The sandbox provider's keys, then the runtime key the agent's command gets. */
|
|
138
|
+
function liveRequirements(provider) {
|
|
139
|
+
return [
|
|
140
|
+
...(provider === "modal"
|
|
141
|
+
? [
|
|
142
|
+
{ kind: "key", name: MODAL_TOKEN_ID_ENV },
|
|
143
|
+
{ kind: "key", name: MODAL_TOKEN_SECRET_ENV },
|
|
144
|
+
]
|
|
145
|
+
: [{ kind: "key", name: "E2B_API_KEY" }]),
|
|
146
|
+
{ kind: "key-one-of", names: ["CODEX_API_KEY", "OPENAI_API_KEY"] },
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
/** What a terminal study on Modal cannot have that one on E2B can. */
|
|
150
|
+
function modalRefusal(config) {
|
|
151
|
+
// E2B adds the key to requests at its egress proxy for each sandbox. Modal does that only from a
|
|
152
|
+
// Secret stored in the operator's workspace, so humanish does not place the key there itself.
|
|
153
|
+
if (config.execution?.runtimeAuth !== "openai-env")
|
|
154
|
+
return {
|
|
155
|
+
code: "HUMANISH_TERMINAL_CREDENTIAL_DENIED",
|
|
156
|
+
message: "On Modal, humanish cannot keep the OpenAI key outside the sandbox: Modal adds a key to outbound requests only from a Secret stored in your Modal workspace. Set `execution.runtimeAuth: openai-env`, which passes the key to the codex command only, or use `execution.target: e2b-terminal`.",
|
|
157
|
+
};
|
|
158
|
+
const egress = modalEgressReason(config.execution?.egressAllow);
|
|
159
|
+
return egress === undefined
|
|
160
|
+
? undefined
|
|
161
|
+
: { code: "HUMANISH_TERMINAL_SUBJECT_INVALID", message: egress };
|
|
162
|
+
}
|
|
163
|
+
const CIDR = /^[0-9a-f:.]+\/\d{1,3}$/i;
|
|
164
|
+
/**
|
|
165
|
+
* Why a declared egress allowlist cannot run on Modal, or undefined when it can. Modal limits
|
|
166
|
+
* egress to listed domains or CIDR ranges; an entry that is neither, such as a bare IP address,
|
|
167
|
+
* has no Modal form.
|
|
168
|
+
*/
|
|
169
|
+
function modalEgressReason(egressAllow) {
|
|
170
|
+
const unsupported = (egressAllow ?? []).filter((entry) => !CIDR.test(entry) && !/^(\*\.)?([a-z0-9-]+\.)+[a-z0-9-]*[a-z][a-z0-9-]*$/i.test(entry));
|
|
171
|
+
return unsupported.length === 0
|
|
172
|
+
? undefined
|
|
173
|
+
: `Modal limits egress to domain names and CIDR ranges, and \`execution.egressAllow\` lists ${unsupported.map((entry) => `"${entry}"`).join(", ")}. Write a bare address as a /32 range, or use \`execution.target: e2b-terminal\`.`;
|
|
174
|
+
}
|
|
@@ -95,6 +95,7 @@ export async function runLiveTerminalSession(args) {
|
|
|
95
95
|
verdictNonce,
|
|
96
96
|
maxMinutes,
|
|
97
97
|
e2bApiKey,
|
|
98
|
+
env,
|
|
98
99
|
runPaths,
|
|
99
100
|
metadata: buildSandboxMetadata({ studyId: plan.studyId, recordId: "sim-001", runId }),
|
|
100
101
|
warnings,
|
|
@@ -160,7 +161,13 @@ async function prepareLivePrompt(args) {
|
|
|
160
161
|
// anything persists (a key has no detectable "shape" if it is an arbitrary token); redactText is
|
|
161
162
|
// the second pass for secret-shaped content. Applied pre-truncation so a cut can never split a
|
|
162
163
|
// value past the scrubber.
|
|
163
|
-
|
|
164
|
+
// The sandbox provider's credentials too: a provider error that quotes one reaches the run.
|
|
165
|
+
const secrets = new RunSecrets([
|
|
166
|
+
runtimeEnv.keyValue,
|
|
167
|
+
env.E2B_API_KEY?.trim() ?? "",
|
|
168
|
+
env.MODAL_TOKEN_ID?.trim() ?? "",
|
|
169
|
+
env.MODAL_TOKEN_SECRET?.trim() ?? "",
|
|
170
|
+
]);
|
|
164
171
|
const sanitize = (text) => redactText(secrets.scrub(text));
|
|
165
172
|
const persona = terminalPersonaRef(terminalPersona, promptDigest, secrets.scrub);
|
|
166
173
|
return {
|
package/dist/run/bundle.d.ts
CHANGED
|
@@ -441,7 +441,8 @@ export interface RunCostLine {
|
|
|
441
441
|
cpuCount: number;
|
|
442
442
|
memoryMiB: number;
|
|
443
443
|
};
|
|
444
|
-
|
|
444
|
+
/** E2B's report of the allocation, or the size humanish asked Modal for. */
|
|
445
|
+
resourceSource?: "e2b.getInfo" | "modal.create";
|
|
445
446
|
resourceUnavailableReason?: "metadata_unavailable" | "metadata_invalid" | "metadata_timeout";
|
|
446
447
|
usdPerSecond?: number;
|
|
447
448
|
};
|
package/dist/run/cost-summary.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { estimateActorCostForExecution, estimateAllocatedDesktopCost, estimateDesktopCost, round6, } from "./pricing.js";
|
|
1
|
+
import { estimateActorCostForExecution, estimateAllocatedDesktopCost, estimateDesktopCost, MODAL_SANDBOX_RESOURCE_RATE, round6, } from "./pricing.js";
|
|
2
2
|
import {} from "./bundle.js";
|
|
3
3
|
export function buildRunCostSummary(args) {
|
|
4
4
|
const breakdown = [...modelCostLines(args.participants), ...desktopCostLines(args)];
|
|
@@ -76,8 +76,11 @@ function desktopCostLines(args) {
|
|
|
76
76
|
const breakdown = [];
|
|
77
77
|
for (const usage of args.desktops ?? []) {
|
|
78
78
|
const observation = usage.observation;
|
|
79
|
-
const
|
|
80
|
-
const
|
|
79
|
+
const observed = observation && "resources" in observation ? observation : undefined;
|
|
80
|
+
const resources = observed?.resources;
|
|
81
|
+
const estimate = observed?.source === "modal.create"
|
|
82
|
+
? estimateAllocatedDesktopCost(usage.minutes, resources, MODAL_SANDBOX_RESOURCE_RATE)
|
|
83
|
+
: estimateAllocatedDesktopCost(usage.minutes, resources);
|
|
81
84
|
breakdown.push({
|
|
82
85
|
kind: "desktop-minutes",
|
|
83
86
|
...(usage.participantId === undefined ? {} : { laneId: usage.participantId }),
|
|
@@ -88,7 +91,9 @@ function desktopCostLines(args) {
|
|
|
88
91
|
desktop: {
|
|
89
92
|
minutes: estimate.minutes,
|
|
90
93
|
durationBasis: "host-acquired-to-cleanup",
|
|
91
|
-
...(
|
|
94
|
+
...(observed === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { resources: observed.resources, resourceSource: observed.source }),
|
|
92
97
|
...(observation && "reason" in observation
|
|
93
98
|
? { resourceUnavailableReason: observation.reason }
|
|
94
99
|
: {}),
|
package/dist/run/pricing.d.ts
CHANGED
|
@@ -50,6 +50,12 @@ export interface DesktopResourceRate {
|
|
|
50
50
|
usdPerGiBSecond: number;
|
|
51
51
|
asOf: string;
|
|
52
52
|
source: string;
|
|
53
|
+
/** The sizes the public sheet prices; a size outside them stays unpriced. Absent: any size. */
|
|
54
|
+
sheet?: {
|
|
55
|
+
cpuCounts: readonly number[];
|
|
56
|
+
minMemoryMiB: number;
|
|
57
|
+
maxMemoryMiB: number;
|
|
58
|
+
};
|
|
53
59
|
}
|
|
54
60
|
/**
|
|
55
61
|
* The token-derived cost estimate for one actor trace. `estimatedCostUsd: null` = declared absent
|
|
@@ -104,6 +110,11 @@ export interface DesktopCostEstimate {
|
|
|
104
110
|
usdPerSecond?: number;
|
|
105
111
|
}
|
|
106
112
|
export declare const MODEL_RATES: Record<string, ModelRate>;
|
|
113
|
+
/**
|
|
114
|
+
* Modal's Sandbox rate, per vCPU: Modal prices a physical core, $0.00003942 a second, and counts
|
|
115
|
+
* it as two vCPU. Modal prices every size the same way.
|
|
116
|
+
*/
|
|
117
|
+
export declare const MODAL_SANDBOX_RESOURCE_RATE: DesktopResourceRate;
|
|
107
118
|
/**
|
|
108
119
|
* The refusal for a spend cap on a model this table cannot price. It names the priced models,
|
|
109
120
|
* since an npm install cannot edit this file.
|
package/dist/run/pricing.js
CHANGED
|
@@ -91,6 +91,19 @@ const DESKTOP_RESOURCE_RATE = {
|
|
|
91
91
|
asOf: "2026-09-05",
|
|
92
92
|
// A source label rather than an executable URL; runtime URL redaction protects E2B streams.
|
|
93
93
|
source: "e2b.dev/pricing",
|
|
94
|
+
// The public Hobby/Pro sheet stops at 8 CPU / 8 GiB; larger allocations may have negotiated
|
|
95
|
+
// prices, so their resource evidence is kept without extrapolating a standard rate to them.
|
|
96
|
+
sheet: { cpuCounts: [1, 2, 4, 6, 8], minMemoryMiB: 512, maxMemoryMiB: 8192 },
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Modal's Sandbox rate, per vCPU: Modal prices a physical core, $0.00003942 a second, and counts
|
|
100
|
+
* it as two vCPU. Modal prices every size the same way.
|
|
101
|
+
*/
|
|
102
|
+
export const MODAL_SANDBOX_RESOURCE_RATE = {
|
|
103
|
+
usdPerCpuSecond: 0.00003942 / 2,
|
|
104
|
+
usdPerGiBSecond: 0.00000667,
|
|
105
|
+
asOf: "2026-10-19",
|
|
106
|
+
source: "modal.com/pricing",
|
|
94
107
|
};
|
|
95
108
|
// Planning assumption behind the public estimateDesktopCost helper: stock desktops observed on
|
|
96
109
|
// 2026-09-05 had 8 vCPU / 8 GiB. This is the largest CPU/RAM combination on that public sheet, not
|
|
@@ -131,11 +144,11 @@ export function estimateAllocatedDesktopCost(minutes, resources, rate = DESKTOP_
|
|
|
131
144
|
minutes: round6(minutes),
|
|
132
145
|
};
|
|
133
146
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
const { sheet } = rate;
|
|
148
|
+
if ((sheet !== undefined &&
|
|
149
|
+
(!sheet.cpuCounts.includes(resources.cpuCount) ||
|
|
150
|
+
resources.memoryMiB < sheet.minMemoryMiB ||
|
|
151
|
+
resources.memoryMiB > sheet.maxMemoryMiB)) ||
|
|
139
152
|
!Number.isFinite(rate.usdPerCpuSecond) ||
|
|
140
153
|
rate.usdPerCpuSecond < 0 ||
|
|
141
154
|
!Number.isFinite(rate.usdPerGiBSecond) ||
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type E2BTagSearch } from "../substrates/e2b/sandbox.js";
|
|
2
2
|
import type { E2BAccount } from "../substrates/e2b/connection.js";
|
|
3
|
+
import { type SandboxProviderId } from "./sandbox-receipts.js";
|
|
4
|
+
/** The account reclaim reaches each provider through; absent when it could not be set up. */
|
|
5
|
+
export type ProviderAccounts = Partial<Record<SandboxProviderId, E2BAccount>>;
|
|
3
6
|
import { type ContainedRefusal, type PreparedOutputRoot } from "./contained-output.js";
|
|
4
7
|
export declare const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
|
|
5
8
|
export declare const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
|
|
@@ -50,7 +53,7 @@ export interface Target {
|
|
|
50
53
|
* kills leaves a record of each one not yet confirmed gone.
|
|
51
54
|
*/
|
|
52
55
|
export declare function targetSet(options: {
|
|
53
|
-
|
|
56
|
+
accounts: ProviderAccounts;
|
|
54
57
|
check: boolean;
|
|
55
58
|
requestTimeoutMs: number;
|
|
56
59
|
earlier: ReclaimOutcome[];
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// receipt writer. src/run/reclaim.ts decides what to find; this module records what happened.
|
|
5
5
|
import { REDACTED_SANDBOX_ID, redactText, sandboxIdDigest, toErrorMessage, } from "../evidence/redaction.js";
|
|
6
6
|
import { destroyE2BSandbox, findE2BSandboxesByTags, inspectE2BSandbox, } from "../substrates/e2b/sandbox.js";
|
|
7
|
+
import { isSandboxProviderId } from "./sandbox-receipts.js";
|
|
7
8
|
import { readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
|
|
8
9
|
import { scrubSandboxIds } from "./sandbox-ids.js";
|
|
9
10
|
import { isRecord } from "./type-guards.js";
|
|
@@ -15,7 +16,7 @@ export const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
|
|
|
15
16
|
* kills leaves a record of each one not yet confirmed gone.
|
|
16
17
|
*/
|
|
17
18
|
export function targetSet(options) {
|
|
18
|
-
const {
|
|
19
|
+
const { accounts, check, requestTimeoutMs, earlier, persist } = options;
|
|
19
20
|
const targets = new Map();
|
|
20
21
|
const work = [];
|
|
21
22
|
const queued = [];
|
|
@@ -25,7 +26,7 @@ export function targetSet(options) {
|
|
|
25
26
|
const seen = new Set(current.map((outcome) => outcome.sandboxIdDigest));
|
|
26
27
|
return [...current, ...carried.filter((outcome) => !seen.has(outcome.sandboxIdDigest))];
|
|
27
28
|
};
|
|
28
|
-
const act = (target) => actOn(
|
|
29
|
+
const act = (target) => actOn(accounts, target, { check, requestTimeoutMs }).then((settled) => {
|
|
29
30
|
target.outcome = settled;
|
|
30
31
|
void persist.write(snapshot(), "unknown");
|
|
31
32
|
});
|
|
@@ -95,17 +96,18 @@ export async function searchTagSets(e2b, tagSets, options) {
|
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
/** Kill (or look up) one sandbox, and say what happened in reclaim's vocabulary. */
|
|
98
|
-
async function actOn(
|
|
99
|
+
async function actOn(accounts, target, options) {
|
|
99
100
|
const { outcome, provider, rawId } = target;
|
|
100
|
-
|
|
101
|
+
const account = isSandboxProviderId(provider) ? accounts[provider] : undefined;
|
|
102
|
+
if (account === undefined)
|
|
101
103
|
return {
|
|
102
104
|
...outcome,
|
|
103
105
|
state: "unsupported-provider",
|
|
104
106
|
detail: `provider ${JSON.stringify(provider)} is not supported by this humanish version; nothing was attempted, and that provider's own timeout is the backstop`,
|
|
105
107
|
};
|
|
106
108
|
const attempt = options.check
|
|
107
|
-
? await inspectE2BSandbox(
|
|
108
|
-
: await destroyE2BSandbox(
|
|
109
|
+
? await inspectE2BSandbox(account, rawId, options)
|
|
110
|
+
: await destroyE2BSandbox(account, rawId, options);
|
|
109
111
|
const { detail: _previous, ...rest } = outcome;
|
|
110
112
|
// A provider error that quotes the id is scrubbed; the raw id stays in sandbox-receipts.ndjson.
|
|
111
113
|
return "detail" in attempt
|
package/dist/run/reclaim.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type E2BAccount } from "../substrates/e2b/connection.js";
|
|
1
2
|
import { type E2BDesktopModule } from "../substrates/e2b/sdk.js";
|
|
2
3
|
import { type StoppedSandboxCreates } from "./sandbox-creates.js";
|
|
3
4
|
import { RECLAIM_RECEIPT_ARTIFACT, RECLAIM_RESULT_SCHEMA, type ReclaimOutcome, type ReclaimState, type ReclaimTagSearch } from "./reclaim-outcomes.js";
|
|
@@ -33,13 +34,15 @@ export interface ReclaimResult {
|
|
|
33
34
|
export interface ReclaimHooks {
|
|
34
35
|
/** Tests inject a fake @e2b/desktop module; default loads the real one. */
|
|
35
36
|
loadModule?: () => Promise<E2BDesktopModule>;
|
|
37
|
+
/** Tests inject a fake Modal account; default reads the Modal credentials from `env`. */
|
|
38
|
+
loadModalAccount?: (env: Readonly<Record<string, string | undefined>>) => Promise<E2BAccount>;
|
|
36
39
|
/**
|
|
37
40
|
* The env whose E2B_API_KEY and E2B_DOMAIN every E2B call carries. Defaults to process.env, where
|
|
38
41
|
* the CLI put the run's key before the run started.
|
|
39
42
|
*/
|
|
40
43
|
env?: Readonly<Record<string, string | undefined>>;
|
|
41
44
|
requestTimeoutMs?: number;
|
|
42
|
-
/** Ask
|
|
45
|
+
/** Ask the provider whether each sandbox still exists, and kill nothing. */
|
|
43
46
|
check?: boolean;
|
|
44
47
|
/** The signal handler's view of this run's creates: the ids they reported, and those still
|
|
45
48
|
* in flight, whose sandboxes are killed as their ids arrive. */
|