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/dist/run/reclaim.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// `humanish reclaim`: stop an interrupted run's sandboxes and report what happened to each. It
|
|
2
2
|
// finds them three ways: the exact ids the run journaled at create time (sandbox-receipts.ndjson),
|
|
3
3
|
// the ids a create in this process reported before its receipt landed (the signal handler only),
|
|
4
|
-
// and
|
|
5
|
-
// sandbox whose id never reached the run.
|
|
4
|
+
// and each provider's list of sandboxes tagged with the run's owner tags (sandboxOwnerTags), which
|
|
5
|
+
// covers a sandbox whose id never reached the run. Every sandbox is acted on through the provider
|
|
6
|
+
// its receipt or owner line names: E2B, or Modal (src/substrates/modal/sandbox.ts). The list is filtered server-side by the run's tags and
|
|
6
7
|
// each result is checked against every tag again, so it can only return this run's sandboxes; an
|
|
7
8
|
// account-wide operation once destroyed unrelated infrastructure. Kills run concurrently, and
|
|
8
9
|
// reclaim-receipt.json is written before the first kill and after each one, so an exit at any
|
|
9
|
-
// point leaves a record of every sandbox not yet confirmed gone. `--check` asks
|
|
10
|
-
// questions and kills nothing.
|
|
10
|
+
// point leaves a record of every sandbox not yet confirmed gone. `--check` asks the providers the
|
|
11
|
+
// same questions and kills nothing.
|
|
11
12
|
import { e2bAccount, e2bConnection } from "../substrates/e2b/connection.js";
|
|
13
|
+
import { modalAccount } from "../substrates/modal/sandbox.js";
|
|
12
14
|
import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
|
|
13
15
|
import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
|
|
14
16
|
import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
|
|
@@ -261,6 +263,45 @@ function unreadableMessage(file, refusal) {
|
|
|
261
263
|
const kept = file === SANDBOX_RECEIPTS_ARTIFACT ? "the receipts were kept" : "the receipt was kept";
|
|
262
264
|
return `${refusalText(file, refusal)}. Nothing was killed and ${kept}; check the file, then run reclaim again.`;
|
|
263
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* The account for each provider the run's journal names, or the message that stops reclaim when
|
|
268
|
+
* one it needs cannot be reached. A run with no record and creates in flight is an E2B run unless
|
|
269
|
+
* its owner line says otherwise, as every run was before Modal.
|
|
270
|
+
*/
|
|
271
|
+
async function reachProviders(receipts, owners, hooks) {
|
|
272
|
+
const env = hooks.env ?? process.env;
|
|
273
|
+
const recorded = new Set([
|
|
274
|
+
...receipts.map((receipt) => receipt.provider),
|
|
275
|
+
...owners.map((owner) => owner.provider),
|
|
276
|
+
]);
|
|
277
|
+
// A create this process began counts too: its receipt and owner line may not have landed.
|
|
278
|
+
for (const provider of hooks.creates?.providers() ?? [])
|
|
279
|
+
recorded.add(provider);
|
|
280
|
+
const needsE2B = recorded.has("e2b") || (hooks.creates !== undefined && !recorded.has("modal"));
|
|
281
|
+
// E2B's SDK lists by tag as well as killing by id, so it is needed even with no receipt. Without
|
|
282
|
+
// it, an E2B receipt cannot be acted on; with no E2B receipt, only the tag search is lost.
|
|
283
|
+
const accounts = {};
|
|
284
|
+
const unavailable = {};
|
|
285
|
+
try {
|
|
286
|
+
const module = await (hooks.loadModule ?? loadE2BDesktopModule)();
|
|
287
|
+
accounts.e2b = e2bAccount(module, e2bConnection(env));
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
unavailable.e2b = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
|
|
291
|
+
if (needsE2B)
|
|
292
|
+
return unavailable.e2b;
|
|
293
|
+
}
|
|
294
|
+
// Modal is reached only for a run that recorded a Modal sandbox or owner line.
|
|
295
|
+
if (recorded.has("modal")) {
|
|
296
|
+
try {
|
|
297
|
+
accounts.modal = await (hooks.loadModalAccount ?? modalAccount)(env);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
return `Cannot reach Modal: ${redactText(toErrorMessage(error))}`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { accounts, unavailable, recorded, needsE2B };
|
|
304
|
+
}
|
|
264
305
|
/**
|
|
265
306
|
* Find and stop (or, for `--check`, look up) every sandbox one directory owns: its receipts, the
|
|
266
307
|
* creates this process reported, and E2B's tagged list, each once by exact id.
|
|
@@ -277,19 +318,10 @@ async function reclaimRoot(root, label, hooks, warnings) {
|
|
|
277
318
|
const owners = parseSandboxOwners(journal, label);
|
|
278
319
|
const check = hooks.check === true;
|
|
279
320
|
const requestTimeoutMs = hooks.requestTimeoutMs ?? 60_000;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
const module = await (hooks.loadModule ?? loadE2BDesktopModule)();
|
|
286
|
-
e2b = e2bAccount(module, e2bConnection(hooks.env ?? process.env));
|
|
287
|
-
}
|
|
288
|
-
catch (error) {
|
|
289
|
-
moduleError = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
|
|
290
|
-
if (receipts.some((receipt) => receipt.provider === "e2b") || hooks.creates !== undefined)
|
|
291
|
-
return { kind: "module-unavailable", receiptCount: receipts.length, message: moduleError };
|
|
292
|
-
}
|
|
321
|
+
const reached = await reachProviders(receipts, owners, hooks);
|
|
322
|
+
if (typeof reached === "string")
|
|
323
|
+
return { kind: "module-unavailable", receiptCount: receipts.length, message: reached };
|
|
324
|
+
const { accounts, unavailable, recorded, needsE2B } = reached;
|
|
293
325
|
// An earlier reclaim may have killed a sandbox no receipt names (the signal handler's creates
|
|
294
326
|
// and tag finds). Its outcome is carried forward, so a later reclaim never erases that record.
|
|
295
327
|
const earlier = check ? [] : await earlierOutcomes(root, label);
|
|
@@ -297,7 +329,7 @@ async function reclaimRoot(root, label, hooks, warnings) {
|
|
|
297
329
|
if (!Array.isArray(earlier))
|
|
298
330
|
return { kind: "unreadable", message: unreadableMessage(RECLAIM_RECEIPT_ARTIFACT, earlier) };
|
|
299
331
|
const set = targetSet({
|
|
300
|
-
|
|
332
|
+
accounts,
|
|
301
333
|
check,
|
|
302
334
|
requestTimeoutMs,
|
|
303
335
|
earlier,
|
|
@@ -312,38 +344,53 @@ async function reclaimRoot(root, label, hooks, warnings) {
|
|
|
312
344
|
if (replaying)
|
|
313
345
|
replayed.set(sandbox.sandboxId, sandbox);
|
|
314
346
|
else
|
|
315
|
-
set.add(
|
|
347
|
+
set.add(sandbox.provider, sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
|
|
316
348
|
});
|
|
317
349
|
replaying = false;
|
|
318
350
|
for (const receipt of receipts) {
|
|
319
|
-
const
|
|
351
|
+
const create = replayed.get(receipt.sandboxId);
|
|
352
|
+
const released = create?.provider === receipt.provider && create.released;
|
|
320
353
|
set.add(receipt.provider, receipt.sandboxId, receipt.laneId, "receipt", released === true);
|
|
321
354
|
}
|
|
322
355
|
for (const sandbox of replayed.values())
|
|
323
|
-
set.add(
|
|
356
|
+
set.add(sandbox.provider, sandbox.sandboxId, sandbox.participantId, "create", sandbox.released);
|
|
324
357
|
await set.start();
|
|
325
358
|
if (hooks.creates !== undefined)
|
|
326
359
|
await settledWithin(hooks.creates.settled, hooks.createsWaitMs);
|
|
327
|
-
//
|
|
328
|
-
// run directory computes other tags than the ones its
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
360
|
+
// Each provider is searched with the tags the run recorded for it before each create, and with
|
|
361
|
+
// this directory's own: a copied or moved run directory computes other tags than the ones its
|
|
362
|
+
// sandboxes carry. E2B is searched unless the journal names only Modal, as before Modal existed.
|
|
363
|
+
const providers = [
|
|
364
|
+
...(needsE2B || !recorded.has("modal") ? ["e2b"] : []),
|
|
365
|
+
...(accounts.modal === undefined ? [] : ["modal"]),
|
|
366
|
+
];
|
|
367
|
+
const searches = await Promise.all(providers.map(async (provider) => {
|
|
368
|
+
const tagSets = [
|
|
369
|
+
...owners.filter((owner) => owner.provider === provider).map((owner) => owner.tags),
|
|
370
|
+
sandboxOwnerTags(root),
|
|
371
|
+
];
|
|
372
|
+
const result = await searchTagSets(accounts[provider], tagSets, {
|
|
373
|
+
requestTimeoutMs,
|
|
374
|
+
unavailable: unavailable[provider] ?? `no ${provider} account`,
|
|
375
|
+
});
|
|
376
|
+
return { provider, result };
|
|
377
|
+
}));
|
|
333
378
|
let found = 0;
|
|
334
|
-
for (const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
379
|
+
for (const { provider, result } of searches)
|
|
380
|
+
for (const listed of result.sandboxes) {
|
|
381
|
+
const metadata = listed.metadata ?? {};
|
|
382
|
+
const participant = metadata.participantId ?? metadata.kind ?? "unknown";
|
|
383
|
+
if (set.add(provider, listed.sandboxId, participant, "tag"))
|
|
384
|
+
found += 1;
|
|
385
|
+
}
|
|
386
|
+
const search = searches.find(({ result }) => result.status !== "done")?.result ?? searches[0].result;
|
|
340
387
|
const tagSearch = {
|
|
341
388
|
status: search.status,
|
|
342
389
|
found,
|
|
343
390
|
...(search.detail === undefined ? {} : { detail: search.detail }),
|
|
344
391
|
};
|
|
345
392
|
if (search.status !== "done")
|
|
346
|
-
warnings.push(`Could not search
|
|
393
|
+
warnings.push(`Could not search for sandboxes tagged with ${label}: ${search.detail ?? search.status}. A sandbox whose id never reached a receipt would not be found.`);
|
|
347
394
|
// A create that reports its id while the search or a kill is still running is killed too.
|
|
348
395
|
await set.drain();
|
|
349
396
|
unwatch?.();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PreparedOutputRoot } from "./contained-output.js";
|
|
2
|
-
import type { SandboxOwnerTags } from "./sandbox-receipts.js";
|
|
2
|
+
import type { SandboxOwnerTags, SandboxProviderId } from "./sandbox-receipts.js";
|
|
3
3
|
/** Refuses a sandbox create for a run directory whose process is stopping. */
|
|
4
4
|
export declare class SandboxCreateRefusedError extends Error {
|
|
5
5
|
constructor();
|
|
@@ -7,6 +7,7 @@ export declare class SandboxCreateRefusedError extends Error {
|
|
|
7
7
|
/** One sandbox a create in this process reported, and what its route did with it. */
|
|
8
8
|
export interface CreatedSandbox {
|
|
9
9
|
sandboxId: string;
|
|
10
|
+
provider: SandboxProviderId;
|
|
10
11
|
/** The participant or subject label the receipt also records. */
|
|
11
12
|
participantId: string;
|
|
12
13
|
/** True once the route's own release confirmed the sandbox gone. */
|
|
@@ -32,14 +33,14 @@ export interface SandboxCreateTicket {
|
|
|
32
33
|
settled(): void;
|
|
33
34
|
}
|
|
34
35
|
/** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
|
|
35
|
-
export declare function beginSandboxCreate(root: PreparedOutputRoot, participantId: string): SandboxCreateTicket;
|
|
36
|
+
export declare function beginSandboxCreate(root: PreparedOutputRoot, participantId: string, provider: SandboxProviderId): SandboxCreateTicket;
|
|
36
37
|
/**
|
|
37
|
-
* Run `write` once per directory and return its promise to every caller. Creates that start
|
|
38
|
+
* Run `write` once per directory and provider and return its promise to every caller. Creates that start
|
|
38
39
|
* together then resume in the order they called, so the order they reach the provider does not
|
|
39
40
|
* depend on which file append finishes first. A write that reports failure is forgotten, so the
|
|
40
41
|
* next create tries again.
|
|
41
42
|
*/
|
|
42
|
-
export declare function recordSandboxOwnerOnce(root: PreparedOutputRoot, write: () => Promise<boolean>): Promise<void>;
|
|
43
|
+
export declare function recordSandboxOwnerOnce(root: PreparedOutputRoot, provider: SandboxProviderId, write: () => Promise<boolean>): Promise<void>;
|
|
43
44
|
/** What the signal handler holds after stopping a directory's creates. */
|
|
44
45
|
export interface StoppedSandboxCreates {
|
|
45
46
|
/**
|
|
@@ -51,6 +52,8 @@ export interface StoppedSandboxCreates {
|
|
|
51
52
|
settled: Promise<void>;
|
|
52
53
|
/** How many of those creates have not settled yet. */
|
|
53
54
|
inFlight(): number;
|
|
55
|
+
/** The providers creates began on, including those whose id has not arrived. */
|
|
56
|
+
providers(): ReadonlySet<SandboxProviderId>;
|
|
54
57
|
}
|
|
55
58
|
/**
|
|
56
59
|
* Stop `root`'s creates: any create that starts from now on is refused. Called synchronously when
|
|
@@ -36,6 +36,8 @@ function directoryFor(root) {
|
|
|
36
36
|
return existing;
|
|
37
37
|
const created = {
|
|
38
38
|
stopping: false,
|
|
39
|
+
ownerLines: new Map(),
|
|
40
|
+
providers: new Set(),
|
|
39
41
|
created: new Map(),
|
|
40
42
|
inFlight: new Set(),
|
|
41
43
|
listeners: new Set(),
|
|
@@ -73,10 +75,11 @@ export function sandboxOwnerTags(root) {
|
|
|
73
75
|
return { tool: "humanish", runId: path.basename(where), runKey };
|
|
74
76
|
}
|
|
75
77
|
/** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
|
|
76
|
-
export function beginSandboxCreate(root, participantId) {
|
|
78
|
+
export function beginSandboxCreate(root, participantId, provider) {
|
|
77
79
|
const directory = directoryFor(root);
|
|
78
80
|
if (directory.stopping)
|
|
79
81
|
throw new SandboxCreateRefusedError();
|
|
82
|
+
directory.providers.add(provider);
|
|
80
83
|
let settle;
|
|
81
84
|
const done = new Promise((resolve) => {
|
|
82
85
|
settle = resolve;
|
|
@@ -87,7 +90,7 @@ export function beginSandboxCreate(root, participantId) {
|
|
|
87
90
|
created(sandboxId) {
|
|
88
91
|
if (directory.created.has(sandboxId))
|
|
89
92
|
return;
|
|
90
|
-
const sandbox = { sandboxId, participantId, released: false };
|
|
93
|
+
const sandbox = { sandboxId, provider, participantId, released: false };
|
|
91
94
|
directory.created.set(sandboxId, sandbox);
|
|
92
95
|
for (const listener of directory.listeners)
|
|
93
96
|
listener(sandbox);
|
|
@@ -104,17 +107,21 @@ export function beginSandboxCreate(root, participantId) {
|
|
|
104
107
|
};
|
|
105
108
|
}
|
|
106
109
|
/**
|
|
107
|
-
* Run `write` once per directory and return its promise to every caller. Creates that start
|
|
110
|
+
* Run `write` once per directory and provider and return its promise to every caller. Creates that start
|
|
108
111
|
* together then resume in the order they called, so the order they reach the provider does not
|
|
109
112
|
* depend on which file append finishes first. A write that reports failure is forgotten, so the
|
|
110
113
|
* next create tries again.
|
|
111
114
|
*/
|
|
112
|
-
export function recordSandboxOwnerOnce(root, write) {
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
export function recordSandboxOwnerOnce(root, provider, write) {
|
|
116
|
+
const { ownerLines } = directoryFor(root);
|
|
117
|
+
const existing = ownerLines.get(provider);
|
|
118
|
+
if (existing !== undefined)
|
|
119
|
+
return existing;
|
|
120
|
+
const pending = write().then((written) => {
|
|
121
|
+
if (!written && ownerLines.get(provider) === pending)
|
|
122
|
+
ownerLines.delete(provider);
|
|
123
|
+
});
|
|
124
|
+
ownerLines.set(provider, pending);
|
|
118
125
|
return pending;
|
|
119
126
|
}
|
|
120
127
|
/**
|
|
@@ -136,5 +143,6 @@ export function stopSandboxCreates(root) {
|
|
|
136
143
|
},
|
|
137
144
|
settled: Promise.all(pending).then(() => undefined),
|
|
138
145
|
inFlight: () => pending.filter((create) => directory.inFlight.has(create)).length,
|
|
146
|
+
providers: () => directory.providers,
|
|
139
147
|
};
|
|
140
148
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type PreparedOutputRoot } from "./contained-output.js";
|
|
2
2
|
export declare const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
|
|
3
3
|
/** The providers humanish allocates sandboxes from. Reclaim dispatches on this id. */
|
|
4
|
-
export type SandboxProviderId = "e2b";
|
|
4
|
+
export type SandboxProviderId = "e2b" | "modal";
|
|
5
|
+
/** Whether this humanish can act on sandboxes from `provider`. */
|
|
6
|
+
export declare function isSandboxProviderId(provider: string): provider is SandboxProviderId;
|
|
5
7
|
export interface SandboxReceipt {
|
|
6
8
|
at: string;
|
|
7
9
|
/** The participant or subject label the sandbox belongs to (public-safe token). */
|
|
@@ -31,18 +33,24 @@ export declare function appendedSandboxIds(root: PreparedOutputRoot): string[];
|
|
|
31
33
|
*/
|
|
32
34
|
export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<boolean>;
|
|
33
35
|
/** Append one owner line; false when the write failed, which never fails the create. */
|
|
34
|
-
export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags): Promise<boolean>;
|
|
36
|
+
export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags, provider: SandboxProviderId): Promise<boolean>;
|
|
35
37
|
/** The owner tags every sandbox carries; sandboxOwnerTags in src/run/sandbox-creates.ts. */
|
|
36
38
|
export type SandboxOwnerTags = {
|
|
37
39
|
tool: "humanish";
|
|
38
40
|
runId: string;
|
|
39
41
|
runKey: string;
|
|
40
42
|
};
|
|
43
|
+
/** One owner line: the provider searched and the tags searched for. */
|
|
44
|
+
export interface SandboxOwner {
|
|
45
|
+
provider: SandboxProviderId;
|
|
46
|
+
tags: SandboxOwnerTags;
|
|
47
|
+
}
|
|
41
48
|
/**
|
|
42
49
|
* The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
|
|
43
|
-
* tuple counts: provider
|
|
44
|
-
* else. A partial or foreign line would widen reclaim's search past this run, so
|
|
50
|
+
* tuple counts: a provider this version knows, `tool: humanish`, this run's id and a well-formed
|
|
51
|
+
* key, and nothing else. A partial or foreign line would widen reclaim's search past this run, so
|
|
52
|
+
* it is skipped.
|
|
45
53
|
*/
|
|
46
|
-
export declare function parseSandboxOwners(text: string, runId: string):
|
|
54
|
+
export declare function parseSandboxOwners(text: string, runId: string): SandboxOwner[];
|
|
47
55
|
/** Parse a receipts file leniently: a torn final line (crash mid-append) drops, valid lines keep. */
|
|
48
56
|
export declare function parseSandboxReceipts(text: string): ParsedSandboxReceipt[];
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
import { appendFile } from "node:fs/promises";
|
|
7
7
|
import { prepareContainedOutputFile } from "./contained-output.js";
|
|
8
8
|
export const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
|
|
9
|
+
const SANDBOX_PROVIDERS = new Set(["e2b", "modal"]);
|
|
10
|
+
/** Whether this humanish can act on sandboxes from `provider`. */
|
|
11
|
+
export function isSandboxProviderId(provider) {
|
|
12
|
+
return SANDBOX_PROVIDERS.has(provider);
|
|
13
|
+
}
|
|
9
14
|
/**
|
|
10
15
|
* The ids this process has receipted for each run directory, kept even when the append fails, so
|
|
11
16
|
* the run's other records can still be scrubbed of them. Keyed by the directory's identity, so a
|
|
@@ -51,9 +56,9 @@ export async function appendSandboxReceipt(root, receipt) {
|
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
/** Append one owner line; false when the write failed, which never fails the create. */
|
|
54
|
-
export async function appendSandboxOwner(root, owner) {
|
|
59
|
+
export async function appendSandboxOwner(root, owner, provider) {
|
|
55
60
|
try {
|
|
56
|
-
const line = { at: new Date().toISOString(), provider
|
|
61
|
+
const line = { at: new Date().toISOString(), provider, owner };
|
|
57
62
|
const filePath = await prepareContainedOutputFile(root, SANDBOX_RECEIPTS_ARTIFACT);
|
|
58
63
|
// The leading newline ends a line an earlier append left torn, so this record parses.
|
|
59
64
|
await appendFile(filePath, `\n${JSON.stringify(line)}\n`, "utf8");
|
|
@@ -68,8 +73,9 @@ export async function appendSandboxOwner(root, owner) {
|
|
|
68
73
|
const RUN_KEY = /^[0-9a-f]{16}$/;
|
|
69
74
|
/**
|
|
70
75
|
* The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
|
|
71
|
-
* tuple counts: provider
|
|
72
|
-
* else. A partial or foreign line would widen reclaim's search past this run, so
|
|
76
|
+
* tuple counts: a provider this version knows, `tool: humanish`, this run's id and a well-formed
|
|
77
|
+
* key, and nothing else. A partial or foreign line would widen reclaim's search past this run, so
|
|
78
|
+
* it is skipped.
|
|
73
79
|
*/
|
|
74
80
|
export function parseSandboxOwners(text, runId) {
|
|
75
81
|
const owners = new Map();
|
|
@@ -80,13 +86,19 @@ export function parseSandboxOwners(text, runId) {
|
|
|
80
86
|
try {
|
|
81
87
|
const parsed = JSON.parse(trimmed);
|
|
82
88
|
const owner = parsed.owner;
|
|
83
|
-
|
|
89
|
+
const provider = parsed.provider;
|
|
90
|
+
if (typeof provider !== "string" || !isSandboxProviderId(provider))
|
|
91
|
+
continue;
|
|
92
|
+
if (owner === null || typeof owner !== "object")
|
|
84
93
|
continue;
|
|
85
94
|
if (Object.keys(owner).length !== 3 || owner.tool !== "humanish" || owner.runId !== runId)
|
|
86
95
|
continue;
|
|
87
96
|
if (typeof owner.runKey !== "string" || !RUN_KEY.test(owner.runKey))
|
|
88
97
|
continue;
|
|
89
|
-
owners.set(
|
|
98
|
+
owners.set(JSON.stringify([provider, owner.runKey]), {
|
|
99
|
+
provider,
|
|
100
|
+
tags: { tool: "humanish", runId, runKey: owner.runKey },
|
|
101
|
+
});
|
|
90
102
|
}
|
|
91
103
|
catch {
|
|
92
104
|
// A torn line: skip it.
|
|
@@ -258,15 +258,15 @@ function desktopCliValidationReason(config) {
|
|
|
258
258
|
return null;
|
|
259
259
|
}
|
|
260
260
|
// terminal-product route: a real autonomous agent studies a CLI/product from public surfaces inside
|
|
261
|
-
//
|
|
262
|
-
// parse error): a registered terminal actor only, execution.target e2b-terminal or
|
|
263
|
-
// defaults to e2b-terminal
|
|
264
|
-
// fan-out lands.
|
|
261
|
+
// a hosted shell. Fail-closed (claims match mechanism: a field that cannot act on this route is a
|
|
262
|
+
// parse error): a registered terminal actor only, execution.target e2b-terminal, modal-terminal or
|
|
263
|
+
// absent (absent defaults to e2b-terminal), one participant until fan-out lands.
|
|
265
264
|
function terminalValidationReason(config) {
|
|
266
265
|
if (config.subject.source === "terminal-product") {
|
|
267
266
|
const type = config.actor?.type ?? "";
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
const target = config.execution?.target;
|
|
268
|
+
if (target !== undefined && target !== "e2b-terminal" && target !== "modal-terminal") {
|
|
269
|
+
return "A terminal-product subject runs its agent in a hosted shell. Set `execution.target` to e2b-terminal or modal-terminal, or omit it for e2b-terminal; `local` and `e2b-desktop` are not supported.";
|
|
270
270
|
}
|
|
271
271
|
if (!actorResolvesToTerminal(type)) {
|
|
272
272
|
return `actor.type must be a registered terminal actor for terminal-product subjects (one of: ${registeredTerminalActors().join(", ")}). Got "${type}".`;
|
|
@@ -275,10 +275,11 @@ function terminalValidationReason(config) {
|
|
|
275
275
|
return "Terminal fan-out to more than one participant is not supported yet; set participants to 1.";
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
|
-
else if (config.execution?.target === "e2b-terminal"
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
278
|
+
else if (config.execution?.target === "e2b-terminal" ||
|
|
279
|
+
config.execution?.target === "modal-terminal") {
|
|
280
|
+
// The terminal targets are the terminal-product substrates only. Any other source declaring one
|
|
281
|
+
// is a mis-config: reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
|
|
282
|
+
return `\`execution.target: ${config.execution.target}\` requires \`subject.source: terminal-product\` with a registered terminal actor.`;
|
|
282
283
|
}
|
|
283
284
|
else if (actorResolvesToTerminal(config.actor?.type)) {
|
|
284
285
|
// A registered terminal actor on a non-terminal-product subject: rejected, never ignored (the
|
package/dist/study/doctor.js
CHANGED
|
@@ -279,7 +279,7 @@ async function realEmailCheck(connection, keys, args) {
|
|
|
279
279
|
/** How the participant authenticates: the terminal model key, or a local agent's login. */
|
|
280
280
|
async function participantChecks(config, route, keys, local, args, checkAccount) {
|
|
281
281
|
if (route === "terminal") {
|
|
282
|
-
const key = keys.find((name) => name
|
|
282
|
+
const key = keys.find((name) => name === "CODEX_API_KEY" || name === "OPENAI_API_KEY");
|
|
283
283
|
return [
|
|
284
284
|
{
|
|
285
285
|
name: "terminal model authentication",
|
|
@@ -14,8 +14,11 @@ export function parseExecution(raw) {
|
|
|
14
14
|
const execution = {};
|
|
15
15
|
if (raw.target !== undefined) {
|
|
16
16
|
const target = str(raw.target);
|
|
17
|
-
if (target !== "local" &&
|
|
18
|
-
|
|
17
|
+
if (target !== "local" &&
|
|
18
|
+
target !== "e2b-desktop" &&
|
|
19
|
+
target !== "e2b-terminal" &&
|
|
20
|
+
target !== "modal-terminal") {
|
|
21
|
+
return invalid("`execution.target` must be local, e2b-desktop, e2b-terminal, or modal-terminal.");
|
|
19
22
|
}
|
|
20
23
|
execution.target = target;
|
|
21
24
|
}
|
|
@@ -4,6 +4,7 @@ import type { ReasoningEffort } from "../actors/reasoning-effort.js";
|
|
|
4
4
|
import type { BrowserSurface } from "../actors/scripted-browser/types.js";
|
|
5
5
|
import type { ScriptedRefusal } from "../routes/scripted/plan.js";
|
|
6
6
|
import type { TerminalRefusal } from "../routes/terminal/plan.js";
|
|
7
|
+
import type { SandboxProviderId } from "../run/sandbox-receipts.js";
|
|
7
8
|
import type { RunStudyProvenance } from "../run/study-provenance.js";
|
|
8
9
|
import type { ArrivalPlan } from "./arrivals.js";
|
|
9
10
|
import type { BundleEstimate } from "./bundle-estimate.js";
|
|
@@ -19,7 +20,7 @@ type AtLeastTwo<T> = readonly [T, T, ...T[]];
|
|
|
19
20
|
*/
|
|
20
21
|
export type Requirement = {
|
|
21
22
|
readonly kind: "key";
|
|
22
|
-
readonly name: "OPENAI_API_KEY" | "E2B_API_KEY";
|
|
23
|
+
readonly name: "OPENAI_API_KEY" | "E2B_API_KEY" | "MODAL_TOKEN_ID" | "MODAL_TOKEN_SECRET";
|
|
23
24
|
}
|
|
24
25
|
/** The terminal runtime key: CODEX_API_KEY, else OPENAI_API_KEY. */
|
|
25
26
|
| {
|
|
@@ -223,6 +224,8 @@ export interface SharedWorldPlan extends PlanBase {
|
|
|
223
224
|
}
|
|
224
225
|
export type TerminalPlan = PlanBase & {
|
|
225
226
|
readonly route: "terminal";
|
|
227
|
+
/** Where the shell sandbox runs: `execution.target` modal-terminal, else E2B. */
|
|
228
|
+
readonly sandboxProvider: SandboxProviderId;
|
|
226
229
|
/** The registered terminal actor that runs. */
|
|
227
230
|
readonly actor: string;
|
|
228
231
|
readonly product: StudySubjectProduct & {
|
|
@@ -9,9 +9,14 @@ import { localAgentRefusal } from "../actors/local-agent/readiness.js";
|
|
|
9
9
|
import { describeMissingKeys } from "../keys/key-resolution.js";
|
|
10
10
|
import { MODEL_RATES, unpricedCapMessage } from "../run/pricing.js";
|
|
11
11
|
/** Provider keys in the order the routes name them in a refusal. */
|
|
12
|
-
const KEY_ORDER = [
|
|
12
|
+
const KEY_ORDER = [
|
|
13
|
+
"OPENAI_API_KEY",
|
|
14
|
+
"E2B_API_KEY",
|
|
15
|
+
"MODAL_TOKEN_ID",
|
|
16
|
+
"MODAL_TOKEN_SECRET",
|
|
17
|
+
];
|
|
13
18
|
const present = (env, name) => (env[name]?.trim() ?? "") !== "";
|
|
14
|
-
/** The `key` requirements whose value `env` lacks,
|
|
19
|
+
/** The `key` requirements whose value `env` lacks, in KEY_ORDER. */
|
|
15
20
|
function missingKeys(requirements, env) {
|
|
16
21
|
const required = new Set(requirements.flatMap((requirement) => (requirement.kind === "key" ? [requirement.name] : [])));
|
|
17
22
|
return KEY_ORDER.filter((name) => required.has(name) && !present(env, name));
|
package/dist/study/routing.d.ts
CHANGED
|
@@ -69,7 +69,7 @@ export declare function isScriptedBrowserComposition(config: StudyConfig): boole
|
|
|
69
69
|
export declare function isProvisionedScriptedBrowserComposition(config: StudyConfig): boolean;
|
|
70
70
|
/**
|
|
71
71
|
* True when this config routes to the terminal-product backend: a terminal-product subject whose
|
|
72
|
-
* first actor resolves to a registered terminal actor (execution.target e2b-terminal or absent;
|
|
72
|
+
* first actor resolves to a registered terminal actor (execution.target e2b-terminal, modal-terminal or absent;
|
|
73
73
|
* the parse layer enforces that pairing). Mirror of isComputerUseComposition/isScriptedBrowserComposition;
|
|
74
74
|
* the single source of truth for routeOf and the warning logic.
|
|
75
75
|
*/
|
package/dist/study/routing.js
CHANGED
|
@@ -177,7 +177,7 @@ export function isProvisionedScriptedBrowserComposition(config) {
|
|
|
177
177
|
}
|
|
178
178
|
/**
|
|
179
179
|
* True when this config routes to the terminal-product backend: a terminal-product subject whose
|
|
180
|
-
* first actor resolves to a registered terminal actor (execution.target e2b-terminal or absent;
|
|
180
|
+
* first actor resolves to a registered terminal actor (execution.target e2b-terminal, modal-terminal or absent;
|
|
181
181
|
* the parse layer enforces that pairing). Mirror of isComputerUseComposition/isScriptedBrowserComposition;
|
|
182
182
|
* the single source of truth for routeOf and the warning logic.
|
|
183
183
|
*/
|
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";
|
|
470
|
+
type StudyExecutionTarget = "local" | "e2b-desktop" | "e2b-terminal" | "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". */
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { E2BDesktopSandbox } from "./sdk.js";
|
|
2
2
|
import { type DesktopResources } from "../../run/pricing.js";
|
|
3
|
+
/** Where an allocation's size came from: E2B's report, or the size humanish asked Modal for. */
|
|
4
|
+
export type DesktopResourceSource = "e2b.getInfo" | "modal.create";
|
|
3
5
|
export type DesktopResourceObservation = {
|
|
4
6
|
resources: DesktopResources;
|
|
5
|
-
source:
|
|
7
|
+
source: DesktopResourceSource;
|
|
6
8
|
} | {
|
|
7
9
|
reason: "metadata_unavailable" | "metadata_invalid" | "metadata_timeout";
|
|
8
10
|
};
|
|
9
11
|
/** Called once after the caller owns and journals the handle. Never throws or delays cleanup
|
|
10
12
|
* beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
|
|
11
|
-
export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number): Promise<DesktopResourceObservation>;
|
|
13
|
+
export declare function observeDesktopResources(desktop: Pick<E2BDesktopSandbox, "getInfo">, timeoutMs?: number, source?: DesktopResourceSource): Promise<DesktopResourceObservation>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isDesktopResources } from "../../run/pricing.js";
|
|
2
2
|
/** Called once after the caller owns and journals the handle. Never throws or delays cleanup
|
|
3
3
|
* beyond the bounded wait. Persist only resource quantities, never the raw info/connection. */
|
|
4
|
-
export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
|
|
4
|
+
export async function observeDesktopResources(desktop, timeoutMs = 1_000, source = "e2b.getInfo") {
|
|
5
5
|
const controller = new AbortController();
|
|
6
6
|
let timer;
|
|
7
7
|
try {
|
|
@@ -16,7 +16,7 @@ export async function observeDesktopResources(desktop, timeoutMs = 1_000) {
|
|
|
16
16
|
});
|
|
17
17
|
const resources = { cpuCount: info?.cpuCount, memoryMiB: info?.memoryMB };
|
|
18
18
|
return isDesktopResources(resources)
|
|
19
|
-
? { resources, source
|
|
19
|
+
? { resources, source }
|
|
20
20
|
: { reason: "metadata_invalid" };
|
|
21
21
|
}),
|
|
22
22
|
new Promise((resolve) => {
|
|
@@ -139,7 +139,7 @@ export function desktopPeerAdvice(install, spec = "@e2b/desktop") {
|
|
|
139
139
|
return {
|
|
140
140
|
where: ", and humanish is running from a source checkout",
|
|
141
141
|
command: "pnpm install",
|
|
142
|
-
advice: spec === "@e2b/desktop"
|
|
142
|
+
advice: spec === "@e2b/desktop" || spec === "modal"
|
|
143
143
|
? "Run `pnpm install` in the humanish checkout, which installs it as a dev dependency."
|
|
144
144
|
: `Set ${spec} in the humanish checkout's package.json, then run \`pnpm install\` there.`,
|
|
145
145
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SandboxProviderId } from "../../run/sandbox-receipts.js";
|
|
1
2
|
import type { PreparedOutputRoot } from "../../run/contained-output.js";
|
|
2
3
|
import { type DesktopReleaseResult, type OwnedDesktopAllocation } from "../desktop-session.js";
|
|
3
4
|
import type { StudyConfig } from "../../study/types.js";
|
|
@@ -13,6 +14,8 @@ interface E2BSandboxReceiptTarget {
|
|
|
13
14
|
}
|
|
14
15
|
export interface E2BSandboxRequest {
|
|
15
16
|
module: E2BDesktopModule;
|
|
17
|
+
/** Who `module` creates sandboxes on; receipts and owner lines name it. Defaults to e2b. */
|
|
18
|
+
provider?: SandboxProviderId;
|
|
16
19
|
options: E2BDesktopCreateOptions;
|
|
17
20
|
retry?: TransientRetryHooks;
|
|
18
21
|
/**
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// journal at that point, so a startup error that quotes it is scrubbed from every run file.
|
|
10
10
|
import { redactText, toErrorMessage } from "../../evidence/redaction.js";
|
|
11
11
|
import { beginSandboxCreate, recordSandboxOwnerOnce, SandboxCreateRefusedError, sandboxOwnerTags, } from "../../run/sandbox-creates.js";
|
|
12
|
-
import { appendSandboxOwner, appendSandboxReceipt } from "../../run/sandbox-receipts.js";
|
|
12
|
+
import { appendSandboxOwner, appendSandboxReceipt, } from "../../run/sandbox-receipts.js";
|
|
13
13
|
import { ownDesktopAllocation, } from "../desktop-session.js";
|
|
14
14
|
import { e2bAccount } from "./connection.js";
|
|
15
15
|
import { E2B_DEBUG_KILL_DETAIL, E2BDesktopStartupError, e2bDebugMode, isSandboxNotFoundError, observeDesktopAllocation, } from "./sdk.js";
|
|
@@ -23,9 +23,10 @@ export function acquireE2BShellSandbox(request) {
|
|
|
23
23
|
}
|
|
24
24
|
async function acquire(request, template) {
|
|
25
25
|
const { module } = request;
|
|
26
|
+
const provider = request.provider ?? "e2b";
|
|
26
27
|
const target = request.receipt;
|
|
27
28
|
// Refused before any provider call once the run is stopping.
|
|
28
|
-
const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId);
|
|
29
|
+
const ticket = target === null ? undefined : beginSandboxCreate(target.root, target.participantId, provider);
|
|
29
30
|
// The receipts written when the guard reported an id, before desktop startup. A startup error
|
|
30
31
|
// can quote the id and the create may never return, so the journal holds it first.
|
|
31
32
|
const reported = new Map();
|
|
@@ -35,7 +36,7 @@ async function acquire(request, template) {
|
|
|
35
36
|
// journal records them first, so reclaim knows which tags to search for.
|
|
36
37
|
const owner = target === null ? undefined : sandboxOwnerTags(target.root);
|
|
37
38
|
if (target !== null && owner !== undefined)
|
|
38
|
-
await recordSandboxOwnerOnce(target.root, () => appendSandboxOwner(target.root, owner));
|
|
39
|
+
await recordSandboxOwnerOnce(target.root, provider, () => appendSandboxOwner(target.root, owner, provider));
|
|
39
40
|
const options = owner === undefined
|
|
40
41
|
? request.options
|
|
41
42
|
: { ...request.options, metadata: { ...request.options.metadata, ...owner } };
|
|
@@ -46,7 +47,7 @@ async function acquire(request, template) {
|
|
|
46
47
|
return appendSandboxReceipt(root, {
|
|
47
48
|
at: new Date(now()).toISOString(),
|
|
48
49
|
laneId: participantId,
|
|
49
|
-
provider
|
|
50
|
+
provider,
|
|
50
51
|
sandboxId,
|
|
51
52
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
52
53
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ModalClient, ModalImage } from "./sdk.js";
|
|
2
|
+
/** The sandbox account commands run as, matching E2B's `user` and its home. */
|
|
3
|
+
export declare const MODAL_SANDBOX_USER: {
|
|
4
|
+
readonly name: "user";
|
|
5
|
+
readonly uid: 1000;
|
|
6
|
+
readonly home: "/home/user";
|
|
7
|
+
};
|
|
8
|
+
/** The terminal route's image: a Debian shell with a passwordless-sudo `user`, as on E2B. */
|
|
9
|
+
export declare function modalShellImage(client: ModalClient): ModalImage;
|