humanish 0.138.0 → 0.139.1
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 +21 -0
- package/dist/actors/codex/operator-instructions.d.ts +5 -0
- package/dist/actors/codex/operator-instructions.js +9 -0
- package/dist/actors/codex/restricted-launch.d.ts +10 -2
- package/dist/actors/codex/restricted-launch.js +65 -16
- package/dist/actors/codex/restricted-participant.d.ts +4 -0
- package/dist/actors/codex/restricted-participant.js +9 -0
- package/dist/actors/codex/restricted-policy.d.ts +5 -2
- package/dist/actors/codex/restricted-policy.js +90 -72
- package/dist/actors/codex/restricted-session.d.ts +4 -0
- package/dist/actors/codex/restricted-session.js +8 -0
- 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/run/bundle-shape.js +1 -1
- package/dist/run/bundle.d.ts +3 -2
- package/dist/run/feedback-shape.js +1 -0
- package/dist/study/composition-rules.js +3 -2
- package/dist/study/doctor.d.ts +3 -0
- package/dist/study/doctor.js +11 -1
- package/dist/study/parse/execution.js +2 -1
- package/dist/study/plan-base.d.ts +1 -0
- package/dist/study/plan-base.js +2 -0
- package/dist/study/plan-types.d.ts +2 -0
- package/dist/study/types.d.ts +1 -1
- package/dist/study/warnings.js +3 -1
- 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 +2 -0
- package/dist/substrates/modal/image.js +36 -1
- package/dist/substrates/modal/sandbox.d.ts +4 -0
- package/dist/substrates/modal/sandbox.js +68 -110
- package/docs/contracts/run-bundle.md +2 -1
- package/docs/contracts/schemas.md +4 -4
- package/package.json +1 -1
|
@@ -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. */
|
package/dist/run/bundle-shape.js
CHANGED
|
@@ -93,7 +93,7 @@ export function isRunOutcome(value) {
|
|
|
93
93
|
function isRunProviderResource(value) {
|
|
94
94
|
return (isRecord(value) &&
|
|
95
95
|
value.schema === "humanish.provider-resource.v1" &&
|
|
96
|
-
value.provider === "e2b-desktop" &&
|
|
96
|
+
(value.provider === "e2b-desktop" || value.provider === "modal-desktop") &&
|
|
97
97
|
value.kind === "sandbox" &&
|
|
98
98
|
typeof value.id === "string" &&
|
|
99
99
|
value.id.trim().length > 0 &&
|
package/dist/run/bundle.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export interface RunFeedbackCandidate {
|
|
|
53
53
|
scenario_id: string;
|
|
54
54
|
persona_id: string;
|
|
55
55
|
actor: "codex-tui" | "codex-exec" | "codex-app-server" | "computer-use" | "synthetic-dry-run" | "unknown";
|
|
56
|
-
substrate: "e2b-desktop" | "local-desktop" | "e2b-terminal" | "local-filesystem" | "codex-app-server" | "unknown";
|
|
56
|
+
substrate: "e2b-desktop" | "modal-desktop" | "local-desktop" | "e2b-terminal" | "local-filesystem" | "codex-app-server" | "unknown";
|
|
57
57
|
failure_owner: "harness" | "target-app" | "actor" | "environment" | "unknown";
|
|
58
58
|
summary: string;
|
|
59
59
|
expected: string;
|
|
@@ -479,7 +479,8 @@ export interface RunCostSummary {
|
|
|
479
479
|
}
|
|
480
480
|
export interface RunProviderResource {
|
|
481
481
|
schema: "humanish.provider-resource.v1";
|
|
482
|
-
provider:
|
|
482
|
+
/** The hosted desktop's provider: E2B, or Modal for `execution.target: modal-desktop`. */
|
|
483
|
+
provider: "e2b-desktop" | "modal-desktop";
|
|
483
484
|
kind: "sandbox";
|
|
484
485
|
/**
|
|
485
486
|
* "[redacted-sandbox-id]" in runs from 0.110 on; earlier runs recorded the raw id here. The raw id
|
|
@@ -111,8 +111,9 @@ function appUrlValidationReason(config) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
else {
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
const target = config.execution?.target;
|
|
115
|
+
if (target !== "e2b-desktop" && target !== "modal-desktop" && target !== "local") {
|
|
116
|
+
return "app-url computer-use subjects require `execution.target: local`, `e2b-desktop` or `modal-desktop`.";
|
|
116
117
|
}
|
|
117
118
|
if (!actorResolvesToComputerUse(type)) {
|
|
118
119
|
return `actor.type must be a registered computer-use actor for app-url × e2b-desktop studies (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`;
|
package/dist/study/doctor.d.ts
CHANGED
|
@@ -22,10 +22,13 @@ type CodexReadiness = {
|
|
|
22
22
|
protocolIncompatibilities?: readonly string[];
|
|
23
23
|
/** Schema values beyond the baseline, recorded by a launch that passed. */
|
|
24
24
|
protocolAdditions?: readonly string[];
|
|
25
|
+
/** What in the operator's Codex setup the launch refused, without paths or values. */
|
|
26
|
+
refusalReason?: string;
|
|
25
27
|
};
|
|
26
28
|
/** What doctor's hosted Codex participant check found: a readiness result and what it admitted. */
|
|
27
29
|
type HostedCodexReadiness = CodexReadiness & {
|
|
28
30
|
cliVersion?: string;
|
|
31
|
+
operatorInstructionFiles?: readonly string[];
|
|
29
32
|
resolvedModel?: string;
|
|
30
33
|
authentication?: "chatgpt-account" | "api-key";
|
|
31
34
|
};
|
package/dist/study/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ import { capacityShortfall, describeCapacity, } from "../substrates/local/capaci
|
|
|
5
5
|
import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
|
|
6
6
|
import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
|
|
7
7
|
import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
|
|
8
|
+
import { operatorInstructionsWarning } from "../actors/codex/operator-instructions.js";
|
|
8
9
|
import { admissionRule } from "../analysis/admission.js";
|
|
9
10
|
import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
|
|
10
11
|
import { externalCatchHealthy } from "../comms/sandbox-catch.js";
|
|
@@ -90,6 +91,8 @@ function codexRecovery(readiness, fallback) {
|
|
|
90
91
|
return codexVersionRecovery(readiness.detectedCliVersion, readiness.installation);
|
|
91
92
|
case "codex_incompatible_release":
|
|
92
93
|
return `${protocolIncompatibilityMessage(readiness.detectedCliVersion, readiness.protocolIncompatibilities ?? [])} ${codexInstallAdvice(readiness.installation)} Then sign in with a ChatGPT account (\`codex login\`).`;
|
|
94
|
+
case "codex_unsafe_configuration":
|
|
95
|
+
return unsafeConfigurationRecovery(readiness.refusalReason, fallback);
|
|
93
96
|
case "codex_login_required":
|
|
94
97
|
return "Codex is installed but not signed in. Run `codex login` and sign in with a ChatGPT account.";
|
|
95
98
|
case "codex_unsupported_auth":
|
|
@@ -107,6 +110,12 @@ function codexRecovery(readiness, fallback) {
|
|
|
107
110
|
return fallback;
|
|
108
111
|
}
|
|
109
112
|
}
|
|
113
|
+
/** What the refused setting is and how to change it; the fallback when no reason was recorded. */
|
|
114
|
+
function unsafeConfigurationRecovery(reason, fallback) {
|
|
115
|
+
if (reason === undefined)
|
|
116
|
+
return fallback;
|
|
117
|
+
return `humanish stopped the Codex launch because ${reason}. Change that setting in the Codex config (~/.codex/config.toml, a profile, or a system config) if you want hosted participants to use it, then rerun doctor.`;
|
|
118
|
+
}
|
|
110
119
|
/** Shared read-only Codex account check for local participants in doctor and the TUI. */
|
|
111
120
|
export async function localCodexParticipantCheck(args) {
|
|
112
121
|
const readiness = await (args.readiness ?? codexAccountReadiness)(args.env).catch(() => ({ ready: false, errorCode: "codex_unavailable" }));
|
|
@@ -320,6 +329,7 @@ async function hostedCodexParticipantCheck(args, actor) {
|
|
|
320
329
|
...(actor?.reasoningEffort === undefined ? {} : { reasoningEffort: actor.reasoningEffort }),
|
|
321
330
|
};
|
|
322
331
|
const readiness = await (args.codexParticipantReadiness ?? hostedCodexReadiness)(args.env, declared).catch(() => ({ ready: false, errorCode: "codex_unavailable" }));
|
|
332
|
+
const operatorNote = operatorInstructionsWarning(readiness.operatorInstructionFiles);
|
|
323
333
|
const account = readiness.authentication === "api-key"
|
|
324
334
|
? "an API key, which bills its usage to that key"
|
|
325
335
|
: "a ChatGPT account";
|
|
@@ -327,7 +337,7 @@ async function hostedCodexParticipantCheck(args, actor) {
|
|
|
327
337
|
name: "local participant authentication",
|
|
328
338
|
ok: readiness.ready,
|
|
329
339
|
message: readiness.ready
|
|
330
|
-
? readyMessage(`Codex CLI ${readiness.cliVersion ?? "(unknown release)"} passed the operator handshake without a turn (initialize, config/read, account/read and an ephemeral thread/start): model ${readiness.resolvedModel ?? "(not reported)"} on ${account}. E2B supplies the desktop; no OpenAI API key is required for this participant
|
|
340
|
+
? readyMessage(`Codex CLI ${readiness.cliVersion ?? "(unknown release)"} passed the operator handshake without a turn (initialize, config/read, account/read and an ephemeral thread/start): model ${readiness.resolvedModel ?? "(not reported)"} on ${account}. E2B supplies the desktop; no OpenAI API key is required for this participant.${operatorNote === undefined ? "" : ` ${operatorNote}`}`, readiness)
|
|
331
341
|
: `Hosted Codex participant setup is unavailable (${readiness.errorCode}). ${codexRecovery(readiness, "Check `codex login status` and the operator's Codex configuration, then rerun doctor.")} No API fallback is used.`,
|
|
332
342
|
};
|
|
333
343
|
}
|
|
@@ -17,8 +17,9 @@ export function parseExecution(raw) {
|
|
|
17
17
|
if (target !== "local" &&
|
|
18
18
|
target !== "e2b-desktop" &&
|
|
19
19
|
target !== "e2b-terminal" &&
|
|
20
|
+
target !== "modal-desktop" &&
|
|
20
21
|
target !== "modal-terminal") {
|
|
21
|
-
return invalid("`execution.target` must be local, e2b-desktop, e2b-terminal, or modal-terminal.");
|
|
22
|
+
return invalid("`execution.target` must be local, e2b-desktop, e2b-terminal, modal-desktop, or modal-terminal.");
|
|
22
23
|
}
|
|
23
24
|
execution.target = target;
|
|
24
25
|
}
|
|
@@ -7,6 +7,7 @@ export declare function provisionedSubject(config: StudyConfig): ProvisionedSubj
|
|
|
7
7
|
/** Keys, env and local tools a live run checks right before it acquires anything. */
|
|
8
8
|
export declare function desktopRequirements(config: StudyConfig, args: {
|
|
9
9
|
e2b: boolean;
|
|
10
|
+
modal?: boolean;
|
|
10
11
|
brain: Brain;
|
|
11
12
|
localVm: boolean;
|
|
12
13
|
externalCatch: boolean;
|
package/dist/study/plan-base.js
CHANGED
|
@@ -73,6 +73,8 @@ export function desktopRequirements(config, args) {
|
|
|
73
73
|
const requirements = [];
|
|
74
74
|
if (args.e2b)
|
|
75
75
|
requirements.push({ kind: "key", name: "E2B_API_KEY" });
|
|
76
|
+
if (args.modal === true)
|
|
77
|
+
requirements.push({ kind: "key", name: "MODAL_TOKEN_ID" }, { kind: "key", name: "MODAL_TOKEN_SECRET" });
|
|
76
78
|
if (args.localVm)
|
|
77
79
|
requirements.push({ kind: "local-vm" });
|
|
78
80
|
if (args.brain.kind === "openai")
|
|
@@ -133,6 +133,8 @@ export interface AppUrlSubject {
|
|
|
133
133
|
*/
|
|
134
134
|
export type ComputerUseRunner = {
|
|
135
135
|
readonly desktop: "e2b-desktop";
|
|
136
|
+
/** Who hosts the desktop: E2B, or Modal for `execution.target: modal-desktop`. */
|
|
137
|
+
readonly provider: SandboxProviderId;
|
|
136
138
|
readonly brain: Brain;
|
|
137
139
|
readonly participants: NonEmpty<ComputerUseParticipant>;
|
|
138
140
|
readonly subject: AppUrlSubject | ProvisionedSubject | {
|
package/dist/study/types.d.ts
CHANGED
|
@@ -467,7 +467,7 @@ export interface StudyActor {
|
|
|
467
467
|
*/
|
|
468
468
|
dwell?: DwellWindow;
|
|
469
469
|
}
|
|
470
|
-
type StudyExecutionTarget = "local" | "e2b-desktop" | "e2b-terminal" | "modal-terminal";
|
|
470
|
+
type StudyExecutionTarget = "local" | "e2b-desktop" | "e2b-terminal" | "modal-desktop" | "modal-terminal";
|
|
471
471
|
/** Terminal transport: the captured non-interactive exec stream (stdin disabled). It is not an
|
|
472
472
|
* interactive duplex PTY; labeling captured exec output "pty" would be a claim/mechanism
|
|
473
473
|
* mismatch, so this route uses "exec-stream". */
|
package/dist/study/warnings.js
CHANGED
|
@@ -262,7 +262,9 @@ function routesOf(config) {
|
|
|
262
262
|
terminal: isTerminalProductComposition(config),
|
|
263
263
|
shared,
|
|
264
264
|
externalPublic: shared && config.subject.source === "app-url",
|
|
265
|
-
hostedCuaBrowser: config.execution?.target === "e2b-desktop"
|
|
265
|
+
hostedCuaBrowser: (config.execution?.target === "e2b-desktop" ||
|
|
266
|
+
config.execution?.target === "modal-desktop") &&
|
|
267
|
+
cua,
|
|
266
268
|
createsE2BDesktop: (cua || (scripted && config.subject.source === "clone")) &&
|
|
267
269
|
config.execution?.target === "e2b-desktop",
|
|
268
270
|
};
|
|
@@ -211,6 +211,11 @@ export declare const DESKTOP_CREATE_CLEANUP_TIMEOUT_MS = 10000;
|
|
|
211
211
|
* the id.
|
|
212
212
|
*/
|
|
213
213
|
export declare function observeDesktopAllocation(options: E2BDesktopCreateOptions, observer: (sandboxId: string) => void): void;
|
|
214
|
+
/**
|
|
215
|
+
* Tell the observer registered for `options` that its sandbox exists, for a module that starts its
|
|
216
|
+
* own desktop after create, as the Modal one does. An observer that throws does not fail the create.
|
|
217
|
+
*/
|
|
218
|
+
export declare function reportDesktopAllocation(options: E2BDesktopCreateOptions, sandboxId: string): void;
|
|
214
219
|
type DesktopCreateCleanup = "killed" | "already_gone" | "unconfirmed";
|
|
215
220
|
/** Startup failed after this call acquired a handle. No credentials/options are included here. */
|
|
216
221
|
export declare class E2BDesktopStartupError extends Error {
|
|
@@ -31,6 +31,18 @@ const allocationObservers = new WeakMap();
|
|
|
31
31
|
export function observeDesktopAllocation(options, observer) {
|
|
32
32
|
allocationObservers.set(options, observer);
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Tell the observer registered for `options` that its sandbox exists, for a module that starts its
|
|
36
|
+
* own desktop after create, as the Modal one does. An observer that throws does not fail the create.
|
|
37
|
+
*/
|
|
38
|
+
export function reportDesktopAllocation(options, sandboxId) {
|
|
39
|
+
try {
|
|
40
|
+
allocationObservers.get(options)?.(sandboxId);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// The resolved handle reports the id as well.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
34
46
|
/** Startup failed after this call acquired a handle. No credentials/options are included here. */
|
|
35
47
|
export class E2BDesktopStartupError extends Error {
|
|
36
48
|
cleanup;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ReadableStream } from "node:stream/web";
|
|
2
|
+
import type { E2BCommandResult, E2BCommandRunOptions } from "../e2b/sdk.js";
|
|
3
|
+
import type { ModalSandbox } from "./sdk.js";
|
|
4
|
+
/** Modal takes sandbox and command timeouts in whole seconds; a shorter one is rounded up. */
|
|
5
|
+
export declare const wholeSeconds: (ms: number) => number;
|
|
6
|
+
/** Run `command` as the sandbox user, to completion or, with `background`, returning a handle. */
|
|
7
|
+
export declare function runCommand(sandbox: ModalSandbox, command: string, options?: E2BCommandRunOptions): Promise<E2BCommandResult>;
|
|
8
|
+
/** Relative paths are under the sandbox user's home, as on E2B. */
|
|
9
|
+
export declare function sandboxPath(path: string): string;
|
|
10
|
+
export declare function writeFile(sandbox: ModalSandbox, path: string, data: string | ArrayBuffer): Promise<{
|
|
11
|
+
path: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** A file's bytes as a stream, read as the sandbox user. */
|
|
14
|
+
export declare function readFile(sandbox: ModalSandbox, path: string): Promise<ReadableStream<Uint8Array>>;
|
|
15
|
+
/** A file's bytes, whole. */
|
|
16
|
+
export declare function readBytes(sandbox: ModalSandbox, path: string): Promise<Uint8Array>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Commands and files in a Modal sandbox, with the E2B SDK's contracts, which the routes were written
|
|
2
|
+
// against: a non-zero exit throws CommandExitError, a timeout throws TimeoutError, relative paths
|
|
3
|
+
// are under the user's home, and a background command returns a handle to wait on, kill or feed.
|
|
4
|
+
//
|
|
5
|
+
// Modal runs every exec as root and ignores the image's user setting, so each command drops to the
|
|
6
|
+
// sandbox user here, the account E2B runs commands as. Modal has no way to signal one exec, so a
|
|
7
|
+
// background command records its pid in a file and is killed by that pid.
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { ReadableStream } from "node:stream/web";
|
|
10
|
+
import { shellQuote } from "../shell.js";
|
|
11
|
+
import { MODAL_SANDBOX_USER } from "./image.js";
|
|
12
|
+
/** E2B's commands.run default, kept so a caller that sets no timeout gets the same bound. */
|
|
13
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
|
14
|
+
/** Modal takes sandbox and command timeouts in whole seconds; a shorter one is rounded up. */
|
|
15
|
+
export const wholeSeconds = (ms) => Math.ceil(ms / 1000) * 1000;
|
|
16
|
+
/** The argv that runs `command` in a login shell as the sandbox user, in their home. */
|
|
17
|
+
function asSandboxUser(command) {
|
|
18
|
+
const { uid } = MODAL_SANDBOX_USER;
|
|
19
|
+
return [
|
|
20
|
+
"setpriv",
|
|
21
|
+
`--reuid=${uid}`,
|
|
22
|
+
`--regid=${uid}`,
|
|
23
|
+
"--init-groups",
|
|
24
|
+
"--",
|
|
25
|
+
"bash",
|
|
26
|
+
"-lc",
|
|
27
|
+
command,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
const USER_ENV = {
|
|
31
|
+
HOME: MODAL_SANDBOX_USER.home,
|
|
32
|
+
USER: MODAL_SANDBOX_USER.name,
|
|
33
|
+
LOGNAME: MODAL_SANDBOX_USER.name,
|
|
34
|
+
};
|
|
35
|
+
/** The E2B SDK throws this shape on a non-zero exit; src/substrates/command-failure.ts reads it. */
|
|
36
|
+
class ModalCommandExitError extends Error {
|
|
37
|
+
exitCode;
|
|
38
|
+
stdout;
|
|
39
|
+
stderr;
|
|
40
|
+
constructor(exitCode, stdout, stderr) {
|
|
41
|
+
super(`Command exited with code ${exitCode}${stderr ? ` and error:\n${stderr}` : ""}`);
|
|
42
|
+
this.exitCode = exitCode;
|
|
43
|
+
this.stdout = stdout;
|
|
44
|
+
this.stderr = stderr;
|
|
45
|
+
this.name = "CommandExitError";
|
|
46
|
+
}
|
|
47
|
+
get error() {
|
|
48
|
+
return this.stderr;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function collect(stream, onData) {
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let text = "";
|
|
54
|
+
for await (const chunk of stream) {
|
|
55
|
+
const data = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
56
|
+
text += data;
|
|
57
|
+
if (onData !== undefined && data !== "")
|
|
58
|
+
await onData(data);
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
/** Modal ends a command at its timeout and reports it as a deadline; E2B names it TimeoutError. */
|
|
63
|
+
function named(error, timeoutMs) {
|
|
64
|
+
if (!(error instanceof Error) || !/deadline exceeded/i.test(error.message))
|
|
65
|
+
return error;
|
|
66
|
+
const timeout = new Error(`Command did not finish within ${timeoutMs} ms.`, { cause: error });
|
|
67
|
+
timeout.name = "TimeoutError";
|
|
68
|
+
return timeout;
|
|
69
|
+
}
|
|
70
|
+
/** Run `command` as the sandbox user, to completion or, with `background`, returning a handle. */
|
|
71
|
+
export async function runCommand(sandbox, command, options = {}) {
|
|
72
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
73
|
+
const pidFile = options.background === true ? `/tmp/humanish-exec-${randomUUID()}.pid` : "";
|
|
74
|
+
const line = options.background === true
|
|
75
|
+
? `printf '%s\\n' "$$" > ${pidFile}; exec bash -c ${shellQuote(command)}`
|
|
76
|
+
: command;
|
|
77
|
+
let finished;
|
|
78
|
+
let child;
|
|
79
|
+
try {
|
|
80
|
+
child = await sandbox.exec(asSandboxUser(line), {
|
|
81
|
+
env: { ...USER_ENV, ...options.envs },
|
|
82
|
+
...(options.cwd === undefined ? {} : { workdir: options.cwd }),
|
|
83
|
+
...(timeoutMs > 0 ? { timeoutMs: wholeSeconds(timeoutMs) } : {}),
|
|
84
|
+
});
|
|
85
|
+
// E2B starts a command with no stdin unless asked; a Modal exec keeps the pipe open, and a
|
|
86
|
+
// reader such as codex exec would wait on it until its timeout.
|
|
87
|
+
if (options.stdin !== true)
|
|
88
|
+
await child.closeStdin();
|
|
89
|
+
const running = child;
|
|
90
|
+
finished = Promise.all([
|
|
91
|
+
collect(running.stdout, options.onStdout),
|
|
92
|
+
collect(running.stderr, options.onStderr),
|
|
93
|
+
running.wait(),
|
|
94
|
+
]).then(([stdout, stderr, exitCode]) => {
|
|
95
|
+
if (exitCode !== 0)
|
|
96
|
+
throw new ModalCommandExitError(exitCode, stdout, stderr);
|
|
97
|
+
return { exitCode, stdout, stderr };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw named(error, timeoutMs);
|
|
102
|
+
}
|
|
103
|
+
if (options.background !== true)
|
|
104
|
+
return finished.catch((error) => {
|
|
105
|
+
throw named(error, timeoutMs);
|
|
106
|
+
});
|
|
107
|
+
// The caller decides when to wait; an exit nobody waits for is not an unhandled rejection.
|
|
108
|
+
finished.catch(() => { });
|
|
109
|
+
const running = child;
|
|
110
|
+
// E2B's handle carries the process's pid, which owners such as the recorder check.
|
|
111
|
+
const pid = await backgroundPid(sandbox, pidFile);
|
|
112
|
+
return {
|
|
113
|
+
...(pid === undefined ? {} : { pid }),
|
|
114
|
+
wait: () => finished,
|
|
115
|
+
kill: async () => {
|
|
116
|
+
const killed = await runCommand(sandbox, `kill -KILL "$(cat ${pidFile})" 2>/dev/null && echo killed || true`, { timeoutMs: 10_000 });
|
|
117
|
+
return killed.stdout?.trim() === "killed";
|
|
118
|
+
},
|
|
119
|
+
sendStdin: (data) => typeof data === "string" ? running.stdin.writeText(data) : running.stdin.writeBytes(data),
|
|
120
|
+
closeStdin: () => running.stdin.close(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** How long a background command has to record its pid before its handle reports none. */
|
|
124
|
+
const PID_WAIT_MS = 5_000;
|
|
125
|
+
/** The pid a background command wrote to `pidFile`, or undefined if none arrived in time. */
|
|
126
|
+
async function backgroundPid(sandbox, pidFile) {
|
|
127
|
+
for (const end = Date.now() + PID_WAIT_MS; Date.now() < end;) {
|
|
128
|
+
const read = await runCommand(sandbox, `cat ${pidFile} 2>/dev/null || true`, {
|
|
129
|
+
timeoutMs: 10_000,
|
|
130
|
+
});
|
|
131
|
+
const pid = Number(read.stdout?.trim());
|
|
132
|
+
if (Number.isSafeInteger(pid) && pid > 0)
|
|
133
|
+
return pid;
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/** Relative paths are under the sandbox user's home, as on E2B. */
|
|
139
|
+
export function sandboxPath(path) {
|
|
140
|
+
return path.startsWith("/") ? path : `${MODAL_SANDBOX_USER.home}/${path}`;
|
|
141
|
+
}
|
|
142
|
+
export async function writeFile(sandbox, path, data) {
|
|
143
|
+
const target = sandboxPath(path);
|
|
144
|
+
const child = await sandbox.exec(asSandboxUser('mkdir -p -- "$(dirname -- "$0")" && cat > "$0"').concat(target), { mode: "binary", env: USER_ENV });
|
|
145
|
+
await child.stdin.writeBytes(typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data));
|
|
146
|
+
await child.stdin.close();
|
|
147
|
+
const [stderr, exitCode] = await Promise.all([child.stderr.readBytes(), child.wait()]);
|
|
148
|
+
if (exitCode !== 0)
|
|
149
|
+
throw new ModalCommandExitError(exitCode, "", new TextDecoder().decode(stderr));
|
|
150
|
+
return { path: target };
|
|
151
|
+
}
|
|
152
|
+
/** A file's bytes as a stream, read as the sandbox user. */
|
|
153
|
+
export async function readFile(sandbox, path) {
|
|
154
|
+
const child = await sandbox.exec(asSandboxUser('cat -- "$0"').concat(sandboxPath(path)), {
|
|
155
|
+
mode: "binary",
|
|
156
|
+
env: USER_ENV,
|
|
157
|
+
});
|
|
158
|
+
await child.closeStdin();
|
|
159
|
+
const bytes = async function* () {
|
|
160
|
+
for await (const chunk of child.stdout)
|
|
161
|
+
yield typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
162
|
+
const exitCode = await child.wait();
|
|
163
|
+
if (exitCode !== 0)
|
|
164
|
+
throw new ModalCommandExitError(exitCode, "", await child.stderr.readText());
|
|
165
|
+
};
|
|
166
|
+
return ReadableStream.from(bytes());
|
|
167
|
+
}
|
|
168
|
+
/** A file's bytes, whole. */
|
|
169
|
+
export async function readBytes(sandbox, path) {
|
|
170
|
+
const parts = [];
|
|
171
|
+
for await (const chunk of await readFile(sandbox, path))
|
|
172
|
+
parts.push(chunk);
|
|
173
|
+
return Buffer.concat(parts);
|
|
174
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { E2BCommandResult, E2BCommandRunOptions, E2BDesktopSandbox } from "../e2b/sdk.js";
|
|
2
|
+
type Run = (command: string, options?: E2BCommandRunOptions) => Promise<E2BCommandResult>;
|
|
3
|
+
/** The X display and the ports the desktop's stream uses, as @e2b/desktop sets them. */
|
|
4
|
+
export declare const MODAL_DESKTOP_DISPLAY = ":0";
|
|
5
|
+
export declare const MODAL_STREAM_PORT = 6080;
|
|
6
|
+
/** Start Xvfb at the requested size, then an XFCE session, as @e2b/desktop's create does. */
|
|
7
|
+
export declare function startModalDesktop(run: Run, options: {
|
|
8
|
+
resolution?: [number, number];
|
|
9
|
+
dpi?: number;
|
|
10
|
+
}): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* The desktop half of a sandbox handle. `hosts` maps each tunneled port to the host Modal serves it
|
|
13
|
+
* on; `readBytes` reads a file in the sandbox.
|
|
14
|
+
*/
|
|
15
|
+
export declare function modalDesktopSurface(run: Run, readBytes: (path: string) => Promise<Uint8Array>, hosts: ReadonlyMap<number, string>): Omit<E2BDesktopSandbox, "sandboxId" | "getInfo" | "commands" | "files">;
|
|
16
|
+
export {};
|