humanish 0.137.0 → 0.139.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/cli/commands/runs.js +2 -2
- package/dist/cli/doctor.js +24 -0
- package/dist/keys/key-resolution.js +53 -2
- package/dist/routes/computer-use/bundle-parts.d.ts +3 -0
- package/dist/routes/computer-use/bundle-parts.js +9 -1
- package/dist/routes/computer-use/bundle.js +3 -4
- package/dist/routes/computer-use/e2b-desktop/prepare.js +8 -4
- package/dist/routes/computer-use/fanout-bundle.js +3 -2
- package/dist/routes/computer-use/participant-deps.d.ts +1 -1
- package/dist/routes/computer-use/plan.js +66 -19
- package/dist/routes/computer-use/setup.js +14 -7
- package/dist/routes/computer-use/types.d.ts +3 -0
- 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-shape.js +1 -1
- package/dist/run/bundle.d.ts +5 -3
- package/dist/run/cost-summary.js +9 -4
- package/dist/run/feedback-shape.js +1 -0
- 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 +14 -12
- package/dist/study/doctor.js +1 -1
- package/dist/study/parse/execution.js +6 -2
- package/dist/study/plan-base.d.ts +1 -0
- package/dist/study/plan-base.js +2 -0
- package/dist/study/plan-types.d.ts +6 -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/study/warnings.js +3 -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/e2b/sdk.d.ts +5 -0
- package/dist/substrates/e2b/sdk.js +12 -0
- package/dist/substrates/modal/commands.d.ts +16 -0
- package/dist/substrates/modal/commands.js +174 -0
- package/dist/substrates/modal/desktop.d.ts +16 -0
- package/dist/substrates/modal/desktop.js +195 -0
- package/dist/substrates/modal/image.d.ts +11 -0
- package/dist/substrates/modal/image.js +71 -0
- package/dist/substrates/modal/sandbox.d.ts +30 -0
- package/dist/substrates/modal/sandbox.js +325 -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 +5 -2
- package/docs/contracts/schemas.md +8 -7
- package/package.json +8 -2
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.139.0: Computer-use studies can run on Modal desktops (2026-10-19)
|
|
12
|
+
|
|
13
|
+
humanish 0.139.0 can run a computer-use study's desktops on Modal. Set
|
|
14
|
+
`execution.target: modal-desktop` on an `app-url` study and each participant gets its own Modal
|
|
15
|
+
sandbox running the same desktop the E2B route uses: Xvfb, XFCE, xdotool input, screenshots, a
|
|
16
|
+
live noVNC stream, Chromium and recordings. The same study file runs on E2B or Modal
|
|
17
|
+
with only the target changed. Clone and local-tree subjects, shared worlds, E2B templates and
|
|
18
|
+
camera or speech media stay on E2B.
|
|
19
|
+
|
|
20
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.139.0)
|
|
21
|
+
|
|
22
|
+
## 0.138.0: A terminal study can run on Modal (2026-10-19)
|
|
23
|
+
|
|
24
|
+
humanish 0.138.0 can run a terminal study's shell on Modal as well as E2B. Set
|
|
25
|
+
`execution.target: modal-terminal` and `execution.runtimeAuth: openai-env`, sign in with
|
|
26
|
+
`modal token new`, and install the optional `modal` package beside humanish. The sandbox runs as a
|
|
27
|
+
non-root user with sudo, as on E2B, lives up to Modal's 24 hours, is journaled and reclaimed by id
|
|
28
|
+
like an E2B sandbox, and is priced at Modal's sandbox rate. Desktop studies still run on E2B or a
|
|
29
|
+
local VM.
|
|
30
|
+
|
|
31
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.138.0)
|
|
32
|
+
|
|
11
33
|
## 0.137.0: Each browser participant can record its requests (2026-10-16)
|
|
12
34
|
|
|
13
35
|
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
|
/**
|
|
@@ -15,7 +15,10 @@ export declare function describeSubjectState(state: RunSubjectProvenance["state"
|
|
|
15
15
|
/** Human-readable provenance line for the single-participant subject.provenance event of a clone
|
|
16
16
|
* or local-tree subject: claims "cloned/packed and served" only when it actually happened. */
|
|
17
17
|
export declare function subjectProvenanceMessage(provenance: RunSubjectProvenance, publicAppUrl: string, dryRun: boolean, hasSession: boolean): string;
|
|
18
|
+
/** The label a run records for the desktop `execution.target` names. */
|
|
19
|
+
export declare function desktopSubstrate(target: string | undefined): "local-desktop" | "modal-desktop" | "e2b-desktop";
|
|
18
20
|
export declare function providerResourcesForOutcome(args: {
|
|
21
|
+
provider: "e2b-desktop" | "modal-desktop";
|
|
19
22
|
outcome: ParticipantRunOutcome | undefined;
|
|
20
23
|
createdAt: string;
|
|
21
24
|
ids: ParticipantIds;
|
|
@@ -73,6 +73,14 @@ export function subjectProvenanceMessage(provenance, publicAppUrl, dryRun, hasSe
|
|
|
73
73
|
}
|
|
74
74
|
return "Subject local-tree packing attempted; archive digest unresolved (provisioning failed before resolution)";
|
|
75
75
|
}
|
|
76
|
+
/** The label a run records for the desktop `execution.target` names. */
|
|
77
|
+
export function desktopSubstrate(target) {
|
|
78
|
+
return target === "local"
|
|
79
|
+
? "local-desktop"
|
|
80
|
+
: target === "modal-desktop"
|
|
81
|
+
? "modal-desktop"
|
|
82
|
+
: "e2b-desktop";
|
|
83
|
+
}
|
|
76
84
|
export function providerResourcesForOutcome(args) {
|
|
77
85
|
if (args.outcome?.sandboxId === undefined) {
|
|
78
86
|
return [];
|
|
@@ -80,7 +88,7 @@ export function providerResourcesForOutcome(args) {
|
|
|
80
88
|
return [
|
|
81
89
|
{
|
|
82
90
|
schema: "humanish.provider-resource.v1",
|
|
83
|
-
provider:
|
|
91
|
+
provider: args.provider,
|
|
84
92
|
kind: "sandbox",
|
|
85
93
|
id: args.outcome.sandboxId,
|
|
86
94
|
owner: "humanish",
|
|
@@ -6,7 +6,7 @@ import { judgeOneParticipant, judgeParticipants } from "../../run/judge.js";
|
|
|
6
6
|
import { renderReviewMarkdown } from "../../run/review-markdown.js";
|
|
7
7
|
import { desktopSpanToMinutes } from "../../run/cost-summary.js";
|
|
8
8
|
import { e2bDesktopTemplate } from "../../substrates/e2b/sandbox.js";
|
|
9
|
-
import { providerResourcesForOutcome } from "./bundle-parts.js";
|
|
9
|
+
import { desktopSubstrate, providerResourcesForOutcome } from "./bundle-parts.js";
|
|
10
10
|
import { participantFactsOf } from "./participant-facts.js";
|
|
11
11
|
import { buildCuaFanoutBundle } from "./fanout-bundle.js";
|
|
12
12
|
import { buildSingleParticipantBundle } from "./single-bundle.js";
|
|
@@ -96,9 +96,7 @@ function singleParticipantArgs(base, state) {
|
|
|
96
96
|
desktopRoute: base.plan.runner.desktop !== "in-process",
|
|
97
97
|
substrate: base.plan.runner.desktop === "in-process"
|
|
98
98
|
? "local-filesystem"
|
|
99
|
-
: plan.residual.execution?.target
|
|
100
|
-
? "local-desktop"
|
|
101
|
-
: "e2b-desktop",
|
|
99
|
+
: desktopSubstrate(plan.residual.execution?.target),
|
|
102
100
|
...(outcome?.desktopGeometry === undefined ? {} : { desktopGeometry: outcome.desktopGeometry }),
|
|
103
101
|
...(outcome?.recording === undefined ? {} : { recording: outcome.recording }),
|
|
104
102
|
...(outcome?.networkLog === undefined ? {} : { network: outcome.networkLog }),
|
|
@@ -127,6 +125,7 @@ function singleParticipantArgs(base, state) {
|
|
|
127
125
|
...(desktopTemplate === undefined ? {} : { desktopTemplate }),
|
|
128
126
|
...(outcome?.desktopBrowser === undefined ? {} : { desktopBrowser: outcome.desktopBrowser }),
|
|
129
127
|
providerResources: providerResourcesForOutcome({
|
|
128
|
+
provider: base.plan.residual.execution?.target === "modal-desktop" ? "modal-desktop" : "e2b-desktop",
|
|
130
129
|
outcome,
|
|
131
130
|
createdAt: base.run.createdAt,
|
|
132
131
|
ids: spec,
|
|
@@ -11,6 +11,7 @@ import { observeDesktopResources } from "../../../substrates/e2b/desktop-resourc
|
|
|
11
11
|
import { acquireE2BDesktopSandbox, e2bDesktopTemplate, withOneRetryOnTransientE2BError, } from "../../../substrates/e2b/sandbox.js";
|
|
12
12
|
import { loadE2BDesktopModule } from "../../../substrates/e2b/sdk.js";
|
|
13
13
|
import { e2bShell } from "../../../substrates/e2b/shell.js";
|
|
14
|
+
import { loadModalDesktopModule } from "../../../substrates/modal/sandbox.js";
|
|
14
15
|
import { attachReceivingInbox, participantCommsEnv, startCommsCatch } from "./comms.js";
|
|
15
16
|
import { participantSubjectEnv } from "../types.js";
|
|
16
17
|
import { plural } from "../../../run/text.js";
|
|
@@ -30,11 +31,14 @@ export async function acquireParticipantDesktop(ctx, state) {
|
|
|
30
31
|
// Off-app comms: the base-URL env is injected at sandbox create, so the app reads it at
|
|
31
32
|
// boot; the catch starts right after create.
|
|
32
33
|
const commsEnv = participantCommsEnv(ctx.comms);
|
|
33
|
-
const
|
|
34
|
+
const provider = deps.sandboxProvider ?? "e2b";
|
|
35
|
+
const desktopModule = await (deps.desktopModule ??
|
|
36
|
+
(provider === "modal" ? () => loadModalDesktopModule(env) : loadE2BDesktopModule))();
|
|
34
37
|
// An explicit template wins. Speech gets the versioned media image; ordinary
|
|
35
|
-
// browser studies retain the SDK default desktop.
|
|
38
|
+
// browser studies retain the SDK default desktop. A Modal desktop has one image.
|
|
36
39
|
const acquired = await acquireE2BDesktopSandbox({
|
|
37
40
|
module: desktopModule,
|
|
41
|
+
provider,
|
|
38
42
|
options: {
|
|
39
43
|
apiKey: deps.e2bApiKey,
|
|
40
44
|
requestTimeoutMs: deps.requestTimeoutMs,
|
|
@@ -67,7 +71,7 @@ export async function acquireParticipantDesktop(ctx, state) {
|
|
|
67
71
|
dpi: 96,
|
|
68
72
|
lifecycle: { onTimeout: "kill" },
|
|
69
73
|
},
|
|
70
|
-
template: e2bDesktopTemplate(residual),
|
|
74
|
+
template: provider === "modal" ? undefined : e2bDesktopTemplate(residual),
|
|
71
75
|
retry: {
|
|
72
76
|
// The default loader reclaims an acquired handle before retrying failed desktop startup.
|
|
73
77
|
// Its error names the cleanup outcome; pre-construction allocation failures remain unowned.
|
|
@@ -92,7 +96,7 @@ export async function acquireParticipantDesktop(ctx, state) {
|
|
|
92
96
|
state.sandboxId = acquired.allocation.resourceId;
|
|
93
97
|
// The billed span starts the instant the sandbox exists.
|
|
94
98
|
state.sandboxCreatedAtMs = deps.now();
|
|
95
|
-
const desktopResources = await observeDesktopResources(desktop);
|
|
99
|
+
const desktopResources = await observeDesktopResources(desktop, undefined, provider === "modal" ? "modal.create" : "e2b.getInfo");
|
|
96
100
|
state.desktopResources = desktopResources;
|
|
97
101
|
if ("reason" in desktopResources) {
|
|
98
102
|
warnings.push(`Desktop resource size unavailable (${desktopResources.reason}); compute cost remains unpriced.`);
|
|
@@ -5,7 +5,7 @@ import { cuaGoalSource } from "../../actors/goal-source.js";
|
|
|
5
5
|
import { redactEncodedText } from "../../evidence/encoded-text.js";
|
|
6
6
|
import { REVIEW_SCHEMA, bundleArtifacts, bundleHead, } from "../../run/bundle.js";
|
|
7
7
|
import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, gapsListClause, skippedSentence, withCuaReviewProvenance, } from "../../run/outcomes.js";
|
|
8
|
-
import { providerResourcesForOutcome, publicSafeAppUrlLabel, streakGaps } from "./bundle-parts.js";
|
|
8
|
+
import { desktopSubstrate, providerResourcesForOutcome, publicSafeAppUrlLabel, streakGaps, } from "./bundle-parts.js";
|
|
9
9
|
import { subjectName } from "../../run/subject-name.js";
|
|
10
10
|
import { participantFeedbackCandidates } from "./participant-feedback.js";
|
|
11
11
|
import { participantFactsOf } from "./participant-facts.js";
|
|
@@ -179,7 +179,7 @@ function fanoutFeedbackCandidates(args) {
|
|
|
179
179
|
runId: args.run.runId,
|
|
180
180
|
scenarioId: `cua-${plan.studyId}`,
|
|
181
181
|
adapterId: plan.studyId,
|
|
182
|
-
substrate: plan.residual.execution?.target
|
|
182
|
+
substrate: desktopSubstrate(plan.residual.execution?.target),
|
|
183
183
|
participants: specs.map((spec, index) => {
|
|
184
184
|
const outcome = outcomes?.[index];
|
|
185
185
|
return {
|
|
@@ -241,6 +241,7 @@ export function buildCuaFanoutBundle(args) {
|
|
|
241
241
|
const desktopTemplate = e2bDesktopTemplate(plan.residual);
|
|
242
242
|
const desktopBrowser = fanoutDesktopBrowser(args);
|
|
243
243
|
const providerResources = (outcomes ?? []).flatMap((outcome) => providerResourcesForOutcome({
|
|
244
|
+
provider: plan.residual.execution?.target === "modal-desktop" ? "modal-desktop" : "e2b-desktop",
|
|
244
245
|
outcome,
|
|
245
246
|
createdAt: args.run.createdAt,
|
|
246
247
|
ids: outcome.spec,
|
|
@@ -36,7 +36,7 @@ interface DesktopRunFacts {
|
|
|
36
36
|
}
|
|
37
37
|
/** The fields one desktop route sets and the other does not. Undefined leaves a field out. */
|
|
38
38
|
type DesktopRouteOverrides = {
|
|
39
|
-
[K in "createDesktop" | "createProvider" | "inProcessExecutor" | "localTreeArchiveBuffer" | "desktopFiles" | "externalComms" | "receiving" | "screenMismatchPolicy"]?: CuaParticipantDeps[K] | undefined;
|
|
39
|
+
[K in "createDesktop" | "createProvider" | "inProcessExecutor" | "localTreeArchiveBuffer" | "desktopFiles" | "externalComms" | "receiving" | "screenMismatchPolicy" | "sandboxProvider"]?: CuaParticipantDeps[K] | undefined;
|
|
40
40
|
};
|
|
41
41
|
/**
|
|
42
42
|
* One run's participant deps. Call it once per run: the run budget it creates is the one ledger
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// in its order and with its codes, then the plan the run uses. What stays in the route reads
|
|
3
3
|
// external state: committed personas, a rerun's source run, keys, the local agent, subject env
|
|
4
4
|
// values, spend-cap prices and the comms catch.
|
|
5
|
+
import { MODAL_MAX_SANDBOX_MS } from "../../substrates/modal/sdk.js";
|
|
5
6
|
import { DESKTOP_FILES_ROUTE_REASON } from "../../study/desktop-files.js";
|
|
6
7
|
import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
|
|
7
8
|
import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
|
|
@@ -206,12 +207,14 @@ function startsOf(config, runner, limits, ceilingMs) {
|
|
|
206
207
|
const declared = config.execution?.concurrency;
|
|
207
208
|
const n = runner.participants.length;
|
|
208
209
|
const hosted = runner.desktop === "e2b-desktop";
|
|
210
|
+
// The E2B plan's limit binds only E2B desktops; Modal's container limit is far above a study's.
|
|
211
|
+
const e2bHosted = hosted && runner.provider === "e2b";
|
|
209
212
|
const atOnce = participantsAtOnce({
|
|
210
213
|
concurrency: boundedConcurrency(declared, n),
|
|
211
214
|
declared: declared !== undefined,
|
|
212
215
|
participants: n,
|
|
213
216
|
}, {
|
|
214
|
-
...(
|
|
217
|
+
...(e2bHosted ? { sandboxes: limits.concurrentSandboxes } : {}),
|
|
215
218
|
cap: limits.concurrencyCap,
|
|
216
219
|
});
|
|
217
220
|
if (!atOnce.ok)
|
|
@@ -265,6 +268,54 @@ function timeZoneRunnerReason(desktop, participants) {
|
|
|
265
268
|
? `${declared} This study runs on the local browser runtime. Remove actor.clock and participants[].clock, or set execution.target: e2b-desktop.`
|
|
266
269
|
: `${declared} This study drives your own browser through RunStudyOptions.inProcess. Remove actor.clock and participants[].clock, and set the zone in that browser yourself, or run on a hosted desktop.`;
|
|
267
270
|
}
|
|
271
|
+
/** Who hosts the study's desktops and the longest sandbox they allow, or why it cannot run. */
|
|
272
|
+
function hostingOf(config, e2bCeiling) {
|
|
273
|
+
if (config.execution?.target !== "modal-desktop")
|
|
274
|
+
return e2bCeiling.ok
|
|
275
|
+
? { provider: "e2b", ceiling: e2bCeiling }
|
|
276
|
+
: { refusal: e2bCeiling.message };
|
|
277
|
+
const refusal = modalDesktopRefusal(config);
|
|
278
|
+
return refusal === undefined
|
|
279
|
+
? { provider: "modal", ceiling: { ms: MODAL_MAX_SANDBOX_MS } }
|
|
280
|
+
: { refusal };
|
|
281
|
+
}
|
|
282
|
+
/** The app-url subject the study names, and the subject a hosted desktop provisions. */
|
|
283
|
+
function subjectsOf(config) {
|
|
284
|
+
const appUrl = config.subject.appUrl ?? "";
|
|
285
|
+
const declaredServeUrl = config.subject.serve?.url;
|
|
286
|
+
const serveUrl = declaredServeUrl === undefined ? {} : { serveUrl: declaredServeUrl };
|
|
287
|
+
const appUrlSubject = {
|
|
288
|
+
kind: "app-url",
|
|
289
|
+
appUrl,
|
|
290
|
+
publicTargets: config.policies?.allowPublicTargets === true,
|
|
291
|
+
...serveUrl,
|
|
292
|
+
};
|
|
293
|
+
const product = config.subject.product;
|
|
294
|
+
const hosted = config.subject.source === "desktop-cli" && product !== undefined
|
|
295
|
+
? { kind: "desktop-cli", product, ...serveUrl }
|
|
296
|
+
: (provisionedSubject(config) ?? appUrlSubject);
|
|
297
|
+
return { appUrl, appUrlSubject, hosted };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* What a study on a Modal desktop cannot have that one on an E2B desktop can, or undefined. The
|
|
301
|
+
* Modal desktop runs an app-url subject in Chromium; provisioned subjects, a terminal subject,
|
|
302
|
+
* E2B templates and the speech template are E2B's.
|
|
303
|
+
*/
|
|
304
|
+
function modalDesktopRefusal(config) {
|
|
305
|
+
const desktop = config.execution?.desktop;
|
|
306
|
+
if (config.subject.source !== "app-url")
|
|
307
|
+
return `A Modal desktop runs app-url subjects only, and this study's subject is ${config.subject.source}. Set execution.target: e2b-desktop.`;
|
|
308
|
+
if (config.route === "shared-world")
|
|
309
|
+
return "A shared-world study runs on E2B desktops only. Set execution.target: e2b-desktop.";
|
|
310
|
+
if (desktop?.template !== undefined)
|
|
311
|
+
return "execution.desktop.template names an E2B template, which a Modal desktop cannot use. Remove it, or set execution.target: e2b-desktop.";
|
|
312
|
+
if (desktop?.media !== undefined)
|
|
313
|
+
return "execution.desktop.media needs the E2B speech or camera template, which a Modal desktop does not have. Remove it, or set execution.target: e2b-desktop.";
|
|
314
|
+
const browser = desktop?.browser;
|
|
315
|
+
if (browser !== undefined && browser !== "default" && browser !== "chromium")
|
|
316
|
+
return `A Modal desktop has Chromium only, and execution.desktop.browser is ${browser}. Set it to chromium or default, or set execution.target: e2b-desktop.`;
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
268
319
|
/** What the participants declare that this runner cannot do, with its code, or undefined. */
|
|
269
320
|
function runnerRefusal(desktop, participants) {
|
|
270
321
|
const files = desktopFilesRunnerReason(desktop, participants);
|
|
@@ -304,9 +355,10 @@ export function planComputerUseStudy(config, input) {
|
|
|
304
355
|
if (!descriptor || !isCuaActorDescriptor(descriptor))
|
|
305
356
|
return refuse("in-scope", "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED", `actor.type "${actorType}" is not a registered computer-use actor.`);
|
|
306
357
|
const actor = descriptor.id;
|
|
307
|
-
const
|
|
308
|
-
if (
|
|
309
|
-
return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID",
|
|
358
|
+
const hosting = hostingOf(config, input.sandboxCeiling);
|
|
359
|
+
if ("refusal" in hosting)
|
|
360
|
+
return refuse("in-scope", "HUMANISH_COMPUTER_USE_SUBJECT_INVALID", hosting.refusal, actor);
|
|
361
|
+
const { provider, ceiling } = hosting;
|
|
310
362
|
const rejection = cuaStudyRejection(config, hasRunSession, driving, declaredSubjectRoute(config, driving), ceiling.ms);
|
|
311
363
|
if (rejection)
|
|
312
364
|
return refuse("in-scope", rejection.code, rejection.message, actor);
|
|
@@ -335,19 +387,7 @@ export function planComputerUseStudy(config, input) {
|
|
|
335
387
|
return refuse("after-personas", "HUMANISH_COMPUTER_USE_FANOUT_INVALID", "Fan-out to more than one participant is not supported on the in-process route (RunStudyOptions.inProcess): fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Run a single in-process participant, or fan out on the E2B route.", actor);
|
|
336
388
|
if (first === undefined)
|
|
337
389
|
throw new Error("computerUseParticipants returned no participant");
|
|
338
|
-
const appUrl = config
|
|
339
|
-
const declaredServeUrl = config.subject.serve?.url;
|
|
340
|
-
const serveUrl = declaredServeUrl === undefined ? {} : { serveUrl: declaredServeUrl };
|
|
341
|
-
const appUrlSubject = {
|
|
342
|
-
kind: "app-url",
|
|
343
|
-
appUrl,
|
|
344
|
-
publicTargets: config.policies?.allowPublicTargets === true,
|
|
345
|
-
...serveUrl,
|
|
346
|
-
};
|
|
347
|
-
const product = config.subject.product;
|
|
348
|
-
const hosted = source === "desktop-cli" && product !== undefined
|
|
349
|
-
? { kind: "desktop-cli", product, ...serveUrl }
|
|
350
|
-
: (provisionedSubject(config) ?? appUrlSubject);
|
|
390
|
+
const { appUrl, appUrlSubject, hosted } = subjectsOf(config);
|
|
351
391
|
const brain = brainOf(config, driving.createProvider);
|
|
352
392
|
const runner = driving.inProcess
|
|
353
393
|
? {
|
|
@@ -358,7 +398,13 @@ export function planComputerUseStudy(config, input) {
|
|
|
358
398
|
}
|
|
359
399
|
: isLocalBrowserStudy(config)
|
|
360
400
|
? { desktop: "local-vm", brain, participants: [first, ...rest], subject: appUrlSubject }
|
|
361
|
-
: {
|
|
401
|
+
: {
|
|
402
|
+
desktop: "e2b-desktop",
|
|
403
|
+
provider,
|
|
404
|
+
brain,
|
|
405
|
+
participants: [first, ...rest],
|
|
406
|
+
subject: hosted,
|
|
407
|
+
};
|
|
362
408
|
const unsupported = runnerRefusal(runner.desktop, participants);
|
|
363
409
|
if (unsupported)
|
|
364
410
|
return refuse("in-scope", unsupported.code, unsupported.message, actor);
|
|
@@ -382,7 +428,8 @@ export function planComputerUseStudy(config, input) {
|
|
|
382
428
|
requirements: base.dryRun || runner.desktop === "in-process"
|
|
383
429
|
? []
|
|
384
430
|
: desktopRequirements(config, {
|
|
385
|
-
e2b: runner.desktop === "e2b-desktop",
|
|
431
|
+
e2b: runner.desktop === "e2b-desktop" && runner.provider === "e2b",
|
|
432
|
+
modal: runner.desktop === "e2b-desktop" && runner.provider === "modal",
|
|
386
433
|
brain,
|
|
387
434
|
localVm: runner.desktop === "local-vm",
|
|
388
435
|
externalCatch: !provisioned,
|
|
@@ -125,13 +125,8 @@ export async function admitCuaRun(plan, input, config) {
|
|
|
125
125
|
// Read keys once into locals (names only; values never logged or persisted).
|
|
126
126
|
const openaiApiKey = env.OPENAI_API_KEY?.trim() ?? "";
|
|
127
127
|
const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
const secrets = new RunSecrets([
|
|
131
|
-
openaiApiKey,
|
|
132
|
-
e2bApiKey,
|
|
133
|
-
...subjectEnvNames.map((name) => env[name] ?? ""),
|
|
134
|
-
]);
|
|
128
|
+
// Every known key and subject value, scrubbed literally, in dry and in-process runs too.
|
|
129
|
+
const secrets = new RunSecrets([openaiApiKey, e2bApiKey, ...otherValues(env, subjectEnvNames)]);
|
|
135
130
|
sanitizeParticipantRuns(participantRuns, secrets.scrub);
|
|
136
131
|
const desktopFiles = await readStudyDesktopFiles(input.cwd, plan, participantRuns, secrets.scrub);
|
|
137
132
|
if (!desktopFiles.ok)
|
|
@@ -342,6 +337,7 @@ function cuaParticipantDeps(plan, input, admitted, run) {
|
|
|
342
337
|
prepareDesktop: input.prepareDesktop,
|
|
343
338
|
}, {
|
|
344
339
|
createDesktop: input.localVm?.desktop,
|
|
340
|
+
sandboxProvider: plan.runner.desktop === "e2b-desktop" ? plan.runner.provider : undefined,
|
|
345
341
|
...callerDriving(input, config),
|
|
346
342
|
localTreeArchiveBuffer: admitted.localTreeArchiveBuffer,
|
|
347
343
|
desktopFiles: admitted.desktopFiles.size === 0 ? undefined : admitted.desktopFiles,
|
|
@@ -373,3 +369,14 @@ function subjectPhaseReporter(input) {
|
|
|
373
369
|
input.emit?.(phaseEvent(event, { kind: "participant", participant }));
|
|
374
370
|
};
|
|
375
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* The Modal token pair's values, which a provider error can quote into run warnings, and the
|
|
374
|
+
* subject env values the study names.
|
|
375
|
+
*/
|
|
376
|
+
function otherValues(env, subjectEnv) {
|
|
377
|
+
return [
|
|
378
|
+
env.MODAL_TOKEN_ID?.trim() ?? "",
|
|
379
|
+
env.MODAL_TOKEN_SECRET?.trim() ?? "",
|
|
380
|
+
...subjectEnv.map((name) => env[name] ?? ""),
|
|
381
|
+
];
|
|
382
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SandboxProviderId } from "../../run/sandbox-receipts.js";
|
|
1
2
|
import type { Verdict } from "../../run/judge.js";
|
|
2
3
|
import type { HostSuspensionReading } from "../../run/host-suspension.js";
|
|
3
4
|
import type { LocalCapacity } from "../../substrates/local/capacity.js";
|
|
@@ -344,6 +345,8 @@ export interface E2BDesktopDeps {
|
|
|
344
345
|
now: () => number;
|
|
345
346
|
/** Loads the E2B SDK for the participant's desktop. Defaults to loadE2BDesktopModule. */
|
|
346
347
|
desktopModule?: () => Promise<E2BDesktopModule>;
|
|
348
|
+
/** Who hosts the participant's desktop; E2B when unset. */
|
|
349
|
+
sandboxProvider?: SandboxProviderId;
|
|
347
350
|
/** The caller's prepareDesktop, called with the participant as its target. */
|
|
348
351
|
prepareDesktop?: NonNullable<RunStudyHomes["prepareDesktop"]>;
|
|
349
352
|
/** Clock and sleep for detached provisioning steps. */
|
|
@@ -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.`);
|