humanish 0.115.0 → 0.116.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 (154) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -6
  3. package/TELEMETRY.md +6 -0
  4. package/dist/actors/codex/app-server-ui.js +11 -4
  5. package/dist/actors/computer-use/loop/debrief.js +61 -27
  6. package/dist/actors/scripted-browser/steps.js +6 -5
  7. package/dist/analysis/analysis-limits.d.ts +35 -0
  8. package/dist/analysis/analysis-limits.js +41 -0
  9. package/dist/analysis/validation.js +14 -21
  10. package/dist/cli/commands/analyze.js +2 -1
  11. package/dist/cli/commands/notes.js +2 -1
  12. package/dist/cli/commands/run-signals.js +5 -1
  13. package/dist/cli/commands/runs.js +2 -1
  14. package/dist/cli/commands/setup.js +3 -3
  15. package/dist/cli/commands/study-format.d.ts +5 -2
  16. package/dist/cli/commands/study-format.js +29 -9
  17. package/dist/cli/commands/study-route-computer-use.js +2 -2
  18. package/dist/cli/commands/study-route-scripted.js +1 -1
  19. package/dist/cli/commands/tui.js +59 -17
  20. package/dist/cli/doctor.js +9 -2
  21. package/dist/cli/findings-text.d.ts +5 -0
  22. package/dist/cli/findings-text.js +169 -0
  23. package/dist/cli/findings.d.ts +0 -4
  24. package/dist/cli/findings.js +5 -167
  25. package/dist/cli/io.js +6 -1
  26. package/dist/cli/update-check.js +4 -1
  27. package/dist/comms/connections.js +4 -3
  28. package/dist/comms/setup.js +18 -12
  29. package/dist/evidence/redaction.js +9 -1
  30. package/dist/evidence/secret-scrub.d.ts +15 -5
  31. package/dist/evidence/secret-scrub.js +67 -9
  32. package/dist/feedback/draft.js +1 -1
  33. package/dist/feedback/export-bundle.js +18 -14
  34. package/dist/feedback/feedback.js +25 -7
  35. package/dist/guest/desktop-keys.js +59 -5
  36. package/dist/keys/key-resolution.d.ts +9 -0
  37. package/dist/keys/key-resolution.js +60 -48
  38. package/dist/keys/key-status.d.ts +2 -0
  39. package/dist/keys/key-status.js +9 -16
  40. package/dist/observer/pinned-files.js +1 -1
  41. package/dist/observer/render.js +3 -2
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  44. package/dist/routes/computer-use/bundle-parts.js +2 -2
  45. package/dist/routes/computer-use/bundle.d.ts +4 -5
  46. package/dist/routes/computer-use/bundle.js +1 -3
  47. package/dist/routes/computer-use/fanout-records.js +10 -11
  48. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  49. package/dist/routes/computer-use/live-phase.js +0 -1
  50. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  51. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  52. package/dist/routes/computer-use/participant-runs.js +9 -5
  53. package/dist/routes/computer-use/plan.d.ts +3 -0
  54. package/dist/routes/computer-use/plan.js +14 -11
  55. package/dist/routes/computer-use/rerun-selection.js +2 -2
  56. package/dist/routes/computer-use/result.js +3 -6
  57. package/dist/routes/computer-use/setup.d.ts +4 -6
  58. package/dist/routes/computer-use/setup.js +2 -6
  59. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  60. package/dist/routes/computer-use/single-bundle.js +22 -25
  61. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  62. package/dist/routes/computer-use/subject-projection.js +9 -31
  63. package/dist/routes/computer-use/types.d.ts +4 -41
  64. package/dist/routes/scripted/scenario.js +28 -12
  65. package/dist/routes/scripted/session-result.js +6 -3
  66. package/dist/routes/shared-world/bundle-records.js +2 -8
  67. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  68. package/dist/routes/shared-world/participant-specs.js +1 -20
  69. package/dist/routes/shared-world/plan.d.ts +3 -0
  70. package/dist/routes/shared-world/plan.js +23 -3
  71. package/dist/routes/shared-world/setup.js +2 -2
  72. package/dist/routes/terminal/ledger.d.ts +9 -9
  73. package/dist/routes/terminal/ledger.js +63 -40
  74. package/dist/routes/terminal/live-finish.js +7 -10
  75. package/dist/routes/terminal/plan.d.ts +3 -0
  76. package/dist/routes/terminal/plan.js +8 -5
  77. package/dist/routes/terminal/token-usage.d.ts +1 -2
  78. package/dist/routes/terminal/token-usage.js +4 -6
  79. package/dist/routes/terminal/types.d.ts +9 -1
  80. package/dist/run/bundle-shape.js +6 -1
  81. package/dist/run/bundle.d.ts +5 -3
  82. package/dist/run/contained-output.d.ts +53 -5
  83. package/dist/run/contained-output.js +141 -42
  84. package/dist/run/detail.d.ts +2 -1
  85. package/dist/run/detail.js +12 -7
  86. package/dist/run/escape-sequences.d.ts +11 -0
  87. package/dist/run/escape-sequences.js +93 -0
  88. package/dist/run/evidence-files.d.ts +8 -8
  89. package/dist/run/evidence-files.js +19 -23
  90. package/dist/run/locate.d.ts +32 -3
  91. package/dist/run/locate.js +45 -40
  92. package/dist/run/notes.d.ts +0 -8
  93. package/dist/run/notes.js +5 -51
  94. package/dist/run/outcomes.d.ts +24 -0
  95. package/dist/run/outcomes.js +19 -0
  96. package/dist/run/paths.d.ts +2 -0
  97. package/dist/run/paths.js +5 -1
  98. package/dist/run/preflight-receipts.js +13 -8
  99. package/dist/run/project.js +7 -4
  100. package/dist/run/reclaim-outcomes.d.ts +8 -6
  101. package/dist/run/reclaim-outcomes.js +10 -6
  102. package/dist/run/reclaim.d.ts +5 -0
  103. package/dist/run/reclaim.js +43 -16
  104. package/dist/run/run-clock.d.ts +50 -0
  105. package/dist/run/run-clock.js +66 -0
  106. package/dist/run/run.js +5 -2
  107. package/dist/run/sandbox-ids.d.ts +7 -2
  108. package/dist/run/sandbox-ids.js +27 -11
  109. package/dist/run/secrets.d.ts +2 -1
  110. package/dist/run/secrets.js +18 -22
  111. package/dist/run/stored-runs.js +14 -7
  112. package/dist/run/transient-comms-secrets.js +6 -17
  113. package/dist/run-study.js +22 -13
  114. package/dist/study/adapter-scorer-loader.js +6 -4
  115. package/dist/study/discover.js +9 -6
  116. package/dist/study/init-paths.js +7 -5
  117. package/dist/study/persona-resolve.d.ts +2 -2
  118. package/dist/study/persona-resolve.js +9 -5
  119. package/dist/study/plan-types.d.ts +2 -2
  120. package/dist/study/plan.d.ts +5 -3
  121. package/dist/study/plan.js +18 -9
  122. package/dist/study/preflight-probes.js +11 -3
  123. package/dist/study/preflight.js +9 -5
  124. package/dist/study/summary.d.ts +8 -2
  125. package/dist/study/summary.js +20 -22
  126. package/dist/study/warnings.d.ts +19 -12
  127. package/dist/study/warnings.js +38 -17
  128. package/dist/substrates/e2b/connection.d.ts +12 -0
  129. package/dist/substrates/e2b/connection.js +35 -0
  130. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  131. package/dist/substrates/e2b/desktop-executor.js +42 -9
  132. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  133. package/dist/substrates/e2b/lifetime.js +41 -4
  134. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  135. package/dist/substrates/e2b/sandbox.js +13 -18
  136. package/dist/substrates/e2b/sdk.d.ts +20 -9
  137. package/dist/substrates/local/capacity.d.ts +18 -2
  138. package/dist/substrates/local/capacity.js +21 -2
  139. package/dist/substrates/local/runtime-host.js +21 -14
  140. package/dist/substrates/local/runtime.js +3 -11
  141. package/dist/tui/contract.d.ts +30 -5
  142. package/dist/tui/contract.js +4 -1
  143. package/dist/tui-app.js +149 -147
  144. package/dist/verify/artifacts.d.ts +9 -2
  145. package/dist/verify/artifacts.js +37 -13
  146. package/dist/verify/liveness.js +19 -5
  147. package/dist/verify/verify.js +54 -27
  148. package/docs/architecture/guest-desktop.md +7 -2
  149. package/docs/architecture/state-driven-executor.md +4 -2
  150. package/docs/contracts/run-bundle.md +18 -10
  151. package/docs/contracts/schemas.md +27 -18
  152. package/docs/contracts/study-analysis.md +10 -1
  153. package/docs/product/automatic-analysis.md +7 -1
  154. package/package.json +2 -2
@@ -8,7 +8,7 @@ import { actorRegistry, isTerminalActorDescriptor } from "../../actors/registry.
8
8
  import { isNonEmpty, planBase } from "../../study/plan-base.js";
9
9
  import { studyUrlCredentialReason } from "../../study/url-credentials.js";
10
10
  import { desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
11
- import { MAX_SANDBOX_MS } from "../../substrates/e2b/lifetime.js";
11
+ import { ceilingAdvice } from "../../substrates/e2b/lifetime.js";
12
12
  import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
13
13
  import { terminalSandboxTimeoutMs } from "./lifetime.js";
14
14
  import { isExactRuntimeVersion, TERMINAL_RUNTIME_VERSION_TIMEOUT_MS } from "./runtime.js";
@@ -105,13 +105,16 @@ export function planTerminalStudy(config, input) {
105
105
  if (maxUsd > 0 && input.hasCostProbe !== true)
106
106
  return refuse("HUMANISH_TERMINAL_UNPRICED_CAP", `caps.maxUsd=${maxUsd} cannot be enforced: Codex reports no provider cost for the participant (its token cost is only estimated after the run) and no product, media or payment spend is measured, so a positive dollar cap can never trip. Set caps.maxUsd to 0 and bound the run with caps.maxMinutes, the codex command's wall-clock kill. No sandbox was created and the runtime key was not used.`, descriptor.id);
107
107
  // The sandbox's timeout covers the steps before the codex command, maxMinutes and the teardown
108
- // buffer, and E2B refuses a sandbox over an hour. Refuse here, with the arithmetic, rather than
109
- // after the plan prints, from a provider 400 that names neither knob.
108
+ // buffer, and E2B refuses a sandbox past the plan's ceiling. Refuse here, with the arithmetic,
109
+ // rather than after the plan prints, from a provider 400 that names neither knob.
110
+ const ceiling = input.sandboxCeiling;
111
+ if (!ceiling.ok)
112
+ return refuse("HUMANISH_TERMINAL_CAPS_INVALID", ceiling.message, descriptor.id);
110
113
  const productInstall = shared.product.install !== undefined;
111
114
  const sandboxTimeoutMs = terminalSandboxTimeoutMs({ maxMinutes, productInstall });
112
- if (sandboxTimeoutMs > MAX_SANDBOX_MS) {
115
+ if (sandboxTimeoutMs > ceiling.ms) {
113
116
  const headroomMinutes = (sandboxTimeoutMs - maxMinutes * 60_000) / 60_000;
114
- return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${MAX_SANDBOX_MS / 60_000}m. The deadline is maxMinutes plus ${headroomMinutes}m: the Node bootstrap (${NODE_BOOTSTRAP_TIMEOUT_MS / 60_000}m), the runtime version check (${TERMINAL_RUNTIME_VERSION_TIMEOUT_MS / 60_000}m)${productInstall ? `, the product setup (${PRODUCT_SETUP_TIMEOUT_MS / 60_000}m)` : ""} and the teardown buffer (${TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS / 60_000}m). Lower caps.maxMinutes to at most ${MAX_SANDBOX_MS / 60_000 - headroomMinutes}. No sandbox was created and the runtime key was not used.`, descriptor.id);
117
+ return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${ceiling.ms / 60_000}m. The deadline is maxMinutes plus ${headroomMinutes}m: the Node bootstrap (${NODE_BOOTSTRAP_TIMEOUT_MS / 60_000}m), the runtime version check (${TERMINAL_RUNTIME_VERSION_TIMEOUT_MS / 60_000}m)${productInstall ? `, the product setup (${PRODUCT_SETUP_TIMEOUT_MS / 60_000}m)` : ""} and the teardown buffer (${TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS / 60_000}m). Lower caps.maxMinutes to at most ${ceiling.ms / 60_000 - headroomMinutes}.${ceilingAdvice(sandboxTimeoutMs)} No sandbox was created and the runtime key was not used.`, descriptor.id);
115
118
  }
116
119
  return {
117
120
  ok: true,
@@ -13,6 +13,5 @@ export declare function terminalUsageRecords(lines: string): string[];
13
13
  */
14
14
  export declare function parseTerminalTokenUsage(transcript: string): ActorTokenUsage | undefined;
15
15
  /** Human-readable token statement for the cost ledger and the no-spend proof. Reports what was
16
- * counted and says plainly that it is unpriced, so a reader never reads "no charge recorded" as
17
- * "nothing was consumed". */
16
+ * counted, so a reader never reads "no charge recorded" as "nothing was consumed". */
18
17
  export declare function describeTokenUsage(usage: ActorTokenUsage): string;
@@ -9,10 +9,9 @@
9
9
  // {"type":"turn.completed","usage":{"input_tokens":201536,"cached_input_tokens":170558,
10
10
  // "cache_write_input_tokens":30951,"output_tokens":2283,"reasoning_output_tokens":902}}
11
11
  //
12
- // So the route can record a measured token fact even when it cannot record a priced one. Rates stay
13
- // out of this module deliberately: pricing lives in src/run/pricing.ts, and the terminal route has no
14
- // real model id to price against (it records `model: "codex"`), so the output is
15
- // tokens-known-rate-unknown rather than a guessed dollar figure.
12
+ // So the route records a measured token fact whether or not a rate prices it. Rates stay out of this
13
+ // module: the route prices these counts once, from the model it passed to Codex, with
14
+ // src/run/pricing.ts (live-finish.ts), and the ledger and run.json both read that estimate.
16
15
  // Bounded to a single line on purpose. `[^}]*` would run past a truncated record's missing brace
17
16
  // and swallow the next, valid record's body, silently dropping a real turn from the count.
18
17
  const USAGE_RE = /"type"\s*:\s*"turn\.completed"\s*,\s*"usage"\s*:\s*(\{[^}\n]*\})/g;
@@ -81,8 +80,7 @@ export function parseTerminalTokenUsage(transcript) {
81
80
  };
82
81
  }
83
82
  /** Human-readable token statement for the cost ledger and the no-spend proof. Reports what was
84
- * counted and says plainly that it is unpriced, so a reader never reads "no charge recorded" as
85
- * "nothing was consumed". */
83
+ * counted, so a reader never reads "no charge recorded" as "nothing was consumed". */
86
84
  export function describeTokenUsage(usage) {
87
85
  const parts = [];
88
86
  if (usage.input !== undefined)
@@ -232,6 +232,11 @@ export interface InterventionRecord {
232
232
  export interface CostLine {
233
233
  /** known zero (0) | not measured (null). The key is always present when the line is applicable. */
234
234
  usd: number | null;
235
+ /** The provider line's price for its counted tokens, from the trace's `estimatedCost` (the
236
+ * figure run.json's model-tokens line gives), when no charge was measured. An estimate from a
237
+ * rate table: `usd` stays null, and neither `knownTotalUsd` nor caps.maxUsd counts it. Present
238
+ * only with source `estimated-token-usage`. */
239
+ estimatedUsd?: number;
235
240
  /** Optional billable-unit count, same discipline: a known count, or null = not measured. */
236
241
  count?: number | null;
237
242
  /** How this line's value was established (provenance for the verifier + the human reviewer). */
@@ -239,7 +244,10 @@ export interface CostLine {
239
244
  /** Tokens were counted but no rate could price them, so `usd` stays null while the note
240
245
  * carries the measured token totals. Distinct from "unmeasured", which means no
241
246
  * signal at all. */
242
- | "unpriced-token-usage";
247
+ | "unpriced-token-usage"
248
+ /** Tokens were counted and priced from a rate table: `estimatedUsd` holds the estimate and
249
+ * `usd` stays null, since no charge was measured. */
250
+ | "estimated-token-usage";
243
251
  /** A short, public-safe note (never a secret value). */
244
252
  note: string;
245
253
  }
@@ -213,10 +213,15 @@ export const ARCHIVE_SHA256_PATTERN = /^[a-f0-9]{64}$/;
213
213
  function isRunSubjectProvenance(value) {
214
214
  if (!isRecord(value))
215
215
  return false;
216
- if (value.source !== "clone" && value.source !== "app-url" && value.source !== "local-tree")
216
+ if (value.source !== "clone" &&
217
+ value.source !== "app-url" &&
218
+ value.source !== "local-tree" &&
219
+ value.source !== "desktop-cli")
217
220
  return false;
218
221
  if (value.repo !== undefined && typeof value.repo !== "string")
219
222
  return false;
223
+ if (value.product !== undefined && typeof value.product !== "string")
224
+ return false;
220
225
  if (value.commit !== undefined && typeof value.commit !== "string")
221
226
  return false;
222
227
  if (value.archiveSha256 !== undefined &&
@@ -158,9 +158,11 @@ export interface RunSubjectStateStepRecord {
158
158
  * pre-existing bundles).
159
159
  */
160
160
  export interface RunSubjectProvenance {
161
- source: "clone" | "app-url" | "local-tree";
161
+ source: "clone" | "app-url" | "local-tree" | "desktop-cli";
162
162
  /** Clone-route only. Honors policies.redactRepos exactly as the provenance event does. */
163
163
  repo?: string;
164
+ /** Desktop-cli only: the product the study declares in `subject.product.name`. */
165
+ product?: string;
164
166
  /** Clone-route: the cloned commit SHA. Local-tree route: the host-side HEAD at pack time,
165
167
  * when the packed root was a git work tree. */
166
168
  commit?: string;
@@ -184,8 +186,8 @@ export interface RunSubjectProvenance {
184
186
  * an external DB is still unpinned overall).
185
187
  * declared-not-run: steps declared but not (all) executed ok: dry-run contract bundles
186
188
  * and failed live provisioning.
187
- * undeclared: no subject.state block (stateless apps, app-url subjects), the explicit
188
- * "absence declared" marker that provenance requires when there is none.
189
+ * undeclared: no subject.state block (stateless apps, app-url and desktop-cli subjects),
190
+ * the explicit "absence declared" marker that provenance requires when there is none.
189
191
  * external-public: an operator-declared, operator-owned public deployment used
190
192
  * directly as the shared plane; humanish neither provisioned nor seeded it (no getHost, no
191
193
  * clone, no in-sandbox filesystem). It is not "seeded" (nothing was seeded) and not "unpinned" (this is
@@ -46,13 +46,61 @@ export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot,
46
46
  * writeContainedOutputFile renames a finished file into place instead, replacing what is there.
47
47
  */
48
48
  export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
49
- /** Read one regular file only when both lexical and physical paths stay in root. */
50
- export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
51
49
  /**
52
- * Whether a contained path is confirmed absent. A present entry that readContainedRegularFile
53
- * refuses or fails to read is not absent.
50
+ * A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
51
+ * and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
52
+ * accepts (32 MiB). Every other reader of a run file uses the same limit, so no command reads a
53
+ * run file that verify could not scan.
54
54
  */
55
- export declare function containedPathAbsent(rootInput: PreparedOutputRoot, relativePath: string): Promise<boolean>;
55
+ export declare const RUN_ARTIFACT_MAX_BYTES: number;
56
+ /** `.humanish/runs/latest.json`: a run id and a path. */
57
+ export declare const LATEST_POINTER_MAX_BYTES: number;
58
+ /** A file a person writes in the project: study, persona and scenario YAML, package.json, agent instructions. */
59
+ export declare const PROJECT_FILE_MAX_BYTES: number;
60
+ /**
61
+ * Why a contained read did not read a file that is there. `too-large`: it holds more than the
62
+ * limit. `changed`: it, its folder or the root changed while it was opened or read, which
63
+ * includes growing past the limit. `directory`: a folder is at the path. `not-regular`: a link, a
64
+ * hard link or a special file. `unsafe-path`: the path leaves the root or passes through a link.
65
+ * `unreadable`: the system refused the open or the read.
66
+ */
67
+ export type ContainedRefusalReason = "too-large" | "changed" | "directory" | "not-regular" | "unsafe-path" | "unreadable";
68
+ /** A file that is there and was not read: why, and the most the read would have taken. */
69
+ export interface ContainedRefusal {
70
+ readonly status: "refused";
71
+ readonly reason: ContainedRefusalReason;
72
+ readonly limit: number;
73
+ }
74
+ /** What a contained read found: the bytes, nothing at the path, or a file it refused. */
75
+ export type ContainedRead = {
76
+ readonly status: "read";
77
+ readonly bytes: Buffer;
78
+ } | {
79
+ readonly status: "missing";
80
+ } | ContainedRefusal;
81
+ /** `relativePath` and why it was refused, as a clause: "x.yaml is larger than 4194304 bytes, ...". */
82
+ export declare function refusalText(relativePath: string, refusal: ContainedRefusal): string;
83
+ /** A refused contained read, for a caller that stops on one. The message names the file. */
84
+ export declare class ContainedReadRefusedError extends Error {
85
+ readonly relativePath: string;
86
+ readonly refusal: ContainedRefusal;
87
+ constructor(relativePath: string, refusal: ContainedRefusal);
88
+ }
89
+ /**
90
+ * Read one regular file only when both lexical and physical paths stay in root and it holds at
91
+ * most `maxBytes`. A larger file is refused without being read, and one that grows past
92
+ * `maxBytes` while it is read is refused after at most `maxBytes + 1` bytes. `missing` means
93
+ * nothing is at the path, or a folder on it is missing.
94
+ */
95
+ export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<ContainedRead>;
96
+ /**
97
+ * At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
98
+ * position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
99
+ * after it was opened is never read whole.
100
+ */
101
+ export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
56
102
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
57
103
  export declare function openContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<FileHandle | null>;
58
104
  export declare function assertSafeOutputPathSegment(value: string, label?: string): void;
105
+ /** A non-empty relative path with no empty, `.` or `..` segment: the names a contained read accepts. */
106
+ export declare function isSafeRelativeFilePath(value: string): boolean;
@@ -150,73 +150,162 @@ export async function createContainedOutputFile(rootInput, relativePath, data) {
150
150
  }
151
151
  await handle.close();
152
152
  }
153
- /** Read one regular file only when both lexical and physical paths stay in root. */
154
- export async function readContainedRegularFile(rootInput, relativePath) {
155
- const handle = await openContainedRegularFile(rootInput, relativePath);
156
- if (!handle)
157
- return null;
153
+ // The most humanish reads of one file of each kind. A larger file is refused unread.
154
+ /**
155
+ * A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
156
+ * and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
157
+ * accepts (32 MiB). Every other reader of a run file uses the same limit, so no command reads a
158
+ * run file that verify could not scan.
159
+ */
160
+ export const RUN_ARTIFACT_MAX_BYTES = 32 * 1024 * 1024;
161
+ /** `.humanish/runs/latest.json`: a run id and a path. */
162
+ export const LATEST_POINTER_MAX_BYTES = 64 * 1024;
163
+ /** A file a person writes in the project: study, persona and scenario YAML, package.json, agent instructions. */
164
+ export const PROJECT_FILE_MAX_BYTES = 4 * 1024 * 1024;
165
+ /** `relativePath` and why it was refused, as a clause: "x.yaml is larger than 4194304 bytes, ...". */
166
+ export function refusalText(relativePath, refusal) {
167
+ switch (refusal.reason) {
168
+ case "too-large":
169
+ return `${relativePath} is larger than ${refusal.limit} bytes, the most humanish reads of it`;
170
+ case "changed":
171
+ return `${relativePath} changed while humanish read it`;
172
+ case "directory":
173
+ return `${relativePath} is a folder`;
174
+ case "not-regular":
175
+ return `${relativePath} is not a single-link regular file`;
176
+ case "unsafe-path":
177
+ return `${relativePath} leaves its folder or passes through a link`;
178
+ case "unreadable":
179
+ return `${relativePath} could not be read`;
180
+ }
181
+ }
182
+ /** A refused contained read, for a caller that stops on one. The message names the file. */
183
+ export class ContainedReadRefusedError extends Error {
184
+ relativePath;
185
+ refusal;
186
+ constructor(relativePath, refusal) {
187
+ super(`${refusalText(relativePath, refusal)}.`);
188
+ this.relativePath = relativePath;
189
+ this.refusal = refusal;
190
+ this.name = "ContainedReadRefusedError";
191
+ }
192
+ }
193
+ /**
194
+ * Read one regular file only when both lexical and physical paths stay in root and it holds at
195
+ * most `maxBytes`. A larger file is refused without being read, and one that grows past
196
+ * `maxBytes` while it is read is refused after at most `maxBytes + 1` bytes. `missing` means
197
+ * nothing is at the path, or a folder on it is missing.
198
+ */
199
+ export async function readContainedRegularFile(rootInput, relativePath, maxBytes) {
200
+ const opened = await openContained(rootInput, relativePath);
201
+ if (opened.status === "missing")
202
+ return opened;
203
+ const refused = (reason) => ({
204
+ status: "refused",
205
+ reason,
206
+ limit: maxBytes,
207
+ });
208
+ if (opened.status === "refused")
209
+ return refused(opened.reason);
210
+ const { handle } = opened;
158
211
  try {
159
- return await handle.readFile();
212
+ if ((await handle.stat()).size > maxBytes)
213
+ return refused("too-large");
214
+ const bytes = await readOpenedAtMost(handle, maxBytes);
215
+ return bytes === null ? refused("changed") : { status: "read", bytes };
160
216
  }
161
217
  catch {
162
- return null;
218
+ return refused("unreadable");
163
219
  }
164
220
  finally {
165
221
  await handle.close().catch(() => { });
166
222
  }
167
223
  }
168
224
  /**
169
- * Whether a contained path is confirmed absent. A present entry that readContainedRegularFile
170
- * refuses or fails to read is not absent.
225
+ * At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
226
+ * position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
227
+ * after it was opened is never read whole.
171
228
  */
172
- export async function containedPathAbsent(rootInput, relativePath) {
173
- try {
174
- assertSafeRelativeOutputPath(relativePath, false);
175
- const root = await resolveOutputRoot(rootInput);
176
- await lstat(path.resolve(root, normalizeRelativeOutputPath(relativePath)));
177
- return false;
178
- }
179
- catch (error) {
180
- return error.code === "ENOENT";
229
+ export async function readOpenedAtMost(handle, maxBytes) {
230
+ const chunks = [];
231
+ let total = 0;
232
+ for (;;) {
233
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
234
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
235
+ if (bytesRead === 0)
236
+ return Buffer.concat(chunks, total);
237
+ total += bytesRead;
238
+ if (total > maxBytes)
239
+ return null;
240
+ chunks.push(chunk.subarray(0, bytesRead));
181
241
  }
182
242
  }
183
243
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
184
244
  export async function openContainedRegularFile(rootInput, relativePath) {
185
- let handle;
245
+ const opened = await openContained(rootInput, relativePath);
246
+ return opened.status === "open" ? opened.handle : null;
247
+ }
248
+ /** The open behind both readers: every check, and what stopped it when one failed. */
249
+ async function openContained(rootInput, relativePath) {
250
+ const refused = (reason) => ({ status: "refused", reason });
251
+ const missingOr = (error, reason) => isNodeError(error) && error.code === "ENOENT"
252
+ ? { status: "missing" }
253
+ : isNodeError(error) && (error.code === "EACCES" || error.code === "EPERM")
254
+ ? refused("unreadable")
255
+ : refused(reason);
186
256
  try {
187
257
  assertSafeRelativeOutputPath(relativePath, false);
188
- const root = await resolveOutputRoot(rootInput);
189
- const candidate = path.resolve(root, normalizeRelativeOutputPath(relativePath));
190
- if (!isPathInside(root, candidate) || candidate === root) {
191
- return null;
192
- }
258
+ }
259
+ catch {
260
+ return refused("unsafe-path");
261
+ }
262
+ let root;
263
+ try {
264
+ root = await resolveOutputRoot(rootInput);
265
+ }
266
+ catch {
267
+ return refused("changed");
268
+ }
269
+ const candidate = path.resolve(root, normalizeRelativeOutputPath(relativePath));
270
+ if (!isPathInside(root, candidate) || candidate === root)
271
+ return refused("unsafe-path");
272
+ let before;
273
+ try {
193
274
  await assertContainedDirectoryChain(root, path.dirname(candidate));
194
- const before = await lstat(candidate, { bigint: true });
195
- if (before.isSymbolicLink() || !before.isFile() || before.nlink > 1n) {
196
- return null;
197
- }
198
- const physicalFile = await realpath(candidate);
199
- if (!isPathInside(root, physicalFile)) {
200
- return null;
201
- }
202
- handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
275
+ before = await lstat(candidate, { bigint: true });
276
+ }
277
+ catch (error) {
278
+ return missingOr(error, "unsafe-path");
279
+ }
280
+ if (before.isDirectory())
281
+ return refused("directory");
282
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink > 1n)
283
+ return refused("not-regular");
284
+ let handle;
285
+ try {
286
+ if (!isPathInside(root, await realpath(candidate)))
287
+ return refused("unsafe-path");
288
+ // O_NONBLOCK: a file swapped for a FIFO after the lstat opens at once and fails the fstat
289
+ // below, where a blocking open would wait for a writer.
290
+ handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
291
+ }
292
+ catch (error) {
293
+ return missingOr(error, "changed");
294
+ }
295
+ try {
203
296
  const after = await handle.stat({ bigint: true });
204
297
  if (!after.isFile() ||
205
298
  after.nlink > 1n ||
206
299
  after.dev !== before.dev ||
207
- after.ino !== before.ino) {
300
+ after.ino !== before.ino ||
301
+ (await resolveOutputRoot(rootInput)) !== root)
208
302
  throw new Error("Artifact identity changed.");
209
- }
210
- const revalidatedRoot = await resolveOutputRoot(rootInput);
211
- if (revalidatedRoot !== root) {
212
- throw new Error("Artifact root changed.");
213
- }
214
303
  await assertContainedDirectoryChain(root, path.dirname(candidate));
215
- return handle;
304
+ return { status: "open", handle };
216
305
  }
217
306
  catch {
218
- await handle?.close().catch(() => { });
219
- return null;
307
+ await handle.close().catch(() => { });
308
+ return refused("changed");
220
309
  }
221
310
  }
222
311
  export function assertSafeOutputPathSegment(value, label = "Output path segment") {
@@ -229,6 +318,16 @@ export function assertSafeOutputPathSegment(value, label = "Output path segment"
229
318
  throw new Error(`${label} must be one non-empty path segment.`);
230
319
  }
231
320
  }
321
+ /** A non-empty relative path with no empty, `.` or `..` segment: the names a contained read accepts. */
322
+ export function isSafeRelativeFilePath(value) {
323
+ try {
324
+ assertSafeRelativeOutputPath(value, false);
325
+ return true;
326
+ }
327
+ catch {
328
+ return false;
329
+ }
330
+ }
232
331
  function assertSafeRelativeOutputPath(value, allowEmpty) {
233
332
  if ((!allowEmpty && value.length === 0) || value.includes("\0")) {
234
333
  throw new Error("Output path must be a non-empty relative path.");
@@ -50,7 +50,8 @@ export interface RunDetail {
50
50
  }
51
51
  /**
52
52
  * Read one run's participants. Returns null when the run has no readable bundle yet, an ordinary
53
- * state for a run that has just started, not a failure.
53
+ * state for a run that has just started, not a failure. Throws when run.json is there and cannot
54
+ * be read: larger than RUN_ARTIFACT_MAX_BYTES, a link, or refused by the system.
54
55
  *
55
56
  * Reads the bundle narrowly rather than through `loadRunBundle`, which applies the strict
56
57
  * evidence-of-record guard. That guard is right for verification and wrong here: a mid-run flush is
@@ -14,7 +14,7 @@ import { resolveRunPath } from "./locate.js";
14
14
  import { RUN_BUNDLE_FILE } from "./bundle.js";
15
15
  import { savedCaption } from "./participant-caption.js";
16
16
  import { recordedPersonaId, streamParticipantIdOf } from "./participant-records.js";
17
- import { readContainedRegularFile } from "./contained-output.js";
17
+ import { ContainedReadRefusedError, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
18
18
  import { isPathInside, resolvePhysicalCwd } from "./paths.js";
19
19
  const RUN_DETAIL_SCHEMA = "humanish.run-detail.v1";
20
20
  /**
@@ -105,7 +105,8 @@ function costOf(trace) {
105
105
  }
106
106
  /**
107
107
  * Read one run's participants. Returns null when the run has no readable bundle yet, an ordinary
108
- * state for a run that has just started, not a failure.
108
+ * state for a run that has just started, not a failure. Throws when run.json is there and cannot
109
+ * be read: larger than RUN_ARTIFACT_MAX_BYTES, a link, or refused by the system.
109
110
  *
110
111
  * Reads the bundle narrowly rather than through `loadRunBundle`, which applies the strict
111
112
  * evidence-of-record guard. That guard is right for verification and wrong here: a mid-run flush is
@@ -118,13 +119,17 @@ export async function readRunDetail(cwdInput, runId) {
118
119
  const runPaths = await resolveRunPath(cwd, runId).catch(() => null);
119
120
  if (runPaths === null)
120
121
  return null;
122
+ // A contained read: a run.json swapped for a symlink or hardlink after resolve is refused.
123
+ const raw = await readContainedRegularFile(runPaths, RUN_BUNDLE_FILE, RUN_ARTIFACT_MAX_BYTES);
124
+ // One that changed while it was read is being written now, as a torn one is below. One that is
125
+ // there and refused for another reason is not a run that has yet to write it.
126
+ if (raw.status === "refused" && raw.reason !== "changed")
127
+ throw new ContainedReadRefusedError(RUN_BUNDLE_FILE, raw);
128
+ if (raw.status !== "read")
129
+ return null;
121
130
  let bundle;
122
131
  try {
123
- // A contained read: a run.json swapped for a symlink or hardlink after resolve is refused.
124
- const raw = await readContainedRegularFile(runPaths, RUN_BUNDLE_FILE);
125
- if (raw === null)
126
- return null;
127
- const parsed = JSON.parse(raw.toString("utf8"));
132
+ const parsed = JSON.parse(raw.bytes.toString("utf8"));
128
133
  if (parsed === null || typeof parsed !== "object")
129
134
  return null;
130
135
  bundle = parsed;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The stretches of the text a value's view drops or decodes, in order and without overlap, each
3
+ * as [start, end): operating-system commands (`ESC ]`), control sequences (`ESC [`) and the
4
+ * two-byte escapes `ESC 7`, `ESC 8`, `ESC =` and `ESC >`, raw or JSON-escaped (`\u001b`), and runs
5
+ * of percent escapes. An operating-system command ends at the first bell character after it or,
6
+ * with none after it, at the last `ESC \` in the text. A JSON-escaped one ends at the first
7
+ * backslash after it, which must start `\u0007` or `\u001b\\`. A start with no end is left as
8
+ * written. One regex of the six shapes finds the same stretches, in time quadratic in a run of
9
+ * unterminated commands; this takes time linear in the text.
10
+ */
11
+ export declare function escapeSequences(text: string): [number, number][];
@@ -0,0 +1,93 @@
1
+ // What a terminal draws with and what a URL encodes with, found in one pass. RunSecrets looks for a
2
+ // value in a view of the text with the first dropped and the second decoded, so that a color code
3
+ // or a percent escape inside a value does not hide it.
4
+ const BEL = "\x07";
5
+ const STRING_TERMINATOR = "\x1b\\";
6
+ const between = (code, low, high) => code >= low && code <= high;
7
+ const isHexDigit = (code) => between(code, 0x30, 0x39) || between(code, 0x41, 0x46) || between(code, 0x61, 0x66);
8
+ /**
9
+ * The end of a control sequence whose parameter bytes start at `from`: parameter bytes, then
10
+ * intermediate bytes, then one final byte. -1 when no final byte follows.
11
+ */
12
+ function controlSequenceEnd(text, from) {
13
+ let at = from;
14
+ while (at < text.length && between(text.charCodeAt(at), 0x30, 0x3f))
15
+ at += 1;
16
+ while (at < text.length && between(text.charCodeAt(at), 0x20, 0x2f))
17
+ at += 1;
18
+ return at < text.length && between(text.charCodeAt(at), 0x40, 0x7e) ? at + 1 : -1;
19
+ }
20
+ /** The end of the run of percent escapes that starts at `from`, or `from` when none does. */
21
+ function percentRunEnd(text, from) {
22
+ let end = from;
23
+ while (end + 2 < text.length &&
24
+ text[end] === "%" &&
25
+ isHexDigit(text.charCodeAt(end + 1)) &&
26
+ isHexDigit(text.charCodeAt(end + 2)))
27
+ end += 3;
28
+ return end;
29
+ }
30
+ /**
31
+ * The stretches of the text a value's view drops or decodes, in order and without overlap, each
32
+ * as [start, end): operating-system commands (`ESC ]`), control sequences (`ESC [`) and the
33
+ * two-byte escapes `ESC 7`, `ESC 8`, `ESC =` and `ESC >`, raw or JSON-escaped (`\u001b`), and runs
34
+ * of percent escapes. An operating-system command ends at the first bell character after it or,
35
+ * with none after it, at the last `ESC \` in the text. A JSON-escaped one ends at the first
36
+ * backslash after it, which must start `\u0007` or `\u001b\\`. A start with no end is left as
37
+ * written. One regex of the six shapes finds the same stretches, in time quadratic in a run of
38
+ * unterminated commands; this takes time linear in the text.
39
+ */
40
+ export function escapeSequences(text) {
41
+ const lastTerminator = text.lastIndexOf(STRING_TERMINATOR);
42
+ // The first bell character at or after the last place searched from. Starts only move forward,
43
+ // so each stretch of text is searched once.
44
+ let bel = text.indexOf(BEL);
45
+ const commandEnd = (start) => {
46
+ if (bel !== -1 && bel < start + 2)
47
+ bel = text.indexOf(BEL, start + 2);
48
+ if (bel !== -1)
49
+ return bel + 1;
50
+ return lastTerminator >= start + 2 ? lastTerminator + STRING_TERMINATOR.length : -1;
51
+ };
52
+ const jsonCommandEnd = (start) => {
53
+ const backslash = text.indexOf("\\", start + 7);
54
+ if (backslash === -1)
55
+ return -1;
56
+ if (text.startsWith("\\u0007", backslash))
57
+ return backslash + 6;
58
+ return text.startsWith("\\u001b\\\\", backslash) ? backslash + 8 : -1;
59
+ };
60
+ const endAt = (at) => {
61
+ switch (text[at]) {
62
+ case "\x1b": {
63
+ const next = text[at + 1];
64
+ if (next === "]")
65
+ return commandEnd(at);
66
+ if (next === "[")
67
+ return controlSequenceEnd(text, at + 2);
68
+ return next !== undefined && "78=>".includes(next) ? at + 2 : -1;
69
+ }
70
+ case "\\":
71
+ if (text.startsWith("\\u001b]", at))
72
+ return jsonCommandEnd(at);
73
+ return text.startsWith("\\u001b[", at) ? controlSequenceEnd(text, at + 7) : -1;
74
+ case "%": {
75
+ const end = percentRunEnd(text, at);
76
+ return end === at ? -1 : end;
77
+ }
78
+ default:
79
+ return -1;
80
+ }
81
+ };
82
+ const sequences = [];
83
+ for (let at = 0; at < text.length;) {
84
+ const end = endAt(at);
85
+ if (end === -1)
86
+ at += 1;
87
+ else {
88
+ sequences.push([at, end]);
89
+ at = end;
90
+ }
91
+ }
92
+ return sequences;
93
+ }
@@ -1,4 +1,3 @@
1
- import { type FileHandle } from "node:fs/promises";
2
1
  import { type PreparedOutputRoot } from "./contained-output.js";
3
2
  /**
4
3
  * Analysis inputs are retained local artifacts, never URLs or caller-selected outputs. The path is
@@ -23,12 +22,6 @@ export declare function readBoundedFile(root: PreparedOutputRoot, relativePath:
23
22
  * there that could not be read. Errors other than ENOENT propagate.
24
23
  */
25
24
  export declare function pathMissing(filePath: string): Promise<boolean>;
26
- /**
27
- * At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
28
- * position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
29
- * after it was opened is never read whole.
30
- */
31
- export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
32
25
  export type BoundedFileResult = {
33
26
  state: "read";
34
27
  bytes: Buffer;
@@ -38,5 +31,12 @@ export type BoundedFileResult = {
38
31
  } | {
39
32
  state: "unavailable";
40
33
  };
41
- /** Size refusals are distinguished only after the same contained regular-file checks. */
34
+ /** readUnchangedFile for an analysis input, whose path must also be an evidence path. */
42
35
  export declare function readBoundedFileResult(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<BoundedFileResult>;
36
+ /**
37
+ * At most `maxBytes` of a contained single-link regular file that stays the same file, with the
38
+ * same size and times, from the first check to the last. Size refusals are distinguished only after
39
+ * the same checks. `relativePath` uses `/`, and any name a contained read accepts
40
+ * (isSafeRelativeFilePath) is read: verify and export read the names they always did.
41
+ */
42
+ export declare function readUnchangedFile(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<BoundedFileResult>;