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.
Files changed (129) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +2 -2
  4. package/dist/actors/computer-use/actor.d.ts +2 -0
  5. package/dist/actors/computer-use/actor.js +1 -0
  6. package/dist/actors/computer-use/loop/clock.d.ts +9 -8
  7. package/dist/actors/computer-use/loop/clock.js +27 -17
  8. package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
  9. package/dist/actors/computer-use/loop/debrief.js +15 -5
  10. package/dist/actors/computer-use/loop/ending.d.ts +8 -5
  11. package/dist/actors/computer-use/loop/ending.js +8 -5
  12. package/dist/actors/computer-use/loop/observation.d.ts +6 -0
  13. package/dist/actors/computer-use/loop/observation.js +19 -1
  14. package/dist/actors/computer-use/loop/session.d.ts +4 -2
  15. package/dist/actors/computer-use/loop/session.js +4 -1
  16. package/dist/actors/computer-use/loop/trace.js +4 -2
  17. package/dist/actors/computer-use/loop/types.d.ts +22 -8
  18. package/dist/actors/computer-use/loop.js +5 -5
  19. package/dist/actors/contract.d.ts +31 -8
  20. package/dist/actors/contract.js +3 -0
  21. package/dist/actors/stop-cause.js +1 -0
  22. package/dist/analysis/evidence.js +2 -2
  23. package/dist/cli/commands/study.js +8 -0
  24. package/dist/evidence/redaction.d.ts +1 -0
  25. package/dist/evidence/redaction.js +1 -0
  26. package/dist/evidence/trace-page.d.ts +6 -0
  27. package/dist/evidence/trace-page.js +90 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +1 -0
  30. package/dist/observer/feed-cursors.d.ts +14 -0
  31. package/dist/observer/feed-cursors.js +61 -0
  32. package/dist/observer/participant-items.d.ts +7 -0
  33. package/dist/observer/pinned-files.d.ts +4 -1
  34. package/dist/observer/pinned-files.js +10 -7
  35. package/dist/observer/run-routes.d.ts +1 -1
  36. package/dist/observer/run-routes.js +39 -17
  37. package/dist/observer-app.html +7 -7
  38. package/dist/routes/computer-use/bundle.js +4 -0
  39. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  40. package/dist/routes/computer-use/diagnostics.js +3 -3
  41. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  42. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  43. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  44. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  45. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  46. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  47. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  48. package/dist/routes/computer-use/fanout-records.js +4 -0
  49. package/dist/routes/computer-use/live-flush.d.ts +9 -4
  50. package/dist/routes/computer-use/live-flush.js +16 -3
  51. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  52. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  53. package/dist/routes/computer-use/participant-model.js +16 -6
  54. package/dist/routes/computer-use/participant-runs.d.ts +2 -2
  55. package/dist/routes/computer-use/participant-runs.js +11 -7
  56. package/dist/routes/computer-use/plan.js +39 -1
  57. package/dist/routes/computer-use/setup.d.ts +1 -0
  58. package/dist/routes/computer-use/setup.js +28 -0
  59. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  60. package/dist/routes/computer-use/single-bundle.js +2 -0
  61. package/dist/routes/computer-use/types.d.ts +16 -10
  62. package/dist/routes/shared-world/bundle-records.js +3 -0
  63. package/dist/routes/shared-world/participant-specs.js +3 -2
  64. package/dist/routes/shared-world/plan.js +5 -0
  65. package/dist/run/bundle-shape.js +2 -2
  66. package/dist/run/bundle.d.ts +10 -3
  67. package/dist/run/bundle.js +11 -2
  68. package/dist/run/contained-output.d.ts +26 -0
  69. package/dist/run/contained-output.js +101 -1
  70. package/dist/run/evidence-files.d.ts +12 -1
  71. package/dist/run/evidence-files.js +70 -40
  72. package/dist/run/judge.d.ts +7 -4
  73. package/dist/run/judge.js +13 -8
  74. package/dist/run/live-items.d.ts +67 -0
  75. package/dist/run/live-items.js +262 -0
  76. package/dist/run/locate.d.ts +10 -0
  77. package/dist/run/locate.js +10 -0
  78. package/dist/run/run.d.ts +3 -2
  79. package/dist/run/run.js +29 -8
  80. package/dist/run/sandbox-ids.d.ts +0 -5
  81. package/dist/run/sandbox-ids.js +0 -7
  82. package/dist/run/stream-trace.d.ts +40 -10
  83. package/dist/run/stream-trace.js +151 -22
  84. package/dist/run/streams.d.ts +33 -2
  85. package/dist/study/arrivals.d.ts +32 -14
  86. package/dist/study/arrivals.js +47 -21
  87. package/dist/study/bundle-estimate.d.ts +6 -5
  88. package/dist/study/bundle-estimate.js +12 -7
  89. package/dist/study/desktop-files.d.ts +37 -0
  90. package/dist/study/desktop-files.js +174 -0
  91. package/dist/study/keys.d.ts +8 -0
  92. package/dist/study/keys.js +3 -0
  93. package/dist/study/migrate/v2.js +12 -3
  94. package/dist/study/parse/actors.d.ts +1 -1
  95. package/dist/study/parse/actors.js +51 -26
  96. package/dist/study/parse/clock.d.ts +18 -0
  97. package/dist/study/parse/clock.js +36 -0
  98. package/dist/study/parse/execution.js +6 -0
  99. package/dist/study/plan-base.js +4 -1
  100. package/dist/study/plan-participants.d.ts +19 -5
  101. package/dist/study/plan-participants.js +17 -1
  102. package/dist/study/plan.js +24 -13
  103. package/dist/study/preflight.js +4 -0
  104. package/dist/study/types.d.ts +34 -5
  105. package/dist/study/validation.js +3 -0
  106. package/dist/study/warnings.d.ts +2 -2
  107. package/dist/study/warnings.js +7 -0
  108. package/dist/subject/local-tree-archive.d.ts +7 -0
  109. package/dist/subject/local-tree-archive.js +12 -0
  110. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  111. package/dist/substrates/e2b/desktop-browser.js +6 -2
  112. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  113. package/dist/substrates/e2b/desktop-clock.js +29 -0
  114. package/dist/substrates/e2b/sdk.d.ts +6 -0
  115. package/dist/verify/artifacts.js +39 -51
  116. package/dist/verify/pages.d.ts +7 -0
  117. package/dist/verify/pages.js +50 -0
  118. package/dist/verify/screenshot-reads.d.ts +21 -0
  119. package/dist/verify/screenshot-reads.js +46 -0
  120. package/dist/verify/verify.js +17 -9
  121. package/docs/architecture/actor-contract.md +2 -0
  122. package/docs/architecture/external-public-shared-world.md +4 -1
  123. package/docs/architecture/github-feedback-loop.md +1 -1
  124. package/docs/architecture/observer.md +10 -1
  125. package/docs/contracts/README.md +1 -1
  126. package/docs/contracts/policy.md +7 -0
  127. package/docs/contracts/run-bundle.md +106 -14
  128. package/docs/contracts/schemas.md +110 -40
  129. 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
+ }
@@ -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: {
@@ -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,
@@ -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
- // The participant's window and idle wait arrived after v2, so a v2 participant cannot carry them.
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, "startAfterMs", "endAfterMs", "windDownMs", "idleWaitMs"),
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 = str(entry.persona);
46
- if (persona)
47
- actor.persona = persona;
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
- if (entry.clock !== undefined) {
67
- const timeZone = isRecord(entry.clock) ? entry.clock.timeZone : undefined;
68
- if (typeof timeZone !== "string" || !isTimeZone(timeZone))
69
- return invalid(`${path}.clock must be { timeZone }, with timeZone a time zone name such as "Europe/London": the participant's local time, which each turn tells it.`);
70
- actor.clock = { timeZone };
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 = str(entry.persona);
145
- if (persona !== undefined)
146
- parsedEntry.persona = persona;
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({ ...parsedEntry, endAfterMs: entry.endAfterMs, windDownMs: entry.windDownMs }, paths.entry(entryIndex));
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")
@@ -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
- ...(config.title === undefined ? {} : { title: config.title }),
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
- /** actor.clock.timeZone: the participant's local time zone, which each turn tells it the time in. */
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
- /** With endAfterMs: how long before the end each turn tells it the time left (default applied). */
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
- export {};
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
+ }