humanish 0.115.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +7 -6
- package/TELEMETRY.md +6 -0
- package/dist/actors/codex/app-server-ui.js +11 -4
- package/dist/actors/codex/restricted-participant-policy.d.ts +0 -2
- package/dist/actors/codex/restricted-participant-policy.js +4 -32
- package/dist/actors/codex/restricted-participant.d.ts +5 -0
- package/dist/actors/codex/restricted-participant.js +3 -2
- package/dist/actors/computer-use/actor.d.ts +4 -0
- package/dist/actors/computer-use/actor.js +2 -0
- package/dist/actors/computer-use/loop/actions.d.ts +4 -4
- package/dist/actors/computer-use/loop/actions.js +25 -18
- package/dist/actors/computer-use/loop/debrief.js +61 -27
- package/dist/actors/computer-use/loop/session.d.ts +3 -0
- package/dist/actors/computer-use/loop/session.js +9 -1
- package/dist/actors/computer-use/loop/trace.js +1 -0
- package/dist/actors/computer-use/loop/types.d.ts +16 -14
- package/dist/actors/computer-use/loop.d.ts +1 -1
- package/dist/actors/computer-use/loop.js +1 -1
- package/dist/actors/computer-use/wait.d.ts +74 -0
- package/dist/actors/computer-use/wait.js +100 -0
- package/dist/actors/contract.d.ts +14 -0
- package/dist/actors/scripted-browser/steps.js +6 -5
- package/dist/analysis/analysis-limits.d.ts +35 -0
- package/dist/analysis/analysis-limits.js +41 -0
- package/dist/analysis/validation.js +14 -21
- package/dist/browser-control/protocol.d.ts +86 -6
- package/dist/browser-control/protocol.js +13 -6
- package/dist/cli/commands/analyze.js +2 -1
- package/dist/cli/commands/notes.js +2 -1
- package/dist/cli/commands/run-signals.js +5 -1
- package/dist/cli/commands/runs.js +2 -1
- package/dist/cli/commands/setup.js +3 -3
- package/dist/cli/commands/study-format.d.ts +5 -2
- package/dist/cli/commands/study-format.js +29 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +1 -1
- package/dist/cli/commands/tui.js +59 -17
- package/dist/cli/doctor.js +9 -2
- package/dist/cli/findings-text.d.ts +5 -0
- package/dist/cli/findings-text.js +169 -0
- package/dist/cli/findings.d.ts +0 -4
- package/dist/cli/findings.js +5 -167
- package/dist/cli/io.js +6 -1
- package/dist/cli/update-check.js +4 -1
- package/dist/comms/connections.js +4 -3
- package/dist/comms/setup.js +18 -12
- package/dist/evidence/redaction.js +9 -1
- package/dist/evidence/secret-scrub.d.ts +15 -5
- package/dist/evidence/secret-scrub.js +67 -9
- package/dist/feedback/draft.js +1 -1
- package/dist/feedback/export-bundle.js +18 -14
- package/dist/feedback/feedback.js +25 -7
- package/dist/guest/desktop-executor.js +3 -1
- package/dist/guest/desktop-keys.js +59 -5
- package/dist/keys/key-resolution.d.ts +9 -0
- package/dist/keys/key-resolution.js +60 -48
- package/dist/keys/key-status.d.ts +2 -0
- package/dist/keys/key-status.js +9 -16
- package/dist/observer/pinned-files.js +1 -1
- package/dist/observer/render.js +3 -2
- package/dist/observer-app.html +5 -5
- package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
- package/dist/routes/computer-use/bundle-parts.js +2 -2
- package/dist/routes/computer-use/bundle.d.ts +4 -5
- package/dist/routes/computer-use/bundle.js +1 -3
- package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
- package/dist/routes/computer-use/fanout-records.js +10 -11
- package/dist/routes/computer-use/live-phase.d.ts +1 -1
- package/dist/routes/computer-use/live-phase.js +0 -1
- package/dist/routes/computer-use/local-vm.js +5 -2
- package/dist/routes/computer-use/participant-execution.d.ts +3 -2
- package/dist/routes/computer-use/participant-model.js +9 -0
- package/dist/routes/computer-use/participant-runs.d.ts +6 -2
- package/dist/routes/computer-use/participant-runs.js +9 -5
- package/dist/routes/computer-use/plan.d.ts +3 -0
- package/dist/routes/computer-use/plan.js +18 -13
- package/dist/routes/computer-use/rerun-selection.js +2 -2
- package/dist/routes/computer-use/result.js +3 -6
- package/dist/routes/computer-use/setup.d.ts +4 -6
- package/dist/routes/computer-use/setup.js +2 -6
- package/dist/routes/computer-use/single-bundle.d.ts +4 -5
- package/dist/routes/computer-use/single-bundle.js +22 -25
- package/dist/routes/computer-use/subject-projection.d.ts +4 -8
- package/dist/routes/computer-use/subject-projection.js +9 -31
- package/dist/routes/computer-use/types.d.ts +5 -42
- package/dist/routes/scripted/scenario.js +28 -12
- package/dist/routes/scripted/session-result.js +6 -3
- package/dist/routes/shared-world/bundle-records.js +2 -8
- package/dist/routes/shared-world/participant-specs.d.ts +0 -2
- package/dist/routes/shared-world/participant-specs.js +1 -20
- package/dist/routes/shared-world/plan.d.ts +3 -0
- package/dist/routes/shared-world/plan.js +25 -4
- package/dist/routes/shared-world/setup.js +2 -2
- package/dist/routes/terminal/ledger.d.ts +9 -9
- package/dist/routes/terminal/ledger.js +63 -40
- package/dist/routes/terminal/live-finish.js +7 -10
- package/dist/routes/terminal/live-sandbox.d.ts +1 -1
- package/dist/routes/terminal/live-sandbox.js +14 -11
- package/dist/routes/terminal/plan.d.ts +3 -0
- package/dist/routes/terminal/plan.js +8 -5
- package/dist/routes/terminal/sandbox.d.ts +4 -3
- package/dist/routes/terminal/sandbox.js +5 -5
- package/dist/routes/terminal/token-usage.d.ts +1 -2
- package/dist/routes/terminal/token-usage.js +4 -6
- package/dist/routes/terminal/types.d.ts +9 -1
- package/dist/run/bundle-shape.js +6 -1
- package/dist/run/bundle.d.ts +5 -3
- package/dist/run/contained-output.d.ts +53 -5
- package/dist/run/contained-output.js +141 -42
- package/dist/run/detail.d.ts +2 -1
- package/dist/run/detail.js +12 -7
- package/dist/run/escape-sequences.d.ts +11 -0
- package/dist/run/escape-sequences.js +93 -0
- package/dist/run/evidence-files.d.ts +8 -8
- package/dist/run/evidence-files.js +19 -23
- package/dist/run/locate.d.ts +32 -3
- package/dist/run/locate.js +45 -40
- package/dist/run/notes.d.ts +0 -8
- package/dist/run/notes.js +5 -51
- package/dist/run/outcomes.d.ts +24 -0
- package/dist/run/outcomes.js +19 -0
- package/dist/run/paths.d.ts +2 -0
- package/dist/run/paths.js +5 -1
- package/dist/run/preflight-receipts.js +13 -8
- package/dist/run/project.js +7 -4
- package/dist/run/reclaim-outcomes.d.ts +8 -6
- package/dist/run/reclaim-outcomes.js +10 -6
- package/dist/run/reclaim.d.ts +5 -0
- package/dist/run/reclaim.js +43 -16
- package/dist/run/run-clock.d.ts +50 -0
- package/dist/run/run-clock.js +66 -0
- package/dist/run/run.js +5 -2
- package/dist/run/sandbox-ids.d.ts +7 -2
- package/dist/run/sandbox-ids.js +27 -11
- package/dist/run/secrets.d.ts +2 -1
- package/dist/run/secrets.js +18 -22
- package/dist/run/stored-runs.js +14 -7
- package/dist/run/transient-comms-secrets.js +6 -17
- package/dist/run-study.js +22 -13
- package/dist/study/adapter-scorer-loader.js +6 -4
- package/dist/study/config.js +4 -1
- package/dist/study/discover.js +9 -6
- package/dist/study/init-paths.js +7 -5
- package/dist/study/keys.d.ts +2 -0
- package/dist/study/keys.js +2 -0
- package/dist/study/migrate/v2.js +2 -1
- package/dist/study/parse/actors.js +8 -0
- package/dist/study/persona-resolve.d.ts +2 -2
- package/dist/study/persona-resolve.js +9 -5
- package/dist/study/plan-participants.d.ts +4 -0
- package/dist/study/plan-participants.js +4 -0
- package/dist/study/plan-types.d.ts +2 -2
- package/dist/study/plan.d.ts +5 -3
- package/dist/study/plan.js +18 -9
- package/dist/study/preflight-probes.js +11 -3
- package/dist/study/preflight.js +9 -5
- package/dist/study/summary.d.ts +8 -2
- package/dist/study/summary.js +20 -22
- package/dist/study/types.d.ts +14 -0
- package/dist/study/validation.d.ts +5 -0
- package/dist/study/validation.js +8 -0
- package/dist/study/warnings.d.ts +19 -12
- package/dist/study/warnings.js +50 -17
- package/dist/subject/serve.d.ts +3 -0
- package/dist/subject/serve.js +4 -1
- package/dist/substrates/detached.js +14 -7
- package/dist/substrates/e2b/connection.d.ts +12 -0
- package/dist/substrates/e2b/connection.js +35 -0
- package/dist/substrates/e2b/desktop-executor.d.ts +1 -5
- package/dist/substrates/e2b/desktop-executor.js +45 -12
- package/dist/substrates/e2b/lifetime.d.ts +24 -4
- package/dist/substrates/e2b/lifetime.js +41 -4
- package/dist/substrates/e2b/sandbox.d.ts +4 -3
- package/dist/substrates/e2b/sandbox.js +13 -18
- package/dist/substrates/e2b/sdk.d.ts +20 -9
- package/dist/substrates/local/capacity.d.ts +18 -2
- package/dist/substrates/local/capacity.js +21 -2
- package/dist/substrates/local/runtime-host.js +21 -14
- package/dist/substrates/local/runtime.js +3 -11
- package/dist/tui/contract.d.ts +30 -5
- package/dist/tui/contract.js +4 -1
- package/dist/tui-app.js +150 -147
- package/dist/verify/artifacts.d.ts +9 -2
- package/dist/verify/artifacts.js +37 -13
- package/dist/verify/liveness.js +19 -5
- package/dist/verify/verify.js +54 -27
- package/docs/architecture/browser-control.md +10 -4
- package/docs/architecture/guest-desktop.md +7 -2
- package/docs/architecture/state-driven-executor.md +4 -2
- package/docs/contracts/run-bundle.md +18 -10
- package/docs/contracts/schemas.md +46 -29
- package/docs/contracts/study-analysis.md +10 -1
- package/docs/product/automatic-analysis.md +7 -1
- package/package.json +2 -2
|
@@ -150,73 +150,162 @@ export async function createContainedOutputFile(rootInput, relativePath, data) {
|
|
|
150
150
|
}
|
|
151
151
|
await handle.close();
|
|
152
152
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
218
|
+
return refused("unreadable");
|
|
163
219
|
}
|
|
164
220
|
finally {
|
|
165
221
|
await handle.close().catch(() => { });
|
|
166
222
|
}
|
|
167
223
|
}
|
|
168
224
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
219
|
-
return
|
|
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.");
|
package/dist/run/detail.d.ts
CHANGED
|
@@ -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
|
package/dist/run/detail.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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>;
|
|
@@ -2,11 +2,12 @@ import { constants } from "node:fs";
|
|
|
2
2
|
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { isPathInside, validatePreparedRunRootIdentity } from "./paths.js";
|
|
5
|
-
import { assertPreparedSelectedOutputDirectory, } from "./contained-output.js";
|
|
5
|
+
import { assertPreparedSelectedOutputDirectory, isSafeRelativeFilePath, readOpenedAtMost, } from "./contained-output.js";
|
|
6
6
|
import { isNodeError } from "./type-guards.js";
|
|
7
7
|
// Reading a file from a retained run directory for analysis: the path must be a plain relative path
|
|
8
8
|
// inside the run, the file a single-link regular file that stays inside it, and the read bounded.
|
|
9
|
-
// Evidence capture, analysis jobs, the analysis store, cost reading and export all read
|
|
9
|
+
// Evidence capture, analysis jobs, the analysis store, cost reading, verify and export all read
|
|
10
|
+
// through here.
|
|
10
11
|
/**
|
|
11
12
|
* Analysis inputs are retained local artifacts, never URLs or caller-selected outputs. The path is
|
|
12
13
|
* percent-decoded up to five times, and each round must still be a plain relative path, so an
|
|
@@ -87,29 +88,24 @@ export async function pathMissing(filePath) {
|
|
|
87
88
|
throw error;
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
|
-
/**
|
|
91
|
-
* At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
|
|
92
|
-
* position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
|
|
93
|
-
* after it was opened is never read whole.
|
|
94
|
-
*/
|
|
95
|
-
export async function readOpenedAtMost(handle, maxBytes) {
|
|
96
|
-
const chunks = [];
|
|
97
|
-
let total = 0;
|
|
98
|
-
for (;;) {
|
|
99
|
-
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
100
|
-
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
|
101
|
-
if (bytesRead === 0)
|
|
102
|
-
return Buffer.concat(chunks, total);
|
|
103
|
-
total += bytesRead;
|
|
104
|
-
if (total > maxBytes)
|
|
105
|
-
return null;
|
|
106
|
-
chunks.push(chunk.subarray(0, bytesRead));
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
91
|
const unavailable = { state: "unavailable" };
|
|
110
|
-
/**
|
|
92
|
+
/** readUnchangedFile for an analysis input, whose path must also be an evidence path. */
|
|
111
93
|
export async function readBoundedFileResult(root, relativePath, maxBytes) {
|
|
112
|
-
|
|
94
|
+
return isEvidencePath(relativePath)
|
|
95
|
+
? readUnchangedFile(root, relativePath, maxBytes)
|
|
96
|
+
: unavailable;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* At most `maxBytes` of a contained single-link regular file that stays the same file, with the
|
|
100
|
+
* same size and times, from the first check to the last. Size refusals are distinguished only after
|
|
101
|
+
* the same checks. `relativePath` uses `/`, and any name a contained read accepts
|
|
102
|
+
* (isSafeRelativeFilePath) is read: verify and export read the names they always did.
|
|
103
|
+
*/
|
|
104
|
+
export async function readUnchangedFile(root, relativePath, maxBytes) {
|
|
105
|
+
if (!isSafeRelativeFilePath(relativePath) ||
|
|
106
|
+
relativePath.includes("\\") ||
|
|
107
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
108
|
+
maxBytes < 1)
|
|
113
109
|
return unavailable;
|
|
114
110
|
const validateRoot = async () => {
|
|
115
111
|
if ("physicalRunRoot" in root) {
|
package/dist/run/locate.d.ts
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
2
|
-
import { type PreparedSelectedOutputDirectory } from "./contained-output.js";
|
|
2
|
+
import { type ContainedRefusal, type PreparedSelectedOutputDirectory } from "./contained-output.js";
|
|
3
3
|
import type { RunPointer } from "./results.js";
|
|
4
4
|
import { type RunBundle } from "./bundle.js";
|
|
5
|
+
import { type BoundedFileResult } from "./evidence-files.js";
|
|
5
6
|
/** The run input that resolves through latest.json rather than naming a run directory. */
|
|
6
7
|
export declare const LATEST_RUN_ALIAS = "latest";
|
|
7
8
|
/** Resolve "latest" or an explicit run id to its prepared artifact paths. */
|
|
8
9
|
export declare function resolveRunPath(cwd: string, runInput: string): Promise<PreparedRunArtifactPaths | null>;
|
|
9
10
|
export declare function readLatest(runsRoot: PreparedSelectedOutputDirectory): Promise<RunPointer | null>;
|
|
10
|
-
|
|
11
|
-
export
|
|
11
|
+
/** A run file as text: its text, nothing at the path, or why it was refused. */
|
|
12
|
+
export type RunTextRead = {
|
|
13
|
+
status: "read";
|
|
14
|
+
text: string;
|
|
15
|
+
} | {
|
|
16
|
+
status: "missing";
|
|
17
|
+
} | ContainedRefusal;
|
|
18
|
+
/** A run file as JSON. `value` is undefined when the file is there but is not valid JSON. */
|
|
19
|
+
export type RunJsonRead = {
|
|
20
|
+
status: "read";
|
|
21
|
+
value: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
status: "missing";
|
|
24
|
+
} | ContainedRefusal;
|
|
25
|
+
/**
|
|
26
|
+
* The JSON, or null for a file that is missing, refused or not valid JSON: for a caller that
|
|
27
|
+
* treats the three alike, as every reader did before a refusal had its own state.
|
|
28
|
+
*/
|
|
29
|
+
export declare function runJsonValue(read: RunJsonRead): unknown;
|
|
30
|
+
/** A run file as JSON, within RUN_ARTIFACT_MAX_BYTES. */
|
|
31
|
+
export declare function readRunJsonIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise<RunJsonRead>;
|
|
32
|
+
/** A run file as text, within RUN_ARTIFACT_MAX_BYTES. */
|
|
33
|
+
export declare function readRunTextIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise<RunTextRead>;
|
|
34
|
+
/**
|
|
35
|
+
* A run file, read whole through readUnchangedFile: `limit` when it holds more than
|
|
36
|
+
* RUN_ARTIFACT_MAX_BYTES, `unavailable` when it is missing, unsafe, or changes while it is read.
|
|
37
|
+
* A `\` in the path reads as `/`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function readSafeRunArtifact(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<BoundedFileResult>;
|
|
40
|
+
/** readSafeRunArtifact's bytes, or null for any file it does not read. */
|
|
12
41
|
export declare function readSafeRunArtifactBytes(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<Buffer | null>;
|
|
13
42
|
export declare function readSafeRunArtifactJson(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<unknown>;
|
|
14
43
|
export declare function loadRunBundle(cwdInput: string, runInput: string): Promise<{
|
package/dist/run/locate.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { lstat } from "node:fs/promises";
|
|
2
1
|
import path from "node:path";
|
|
3
|
-
import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveExistingRunDirectory, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "./paths.js";
|
|
4
|
-
import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, } from "./contained-output.js";
|
|
2
|
+
import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveExistingRunDirectory, resolveLatestRunDirectory, RUNS_RELATIVE_ROOT, validatePreparedRunArtifactPaths, } from "./paths.js";
|
|
3
|
+
import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
|
|
5
4
|
import { RUN_BUNDLE_FILE } from "./bundle.js";
|
|
6
5
|
import { isRunBundle, isRunPointer } from "./bundle-shape.js";
|
|
7
|
-
import {
|
|
6
|
+
import { readUnchangedFile } from "./evidence-files.js";
|
|
8
7
|
/** The run input that resolves through latest.json rather than naming a run directory. */
|
|
9
8
|
export const LATEST_RUN_ALIAS = "latest";
|
|
10
9
|
/** Resolve "latest" or an explicit run id to its prepared artifact paths. */
|
|
@@ -33,59 +32,61 @@ export async function resolveRunPath(cwd, runInput) {
|
|
|
33
32
|
return bindExistingRunArtifactPaths(cwd, runInput);
|
|
34
33
|
}
|
|
35
34
|
export async function readLatest(runsRoot) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
throw error;
|
|
46
|
-
}
|
|
47
|
-
if (latestStats.isSymbolicLink() || !latestStats.isFile() || latestStats.nlink !== 1n) {
|
|
48
|
-
throw new Error("Latest run pointer must be a single-link regular file.");
|
|
49
|
-
}
|
|
50
|
-
const bytes = await readContainedRegularFile(runsRoot, "latest.json");
|
|
51
|
-
if (!bytes) {
|
|
52
|
-
throw new Error("Latest run pointer changed while it was being read.");
|
|
35
|
+
const read = await readContainedRegularFile(runsRoot, "latest.json", LATEST_POINTER_MAX_BYTES);
|
|
36
|
+
if (read.status === "missing")
|
|
37
|
+
return null;
|
|
38
|
+
if (read.status === "refused") {
|
|
39
|
+
if (read.reason === "too-large")
|
|
40
|
+
throw new ContainedReadRefusedError(path.join(RUNS_RELATIVE_ROOT, "latest.json"), read);
|
|
41
|
+
throw new Error(read.reason === "not-regular" || read.reason === "directory"
|
|
42
|
+
? "Latest run pointer must be a single-link regular file."
|
|
43
|
+
: "Latest run pointer changed while it was being read.");
|
|
53
44
|
}
|
|
54
45
|
let latest;
|
|
55
46
|
try {
|
|
56
|
-
latest = JSON.parse(bytes.toString("utf8"));
|
|
47
|
+
latest = JSON.parse(read.bytes.toString("utf8"));
|
|
57
48
|
}
|
|
58
49
|
catch {
|
|
59
50
|
return null;
|
|
60
51
|
}
|
|
61
52
|
return isRunPointer(latest) ? latest : null;
|
|
62
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The JSON, or null for a file that is missing, refused or not valid JSON: for a caller that
|
|
56
|
+
* treats the three alike, as every reader did before a refusal had its own state.
|
|
57
|
+
*/
|
|
58
|
+
export function runJsonValue(read) {
|
|
59
|
+
return read.status === "read" ? (read.value ?? null) : null;
|
|
60
|
+
}
|
|
61
|
+
/** A run file as JSON, within RUN_ARTIFACT_MAX_BYTES. */
|
|
63
62
|
export async function readRunJsonIfExists(runPaths, ...segments) {
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
67
|
-
}
|
|
63
|
+
const read = await readRunTextIfExists(runPaths, ...segments);
|
|
64
|
+
if (read.status !== "read")
|
|
65
|
+
return read;
|
|
68
66
|
try {
|
|
69
|
-
return JSON.parse(text);
|
|
67
|
+
return { status: "read", value: JSON.parse(read.text) };
|
|
70
68
|
}
|
|
71
69
|
catch {
|
|
72
|
-
return
|
|
70
|
+
return { status: "read", value: undefined };
|
|
73
71
|
}
|
|
74
72
|
}
|
|
73
|
+
/** A run file as text, within RUN_ARTIFACT_MAX_BYTES. */
|
|
75
74
|
export async function readRunTextIfExists(runPaths, ...segments) {
|
|
76
|
-
const
|
|
77
|
-
return bytes
|
|
75
|
+
const read = await readContainedRegularFile(runPaths, segments.join("/"), RUN_ARTIFACT_MAX_BYTES);
|
|
76
|
+
return read.status === "read" ? { status: "read", text: read.bytes.toString("utf8") } : read;
|
|
78
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* A run file, read whole through readUnchangedFile: `limit` when it holds more than
|
|
80
|
+
* RUN_ARTIFACT_MAX_BYTES, `unavailable` when it is missing, unsafe, or changes while it is read.
|
|
81
|
+
* A `\` in the path reads as `/`.
|
|
82
|
+
*/
|
|
83
|
+
export async function readSafeRunArtifact(runPaths, relativePath) {
|
|
84
|
+
return readUnchangedFile(runPaths, relativePath.replace(/\\/g, "/"), RUN_ARTIFACT_MAX_BYTES);
|
|
85
|
+
}
|
|
86
|
+
/** readSafeRunArtifact's bytes, or null for any file it does not read. */
|
|
79
87
|
export async function readSafeRunArtifactBytes(runPaths, relativePath) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (path.isAbsolute(relativePath) ||
|
|
83
|
-
path.win32.isAbsolute(relativePath) ||
|
|
84
|
-
segments.length === 0 ||
|
|
85
|
-
segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
return readContainedRegularFile(runPaths, normalized);
|
|
88
|
+
const read = await readSafeRunArtifact(runPaths, relativePath);
|
|
89
|
+
return read.state === "read" ? read.bytes : null;
|
|
89
90
|
}
|
|
90
91
|
export async function readSafeRunArtifactJson(runPaths, relativePath) {
|
|
91
92
|
const bytes = await readSafeRunArtifactBytes(runPaths, relativePath);
|
|
@@ -112,7 +113,11 @@ export async function loadRunBundlePrepared(cwdInput, runPaths) {
|
|
|
112
113
|
const cwd = path.resolve(cwdInput);
|
|
113
114
|
await validatePreparedRunArtifactPaths(runPaths);
|
|
114
115
|
const bundlePath = path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE);
|
|
115
|
-
const
|
|
116
|
+
const read = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
|
|
117
|
+
// A run.json it refuses is no reason to call the run absent: the caller reports why.
|
|
118
|
+
if (read.status === "refused")
|
|
119
|
+
throw new ContainedReadRefusedError(RUN_BUNDLE_FILE, read);
|
|
120
|
+
const bundle = read.status === "read" ? read.value : null;
|
|
116
121
|
if (!isRunBundle(bundle)) {
|
|
117
122
|
return null;
|
|
118
123
|
}
|