humanish 0.125.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 (104) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/clock.d.ts +9 -8
  5. package/dist/actors/computer-use/loop/clock.js +27 -17
  6. package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
  7. package/dist/actors/computer-use/loop/debrief.js +15 -5
  8. package/dist/actors/computer-use/loop/ending.d.ts +8 -5
  9. package/dist/actors/computer-use/loop/ending.js +8 -5
  10. package/dist/actors/computer-use/loop/observation.d.ts +6 -0
  11. package/dist/actors/computer-use/loop/observation.js +19 -1
  12. package/dist/actors/computer-use/loop/session.d.ts +4 -2
  13. package/dist/actors/computer-use/loop/session.js +4 -1
  14. package/dist/actors/computer-use/loop/trace.js +4 -2
  15. package/dist/actors/computer-use/loop/types.d.ts +22 -8
  16. package/dist/actors/computer-use/loop.js +5 -5
  17. package/dist/actors/contract.d.ts +31 -8
  18. package/dist/actors/contract.js +3 -0
  19. package/dist/actors/stop-cause.js +1 -0
  20. package/dist/cli/commands/study.js +8 -0
  21. package/dist/evidence/redaction.d.ts +1 -0
  22. package/dist/evidence/redaction.js +1 -0
  23. package/dist/evidence/trace-page.d.ts +6 -0
  24. package/dist/evidence/trace-page.js +90 -0
  25. package/dist/observer/feed-cursors.d.ts +14 -0
  26. package/dist/observer/feed-cursors.js +61 -0
  27. package/dist/observer/participant-items.d.ts +7 -0
  28. package/dist/observer/pinned-files.d.ts +2 -1
  29. package/dist/observer/pinned-files.js +2 -2
  30. package/dist/observer/run-routes.d.ts +1 -1
  31. package/dist/observer/run-routes.js +38 -17
  32. package/dist/observer-app.html +7 -7
  33. package/dist/routes/computer-use/bundle.js +4 -0
  34. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  35. package/dist/routes/computer-use/diagnostics.js +3 -3
  36. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  37. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  38. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  39. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  40. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  41. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  42. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  43. package/dist/routes/computer-use/fanout-records.js +4 -0
  44. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  45. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  46. package/dist/routes/computer-use/participant-model.js +16 -6
  47. package/dist/routes/computer-use/participant-runs.d.ts +2 -2
  48. package/dist/routes/computer-use/participant-runs.js +11 -7
  49. package/dist/routes/computer-use/plan.js +39 -1
  50. package/dist/routes/computer-use/setup.d.ts +1 -0
  51. package/dist/routes/computer-use/setup.js +28 -0
  52. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  53. package/dist/routes/computer-use/single-bundle.js +2 -0
  54. package/dist/routes/computer-use/types.d.ts +16 -10
  55. package/dist/routes/shared-world/bundle-records.js +3 -0
  56. package/dist/routes/shared-world/participant-specs.js +3 -2
  57. package/dist/routes/shared-world/plan.js +5 -0
  58. package/dist/run/contained-output.d.ts +7 -3
  59. package/dist/run/contained-output.js +11 -7
  60. package/dist/run/judge.d.ts +7 -4
  61. package/dist/run/judge.js +13 -8
  62. package/dist/run/live-items.d.ts +19 -0
  63. package/dist/run/live-items.js +28 -8
  64. package/dist/run/stream-trace.d.ts +25 -4
  65. package/dist/run/stream-trace.js +55 -17
  66. package/dist/run/streams.d.ts +16 -0
  67. package/dist/study/arrivals.d.ts +32 -14
  68. package/dist/study/arrivals.js +47 -21
  69. package/dist/study/bundle-estimate.d.ts +6 -5
  70. package/dist/study/bundle-estimate.js +12 -7
  71. package/dist/study/desktop-files.d.ts +37 -0
  72. package/dist/study/desktop-files.js +174 -0
  73. package/dist/study/keys.d.ts +8 -0
  74. package/dist/study/keys.js +3 -0
  75. package/dist/study/migrate/v2.js +12 -3
  76. package/dist/study/parse/actors.d.ts +1 -1
  77. package/dist/study/parse/actors.js +28 -20
  78. package/dist/study/parse/clock.d.ts +18 -0
  79. package/dist/study/parse/clock.js +36 -0
  80. package/dist/study/plan-participants.d.ts +19 -5
  81. package/dist/study/plan-participants.js +17 -1
  82. package/dist/study/plan.js +24 -13
  83. package/dist/study/preflight.js +4 -0
  84. package/dist/study/types.d.ts +34 -5
  85. package/dist/study/validation.js +3 -0
  86. package/dist/study/warnings.d.ts +2 -2
  87. package/dist/study/warnings.js +7 -0
  88. package/dist/subject/local-tree-archive.d.ts +7 -0
  89. package/dist/subject/local-tree-archive.js +12 -0
  90. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  91. package/dist/substrates/e2b/desktop-browser.js +6 -2
  92. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  93. package/dist/substrates/e2b/desktop-clock.js +29 -0
  94. package/dist/substrates/e2b/sdk.d.ts +6 -0
  95. package/dist/verify/pages.d.ts +7 -0
  96. package/dist/verify/pages.js +50 -0
  97. package/dist/verify/verify.js +10 -3
  98. package/docs/architecture/actor-contract.md +2 -0
  99. package/docs/architecture/external-public-shared-world.md +4 -1
  100. package/docs/architecture/observer.md +10 -1
  101. package/docs/contracts/policy.md +7 -0
  102. package/docs/contracts/run-bundle.md +31 -1
  103. package/docs/contracts/schemas.md +72 -3
  104. package/package.json +2 -2
@@ -8,41 +8,58 @@ export declare const LATEST_START_AFTER_MS: number;
8
8
  export declare function isStartAfterMs(value: unknown): value is number;
9
9
  /** How long before its end a participant is told the time left, unless it declares `windDownMs`. */
10
10
  export declare const DEFAULT_WIND_DOWN_MS: number;
11
- /** A participant's declared window: when it starts and when it ends, on the run's clock. */
11
+ /**
12
+ * A participant's declared window: when it starts, and when it ends on the run's clock
13
+ * (`endAfterMs`) or at the latest after its own session started (`maxSessionMs`).
14
+ */
12
15
  export interface ParticipantWindow {
13
16
  readonly id?: string;
14
17
  readonly startAfterMs?: number;
15
18
  readonly endAfterMs?: number;
19
+ readonly maxSessionMs?: number;
16
20
  readonly windDownMs?: number;
17
21
  }
18
22
  /**
19
23
  * Why a participant's declared end cannot run, or undefined: an end that is not a whole number of
20
- * milliseconds up to 24 hours, one at or before its start, or a wind-down that is not a whole
21
- * number of milliseconds or has no end. `path` names the entry, as in "participants[1]".
24
+ * milliseconds up to 24 hours, one at or before its start, a cap that is not a whole number of
25
+ * milliseconds from 1 to 24 hours, a cap beside an end, or a wind-down that is not a whole number
26
+ * of milliseconds or has neither. `path` names the entry, as in "participants[1]".
22
27
  */
23
28
  export declare function windowReason(entry: {
24
29
  readonly id?: string;
25
30
  readonly startAfterMs?: number;
26
31
  readonly endAfterMs?: unknown;
32
+ readonly maxSessionMs?: unknown;
27
33
  readonly windDownMs?: unknown;
28
34
  }, path: string): string | undefined;
29
35
  /**
30
- * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS in
31
- * place of the study's `sessionMs` (execution.timeoutMs or its default); else `sessionMs`.
36
+ * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS, and
37
+ * with a cap, the cap plus the same room, in place of the study's `sessionMs` (execution.timeoutMs
38
+ * or its default); else `sessionMs`.
32
39
  */
33
40
  export declare function participantSessionMs(participant: ParticipantWindow, sessionMs: number): number;
34
41
  /**
35
- * When a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, on the
36
- * same clock, or undefined when it declares no end. A participant that waited for a slot still ends
37
- * at its declared time.
42
+ * When a participant's session ends: at a time on the run's clock, or at the latest a time after
43
+ * its own session starts, which the route reads when the session starts.
38
44
  */
39
- export declare function scheduledEndAt(participant: ParticipantWindow, scheduledAt: number): {
45
+ export type ParticipantEnd = {
46
+ readonly kind: "scheduled_end";
40
47
  readonly atMs: number;
41
48
  readonly windDownMs: number;
42
- } | undefined;
49
+ } | {
50
+ readonly kind: "session_cap";
51
+ readonly afterMs: number;
52
+ readonly windDownMs: number;
53
+ };
54
+ /**
55
+ * How a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, or
56
+ * undefined when it declares neither an end nor a cap. A participant that waited for a slot still
57
+ * ends at its declared time, and still gets its whole cap.
58
+ */
59
+ export declare function participantEnd(participant: ParticipantWindow, scheduledAt: number): ParticipantEnd | undefined;
43
60
  /**
44
- * Why the sandbox of the first participant whose window passes `ceilingMs` cannot live that long,
45
- * or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
61
+ * Why the sandbox of the first participant whose window or cap passes `ceilingMs` cannot live that
62
+ * long, or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
46
63
  * route adds to a session for setup and teardown (sandboxHeadroomMs).
47
64
  */
48
65
  export declare function windowDeadlineRefusal(participants: readonly ParticipantWindow[], sandbox: {
@@ -50,8 +67,9 @@ export declare function windowDeadlineRefusal(participants: readonly Participant
50
67
  readonly headroomMs: number;
51
68
  }, ceilingMs: number): string | undefined;
52
69
  /**
53
- * Each participant's window for `study check`, when any participant declares an end: when it starts
54
- * and ends and when it is first told the time left, or when it starts and its session budget.
70
+ * Each participant's window for `study check`, when any participant declares an end or a cap: when
71
+ * it starts and ends and when it is first told the time left, when it starts and its cap and how
72
+ * far into its session it is first told the time left, or when it starts and its session budget.
55
73
  */
56
74
  export declare function describeWindows(participants: readonly ParticipantWindow[], sessionMs: number): string | undefined;
57
75
  /** The refusal of a start offset that is not one, naming its `field`. */
@@ -32,13 +32,18 @@ export const DEFAULT_WIND_DOWN_MS = 10 * 60_000;
32
32
  const SCHEDULED_END_ROOM_MS = 5 * 60_000;
33
33
  /**
34
34
  * Why a participant's declared end cannot run, or undefined: an end that is not a whole number of
35
- * milliseconds up to 24 hours, one at or before its start, or a wind-down that is not a whole
36
- * number of milliseconds or has no end. `path` names the entry, as in "participants[1]".
35
+ * milliseconds up to 24 hours, one at or before its start, a cap that is not a whole number of
36
+ * milliseconds from 1 to 24 hours, a cap beside an end, or a wind-down that is not a whole number
37
+ * of milliseconds or has neither. `path` names the entry, as in "participants[1]".
37
38
  */
38
39
  export function windowReason(entry, path) {
39
- const { endAfterMs, windDownMs } = entry;
40
- if (windDownMs !== undefined && endAfterMs === undefined)
41
- return `${path}.windDownMs is how long before its endAfterMs a participant is told the time left, so it needs ${path}.endAfterMs. Add the end, or remove windDownMs.`;
40
+ const { endAfterMs, maxSessionMs, windDownMs } = entry;
41
+ if (maxSessionMs !== undefined && endAfterMs !== undefined)
42
+ return `${path} sets both endAfterMs and maxSessionMs. endAfterMs holds the participant until a set time, and maxSessionMs lets it end its session sooner, up to a cap, so a participant takes one of them. Keep endAfterMs for one that stays until a set time, or maxSessionMs for one that may leave when it is done.`;
43
+ if (maxSessionMs !== undefined && !(isStartAfterMs(maxSessionMs) && maxSessionMs > 0))
44
+ return `${path}.maxSessionMs must be a whole number of milliseconds from 1 to ${LATEST_START_AFTER_MS} (24 hours): the longest the participant's session lasts, counted from its start.`;
45
+ if (windDownMs !== undefined && endAfterMs === undefined && maxSessionMs === undefined)
46
+ return `${path}.windDownMs is how long before its endAfterMs or maxSessionMs a participant is told the time left, so it needs one of them. Add an end, or remove windDownMs.`;
42
47
  if (windDownMs !== undefined && !isStartAfterMs(windDownMs))
43
48
  return `${path}.windDownMs must be a whole number of milliseconds from 0 to ${LATEST_START_AFTER_MS} (24 hours): how long before its end the participant is told the time left.`;
44
49
  if (endAfterMs === undefined)
@@ -53,54 +58,75 @@ export function windowReason(entry, path) {
53
58
  return undefined;
54
59
  }
55
60
  /**
56
- * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS in
57
- * place of the study's `sessionMs` (execution.timeoutMs or its default); else `sessionMs`.
61
+ * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS, and
62
+ * with a cap, the cap plus the same room, in place of the study's `sessionMs` (execution.timeoutMs
63
+ * or its default); else `sessionMs`.
58
64
  */
59
65
  export function participantSessionMs(participant, sessionMs) {
66
+ const windowMs = declaredWindowMs(participant);
67
+ return windowMs === undefined ? sessionMs : windowMs + SCHEDULED_END_ROOM_MS;
68
+ }
69
+ /** How long a participant with a declared end or cap may work, or undefined without either. */
70
+ function declaredWindowMs(participant) {
71
+ if (participant.maxSessionMs !== undefined)
72
+ return participant.maxSessionMs;
60
73
  if (participant.endAfterMs === undefined)
61
- return sessionMs;
62
- return participant.endAfterMs - (participant.startAfterMs ?? 0) + SCHEDULED_END_ROOM_MS;
74
+ return undefined;
75
+ return participant.endAfterMs - (participant.startAfterMs ?? 0);
63
76
  }
64
77
  /**
65
- * When a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, on the
66
- * same clock, or undefined when it declares no end. A participant that waited for a slot still ends
67
- * at its declared time.
78
+ * How a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, or
79
+ * undefined when it declares neither an end nor a cap. A participant that waited for a slot still
80
+ * ends at its declared time, and still gets its whole cap.
68
81
  */
69
- export function scheduledEndAt(participant, scheduledAt) {
82
+ export function participantEnd(participant, scheduledAt) {
83
+ const windDownMs = participant.windDownMs ?? DEFAULT_WIND_DOWN_MS;
84
+ if (participant.maxSessionMs !== undefined)
85
+ return { kind: "session_cap", afterMs: participant.maxSessionMs, windDownMs };
70
86
  if (participant.endAfterMs === undefined)
71
87
  return undefined;
72
88
  return {
89
+ kind: "scheduled_end",
73
90
  atMs: scheduledAt - (participant.startAfterMs ?? 0) + participant.endAfterMs,
74
- windDownMs: participant.windDownMs ?? DEFAULT_WIND_DOWN_MS,
91
+ windDownMs,
75
92
  };
76
93
  }
77
94
  /**
78
- * Why the sandbox of the first participant whose window passes `ceilingMs` cannot live that long,
79
- * or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
95
+ * Why the sandbox of the first participant whose window or cap passes `ceilingMs` cannot live that
96
+ * long, or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
80
97
  * route adds to a session for setup and teardown (sandboxHeadroomMs).
81
98
  */
82
99
  export function windowDeadlineRefusal(participants, sandbox, ceilingMs) {
83
100
  const inMinutes = (ms) => Math.round(ms / 60_000);
84
101
  for (const participant of participants) {
85
- if (participant.endAfterMs === undefined)
102
+ if (declaredWindowMs(participant) === undefined)
86
103
  continue;
87
104
  const deadlineMs = participantSessionMs(participant, sandbox.sessionMs) + sandbox.headroomMs;
88
105
  if (deadlineMs <= ceilingMs)
89
106
  continue;
90
107
  const name = participant.id === undefined ? "A participant" : `Participant "${participant.id}"`;
91
- return `${name} works from +${formatDuration(participant.startAfterMs ?? 0)} to +${formatDuration(participant.endAfterMs)}, so its sandbox needs a ${inMinutes(deadlineMs)}m deadline: the window, ${inMinutes(SCHEDULED_END_ROOM_MS)}m for its closing report and ${inMinutes(sandbox.headroomMs)}m to set up and tear down. A sandbox may not live longer than ${ceilingMs / 60_000}m. Shorten its window.${ceilingAdvice(deadlineMs)}`;
108
+ const works = participant.maxSessionMs === undefined
109
+ ? `works from +${formatDuration(participant.startAfterMs ?? 0)} to +${formatDuration(participant.endAfterMs ?? 0)}`
110
+ : `may work for up to ${formatDuration(participant.maxSessionMs)}`;
111
+ const window = participant.maxSessionMs === undefined ? "window" : "cap";
112
+ return `${name} ${works}, so its sandbox needs a ${inMinutes(deadlineMs)}m deadline: the ${window}, ${inMinutes(SCHEDULED_END_ROOM_MS)}m for its closing report and ${inMinutes(sandbox.headroomMs)}m to set up and tear down. A sandbox may not live longer than ${ceilingMs / 60_000}m. Shorten its ${window}.${ceilingAdvice(deadlineMs)}`;
92
113
  }
93
114
  return undefined;
94
115
  }
95
116
  /**
96
- * Each participant's window for `study check`, when any participant declares an end: when it starts
97
- * and ends and when it is first told the time left, or when it starts and its session budget.
117
+ * Each participant's window for `study check`, when any participant declares an end or a cap: when
118
+ * it starts and ends and when it is first told the time left, when it starts and its cap and how
119
+ * far into its session it is first told the time left, or when it starts and its session budget.
98
120
  */
99
121
  export function describeWindows(participants, sessionMs) {
100
- if (!participants.some((participant) => participant.endAfterMs !== undefined))
122
+ if (!participants.some((participant) => declaredWindowMs(participant) !== undefined))
101
123
  return undefined;
102
124
  const windows = participants.map((participant) => {
103
125
  const start = `${participant.id ?? "participant"} from +${formatDuration(participant.startAfterMs ?? 0)}`;
126
+ if (participant.maxSessionMs !== undefined) {
127
+ const told = Math.max(0, participant.maxSessionMs - (participant.windDownMs ?? DEFAULT_WIND_DOWN_MS));
128
+ return `${start} until it ends itself or for up to ${formatDuration(participant.maxSessionMs)}, told the time left from ${formatDuration(told)} into its session`;
129
+ }
104
130
  if (participant.endAfterMs === undefined)
105
131
  return `${start} for up to ${formatDuration(sessionMs)}`;
106
132
  const told = Math.max(participant.startAfterMs ?? 0, participant.endAfterMs - (participant.windDownMs ?? DEFAULT_WIND_DOWN_MS));
@@ -1,8 +1,9 @@
1
1
  import type { DwellWindow } from "../actors/stop-conditions.js";
2
- /** One participant as the plan has it: when it starts and ends, and its dwell window. */
2
+ /** One participant as the plan has it: when it starts and ends, its cap, and its dwell window. */
3
3
  export interface EstimatedParticipant {
4
4
  readonly startAfterMs?: number;
5
5
  readonly endAfterMs?: number;
6
+ readonly maxSessionMs?: number;
6
7
  readonly limits: {
7
8
  readonly dwell?: DwellWindow;
8
9
  };
@@ -33,10 +34,10 @@ export interface BundleEstimate {
33
34
  }
34
35
  /**
35
36
  * The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
36
- * declares an end. A participant with an end works its whole window; one without is counted for
37
- * the measured open session above, or its budget when that is shorter. Each takes a model turn at
38
- * the measured pace, and a dwell window adds a frame every `everyMs` for its length, cut to the
39
- * session, and the frame after it.
37
+ * declares an end or a cap. A participant with an end works its whole window; one without ends
38
+ * itself, so it is counted for the measured open session above, or its budget (its cap, else
39
+ * `sessionMs`) when that is shorter. Each takes a model turn at the measured pace, and a dwell
40
+ * window adds a frame every `everyMs` for its length, cut to the session, and the frame after it.
40
41
  */
41
42
  export declare function estimateBundle(participants: readonly EstimatedParticipant[], sessionMs: number): BundleEstimate;
42
43
  /**
@@ -48,26 +48,31 @@ const RATES = Object.freeze({
48
48
  const MIB = 1024 * 1024;
49
49
  /**
50
50
  * The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
51
- * declares an end. A participant with an end works its whole window; one without is counted for
52
- * the measured open session above, or its budget when that is shorter. Each takes a model turn at
53
- * the measured pace, and a dwell window adds a frame every `everyMs` for its length, cut to the
54
- * session, and the frame after it.
51
+ * declares an end or a cap. A participant with an end works its whole window; one without ends
52
+ * itself, so it is counted for the measured open session above, or its budget (its cap, else
53
+ * `sessionMs`) when that is shorter. Each takes a model turn at the measured pace, and a dwell
54
+ * window adds a frame every `everyMs` for its length, cut to the session, and the frame after it.
55
55
  */
56
56
  export function estimateBundle(participants, sessionMs) {
57
57
  let turns = 0;
58
58
  let frames = 0;
59
59
  let longestWindowMs = 0;
60
- const openMs = Math.min(sessionMs, RATES.openSessionMs);
60
+ // The longest any participant without an end is counted, for the messages.
61
+ let openMs = Math.min(sessionMs, RATES.openSessionMs);
61
62
  for (const participant of participants) {
62
63
  const windowMs = participant.endAfterMs === undefined
63
64
  ? undefined
64
65
  : participant.endAfterMs - (participant.startAfterMs ?? 0);
66
+ const budgetMs = windowMs ?? participant.maxSessionMs ?? sessionMs;
67
+ const countedMs = windowMs ?? Math.min(budgetMs, RATES.openSessionMs);
65
68
  if (windowMs !== undefined)
66
69
  longestWindowMs = Math.max(longestWindowMs, windowMs);
67
- turns += Math.ceil((windowMs ?? openMs) / (RATES.secondsPerTurn * 1000));
70
+ else
71
+ openMs = Math.max(openMs, countedMs);
72
+ turns += Math.ceil(countedMs / (RATES.secondsPerTurn * 1000));
68
73
  const dwell = participant.limits.dwell;
69
74
  if (dwell !== undefined)
70
- frames += Math.floor(Math.min(dwell.ms, windowMs ?? sessionMs) / dwell.everyMs) + 1;
75
+ frames += Math.floor(Math.min(dwell.ms, budgetMs) / dwell.everyMs) + 1;
71
76
  }
72
77
  const count = participants.length;
73
78
  const runFileBytes = RATES.runBytes +
@@ -0,0 +1,37 @@
1
+ import type { StudyParseFailure, StudyParticipantFile } from "./types.js";
2
+ /**
3
+ * Where `participants[].files` act: the hosted desktop of an independent computer-use participant.
4
+ * The parser's unread-field check and the route planners give this reason.
5
+ */
6
+ export declare const DESKTOP_FILES_ROUTE_REASON = "files are copied onto a hosted computer-use desktop; needs route: computer-use with execution.target: e2b-desktop";
7
+ /** A participant's `files` list: each entry's `from` and `as`. */
8
+ export declare function parseParticipantFiles(raw: unknown, field: string): {
9
+ ok: true;
10
+ value: StudyParticipantFile[] | undefined;
11
+ } | StudyParseFailure;
12
+ /** One file read on this machine for a participant's desktop. */
13
+ export interface DesktopFile {
14
+ /** Where it lands, relative to the desktop user's home folder. */
15
+ readonly as: string;
16
+ /** The file's bytes, shared by every participant that receives it and uploaded as they are. */
17
+ readonly data: ArrayBuffer;
18
+ readonly size: number;
19
+ /** The sha256 of `data`, 64 hex characters. */
20
+ readonly sha256: string;
21
+ }
22
+ type DesktopFilesRead = {
23
+ readonly ok: true;
24
+ readonly files: ReadonlyMap<string, readonly DesktopFile[]>;
25
+ } | {
26
+ readonly ok: false;
27
+ readonly message: string;
28
+ };
29
+ /**
30
+ * Read every participant's declared files from `folder`, the folder that holds the study file, and
31
+ * return them by participant id. Runs before any sandbox exists, so a refusal costs nothing.
32
+ */
33
+ export declare function readDesktopFiles(folder: string, participants: readonly {
34
+ readonly id: string;
35
+ readonly files?: readonly StudyParticipantFile[];
36
+ }[]): Promise<DesktopFilesRead>;
37
+ export {};
@@ -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