humanish 0.136.1 → 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 +23 -0
- package/dist/actors/computer-use/actor.d.ts +2 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/observation.js +2 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -2
- package/dist/actors/computer-use/loop/session.js +8 -12
- package/dist/actors/computer-use/loop/types.d.ts +14 -1
- package/dist/cli/commands/runs.js +2 -2
- package/dist/cli/doctor.js +24 -0
- package/dist/evidence/browser-network.d.ts +108 -0
- package/dist/evidence/browser-network.js +516 -0
- package/dist/evidence/trace-page.d.ts +31 -1
- package/dist/evidence/trace-page.js +68 -2
- package/dist/keys/key-resolution.js +53 -2
- package/dist/observer-app.html +2 -2
- package/dist/routes/computer-use/bundle.js +1 -0
- package/dist/routes/computer-use/e2b-desktop/desktop.js +6 -0
- package/dist/routes/computer-use/e2b-desktop/fidelity.d.ts +12 -1
- package/dist/routes/computer-use/e2b-desktop/fidelity.js +7 -2
- package/dist/routes/computer-use/e2b-desktop/network.d.ts +52 -0
- package/dist/routes/computer-use/e2b-desktop/network.js +153 -0
- package/dist/routes/computer-use/e2b-desktop/start.js +18 -1
- package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -0
- package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
- package/dist/routes/computer-use/e2b-desktop/teardown.js +6 -3
- package/dist/routes/computer-use/fanout-records.js +10 -0
- package/dist/routes/computer-use/participant-desktop.d.ts +7 -2
- package/dist/routes/computer-use/participant-model.js +2 -1
- package/dist/routes/computer-use/plan.js +2 -1
- package/dist/routes/computer-use/single-bundle.d.ts +3 -0
- package/dist/routes/computer-use/single-bundle.js +4 -0
- package/dist/routes/computer-use/types.d.ts +7 -0
- package/dist/routes/scripted/plan.js +2 -2
- package/dist/routes/shared-world/plan.js +3 -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 +51 -8
- 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/live-items.d.ts +20 -2
- package/dist/run/live-items.js +23 -14
- 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/run/streams.d.ts +7 -0
- package/dist/study/composition-rules.js +11 -10
- package/dist/study/config.js +4 -1
- package/dist/study/doctor.js +1 -1
- package/dist/study/keys.d.ts +3 -0
- package/dist/study/keys.js +2 -0
- package/dist/study/parse/comms.js +1 -20
- package/dist/study/parse/execution.js +35 -3
- package/dist/study/parse/values.d.ts +5 -0
- package/dist/study/parse/values.js +22 -0
- package/dist/study/plan-base.js +6 -1
- package/dist/study/plan-types.d.ts +5 -2
- 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/study-fields.d.ts +7 -1
- package/dist/study/study-fields.js +10 -0
- package/dist/study/types.d.ts +12 -1
- package/dist/study/validation.d.ts +8 -0
- package/dist/study/validation.js +31 -2
- package/dist/substrates/detached.d.ts +5 -0
- package/dist/substrates/detached.js +9 -0
- package/dist/substrates/e2b/cdp-network-capture.d.ts +23 -0
- package/dist/substrates/e2b/cdp-network-capture.js +194 -0
- package/dist/substrates/e2b/cdp-probe.d.ts +20 -5
- package/dist/substrates/e2b/cdp-probe.js +98 -34
- package/dist/substrates/e2b/desktop-cdp.d.ts +32 -2
- package/dist/substrates/e2b/desktop-cdp.js +87 -5
- 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/dist/verify/network.d.ts +10 -0
- package/dist/verify/network.js +162 -0
- package/dist/verify/pages.js +1 -16
- package/dist/verify/verify.js +8 -2
- package/docs/architecture/terminal-product-route.md +1 -1
- package/docs/contracts/run-bundle.md +90 -3
- 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.
|
package/dist/run/streams.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CodexAppServerTrace } from "../actors/codex/app-server-trace.js";
|
|
2
2
|
import type { ActorStatus, ActorTrace, ActorTraceItem } from "../actors/contract.js";
|
|
3
|
+
import type { BrowserNetworkIndex } from "../evidence/browser-network.js";
|
|
3
4
|
import type { RunDesktopRecording } from "../evidence/desktop-recording-types.js";
|
|
4
5
|
export type RunStreamKind = "ui" | "browser" | "terminal" | "tui" | "codex-ui" | "artifact" | "summary";
|
|
5
6
|
export type RunSimulationStatus = "queued" | "preparing" | "running" | "passed" | "abandoned" | "incomplete" | "complete" | "blocked" | "timed_out" | "failed" | "contract_proof_only";
|
|
@@ -310,6 +311,12 @@ export interface RunStream {
|
|
|
310
311
|
};
|
|
311
312
|
completion?: RunStreamCompletion;
|
|
312
313
|
recording?: RunDesktopRecording;
|
|
314
|
+
/**
|
|
315
|
+
* The participant's browser network log, when its study set `execution.network`: where the
|
|
316
|
+
* file is, what binds it, and why records are missing. The shape guard does not read it; verify
|
|
317
|
+
* checks it with the file (src/verify/network.ts).
|
|
318
|
+
*/
|
|
319
|
+
network?: BrowserNetworkIndex;
|
|
313
320
|
artifacts: Array<{
|
|
314
321
|
label: string;
|
|
315
322
|
path: string;
|
|
@@ -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/config.js
CHANGED
|
@@ -39,7 +39,7 @@ import { invalid, optionalStr, str } from "./parse/values.js";
|
|
|
39
39
|
import { isComputerUseComposition, routeOf } from "./routing.js";
|
|
40
40
|
import { declaredParticipantIds } from "./plan-participants.js";
|
|
41
41
|
import { ID_PATTERN, STUDY_SCHEMA, } from "./types.js";
|
|
42
|
-
import { automaticAnalysisRouteReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "./validation.js";
|
|
42
|
+
import { automaticAnalysisRouteReason, desktopMediaValidationReason, networkCaptureValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, smtpValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "./validation.js";
|
|
43
43
|
import { forwardDeclaredWarnings, inertFieldLabels, inertFieldPaths } from "./warnings.js";
|
|
44
44
|
import { plural } from "../run/text.js";
|
|
45
45
|
/**
|
|
@@ -228,6 +228,9 @@ function checkStudyConfig(config) {
|
|
|
228
228
|
const mediaReason = desktopMediaValidationReason(config);
|
|
229
229
|
if (mediaReason)
|
|
230
230
|
return invalid(mediaReason);
|
|
231
|
+
const networkReason = networkCaptureValidationReason(config);
|
|
232
|
+
if (networkReason)
|
|
233
|
+
return invalid(networkReason);
|
|
231
234
|
const outputLimitReason = outputTokenLimitValidationReason(config);
|
|
232
235
|
if (outputLimitReason)
|
|
233
236
|
return invalid(outputLimitReason);
|
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",
|
package/dist/study/keys.d.ts
CHANGED
package/dist/study/keys.js
CHANGED
|
@@ -119,6 +119,8 @@ export const EXECUTION = {
|
|
|
119
119
|
completionTimeoutMs: true,
|
|
120
120
|
egressAllow: true,
|
|
121
121
|
terminal: { stdin: true, transport: true, doNotTrack: true },
|
|
122
|
+
// true or false, or a mapping whose keys are checked here.
|
|
123
|
+
network: { origins: true },
|
|
122
124
|
desktop: {
|
|
123
125
|
template: true,
|
|
124
126
|
browser: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { invalid, posInt, str } from "./values.js";
|
|
1
|
+
import { exactOrigin, invalid, posInt, str } from "./values.js";
|
|
2
2
|
import { isRecord } from "../../run/type-guards.js";
|
|
3
3
|
// Fail-loud (never silently swallow a comms setting): a malformed `comms` block returns a parse
|
|
4
4
|
// failure rather than being dropped.
|
|
@@ -104,25 +104,6 @@ function parseEmailConnection(raw) {
|
|
|
104
104
|
}
|
|
105
105
|
return { ok: true, value };
|
|
106
106
|
}
|
|
107
|
-
/** The origin of an http(s) URL with no credentials, path, query or fragment, else undefined. */
|
|
108
|
-
function exactOrigin(input) {
|
|
109
|
-
if (typeof input !== "string")
|
|
110
|
-
return undefined;
|
|
111
|
-
try {
|
|
112
|
-
const url = new URL(input);
|
|
113
|
-
return ["http:", "https:"].includes(url.protocol) &&
|
|
114
|
-
!url.username &&
|
|
115
|
-
!url.password &&
|
|
116
|
-
url.pathname === "/" &&
|
|
117
|
-
!url.search &&
|
|
118
|
-
!url.hash
|
|
119
|
-
? url.origin
|
|
120
|
-
: undefined;
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
107
|
/** `comms.email.external`: the catch the adopter runs, its inbox and the env var holding its token. */
|
|
127
108
|
function parseExternalCatch(raw) {
|
|
128
109
|
if (raw === undefined)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
|
|
2
2
|
import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
|
|
3
3
|
import { isExactRuntimeVersion } from "../../routes/terminal/runtime.js";
|
|
4
|
-
import { invalid, nonNegNumber, posInt, str } from "./values.js";
|
|
4
|
+
import { exactOrigin, invalid, nonNegNumber, posInt, str } from "./values.js";
|
|
5
5
|
import { isRecord } from "../../run/type-guards.js";
|
|
6
6
|
import { scanEncodedText } from "../../evidence/encoded-text.js";
|
|
7
7
|
export function parseExecution(raw) {
|
|
@@ -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
|
}
|
|
@@ -34,6 +37,11 @@ export function parseExecution(raw) {
|
|
|
34
37
|
}
|
|
35
38
|
if (desktopResult.value)
|
|
36
39
|
execution.desktop = desktopResult.value;
|
|
40
|
+
const network = parseNetwork(raw.network);
|
|
41
|
+
if (!network.ok)
|
|
42
|
+
return network;
|
|
43
|
+
if (network.value !== undefined)
|
|
44
|
+
execution.network = network.value;
|
|
37
45
|
const terminalResult = parseTerminal(raw.terminal);
|
|
38
46
|
if (!terminalResult.ok) {
|
|
39
47
|
return terminalResult;
|
|
@@ -71,6 +79,30 @@ export function parseExecution(raw) {
|
|
|
71
79
|
}
|
|
72
80
|
return { ok: true, value: Object.keys(execution).length > 0 ? execution : undefined };
|
|
73
81
|
}
|
|
82
|
+
/** `execution.network`: true, false, or `{ origins }`. False reads as absent. */
|
|
83
|
+
function parseNetwork(raw) {
|
|
84
|
+
if (raw === undefined || raw === false)
|
|
85
|
+
return { ok: true, value: undefined };
|
|
86
|
+
if (raw === true)
|
|
87
|
+
return { ok: true, value: true };
|
|
88
|
+
if (!isRecord(raw))
|
|
89
|
+
return invalid("`execution.network` must be true, false, or a mapping with `origins`: the origins whose requests are kept beside the target's.");
|
|
90
|
+
if (raw.origins === undefined)
|
|
91
|
+
return { ok: true, value: {} };
|
|
92
|
+
if (!Array.isArray(raw.origins) || raw.origins.length > 16)
|
|
93
|
+
return invalid("`execution.network.origins` must list at most 16 exact http(s) origins, such as https://api.example.com.");
|
|
94
|
+
const origins = [];
|
|
95
|
+
for (const entry of raw.origins) {
|
|
96
|
+
const origin = exactOrigin(entry);
|
|
97
|
+
if (origin === undefined)
|
|
98
|
+
return invalid("`execution.network.origins` accepts exact http(s) origins without credentials, path, query or fragment, such as https://api.example.com.");
|
|
99
|
+
// The network log's index records each origin, so one verify would flag is refused here.
|
|
100
|
+
if (scanEncodedText(origin).sensitive)
|
|
101
|
+
return invalid("`execution.network.origins` has an origin that looks like a secret or a local path, and the run records each origin in its network log index. List the origin of the API or asset host only.");
|
|
102
|
+
origins.push(origin);
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, value: { origins: [...new Set(origins)] } };
|
|
105
|
+
}
|
|
74
106
|
function parseTerminal(raw) {
|
|
75
107
|
if (raw === undefined) {
|
|
76
108
|
return { ok: true, value: undefined };
|
|
@@ -14,3 +14,8 @@ export declare function posInt(value: unknown): number | undefined;
|
|
|
14
14
|
/** A non-negative finite number (0 allowed; caps default to 0 = no-spend). Accepts a numeric
|
|
15
15
|
* string too, since YAML scalars can arrive as strings. */
|
|
16
16
|
export declare function nonNegNumber(value: unknown): number | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The origin of an http(s) URL with no credentials, path, query or fragment, else undefined. The
|
|
19
|
+
* origin lists `comms.email.allowedOrigins` and `execution.network.origins` take this rule.
|
|
20
|
+
*/
|
|
21
|
+
export declare function exactOrigin(input: unknown): string | undefined;
|
|
@@ -60,3 +60,25 @@ export function nonNegNumber(value) {
|
|
|
60
60
|
}
|
|
61
61
|
return undefined;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The origin of an http(s) URL with no credentials, path, query or fragment, else undefined. The
|
|
65
|
+
* origin lists `comms.email.allowedOrigins` and `execution.network.origins` take this rule.
|
|
66
|
+
*/
|
|
67
|
+
export function exactOrigin(input) {
|
|
68
|
+
if (typeof input !== "string")
|
|
69
|
+
return undefined;
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(input);
|
|
72
|
+
return ["http:", "https:"].includes(url.protocol) &&
|
|
73
|
+
!url.username &&
|
|
74
|
+
!url.password &&
|
|
75
|
+
url.pathname === "/" &&
|
|
76
|
+
!url.search &&
|
|
77
|
+
!url.hash
|
|
78
|
+
? url.origin
|
|
79
|
+
: undefined;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|