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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Files a participant's hosted desktop receives before it starts (`participants[].files`), so a
|
|
2
|
+
// browser upload dialog can find them. The parser checks the declared paths; readDesktopFiles reads
|
|
3
|
+
// the files from the study's folder on this machine before any sandbox exists.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { stat } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { scanEncodedText } from "../evidence/encoded-text.js";
|
|
8
|
+
import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
|
|
9
|
+
import { isRecord } from "../run/type-guards.js";
|
|
10
|
+
import { isLocalTreeDenylisted } from "../subject/local-tree-archive.js";
|
|
11
|
+
import { SUBJECT_DIR } from "../subject/steps.js";
|
|
12
|
+
import { invalid, str } from "./parse/values.js";
|
|
13
|
+
/**
|
|
14
|
+
* Where `participants[].files` act: the hosted desktop of an independent computer-use participant.
|
|
15
|
+
* The parser's unread-field check and the route planners give this reason.
|
|
16
|
+
*/
|
|
17
|
+
export const DESKTOP_FILES_ROUTE_REASON = "files are copied onto a hosted computer-use desktop; needs route: computer-use with execution.target: e2b-desktop";
|
|
18
|
+
/** The most files one participant's desktop receives. */
|
|
19
|
+
const MAX_FILES = 20;
|
|
20
|
+
/** A participant's `files` list: each entry's `from` and `as`. */
|
|
21
|
+
export function parseParticipantFiles(raw, field) {
|
|
22
|
+
if (raw === undefined)
|
|
23
|
+
return { ok: true, value: undefined };
|
|
24
|
+
const listReason = `\`${field}\` must be a list of 1 to ${MAX_FILES} { from, as } entries, one per file the participant's desktop receives.`;
|
|
25
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_FILES)
|
|
26
|
+
return invalid(listReason);
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const [index, entry] of raw.entries()) {
|
|
29
|
+
const at = `${field}[${index}]`;
|
|
30
|
+
const from = isRecord(entry) ? str(entry.from) : undefined;
|
|
31
|
+
const as = isRecord(entry) ? str(entry.as) : undefined;
|
|
32
|
+
if (from === undefined || as === undefined)
|
|
33
|
+
return invalid(listReason);
|
|
34
|
+
if (!isPlainRelativePath(from))
|
|
35
|
+
return invalid(`\`${at}.from\` must be a path inside the folder that holds the study file, written relative to it, such as fixtures/insurance-card.png, with / between folders and no empty, . or .. part.`);
|
|
36
|
+
if (isLocalTreeDenylisted(from))
|
|
37
|
+
return invalid(`\`${at}.from\` names a file humanish keeps on this machine: a .env file, a key, a credential file, or one under .git, node_modules or .humanish. The local-tree archive leaves the same files out. Copy a synthetic file instead.`);
|
|
38
|
+
if (!isPlainRelativePath(as) || as.startsWith("~"))
|
|
39
|
+
return invalid(`\`${at}.as\` must be a path relative to the desktop user's home folder, such as Downloads/insurance-card.png, with / between folders, no empty, . or .. part, and no leading ~.`);
|
|
40
|
+
if (isHumanishDesktopPath(as))
|
|
41
|
+
return invalid(`\`${at}.as\` is a path humanish uses on the desktop: ${SUBJECT_FOLDER}/ holds the cloned or unpacked app, and names that start with .humanish are humanish's own files. Pick another path, such as Downloads/insurance-card.png.`);
|
|
42
|
+
// The run records `as`, and redacting it would record a different path. Encoded text the
|
|
43
|
+
// public-safety scan cannot read would leave run.json unscanned.
|
|
44
|
+
const scanned = scanEncodedText(as);
|
|
45
|
+
if (scanned.sensitive || scanned.opaque)
|
|
46
|
+
return invalid(`\`${at}.as\` looks like a secret, a local path or encoded text, and the run records it. Name the file plainly, such as Downloads/insurance-card.png.`);
|
|
47
|
+
const clash = files.find((earlier) => earlier.as === as || as.startsWith(`${earlier.as}/`) || earlier.as.startsWith(`${as}/`));
|
|
48
|
+
if (clash !== undefined)
|
|
49
|
+
return invalid(clash.as === as
|
|
50
|
+
? `\`${at}.as\` names ${as}, which another file in this list already uses. Give each file its own path.`
|
|
51
|
+
: `\`${at}.as\` names ${as}, and another file in this list uses ${clash.as}, so one would have to be both a file and a folder. Give each file its own path.`);
|
|
52
|
+
files.push({ from, as });
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, value: files };
|
|
55
|
+
}
|
|
56
|
+
/** The subject's folder under the sandbox user's home, where a clone or local tree is put. */
|
|
57
|
+
const SUBJECT_FOLDER = path.posix.relative("/home/user", SUBJECT_DIR);
|
|
58
|
+
/**
|
|
59
|
+
* Whether `as` lands where humanish puts its own files: the subject's folder, which provisioning
|
|
60
|
+
* replaces, or a name starting with .humanish, such as the local-tree archive.
|
|
61
|
+
*/
|
|
62
|
+
function isHumanishDesktopPath(as) {
|
|
63
|
+
const first = as.split("/")[0] ?? "";
|
|
64
|
+
return first === SUBJECT_FOLDER || first.startsWith(".humanish");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A relative path of plain names joined by `/`. A backslash is refused because the desktop is
|
|
68
|
+
* Linux, where it is part of a name, and on Windows it would be a separator.
|
|
69
|
+
*/
|
|
70
|
+
function isPlainRelativePath(value) {
|
|
71
|
+
if (value.includes("\0") || value.includes("\\") || value.startsWith("/"))
|
|
72
|
+
return false;
|
|
73
|
+
return value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
74
|
+
}
|
|
75
|
+
const MIB = 1024 * 1024;
|
|
76
|
+
/** The most bytes humanish copies for one file. */
|
|
77
|
+
const FILE_MAX_BYTES = 10 * MIB;
|
|
78
|
+
/** The most bytes one participant's desktop receives, a file counted each time it is placed. */
|
|
79
|
+
const PARTICIPANT_MAX_BYTES = 25 * MIB;
|
|
80
|
+
/** The most bytes of distinct files a study names: humanish holds them in memory for the run. */
|
|
81
|
+
const STUDY_MAX_BYTES = 100 * MIB;
|
|
82
|
+
/**
|
|
83
|
+
* Read every participant's declared files from `folder`, the folder that holds the study file, and
|
|
84
|
+
* return them by participant id. Runs before any sandbox exists, so a refusal costs nothing.
|
|
85
|
+
*/
|
|
86
|
+
export async function readDesktopFiles(folder, participants) {
|
|
87
|
+
// A library caller's config skips the parser, so its rules run again here.
|
|
88
|
+
for (const participant of participants) {
|
|
89
|
+
const checked = parseParticipantFiles(participant.files, `Participant ${participant.id}'s files`);
|
|
90
|
+
if (!checked.ok)
|
|
91
|
+
return { ok: false, message: checked.error.message };
|
|
92
|
+
}
|
|
93
|
+
const files = new Map();
|
|
94
|
+
if (!participants.some((participant) => participant.files !== undefined))
|
|
95
|
+
return { ok: true, files };
|
|
96
|
+
const root = await bindFolder(folder);
|
|
97
|
+
if (root === undefined)
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
message: "The folder that holds the study file could not be read as a folder, so its participants' files cannot be copied. Run the study from its file, with the folder in place.",
|
|
101
|
+
};
|
|
102
|
+
// One read per distinct `from`: a group of participants shares one file's bytes.
|
|
103
|
+
const sources = new Map();
|
|
104
|
+
let studyBytes = 0;
|
|
105
|
+
for (const participant of participants) {
|
|
106
|
+
const placed = [];
|
|
107
|
+
let participantBytes = 0;
|
|
108
|
+
for (const [index, file] of (participant.files ?? []).entries()) {
|
|
109
|
+
let source = sources.get(file.from);
|
|
110
|
+
if (source === undefined) {
|
|
111
|
+
// A file past what the study has left is refused unread, so memory stays under the total.
|
|
112
|
+
const left = STUDY_MAX_BYTES - studyBytes;
|
|
113
|
+
const read = await readContainedRegularFile(root, file.from, Math.min(FILE_MAX_BYTES, left));
|
|
114
|
+
if (read.status === "refused" && read.reason === "too-large" && left < FILE_MAX_BYTES)
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
message: `The files this study's participants receive total more than ${STUDY_MAX_BYTES / MIB} MiB, the most humanish holds in memory for one run. Use smaller files, or give more participants the same file.`,
|
|
118
|
+
};
|
|
119
|
+
if (read.status !== "read")
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
message: `Participant ${participant.id}'s files[${index}].from, ${file.from}, ${unreadReason(read)}. Paths in from are relative to the folder that holds the study file.`,
|
|
123
|
+
};
|
|
124
|
+
const sha256 = createHash("sha256").update(read.bytes).digest("hex");
|
|
125
|
+
source = { data: new Uint8Array(read.bytes).buffer, size: read.bytes.length, sha256 };
|
|
126
|
+
sources.set(file.from, source);
|
|
127
|
+
studyBytes += source.size;
|
|
128
|
+
}
|
|
129
|
+
participantBytes += source.size;
|
|
130
|
+
if (participantBytes > PARTICIPANT_MAX_BYTES)
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
message: `Participant ${participant.id}'s files total more than ${PARTICIPANT_MAX_BYTES / MIB} MiB, the most humanish copies onto one desktop. Use smaller or fewer files.`,
|
|
134
|
+
};
|
|
135
|
+
placed.push({ as: file.as, ...source });
|
|
136
|
+
}
|
|
137
|
+
if (placed.length > 0)
|
|
138
|
+
files.set(participant.id, placed);
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, files };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The study's folder as a contained-read root, or undefined when it is not an existing folder.
|
|
144
|
+
* prepareSelectedOutputDirectory would create a missing one.
|
|
145
|
+
*/
|
|
146
|
+
async function bindFolder(folder) {
|
|
147
|
+
try {
|
|
148
|
+
if (!(await stat(folder)).isDirectory())
|
|
149
|
+
return undefined;
|
|
150
|
+
return await prepareSelectedOutputDirectory(folder, folder);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Why a `from` could not be read, as a clause after its path. */
|
|
157
|
+
function unreadReason(read) {
|
|
158
|
+
if (read.status === "missing")
|
|
159
|
+
return "does not exist in the folder that holds the study file";
|
|
160
|
+
switch (read.reason) {
|
|
161
|
+
case "too-large":
|
|
162
|
+
return `is larger than ${FILE_MAX_BYTES / MIB} MiB, the most humanish copies for one file`;
|
|
163
|
+
case "directory":
|
|
164
|
+
return "is a folder; name a file";
|
|
165
|
+
case "not-regular":
|
|
166
|
+
return "is a symbolic link, a hard link or another kind of file humanish does not copy; put a plain copy of the file in the folder";
|
|
167
|
+
case "unsafe-path":
|
|
168
|
+
return "leaves the folder that holds the study file or passes through a symbolic link; put a plain copy of the file in the folder";
|
|
169
|
+
case "changed":
|
|
170
|
+
return "changed while humanish read it; run the study again";
|
|
171
|
+
case "unreadable":
|
|
172
|
+
return "could not be read; check its permissions";
|
|
173
|
+
}
|
|
174
|
+
}
|
package/dist/study/keys.d.ts
CHANGED
|
@@ -16,12 +16,20 @@ export declare const PARTICIPANT_ENTRY: {
|
|
|
16
16
|
device: true;
|
|
17
17
|
instruction: true;
|
|
18
18
|
target: true;
|
|
19
|
+
files: {
|
|
20
|
+
from: true;
|
|
21
|
+
as: true;
|
|
22
|
+
};
|
|
19
23
|
entry: true;
|
|
20
24
|
host: true;
|
|
21
25
|
startAfterMs: true;
|
|
22
26
|
endAfterMs: true;
|
|
27
|
+
maxSessionMs: true;
|
|
23
28
|
windDownMs: true;
|
|
24
29
|
idleWaitMs: true;
|
|
30
|
+
clock: {
|
|
31
|
+
timeZone: true;
|
|
32
|
+
};
|
|
25
33
|
reasoningEffort: true;
|
|
26
34
|
stopWhen: {
|
|
27
35
|
any: {
|
package/dist/study/keys.js
CHANGED
|
@@ -37,12 +37,15 @@ export const PARTICIPANT_ENTRY = {
|
|
|
37
37
|
device: true,
|
|
38
38
|
instruction: true,
|
|
39
39
|
target: true,
|
|
40
|
+
files: { from: true, as: true },
|
|
40
41
|
entry: true,
|
|
41
42
|
host: true,
|
|
42
43
|
startAfterMs: true,
|
|
43
44
|
endAfterMs: true,
|
|
45
|
+
maxSessionMs: true,
|
|
44
46
|
windDownMs: true,
|
|
45
47
|
idleWaitMs: true,
|
|
48
|
+
clock: { timeZone: true },
|
|
46
49
|
reasoningEffort: true,
|
|
47
50
|
stopWhen: STOP_WHEN,
|
|
48
51
|
dwell: DWELL,
|
package/dist/study/migrate/v2.js
CHANGED
|
@@ -80,6 +80,16 @@ export function movedV2KeyReason(raw) {
|
|
|
80
80
|
}
|
|
81
81
|
return undefined;
|
|
82
82
|
}
|
|
83
|
+
// Participant keys that arrived after v2, so a v2 participant cannot carry them.
|
|
84
|
+
const AFTER_V2_PARTICIPANT_KEYS = [
|
|
85
|
+
"startAfterMs",
|
|
86
|
+
"endAfterMs",
|
|
87
|
+
"windDownMs",
|
|
88
|
+
"idleWaitMs",
|
|
89
|
+
"files",
|
|
90
|
+
"maxSessionMs",
|
|
91
|
+
"clock",
|
|
92
|
+
];
|
|
83
93
|
// The keys of a humanish.lab.v2 file, in the order its parser listed them: the v3 sections with
|
|
84
94
|
// `subject.topology`, the actor list with its participant keys, `execution.caps`, `personas` and
|
|
85
95
|
// the `scenario` object.
|
|
@@ -92,11 +102,10 @@ const V2_KEYS = {
|
|
|
92
102
|
actors: {
|
|
93
103
|
type: true,
|
|
94
104
|
count: true,
|
|
95
|
-
|
|
96
|
-
lanes: without(PARTICIPANT_ENTRY, "startAfterMs", "endAfterMs", "windDownMs", "idleWaitMs"),
|
|
105
|
+
lanes: without(PARTICIPANT_ENTRY, ...AFTER_V2_PARTICIPANT_KEYS),
|
|
97
106
|
// Roster groups are participants with a count; the parser expanded them into `lanes[]`.
|
|
98
107
|
roster: {
|
|
99
|
-
...without(PARTICIPANT_ENTRY,
|
|
108
|
+
...without(PARTICIPANT_ENTRY, ...AFTER_V2_PARTICIPANT_KEYS),
|
|
100
109
|
count: true,
|
|
101
110
|
},
|
|
102
111
|
// maxWaitMs, idleWaitMs and clock arrived after v2, so a v2 file cannot carry them.
|
|
@@ -25,7 +25,7 @@ export interface ParticipantPaths {
|
|
|
25
25
|
* Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
|
|
26
26
|
* study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
|
|
27
27
|
* Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
|
|
28
|
-
* instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }`.
|
|
28
|
+
* instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }`.
|
|
29
29
|
* Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
|
|
30
30
|
* must be public-safe tokens; a device must be a known preset name. The
|
|
31
31
|
* route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
|
|
@@ -7,7 +7,10 @@ import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js
|
|
|
7
7
|
import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
|
|
8
8
|
import { invalid, str } from "./values.js";
|
|
9
9
|
import { isRecord } from "../../run/type-guards.js";
|
|
10
|
+
import { scanEncodedText } from "../../evidence/encoded-text.js";
|
|
10
11
|
import { isStartAfterMs, startAfterMessage, windowReason } from "../arrivals.js";
|
|
12
|
+
import { parseParticipantFiles } from "../desktop-files.js";
|
|
13
|
+
import { parseClock } from "./clock.js";
|
|
11
14
|
// A participant id interpolates into its evidence paths (screenshots/<id>/, actors/<id>.json), so
|
|
12
15
|
// it must be a public-safe path token, same shape as a study id.
|
|
13
16
|
export const PARTICIPANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
@@ -36,15 +39,29 @@ export function parseActorType(entry, path) {
|
|
|
36
39
|
}
|
|
37
40
|
return { ok: true, value: type };
|
|
38
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* A persona id. It names the persona file `humanish/personas/<id>.yaml`, and the run records it, so
|
|
44
|
+
* text verify would flag, as written or encoded, is refused here: redacting it would name a
|
|
45
|
+
* different file.
|
|
46
|
+
*/
|
|
47
|
+
function parsePersonaId(raw, path) {
|
|
48
|
+
const persona = str(raw);
|
|
49
|
+
if (persona !== undefined && scanEncodedText(persona).sensitive) {
|
|
50
|
+
return invalid(`\`${path}\` looks like a secret or a local path, and the run records it. A persona id names the persona file humanish/personas/<id>.yaml: use a plain file name such as skeptical-shopper, and put the persona's details in that file.`);
|
|
51
|
+
}
|
|
52
|
+
return { ok: true, value: persona };
|
|
53
|
+
}
|
|
39
54
|
/**
|
|
40
55
|
* Every actor key other than `type`. The parser reads them after the participant entries, so a file
|
|
41
56
|
* with errors in both reports the entry first.
|
|
42
57
|
*/
|
|
43
58
|
export function parseActorFields(entry, path) {
|
|
44
59
|
const actor = {};
|
|
45
|
-
const persona =
|
|
46
|
-
if (persona)
|
|
47
|
-
|
|
60
|
+
const persona = parsePersonaId(entry.persona, `${path}.persona`);
|
|
61
|
+
if (!persona.ok)
|
|
62
|
+
return persona;
|
|
63
|
+
if (persona.value !== undefined)
|
|
64
|
+
actor.persona = persona.value;
|
|
48
65
|
const mission = str(entry.mission);
|
|
49
66
|
if (mission)
|
|
50
67
|
actor.mission = mission;
|
|
@@ -63,12 +80,11 @@ export function parseActorFields(entry, path) {
|
|
|
63
80
|
actor.maxWaitMs = entry.maxWaitMs;
|
|
64
81
|
if (typeof entry.idleWaitMs === "number")
|
|
65
82
|
actor.idleWaitMs = entry.idleWaitMs;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
actor.clock =
|
|
71
|
-
}
|
|
83
|
+
const clock = parseClock(entry.clock, `${path}.clock`);
|
|
84
|
+
if (!clock.ok)
|
|
85
|
+
return clock;
|
|
86
|
+
if (clock.value !== undefined)
|
|
87
|
+
actor.clock = clock.value;
|
|
72
88
|
const localAgent = str(entry.localAgent);
|
|
73
89
|
if (entry.localAgent !== undefined) {
|
|
74
90
|
if (localAgent !== "codex" && localAgent !== "claude") {
|
|
@@ -103,7 +119,7 @@ export function parseActorFields(entry, path) {
|
|
|
103
119
|
* Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
|
|
104
120
|
* study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
|
|
105
121
|
* Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
|
|
106
|
-
* instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }`.
|
|
122
|
+
* instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }`.
|
|
107
123
|
* Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
|
|
108
124
|
* must be public-safe tokens; a device must be a known preset name. The
|
|
109
125
|
* route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
|
|
@@ -114,13 +130,13 @@ export function parseParticipantEntries(raw, paths) {
|
|
|
114
130
|
return { ok: true, value: undefined };
|
|
115
131
|
}
|
|
116
132
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
117
|
-
return invalid(`${paths.list} must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
|
|
133
|
+
return invalid(`${paths.list} must be a non-empty array of participant objects ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }) when set.`);
|
|
118
134
|
}
|
|
119
135
|
const entries = [];
|
|
120
136
|
const seenIds = new Set();
|
|
121
137
|
for (const [entryIndex, entry] of raw.entries()) {
|
|
122
138
|
if (!isRecord(entry)) {
|
|
123
|
-
return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }).`);
|
|
139
|
+
return invalid(`${paths.entry(entryIndex)} must be an object ({ id?, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }).`);
|
|
124
140
|
}
|
|
125
141
|
const parsedEntry = {};
|
|
126
142
|
const id = str(entry.id);
|
|
@@ -141,9 +157,11 @@ export function parseParticipantEntries(raw, paths) {
|
|
|
141
157
|
}
|
|
142
158
|
parsedEntry.device = device;
|
|
143
159
|
}
|
|
144
|
-
const persona =
|
|
145
|
-
if (persona
|
|
146
|
-
|
|
160
|
+
const persona = parsePersonaId(entry.persona, `${paths.entry(entryIndex)}.persona`);
|
|
161
|
+
if (!persona.ok)
|
|
162
|
+
return persona;
|
|
163
|
+
if (persona.value !== undefined)
|
|
164
|
+
parsedEntry.persona = persona.value;
|
|
147
165
|
const actorType = parseEntryMetadata(entry.actorType, `${paths.entry(entryIndex)}.actorType`);
|
|
148
166
|
if (!actorType.ok)
|
|
149
167
|
return actorType;
|
|
@@ -188,6 +206,11 @@ export function parseParticipantEntries(raw, paths) {
|
|
|
188
206
|
return invalid(credential);
|
|
189
207
|
parsedEntry.target = target;
|
|
190
208
|
}
|
|
209
|
+
const files = parseParticipantFiles(entry.files, `${paths.entry(entryIndex)}.files`);
|
|
210
|
+
if (!files.ok)
|
|
211
|
+
return files;
|
|
212
|
+
if (files.value !== undefined)
|
|
213
|
+
parsedEntry.files = files.value;
|
|
191
214
|
// `entry` is shape-captured here; the same-origin-with-serve.url check needs serve context, so
|
|
192
215
|
// it runs in sharedWorldValidationReason (where the route + serve.url are known).
|
|
193
216
|
const entryPath = str(entry.entry);
|
|
@@ -211,11 +234,18 @@ export function parseParticipantEntries(raw, paths) {
|
|
|
211
234
|
return invalid(startAfterMessage(`${paths.entry(entryIndex)}.startAfterMs`));
|
|
212
235
|
parsedEntry.startAfterMs = entry.startAfterMs;
|
|
213
236
|
}
|
|
214
|
-
const window = windowReason({
|
|
237
|
+
const window = windowReason({
|
|
238
|
+
...parsedEntry,
|
|
239
|
+
endAfterMs: entry.endAfterMs,
|
|
240
|
+
maxSessionMs: entry.maxSessionMs,
|
|
241
|
+
windDownMs: entry.windDownMs,
|
|
242
|
+
}, paths.entry(entryIndex));
|
|
215
243
|
if (window !== undefined)
|
|
216
244
|
return invalid(window);
|
|
217
245
|
if (typeof entry.endAfterMs === "number")
|
|
218
246
|
parsedEntry.endAfterMs = entry.endAfterMs;
|
|
247
|
+
if (typeof entry.maxSessionMs === "number")
|
|
248
|
+
parsedEntry.maxSessionMs = entry.maxSessionMs;
|
|
219
249
|
if (typeof entry.windDownMs === "number")
|
|
220
250
|
parsedEntry.windDownMs = entry.windDownMs;
|
|
221
251
|
// Checked against actor.maxWaitMs once the actor is read (waitLimitValidationReason).
|
|
@@ -224,6 +254,11 @@ export function parseParticipantEntries(raw, paths) {
|
|
|
224
254
|
return invalid(idleWait);
|
|
225
255
|
if (typeof entry.idleWaitMs === "number")
|
|
226
256
|
parsedEntry.idleWaitMs = entry.idleWaitMs;
|
|
257
|
+
const clock = parseClock(entry.clock, `${paths.entry(entryIndex)}.clock`);
|
|
258
|
+
if (!clock.ok)
|
|
259
|
+
return clock;
|
|
260
|
+
if (clock.value !== undefined)
|
|
261
|
+
parsedEntry.clock = clock.value;
|
|
227
262
|
entries.push(parsedEntry);
|
|
228
263
|
}
|
|
229
264
|
return { ok: true, value: entries };
|
|
@@ -395,13 +430,3 @@ function parseEntryMetadata(raw, field) {
|
|
|
395
430
|
}
|
|
396
431
|
return { ok: true, value };
|
|
397
432
|
}
|
|
398
|
-
/** Whether Intl knows `name` as a time zone. */
|
|
399
|
-
function isTimeZone(name) {
|
|
400
|
-
try {
|
|
401
|
-
new Intl.DateTimeFormat("en-GB", { timeZone: name });
|
|
402
|
-
return true;
|
|
403
|
-
}
|
|
404
|
-
catch {
|
|
405
|
-
return false;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StudyParseFailure } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A participant's clock, `actor.clock` or `participants[].clock`: `{ timeZone }`. Each turn gives the
|
|
4
|
+
* participant the time in the zone, and a hosted desktop launches its browser with TZ set to it.
|
|
5
|
+
* Chrome reads TZ through ICU, which takes only a tz database name spelled with its capitals: on
|
|
6
|
+
* TZ=+05:00 it runs in UTC, and on TZ=europe/london it reports Etc/Unknown, though Intl here
|
|
7
|
+
* accepts both. So the zone must be a name Intl knows that is not a UTC offset and is spelled with
|
|
8
|
+
* capitals. Intl gives no case-sensitive check: it spells europe/belfast as Europe/London, and its
|
|
9
|
+
* supportedValuesOf omits aliases such as Europe/Belfast. So the rule is the one every tz database name
|
|
10
|
+
* keeps: each part between slashes starts with a capital, and a case variant of Intl's spelling is
|
|
11
|
+
* refused. A miscased letter after the first in a part of an alias (Europe/BELFAST) still passes.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseClock(raw: unknown, path: string): {
|
|
14
|
+
ok: true;
|
|
15
|
+
value: {
|
|
16
|
+
timeZone: string;
|
|
17
|
+
} | undefined;
|
|
18
|
+
} | StudyParseFailure;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isRecord } from "../../run/type-guards.js";
|
|
2
|
+
import { invalid } from "./values.js";
|
|
3
|
+
/**
|
|
4
|
+
* A participant's clock, `actor.clock` or `participants[].clock`: `{ timeZone }`. Each turn gives the
|
|
5
|
+
* participant the time in the zone, and a hosted desktop launches its browser with TZ set to it.
|
|
6
|
+
* Chrome reads TZ through ICU, which takes only a tz database name spelled with its capitals: on
|
|
7
|
+
* TZ=+05:00 it runs in UTC, and on TZ=europe/london it reports Etc/Unknown, though Intl here
|
|
8
|
+
* accepts both. So the zone must be a name Intl knows that is not a UTC offset and is spelled with
|
|
9
|
+
* capitals. Intl gives no case-sensitive check: it spells europe/belfast as Europe/London, and its
|
|
10
|
+
* supportedValuesOf omits aliases such as Europe/Belfast. So the rule is the one every tz database name
|
|
11
|
+
* keeps: each part between slashes starts with a capital, and a case variant of Intl's spelling is
|
|
12
|
+
* refused. A miscased letter after the first in a part of an alias (Europe/BELFAST) still passes.
|
|
13
|
+
*/
|
|
14
|
+
export function parseClock(raw, path) {
|
|
15
|
+
if (raw === undefined)
|
|
16
|
+
return { ok: true, value: undefined };
|
|
17
|
+
const timeZone = isRecord(raw) ? raw.timeZone : undefined;
|
|
18
|
+
const resolved = typeof timeZone === "string" ? intlSpelling(timeZone) : undefined;
|
|
19
|
+
if (typeof timeZone !== "string" || resolved === undefined)
|
|
20
|
+
return invalid(`${path} must be { timeZone }, with timeZone a time zone name such as "Europe/London": the participant's local time, which each turn tells it and its hosted browser runs in.`);
|
|
21
|
+
if (/^[+-]/.test(resolved))
|
|
22
|
+
return invalid(`${path}.timeZone "${timeZone}" is a UTC offset, and a browser's time zone takes only a time zone name. Write the name of a place in that zone, such as "Asia/Karachi" for +05:00.`);
|
|
23
|
+
if (/(^|\/)[^A-Z]/.test(timeZone) ||
|
|
24
|
+
(resolved !== timeZone && resolved.toLowerCase() === timeZone.toLowerCase()))
|
|
25
|
+
return invalid(`${path}.timeZone "${timeZone}" must be written with the capitals of a time zone name, such as "${resolved}" for this zone: the browser reads a time zone name only with its capitals.`);
|
|
26
|
+
return { ok: true, value: { timeZone } };
|
|
27
|
+
}
|
|
28
|
+
/** How Intl spells the time zone `name`, or undefined when Intl does not know it. */
|
|
29
|
+
function intlSpelling(name) {
|
|
30
|
+
try {
|
|
31
|
+
return new Intl.DateTimeFormat("en-GB", { timeZone: name }).resolvedOptions().timeZone;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -3,6 +3,7 @@ import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
|
|
|
3
3
|
import { isExactRuntimeVersion } from "../../routes/terminal/runtime.js";
|
|
4
4
|
import { invalid, nonNegNumber, posInt, str } from "./values.js";
|
|
5
5
|
import { isRecord } from "../../run/type-guards.js";
|
|
6
|
+
import { scanEncodedText } from "../../evidence/encoded-text.js";
|
|
6
7
|
export function parseExecution(raw) {
|
|
7
8
|
if (raw === undefined) {
|
|
8
9
|
return { ok: true, value: undefined };
|
|
@@ -154,11 +155,16 @@ function parseDesktop(raw) {
|
|
|
154
155
|
// A custom E2B desktop template name or ID. Trimmed non-empty when present; deliberately not
|
|
155
156
|
// allowlisted (any string is a valid template name/id; over-restricting would reject real
|
|
156
157
|
// adopter images). An explicitly-set but blank/whitespace value is a mistake, not a template.
|
|
158
|
+
// The run records the template it launched on, so text verify would flag, as written or
|
|
159
|
+
// encoded, is refused: redacting it in the record would name an image other than the one that ran.
|
|
157
160
|
if (raw.template !== undefined) {
|
|
158
161
|
const template = str(raw.template);
|
|
159
162
|
if (template === undefined) {
|
|
160
163
|
return invalid("`execution.desktop.template` must be a non-empty E2B desktop template name or ID when it is set. Any name is accepted.");
|
|
161
164
|
}
|
|
165
|
+
if (scanEncodedText(template).sensitive) {
|
|
166
|
+
return invalid("`execution.desktop.template` looks like a secret or a local path, and the run records the template it launched on. Set it to the E2B template's name or ID only.");
|
|
167
|
+
}
|
|
162
168
|
desktop.template = template;
|
|
163
169
|
}
|
|
164
170
|
if (typeof raw.codexAppServer === "boolean")
|
package/dist/study/plan-base.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// What every route planner shares: the plan fields common to all routes, the frozen residual config,
|
|
2
2
|
// and the requirement and subject helpers.
|
|
3
3
|
import { DEFAULT_OPENAI_CU_MODEL } from "../actors/computer-use/openai-provider.js";
|
|
4
|
+
import { redactText } from "../evidence/redaction.js";
|
|
4
5
|
// A YAML alias can make a parsed record contain itself (an inline persona that names its own
|
|
5
6
|
// anchor), and structuredClone keeps the cycle, so each object is frozen once.
|
|
6
7
|
function deepFreeze(value, frozen = new WeakSet()) {
|
|
@@ -110,7 +111,9 @@ export function planBase(config, input) {
|
|
|
110
111
|
const analysis = input.analysis?.config;
|
|
111
112
|
return {
|
|
112
113
|
studyId: config.id,
|
|
113
|
-
|
|
114
|
+
// Every bundle writer takes the title from here, and review.md, run.json and the Observer show
|
|
115
|
+
// it, so a key or sandbox URL in it would make verify grade the run blocked.
|
|
116
|
+
...(config.title === undefined ? {} : { title: redactText(config.title) }),
|
|
114
117
|
residual: residualOf(config),
|
|
115
118
|
dryRun: input.dryRun,
|
|
116
119
|
...(analysis === undefined
|
|
@@ -2,7 +2,7 @@ import type { DwellWindow, StopWhen } from "../actors/stop-conditions.js";
|
|
|
2
2
|
import type { ReasoningEffort } from "../actors/reasoning-effort.js";
|
|
3
3
|
import { type DevicePreset } from "./device-presets.js";
|
|
4
4
|
import type { StudyTask } from "./tasks.js";
|
|
5
|
-
import type { StudyConfig } from "./types.js";
|
|
5
|
+
import type { StudyConfig, StudyParticipantFile } from "./types.js";
|
|
6
6
|
/** Who one participant is. Every route with participants carries this record. */
|
|
7
7
|
export interface Participant {
|
|
8
8
|
/** Declared roster id, else `lane-NN` (independent participants) or `role-NN` (shared-world participants). */
|
|
@@ -24,7 +24,7 @@ export interface Participant {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
/** A participant at a desktop: computer use and shared world. */
|
|
27
|
-
interface DesktopParticipant extends Participant {
|
|
27
|
+
export interface DesktopParticipant extends Participant {
|
|
28
28
|
readonly device: {
|
|
29
29
|
readonly name: string;
|
|
30
30
|
readonly preset: DevicePreset;
|
|
@@ -43,14 +43,22 @@ interface DesktopParticipant extends Participant {
|
|
|
43
43
|
* while the participant only waits.
|
|
44
44
|
*/
|
|
45
45
|
readonly idleWaitMs?: number;
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The participant's clock.timeZone, else actor.clock.timeZone: each turn tells it the time in
|
|
48
|
+
* this zone, and a hosted desktop runs its browser in it.
|
|
49
|
+
*/
|
|
47
50
|
readonly timeZone?: string;
|
|
48
51
|
};
|
|
49
52
|
/** When it starts, in ms after the run starts its participants; absent, with the run. */
|
|
50
53
|
readonly startAfterMs?: number;
|
|
51
54
|
/** Computer use: when it ends, on the same clock; absent, it runs for the study's session budget. */
|
|
52
55
|
readonly endAfterMs?: number;
|
|
53
|
-
/**
|
|
56
|
+
/** Computer use: the longest its session lasts, from its start; it may end the session sooner. */
|
|
57
|
+
readonly maxSessionMs?: number;
|
|
58
|
+
/**
|
|
59
|
+
* With endAfterMs or maxSessionMs: how long before the end each turn tells it the time left
|
|
60
|
+
* (default applied).
|
|
61
|
+
*/
|
|
54
62
|
readonly windDownMs?: number;
|
|
55
63
|
}
|
|
56
64
|
/** Only independent computer-use participants consume a task protocol and their own target. */
|
|
@@ -58,6 +66,8 @@ export interface ComputerUseParticipant extends DesktopParticipant {
|
|
|
58
66
|
readonly tasks?: readonly StudyTask[];
|
|
59
67
|
/** app-url fan-out only: this participant's own entry URL; absent means the subject URL. */
|
|
60
68
|
readonly targetUrl?: string;
|
|
69
|
+
/** Hosted desktop only: the files copied onto it before the participant starts. */
|
|
70
|
+
readonly files?: readonly StudyParticipantFile[];
|
|
61
71
|
}
|
|
62
72
|
/** A participant on a provisioned plane: an optional same-origin path under `serve.url`. */
|
|
63
73
|
export interface ProvisionedParticipant extends DesktopParticipant {
|
|
@@ -93,4 +103,8 @@ export declare function sharedWorldParticipants(config: StudyConfig): SharedWorl
|
|
|
93
103
|
export declare function declaredParticipantIds(config: StudyConfig): string[];
|
|
94
104
|
/** The entry URLs roster entries declare in place of the subject URL (computer use, app-url). */
|
|
95
105
|
export declare function declaredTargets(config: StudyConfig): string[];
|
|
96
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Each participant's time zone, as `study check` and `study show` print it, in roster order with
|
|
108
|
+
* `none` for a participant without one. Undefined when no participant has a zone.
|
|
109
|
+
*/
|
|
110
|
+
export declare function describeTimeZones(participants: readonly DesktopParticipant[]): string | undefined;
|
|
@@ -15,7 +15,7 @@ function desktopParticipant(config, entry, index, kind, focus) {
|
|
|
15
15
|
const maxOutputTokens = actor?.maxOutputTokens;
|
|
16
16
|
const maxWaitMs = actor?.maxWaitMs;
|
|
17
17
|
const idleWaitMs = entry?.idleWaitMs ?? actor?.idleWaitMs;
|
|
18
|
-
const timeZone = actor?.clock?.timeZone;
|
|
18
|
+
const timeZone = entry?.clock?.timeZone ?? actor?.clock?.timeZone;
|
|
19
19
|
return {
|
|
20
20
|
id: participantIdAt(index, entry?.id, kind),
|
|
21
21
|
index,
|
|
@@ -46,6 +46,12 @@ function desktopParticipant(config, entry, index, kind, focus) {
|
|
|
46
46
|
endAfterMs: entry.endAfterMs,
|
|
47
47
|
windDownMs: entry.windDownMs ?? DEFAULT_WIND_DOWN_MS,
|
|
48
48
|
}),
|
|
49
|
+
...(entry?.maxSessionMs === undefined
|
|
50
|
+
? {}
|
|
51
|
+
: {
|
|
52
|
+
maxSessionMs: entry.maxSessionMs,
|
|
53
|
+
windDownMs: entry.windDownMs ?? DEFAULT_WIND_DOWN_MS,
|
|
54
|
+
}),
|
|
49
55
|
};
|
|
50
56
|
}
|
|
51
57
|
/**
|
|
@@ -62,6 +68,7 @@ export function computerUseParticipants(config, countOverride) {
|
|
|
62
68
|
...desktopParticipant(config, entry, index, "lane", focus),
|
|
63
69
|
...(actor?.tasks === undefined ? {} : { tasks: actor.tasks }),
|
|
64
70
|
...(entry?.target === undefined ? {} : { targetUrl: entry.target }),
|
|
71
|
+
...(entry?.files === undefined ? {} : { files: entry.files }),
|
|
65
72
|
};
|
|
66
73
|
});
|
|
67
74
|
}
|
|
@@ -102,3 +109,12 @@ export function declaredTargets(config) {
|
|
|
102
109
|
.map((entry) => entry.target)
|
|
103
110
|
.filter((target) => target !== undefined);
|
|
104
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Each participant's time zone, as `study check` and `study show` print it, in roster order with
|
|
114
|
+
* `none` for a participant without one. Undefined when no participant has a zone.
|
|
115
|
+
*/
|
|
116
|
+
export function describeTimeZones(participants) {
|
|
117
|
+
if (participants.every((participant) => participant.limits.timeZone === undefined))
|
|
118
|
+
return undefined;
|
|
119
|
+
return `${participants.map((participant) => `${participant.id} ${participant.limits.timeZone ?? "none"}`).join(", ")}.`;
|
|
120
|
+
}
|