humanish 0.125.0 → 0.127.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 (128) hide show
  1. package/CHANGELOG.md +29 -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 +10 -5
  9. package/dist/actors/computer-use/loop/ending.js +12 -7
  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/computer-use/openai-provider.js +1 -1
  18. package/dist/actors/contract.d.ts +31 -8
  19. package/dist/actors/contract.js +3 -0
  20. package/dist/actors/goal-source.d.ts +32 -2
  21. package/dist/actors/goal-source.js +41 -2
  22. package/dist/actors/stop-cause.js +1 -0
  23. package/dist/analysis/evidence-sources.js +2 -2
  24. package/dist/analysis/types.d.ts +2 -2
  25. package/dist/analysis/validation.js +2 -1
  26. package/dist/cli/commands/study.js +8 -0
  27. package/dist/evidence/redaction.d.ts +1 -0
  28. package/dist/evidence/redaction.js +1 -0
  29. package/dist/evidence/trace-page.d.ts +6 -0
  30. package/dist/evidence/trace-page.js +90 -0
  31. package/dist/observer/data.js +8 -5
  32. package/dist/observer/feed-cursors.d.ts +14 -0
  33. package/dist/observer/feed-cursors.js +61 -0
  34. package/dist/observer/participant-items.d.ts +7 -0
  35. package/dist/observer/pinned-files.d.ts +2 -1
  36. package/dist/observer/pinned-files.js +2 -2
  37. package/dist/observer/run-routes.d.ts +1 -1
  38. package/dist/observer/run-routes.js +38 -17
  39. package/dist/observer-app.html +7 -7
  40. package/dist/routes/computer-use/bundle.js +4 -0
  41. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  42. package/dist/routes/computer-use/diagnostics.js +3 -3
  43. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  44. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  45. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  46. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  47. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  48. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  49. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  50. package/dist/routes/computer-use/fanout-bundle.js +3 -0
  51. package/dist/routes/computer-use/fanout-records.js +5 -1
  52. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  53. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  54. package/dist/routes/computer-use/participant-execution.js +6 -2
  55. package/dist/routes/computer-use/participant-facts.js +4 -1
  56. package/dist/routes/computer-use/participant-model.js +16 -6
  57. package/dist/routes/computer-use/participant-runs.d.ts +10 -10
  58. package/dist/routes/computer-use/participant-runs.js +54 -84
  59. package/dist/routes/computer-use/plan.d.ts +2 -0
  60. package/dist/routes/computer-use/plan.js +65 -17
  61. package/dist/routes/computer-use/rerun-selection.js +8 -5
  62. package/dist/routes/computer-use/setup.d.ts +1 -0
  63. package/dist/routes/computer-use/setup.js +29 -1
  64. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  65. package/dist/routes/computer-use/single-bundle.js +11 -1
  66. package/dist/routes/computer-use/types.d.ts +18 -13
  67. package/dist/routes/computer-use/types.js +0 -3
  68. package/dist/routes/shared-world/bundle-records.js +4 -1
  69. package/dist/routes/shared-world/external-public.js +1 -1
  70. package/dist/routes/shared-world/participant-specs.js +3 -2
  71. package/dist/routes/shared-world/plan.d.ts +2 -0
  72. package/dist/routes/shared-world/plan.js +8 -3
  73. package/dist/routes/shared-world/provisioned.js +1 -1
  74. package/dist/run/bundle.d.ts +11 -1
  75. package/dist/run/contained-output.d.ts +7 -3
  76. package/dist/run/contained-output.js +11 -7
  77. package/dist/run/judge.d.ts +14 -4
  78. package/dist/run/judge.js +17 -12
  79. package/dist/run/live-items.d.ts +19 -0
  80. package/dist/run/live-items.js +28 -8
  81. package/dist/run/notes.js +1 -1
  82. package/dist/run/outcomes.d.ts +8 -2
  83. package/dist/run/outcomes.js +56 -14
  84. package/dist/run/stream-trace.d.ts +25 -4
  85. package/dist/run/stream-trace.js +55 -17
  86. package/dist/run/streams.d.ts +16 -0
  87. package/dist/study/arrivals.d.ts +105 -34
  88. package/dist/study/arrivals.js +179 -74
  89. package/dist/study/bundle-estimate.d.ts +12 -10
  90. package/dist/study/bundle-estimate.js +138 -72
  91. package/dist/study/desktop-files.d.ts +37 -0
  92. package/dist/study/desktop-files.js +174 -0
  93. package/dist/study/keys.d.ts +8 -0
  94. package/dist/study/keys.js +3 -0
  95. package/dist/study/migrate/v2.js +12 -3
  96. package/dist/study/parse/actors.d.ts +1 -1
  97. package/dist/study/parse/actors.js +28 -20
  98. package/dist/study/parse/clock.d.ts +18 -0
  99. package/dist/study/parse/clock.js +36 -0
  100. package/dist/study/plan-participants.d.ts +19 -10
  101. package/dist/study/plan-participants.js +13 -9
  102. package/dist/study/plan-types.d.ts +6 -1
  103. package/dist/study/plan.d.ts +2 -1
  104. package/dist/study/plan.js +28 -15
  105. package/dist/study/preflight.js +4 -0
  106. package/dist/study/types.d.ts +35 -5
  107. package/dist/study/validation.js +3 -0
  108. package/dist/study/warnings.d.ts +2 -2
  109. package/dist/study/warnings.js +7 -0
  110. package/dist/subject/local-tree-archive.d.ts +7 -0
  111. package/dist/subject/local-tree-archive.js +12 -0
  112. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  113. package/dist/substrates/e2b/desktop-browser.js +6 -2
  114. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  115. package/dist/substrates/e2b/desktop-clock.js +29 -0
  116. package/dist/substrates/e2b/lifetime.d.ts +22 -8
  117. package/dist/substrates/e2b/lifetime.js +54 -10
  118. package/dist/substrates/e2b/sdk.d.ts +6 -0
  119. package/dist/verify/pages.d.ts +7 -0
  120. package/dist/verify/pages.js +50 -0
  121. package/dist/verify/verify.js +10 -3
  122. package/docs/architecture/actor-contract.md +2 -0
  123. package/docs/architecture/external-public-shared-world.md +4 -1
  124. package/docs/architecture/observer.md +10 -1
  125. package/docs/contracts/policy.md +7 -0
  126. package/docs/contracts/run-bundle.md +31 -1
  127. package/docs/contracts/schemas.md +93 -11
  128. package/package.json +2 -2
@@ -0,0 +1,29 @@
1
+ // The clock in the hosted desktop's panel. The stock template's XFCE panel sits above every window,
2
+ // so a participant sees its clock in each screenshot, and it shows the desktop's zone, UTC, unless
3
+ // its clock plugin names another.
4
+ import { shellQuote } from "../shell.js";
5
+ import { e2bShell } from "./shell.js";
6
+ /**
7
+ * Set every clock in the desktop's XFCE panel to `timeZone`, a tz database name, by writing the
8
+ * clock plugin's `timezone` property through xfconf-query. The property lives in the session's
9
+ * settings daemon, reached over the session bus whose address xfce4-session holds, and the panel
10
+ * redraws its clock when the property changes (live: 02:12 UTC became 11:12 for Asia/Tokyo with no
11
+ * restart). A desktop without xfconf-query or without a clock plugin is left as it is. Throws with
12
+ * xfconf-query's error output when a clock is found and cannot be set.
13
+ */
14
+ export async function setPanelClockZone(desktop, timeZone, requestTimeoutMs) {
15
+ const result = await e2bShell(desktop).run([
16
+ `zone=${shellQuote(timeZone)}`,
17
+ "command -v xfconf-query >/dev/null 2>&1 || exit 0",
18
+ 'session_pid="$(pgrep -x xfce4-session | head -n 1)"',
19
+ 'if [ -n "$session_pid" ]; then',
20
+ ` bus="$(tr '\\0' '\\n' < "/proc/$session_pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')"`,
21
+ ' if [ -n "$bus" ]; then export DBUS_SESSION_BUS_ADDRESS="$bus"; fi',
22
+ "fi",
23
+ `for plugin in $(xfconf-query -c xfce4-panel -p /plugins -l -v | awk '$2 == "clock" { print $1 }'); do`,
24
+ ' xfconf-query -c xfce4-panel -p "$plugin/timezone" -n -t string -s "$zone" || exit 1',
25
+ "done",
26
+ ].join("\n"), { requestTimeoutMs, timeoutMs: 15_000 });
27
+ if (result.exitCode !== 0)
28
+ throw new Error(result.stderr.trim() || `xfconf-query exited ${result.exitCode}`);
29
+ }
@@ -45,14 +45,24 @@ export type ConcurrentSandboxes = {
45
45
  export declare function concurrentSandboxes(env: Readonly<Record<string, string | undefined>>): ConcurrentSandboxes;
46
46
  /** The limit in use and where it came from, or why the setting cannot be read (doctor's row). */
47
47
  export declare function describeConcurrentSandboxes(limit: ConcurrentSandboxes): string;
48
+ /** The setting that lowers how many participants a study runs at once, on any desktop. */
49
+ export declare const CUA_MAX_CONCURRENCY_ENV = "HUMANISH_CUA_MAX_CONCURRENCY";
48
50
  /**
49
- * How many participants a hosted study runs at once: its `concurrency` (the declared
50
- * execution.concurrency clamped to the roster, else every participant), lowered to what the plan
51
- * runs. `declared` says whether the study set it. `alongside` counts the sandboxes the study keeps
52
- * for the whole run besides its participants' desktops, such as a shared world's app, and
53
- * `minimum` is the fewest at once the route can run. A declared value that does not fit is a
54
- * refusal naming the setting value that admits it. `lowered` is the warning a plan records when
55
- * the limit holds an undeclared default to waves.
51
+ * HUMANISH_CUA_MAX_CONCURRENCY when it is a whole number of 1 or more, else undefined: the
52
+ * operator's own bound on participants at once on this machine, which may only lower a study's.
53
+ * Any other value is ignored.
54
+ */
55
+ export declare function concurrencyCap(env: Readonly<Record<string, string | undefined>>): number | undefined;
56
+ /**
57
+ * How many participants a study runs at once: its `concurrency` (the declared
58
+ * execution.concurrency clamped to the roster, else every participant), lowered to `cap`
59
+ * (concurrencyCap) and then to what the E2B plan runs (`sandboxes`, on a hosted desktop).
60
+ * `declared` says whether the study set it. `alongside` counts the sandboxes the study keeps for
61
+ * the whole run besides its participants' desktops, such as a shared world's app, and `minimum` is
62
+ * the fewest at once the route can run. A declared value the E2B plan cannot run, or a cap under
63
+ * `minimum`, is a refusal naming the setting. `lowered` is the warning a plan records when the cap
64
+ * or the E2B limit lowers the count; when both do, it is the E2B limit's, since that sets the
65
+ * count. `loweredFrom` is the count the cap lowered, when the cap sets the count.
56
66
  */
57
67
  export declare function participantsAtOnce(study: {
58
68
  readonly concurrency: number;
@@ -60,10 +70,14 @@ export declare function participantsAtOnce(study: {
60
70
  readonly participants: number;
61
71
  readonly alongside?: number;
62
72
  readonly minimum?: number;
63
- }, limit: ConcurrentSandboxes): {
73
+ }, limits: {
74
+ readonly sandboxes?: ConcurrentSandboxes;
75
+ readonly cap?: number | undefined;
76
+ }): {
64
77
  readonly ok: true;
65
78
  readonly count: number;
66
79
  readonly lowered?: string;
80
+ readonly loweredFrom?: number;
67
81
  } | {
68
82
  readonly ok: false;
69
83
  readonly message: string;
@@ -1,5 +1,6 @@
1
1
  // The operator's E2B plan limits humanish plans against: how long a sandbox may live and how many
2
- // run at once. Also the budgets the desktop routes add to a participant's own deadline when they set
2
+ // run at once, with the operator's own bound on participants at once (HUMANISH_CUA_MAX_CONCURRENCY).
3
+ // Also the budgets the desktop routes add to a participant's own deadline when they set
3
4
  // a sandbox's server-side timeout. A dead host process can never orphan a sandbox past that
4
5
  // timeout, so every route derives it from these values.
5
6
  import { plural } from "../../run/text.js";
@@ -88,19 +89,62 @@ export function describeConcurrentSandboxes(limit) {
88
89
  if (!limit.ok)
89
90
  return limit.message;
90
91
  return limit.source === "setting"
91
- ? `${limit.count} at once, from ${CONCURRENCY_SETTING}. A study that runs more desktops at once runs them in waves, and an execution.concurrency above it is refused before any sandbox is created.`
92
+ ? `${limit.count} at once, from ${CONCURRENCY_SETTING}. A study that runs more desktops at once runs them in waves, and an execution.concurrency above it is refused before any sandbox is created, unless ${CUA_MAX_CONCURRENCY_ENV} lowers it to the limit or below.`
92
93
  : `${limit.count} at once, E2B's Hobby limit. On a plan that runs more (Pro runs ${PRO_CONCURRENT_SANDBOXES}), set ${CONCURRENCY_SETTING} to that plan's limit.`;
93
94
  }
95
+ /** The setting that lowers how many participants a study runs at once, on any desktop. */
96
+ export const CUA_MAX_CONCURRENCY_ENV = "HUMANISH_CUA_MAX_CONCURRENCY";
94
97
  /**
95
- * How many participants a hosted study runs at once: its `concurrency` (the declared
96
- * execution.concurrency clamped to the roster, else every participant), lowered to what the plan
97
- * runs. `declared` says whether the study set it. `alongside` counts the sandboxes the study keeps
98
- * for the whole run besides its participants' desktops, such as a shared world's app, and
99
- * `minimum` is the fewest at once the route can run. A declared value that does not fit is a
100
- * refusal naming the setting value that admits it. `lowered` is the warning a plan records when
101
- * the limit holds an undeclared default to waves.
98
+ * HUMANISH_CUA_MAX_CONCURRENCY when it is a whole number of 1 or more, else undefined: the
99
+ * operator's own bound on participants at once on this machine, which may only lower a study's.
100
+ * Any other value is ignored.
102
101
  */
103
- export function participantsAtOnce(study, limit) {
102
+ export function concurrencyCap(env) {
103
+ const cap = readPositiveInt(env[CUA_MAX_CONCURRENCY_ENV], 0);
104
+ return cap > 0 ? cap : undefined;
105
+ }
106
+ /**
107
+ * How many participants a study runs at once: its `concurrency` (the declared
108
+ * execution.concurrency clamped to the roster, else every participant), lowered to `cap`
109
+ * (concurrencyCap) and then to what the E2B plan runs (`sandboxes`, on a hosted desktop).
110
+ * `declared` says whether the study set it. `alongside` counts the sandboxes the study keeps for
111
+ * the whole run besides its participants' desktops, such as a shared world's app, and `minimum` is
112
+ * the fewest at once the route can run. A declared value the E2B plan cannot run, or a cap under
113
+ * `minimum`, is a refusal naming the setting. `lowered` is the warning a plan records when the cap
114
+ * or the E2B limit lowers the count; when both do, it is the E2B limit's, since that sets the
115
+ * count. `loweredFrom` is the count the cap lowered, when the cap sets the count.
116
+ */
117
+ export function participantsAtOnce(study, limits) {
118
+ const capped = cappedAtOnce(study, limits.cap);
119
+ if (!capped.ok || limits.sandboxes === undefined)
120
+ return capped;
121
+ // A count the cap lowered is the operator's bound and no longer the study's declared value, so
122
+ // the E2B limit runs it in waves as it would an undeclared default.
123
+ const left = capped.loweredFrom === undefined
124
+ ? study
125
+ : { ...study, concurrency: capped.count, declared: false };
126
+ const planned = sandboxesAtOnce(left, limits.sandboxes);
127
+ return !planned.ok || planned.count < capped.count ? planned : capped;
128
+ }
129
+ /** participantsAtOnce under the cap alone. */
130
+ function cappedAtOnce(study, cap) {
131
+ if (cap === undefined || cap >= study.concurrency)
132
+ return { ok: true, count: study.concurrency };
133
+ const minimum = study.minimum ?? 1;
134
+ if (cap < minimum)
135
+ return {
136
+ ok: false,
137
+ message: `This study runs at least ${minimum} participants at once, and ${CUA_MAX_CONCURRENCY_ENV} allows ${cap}. Set it to ${minimum} or more, or unset it.`,
138
+ };
139
+ return {
140
+ ok: true,
141
+ count: cap,
142
+ loweredFrom: study.concurrency,
143
+ lowered: `This study's ${study.participants} participants run ${cap} at a time, because ${CUA_MAX_CONCURRENCY_ENV} is ${cap}.`,
144
+ };
145
+ }
146
+ /** participantsAtOnce under the E2B plan's limit alone. */
147
+ function sandboxesAtOnce(study, limit) {
104
148
  if (!limit.ok)
105
149
  return limit;
106
150
  const alongside = study.alongside ?? 0;
@@ -154,6 +154,12 @@ export interface E2BDesktopSandbox {
154
154
  requestTimeoutMs?: number;
155
155
  useOctetStream?: boolean;
156
156
  }): Promise<unknown>;
157
+ /** A file's size as the sandbox sees it. A relative path is under the sandbox user's home. */
158
+ getInfo?(path: string, options?: {
159
+ requestTimeoutMs?: number;
160
+ }): Promise<{
161
+ size: number;
162
+ }>;
157
163
  };
158
164
  launch(application: string, uri?: string): Promise<void>;
159
165
  /** Open a file or URL with the desktop's default application (present on @e2b/desktop >= 1.x). */
@@ -0,0 +1,7 @@
1
+ import type { RunBundle } from "../run/bundle.js";
2
+ /**
3
+ * One finding per recorded page that holds more than an origin and a path, in each finished
4
+ * trace's items and summary and each running participant's items. A finding names where the page
5
+ * is, never what it says.
6
+ */
7
+ export declare function pageFindings(bundle: RunBundle): string[];
@@ -0,0 +1,50 @@
1
+ import { REDACTION_MARKERS } from "../evidence/redaction.js";
2
+ import { isRecord } from "../run/type-guards.js";
3
+ // A recorded page (`items[].page`, `finalPage`) keeps a URL's origin and path only. The text scan
4
+ // finds secret shapes in it as in any trace text, but a query string, a fragment or user info can
5
+ // carry a short token no pattern matches, so a page holding one is a public-safety finding.
6
+ // The markers that can stand for a whole origin, such as a withheld sandbox host.
7
+ const MARKERS = new Set(Object.values(REDACTION_MARKERS));
8
+ // `scheme:` alone (a non-web URL) or `scheme://host[:port]`, with nothing after the host. The URL
9
+ // parser reads a backslash there as a slash, so a backslash counts as a path too.
10
+ const ORIGIN = /^[a-z][a-z0-9+.-]*:(?:\/\/[^/\\?#@]*)?$/i;
11
+ /** What is wrong with a recorded page, or undefined when it holds an origin and a path only. */
12
+ function pageProblem(page) {
13
+ if (!isRecord(page) || typeof page.origin !== "string" || typeof page.path !== "string")
14
+ return "is not an origin and a path";
15
+ if (/[?#]/.test(page.path))
16
+ return "has a query string or fragment in its path";
17
+ if (!MARKERS.has(page.origin) && !ORIGIN.test(page.origin))
18
+ return "has an origin with user info, a path, a query string or a fragment";
19
+ return undefined;
20
+ }
21
+ /**
22
+ * One finding per recorded page that holds more than an origin and a path, in each finished
23
+ * trace's items and summary and each running participant's items. A finding names where the page
24
+ * is, never what it says.
25
+ */
26
+ export function pageFindings(bundle) {
27
+ const findings = [];
28
+ const check = (where, page) => {
29
+ if (page === undefined)
30
+ return;
31
+ const problem = pageProblem(page);
32
+ if (problem !== undefined)
33
+ findings.push(`${where} ${problem}`);
34
+ };
35
+ // The shape guard admits items that are not records, so each is read as unknown.
36
+ const checkItems = (where, items) => {
37
+ if (!Array.isArray(items))
38
+ return;
39
+ items.forEach((item, index) => {
40
+ if (isRecord(item))
41
+ check(`${where}[${index}].page`, item.page);
42
+ });
43
+ };
44
+ for (const stream of bundle.streams) {
45
+ checkItems(`${stream.id} items`, stream.actor?.items);
46
+ check(`${stream.id} finalPage`, stream.actor?.finalPage);
47
+ checkItems(`${stream.id} liveActor.items`, stream.liveActor?.items);
48
+ }
49
+ return findings;
50
+ }
@@ -16,6 +16,7 @@ import { runNotFoundMessage } from "../run/run-not-found.js";
16
16
  import { actorVerdictConsistencyFindings, noEngagementActorFindings, validateCodexAppServerEvidence, validateTerminalProductEvidence, } from "./actor.js";
17
17
  import { invalidRunEvidenceReferences, MAX_REPORTED_FINDINGS, missingLocalEvidenceArtifacts, rawScreenshotPostureWarnings, rawScreenshotStreamIds, redactedShapeFramePaths, scanRunPublicSafetyArtifacts, scanSourceEvidence, streamScreenshotPaths, unscannedMessage, } from "./artifacts.js";
18
18
  import { flatContextWarnings } from "./context-growth.js";
19
+ import { pageFindings } from "./pages.js";
19
20
  import { runNotFinished } from "./liveness.js";
20
21
  import { costAndReceiptFindings } from "./costs.js";
21
22
  import { rerunLineageFindings } from "./rerun.js";
@@ -229,7 +230,7 @@ function evidenceArtifactsCheck(shaped, problems) {
229
230
  async function loadTraces(runPaths, stored) {
230
231
  const scanned = { sensitive: [], opaque: [] };
231
232
  if (!isRunBundle(stored))
232
- return { bundle: stored, refusedTraces: [], ...scanned };
233
+ return { bundle: stored, refusedTraces: [], ...scanned, pages: [] };
233
234
  // Each trace is source evidence: scanned by the bytes it was joined from, apart from the
234
235
  // directory walk and any path exemption the walk makes.
235
236
  const { bundle, refused } = await loadStreamTraces(runPaths, stored, {
@@ -239,9 +240,14 @@ async function loadTraces(runPaths, stored) {
239
240
  scanned[scan === "sensitive" ? "sensitive" : "opaque"].push(file);
240
241
  },
241
242
  });
242
- return { bundle, refusedTraces: refused.map((error) => error.message), ...scanned };
243
+ return {
244
+ bundle,
245
+ refusedTraces: refused.map((error) => error.message),
246
+ ...scanned,
247
+ pages: pageFindings(bundle),
248
+ };
243
249
  }
244
- /** The trace scan's results, beside the walk's: each file named once. */
250
+ /** The trace scan's results, beside the walk's: each file named once, then each page finding. */
245
251
  function addTraceScan(traces, findings, unscanned) {
246
252
  for (const file of traces.sensitive)
247
253
  if (!findings.includes(`sensitive text ${file}`))
@@ -249,6 +255,7 @@ function addTraceScan(traces, findings, unscanned) {
249
255
  for (const file of traces.opaque)
250
256
  if (!unscanned.files.includes(file))
251
257
  unscanned.files.push(file);
258
+ findings.push(...traces.pages);
252
259
  }
253
260
  /** The top-level run.json sections the shape guard requires. */
254
261
  const REQUIRED_SECTIONS = [
@@ -167,6 +167,7 @@ export interface ActorTraceItem {
167
167
  command?: { text?: string; cwd?: string; exitCode?: number; outputTail?: string };
168
168
  screenshotRef?: { path: string; redaction: "blurred" | "ocr_scrubbed" | "none" };
169
169
  text?: string; // redacted
170
+ page?: { origin: string; path: string }; // computer-use screenshot items; redacted, no query
170
171
  }
171
172
 
172
173
  export interface ActorCapabilities {
@@ -204,6 +205,7 @@ export interface ActorTrace {
204
205
  ids: { sessionId?: string; threadId?: string; turnId?: string; model?: string };
205
206
  counts: Record<string, number>;
206
207
  items: ActorTraceItem[];
208
+ finalPage?: { origin: string; path: string }; // computer-use: the last observation's page
207
209
  tokenUsage?: { input?: number; output?: number; total?: number; costUsd?: number };
208
210
  estimatedCost?: ActorEstimatedCost; // humanish.actor-estimated-cost.v1 (additive)
209
211
  capabilities: ActorCapabilities;
@@ -120,7 +120,10 @@ The runtime `location.href` (and the 6-char code inside it) is never persisted r
120
120
  runtime-only; the threaded code flows only into the follower's composed prompt (never a raw bundle
121
121
  field) and is scrubbed from all narration once latched; the shared origin and lobby path persist only
122
122
  as sha256-16 digests (`publicOriginDigest`, `lobbyConvergenceDigest`). This matches the existing
123
- e2b-URL / host-digest redaction discipline.
123
+ e2b-URL / host-digest redaction discipline. The computer-use loop records each observation's page
124
+ origin and path by default; the shared-world runner (`participantRunDeps` in
125
+ `src/routes/shared-world/participant-specs.ts`) runs every session with `recordPages: false`, on
126
+ both planes, so no page and no `finalPage` reaches a shared-world trace.
124
127
 
125
128
  ## Mobile fidelity caveat
126
129
 
@@ -74,7 +74,16 @@ public-safe metadata.
74
74
 
75
75
  The browser polls `observer-feed.json` with `no-store` caching: the server's
76
76
  projection of the run as `{ data, items }`, observer-data.json's content and the
77
- items, from one read of the run per poll. Static
77
+ items, from one read of the run per poll. While a participant runs, its entry in
78
+ `items` carries `live`, a cursor `<position>.<generation>.<items>.<bytes>` naming its
79
+ live file (`live/<position>.<generation>.ndjson`) and how far the list goes. The page
80
+ sends the cursors of its last body back as `?after=<cursor>,<cursor>`, and the server
81
+ then reads and sends only the items past each one, marking that entry with
82
+ `liveAfter`, the cursor they follow (`src/observer/feed-cursors.ts`). A cursor for an
83
+ earlier generation, past the index or off a line end gets the participant's full list,
84
+ and so does every participant of a finished run. A tail is checked for line ends and
85
+ its line count; the sha256 run.json records is checked whenever a list is read whole.
86
+ A page that cannot join a body sends no cursors with its next poll. Static
78
87
  `file://` opening still works for immutable review, but follow mode is the
79
88
  operator path. Agents and CI should use `humanish watch --json --no-open` for
80
89
  the same fresh evidence without browser open or a long-running process.
@@ -146,6 +146,13 @@ certification that the artifact is free of every class in the policy below. A
146
146
  first-class PII/PHI detector is [planned](https://github.com/danielgwilson/humanish/issues/108) and would move the listed
147
147
  PII classes from author-responsibility to enforced.
148
148
 
149
+ The same scan reads the page each computer-use observation recorded (`items[].page`,
150
+ `finalPage`). verify also reports a page whose path holds a query string or fragment, or whose
151
+ origin holds user info or anything after the host, since such a part can carry a short token no
152
+ pattern matches. The recorder withholds email- and token-shaped path segments
153
+ ([schemas.md](schemas.md#actor-trace)); a name or other personal data in a readable path is not
154
+ detected.
155
+
149
156
  Required redaction gates:
150
157
 
151
158
  - run bundle verification;
@@ -332,7 +332,7 @@ An actor trace may include `stopCause` alongside its unchanged `status`,
332
332
  `completionReason` and verbatim `reason`. Computer-use sessions distinguish
333
333
  `provider_output_limit`, `provider_token_limit`, `time_limit`, `spend_limit`,
334
334
  `study_spend_limit`, `adapter_limit`, `provider_incomplete`, `provider_status`,
335
- `provider_refused_prompt`, `harness_aborted`, `usage_unreported`, and `scheduled_end`. Absence means the route or
335
+ `provider_refused_prompt`, `harness_aborted`, `usage_unreported`, `scheduled_end`, and `session_cap`. Absence means the route or
336
336
  recording did not retain this precise field; it does not mean the participant
337
337
  finished.
338
338
 
@@ -454,6 +454,36 @@ contain the older requested-screen `stream.viewport` shape without `desktopGeome
454
454
  Deterministic Playwright-style adapters remain free to declare a viewport that they also render
455
455
  exactly; this hosted-desktop rule does not change that contract.
456
456
 
457
+ ## Desktop Files
458
+
459
+ A hosted computer-use participant's stream carries `desktopFiles` when its study gave it
460
+ `participants[].files`: one entry per file confirmed on the desktop before the participant
461
+ started, in the order they landed.
462
+
463
+ ```yaml
464
+ desktopFiles:
465
+ - as: Downloads/photo.png
466
+ size: 251
467
+ sha256: <64 hex characters>
468
+ ```
469
+
470
+ `as` is relative to the desktop user's home folder. `size` is the byte count humanish copied,
471
+ which `files.getInfo` on the desktop matched. `sha256` is the digest of those bytes, read on the
472
+ host before any sandbox existed. The contents and the `from` path are not recorded. A participant
473
+ whose copy failed records the files confirmed before the failure. No `desktopFiles` means no file
474
+ was confirmed on that desktop: the study gave the participant none, the run was a dry run, or the
475
+ copy failed before the first file was confirmed. A copy that failed records a `warn` event of type
476
+ `cua-lab.desktop.files.completed`.
477
+
478
+ ## Participant Time Zone
479
+
480
+ A computer-use or shared-world participant's stream carries `timeZone`, a tz database name such as
481
+ `Europe/London`, when its study gave it one: `participants[].clock.timeZone`, else
482
+ `actor.clock.timeZone`. Its turns gave the local time in that zone, and on a hosted desktop its
483
+ browser launched with `TZ` set to it. The field records the declared zone, also on a dry run; it is
484
+ not read back from the browser. No `timeZone` means the participant had none, and its hosted
485
+ browser ran in the desktop's UTC.
486
+
457
487
  ## Subject Provenance
458
488
 
459
489
  `subject` is an optional, additive top-level field: structured provenance for
@@ -210,6 +210,24 @@ The rest of this section names keys by their v3 spelling:
210
210
  Public/non-loopback targets still require `policies.allowPublicTargets: true`.
211
211
  `target` is mutually exclusive with `entry`: `target` is an absolute app-url
212
212
  browser target; `entry` is a shared-world participant's same-origin path;
213
+ - `participants[].files` (computer-use on `execution.target: e2b-desktop` only):
214
+ 1 to 20 `{ from, as }` entries, each a file copied onto that participant's desktop
215
+ before its browser opens. `from` is relative to the folder that holds the study file
216
+ (`cwd` for a library config); `as` is relative to the desktop user's home folder.
217
+ The parser refuses an absolute path, a `.`, `..` or empty part, a backslash, an `as`
218
+ that starts with `~`, lands in `subject/` or on a name starting with `.humanish`, looks
219
+ like a secret, a local path or encoded text, repeats another `as` or is its folder,
220
+ and a `from` the local-tree denylist names in any case (`isLocalTreeDenylisted` in
221
+ `src/subject/local-tree-archive.ts`). The route also refuses an `as` that holds a
222
+ value from the run's environment, such as a key. The route reads each distinct `from` once, before
223
+ any sandbox and on dry runs too, with `readContainedRegularFile`: a missing file, a
224
+ folder, a symbolic or hard link, a path out of the folder, a file above 10 MiB, more
225
+ than 25 MiB for one participant or 100 MiB of distinct files for the study is
226
+ refused with `HUMANISH_COMPUTER_USE_DESKTOP_FILES_INVALID` (`readDesktopFiles` in
227
+ `src/study/desktop-files.ts`). Each file
228
+ is written with the E2B SDK's `files.write` and its `files.getInfo` size must match, or
229
+ the participant fails before its first turn. The stream records `desktopFiles`
230
+ ([run-bundle.md](run-bundle.md#desktop-files)). Other routes refuse the field;
213
231
  - a `participants` entry with `count: n` (computer-use E2B route): a group of
214
232
  repeated participants. It needs an `id` and the parser expands it to
215
233
  `<id>-01` … `<id>-NN`, even when n is 1, before the engine runs (`viewer-01`,
@@ -229,23 +247,51 @@ The rest of this section names keys by their v3 spelling:
229
247
  against the sandbox ceiling. A rerun starts its selected participants together;
230
248
  - `participants[].endAfterMs` and `windDownMs` (computer-use only): when the participant's
231
249
  session ends, in whole milliseconds on the `startAfterMs` clock and after its start, and how
232
- long before that each turn tells it the end time and the minutes left (default 600,000).
250
+ long before that each turn tells it the end time and the minutes left (default a quarter of
251
+ the window, at most 600,000; `participantSchedule` in `src/study/arrivals.ts`).
233
252
  Refused: an end at or before the start, a `windDownMs` without `endAfterMs`, an end on
234
253
  `shared-world`, and a window whose sandbox deadline passes the sandbox ceiling. The
235
- participant's session budget is its window plus 5 minutes (`participantSessionMs` in
254
+ participant's session budget is its window plus 5 minutes (`participantSession` in
236
255
  `src/study/arrivals.ts`) in place of `execution.timeoutMs`, and its sandbox deadline adds the
237
256
  route's headroom to that. At the end the loop requests the closing report (`debrief.trigger:
238
- scheduled_end`) and the session ends `goal_satisfied` with `stopCause: scheduled_end`. Before
257
+ scheduled_end`) and the session ends `goal_satisfied` with `stopCause: scheduled_end`. The
258
+ outcome an accepted report declares, its `outcome` field or the first line of its summary that
259
+ is not a `>` blockquote, is the trace's `declaredOutcome`. The blocker rule reads a declared
260
+ `reached` or `blocked` before the summary, and after a declared `not_reached` it reads the
261
+ summary. A participant that passes without declaring `reached` counts in the review's
262
+ `participants.ranUntilEnd`, and its completion labels say it ran until its scheduled end. Before
239
263
  the end, a turn that requests no action does not end the session: the trace records a
240
264
  `participant ended before its scheduled end` notice, the next request says the time left, and
241
265
  the turn counts toward the idle backstop. Before the end, a turn that only waits or takes
242
266
  screenshots counts toward neither backstop streak (`advanceBackstop`'s `waitingIsTheTask`). The
243
267
  plan entry carries `endAfterMs`, and `study check` prints a `windows` row;
268
+ - `participants[].maxSessionMs` (computer-use only, also on `count:` groups): the longest the
269
+ participant's session lasts, in whole milliseconds from 1 to 86,400,000, counted from the start
270
+ of its own session. `windDownMs` applies to it as to `endAfterMs`. Refused: a value outside that
271
+ range, `maxSessionMs` beside `endAfterMs` on one participant, `maxSessionMs` on `shared-world`,
272
+ and a cap whose sandbox deadline passes the sandbox ceiling. The session budget is the cap plus
273
+ 5 minutes (`participantSessionMs`) in place of `execution.timeoutMs`, and the schedule holds the
274
+ participant's slot that long. The route reads the cap's end when the session starts
275
+ (`participantEnd` in `src/study/arrivals.ts`, `participantClock` in
276
+ `src/routes/computer-use/participant-model.ts`) and passes it to the loop as
277
+ `clock.end` with `kind: session_cap`. The participant may end its session before the cap, and
278
+ its waiting turns count toward the backstops. At the cap the loop requests the closing report
279
+ (`debrief.trigger: session_cap`) and the session ends `goal_satisfied` with
280
+ `stopCause: session_cap`, its report's declared outcome read as at a scheduled end. The plan
281
+ entry carries `maxSessionMs`, and the plan line shows
282
+ `cap=<duration>`;
244
283
  - `participants[].idleWaitMs` (computer-use and shared-world, also on `count:` groups): this
245
284
  participant's `actor.idleWaitMs`, in the same range and refused above an explicit
246
285
  `actor.maxWaitMs` (`waitLimitValidationReason`). Its trace records it in `waitSettings`;
247
- - `actor.clock.timeZone` (computer-use): a tz database time zone name, such as `Europe/London`. Each turn's context hint carries
248
- the local time in it, and a scheduled end is given in it (UTC without it);
286
+ - `actor.clock.timeZone` (computer-use and shared-world): a tz database time zone name, such as `Europe/London`. Each turn's context hint carries
287
+ the local time in it, and a scheduled end or session cap is given in it (UTC without it). On a hosted desktop the
288
+ participant's browser launches with `TZ` set to it (`openParticipantSurface` in
289
+ `src/routes/computer-use/e2b-desktop/start.ts`). `parseClock` in `src/study/parse/clock.ts` refuses a name Intl does not know, a
290
+ UTC offset and a case variant of Intl's spelling, which Chrome's ICU reads as UTC or `Etc/Unknown`. The computer-use planner
291
+ refuses a zone on the local runtime and the in-process route with `HUMANISH_COMPUTER_USE_TIME_ZONE_UNSUPPORTED`;
292
+ - `participants[].clock.timeZone` (computer-use and shared-world, also on `count:` groups): this participant's `actor.clock`,
293
+ with the same rule. The plan's `limits.timeZone` is the participant's zone, else the actor's. `study check` prints a
294
+ `time zones` row, and each participant's stream in `run.json` records `timeZone`;
249
295
  - `execution.concurrency` (computer-use E2B routes, including shared-world): a
250
296
  cap on participants in flight at once. When omitted, every participant runs
251
297
  simultaneously: both routes resolve it when they plan, from the final participant count
@@ -253,16 +299,21 @@ The rest of this section names keys by their v3 spelling:
253
299
  `HUMANISH_E2B_MAX_CONCURRENT_SANDBOXES` sandboxes at once (20 when unset; a provisioned
254
300
  shared world's app sandbox counts against it), so a larger roster runs in waves and the plan
255
301
  records a warning naming the setting. A declared value above that limit is refused at plan
256
- time. Total sessions and spend are identical either way; only wall-clock
257
- and simultaneity differ. Declaring a value below the participant count runs participants in
302
+ time, unless `HUMANISH_CUA_MAX_CONCURRENCY` lowers it first; the limit then lowers what is
303
+ left as it does an undeclared default. Total sessions and spend are identical either way; only
304
+ wall-clock and simultaneity differ. Declaring a value below the participant count runs participants in
258
305
  waves and emits a warning saying so, because a green waved run is otherwise
259
306
  indistinguishable from the all-live run the author meant. Shared-world studies
260
307
  need at least 2 (the host and a follower are live together); `concurrency: 1`
261
308
  there is refused at parse, since the sequential turn-taking route was removed
262
309
  in 0.106.0. The env override
263
310
  `HUMANISH_CUA_MAX_CONCURRENCY` may only lower the effective bound, never
264
- raise concurrent paid desktops, and a lowering is recorded on
265
- the plan (`envLoweredConcurrencyFrom`). Inert (warned) on other routes.
311
+ raise concurrent paid desktops. The plan applies it before the E2B limit, on every
312
+ computer-use desktop and on shared-world, so the schedule, `study check` and the late-start
313
+ warnings use the lowered count; the plan records a warning and, on computer-use,
314
+ `envLoweredConcurrencyFrom`. When the E2B limit lowers the count further, the plan records
315
+ the E2B limit's warning instead. A shared world refuses a value under 2. Inert
316
+ (warned) on other routes.
266
317
  `execution.timeoutMs` is the per-participant session budget on this route (semantics
267
318
  change: it was the single-session budget pre-fan-out); there is no run-level
268
319
  wall clock. `policies.allowPublicTargets` cannot combine with N>1 against one
@@ -1011,6 +1062,32 @@ Core-owned fields:
1011
1062
  click-like `ui_action` items, structured `coord` (`x`/`y`). Both are additive:
1012
1063
  absent on older bundles and non-stamping producers, and absence means
1013
1064
  "timing/position unrecorded", never zero
1065
+ - optional `items[].page` and `finalPage` (`{ origin, path }`, `TracePage` in
1066
+ `src/actors/contract.ts`): where a computer-use participant's browser was. The loop puts `page`
1067
+ on the `screenshot` item of each observation that read a URL: `turn-00-start` is the page before
1068
+ any action, `turn-01` the page after turn 1's actions, and dwell frames have their own.
1069
+ `finalPage` is the page of the session's last observation, which is a closing observation with
1070
+ no item when tasks are declared; it is absent when that observation read no URL. The screenshot
1071
+ items with a `page`, in item order, then `finalPage`, list the participant's path through the
1072
+ app. `origin` is the URL's scheme, host and port; `path` is its percent-encoded path. A query
1073
+ string, fragment and user info are never kept, and a non-web URL keeps its scheme only
1074
+ (`{ origin: "about:", path: "" }`). Redaction (`tracePage` in `src/evidence/trace-page.ts`):
1075
+ a path segment becomes `[REDACTED_URL_PART]` when it percent-encodes a letter, digit, `-`, `.`,
1076
+ `_`, `~` or `%`, or when any decoded reading of it is email-shaped, holds a run of 16 or more letters,
1077
+ digits, `-` and `_` with a piece of 8 or more characters mixing letters and digits, or changes
1078
+ under the trace text redaction (the study's secrets, then `redactText`). The whole path then
1079
+ goes through that redaction, is cut to 1024 characters ending `[TRUNCATED]`, and is withheld
1080
+ whole when verify's trace scan would still read it as sensitive or opaque. A host label
1081
+ shaped like that token run becomes `[REDACTED_URL_PART]`, then the origin goes through the same
1082
+ redaction (an E2B sandbox host becomes `[REDACTED_SECRET]`). When origin and path together
1083
+ still change under that redaction (a study secret that is a whole link), both become
1084
+ `[REDACTED_URL_PART]`. A token shorter than 16 characters, an
1085
+ all-digit id and personal data in readable words stay, so the sharing rules for synthetic data
1086
+ still apply. Pages come from the browser-state probe, which hosted runs have on Chrome and
1087
+ Chromium only; a Firefox participant, or one whose probe is unavailable, has screenshots without
1088
+ pages. The local browser route and the shared-world route record none. verify reports a page
1089
+ whose path holds `?` or `#`, or whose origin is neither a redaction marker nor a scheme with an
1090
+ optional host and port, as a public-safety finding. Both fields are additive; absence means "page not recorded"
1014
1091
  - optional `modelSettings` (`humanish.model-settings.v1`): how the model was
1015
1092
  asked to run, alongside `ids.model` which says which model it was.
1016
1093
  `reasoningEffort` records the value the request actually carried,
@@ -1845,8 +1922,13 @@ verification or maintainer acceptance.
1845
1922
  the run passed also reads `run.json`'s `outcome.ok` ([Run Outcome](run-bundle.md#run-outcome)):
1846
1923
  a run whose execution failed shows as failed beside a `pass` verdict. `participants.reachedGoal` retains the count of
1847
1924
  recorded successful sessions; it is not an independent adjudication of their
1848
- claims. Computer-use review and Observer labels distinguish a participant's
1849
- reported completion from a recorded `stopWhen` or dwell condition match. A
1925
+ claims. A computer-use session that passed at its scheduled end or session cap
1926
+ counts there only when its participant declared `reached`; otherwise it counts
1927
+ in `participants.ranUntilEnd`, present when above 0. Bundles written before
1928
+ `ranUntilEnd` existed counted it in `reachedGoal`, and a re-read keeps that
1929
+ count. Computer-use review and Observer labels distinguish a participant's
1930
+ reported completion from a recorded `stopWhen` or dwell condition match, and
1931
+ from a pass at a clock end without a declared reach. A
1850
1932
  condition match establishes that condition, not every aspect of the mission.
1851
1933
  Missing or malformed completion evidence is labeled unavailable. Other actor
1852
1934
  routes retain their own completion semantics. Re-reading a historical review
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.125.0",
3
+ "version": "0.127.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -96,7 +96,7 @@
96
96
  "comms:agentmail:contract": "node --import tsx scripts/agentmail-contract-check.mjs",
97
97
  "format": "oxfmt",
98
98
  "format:check": "oxfmt --check",
99
- "lint": "node scripts/check-lint-cap.mjs --max-warnings=424",
99
+ "lint": "node scripts/check-lint-cap.mjs --max-warnings=411",
100
100
  "knip": "knip",
101
101
  "prose:check": "node scripts/check-code-prose.mjs",
102
102
  "vocabulary:check": "tsx scripts/check-retired-words.ts",