engineering-behavior-observatory 0.2.3 → 0.2.5

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.
@@ -20,14 +20,14 @@ export declare function validateArtifact(artifact: string, document: unknown): A
20
20
  export declare function validateRunManifestEvidence(artifact: string, manifest: unknown, bundleRoot: string): ArtifactValidationError[];
21
21
  export declare function validateExportManifest(artifact: string, exportManifest: unknown, containingManifest: unknown | undefined, bundleRoot?: string): ArtifactValidationError[];
22
22
  export declare function readVerifiedArtifact(artifactRoot: string, relativePath: string, expectedDigest: Digest, maxBytes?: number): Promise<Buffer>;
23
- export declare function inspectRetainedArtifact(artifactRoot: string, relativePath: string): Promise<{
23
+ export declare function inspectRetainedArtifact(artifactRoot: string, relativePath: string, maxBytes?: number): Promise<{
24
24
  digest: Digest;
25
25
  sizeBytes: number;
26
26
  }>;
27
27
  export declare function writeMetadataAtomically(artifactRoot: string, relativePath: string, metadata: unknown, signal?: AbortSignal, options?: {
28
28
  overwrite?: boolean;
29
29
  }): Promise<Digest>;
30
- export declare function writeArtifactAtomically(artifactRoot: string, relativePath: string, content: Uint8Array, signal?: AbortSignal, options?: {
30
+ export declare function writeArtifactAtomically(artifactRoot: string, relativePath: string, content: Uint8Array | AsyncIterable<Uint8Array>, signal?: AbortSignal, options?: {
31
31
  overwrite?: boolean;
32
32
  }): Promise<Digest>;
33
33
  export declare function writeMetadataAtomicallyIfAbsentSync(artifactRoot: string, relativePath: string, metadata: unknown, rootHandle?: BundleRootHandle, beforeDestinationPublish?: () => void, afterDestinationPublish?: () => void): {
@@ -852,22 +852,35 @@ export async function readVerifiedArtifact(artifactRoot, relativePath, expectedD
852
852
  await handle.close();
853
853
  }
854
854
  }
855
- export async function inspectRetainedArtifact(artifactRoot, relativePath) {
855
+ export async function inspectRetainedArtifact(artifactRoot, relativePath, maxBytes) {
856
+ if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes < 0)) {
857
+ throw new Error("Artifact byte limit must be a nonnegative safe integer.");
858
+ }
856
859
  const { path } = await resolveExistingArtifactPath(artifactRoot, relativePath);
857
- return inspectExistingPath(path, relativePath);
860
+ return inspectExistingPath(path, relativePath, maxBytes);
858
861
  }
859
862
  export async function writeMetadataAtomically(artifactRoot, relativePath, metadata, signal, options = {}) {
860
863
  const bytes = Buffer.from(canonicalizeMetadata(metadata));
861
864
  return writeArtifactAtomically(artifactRoot, relativePath, bytes, signal, options);
862
865
  }
863
866
  export async function writeArtifactAtomically(artifactRoot, relativePath, content, signal, options = {}) {
864
- const bytes = Buffer.from(content);
867
+ const source = content instanceof Uint8Array ? [Buffer.from(content)] : content;
868
+ const hash = createHash("sha256");
869
+ async function* chunks() {
870
+ for await (const chunk of source) {
871
+ if (signal?.aborted)
872
+ throw new Error("Artifact metadata write interrupted.");
873
+ const bytes = Buffer.from(chunk);
874
+ hash.update(bytes);
875
+ yield bytes;
876
+ }
877
+ }
865
878
  const { parent, path } = await prepareArtifactPath(artifactRoot, relativePath);
866
879
  const temporaryPath = resolve(parent, `.${randomUUID()}.tmp`);
867
880
  let handle;
868
881
  try {
869
882
  handle = await open(temporaryPath, "wx", 0o600);
870
- await handle.writeFile(bytes);
883
+ await handle.writeFile(chunks());
871
884
  await handle.sync();
872
885
  await handle.close();
873
886
  handle = undefined;
@@ -894,8 +907,10 @@ export async function writeArtifactAtomically(artifactRoot, relativePath, conten
894
907
  await handle?.close();
895
908
  await rm(temporaryPath, { force: true });
896
909
  }
897
- const digest = digestBytes(bytes);
898
- await readVerifiedArtifact(artifactRoot, relativePath, digest);
910
+ const digest = { algorithm: "sha256", value: hash.digest("hex") };
911
+ const inspected = await inspectRetainedArtifact(artifactRoot, relativePath);
912
+ if (inspected.digest.value !== digest.value)
913
+ throw new Error(`Artifact "${relativePath}" digest does not match its source reference.`);
899
914
  return digest;
900
915
  }
901
916
  export function writeMetadataAtomicallyIfAbsentSync(artifactRoot, relativePath, metadata, rootHandle, beforeDestinationPublish, afterDestinationPublish) {
@@ -1270,7 +1285,7 @@ function prepareArtifactPathSync(artifactRoot, relativePath) {
1270
1285
  function digestExistingPath(path, relativePath) {
1271
1286
  return inspectExistingPath(path, relativePath).digest;
1272
1287
  }
1273
- function inspectExistingPath(path, relativePath) {
1288
+ function inspectExistingPath(path, relativePath, maxBytes) {
1274
1289
  const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1275
1290
  try {
1276
1291
  let opened = fstatSync(descriptor);
@@ -1283,6 +1298,9 @@ function inspectExistingPath(path, relativePath) {
1283
1298
  || !Number.isSafeInteger(opened.size) || opened.size < 0) {
1284
1299
  throw new Error(`Artifact path "${relativePath}" is not an isolated regular file.`);
1285
1300
  }
1301
+ if (maxBytes !== undefined && opened.size > maxBytes) {
1302
+ throw new Error(`Artifact "${relativePath}" exceeds the qualification byte limit of ${maxBytes}.`);
1303
+ }
1286
1304
  const hash = createHash("sha256");
1287
1305
  const chunk = Buffer.allocUnsafe(64 * 1024);
1288
1306
  for (let offset = 0; offset < opened.size;) {
@@ -1,9 +1,12 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { cp, lstat, mkdir, mkdtemp, readdir, rm, rmdir, utimes, writeFile } from "node:fs/promises";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { cp, lstat, mkdir, mkdtemp, readdir, realpath, rm, rmdir, utimes, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join, resolve, sep } from "node:path";
6
7
  import { promisify } from "node:util";
8
+ import { Transform } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
7
10
  import { HOOK_EVENTS } from "@anthropic-ai/claude-agent-sdk";
8
11
  import { assertNoDuplicateJsonKeys, assertUniqueArtifactIdentities, canonicalizeMetadata, inspectRetainedArtifact, readVerifiedArtifact, validateArtifact, validateExportManifest, validateRunManifestEvidence, writeArtifactAtomically, writeMetadataAtomically, } from "./artifacts.js";
9
12
  import { isSafeArtifactRelativePath } from "./contracts.js";
@@ -11,7 +14,7 @@ import { digestWorkspace, digestWorkspaceTree } from "./verifiers.js";
11
14
  import { readBoundedFile } from "./scheduler.js";
12
15
  const execFileAsync = promisify(execFile);
13
16
  const MAX_WORKSPACE_PATCH_BYTES = 64 * 1024 * 1024;
14
- const MAX_WORKSPACE_SNAPSHOT_BYTES = 128 * 1024 * 1024;
17
+ const MAX_WORKSPACE_SNAPSHOT_BYTES = 1024 * 1024 * 1024;
15
18
  const MAX_QUALIFICATION_ARTIFACT_BYTES = 64 * 1024 * 1024;
16
19
  const QUALIFICATION_DIMENSION_RANK = { qualified: 0, unsupported: 1, gap: 2, unqualified: 3 };
17
20
  const PINNED_HOOK_EVENTS = new Set(HOOK_EVENTS);
@@ -151,11 +154,17 @@ export class RunBundleAssembler {
151
154
  throw new Error("Final workspace metadata changed while its outcome was being captured.");
152
155
  }
153
156
  const format = patch === undefined ? "snapshot" : "patch";
154
- const content = patch ?? await workspaceSnapshot(capturedPath, treeDigest);
155
157
  const relativePath = format === "patch"
156
158
  ? options.relativePath ?? "workspace.patch"
157
159
  : options.snapshotRelativePath ?? "workspace.tar.gz";
158
- await writeArtifactAtomically(this.bundleRoot, relativePath, content, undefined, { overwrite: false });
160
+ if (patch !== undefined) {
161
+ await writeArtifactAtomically(this.bundleRoot, relativePath, patch, undefined, { overwrite: false });
162
+ }
163
+ else {
164
+ await workspaceSnapshot(capturedPath, treeDigest, async (snapshotPath) => {
165
+ await writeArtifactAtomically(this.bundleRoot, relativePath, createReadStream(snapshotPath), undefined, { overwrite: false });
166
+ });
167
+ }
159
168
  const descriptor = await this.registerArtifact({
160
169
  id: options.id ?? "workspace",
161
170
  source: options.source ?? `workspace-${format}`,
@@ -259,6 +268,7 @@ async function withWorkspaceOutcomeProjection(options, use) {
259
268
  try {
260
269
  await cp(finalPath, projectedPath, {
261
270
  recursive: true,
271
+ verbatimSymlinks: true,
262
272
  preserveTimestamps: true,
263
273
  force: false,
264
274
  filter: async (source) => source === finalPath
@@ -312,9 +322,15 @@ export async function qualifyRunBundle(bundleRoot, options = {}) {
312
322
  const state = { descriptor, valid: true };
313
323
  states.set(descriptor.id, state);
314
324
  try {
315
- const bytes = await readVerifiedArtifact(bundleRoot, descriptor.relativePath, digestValue(descriptor.digest), descriptor.kind === "workspace" && descriptor.mediaType === "application/gzip"
316
- ? MAX_WORKSPACE_SNAPSHOT_BYTES
317
- : MAX_QUALIFICATION_ARTIFACT_BYTES);
325
+ if (descriptor.kind === "workspace" && descriptor.mediaType === "application/gzip") {
326
+ // Opaque snapshots need streaming integrity checks, not a whole-archive buffer.
327
+ const inspected = await inspectRetainedArtifact(bundleRoot, descriptor.relativePath, MAX_WORKSPACE_SNAPSHOT_BYTES);
328
+ if (inspected.sizeBytes !== descriptor.sizeBytes || digestString(inspected.digest.value) !== descriptor.digest) {
329
+ throw new Error("Workspace snapshot size or digest does not match its descriptor.");
330
+ }
331
+ continue;
332
+ }
333
+ const bytes = await readVerifiedArtifact(bundleRoot, descriptor.relativePath, digestValue(descriptor.digest), MAX_QUALIFICATION_ARTIFACT_BYTES);
318
334
  if (descriptor.mediaType === "application/x-ndjson") {
319
335
  const summary = parseNativeJsonl(bytes);
320
336
  state.hookNames = summary.hookNames;
@@ -633,7 +649,7 @@ async function workspacePatchApplies(startingWorkspacePath, patch) {
633
649
  const applied = join(temporaryRoot, "workspace");
634
650
  const patchPath = join(temporaryRoot, "workspace.patch");
635
651
  try {
636
- await cp(resolve(startingWorkspacePath), applied, { recursive: true, preserveTimestamps: true, force: false });
652
+ await cp(resolve(startingWorkspacePath), applied, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
637
653
  if (patch.length > 0) {
638
654
  await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
639
655
  await execFileAsync("git", ["apply", "--check", "--binary", patchPath], { cwd: applied });
@@ -827,7 +843,7 @@ async function removeIgnoredWorkspaceEntries(startPath, finalPath) {
827
843
  const globalExcludes = join(temporaryRoot, "global-excludes");
828
844
  try {
829
845
  await writeFile(globalExcludes, "");
830
- await cp(startPath, baseline, { recursive: true, preserveTimestamps: true, force: false });
846
+ await cp(startPath, baseline, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
831
847
  await execFileAsync("git", ["init", "--quiet"], { cwd: baseline });
832
848
  await execFileAsync("git", ["add", "--force", "--all"], { cwd: baseline });
833
849
  for (const relativePath of await ignoredWorkspacePaths(baseline, finalPath, globalExcludes)) {
@@ -895,12 +911,31 @@ async function isExcludedWorkspaceDirectory(root, source, exclusions) {
895
911
  }
896
912
  return false;
897
913
  }
898
- async function removeEmptyDirectories(directory, root = directory) {
914
+ async function removeEmptyDirectories(directory, root = directory, targets) {
915
+ if (targets === undefined) {
916
+ targets = new Set();
917
+ async function collect(path) {
918
+ for (const entry of await readdir(path, { withFileTypes: true })) {
919
+ const child = join(path, entry.name);
920
+ if (entry.isDirectory())
921
+ await collect(child);
922
+ else if (entry.isSymbolicLink()) {
923
+ // Omission must not turn a valid link into a dangling one. Invalid
924
+ // links are rejected later by workspace hashing; no content is read.
925
+ try {
926
+ targets.add(await realpath(child));
927
+ }
928
+ catch { /* validated by hashing */ }
929
+ }
930
+ }
931
+ }
932
+ await collect(directory);
933
+ }
899
934
  for (const entry of await readdir(directory, { withFileTypes: true })) {
900
935
  if (entry.isDirectory())
901
- await removeEmptyDirectories(join(directory, entry.name), root);
936
+ await removeEmptyDirectories(join(directory, entry.name), root, targets);
902
937
  }
903
- if (directory !== root && (await readdir(directory)).length === 0)
938
+ if (directory !== root && !targets.has(await realpath(directory)) && (await readdir(directory)).length === 0)
904
939
  await rmdir(directory);
905
940
  }
906
941
  async function restoreProjectedDirectoryTimestamps(source, projected) {
@@ -923,7 +958,7 @@ async function workspacePatch(startPath, finalPath, finalTreeDigest) {
923
958
  const applied = join(temporaryRoot, "applied");
924
959
  const patchPath = join(temporaryRoot, "workspace.patch");
925
960
  try {
926
- await cp(startPath, worktree, { recursive: true, preserveTimestamps: true, force: false });
961
+ await cp(startPath, worktree, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
927
962
  await execFileAsync("git", ["init", "--quiet"], { cwd: worktree });
928
963
  await execFileAsync("git", ["add", "--force", "--all"], { cwd: worktree });
929
964
  // A tree is enough for the diff. A commit can spawn detached maintenance
@@ -936,7 +971,7 @@ async function workspacePatch(startPath, finalPath, finalTreeDigest) {
936
971
  for (const entry of await readdir(finalPath)) {
937
972
  if (entry === ".git")
938
973
  throw new Error("Workspace patches cannot retain Git administrative state.");
939
- await cp(join(finalPath, entry), join(worktree, entry), { recursive: true, preserveTimestamps: true, force: false });
974
+ await cp(join(finalPath, entry), join(worktree, entry), { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
940
975
  }
941
976
  await execFileAsync("git", ["add", "--force", "--intent-to-add", "--all"], { cwd: worktree });
942
977
  let stdout;
@@ -951,12 +986,14 @@ async function workspacePatch(startPath, finalPath, finalTreeDigest) {
951
986
  throw error;
952
987
  }
953
988
  const patch = Buffer.from(stdout);
954
- await cp(startPath, applied, { recursive: true, preserveTimestamps: true, force: false });
989
+ await cp(startPath, applied, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
955
990
  if (patch.length > 0) {
956
991
  await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
957
992
  await execFileAsync("git", ["apply", "--binary", patchPath], { cwd: applied });
958
993
  }
959
- const appliedTreeDigest = await digestWorkspaceTree(applied);
994
+ // Git omits empty directories, including legitimate link targets. An
995
+ // invalid reconstructed tree is a patch limitation, so use the snapshot.
996
+ const appliedTreeDigest = await digestWorkspaceTree(applied).catch(() => undefined);
960
997
  if (appliedTreeDigest !== finalTreeDigest) {
961
998
  return undefined;
962
999
  }
@@ -966,7 +1003,7 @@ async function workspacePatch(startPath, finalPath, finalTreeDigest) {
966
1003
  await rm(temporaryRoot, { recursive: true, force: true });
967
1004
  }
968
1005
  }
969
- async function workspaceSnapshot(finalPath, finalTreeDigest) {
1006
+ async function workspaceSnapshot(finalPath, finalTreeDigest, use) {
970
1007
  const temporaryRoot = await mkdtemp(join(tmpdir(), "ebo-workspace-snapshot-"));
971
1008
  const snapshotPath = join(temporaryRoot, "workspace.tar.gz");
972
1009
  const snapshotParent = join(temporaryRoot, "source");
@@ -974,19 +1011,43 @@ async function workspaceSnapshot(finalPath, finalTreeDigest) {
974
1011
  const extracted = join(temporaryRoot, "extracted");
975
1012
  try {
976
1013
  await mkdir(snapshotParent);
977
- await cp(finalPath, snapshotRoot, { recursive: true, preserveTimestamps: true, force: false });
978
- const { stdout } = await execFileAsync(TAR_COMMAND, ["-czf", "-", "-C", snapshotParent, "workspace"], {
979
- encoding: "buffer",
980
- maxBuffer: MAX_WORKSPACE_SNAPSHOT_BYTES,
1014
+ await cp(finalPath, snapshotRoot, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, force: false });
1015
+ const child = spawn(TAR_COMMAND, [...(process.platform === "darwin" ? ["--no-mac-metadata"] : []), "-czf", "-", "-C", snapshotParent, "workspace"], { stdio: ["ignore", "pipe", "pipe"] });
1016
+ let stderr = "";
1017
+ child.stderr.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-8192); });
1018
+ const exited = new Promise((resolveExit, reject) => {
1019
+ child.once("error", reject);
1020
+ child.once("close", (code) => code === 0 ? resolveExit() : reject(new Error(`Workspace snapshot tar failed (${code}): ${stderr.trim()}`)));
981
1021
  });
982
- const snapshot = Buffer.from(stdout);
983
- await writeFile(snapshotPath, snapshot, { flag: "wx", mode: 0o600 });
1022
+ let size = 0;
1023
+ const limit = new Transform({ transform(chunk, _encoding, callback) {
1024
+ size += chunk.length;
1025
+ callback(size > MAX_WORKSPACE_SNAPSHOT_BYTES ? new Error("Workspace snapshot exceeds the 1 GiB compressed byte limit.") : null, chunk);
1026
+ } });
1027
+ const streamed = pipeline(child.stdout, limit, createWriteStream(snapshotPath, { flags: "wx", mode: 0o600 }));
1028
+ try {
1029
+ await Promise.all([exited, streamed]);
1030
+ }
1031
+ finally {
1032
+ if (child.exitCode === null)
1033
+ child.kill("SIGKILL");
1034
+ await Promise.allSettled([exited, streamed]);
1035
+ }
984
1036
  await mkdir(extracted, { mode: 0o700 });
985
- await execFileAsync(TAR_COMMAND, ["-xzpf", snapshotPath, "-C", extracted]);
1037
+ // macOS libarchive consumes genuine ._* AppleDouble files even when its
1038
+ // metadata flags are disabled. Native pax preserves them with copyfile off.
1039
+ if (process.platform === "darwin") {
1040
+ await execFileAsync("/bin/pax", ["-rz", "-p", "p", "-f", snapshotPath], {
1041
+ cwd: extracted, env: { ...process.env, COPYFILE_DISABLE: "1" },
1042
+ });
1043
+ }
1044
+ else {
1045
+ await execFileAsync(TAR_COMMAND, ["-xzpf", snapshotPath, "-C", extracted]);
1046
+ }
986
1047
  if (await digestWorkspaceTree(join(extracted, "workspace")) !== finalTreeDigest) {
987
1048
  throw new Error("Bounded workspace snapshot cannot reproduce the final workspace tree.");
988
1049
  }
989
- return snapshot;
1050
+ await use(snapshotPath);
990
1051
  }
991
1052
  finally {
992
1053
  await rm(temporaryRoot, { recursive: true, force: true });
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createReadStream, createWriteStream } from "node:fs";
4
- import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, realpath, rm, utimes } from "node:fs/promises";
4
+ import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rm, utimes } from "node:fs/promises";
5
5
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { performance } from "node:perf_hooks";
@@ -72,10 +72,10 @@ async function digestWorkspaceEntries(workspacePath, includeTimestamps, signal)
72
72
  if (!metadata.isDirectory() || metadata.isSymbolicLink())
73
73
  throw new Error("Workspace root is not a directory.");
74
74
  hash.update(`root\0${metadata.mode & 4095n}\0${includeTimestamps ? `${workspaceTimestamp(metadata)}\0` : ""}`);
75
- await hashWorkspaceDirectory(root, "", hash, includeTimestamps, signal);
75
+ await hashWorkspaceDirectory(root, "", hash, includeTimestamps, signal, root);
76
76
  return `sha256:${hash.digest("hex")}`;
77
77
  }
78
- async function hashWorkspaceDirectory(directory, relativeDirectory, hash, includeTimestamps, signal) {
78
+ async function hashWorkspaceDirectory(directory, relativeDirectory, hash, includeTimestamps, signal, root = directory) {
79
79
  assertNotAborted(signal);
80
80
  const entries = await readdir(directory, { withFileTypes: true });
81
81
  entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
@@ -83,11 +83,31 @@ async function hashWorkspaceDirectory(directory, relativeDirectory, hash, includ
83
83
  const path = join(directory, entry.name);
84
84
  const relativePath = posix.join(relativeDirectory, entry.name);
85
85
  const metadata = await lstat(path, { bigint: true });
86
- if (metadata.isSymbolicLink())
87
- throw new Error(`Workspace contains a symbolic link at "${relativePath}".`);
88
- if (metadata.isDirectory()) {
86
+ if (metadata.isSymbolicLink()) {
87
+ // Preserve link text, never hash the referent through the link. Both its
88
+ // lexical target and resolved chain must stay in this workspace.
89
+ const target = await readlink(path);
90
+ const contained = (candidate) => {
91
+ const rel = relative(root, candidate);
92
+ return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
93
+ };
94
+ if (isAbsolute(target) || !contained(resolve(dirname(path), target))) {
95
+ throw new Error(`Workspace symbolic link escapes its root at "${relativePath}".`);
96
+ }
97
+ let resolved;
98
+ try {
99
+ resolved = await realpath(path);
100
+ }
101
+ catch {
102
+ throw new Error(`Workspace symbolic link is dangling or cyclic at "${relativePath}".`);
103
+ }
104
+ if (!contained(resolved))
105
+ throw new Error(`Workspace symbolic link escapes its root at "${relativePath}".`);
106
+ hash.update(`symlink\0${relativePath}\0${metadata.mode & 4095n}\0${includeTimestamps ? `${workspaceTimestamp(metadata)}\0` : ""}${Buffer.byteLength(target)}\0${target}`);
107
+ }
108
+ else if (metadata.isDirectory()) {
89
109
  hash.update(`directory\0${relativePath}\0${metadata.mode & 4095n}\0${includeTimestamps ? `${workspaceTimestamp(metadata)}\0` : ""}`);
90
- await hashWorkspaceDirectory(path, relativePath, hash, includeTimestamps, signal);
110
+ await hashWorkspaceDirectory(path, relativePath, hash, includeTimestamps, signal, root);
91
111
  }
92
112
  else if (metadata.isFile()) {
93
113
  if (metadata.nlink > 1n)
@@ -46,6 +46,43 @@ missing-evidence entries. Inspect the detail before retrying capture against a
46
46
  retained workspace. A later successful capture is recovery evidence; it does
47
47
  not change the original attempt's recorded failure.
48
48
 
49
+ When the native run completed but capture failed, recover capture without
50
+ executing the task again. Materialize the same frozen starting workspace, use
51
+ the same capture exclusions, and assemble a separate bundle with byte-identical
52
+ native evidence plus the recovered workspace outcome. Record the original
53
+ manifest digest, recovery time, capture-code identity and qualification beside
54
+ the recovered bundle. Preserve the original failed bundle. A later workspace
55
+ capture cannot prove the retained directory was unchanged since execution ended
56
+ unless a fingerprint was recorded then. Do not count the recovery as another
57
+ model trial or as an independent sample.
58
+
59
+ Workspace outcomes preserve relative symbolic links whose lexical targets and
60
+ resolved referents stay inside the workspace. Link text is hashed and copied
61
+ verbatim, not replaced by the referent's bytes. Empty-directory omission keeps
62
+ directories referenced by links. Absolute, escaping, dangling and
63
+ cyclic links fail capture. Patch application or snapshot extraction must still
64
+ reproduce the resulting tree digest. This does not relax task-archive admission
65
+ or verifier sandbox rules.
66
+
67
+ On macOS, snapshot creation disables generated AppleDouble metadata. Snapshot
68
+ verification uses the built-in `/bin/pax` with `COPYFILE_DISABLE=1` so genuine
69
+ `._*` files remain files. macOS tar may otherwise consume them as metadata,
70
+ changing the reconstructed tree. To extract a snapshot for inspection on macOS:
71
+
72
+ ```sh
73
+ mkdir restored
74
+ (cd restored && COPYFILE_DISABLE=1 /bin/pax -rz -p p -f /absolute/path/workspace.tar.gz)
75
+ ```
76
+
77
+ Workspace capture tries a patch up to 64 MiB, then falls back to a snapshot
78
+ when the patch is too large or cannot reproduce the tree. Snapshots stream to
79
+ disk with a 1 GiB compressed limit and are extracted to verify the tree digest
80
+ before publication. Snapshot qualification also checks integrity without loading
81
+ the archive into memory. Exceeding the limit leaves explicit missing evidence
82
+ and retains the workspace; it never truncates the archive. These capture limits
83
+ are separate from portable-export limits. Configure known cache-directory
84
+ exclusions before a study rather than dropping files from an existing attempt.
85
+
49
86
  Use a new output destination for derived records and reruns. Do not edit a
50
87
  native bundle to make a validator accept it.
51
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-behavior-observatory",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Capture engineering-agent trajectories, evaluate behavior, and inspect cited evidence across harnesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -47,6 +47,8 @@
47
47
  "release/0.2.1/",
48
48
  "release/0.2.2/",
49
49
  "release/0.2.3/",
50
+ "release/0.2.4/",
51
+ "release/0.2.5/",
50
52
  "schemas/",
51
53
  "scripts/atlas-grafana.sh"
52
54
  ],
@@ -0,0 +1,16 @@
1
+ # v0.2.4 known limitations
2
+
3
+ The [v0.2.3 limitations](../0.2.3/KNOWN_LIMITATIONS.md) continue to apply.
4
+
5
+ - Snapshots remain bounded to 1 GiB compressed. Temporary source and extracted
6
+ copies also require free disk space; large cache trees increase capture time.
7
+ - Known cache exclusions must be declared before a study. Capture does not
8
+ silently omit additional files to fit a limit.
9
+ - Opaque snapshots remain restricted evidence; larger capture support does
10
+ not make them portable sanitized exports.
11
+ - Full-parallel testing exposed intermittent interruption/cleanup failures.
12
+ Release acceptance runs tests serially, as in earlier releases.
13
+ - npm audit reports three dependency findings (two moderate, one high) through
14
+ the pinned Cursor SDK's connect-node/undici dependency chain. npm reports no
15
+ available automatic fix for this dependency graph. This patch leaves those
16
+ dependencies unchanged; passing acceptance is not a clean security audit.
@@ -0,0 +1,23 @@
1
+ # v0.2.4: Stream large workspace snapshots
2
+
3
+ Workspace snapshot capture no longer buffers the compressed archive in child
4
+ process stdout. Archives stream to disk, pass extraction and tree-digest checks,
5
+ and publish atomically. Qualification verifies snapshot integrity in chunks.
6
+
7
+ The compressed snapshot limit increases from 128 MiB to 1 GiB. Output above
8
+ that limit fails explicitly and retains partial evidence. The 64 MiB patch
9
+ limit, capture exclusions, archive format, and portable-export policy are
10
+ unchanged. No runtime pins or dependencies change.
11
+
12
+ ## Verification
13
+
14
+ Regression tests cover a 129 MiB incompressible snapshot, streamed-write failure
15
+ cleanup, no-clobber publication, and bounded integrity checks. A retained real
16
+ workspace that failed on v0.2.3 was captured separately as a 605,143,040-byte
17
+ snapshot, with its extracted tree digest verified. Original evidence was not
18
+ rewritten.
19
+
20
+ Run `npm ci` and `npm run acceptance` on Node 24.19.0 for the full suite,
21
+ documentation and package checks, and two byte-identical package builds.
22
+ See [known limitations](KNOWN_LIMITATIONS.md) and the
23
+ [reproducibility manifest](reproducibility.json).
@@ -0,0 +1,74 @@
1
+ {
2
+ "schemaVersion": "ebo.release-reproducibility/v1",
3
+ "release": {
4
+ "name": "engineering-behavior-observatory",
5
+ "version": "0.2.4"
6
+ },
7
+ "runtime": {
8
+ "node": "24.19.0",
9
+ "claudeAgentSdk": "0.3.258",
10
+ "openhandsAgentServer": "1.46.0",
11
+ "deepseekClient": "0.1.1-rc.2",
12
+ "deepseekProtocol": "0.1.1-rc.2",
13
+ "deepseekRuntime": "0.1.1-rc.2",
14
+ "codexAppServer": "0.153.4",
15
+ "grafana": "13.2.0",
16
+ "grafanaInfinity": "4.0.0",
17
+ "piSdk": "0.85.1",
18
+ "cursorSdk": "1.0.31"
19
+ },
20
+ "commands": [
21
+ "npm ci",
22
+ "npm run acceptance"
23
+ ],
24
+ "fixtureCoverage": {
25
+ "agent-sdk": "frozen queue entry through qualified capture, approved export, normalization, configurable judging, review, aggregation, and Atlas",
26
+ "openhands-agent-server": "pinned REST/WebSocket stream-final reconciliation, workspace/outcome evidence, partial capture, and retained evaluation",
27
+ "deepseek-harness": "official-client JSON-RPC composition, receipt-to-idle completion, stderr, interruption, shutdown, swaps, and retained evaluation",
28
+ "codex-app-server": "owned stdio lifecycle, full history, OTLP, interruption/failure, export, and retained evaluation; configurable workspace-write networking with explicit offline queue propagation",
29
+ "acceptance-cases": "seeded ordering, secret scanning, partial attempts, native references, abstention, disputes, denominators, and unsupported comparisons",
30
+ "pi-sdk": "frozen queue, passive hooks, durable session, partial cleanup, export, retained normalization and evaluation",
31
+ "cursor-sdk": "frozen queue, official store, callbacks/history, bounded cancellation, export, retained normalization and evaluation",
32
+ "workspace-capture": "streamed snapshot capture, atomic publication and qualification beyond 128 MiB; bounded 1 GiB compressed output; retained error and partial evidence"
33
+ },
34
+ "determinism": {
35
+ "stable": [
36
+ "task, run, attempt, event, assertion, aggregate, and Atlas case identities",
37
+ "fixture, native-reference, normalized-dataset, source/cohort, archive, and package digests",
38
+ "seeded queue and review-sample ordering"
39
+ ],
40
+ "excluded": [
41
+ "native and EBO observation timestamps",
42
+ "lifecycle start/finish timestamps",
43
+ "provider timing and usage",
44
+ "temporary workspace and output paths"
45
+ ]
46
+ },
47
+ "fixtures": {
48
+ "tests/fixtures/task-packet.valid.v1.json": "ac2ef1043c0cc16bf4c21d05c1dc880ca1b4b8cc2f9ea7653328e91f0b0e2681",
49
+ "test/fixtures/agent-sdk-normalizer/complete.input.json": "7285dee088517e5de38ceda84fc7c58d9303949c7448ab3b5e10a5a2d2ebdfe3",
50
+ "test/fixtures/agent-sdk-normalizer/complete.expected.jsonl": "d32fa3a013bc179f1673f7e1a89b8a721bb8ddaf63eba5bc8a3f14f1cc148ed8",
51
+ "contracts/openhands-agent-server-v1.44.1.json": "e7a07977688a0703b15751a8a1ab17f29be45f9fdf438017e0dbcbc49cca37b0",
52
+ "contracts/openhands-agent-server-v1.46.0.json": "455cdddd2c206cb2f20245c2189b92e773657ec14d651236baa532b2dbadeb28",
53
+ "test/fixtures/openhands/v1.44.1/streamed-events.json": "d751083ba2ef94c9865117db481cc2f4933772bbbbb6b9fcf60571563715c1ea",
54
+ "test/fixtures/openhands/v1.44.1/final-events.json": "551db9904e860004d604c923692b5c5d3aaee966220492214c60003d2df415b6",
55
+ "test/fixtures/deepseek/golden-success.json": "f3867d38169bf0e28f64aba1dd60553f21cf07f41f81b4bad48261e49fa3ca88",
56
+ "test/fixtures/deepseek/golden-interrupted.json": "79e1f8404df1ee3b562d0bdbc0b2ee712ffc6d5774992b92970814ea815677b7",
57
+ "test/fixtures/deepseek/compositions/minimal/composition.json": "72fe4368e105f1bad82b7fdf8380e92c9fa689d8985f6ab91f534f0baf3eda02",
58
+ "contracts/codex-app-server-0.153.4/manifest.json": "e62281ff5e5d1cc71d07763b85e997ccc9ddbad9aa82a469da4181ee4b1890a8",
59
+ "test/fixtures/codex/legacy-0.150.1.dataset.json": "f71906b009e546afbf3b4e79396f223181382f3b3d8795e9cfcd032398a9d6bf",
60
+ "test/fixtures/behavior-assertions/abstained.json": "7f1962864f664c794925a67d93074e7b150af0f57c58041a81e062990d641197",
61
+ "test/fixtures/behavior-assertions/disputed.review.json": "450d3d9efe7feefaf7500b3ad20ab2803b412c7e53b91763125e15a83be87981",
62
+ "test/fixtures/comparison/exact.json": "cafa68e94b8d50dc308ddcfc2e73aa139c828b4ecd1e1c762bbc51bf1b44501e",
63
+ "test/fixtures/structural-observations/golden.json": "bad71a3a93524b76c0a2fdbb58e98e1e65be46cae14acf18ac89d9ba76e03e8a",
64
+ "test/atlas-fixture.ts": "9f03ce9cbcd0e5d3e3a5916866554ceb1c21f1c8712e58529499c1ea499f048d",
65
+ "test/pi.test.ts": "a00997c5617ca178e19b25b0d483cbdce8ce7c306d4c871557945b31a2e657cf",
66
+ "test/cursor-sdk.test.ts": "753094f127b11dc392ec2244c9edddabec27aa94347bb6159a292129ed2a2822",
67
+ "examples/cursor-sdk/README.md": "a2cf481123e7f3deb1c5a682ba208a775c3f91c35e1a6c1a428c517c79692618",
68
+ "examples/cursor-sdk/capture-profile.json": "d1a5787875daf5523b3783f259b891c256a904cbb10279d5be214af846305a2d",
69
+ "examples/cursor-sdk/harness.json": "2f1da6f2b17258d0ee59431f687696901ce3692589175d182f3a4bbe8dd4d689",
70
+ "examples/cursor-sdk/model.json": "ad8f31ff5d2387c02d79226679022358afafc61ba5dda73040e3f754530a230a",
71
+ "examples/cursor-sdk/native-limits.json": "40fe451f56b0863e79b42f5c85defbce678e1dc90dd248d15c430db176d71511",
72
+ "examples/cursor-sdk/native-tool-policy.json": "0fef98b36cd39de82914c5b068022c1335dadda24b7d6e7df398b2430e6a16e0"
73
+ }
74
+ }
@@ -0,0 +1,12 @@
1
+ # v0.2.5 known limitations
2
+
3
+ The [v0.2.4 limitations](../0.2.4/KNOWN_LIMITATIONS.md) continue to apply.
4
+
5
+ - Symlink support applies to workspace outcome capture, not task-archive
6
+ admission or permission to read outside the workspace.
7
+ - On macOS, use the documented native `pax` command when inspecting snapshots
8
+ containing genuine AppleDouble files. Default `tar` extraction can consume them.
9
+ - Capture-only recovery can use retained evidence and workspace files without
10
+ rerunning a model, but cannot reconstruct missing files or prove an earlier
11
+ workspace state without a fingerprint captured at that time.
12
+ - Workspace snapshots remain restricted evidence, not sanitized partner exports.
@@ -0,0 +1,25 @@
1
+ # v0.2.5: Preserve framework files in workspace capture
2
+
3
+ Workspace capture now preserves contained relative symbolic links, including
4
+ framework link chains and links to empty directories. Absolute, escaping,
5
+ dangling, and cyclic links remain rejected. Copies retain the original link
6
+ text, and patches that cannot reproduce the tree fall back to a snapshot.
7
+
8
+ On macOS, snapshot verification uses native `pax` to preserve genuine AppleDouble
9
+ files that macOS `tar` otherwise consumes as metadata. Snapshots remain tar.gz
10
+ archives. No runtime dependencies, pins, or serialized artifact formats change.
11
+
12
+ ## Verification
13
+
14
+ Regression tests cover link preservation, invalid targets, empty target
15
+ directories, AppleDouble bytes, and long paths. A completed model attempt's
16
+ retained workspace was recovered as a 695,459,840-byte snapshot with a verified
17
+ extracted tree digest, without another model call or rewriting original evidence.
18
+ The recovery qualified with an explicit missing-telemetry-receipt gap; it records
19
+ the later capture time rather than claiming an exact end-of-turn snapshot.
20
+
21
+ Run `npm ci` and `npm run acceptance` on Node 24.19.0 for tests, documentation
22
+ and package checks, and two byte-identical package builds. See
23
+ [known limitations](KNOWN_LIMITATIONS.md), the
24
+ [reproducibility manifest](reproducibility.json), and the
25
+ [workspace inspection guide](../../docs/guides/evidence-and-sharing.md).
@@ -0,0 +1,74 @@
1
+ {
2
+ "schemaVersion": "ebo.release-reproducibility/v1",
3
+ "release": {
4
+ "name": "engineering-behavior-observatory",
5
+ "version": "0.2.5"
6
+ },
7
+ "runtime": {
8
+ "node": "24.19.0",
9
+ "claudeAgentSdk": "0.3.258",
10
+ "openhandsAgentServer": "1.46.0",
11
+ "deepseekClient": "0.1.1-rc.2",
12
+ "deepseekProtocol": "0.1.1-rc.2",
13
+ "deepseekRuntime": "0.1.1-rc.2",
14
+ "codexAppServer": "0.153.4",
15
+ "grafana": "13.2.0",
16
+ "grafanaInfinity": "4.0.0",
17
+ "piSdk": "0.85.1",
18
+ "cursorSdk": "1.0.31"
19
+ },
20
+ "commands": [
21
+ "npm ci",
22
+ "npm run acceptance"
23
+ ],
24
+ "fixtureCoverage": {
25
+ "agent-sdk": "frozen queue entry through qualified capture, approved export, normalization, configurable judging, review, aggregation, and Atlas",
26
+ "openhands-agent-server": "pinned REST/WebSocket stream-final reconciliation, workspace/outcome evidence, partial capture, and retained evaluation",
27
+ "deepseek-harness": "official-client JSON-RPC composition, receipt-to-idle completion, stderr, interruption, shutdown, swaps, and retained evaluation",
28
+ "codex-app-server": "owned stdio lifecycle, full history, OTLP, interruption/failure, export, and retained evaluation; configurable workspace-write networking with explicit offline queue propagation",
29
+ "acceptance-cases": "seeded ordering, secret scanning, partial attempts, native references, abstention, disputes, denominators, and unsupported comparisons",
30
+ "pi-sdk": "frozen queue, passive hooks, durable session, partial cleanup, export, retained normalization and evaluation",
31
+ "cursor-sdk": "frozen queue, official store, callbacks/history, bounded cancellation, export, retained normalization and evaluation",
32
+ "workspace-capture": "Contained relative symlinks, empty link targets, AppleDouble files and long paths; streamed snapshots with verified tree digests and unchanged 1 GiB bound"
33
+ },
34
+ "determinism": {
35
+ "stable": [
36
+ "task, run, attempt, event, assertion, aggregate, and Atlas case identities",
37
+ "fixture, native-reference, normalized-dataset, source/cohort, archive, and package digests",
38
+ "seeded queue and review-sample ordering"
39
+ ],
40
+ "excluded": [
41
+ "native and EBO observation timestamps",
42
+ "lifecycle start/finish timestamps",
43
+ "provider timing and usage",
44
+ "temporary workspace and output paths"
45
+ ]
46
+ },
47
+ "fixtures": {
48
+ "tests/fixtures/task-packet.valid.v1.json": "ac2ef1043c0cc16bf4c21d05c1dc880ca1b4b8cc2f9ea7653328e91f0b0e2681",
49
+ "test/fixtures/agent-sdk-normalizer/complete.input.json": "7285dee088517e5de38ceda84fc7c58d9303949c7448ab3b5e10a5a2d2ebdfe3",
50
+ "test/fixtures/agent-sdk-normalizer/complete.expected.jsonl": "d32fa3a013bc179f1673f7e1a89b8a721bb8ddaf63eba5bc8a3f14f1cc148ed8",
51
+ "contracts/openhands-agent-server-v1.44.1.json": "e7a07977688a0703b15751a8a1ab17f29be45f9fdf438017e0dbcbc49cca37b0",
52
+ "contracts/openhands-agent-server-v1.46.0.json": "455cdddd2c206cb2f20245c2189b92e773657ec14d651236baa532b2dbadeb28",
53
+ "test/fixtures/openhands/v1.44.1/streamed-events.json": "d751083ba2ef94c9865117db481cc2f4933772bbbbb6b9fcf60571563715c1ea",
54
+ "test/fixtures/openhands/v1.44.1/final-events.json": "551db9904e860004d604c923692b5c5d3aaee966220492214c60003d2df415b6",
55
+ "test/fixtures/deepseek/golden-success.json": "f3867d38169bf0e28f64aba1dd60553f21cf07f41f81b4bad48261e49fa3ca88",
56
+ "test/fixtures/deepseek/golden-interrupted.json": "79e1f8404df1ee3b562d0bdbc0b2ee712ffc6d5774992b92970814ea815677b7",
57
+ "test/fixtures/deepseek/compositions/minimal/composition.json": "72fe4368e105f1bad82b7fdf8380e92c9fa689d8985f6ab91f534f0baf3eda02",
58
+ "contracts/codex-app-server-0.153.4/manifest.json": "e62281ff5e5d1cc71d07763b85e997ccc9ddbad9aa82a469da4181ee4b1890a8",
59
+ "test/fixtures/codex/legacy-0.150.1.dataset.json": "f71906b009e546afbf3b4e79396f223181382f3b3d8795e9cfcd032398a9d6bf",
60
+ "test/fixtures/behavior-assertions/abstained.json": "7f1962864f664c794925a67d93074e7b150af0f57c58041a81e062990d641197",
61
+ "test/fixtures/behavior-assertions/disputed.review.json": "450d3d9efe7feefaf7500b3ad20ab2803b412c7e53b91763125e15a83be87981",
62
+ "test/fixtures/comparison/exact.json": "cafa68e94b8d50dc308ddcfc2e73aa139c828b4ecd1e1c762bbc51bf1b44501e",
63
+ "test/fixtures/structural-observations/golden.json": "bad71a3a93524b76c0a2fdbb58e98e1e65be46cae14acf18ac89d9ba76e03e8a",
64
+ "test/atlas-fixture.ts": "9f03ce9cbcd0e5d3e3a5916866554ceb1c21f1c8712e58529499c1ea499f048d",
65
+ "test/pi.test.ts": "a00997c5617ca178e19b25b0d483cbdce8ce7c306d4c871557945b31a2e657cf",
66
+ "test/cursor-sdk.test.ts": "753094f127b11dc392ec2244c9edddabec27aa94347bb6159a292129ed2a2822",
67
+ "examples/cursor-sdk/README.md": "a2cf481123e7f3deb1c5a682ba208a775c3f91c35e1a6c1a428c517c79692618",
68
+ "examples/cursor-sdk/capture-profile.json": "d1a5787875daf5523b3783f259b891c256a904cbb10279d5be214af846305a2d",
69
+ "examples/cursor-sdk/harness.json": "2f1da6f2b17258d0ee59431f687696901ce3692589175d182f3a4bbe8dd4d689",
70
+ "examples/cursor-sdk/model.json": "ad8f31ff5d2387c02d79226679022358afafc61ba5dda73040e3f754530a230a",
71
+ "examples/cursor-sdk/native-limits.json": "40fe451f56b0863e79b42f5c85defbce678e1dc90dd248d15c430db176d71511",
72
+ "examples/cursor-sdk/native-tool-policy.json": "0fef98b36cd39de82914c5b068022c1335dadda24b7d6e7df398b2430e6a16e0"
73
+ }
74
+ }
package/release/README.md CHANGED
@@ -5,6 +5,8 @@ For downloads and published artifacts, see
5
5
 
6
6
  | Version | Changes and verification | Support boundary |
7
7
  | :--- | :--- | :--- |
8
+ | [0.2.5](0.2.5/README.md) | Preserve contained workspace symlinks and macOS AppleDouble files | [Known limitations](0.2.5/KNOWN_LIMITATIONS.md) |
9
+ | [0.2.4](0.2.4/README.md) | Stream workspace snapshots and integrity checks; support archives up to 1 GiB | [Known limitations](0.2.4/KNOWN_LIMITATIONS.md) |
8
10
  | [0.2.3](0.2.3/README.md) | Retain workspace capture errors in partial-bundle reports | [Known limitations](0.2.3/KNOWN_LIMITATIONS.md) |
9
11
  | [0.2.2](0.2.2/README.md) | Configurable Codex tool networking, enabled by default for workspace-write | [Known limitations](0.2.2/KNOWN_LIMITATIONS.md) |
10
12
  | [0.2.1](0.2.1/README.md) | Documentation redesign, Apache-2.0, and npm distribution setup | [Known limitations](0.2.1/KNOWN_LIMITATIONS.md) |