humanish 0.124.0 → 0.126.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 +31 -0
- package/CONTEXT.md +1 -1
- package/README.md +2 -2
- 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/clock.d.ts +9 -8
- package/dist/actors/computer-use/loop/clock.js +27 -17
- package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
- package/dist/actors/computer-use/loop/debrief.js +15 -5
- package/dist/actors/computer-use/loop/ending.d.ts +8 -5
- package/dist/actors/computer-use/loop/ending.js +8 -5
- package/dist/actors/computer-use/loop/observation.d.ts +6 -0
- package/dist/actors/computer-use/loop/observation.js +19 -1
- package/dist/actors/computer-use/loop/session.d.ts +4 -2
- package/dist/actors/computer-use/loop/session.js +4 -1
- package/dist/actors/computer-use/loop/trace.js +4 -2
- package/dist/actors/computer-use/loop/types.d.ts +22 -8
- package/dist/actors/computer-use/loop.js +5 -5
- package/dist/actors/contract.d.ts +31 -8
- package/dist/actors/contract.js +3 -0
- package/dist/actors/stop-cause.js +1 -0
- package/dist/analysis/evidence.js +2 -2
- package/dist/cli/commands/study.js +8 -0
- package/dist/evidence/redaction.d.ts +1 -0
- package/dist/evidence/redaction.js +1 -0
- package/dist/evidence/trace-page.d.ts +6 -0
- package/dist/evidence/trace-page.js +90 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/observer/feed-cursors.d.ts +14 -0
- package/dist/observer/feed-cursors.js +61 -0
- package/dist/observer/participant-items.d.ts +7 -0
- package/dist/observer/pinned-files.d.ts +4 -1
- package/dist/observer/pinned-files.js +10 -7
- package/dist/observer/run-routes.d.ts +1 -1
- package/dist/observer/run-routes.js +39 -17
- package/dist/observer-app.html +7 -7
- package/dist/routes/computer-use/bundle.js +4 -0
- package/dist/routes/computer-use/diagnostics.d.ts +1 -1
- package/dist/routes/computer-use/diagnostics.js +3 -3
- package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
- package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
- package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
- package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
- package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
- package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
- package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
- package/dist/routes/computer-use/fanout-records.js +4 -0
- package/dist/routes/computer-use/live-flush.d.ts +9 -4
- package/dist/routes/computer-use/live-flush.js +16 -3
- package/dist/routes/computer-use/participant-deps.d.ts +1 -1
- package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
- package/dist/routes/computer-use/participant-model.js +16 -6
- package/dist/routes/computer-use/participant-runs.d.ts +2 -2
- package/dist/routes/computer-use/participant-runs.js +11 -7
- package/dist/routes/computer-use/plan.js +39 -1
- package/dist/routes/computer-use/setup.d.ts +1 -0
- package/dist/routes/computer-use/setup.js +28 -0
- package/dist/routes/computer-use/single-bundle.d.ts +5 -1
- package/dist/routes/computer-use/single-bundle.js +2 -0
- package/dist/routes/computer-use/types.d.ts +16 -10
- package/dist/routes/shared-world/bundle-records.js +3 -0
- package/dist/routes/shared-world/participant-specs.js +3 -2
- package/dist/routes/shared-world/plan.js +5 -0
- package/dist/run/bundle-shape.js +2 -2
- package/dist/run/bundle.d.ts +10 -3
- package/dist/run/bundle.js +11 -2
- package/dist/run/contained-output.d.ts +26 -0
- package/dist/run/contained-output.js +101 -1
- package/dist/run/evidence-files.d.ts +12 -1
- package/dist/run/evidence-files.js +70 -40
- package/dist/run/judge.d.ts +7 -4
- package/dist/run/judge.js +13 -8
- package/dist/run/live-items.d.ts +67 -0
- package/dist/run/live-items.js +262 -0
- package/dist/run/locate.d.ts +10 -0
- package/dist/run/locate.js +10 -0
- package/dist/run/run.d.ts +3 -2
- package/dist/run/run.js +29 -8
- package/dist/run/sandbox-ids.d.ts +0 -5
- package/dist/run/sandbox-ids.js +0 -7
- package/dist/run/stream-trace.d.ts +40 -10
- package/dist/run/stream-trace.js +151 -22
- package/dist/run/streams.d.ts +33 -2
- package/dist/study/arrivals.d.ts +32 -14
- package/dist/study/arrivals.js +47 -21
- package/dist/study/bundle-estimate.d.ts +6 -5
- package/dist/study/bundle-estimate.js +12 -7
- package/dist/study/desktop-files.d.ts +37 -0
- package/dist/study/desktop-files.js +174 -0
- package/dist/study/keys.d.ts +8 -0
- package/dist/study/keys.js +3 -0
- package/dist/study/migrate/v2.js +12 -3
- package/dist/study/parse/actors.d.ts +1 -1
- package/dist/study/parse/actors.js +51 -26
- package/dist/study/parse/clock.d.ts +18 -0
- package/dist/study/parse/clock.js +36 -0
- package/dist/study/parse/execution.js +6 -0
- package/dist/study/plan-base.js +4 -1
- package/dist/study/plan-participants.d.ts +19 -5
- package/dist/study/plan-participants.js +17 -1
- package/dist/study/plan.js +24 -13
- package/dist/study/preflight.js +4 -0
- package/dist/study/types.d.ts +34 -5
- package/dist/study/validation.js +3 -0
- package/dist/study/warnings.d.ts +2 -2
- package/dist/study/warnings.js +7 -0
- package/dist/subject/local-tree-archive.d.ts +7 -0
- package/dist/subject/local-tree-archive.js +12 -0
- package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
- package/dist/substrates/e2b/desktop-browser.js +6 -2
- package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
- package/dist/substrates/e2b/desktop-clock.js +29 -0
- package/dist/substrates/e2b/sdk.d.ts +6 -0
- package/dist/verify/artifacts.js +39 -51
- package/dist/verify/pages.d.ts +7 -0
- package/dist/verify/pages.js +50 -0
- package/dist/verify/screenshot-reads.d.ts +21 -0
- package/dist/verify/screenshot-reads.js +46 -0
- package/dist/verify/verify.js +17 -9
- package/docs/architecture/actor-contract.md +2 -0
- package/docs/architecture/external-public-shared-world.md +4 -1
- package/docs/architecture/github-feedback-loop.md +1 -1
- package/docs/architecture/observer.md +10 -1
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/policy.md +7 -0
- package/docs/contracts/run-bundle.md +106 -14
- package/docs/contracts/schemas.md +110 -40
- package/package.json +4 -2
package/dist/study/plan.js
CHANGED
|
@@ -86,12 +86,11 @@ export function planStudy(config, options, deps = {}) {
|
|
|
86
86
|
const bundle = plannedBundle(result.plan);
|
|
87
87
|
// The manifest the CLI resolved enters the plan here and nowhere else; the routes read
|
|
88
88
|
// plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
|
|
89
|
-
const closing = closingReportWarning(result.plan);
|
|
90
89
|
const warnings = [
|
|
91
90
|
...recordedStudyWarnings(study),
|
|
92
91
|
...(result.plan.warnings ?? []),
|
|
93
92
|
...(bundle.warning === undefined ? [] : [bundle.warning]),
|
|
94
|
-
...(
|
|
93
|
+
...closingReportWarnings(result.plan),
|
|
95
94
|
];
|
|
96
95
|
const plan = {
|
|
97
96
|
...result.plan,
|
|
@@ -115,21 +114,33 @@ function plannedBundle(plan) {
|
|
|
115
114
|
return warning === undefined ? { estimate } : { estimate, warning };
|
|
116
115
|
}
|
|
117
116
|
/**
|
|
118
|
-
* The
|
|
119
|
-
* Its session has no read-only closing request, so each such participant ends
|
|
120
|
-
* end as its reason and no account of its session.
|
|
117
|
+
* The warnings for participants with a scheduled end or a session cap on Claude as a local-agent
|
|
118
|
+
* participant model. Its session has no read-only closing request, so each such participant ends
|
|
119
|
+
* at its end or cap with that as its reason and no account of its session.
|
|
121
120
|
*/
|
|
122
|
-
function
|
|
121
|
+
function closingReportWarnings(plan) {
|
|
123
122
|
if (plan.route !== "computer-use")
|
|
124
|
-
return
|
|
123
|
+
return [];
|
|
125
124
|
const { brain } = plan.runner;
|
|
126
125
|
if (brain.kind !== "local-agent" || brain.agent !== "claude")
|
|
127
|
-
return
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
126
|
+
return [];
|
|
127
|
+
const { participants } = plan.runner;
|
|
128
|
+
const scheduled = participants.filter((participant) => participant.endAfterMs !== undefined);
|
|
129
|
+
const capped = participants.filter((participant) => participant.maxSessionMs !== undefined);
|
|
130
|
+
const claude = "Claude as a local-agent participant model cannot be asked for a closing report";
|
|
131
|
+
const writers = "A computer-use model (actor.type: openai-computer-use) or Codex (actor.localAgent: codex) writes one.";
|
|
132
|
+
return [
|
|
133
|
+
...(scheduled.length === 0
|
|
134
|
+
? []
|
|
135
|
+
: [
|
|
136
|
+
`${plural(scheduled.length, "participant")} ${scheduled.length === 1 ? "has" : "have"} a scheduled end (participants[].endAfterMs), and ${claude}, so ${scheduled.length === 1 ? "it ends" : "each ends"} with the scheduled end as its reason and no closing account. ${writers}`,
|
|
137
|
+
]),
|
|
138
|
+
...(capped.length === 0
|
|
139
|
+
? []
|
|
140
|
+
: [
|
|
141
|
+
`${plural(capped.length, "participant")} ${capped.length === 1 ? "has" : "have"} a session cap (participants[].maxSessionMs), and ${claude}, so ${capped.length === 1 ? "if it reaches its cap it ends" : "each that reaches its cap ends"} with the cap as its reason and no closing account. ${writers}`,
|
|
142
|
+
]),
|
|
143
|
+
];
|
|
133
144
|
}
|
|
134
145
|
function planRoute(route, study, options, input, deps, e2b) {
|
|
135
146
|
const { sandboxCeiling } = e2b;
|
package/dist/study/preflight.js
CHANGED
|
@@ -8,6 +8,7 @@ import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.j
|
|
|
8
8
|
import { planStudy, routeOf } from "./plan.js";
|
|
9
9
|
import { describeArrivals, describeWindows } from "./arrivals.js";
|
|
10
10
|
import { describeBundle } from "./bundle-estimate.js";
|
|
11
|
+
import { describeTimeZones } from "./plan-participants.js";
|
|
11
12
|
import { resolveStudyManifest } from "./discover.js";
|
|
12
13
|
import { participantList } from "./study-fields.js";
|
|
13
14
|
import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
|
|
@@ -82,6 +83,9 @@ export async function runStudyPreflight(options) {
|
|
|
82
83
|
const windows = windowsCheck(planned.planned.plan);
|
|
83
84
|
if (windows !== undefined)
|
|
84
85
|
ctx.checks.push(windows);
|
|
86
|
+
const timeZones = describeTimeZones(participants);
|
|
87
|
+
if (timeZones !== undefined)
|
|
88
|
+
ctx.checks.push({ name: "time zones", ok: true, message: timeZones });
|
|
85
89
|
const bundle = planned.planned.bundle;
|
|
86
90
|
if (bundle !== undefined)
|
|
87
91
|
ctx.checks.push({ name: "bundle size", ok: true, message: describeBundle(bundle) });
|
package/dist/study/types.d.ts
CHANGED
|
@@ -257,6 +257,13 @@ export interface StudySubject {
|
|
|
257
257
|
*/
|
|
258
258
|
localTree?: StudySubjectLocalTree;
|
|
259
259
|
}
|
|
260
|
+
/** One file a participant's desktop receives (`participants[].files`). */
|
|
261
|
+
export interface StudyParticipantFile {
|
|
262
|
+
/** Path of the file, relative to the folder that holds the study file. */
|
|
263
|
+
from: string;
|
|
264
|
+
/** Where it lands, relative to the desktop user's home folder. */
|
|
265
|
+
as: string;
|
|
266
|
+
}
|
|
260
267
|
/**
|
|
261
268
|
* One participant in a differentiated fan-out on the computer-use E2B route (`per-lane-worlds`).
|
|
262
269
|
* Each entry becomes an independent E2B desktop sandbox with its own persona/device/starting-steer. All
|
|
@@ -304,6 +311,11 @@ export interface StudyParticipantEntry {
|
|
|
304
311
|
* shared-world, scripted-browser, and terminal routes.
|
|
305
312
|
*/
|
|
306
313
|
target?: string;
|
|
314
|
+
/**
|
|
315
|
+
* Hosted computer use only: files copied onto this participant's desktop before it starts, so a
|
|
316
|
+
* browser upload dialog can find them. src/study/desktop-files.ts holds the rules.
|
|
317
|
+
*/
|
|
318
|
+
files?: StudyParticipantFile[];
|
|
307
319
|
/**
|
|
308
320
|
* Shared-world only: this participant's loopback entry route, resolved against
|
|
309
321
|
* `subject.serve.url`. It must be same-origin (loopback) with it, and the participant opens
|
|
@@ -334,8 +346,16 @@ export interface StudyParticipantEntry {
|
|
|
334
346
|
*/
|
|
335
347
|
endAfterMs?: number;
|
|
336
348
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
349
|
+
* Computer use: the longest this participant's session lasts, in milliseconds from the start of
|
|
350
|
+
* its session (1 to 86,400,000). It may end its session before then and counts as passed when it
|
|
351
|
+
* does so normally; at the cap it is asked for its closing report and the session ends as passed,
|
|
352
|
+
* with stop cause `session_cap`. Its session lasts the cap plus 5 minutes in place of
|
|
353
|
+
* execution.timeoutMs, and its sandbox is sized from that. Not with endAfterMs.
|
|
354
|
+
*/
|
|
355
|
+
maxSessionMs?: number;
|
|
356
|
+
/**
|
|
357
|
+
* With endAfterMs or maxSessionMs: how long before the end each turn tells the participant the
|
|
358
|
+
* end time and the minutes left. Default 600000 (10 minutes).
|
|
339
359
|
*/
|
|
340
360
|
windDownMs?: number;
|
|
341
361
|
/**
|
|
@@ -343,6 +363,14 @@ export interface StudyParticipantEntry {
|
|
|
343
363
|
* no duration lasts while it only waits. Same range, and no longer than actor.maxWaitMs.
|
|
344
364
|
*/
|
|
345
365
|
idleWaitMs?: number;
|
|
366
|
+
/**
|
|
367
|
+
* Computer use and shared world: this participant's clock, in place of actor.clock. Its
|
|
368
|
+
* `timeZone` is the zone each turn gives the time in and, on a hosted desktop, the zone its
|
|
369
|
+
* browser runs in.
|
|
370
|
+
*/
|
|
371
|
+
clock?: {
|
|
372
|
+
timeZone: string;
|
|
373
|
+
};
|
|
346
374
|
}
|
|
347
375
|
export interface StudyActor {
|
|
348
376
|
/**
|
|
@@ -388,9 +416,10 @@ export interface StudyActor {
|
|
|
388
416
|
*/
|
|
389
417
|
idleWaitMs?: number;
|
|
390
418
|
/**
|
|
391
|
-
* Computer use: the
|
|
392
|
-
* the local time to each turn, for missions that
|
|
393
|
-
* scheduled end
|
|
419
|
+
* Computer use: the participants' clock, unless a participant declares its own. `timeZone`, a
|
|
420
|
+
* time zone name such as "Europe/London", adds the local time to each turn, for missions that
|
|
421
|
+
* refer to clock times, gives a scheduled end or session cap in that zone, and sets the zone
|
|
422
|
+
* of a hosted desktop's browser and of the clock in its panel, which shows UTC without one.
|
|
394
423
|
*/
|
|
395
424
|
clock?: {
|
|
396
425
|
timeZone: string;
|
package/dist/study/validation.js
CHANGED
|
@@ -114,6 +114,9 @@ export function sharedWorldValidationReason(config) {
|
|
|
114
114
|
const ending = participantList(config)?.findIndex((entry) => entry?.endAfterMs !== undefined);
|
|
115
115
|
if (ending !== undefined && ending >= 0)
|
|
116
116
|
return `participants[${ending}].endAfterMs ends a participant at a set time on route: computer-use only. A shared-world participant runs for execution.timeoutMs; remove endAfterMs and windDownMs.`;
|
|
117
|
+
const capped = participantList(config)?.findIndex((entry) => entry?.maxSessionMs !== undefined);
|
|
118
|
+
if (capped !== undefined && capped >= 0)
|
|
119
|
+
return `participants[${capped}].maxSessionMs caps a participant's session on route: computer-use only. A shared-world participant runs for execution.timeoutMs; remove maxSessionMs and windDownMs.`;
|
|
117
120
|
return config.subject.source === "app-url"
|
|
118
121
|
? externalPublicSharedWorldValidationReason(config)
|
|
119
122
|
: concurrentSharedWorldValidationReason(config);
|
package/dist/study/warnings.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedPersona } from "./persona.js";
|
|
2
2
|
import type { StudyConfig } from "./types.js";
|
|
3
|
-
import { type Participant } from "./plan-participants.js";
|
|
3
|
+
import { type DesktopParticipant, type Participant } from "./plan-participants.js";
|
|
4
4
|
/**
|
|
5
5
|
* The fields a config sets that its route does not read, each as `field` or `field (reason)`.
|
|
6
6
|
* parseStudy refuses a study for any of them.
|
|
@@ -30,4 +30,4 @@ export declare function personaBackgroundWarnings(studyId: string, participants:
|
|
|
30
30
|
* and `study show`, which run no plan. Only computer-use and shared-world studies have them; a run's
|
|
31
31
|
* `--count` is not applied.
|
|
32
32
|
*/
|
|
33
|
-
export declare function plannedParticipants(config: StudyConfig): readonly
|
|
33
|
+
export declare function plannedParticipants(config: StudyConfig): readonly DesktopParticipant[];
|
package/dist/study/warnings.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cli } from "../cli/invocation.js";
|
|
2
2
|
import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
|
|
3
|
+
import { DESKTOP_FILES_ROUTE_REASON } from "./desktop-files.js";
|
|
3
4
|
import { computerUseParticipants, declaredParticipantIds, sharedWorldParticipants, } from "./plan-participants.js";
|
|
4
5
|
import { addressedRecipients } from "./parse/comms.js";
|
|
5
6
|
import { participantList, participantInstruction, declaredParticipantCount, } from "./study-fields.js";
|
|
@@ -27,6 +28,12 @@ const ACTOR_ROWS = [
|
|
|
27
28
|
applies: (config, routes) => Boolean(participantList(config)?.some((entry) => entry.host === true)) &&
|
|
28
29
|
!routes.externalPublic,
|
|
29
30
|
},
|
|
31
|
+
{
|
|
32
|
+
field: "participants[].files",
|
|
33
|
+
reason: DESKTOP_FILES_ROUTE_REASON,
|
|
34
|
+
applies: (config, routes) => Boolean(participantList(config)?.some((entry) => entry.files !== undefined)) &&
|
|
35
|
+
!(routes.hostedCuaBrowser && !routes.shared),
|
|
36
|
+
},
|
|
30
37
|
{
|
|
31
38
|
field: "actor.mission",
|
|
32
39
|
reason: "the scripted-browser actor runs no model",
|
|
@@ -71,6 +71,13 @@ export declare function enumerateLocalTree(root: string, options?: {
|
|
|
71
71
|
* produces zero entries; or total packed bytes exceed maxArchiveBytes.
|
|
72
72
|
*/
|
|
73
73
|
export declare function createLocalTreeArchive(root: string, options?: CreateLocalTreeArchiveOptions): LocalTreeArchive;
|
|
74
|
+
/**
|
|
75
|
+
* Whether a relative path an author wrote names a file the always-on denylist keeps on this
|
|
76
|
+
* machine: a secret-shaped name, or one under .git, node_modules or .humanish. It compares without
|
|
77
|
+
* case, since on a case-insensitive filesystem `.ENV` opens `.env`. participants[].files refuses
|
|
78
|
+
* the same paths.
|
|
79
|
+
*/
|
|
80
|
+
export declare function isLocalTreeDenylisted(relPath: string): boolean;
|
|
74
81
|
/**
|
|
75
82
|
* Normalize an author-supplied exclude entry to the relPath shape enumeration
|
|
76
83
|
* produces (no leading "./", no trailing "/"). Absolute paths and glob syntax
|
|
@@ -283,6 +283,18 @@ function matchesBasenamePattern(basename, pattern) {
|
|
|
283
283
|
function isDenylistedBasename(basename) {
|
|
284
284
|
return LOCAL_TREE_DENYLIST_BASENAME_PATTERNS.some((pattern) => matchesBasenamePattern(basename, pattern));
|
|
285
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether a relative path an author wrote names a file the always-on denylist keeps on this
|
|
288
|
+
* machine: a secret-shaped name, or one under .git, node_modules or .humanish. It compares without
|
|
289
|
+
* case, since on a case-insensitive filesystem `.ENV` opens `.env`. participants[].files refuses
|
|
290
|
+
* the same paths.
|
|
291
|
+
*/
|
|
292
|
+
export function isLocalTreeDenylisted(relPath) {
|
|
293
|
+
const segments = relPath.toLowerCase().split("/");
|
|
294
|
+
const basename = segments.at(-1) ?? "";
|
|
295
|
+
return (segments.some((segment) => LOCAL_TREE_DENYLIST_PATH_SEGMENTS.some((denied) => denied.toLowerCase() === segment)) ||
|
|
296
|
+
LOCAL_TREE_DENYLIST_BASENAME_PATTERNS.some((pattern) => matchesBasenamePattern(basename, pattern.toLowerCase())));
|
|
297
|
+
}
|
|
286
298
|
function isDenylistedSegment(relPath) {
|
|
287
299
|
const segments = relPath.split("/");
|
|
288
300
|
return segments.some((segment) => LOCAL_TREE_DENYLIST_PATH_SEGMENTS.includes(segment));
|
|
@@ -76,6 +76,9 @@ extraChromiumFlags?: readonly string[], environment?: Readonly<Record<string, st
|
|
|
76
76
|
* `x-terminal-emulator` is tried first so a template that swaps
|
|
77
77
|
* the emulator still works; a desktop with neither is a template problem and fails closed rather
|
|
78
78
|
* than handing a participant an empty screen and calling it a study.
|
|
79
|
+
*
|
|
80
|
+
* A `timeZone` is TZ on the terminal, so its shells, the product under test and any browser started
|
|
81
|
+
* from it run in that zone.
|
|
79
82
|
*/
|
|
80
|
-
export declare function openDesktopTerminal(desktop: E2BDesktopSandbox, requestTimeoutMs: number, workdir: string | undefined): Promise<void>;
|
|
83
|
+
export declare function openDesktopTerminal(desktop: E2BDesktopSandbox, requestTimeoutMs: number, workdir: string | undefined, timeZone?: string): Promise<void>;
|
|
81
84
|
export declare function startDesktopStream(desktop: E2BDesktopSandbox, browserWindowId: string | undefined): Promise<void>;
|
|
@@ -367,9 +367,13 @@ function desktopBrowserFamily(value) {
|
|
|
367
367
|
* `x-terminal-emulator` is tried first so a template that swaps
|
|
368
368
|
* the emulator still works; a desktop with neither is a template problem and fails closed rather
|
|
369
369
|
* than handing a participant an empty screen and calling it a study.
|
|
370
|
+
*
|
|
371
|
+
* A `timeZone` is TZ on the terminal, so its shells, the product under test and any browser started
|
|
372
|
+
* from it run in that zone.
|
|
370
373
|
*/
|
|
371
|
-
export async function openDesktopTerminal(desktop, requestTimeoutMs, workdir) {
|
|
374
|
+
export async function openDesktopTerminal(desktop, requestTimeoutMs, workdir, timeZone) {
|
|
372
375
|
const dir = workdir ?? "/home/user";
|
|
376
|
+
const zone = timeZone === undefined ? "" : `TZ=${shellQuote(timeZone)} `;
|
|
373
377
|
const result = await runDetachedStep(e2bShell(desktop), {
|
|
374
378
|
name: "desktop-cli-terminal",
|
|
375
379
|
command: [
|
|
@@ -379,7 +383,7 @@ export async function openDesktopTerminal(desktop, requestTimeoutMs, workdir) {
|
|
|
379
383
|
// a study that measures our own mojibake against an unconfigured template would be measuring
|
|
380
384
|
// the template. The product-side fix (an ASCII fallback when the locale is not UTF-8) is in
|
|
381
385
|
// src/routes/terminal/encoding.ts, and it is the one that matters for real users.
|
|
382
|
-
` (cd ${shellQuote(dir)} 2>/dev/null || cd /home/user; DISPLAY=:0 LANG=C.UTF-8 LC_ALL=C.UTF-8 HUMANISH_STUDY_PARTICIPANT=1 nohup "$candidate" >/dev/null 2>&1 &)`,
|
|
386
|
+
` (cd ${shellQuote(dir)} 2>/dev/null || cd /home/user; DISPLAY=:0 LANG=C.UTF-8 LC_ALL=C.UTF-8 ${zone}HUMANISH_STUDY_PARTICIPANT=1 nohup "$candidate" >/dev/null 2>&1 &)`,
|
|
383
387
|
" sleep 3",
|
|
384
388
|
' echo "humanish: opened $candidate"',
|
|
385
389
|
" exit 0",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { E2BDesktopSandbox } from "./sdk.js";
|
|
2
|
+
/**
|
|
3
|
+
* Set every clock in the desktop's XFCE panel to `timeZone`, a tz database name, by writing the
|
|
4
|
+
* clock plugin's `timezone` property through xfconf-query. The property lives in the session's
|
|
5
|
+
* settings daemon, reached over the session bus whose address xfce4-session holds, and the panel
|
|
6
|
+
* redraws its clock when the property changes (live: 02:12 UTC became 11:12 for Asia/Tokyo with no
|
|
7
|
+
* restart). A desktop without xfconf-query or without a clock plugin is left as it is. Throws with
|
|
8
|
+
* xfconf-query's error output when a clock is found and cannot be set.
|
|
9
|
+
*/
|
|
10
|
+
export declare function setPanelClockZone(desktop: E2BDesktopSandbox, timeZone: string, requestTimeoutMs: number): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The clock in the hosted desktop's panel. The stock template's XFCE panel sits above every window,
|
|
2
|
+
// so a participant sees its clock in each screenshot, and it shows the desktop's zone, UTC, unless
|
|
3
|
+
// its clock plugin names another.
|
|
4
|
+
import { shellQuote } from "../shell.js";
|
|
5
|
+
import { e2bShell } from "./shell.js";
|
|
6
|
+
/**
|
|
7
|
+
* Set every clock in the desktop's XFCE panel to `timeZone`, a tz database name, by writing the
|
|
8
|
+
* clock plugin's `timezone` property through xfconf-query. The property lives in the session's
|
|
9
|
+
* settings daemon, reached over the session bus whose address xfce4-session holds, and the panel
|
|
10
|
+
* redraws its clock when the property changes (live: 02:12 UTC became 11:12 for Asia/Tokyo with no
|
|
11
|
+
* restart). A desktop without xfconf-query or without a clock plugin is left as it is. Throws with
|
|
12
|
+
* xfconf-query's error output when a clock is found and cannot be set.
|
|
13
|
+
*/
|
|
14
|
+
export async function setPanelClockZone(desktop, timeZone, requestTimeoutMs) {
|
|
15
|
+
const result = await e2bShell(desktop).run([
|
|
16
|
+
`zone=${shellQuote(timeZone)}`,
|
|
17
|
+
"command -v xfconf-query >/dev/null 2>&1 || exit 0",
|
|
18
|
+
'session_pid="$(pgrep -x xfce4-session | head -n 1)"',
|
|
19
|
+
'if [ -n "$session_pid" ]; then',
|
|
20
|
+
` bus="$(tr '\\0' '\\n' < "/proc/$session_pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')"`,
|
|
21
|
+
' if [ -n "$bus" ]; then export DBUS_SESSION_BUS_ADDRESS="$bus"; fi',
|
|
22
|
+
"fi",
|
|
23
|
+
`for plugin in $(xfconf-query -c xfce4-panel -p /plugins -l -v | awk '$2 == "clock" { print $1 }'); do`,
|
|
24
|
+
' xfconf-query -c xfce4-panel -p "$plugin/timezone" -n -t string -s "$zone" || exit 1',
|
|
25
|
+
"done",
|
|
26
|
+
].join("\n"), { requestTimeoutMs, timeoutMs: 15_000 });
|
|
27
|
+
if (result.exitCode !== 0)
|
|
28
|
+
throw new Error(result.stderr.trim() || `xfconf-query exited ${result.exitCode}`);
|
|
29
|
+
}
|
|
@@ -154,6 +154,12 @@ export interface E2BDesktopSandbox {
|
|
|
154
154
|
requestTimeoutMs?: number;
|
|
155
155
|
useOctetStream?: boolean;
|
|
156
156
|
}): Promise<unknown>;
|
|
157
|
+
/** A file's size as the sandbox sees it. A relative path is under the sandbox user's home. */
|
|
158
|
+
getInfo?(path: string, options?: {
|
|
159
|
+
requestTimeoutMs?: number;
|
|
160
|
+
}): Promise<{
|
|
161
|
+
size: number;
|
|
162
|
+
}>;
|
|
157
163
|
};
|
|
158
164
|
launch(application: string, uri?: string): Promise<void>;
|
|
159
165
|
/** Open a file or URL with the desktop's default application (present on @e2b/desktop >= 1.x). */
|
package/dist/verify/artifacts.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { lstat, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { isRedactedFrameShape, screenshotEvidenceError } from "../evidence/image.js";
|
|
4
3
|
import { isAnalysisRecordPath } from "../analysis/sharing.js";
|
|
5
4
|
import { scanEncodedTextCached } from "../evidence/encoded-text.js";
|
|
6
5
|
import { readPlainText } from "../evidence/plain-text.js";
|
|
@@ -9,12 +8,14 @@ import { isLocalEvidenceArtifactPath, isRiskyPublicArtifactPath, validatePrepare
|
|
|
9
8
|
import { openContainedRegularFile, RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
|
|
10
9
|
import { holdsKeyedSandboxId } from "../run/sandbox-ids.js";
|
|
11
10
|
import { SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
|
|
11
|
+
import { mapWithConcurrency } from "../run/concurrency.js";
|
|
12
12
|
import { runEntryLimit } from "./scan-limit.js";
|
|
13
13
|
import { readSafeRunArtifact, readSafeRunArtifactBytes, readSafeRunArtifactJson, } from "../run/locate.js";
|
|
14
14
|
import { isRecord } from "../run/type-guards.js";
|
|
15
15
|
import { TERMINAL_EVENTS_ARTIFACT } from "../run/terminal-contract.js";
|
|
16
16
|
import { plural } from "../run/text.js";
|
|
17
17
|
import { isZeroEventTerminalTrace } from "./actor.js";
|
|
18
|
+
import { readScreenshot } from "./screenshot-reads.js";
|
|
18
19
|
/** Public-safety and evidence-reference findings stop at this many per list. */
|
|
19
20
|
export const MAX_REPORTED_FINDINGS = 50;
|
|
20
21
|
// The UNSCANNED_ARTIFACT reason names this many paths of each kind and counts the rest.
|
|
@@ -108,44 +109,38 @@ export async function missingLocalEvidenceArtifacts(runPaths, bundle) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (artifact.state === "limit") {
|
|
133
|
-
missing.push(`${artifactPath} (larger than the ${RUN_ARTIFACT_MAX_BYTES} bytes verify reads)`);
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (artifact.state === "unavailable" ||
|
|
137
|
-
(artifact.bytes.length === 0 && !requirements.allowEmpty)) {
|
|
138
|
-
missing.push(artifactPath);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (requirements.screenshot) {
|
|
142
|
-
const imageError = screenshotEvidenceError(artifactPath, artifact.bytes);
|
|
143
|
-
if (imageError) {
|
|
144
|
-
missing.push(`${artifactPath} (${imageError})`);
|
|
145
|
-
}
|
|
112
|
+
// Reads overlap, so one frame's file system calls run while another frame's PNG is checked.
|
|
113
|
+
const found = await mapWithConcurrency([...requiredPaths], EVIDENCE_READ_CONCURRENCY, ([artifactPath, requirements]) => missingArtifact(runPaths, artifactPath, requirements, recordings.get(artifactPath)));
|
|
114
|
+
return found.filter((entry) => entry !== null);
|
|
115
|
+
}
|
|
116
|
+
// Evidence files verify reads at once. Each read holds at most RUN_ARTIFACT_MAX_BYTES.
|
|
117
|
+
const EVIDENCE_READ_CONCURRENCY = 8;
|
|
118
|
+
/** Why one required path fails its requirements, or null when it meets them. */
|
|
119
|
+
async function missingArtifact(runPaths, artifactPath, requirements, recording) {
|
|
120
|
+
if (recording) {
|
|
121
|
+
const handle = await openContainedRegularFile(runPaths, artifactPath);
|
|
122
|
+
try {
|
|
123
|
+
if (!handle || (await handle.stat()).size !== recording.bytes)
|
|
124
|
+
return artifactPath;
|
|
125
|
+
const header = Buffer.alloc(12);
|
|
126
|
+
const read = await handle.read(header, 0, header.length, 0);
|
|
127
|
+
return read.bytesRead !== header.length || header.toString("ascii", 4, 8) !== "ftyp"
|
|
128
|
+
? `${artifactPath} (invalid MP4 header)`
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await handle?.close();
|
|
146
133
|
}
|
|
147
134
|
}
|
|
148
|
-
|
|
135
|
+
const read = requirements.screenshot
|
|
136
|
+
? await readScreenshot(runPaths, artifactPath)
|
|
137
|
+
: await readSafeRunArtifact(runPaths, artifactPath);
|
|
138
|
+
if (read.state === "limit")
|
|
139
|
+
return `${artifactPath} (larger than the ${RUN_ARTIFACT_MAX_BYTES} bytes verify reads)`;
|
|
140
|
+
if (read.state === "unavailable" ||
|
|
141
|
+
(("size" in read ? read.size : read.bytes.length) === 0 && !requirements.allowEmpty))
|
|
142
|
+
return artifactPath;
|
|
143
|
+
return "error" in read && read.error !== null ? `${artifactPath} (${read.error})` : null;
|
|
149
144
|
}
|
|
150
145
|
function declaredActorScreenshotReferences(stream) {
|
|
151
146
|
const references = [];
|
|
@@ -270,20 +265,13 @@ export function rawScreenshotPostureWarnings(bundle, redactedShapeFrames) {
|
|
|
270
265
|
* (isRedactedFrameShape). A frame that is missing or cannot be read is not in the set.
|
|
271
266
|
*/
|
|
272
267
|
export async function redactedShapeFramePaths(runPaths, bundle) {
|
|
273
|
-
const
|
|
274
|
-
for (const stream of bundle.streams)
|
|
275
|
-
for (const reference of declaredActorScreenshotReferences(stream))
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
continue;
|
|
281
|
-
const bytes = await readSafeRunArtifactBytes(runPaths, normalized).catch(() => null);
|
|
282
|
-
if (bytes !== null && isRedactedFrameShape(bytes))
|
|
283
|
-
shaped.add(normalized);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return shaped;
|
|
268
|
+
const frames = new Set();
|
|
269
|
+
for (const stream of bundle.streams)
|
|
270
|
+
for (const reference of declaredActorScreenshotReferences(stream))
|
|
271
|
+
if (isRunRootEvidenceReference(reference.path))
|
|
272
|
+
frames.add(normalizedFramePath(reference.path));
|
|
273
|
+
const reads = await mapWithConcurrency([...frames], EVIDENCE_READ_CONCURRENCY, (frame) => readScreenshot(runPaths, frame));
|
|
274
|
+
return new Set([...frames].filter((_, index) => reads[index].state === "read" && reads[index].redactedShape));
|
|
287
275
|
}
|
|
288
276
|
function normalizedFramePath(value) {
|
|
289
277
|
return path.posix.normalize(value.replace(/\\/g, "/"));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RunBundle } from "../run/bundle.js";
|
|
2
|
+
/**
|
|
3
|
+
* One finding per recorded page that holds more than an origin and a path, in each finished
|
|
4
|
+
* trace's items and summary and each running participant's items. A finding names where the page
|
|
5
|
+
* is, never what it says.
|
|
6
|
+
*/
|
|
7
|
+
export declare function pageFindings(bundle: RunBundle): string[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { REDACTION_MARKERS } from "../evidence/redaction.js";
|
|
2
|
+
import { isRecord } from "../run/type-guards.js";
|
|
3
|
+
// A recorded page (`items[].page`, `finalPage`) keeps a URL's origin and path only. The text scan
|
|
4
|
+
// finds secret shapes in it as in any trace text, but a query string, a fragment or user info can
|
|
5
|
+
// carry a short token no pattern matches, so a page holding one is a public-safety finding.
|
|
6
|
+
// The markers that can stand for a whole origin, such as a withheld sandbox host.
|
|
7
|
+
const MARKERS = new Set(Object.values(REDACTION_MARKERS));
|
|
8
|
+
// `scheme:` alone (a non-web URL) or `scheme://host[:port]`, with nothing after the host. The URL
|
|
9
|
+
// parser reads a backslash there as a slash, so a backslash counts as a path too.
|
|
10
|
+
const ORIGIN = /^[a-z][a-z0-9+.-]*:(?:\/\/[^/\\?#@]*)?$/i;
|
|
11
|
+
/** What is wrong with a recorded page, or undefined when it holds an origin and a path only. */
|
|
12
|
+
function pageProblem(page) {
|
|
13
|
+
if (!isRecord(page) || typeof page.origin !== "string" || typeof page.path !== "string")
|
|
14
|
+
return "is not an origin and a path";
|
|
15
|
+
if (/[?#]/.test(page.path))
|
|
16
|
+
return "has a query string or fragment in its path";
|
|
17
|
+
if (!MARKERS.has(page.origin) && !ORIGIN.test(page.origin))
|
|
18
|
+
return "has an origin with user info, a path, a query string or a fragment";
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One finding per recorded page that holds more than an origin and a path, in each finished
|
|
23
|
+
* trace's items and summary and each running participant's items. A finding names where the page
|
|
24
|
+
* is, never what it says.
|
|
25
|
+
*/
|
|
26
|
+
export function pageFindings(bundle) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
const check = (where, page) => {
|
|
29
|
+
if (page === undefined)
|
|
30
|
+
return;
|
|
31
|
+
const problem = pageProblem(page);
|
|
32
|
+
if (problem !== undefined)
|
|
33
|
+
findings.push(`${where} ${problem}`);
|
|
34
|
+
};
|
|
35
|
+
// The shape guard admits items that are not records, so each is read as unknown.
|
|
36
|
+
const checkItems = (where, items) => {
|
|
37
|
+
if (!Array.isArray(items))
|
|
38
|
+
return;
|
|
39
|
+
items.forEach((item, index) => {
|
|
40
|
+
if (isRecord(item))
|
|
41
|
+
check(`${where}[${index}].page`, item.page);
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
for (const stream of bundle.streams) {
|
|
45
|
+
checkItems(`${stream.id} items`, stream.actor?.items);
|
|
46
|
+
check(`${stream.id} finalPage`, stream.actor?.finalPage);
|
|
47
|
+
checkItems(`${stream.id} liveActor.items`, stream.liveActor?.items);
|
|
48
|
+
}
|
|
49
|
+
return findings;
|
|
50
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PreparedRunArtifactPaths } from "../run/paths.js";
|
|
2
|
+
/** What verify needs from a registered screenshot, from one read of its bytes. */
|
|
3
|
+
type ScreenshotRead = {
|
|
4
|
+
state: "read";
|
|
5
|
+
size: number;
|
|
6
|
+
/** screenshotEvidenceError for the path the caller gave. */
|
|
7
|
+
error: string | null;
|
|
8
|
+
redactedShape: boolean;
|
|
9
|
+
} | {
|
|
10
|
+
state: "limit";
|
|
11
|
+
} | {
|
|
12
|
+
state: "unavailable";
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A registered screenshot's facts: its size, why it is not PNG evidence (null when it is), and
|
|
16
|
+
* whether it has the redactor's output shape. `relativePath` may use `\`, read as `/`. A frame read
|
|
17
|
+
* before through the same `runPaths` is not read again while it is still the same file with the
|
|
18
|
+
* same size and times, and its last change was at least 2 s before that read started.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readScreenshot(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<ScreenshotRead>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isRedactedFrameShape, screenshotEvidenceError } from "../evidence/image.js";
|
|
2
|
+
import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
|
|
3
|
+
import { isUnchangedFile, readUnchangedFile } from "../run/evidence-files.js";
|
|
4
|
+
// A stamp from a read that started within this long of the file's last change may not show a
|
|
5
|
+
// later write: a filesystem with coarse timestamps gives both writes the same times.
|
|
6
|
+
const RACY_WINDOW_NS = 2000000000n;
|
|
7
|
+
/**
|
|
8
|
+
* Facts per prepared run, by the path the caller gave. A finished study verifies its run up to five
|
|
9
|
+
* times through one prepared object (the Observer render, the analysis gate, the analysis and two
|
|
10
|
+
* refreshes), and decoding every frame each time took most of each verify on a long run. The
|
|
11
|
+
* entries live as long as the prepared object.
|
|
12
|
+
*/
|
|
13
|
+
const known = new WeakMap();
|
|
14
|
+
/**
|
|
15
|
+
* A registered screenshot's facts: its size, why it is not PNG evidence (null when it is), and
|
|
16
|
+
* whether it has the redactor's output shape. `relativePath` may use `\`, read as `/`. A frame read
|
|
17
|
+
* before through the same `runPaths` is not read again while it is still the same file with the
|
|
18
|
+
* same size and times, and its last change was at least 2 s before that read started.
|
|
19
|
+
*/
|
|
20
|
+
export async function readScreenshot(runPaths, relativePath) {
|
|
21
|
+
const filePath = relativePath.replace(/\\/g, "/");
|
|
22
|
+
// Created before the first await: reads of one run overlap, and each must add to the same map.
|
|
23
|
+
let entries = known.get(runPaths);
|
|
24
|
+
if (entries === undefined)
|
|
25
|
+
known.set(runPaths, (entries = new Map()));
|
|
26
|
+
const entry = entries.get(relativePath);
|
|
27
|
+
if (entry !== undefined && (await isUnchangedFile(runPaths, filePath, entry.stamp)))
|
|
28
|
+
return entry.facts;
|
|
29
|
+
const startedNs = BigInt(Date.now()) * 1000000n;
|
|
30
|
+
const read = await readUnchangedFile(runPaths, filePath, RUN_ARTIFACT_MAX_BYTES);
|
|
31
|
+
if (read.state !== "read") {
|
|
32
|
+
entries.delete(relativePath);
|
|
33
|
+
return read.state === "limit" ? { state: "limit" } : read;
|
|
34
|
+
}
|
|
35
|
+
const facts = {
|
|
36
|
+
state: "read",
|
|
37
|
+
size: read.bytes.length,
|
|
38
|
+
error: screenshotEvidenceError(relativePath, read.bytes),
|
|
39
|
+
redactedShape: isRedactedFrameShape(read.bytes),
|
|
40
|
+
};
|
|
41
|
+
if (read.stamp.ctimeNs <= startedNs - RACY_WINDOW_NS)
|
|
42
|
+
entries.set(relativePath, { stamp: read.stamp, facts });
|
|
43
|
+
else
|
|
44
|
+
entries.delete(relativePath);
|
|
45
|
+
return facts;
|
|
46
|
+
}
|