@staix/agent-research 0.0.0-stage → 0.1.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 (52) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +572 -2
  4. package/bin/ares.js +11 -0
  5. package/package.json +35 -4
  6. package/src/cli.ts +295 -0
  7. package/src/commands-table.ts +72 -0
  8. package/src/commands.ts +520 -0
  9. package/src/console/registry.ts +97 -0
  10. package/src/exp/ablate.ts +140 -0
  11. package/src/exp/artifacts.ts +229 -0
  12. package/src/exp/attempts.ts +475 -0
  13. package/src/exp/executor.ts +207 -0
  14. package/src/exp/final.ts +197 -0
  15. package/src/exp/ideas.ts +117 -0
  16. package/src/exp/investigate.ts +182 -0
  17. package/src/exp/logs.ts +63 -0
  18. package/src/exp/metrics.ts +132 -0
  19. package/src/exp/protocol.ts +145 -0
  20. package/src/exp/selection.ts +53 -0
  21. package/src/exp/snapshot.ts +147 -0
  22. package/src/http.ts +62 -0
  23. package/src/index.ts +4 -0
  24. package/src/lit/bibtex.ts +61 -0
  25. package/src/lit/candidates.ts +148 -0
  26. package/src/lit/cards.ts +21 -0
  27. package/src/lit/cite.ts +26 -0
  28. package/src/lit/sources.ts +122 -0
  29. package/src/lit/verify.ts +110 -0
  30. package/src/lit/watch.ts +109 -0
  31. package/src/manifest.ts +38 -0
  32. package/src/mcp-tools.ts +200 -0
  33. package/src/mcp.ts +111 -0
  34. package/src/memory/amem.ts +97 -0
  35. package/src/memory/archive.ts +63 -0
  36. package/src/memory/lessons.ts +88 -0
  37. package/src/memory/skills.ts +99 -0
  38. package/src/paper/build.ts +70 -0
  39. package/src/paper/check.ts +180 -0
  40. package/src/paper/placeholders.ts +116 -0
  41. package/src/paper/review.ts +115 -0
  42. package/src/plan/controller.ts +192 -0
  43. package/src/plan/definition.ts +156 -0
  44. package/src/plan/hub.ts +56 -0
  45. package/src/plan/state.ts +234 -0
  46. package/src/playbook.ts +129 -0
  47. package/src/scope.ts +53 -0
  48. package/src/setup.ts +296 -0
  49. package/src/study.ts +296 -0
  50. package/src/ui/panel.css +11 -0
  51. package/src/ui/panel.js +156 -0
  52. package/src/ui/plugin.ts +361 -0
@@ -0,0 +1,140 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { assertWritable, boundedText, commit, onlyKeys, openStudy, plain, readLedger, readTextBounded, requireExperiment, sha256, type Actor, type LedgerEvent } from "../study.ts";
4
+ import { listAttempts, type AttemptStatus } from "./attempts.ts";
5
+ import { readProtocol } from "./protocol.ts";
6
+ import { readFinalSeeds, finalSeedsHash } from "./final.ts";
7
+ import { metricValue, pairSamples, readClosedInvestigation, type Sample, type PairedSamples } from "./investigate.ts";
8
+
9
+ export const MAX_ABLATIONS = 200, ABLATE_BYTES = 1_048_576;
10
+ export interface Ablation { id: string; commit: string; removes: string }
11
+ type Exclusion = "other-protocol" | "before-selection" | "missing-start-event" | "undeclared-seed" | "reserved-seed" | "undeclared-ablation" | "later-success";
12
+ type AttemptsByStatus = Record<AttemptStatus, string[]>;
13
+ export interface AblateRow extends Ablation { attempts: AttemptsByStatus; perSeed: Sample[]; paired: PairedSamples; reason: "no-data" | "reference-seeds" | "no-shared-seeds" | "paired" }
14
+ export interface AblateResult {
15
+ v: 1; run: "ablate"; investigateSha256: string; inputsSha256: string; protocolHash: string;
16
+ metric: { name: string; unit: string; direction: "max" | "min" };
17
+ reference: { commit: string; attempts: AttemptsByStatus; perSeed: Sample[] };
18
+ ablations: AblateRow[];
19
+ totals: { attempts: number; byStatus: Record<AttemptStatus, number>; countedSuccesses: number; excluded: number };
20
+ excluded: { id: string; commit: string; seed: number; status: AttemptStatus; reasons: Exclusion[] }[];
21
+ }
22
+ function bad(reason: string): never { throw new Error(`ablate: ${reason}; inspect ares investigate show and ares attempt list, then correct the inputs and retry ablate`); }
23
+ function canonical(raw: unknown): string {
24
+ if (Array.isArray(raw)) return `[${raw.map(canonical).join(",")}]`;
25
+ if (plain(raw)) return `{${Object.keys(raw).sort().map(k => `${JSON.stringify(k)}:${canonical(raw[k])}`).join(",")}}`;
26
+ return JSON.stringify(raw) ?? "null";
27
+ }
28
+ function declaration(raw: unknown, strict: boolean): Ablation {
29
+ if (!plain(raw)) bad("declaration object required");
30
+ if (strict) onlyKeys(raw, ["id", "commit", "removes"], "ablate declaration");
31
+ if (typeof raw.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(raw.id)) bad("id must be a run name of 1..64 characters");
32
+ if (typeof raw.commit !== "string" || !/^[a-fA-F0-9]{40}$/.test(raw.commit)) bad("commit must be a full 40-hex SHA");
33
+ const removes = boundedText(raw.removes, "ablate removes", 2000);
34
+ if (Buffer.byteLength(removes) > 2000) bad("removes exceeds 2000 bytes");
35
+ return { id: raw.id, commit: raw.commit.toLowerCase(), removes };
36
+ }
37
+ export function foldAblations(events: LedgerEvent[]): Ablation[] {
38
+ const rows: Ablation[] = [];
39
+ for (const e of events) if (e.type === "ablation.added") {
40
+ const row = declaration(e.data.ablation, false);
41
+ if (rows.length >= MAX_ABLATIONS || rows.some(old => old.id === row.id || old.commit === row.commit)) bad("journal has duplicate ablation id/commit or exceeds 200 declarations; restore the last valid ledger");
42
+ rows.push(row);
43
+ }
44
+ return rows;
45
+ }
46
+ function context(dirArg: string) {
47
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
48
+ const closed = readClosedInvestigation(dir), protocol = readProtocol(dir)!;
49
+ if (!closed.result.winner) bad("investigation has no winner; finish and close an investigation with a winner first");
50
+ const events = readLedger(dir), declarations = foldAblations(events);
51
+ return { dir, study, closed, protocol, events, declarations, winner: closed.result.winner.commit, baseline: closed.result.baseline.commit };
52
+ }
53
+ /** Caller holds the study lock; declarations never dispatch work or rewrite earlier journal entries. */
54
+ export function addAblation(dirArg: string, raw: unknown, actor: Actor): Ablation {
55
+ try {
56
+ const input = context(dirArg), row = declaration(raw, true);
57
+ if (input.declarations.length >= MAX_ABLATIONS) bad("200 declarations reached; start a new study for more ablations");
58
+ if (input.declarations.some(old => old.id === row.id)) bad(`duplicate id ${row.id}; choose a unique id`);
59
+ if (input.declarations.some(old => old.commit === row.commit)) bad(`duplicate commit for ${row.id}; choose a unique frozen ablation`);
60
+ if (row.commit === input.winner || row.commit === input.baseline) bad("commit is the winner or selection baseline; use a distinct ablation commit");
61
+ commit(input.dir, [], [{ actor, type: "ablation.added", data: { ablation: row } }]); return row;
62
+ } catch (error) {
63
+ const message = error instanceof Error ? error.message : "ablate: invalid declaration";
64
+ throw new Error(message.includes("retry ablate") ? message : `${message}; correct the file and run ares ablate add --file PATH again`);
65
+ }
66
+ }
67
+ function empty(): AttemptsByStatus { return { running: [], succeeded: [], failed: [], cancelled: [], unknown: [] }; }
68
+ function calculate(input: ReturnType<typeof context>): { result: AblateResult; running: boolean } {
69
+ const { dir, study, closed, protocol, events, declarations, winner } = input;
70
+ const attempts = listAttempts(dir, { run: "ablate" }).attempts, reserved = readFinalSeeds(dir, protocol)?.seeds ?? [];
71
+ const cutoff = events.findLastIndex(e => e.type === "selection.set"), starts = new Map<string, { index: number; data: Record<string, unknown> }>();
72
+ events.forEach((e, index) => { if (e.type === "attempt.started" && typeof e.data.id === "string") { if (starts.has(e.data.id)) bad(`duplicate start receipt ${e.data.id}`); starts.set(e.data.id, { index, data: e.data }); } });
73
+ const ordered = [...attempts].sort((a, b) => (starts.get(a.id)?.index ?? -1) - (starts.get(b.id)?.index ?? -1) || a.id.localeCompare(b.id));
74
+ const reference = { commit: winner, attempts: empty(), perSeed: [] as Sample[] };
75
+ const rows = declarations.map(d => ({ ...d, attempts: empty(), perSeed: [] as Sample[] }));
76
+ const byStatus = { running: 0, succeeded: 0, failed: 0, cancelled: 0, unknown: 0 }, excluded: AblateResult["excluded"] = [];
77
+ for (const a of ordered) {
78
+ if (a.studyId !== study.id || typeof a.commit !== "string" || !/^[a-f0-9]{40}$/.test(a.commit) || !Number.isSafeInteger(a.seed)) bad(`attempt ${a.id} has invalid study/commit/seed metadata`);
79
+ byStatus[a.status]++;
80
+ const row = a.commit === winner ? reference : rows.find(r => r.commit === a.commit);
81
+ if (row) row.attempts[a.status].push(a.id);
82
+ const reasons: Exclusion[] = [], start = starts.get(a.id);
83
+ if (!row) reasons.push("undeclared-ablation");
84
+ if (a.protocolHash !== closed.result.protocolHash) reasons.push("other-protocol");
85
+ if (!start) reasons.push("missing-start-event");
86
+ else {
87
+ if (start.data.run !== a.run || start.data.commit !== a.commit || start.data.seed !== a.seed) bad(`attempt ${a.id} start receipt identity mismatch`);
88
+ if (start.index <= cutoff) reasons.push("before-selection");
89
+ }
90
+ if (!protocol.seeds.includes(a.seed)) reasons.push("undeclared-seed");
91
+ if (reserved.includes(a.seed)) reasons.push("reserved-seed");
92
+ if (!reasons.length && a.status === "succeeded" && row) {
93
+ if (row.perSeed.some(s => s.seed === a.seed)) reasons.push("later-success");
94
+ else row.perSeed.push({ seed: a.seed, attempt: a.id, value: metricValue(a, closed.result.metric) });
95
+ }
96
+ if (reasons.length) excluded.push({ id: a.id, commit: a.commit, seed: a.seed, status: a.status, reasons });
97
+ }
98
+ reference.perSeed.sort((a, b) => a.seed - b.seed);
99
+ const ablations: AblateRow[] = rows.map(row => {
100
+ row.perSeed.sort((a, b) => a.seed - b.seed);
101
+ const paired = pairSamples(row.perSeed, reference.perSeed, closed.result.metric.direction, row.commit);
102
+ const reason = !row.perSeed.length ? "no-data" : !reference.perSeed.length ? "reference-seeds" : !paired.pairedSeeds.length ? "no-shared-seeds" : "paired";
103
+ return { ...row, paired, reason };
104
+ });
105
+ const result: AblateResult = {
106
+ v: 1, run: "ablate", investigateSha256: closed.sha256, protocolHash: closed.result.protocolHash, metric: closed.result.metric,
107
+ inputsSha256: sha256(canonical({ investigate: closed.sha256, declarations, finalSeeds: finalSeedsHash(dir, protocol) ?? null, attempts: ordered.map(a => ({ record: sha256(canonical(a)), start: starts.get(a.id)?.index ?? null })) })),
108
+ reference, ablations, totals: { attempts: attempts.length, byStatus, countedSuccesses: reference.perSeed.length + rows.reduce((n, r) => n + r.perSeed.length, 0), excluded: excluded.length }, excluded,
109
+ };
110
+ assertWritable(JSON.stringify(result, null, 2) + "\n", ABLATE_BYTES, "ablate result; archive this study and use a new bounded run");
111
+ return { result, running: attempts.some(a => a.status === "running") };
112
+ }
113
+ export function ablateResultPath(dir: string): string { return join(dir, "runs", "ablate", "ablate.json"); }
114
+ function previous(file: string): string | undefined {
115
+ if (!existsSync(file)) return undefined;
116
+ let text: string;
117
+ try { text = readTextBounded(file, ABLATE_BYTES); } catch { bad("result is unreadable or oversized; restore the last valid bounded ablate.json"); }
118
+ let value: unknown; try { value = JSON.parse(text); } catch { bad("result JSON is malformed; restore the last valid ablate.json"); }
119
+ if (!plain(value) || value.v !== 1) throw new Error("ablate result: unsupported version; use an ares version that supports it, keeping ablate.json unchanged");
120
+ return text;
121
+ }
122
+ export function showAblate(dirArg: string): AblateResult { const input = context(dirArg); previous(ablateResultPath(input.dir)); return calculate(input).result; }
123
+ /** Caller holds the study lock; result and receipt share one commit. */
124
+ export function closeAblate(dirArg: string, actor: Actor): AblateResult {
125
+ const input = context(dirArg), file = ablateResultPath(input.dir), old = previous(file), next = calculate(input);
126
+ if (next.running) throw new Error("ablate close: an ablate attempt is running; run ares attempt reconcile before closing");
127
+ const text = JSON.stringify(next.result, null, 2) + "\n";
128
+ if (old !== text) commit(input.dir, [[file, text, ABLATE_BYTES]], [{ actor, type: "ablate.closed", data: { sha256: sha256(text), investigateSha256: input.closed.sha256 } }]);
129
+ return next.result;
130
+ }
131
+
132
+ export function readClosedAblate(dirArg: string): { result: AblateResult; sha256: string } {
133
+ const input = context(dirArg), text = previous(ablateResultPath(input.dir));
134
+ if (text === undefined) throw new Error("ablate result: not closed; run ares ablate close before final approval");
135
+ const hash = sha256(text), receipt = input.events.findLast(e => e.type === "ablate.closed");
136
+ if (!receipt || receipt.data.sha256 !== hash) throw new Error("ablate result: bytes disagree with the latest close receipt; run ares ablate close again");
137
+ const next = calculate(input);
138
+ if (next.running || text !== JSON.stringify(next.result, null, 2) + "\n") throw new Error("ablate result: stale inputs or running work; reconcile attempts and run ares ablate close again");
139
+ return { result: next.result, sha256: hash };
140
+ }
@@ -0,0 +1,229 @@
1
+ import { createHash, type Hash } from "node:crypto";
2
+ import { runOnExecutor, type Executor, type ProcessRunner } from "./executor.ts";
3
+
4
+ export const ARTIFACT_FILES = 500, ARTIFACT_BYTES = 256 * 1024 * 1024;
5
+ export const ARTIFACT_CAPTURE_BYTES = 65_536;
6
+ export const ARTIFACT_HEADERS = 2048, ARTIFACT_METADATA_BYTES = 65_536;
7
+ export const ARTIFACT_WIRE_BYTES = ARTIFACT_BYTES + 16 * 1024 * 1024;
8
+ export type ArtifactErrorCode = "invalid-artifact" | "artifact-limit" | "artifact-read";
9
+ export class ArtifactError extends Error {
10
+ constructor(readonly code: ArtifactErrorCode, cause: string) {
11
+ super(`artifact archive: ${cause}; ${code === "artifact-limit" ? "reduce artifact file count, bytes or metadata within the declared limits" : code === "artifact-read" ? "check the executor host and archive command" : "recreate the archive using regular files/directories, valid tar headers and safe relative paths"}, then rerun ares attempt reconcile <dir>`);
12
+ this.name = "ArtifactError";
13
+ }
14
+ }
15
+ export interface ArtifactFile { path: string; sha256: string; bytes: number }
16
+ export interface ArtifactArchive { files: ArtifactFile[]; captured?: Uint8Array }
17
+ export interface ArtifactArchiveOptions {
18
+ capture?: string; captureBytes?: number; maxBytes?: number; maxFiles?: number;
19
+ }
20
+ function invalid(cause: string): never { throw new ArtifactError("invalid-artifact", cause); }
21
+ function limit(cause: string): never { throw new ArtifactError("artifact-limit", cause); }
22
+ function utf8(bytes: Uint8Array): string {
23
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
24
+ catch { return invalid("invalid UTF-8 metadata"); }
25
+ }
26
+ function field(header: Buffer, start: number, length: number, strict = true): string {
27
+ const bytes = header.subarray(start, start + length), end = bytes.indexOf(0);
28
+ const content = end === -1 ? bytes : bytes.subarray(0, end);
29
+ return strict ? utf8(content) : content.toString("utf8");
30
+ }
31
+ function octal(bytes: Uint8Array): number {
32
+ // GNU base-256 numbers are accepted only when positive and exactly representable.
33
+ if ((bytes[0]! & 128) !== 0) {
34
+ if ((bytes[0]! & 64) !== 0) return invalid("negative numeric header field");
35
+ let value = BigInt(bytes[0]! & 127);
36
+ for (const byte of bytes.subarray(1)) value = value * 256n + BigInt(byte);
37
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) return limit("numeric header field exceeds safe bounds");
38
+ return Number(value);
39
+ }
40
+ if (bytes.some(byte => byte !== 0 && byte !== 32 && (byte < 48 || byte > 55))) return invalid("malformed octal header field");
41
+ const text = Buffer.from(bytes).toString("ascii").replaceAll("\0", " ").trim();
42
+ if (!/^[0-7]+$/.test(text)) return invalid("malformed octal header field");
43
+ const value = Number.parseInt(text, 8);
44
+ if (!Number.isSafeInteger(value)) return limit("numeric header field exceeds safe bounds");
45
+ return value;
46
+ }
47
+ function decimal(value: string): number {
48
+ if (!/^[0-9]+$/.test(value)) return invalid("malformed PAX size field");
49
+ const number = Number(value);
50
+ if (!Number.isSafeInteger(number)) return limit("PAX size exceeds safe bounds");
51
+ return number;
52
+ }
53
+ function relative(value: string, directory = false): string {
54
+ if (!value || Buffer.byteLength(value) > 4096 || value.startsWith("/") || /^[A-Za-z]:/.test(value) || value.includes("\\") || /\p{Cc}/u.test(value)) return invalid("unsafe or oversized artifact path");
55
+ const parts = value.split("/");
56
+ if (parts.includes("..") || (!directory && value.endsWith("/"))) return invalid("traversal or ambiguous artifact path");
57
+ const normalized = parts.filter(part => part !== "" && part !== ".").join("/");
58
+ if (!normalized && !directory) return invalid("empty artifact file path");
59
+ return normalized;
60
+ }
61
+ function bound(value: number, max: number, what: string): number {
62
+ if (!Number.isSafeInteger(value) || value < 0 || value > max) return limit(`${what} must be an integer in 0..${max}`);
63
+ return value;
64
+ }
65
+ const ZERO_BLOCK = Buffer.alloc(65_536);
66
+ function allZero(bytes: Uint8Array): boolean {
67
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
68
+ for (let offset = 0; offset < buffer.length; offset += ZERO_BLOCK.length) {
69
+ const part = buffer.subarray(offset, offset + ZERO_BLOCK.length);
70
+ if (!part.equals(ZERO_BLOCK.subarray(0, part.length))) return false;
71
+ }
72
+ return true;
73
+ }
74
+ type Pax = { path?: string | null; size?: string | null };
75
+ interface FileBody { kind: "file"; path: string; size: number; remaining: number; hash: Hash; capture?: Buffer; offset: number }
76
+ interface MetadataBody { kind: "metadata"; type: "x" | "g" | "L"; remaining: number; data: Buffer; offset: number }
77
+
78
+ class TarArtifacts {
79
+ readonly files: ArtifactFile[] = [];
80
+ captured?: Uint8Array;
81
+ private readonly header = Buffer.alloc(512);
82
+ private headerBytes = 0;
83
+ private body?: FileBody | MetadataBody;
84
+ private padding = 0;
85
+ private zeros = 0;
86
+ private ended = false;
87
+ private wireBytes = 0;
88
+ private headers = 0;
89
+ private totalBytes = 0;
90
+ private fileCount = 0;
91
+ private global: Pax = {};
92
+ private next: Pax = {};
93
+ private pendingMetadata = false;
94
+ private longName?: string;
95
+ private seen = new Map<string, "file" | "directory">();
96
+ constructor(private capture: string | undefined, private captureBytes: number, private maxBytes: number, private maxFiles: number) {}
97
+ push(chunk: Uint8Array): void {
98
+ if (!(chunk instanceof Uint8Array)) return invalid("non-binary archive stream");
99
+ this.wireBytes += chunk.byteLength;
100
+ if (this.wireBytes > this.maxBytes + ARTIFACT_WIRE_BYTES - ARTIFACT_BYTES) return limit("archive stream exceeds its payload and metadata overhead limits");
101
+ let offset = 0;
102
+ while (offset < chunk.byteLength) {
103
+ if (this.ended) { if (!allZero(chunk.subarray(offset))) return invalid("nonzero data after tar end markers"); return; }
104
+ if (this.padding && !this.body) {
105
+ const count = Math.min(this.padding, chunk.byteLength - offset);
106
+ if (chunk.subarray(offset, offset + count).some(byte => byte !== 0)) return invalid("nonzero tar entry padding");
107
+ this.padding -= count; offset += count; continue;
108
+ }
109
+ if (this.body) {
110
+ const body = this.body, count = Math.min(body.remaining, chunk.byteLength - offset), part = chunk.subarray(offset, offset + count);
111
+ if (body.kind === "metadata") body.data.set(part, body.offset);
112
+ else {
113
+ body.hash.update(part);
114
+ body.capture?.set(part, body.offset);
115
+ }
116
+ body.offset += count; body.remaining -= count; offset += count;
117
+ if (body.remaining === 0) this.completeBody();
118
+ continue;
119
+ }
120
+ const count = Math.min(512 - this.headerBytes, chunk.byteLength - offset);
121
+ this.header.set(chunk.subarray(offset, offset + count), this.headerBytes); this.headerBytes += count; offset += count;
122
+ if (this.headerBytes === 512) { this.headerBytes = 0; this.readHeader(); }
123
+ }
124
+ }
125
+ private readHeader(): void {
126
+ const header = this.header;
127
+ if (header.every(byte => byte === 0)) { if (++this.zeros === 2) this.ended = true; return; }
128
+ if (this.zeros) return invalid("entry after an incomplete tar end marker");
129
+ if (++this.headers > ARTIFACT_HEADERS) return limit(`archive exceeds ${ARTIFACT_HEADERS} headers`);
130
+ const expected = octal(header.subarray(148, 156));
131
+ let checksum = 0;
132
+ for (let i = 0; i < 512; i++) checksum += i >= 148 && i < 156 ? 32 : header[i]!;
133
+ if (checksum !== expected) return invalid("tar header checksum mismatch");
134
+ const magic = field(header, 257, 6);
135
+ if (magic !== "" && magic !== "ustar" && magic !== "ustar ") return invalid("unsupported tar header format");
136
+ const type = header[156] === 0 ? "0" : String.fromCharCode(header[156]!);
137
+ if (!["0", "5", "x", "g", "L"].includes(type)) return invalid("link or special tar entry is forbidden");
138
+ if (field(header, 157, 100) !== "") return invalid("link target on a regular tar entry is forbidden");
139
+ const hasPathOverride = ({ ...this.global, ...this.next }.path ?? this.longName) !== undefined;
140
+ const prefix = magic === "ustar" ? field(header, 345, 155, !hasPathOverride) : "", name = field(header, 0, 100, !hasPathOverride);
141
+ const headerPath = prefix ? `${prefix}/${name}` : name;
142
+ // Metadata entry paths never become artifacts, but still must be safe paths.
143
+ relative(headerPath, type === "5" || hasPathOverride);
144
+ const headerSize = octal(header.subarray(124, 136));
145
+ if (type === "x" || type === "g" || type === "L") {
146
+ if (headerSize > ARTIFACT_METADATA_BYTES) return limit(`tar metadata exceeds ${ARTIFACT_METADATA_BYTES} bytes`);
147
+ this.body = { kind: "metadata", type, remaining: headerSize, data: Buffer.alloc(headerSize), offset: 0 };
148
+ this.padding = (512 - headerSize % 512) % 512;
149
+ } else {
150
+ const overrides = { ...this.global, ...this.next }, path = relative(overrides.path ?? this.longName ?? headerPath, type === "5"), size = overrides.size === undefined || overrides.size === null ? headerSize : decimal(overrides.size);
151
+ this.next = {}; this.longName = undefined; this.pendingMetadata = false;
152
+ const parts = path.split("/");
153
+ for (let count = 1; count < parts.length; count++) if (this.seen.get(parts.slice(0, count).join("/")) === "file") return invalid("artifact file is used as a parent directory");
154
+ if (type === "5") {
155
+ if (size !== 0) return invalid("directory tar entry has a payload");
156
+ if (this.seen.get(path) === "file") return invalid("artifact path changes entry type");
157
+ this.seen.set(path, "directory"); return;
158
+ }
159
+ if (this.seen.has(path)) return invalid("duplicate artifact file path");
160
+ if ([...this.seen.keys()].some(existing => existing.startsWith(`${path}/`))) return invalid("artifact file replaces a parent directory");
161
+ this.seen.set(path, "file");
162
+ if (++this.fileCount > this.maxFiles) return limit(`archive exceeds ${this.maxFiles} regular files`);
163
+ if (size > this.maxBytes - this.totalBytes) return limit(`regular file bytes exceed ${this.maxBytes}`);
164
+ this.totalBytes += size;
165
+ if (path === this.capture && size > this.captureBytes) return limit(`selected capture exceeds ${this.captureBytes} bytes`);
166
+ this.body = { kind: "file", path, size, remaining: size, hash: createHash("sha256"), ...(path === this.capture ? { capture: Buffer.alloc(size) } : {}), offset: 0 };
167
+ this.padding = (512 - size % 512) % 512;
168
+ }
169
+ if (this.body.remaining === 0) this.completeBody();
170
+ }
171
+ private completeBody(): void {
172
+ const body = this.body!; this.body = undefined;
173
+ if (body.kind === "file") {
174
+ this.files.push({ path: body.path, sha256: body.hash.digest("hex"), bytes: body.size });
175
+ if (body.capture) this.captured = body.capture;
176
+ } else if (body.type === "L") {
177
+ let end = body.data.length;
178
+ while (end && body.data[end - 1] === 0) end--;
179
+ this.longName = utf8(body.data.subarray(0, end)); relative(this.longName, true); this.pendingMetadata = true;
180
+ } else {
181
+ const values: Pax = {}, seen = new Set<string>();
182
+ for (let offset = 0; offset < body.data.length;) {
183
+ const separator = body.data.indexOf(32, offset);
184
+ if (separator === -1) return invalid("malformed PAX record length");
185
+ const lengthText = body.data.subarray(offset, separator).toString("utf8");
186
+ if (!/^[1-9][0-9]*$/.test(lengthText)) return invalid("malformed PAX record length");
187
+ const length = Number(lengthText), end = offset + length;
188
+ if (!Number.isSafeInteger(length) || end > body.data.length || end <= separator + 1 || body.data[end - 1] !== 10) return invalid("truncated or malformed PAX record");
189
+ const record = utf8(body.data.subarray(separator + 1, end - 1)), equals = record.indexOf("=");
190
+ if (equals < 1) return invalid("malformed PAX key/value record");
191
+ const key = record.slice(0, equals), value = record.slice(equals + 1);
192
+ if (seen.has(key)) return invalid("duplicate PAX metadata key"); seen.add(key);
193
+ if (key === "linkpath" || key.startsWith("GNU.sparse.") || key === "SCHILY.filetype" || key === "SCHILY.realsize") return invalid("link, sparse or special PAX metadata is forbidden");
194
+ if (key === "path") { if (value) relative(value, true); values.path = value || null; }
195
+ if (key === "size") { if (value) decimal(value); values.size = value || null; }
196
+ offset = end;
197
+ }
198
+ if (body.type === "g") {
199
+ for (const key of ["path", "size"] as const) if (values[key] !== undefined) { if (values[key] === null) delete this.global[key]; else this.global[key] = values[key]; }
200
+ } else { this.next = { ...this.next, ...values }; this.pendingMetadata = true; }
201
+ }
202
+ }
203
+ finish(): ArtifactArchive {
204
+ if (this.body || this.padding || this.headerBytes || !this.ended || this.pendingMetadata) return invalid("truncated archive, missing end markers or dangling metadata");
205
+ return { files: this.files, ...(this.captured !== undefined ? { captured: this.captured } : {}) };
206
+ }
207
+ }
208
+
209
+ /** Hash regular artifacts, retaining no payload except an explicitly selected bounded capture. */
210
+ export async function readArtifactArchive(executor: Executor, command: string, args: string[], runner: ProcessRunner, options: ArtifactArchiveOptions = {}): Promise<ArtifactArchive> {
211
+ const capture = options.capture === undefined ? undefined : relative(options.capture), captureBytes = bound(options.captureBytes ?? ARTIFACT_CAPTURE_BYTES, ARTIFACT_CAPTURE_BYTES, "captureBytes");
212
+ const maxBytes = bound(options.maxBytes ?? ARTIFACT_BYTES, ARTIFACT_BYTES, "maxBytes"), maxFiles = bound(options.maxFiles ?? ARTIFACT_FILES, ARTIFACT_FILES, "maxFiles");
213
+ const parser = new TarArtifacts(capture, captureBytes, maxBytes, maxFiles), controller = new AbortController();
214
+ let parseError: ArtifactError | undefined;
215
+ try {
216
+ const result = await runOnExecutor(executor, command, args, runner, { signal: controller.signal, timeoutMs: 300_000,
217
+ stdoutSink: chunk => {
218
+ try { parser.push(chunk); }
219
+ catch (error) { parseError = error instanceof ArtifactError ? error : new ArtifactError("invalid-artifact", "malformed archive stream"); controller.abort(); throw parseError; }
220
+ } });
221
+ if (result.exitCode !== 0 || result.timedOut || result.truncated) throw new ArtifactError("artifact-read", "executor archive read failed");
222
+ return parser.finish();
223
+ } catch (error) {
224
+ controller.abort();
225
+ if (parseError) throw parseError;
226
+ if (error instanceof ArtifactError) throw error;
227
+ throw new ArtifactError("artifact-read", "executor archive read failed");
228
+ }
229
+ }