intentdna 1.9.0 → 1.9.3
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +227 -87
- package/dist/cli/commands/run-lifecycle.d.ts +61 -11
- package/dist/cli/commands/run-lifecycle.js +184 -50
- package/dist/cli/commands/run-observer.d.ts +15 -0
- package/dist/cli/commands/run-observer.js +210 -0
- package/dist/cli/commands/run.d.ts +16 -20
- package/dist/cli/commands/run.js +461 -659
- package/dist/cli/commands/sync.js +16 -12
- package/dist/cli/index.js +14 -5
- package/dist/compiler/compile.d.ts +1 -0
- package/dist/compiler/compile.js +1 -1
- package/dist/compiler/controller.d.ts +16 -0
- package/dist/compiler/controller.js +693 -0
- package/dist/compiler/input-resolver.d.ts +2 -0
- package/dist/compiler/input-resolver.js +22 -6
- package/dist/hooks/cli.d.ts +45 -0
- package/dist/hooks/cli.js +263 -31
- package/dist/hooks/host-event.d.ts +57 -0
- package/dist/hooks/host-event.js +187 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/protocol.d.ts +7 -0
- package/dist/hooks/schema.d.ts +1 -0
- package/dist/hooks/schema.js +89 -1
- package/dist/hooks/state.d.ts +23 -1
- package/dist/hooks/state.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/tools-run.d.ts +4 -0
- package/dist/mcp/tools-run.js +419 -0
- package/dist/mcp/tools-state.d.ts +1 -1
- package/dist/mcp/tools-state.js +13 -241
- package/dist/runtime/artifact-store.d.ts +146 -0
- package/dist/runtime/artifact-store.js +1436 -0
- package/dist/runtime/canonical-attempt-outcome.d.ts +12 -0
- package/dist/runtime/canonical-attempt-outcome.js +195 -0
- package/dist/runtime/canonical-json.d.ts +10 -0
- package/dist/runtime/canonical-json.js +105 -0
- package/dist/runtime/canonical-run-application.d.ts +93 -0
- package/dist/runtime/canonical-run-application.js +229 -0
- package/dist/runtime/canonical-run-service.d.ts +454 -0
- package/dist/runtime/canonical-run-service.js +4760 -0
- package/dist/runtime/canonical-runtime-composition.d.ts +22 -0
- package/dist/runtime/canonical-runtime-composition.js +34 -0
- package/dist/runtime/canonical-runtime-projection.d.ts +3 -0
- package/dist/runtime/canonical-runtime-projection.js +4 -0
- package/dist/runtime/canonical-target-compiler.d.ts +46 -0
- package/dist/runtime/canonical-target-compiler.js +514 -0
- package/dist/runtime/claude-agent-identity.d.ts +2 -0
- package/dist/runtime/claude-agent-identity.js +21 -0
- package/dist/runtime/executable-run-plan.d.ts +328 -0
- package/dist/runtime/executable-run-plan.js +1485 -0
- package/dist/runtime/execution-authority.d.ts +83 -0
- package/dist/runtime/execution-authority.js +98 -0
- package/dist/runtime/handoff-resolver.js +4 -1
- package/dist/runtime/harness-pull-adapter.d.ts +146 -0
- package/dist/runtime/harness-pull-adapter.js +315 -0
- package/dist/runtime/index.d.ts +39 -14
- package/dist/runtime/index.js +29 -7
- package/dist/runtime/local-execution-authority.d.ts +194 -0
- package/dist/runtime/local-execution-authority.js +2897 -0
- package/dist/runtime/local-execution-reconciliation.d.ts +20 -0
- package/dist/runtime/local-execution-reconciliation.js +107 -0
- package/dist/runtime/local-execution-supervisor-script.d.ts +7 -0
- package/dist/runtime/local-execution-supervisor-script.js +793 -0
- package/dist/runtime/local-provider-sandbox.d.ts +42 -0
- package/dist/runtime/local-provider-sandbox.js +154 -0
- package/dist/runtime/local-verifier-execution.d.ts +24 -0
- package/dist/runtime/local-verifier-execution.js +290 -0
- package/dist/runtime/local-windows-execution-supervisor-script.d.ts +6 -0
- package/dist/runtime/local-windows-execution-supervisor-script.js +788 -0
- package/dist/runtime/plan-store.d.ts +30 -0
- package/dist/runtime/plan-store.js +231 -0
- package/dist/runtime/process-tree.d.ts +9 -0
- package/dist/runtime/process-tree.js +97 -24
- package/dist/runtime/providers/codex.js +2 -2
- package/dist/runtime/push-driver.d.ts +184 -0
- package/dist/runtime/push-driver.js +1002 -0
- package/dist/runtime/result-store.d.ts +34 -3
- package/dist/runtime/result-store.js +1073 -2
- package/dist/runtime/run-binding.d.ts +68 -0
- package/dist/runtime/run-binding.js +278 -0
- package/dist/runtime/run-contracts.d.ts +575 -0
- package/dist/runtime/run-contracts.js +58 -7
- package/dist/runtime/run-controller.d.ts +1 -6
- package/dist/runtime/run-controller.js +0 -41
- package/dist/runtime/run-store.d.ts +85 -7
- package/dist/runtime/run-store.js +2528 -186
- package/dist/runtime/skill-adapter.d.ts +18 -7
- package/dist/runtime/skill-adapter.js +100 -742
- package/dist/runtime/structured-output-validator.d.ts +44 -0
- package/dist/runtime/structured-output-validator.js +171 -0
- package/dist/runtime/verifier-command-binding.d.ts +22 -0
- package/dist/runtime/verifier-command-binding.js +162 -0
- package/dist/runtime/verifier.d.ts +46 -6
- package/dist/runtime/verifier.js +355 -53
- package/dist/runtime/windows-job-keeper.d.ts +47 -0
- package/dist/runtime/windows-job-keeper.js +231 -0
- package/dist/runtime/worker-executor.d.ts +17 -1
- package/dist/runtime/worker-executor.js +26 -5
- package/dist/runtime/workflow-plan-adapter.d.ts +19 -0
- package/dist/runtime/workflow-plan-adapter.js +502 -6
- package/dist/runtime/workflow-runtime-manifest.d.ts +1 -0
- package/dist/runtime/workflow-runtime-manifest.js +5 -0
- package/dist/runtime/workspace-isolation.d.ts +29 -2
- package/dist/runtime/workspace-isolation.js +488 -11
- package/dist/runtime/workspace-observation.d.ts +44 -0
- package/dist/runtime/workspace-observation.js +216 -0
- package/dist/schema/controller-registry.d.ts +0 -7
- package/dist/schema/controller-registry.js +0 -8
- package/dist/schema/types.d.ts +84 -10
- package/dist/schema/types.js +2 -0
- package/dist/schema/validate.js +151 -1
- package/dist/schema/validators/controllers.d.ts +1 -1
- package/dist/schema/validators/controllers.js +126 -41
- package/dist/schema/workflow-authoring-contract.js +2 -0
- package/dist/schema/yaml-parser.js +1 -1
- package/dist/templates/flutter-rewrite-new.dna.yaml +1 -0
- package/dist/templates/flutter-rewrite.dna.yaml +87 -60
- package/dist/templates/safe-refactoring.dna.yaml +1 -0
- package/native/windows-job-keeper/README.md +80 -0
- package/native/windows-job-keeper/bin/aarch64/intentdna-windows-job-keeper.exe +0 -0
- package/native/windows-job-keeper/bin/x86_64/intentdna-windows-job-keeper.exe +0 -0
- package/native/windows-job-keeper/keeper.c +979 -0
- package/native/windows-job-keeper/manifest.json +33 -0
- package/package.json +6 -2
- package/scripts/build-windows-job-keeper.mjs +172 -0
- package/scripts/verify-windows-job-keeper.mjs +184 -0
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { chmod, link, lstat, mkdir, open, readdir, realpath, rm, } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep, } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { canonicalizeJson } from "./canonical-json.js";
|
|
8
|
+
export const DIRECTORY_ARTIFACT_SCHEMA_VERSION = "intentdna.directory_artifact.v1";
|
|
9
|
+
export const GIT_COMMIT_ARTIFACT_SCHEMA_VERSION = "intentdna.git_commit_artifact.v1";
|
|
10
|
+
export class ArtifactStoreError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ArtifactStoreError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_MAX_FILE_SIZE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_MAX_DIRECTORY_SIZE_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const DEFAULT_MAX_DIRECTORY_ENTRIES = 10_000;
|
|
21
|
+
const DEFAULT_MAX_MANIFEST_SIZE_BYTES = 8 * 1024 * 1024;
|
|
22
|
+
const DIRECTORY_MODE = 0o700;
|
|
23
|
+
const STORED_FILE_MODE = 0o444;
|
|
24
|
+
const MATERIALIZED_DIRECTORY_MODE = 0o555;
|
|
25
|
+
const MATERIALIZED_FILE_MODE = 0o444;
|
|
26
|
+
const MAX_BINDING_ID_BYTES = 4_096;
|
|
27
|
+
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
28
|
+
const GIT_SHA1_PATTERN = /^[0-9a-f]{40}$/;
|
|
29
|
+
const GIT_SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
31
|
+
function isErrno(error, code) {
|
|
32
|
+
return (error instanceof Error &&
|
|
33
|
+
"code" in error &&
|
|
34
|
+
error.code === code);
|
|
35
|
+
}
|
|
36
|
+
function assertPositiveSafeInteger(name, value) {
|
|
37
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
38
|
+
throw new ArtifactStoreError("invalid_options", `${name} must be a positive safe integer`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function assertNonEmpty(value, label) {
|
|
42
|
+
if (!value.trim()) {
|
|
43
|
+
throw new ArtifactStoreError("invalid_request", `${label} must be a non-empty string`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function resolveOptions(options) {
|
|
47
|
+
if (!options.root_directory.trim()) {
|
|
48
|
+
throw new ArtifactStoreError("invalid_options", "root_directory must be a non-empty string");
|
|
49
|
+
}
|
|
50
|
+
const resolved = {
|
|
51
|
+
root_directory: resolve(options.root_directory),
|
|
52
|
+
max_file_size_bytes: options.max_file_size_bytes ?? DEFAULT_MAX_FILE_SIZE_BYTES,
|
|
53
|
+
max_directory_size_bytes: options.max_directory_size_bytes ?? DEFAULT_MAX_DIRECTORY_SIZE_BYTES,
|
|
54
|
+
max_directory_entries: options.max_directory_entries ?? DEFAULT_MAX_DIRECTORY_ENTRIES,
|
|
55
|
+
max_manifest_size_bytes: options.max_manifest_size_bytes ?? DEFAULT_MAX_MANIFEST_SIZE_BYTES,
|
|
56
|
+
};
|
|
57
|
+
assertPositiveSafeInteger("max_file_size_bytes", resolved.max_file_size_bytes);
|
|
58
|
+
assertPositiveSafeInteger("max_directory_size_bytes", resolved.max_directory_size_bytes);
|
|
59
|
+
assertPositiveSafeInteger("max_directory_entries", resolved.max_directory_entries);
|
|
60
|
+
assertPositiveSafeInteger("max_manifest_size_bytes", resolved.max_manifest_size_bytes);
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
function sha256(bytes) {
|
|
64
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
65
|
+
}
|
|
66
|
+
function artifactHex(artifactId) {
|
|
67
|
+
if (typeof artifactId !== "string" || !SHA256_PATTERN.test(artifactId)) {
|
|
68
|
+
throw new ArtifactStoreError("invalid_request", `invalid artifact_id: ${artifactId}`);
|
|
69
|
+
}
|
|
70
|
+
return artifactId.slice("sha256:".length);
|
|
71
|
+
}
|
|
72
|
+
function assertArtifactKind(kind) {
|
|
73
|
+
if (!["file", "directory", "git_commit"].includes(kind)) {
|
|
74
|
+
throw new ArtifactStoreError("invalid_request", `invalid artifact kind: ${String(kind)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function comparePosixPath(left, right) {
|
|
78
|
+
return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
|
|
79
|
+
}
|
|
80
|
+
function isContained(root, candidate) {
|
|
81
|
+
const child = relative(root, candidate);
|
|
82
|
+
return (child === "" ||
|
|
83
|
+
(child !== ".." &&
|
|
84
|
+
!child.startsWith(`..${sep}`) &&
|
|
85
|
+
!isAbsolute(child)));
|
|
86
|
+
}
|
|
87
|
+
function signatureOf(stats) {
|
|
88
|
+
return {
|
|
89
|
+
device: stats.dev,
|
|
90
|
+
inode: stats.ino,
|
|
91
|
+
mode: stats.mode,
|
|
92
|
+
size: stats.size,
|
|
93
|
+
modified_ns: stats.mtimeNs,
|
|
94
|
+
changed_ns: stats.ctimeNs,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function signaturesEqual(left, right) {
|
|
98
|
+
return (left.device === right.device &&
|
|
99
|
+
left.inode === right.inode &&
|
|
100
|
+
left.mode === right.mode &&
|
|
101
|
+
left.size === right.size &&
|
|
102
|
+
left.modified_ns === right.modified_ns &&
|
|
103
|
+
left.changed_ns === right.changed_ns);
|
|
104
|
+
}
|
|
105
|
+
function stableObjectIdentityEqual(left, right) {
|
|
106
|
+
return (left.device === right.device &&
|
|
107
|
+
left.inode === right.inode &&
|
|
108
|
+
left.mode === right.mode &&
|
|
109
|
+
left.size === right.size &&
|
|
110
|
+
left.modified_ns === right.modified_ns);
|
|
111
|
+
}
|
|
112
|
+
function assertRegularNode(stats, path) {
|
|
113
|
+
if (stats.isSymbolicLink()) {
|
|
114
|
+
throw new ArtifactStoreError("symlink_not_allowed", `symbolic links are not capturable: ${path}`);
|
|
115
|
+
}
|
|
116
|
+
if (stats.isFile())
|
|
117
|
+
return "file";
|
|
118
|
+
if (stats.isDirectory())
|
|
119
|
+
return "directory";
|
|
120
|
+
throw new ArtifactStoreError("special_file_not_allowed", `special filesystem nodes are not capturable: ${path}`);
|
|
121
|
+
}
|
|
122
|
+
function assertManifestPath(path) {
|
|
123
|
+
if (!path ||
|
|
124
|
+
path.includes("\\") ||
|
|
125
|
+
path.includes("\0") ||
|
|
126
|
+
posix.isAbsolute(path) ||
|
|
127
|
+
posix.normalize(path) !== path ||
|
|
128
|
+
path.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
129
|
+
throw new ArtifactStoreError("artifact_corrupt", `unsafe directory artifact path: ${JSON.stringify(path)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function canonicalBytes(value) {
|
|
133
|
+
return Buffer.from(canonicalizeJson(value), "utf8");
|
|
134
|
+
}
|
|
135
|
+
function directoryIdentity(manifest) {
|
|
136
|
+
return sha256(canonicalBytes(manifest));
|
|
137
|
+
}
|
|
138
|
+
function gitCommitIdentity(stored) {
|
|
139
|
+
return sha256(canonicalBytes(stored));
|
|
140
|
+
}
|
|
141
|
+
function gitCommitPattern(objectFormat) {
|
|
142
|
+
return objectFormat === "sha1" ? GIT_SHA1_PATTERN : GIT_SHA256_PATTERN;
|
|
143
|
+
}
|
|
144
|
+
async function gitOutput(workspaceRoot, args, label, errorCode = "git_repository_invalid") {
|
|
145
|
+
try {
|
|
146
|
+
const { stdout } = await execFileAsync("git", ["-C", workspaceRoot, ...args], {
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
maxBuffer: 1024 * 1024,
|
|
149
|
+
env: {
|
|
150
|
+
PATH: process.env.PATH ?? "",
|
|
151
|
+
HOME: process.env.HOME ?? "",
|
|
152
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
153
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return stdout.trim();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new ArtifactStoreError(errorCode, `failed to resolve ${label} from the declared workspace repository`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function gitBranchRef(workspaceRoot) {
|
|
163
|
+
try {
|
|
164
|
+
const { stdout } = await execFileAsync("git", ["-C", workspaceRoot, "symbolic-ref", "--quiet", "HEAD"], {
|
|
165
|
+
encoding: "utf8",
|
|
166
|
+
maxBuffer: 1024 * 1024,
|
|
167
|
+
env: {
|
|
168
|
+
PATH: process.env.PATH ?? "",
|
|
169
|
+
HOME: process.env.HOME ?? "",
|
|
170
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
171
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
return stdout.trim();
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof Error
|
|
178
|
+
&& "code" in error
|
|
179
|
+
&& error.code === 1) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
throw new ArtifactStoreError("source_drift", "failed to resolve current Git branch from the declared workspace repository");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function exactObjectKeys(value, expected) {
|
|
186
|
+
const actual = Object.keys(value).sort();
|
|
187
|
+
const wanted = [...expected].sort();
|
|
188
|
+
return actual.length === wanted.length &&
|
|
189
|
+
actual.every((key, index) => key === wanted[index]);
|
|
190
|
+
}
|
|
191
|
+
function validateManifest(manifest, expectedSize, maxEntries) {
|
|
192
|
+
if (!Array.isArray(manifest)) {
|
|
193
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact manifest must be an array");
|
|
194
|
+
}
|
|
195
|
+
if (manifest.length > maxEntries) {
|
|
196
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact manifest exceeds the configured entry limit");
|
|
197
|
+
}
|
|
198
|
+
if (!Number.isSafeInteger(expectedSize) || expectedSize < 0) {
|
|
199
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact size_bytes must be a non-negative safe integer");
|
|
200
|
+
}
|
|
201
|
+
const directories = new Set();
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
const entries = [];
|
|
204
|
+
let totalSize = 0;
|
|
205
|
+
let previousPath = null;
|
|
206
|
+
for (const value of manifest) {
|
|
207
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
208
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact contains a non-object entry");
|
|
209
|
+
}
|
|
210
|
+
const candidate = value;
|
|
211
|
+
if (typeof candidate.path !== "string") {
|
|
212
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact entry path must be a string");
|
|
213
|
+
}
|
|
214
|
+
assertManifestPath(candidate.path);
|
|
215
|
+
if (previousPath !== null &&
|
|
216
|
+
comparePosixPath(previousPath, candidate.path) >= 0) {
|
|
217
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact entries are not uniquely POSIX-sorted");
|
|
218
|
+
}
|
|
219
|
+
previousPath = candidate.path;
|
|
220
|
+
if (seen.has(candidate.path)) {
|
|
221
|
+
throw new ArtifactStoreError("artifact_corrupt", `duplicate directory artifact path: ${candidate.path}`);
|
|
222
|
+
}
|
|
223
|
+
seen.add(candidate.path);
|
|
224
|
+
const parent = posix.dirname(candidate.path);
|
|
225
|
+
if (parent !== "." && !directories.has(parent)) {
|
|
226
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact entry has missing parent: ${candidate.path}`);
|
|
227
|
+
}
|
|
228
|
+
if (candidate.kind === "directory") {
|
|
229
|
+
if (!exactObjectKeys(candidate, ["kind", "path"])) {
|
|
230
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory entry has unknown fields: ${candidate.path}`);
|
|
231
|
+
}
|
|
232
|
+
directories.add(candidate.path);
|
|
233
|
+
entries.push({ kind: "directory", path: candidate.path });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (candidate.kind !== "file") {
|
|
237
|
+
throw new ArtifactStoreError("artifact_corrupt", `unsupported directory entry kind at ${candidate.path}`);
|
|
238
|
+
}
|
|
239
|
+
if (!exactObjectKeys(candidate, [
|
|
240
|
+
"artifact_id",
|
|
241
|
+
"kind",
|
|
242
|
+
"path",
|
|
243
|
+
"size_bytes",
|
|
244
|
+
]) ||
|
|
245
|
+
typeof candidate.artifact_id !== "string" ||
|
|
246
|
+
!SHA256_PATTERN.test(candidate.artifact_id) ||
|
|
247
|
+
!Number.isSafeInteger(candidate.size_bytes) ||
|
|
248
|
+
candidate.size_bytes < 0) {
|
|
249
|
+
throw new ArtifactStoreError("artifact_corrupt", `invalid file entry at ${candidate.path}`);
|
|
250
|
+
}
|
|
251
|
+
totalSize += candidate.size_bytes;
|
|
252
|
+
if (!Number.isSafeInteger(totalSize)) {
|
|
253
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact total size is not a safe integer");
|
|
254
|
+
}
|
|
255
|
+
entries.push({
|
|
256
|
+
kind: "file",
|
|
257
|
+
path: candidate.path,
|
|
258
|
+
artifact_id: candidate.artifact_id,
|
|
259
|
+
size_bytes: candidate.size_bytes,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (totalSize !== expectedSize) {
|
|
263
|
+
throw new ArtifactStoreError("artifact_corrupt", "directory artifact size does not match its manifest");
|
|
264
|
+
}
|
|
265
|
+
return entries;
|
|
266
|
+
}
|
|
267
|
+
async function requireLstat(path) {
|
|
268
|
+
try {
|
|
269
|
+
return await lstat(path, { bigint: true });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (isErrno(error, "ENOENT")) {
|
|
273
|
+
throw new ArtifactStoreError("source_not_found", `artifact source does not exist: ${path}`);
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function assertDirectoryPath(path, label) {
|
|
279
|
+
const stats = await lstat(path, { bigint: true });
|
|
280
|
+
if (stats.isSymbolicLink()) {
|
|
281
|
+
throw new ArtifactStoreError("symlink_not_allowed", `${label} must not be a symbolic link: ${path}`);
|
|
282
|
+
}
|
|
283
|
+
if (!stats.isDirectory()) {
|
|
284
|
+
throw new ArtifactStoreError("invalid_options", `${label} must be a directory: ${path}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function isUnsupportedDirectorySync(error) {
|
|
288
|
+
if (!(error instanceof Error) || !("code" in error))
|
|
289
|
+
return false;
|
|
290
|
+
const code = error.code;
|
|
291
|
+
return (code === "EINVAL" ||
|
|
292
|
+
code === "ENOTSUP" ||
|
|
293
|
+
code === "EISDIR" ||
|
|
294
|
+
(code === "EPERM" && process.platform === "win32"));
|
|
295
|
+
}
|
|
296
|
+
async function syncDirectory(path) {
|
|
297
|
+
let handle;
|
|
298
|
+
try {
|
|
299
|
+
handle = await open(path, constants.O_RDONLY);
|
|
300
|
+
await handle.sync();
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (!isUnsupportedDirectorySync(error))
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
await handle?.close().catch(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function stableFileRead(path, maxBytes) {
|
|
311
|
+
const before = await requireLstat(path);
|
|
312
|
+
if (assertRegularNode(before, path) !== "file") {
|
|
313
|
+
throw new ArtifactStoreError("source_kind_mismatch", `expected a file artifact source: ${path}`);
|
|
314
|
+
}
|
|
315
|
+
if (before.size > BigInt(maxBytes)) {
|
|
316
|
+
throw new ArtifactStoreError("limit_exceeded", `file artifact exceeds ${maxBytes} bytes: ${path}`);
|
|
317
|
+
}
|
|
318
|
+
let handle;
|
|
319
|
+
try {
|
|
320
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (isErrno(error, "ELOOP")) {
|
|
324
|
+
throw new ArtifactStoreError("symlink_not_allowed", `symbolic links are not capturable: ${path}`);
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const opened = await handle.stat({ bigint: true });
|
|
330
|
+
if (!opened.isFile()) {
|
|
331
|
+
throw new ArtifactStoreError("special_file_not_allowed", `source is no longer a regular file: ${path}`);
|
|
332
|
+
}
|
|
333
|
+
if (!signaturesEqual(signatureOf(before), signatureOf(opened))) {
|
|
334
|
+
throw new ArtifactStoreError("source_drift", `source changed while it was opened: ${path}`);
|
|
335
|
+
}
|
|
336
|
+
const bytes = await readBounded(handle, maxBytes, path);
|
|
337
|
+
const afterHandle = await handle.stat({ bigint: true });
|
|
338
|
+
const afterPath = await requireLstat(path);
|
|
339
|
+
if (!signaturesEqual(signatureOf(opened), signatureOf(afterHandle)) ||
|
|
340
|
+
!signaturesEqual(signatureOf(opened), signatureOf(afterPath)) ||
|
|
341
|
+
BigInt(bytes.byteLength) !== afterHandle.size) {
|
|
342
|
+
throw new ArtifactStoreError("source_drift", `source changed while it was read: ${path}`);
|
|
343
|
+
}
|
|
344
|
+
return { bytes, signature: signatureOf(afterHandle) };
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await handle.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async function readBounded(handle, maxBytes, path) {
|
|
351
|
+
const chunks = [];
|
|
352
|
+
let total = 0;
|
|
353
|
+
while (true) {
|
|
354
|
+
const remaining = maxBytes - total + 1;
|
|
355
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
|
|
356
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);
|
|
357
|
+
if (bytesRead === 0)
|
|
358
|
+
return Buffer.concat(chunks, total);
|
|
359
|
+
total += bytesRead;
|
|
360
|
+
if (total > maxBytes) {
|
|
361
|
+
throw new ArtifactStoreError("limit_exceeded", `file exceeds ${maxBytes} bytes: ${path}`);
|
|
362
|
+
}
|
|
363
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function readForMaterializationVerification(path, maxBytes) {
|
|
367
|
+
let handle;
|
|
368
|
+
try {
|
|
369
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized file cannot be opened safely: ${path}`);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
return await readBounded(handle, maxBytes, path);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized file exceeds its expected size: ${path}`);
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
await handle.close();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function validateCaptureRequest(request) {
|
|
385
|
+
assertNonEmpty(request.workspace_root, "workspace_root");
|
|
386
|
+
assertNonEmpty(request.source_path, "source_path");
|
|
387
|
+
assertNonEmpty(request.run_id, "run_id");
|
|
388
|
+
assertNonEmpty(request.activation_id, "activation_id");
|
|
389
|
+
assertNonEmpty(request.attempt_id, "attempt_id");
|
|
390
|
+
assertNonEmpty(request.output_name, "output_name");
|
|
391
|
+
assertNonEmpty(request.source_workspace_id, "source_workspace_id");
|
|
392
|
+
assertArtifactKind(request.kind);
|
|
393
|
+
if (isAbsolute(request.source_path) ||
|
|
394
|
+
request.source_path.split("/").includes("..")) {
|
|
395
|
+
throw new ArtifactStoreError("source_outside_workspace", "source_path must be relative to workspace_root");
|
|
396
|
+
}
|
|
397
|
+
if (request.source_path.includes("\0") ||
|
|
398
|
+
request.source_path.includes("\\")) {
|
|
399
|
+
throw new ArtifactStoreError("invalid_request", "source_path must be a portable relative path without NUL or backslash");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function captureRef(request, object, normalizedSourcePath, capturedAt) {
|
|
403
|
+
return {
|
|
404
|
+
capture_id: `capture:${randomUUID()}`,
|
|
405
|
+
artifact_id: object.artifact_id,
|
|
406
|
+
artifact_kind: object.kind,
|
|
407
|
+
run_id: request.run_id,
|
|
408
|
+
activation_id: request.activation_id,
|
|
409
|
+
attempt_id: request.attempt_id,
|
|
410
|
+
output_name: request.output_name,
|
|
411
|
+
source_workspace_id: request.source_workspace_id,
|
|
412
|
+
source_path: normalizedSourcePath,
|
|
413
|
+
captured_at: capturedAt,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
export class ArtifactStore {
|
|
417
|
+
options;
|
|
418
|
+
now;
|
|
419
|
+
constructor(options) {
|
|
420
|
+
this.options = resolveOptions(options);
|
|
421
|
+
this.now = options.now ?? (() => new Date());
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The caller must stop the producer and hold exclusive mutation authority for
|
|
425
|
+
* the workspace namespace until this promise settles.
|
|
426
|
+
*/
|
|
427
|
+
async capture(request) {
|
|
428
|
+
validateCaptureRequest(request);
|
|
429
|
+
if (request.kind === "git_commit") {
|
|
430
|
+
await this.ensureLayout();
|
|
431
|
+
const object = await this.captureGitCommit(request.workspace_root, request.source_path);
|
|
432
|
+
return {
|
|
433
|
+
object,
|
|
434
|
+
capture: captureRef(request, object, object.commit_oid, this.now().toISOString()),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
await this.ensureLayout();
|
|
438
|
+
const source = await this.resolveSource(request.workspace_root, request.source_path);
|
|
439
|
+
await this.assertSourceResolutionStable(source);
|
|
440
|
+
const stats = await requireLstat(source.absolute_path);
|
|
441
|
+
const actualKind = assertRegularNode(stats, source.absolute_path);
|
|
442
|
+
if (actualKind !== request.kind) {
|
|
443
|
+
throw new ArtifactStoreError("source_kind_mismatch", `expected ${request.kind}, found ${actualKind}: ${source.absolute_path}`);
|
|
444
|
+
}
|
|
445
|
+
const object = request.kind === "file"
|
|
446
|
+
? await this.captureFile(source.absolute_path)
|
|
447
|
+
: await this.captureDirectory(source.absolute_path);
|
|
448
|
+
await this.assertSourceResolutionStable(source);
|
|
449
|
+
return {
|
|
450
|
+
object,
|
|
451
|
+
capture: captureRef(request, object, source.relative_path, this.now().toISOString()),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
async captureExisting(request) {
|
|
455
|
+
validateCaptureRequest({
|
|
456
|
+
kind: request.artifact_kind,
|
|
457
|
+
workspace_root: ".",
|
|
458
|
+
source_path: request.source_path,
|
|
459
|
+
run_id: request.run_id,
|
|
460
|
+
activation_id: request.activation_id,
|
|
461
|
+
attempt_id: request.attempt_id,
|
|
462
|
+
output_name: request.output_name,
|
|
463
|
+
source_workspace_id: request.source_workspace_id,
|
|
464
|
+
});
|
|
465
|
+
const object = await this.require(request);
|
|
466
|
+
return {
|
|
467
|
+
object,
|
|
468
|
+
capture: captureRef({
|
|
469
|
+
kind: request.artifact_kind,
|
|
470
|
+
workspace_root: ".",
|
|
471
|
+
source_path: request.source_path,
|
|
472
|
+
run_id: request.run_id,
|
|
473
|
+
activation_id: request.activation_id,
|
|
474
|
+
attempt_id: request.attempt_id,
|
|
475
|
+
output_name: request.output_name,
|
|
476
|
+
source_workspace_id: request.source_workspace_id,
|
|
477
|
+
}, object, request.source_path, this.now().toISOString()),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
async resolve(locator) {
|
|
481
|
+
await this.ensureLayout();
|
|
482
|
+
assertArtifactKind(locator.artifact_kind);
|
|
483
|
+
artifactHex(locator.artifact_id);
|
|
484
|
+
if (locator.artifact_kind === "git_commit") {
|
|
485
|
+
return this.readGitCommitObject(locator.artifact_id, false);
|
|
486
|
+
}
|
|
487
|
+
if (locator.artifact_kind === "file") {
|
|
488
|
+
const bytes = await this.readFileObject(locator.artifact_id, false);
|
|
489
|
+
return bytes === null
|
|
490
|
+
? null
|
|
491
|
+
: {
|
|
492
|
+
artifact_id: locator.artifact_id,
|
|
493
|
+
kind: "file",
|
|
494
|
+
size_bytes: bytes.byteLength,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return this.readDirectoryObject(locator.artifact_id, false);
|
|
498
|
+
}
|
|
499
|
+
async require(locator) {
|
|
500
|
+
const object = await this.resolve(locator);
|
|
501
|
+
if (!object) {
|
|
502
|
+
throw new ArtifactStoreError("artifact_not_found", `${locator.artifact_kind} artifact not found: ${locator.artifact_id}`);
|
|
503
|
+
}
|
|
504
|
+
return object;
|
|
505
|
+
}
|
|
506
|
+
async readFileBytes(artifactId) {
|
|
507
|
+
await this.ensureLayout();
|
|
508
|
+
const bytes = await this.readFileObject(artifactId, true);
|
|
509
|
+
if (bytes === null) {
|
|
510
|
+
throw new ArtifactStoreError("artifact_not_found", `file artifact not found: ${artifactId}`);
|
|
511
|
+
}
|
|
512
|
+
return Buffer.from(bytes);
|
|
513
|
+
}
|
|
514
|
+
async readGitCommitBytes(artifactId) {
|
|
515
|
+
const object = await this.readGitCommitObject(artifactId, true);
|
|
516
|
+
if (!object) {
|
|
517
|
+
throw new ArtifactStoreError("artifact_not_found", `git_commit artifact not found: ${artifactId}`);
|
|
518
|
+
}
|
|
519
|
+
return canonicalBytes({
|
|
520
|
+
schema_version: GIT_COMMIT_ARTIFACT_SCHEMA_VERSION,
|
|
521
|
+
kind: object.kind,
|
|
522
|
+
repository_id: object.repository_id,
|
|
523
|
+
object_format: object.object_format,
|
|
524
|
+
commit_oid: object.commit_oid,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
async verifyGitCommitInWorkspace(locator, workspaceRoot) {
|
|
528
|
+
if (locator.artifact_kind !== "git_commit") {
|
|
529
|
+
throw new ArtifactStoreError("invalid_request", "verifyGitCommitInWorkspace requires a git_commit artifact");
|
|
530
|
+
}
|
|
531
|
+
const object = await this.require(locator);
|
|
532
|
+
if (object.kind !== "git_commit") {
|
|
533
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit locator resolved to ${object.kind}`);
|
|
534
|
+
}
|
|
535
|
+
const observed = await this.inspectGitCommit(workspaceRoot, object.commit_oid);
|
|
536
|
+
if (observed.repository_id !== object.repository_id
|
|
537
|
+
|| observed.object_format !== object.object_format
|
|
538
|
+
|| observed.commit_oid !== object.commit_oid) {
|
|
539
|
+
throw new ArtifactStoreError("source_drift", "git_commit artifact does not belong to the target workspace repository");
|
|
540
|
+
}
|
|
541
|
+
return object;
|
|
542
|
+
}
|
|
543
|
+
async verifyGitCommitProducedByAttempt(locator, workspaceRoot, baselineCommitOid, expectedBranchRef) {
|
|
544
|
+
const object = await this.verifyGitCommitInWorkspace(locator, workspaceRoot);
|
|
545
|
+
if (!gitCommitPattern(object.object_format).test(baselineCommitOid)) {
|
|
546
|
+
throw new ArtifactStoreError("source_drift", "git_commit attempt baseline is not a full lowercase commit id");
|
|
547
|
+
}
|
|
548
|
+
const baseline = await this.inspectGitCommit(workspaceRoot, baselineCommitOid);
|
|
549
|
+
if (baseline.repository_id !== object.repository_id
|
|
550
|
+
|| baseline.object_format !== object.object_format) {
|
|
551
|
+
throw new ArtifactStoreError("source_drift", "git_commit attempt baseline belongs to a different repository");
|
|
552
|
+
}
|
|
553
|
+
const currentHead = await gitOutput(workspaceRoot, ["rev-parse", "--verify", "HEAD"], "current Git HEAD", "source_drift");
|
|
554
|
+
if (currentHead !== object.commit_oid) {
|
|
555
|
+
throw new ArtifactStoreError("source_drift", "git_commit output does not match the stopped attempt workspace HEAD");
|
|
556
|
+
}
|
|
557
|
+
if (currentHead === baselineCommitOid) {
|
|
558
|
+
throw new ArtifactStoreError("source_drift", "git_commit output reuses the attempt workspace baseline commit");
|
|
559
|
+
}
|
|
560
|
+
const currentBranchRef = await gitBranchRef(workspaceRoot);
|
|
561
|
+
if (currentBranchRef !== expectedBranchRef) {
|
|
562
|
+
throw new ArtifactStoreError("source_drift", "git_commit output changed the committed attempt workspace branch");
|
|
563
|
+
}
|
|
564
|
+
await gitOutput(workspaceRoot, ["merge-base", "--is-ancestor", baselineCommitOid, currentHead], "Git commit ancestry", "source_drift");
|
|
565
|
+
return object;
|
|
566
|
+
}
|
|
567
|
+
async beforeSourceRevalidation(_sourcePath, _kind) { }
|
|
568
|
+
async ensureLayout() {
|
|
569
|
+
await mkdir(this.options.root_directory, {
|
|
570
|
+
recursive: true,
|
|
571
|
+
mode: DIRECTORY_MODE,
|
|
572
|
+
});
|
|
573
|
+
await assertDirectoryPath(this.options.root_directory, "artifact store root");
|
|
574
|
+
await chmod(this.options.root_directory, DIRECTORY_MODE);
|
|
575
|
+
const realRoot = await realpath(this.options.root_directory);
|
|
576
|
+
for (const path of [
|
|
577
|
+
join(this.options.root_directory, "objects"),
|
|
578
|
+
this.fileObjectsDirectory(),
|
|
579
|
+
this.directoryObjectsDirectory(),
|
|
580
|
+
this.gitCommitObjectsDirectory(),
|
|
581
|
+
this.temporaryDirectory(),
|
|
582
|
+
]) {
|
|
583
|
+
try {
|
|
584
|
+
await mkdir(path, { mode: DIRECTORY_MODE });
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (!isErrno(error, "EEXIST"))
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
await assertDirectoryPath(path, "artifact store directory");
|
|
591
|
+
await chmod(path, DIRECTORY_MODE);
|
|
592
|
+
if (!isContained(realRoot, await realpath(path))) {
|
|
593
|
+
throw new ArtifactStoreError("invalid_options", `artifact store directory escapes its root: ${path}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
fileObjectsDirectory() {
|
|
598
|
+
return join(this.options.root_directory, "objects", "files");
|
|
599
|
+
}
|
|
600
|
+
directoryObjectsDirectory() {
|
|
601
|
+
return join(this.options.root_directory, "objects", "directories");
|
|
602
|
+
}
|
|
603
|
+
gitCommitObjectsDirectory() {
|
|
604
|
+
return join(this.options.root_directory, "objects", "git-commits");
|
|
605
|
+
}
|
|
606
|
+
temporaryDirectory() {
|
|
607
|
+
return join(this.options.root_directory, "tmp");
|
|
608
|
+
}
|
|
609
|
+
fileObjectPath(artifactId) {
|
|
610
|
+
return join(this.fileObjectsDirectory(), artifactHex(artifactId));
|
|
611
|
+
}
|
|
612
|
+
directoryObjectPath(artifactId) {
|
|
613
|
+
return join(this.directoryObjectsDirectory(), `${artifactHex(artifactId)}.json`);
|
|
614
|
+
}
|
|
615
|
+
gitCommitObjectPath(artifactId) {
|
|
616
|
+
return join(this.gitCommitObjectsDirectory(), `${artifactHex(artifactId)}.json`);
|
|
617
|
+
}
|
|
618
|
+
async resolveSource(workspaceRoot, sourcePath) {
|
|
619
|
+
const root = resolve(workspaceRoot);
|
|
620
|
+
const candidate = resolve(root, sourcePath);
|
|
621
|
+
if (!isContained(root, candidate)) {
|
|
622
|
+
throw new ArtifactStoreError("source_outside_workspace", `artifact source escapes its workspace: ${sourcePath}`);
|
|
623
|
+
}
|
|
624
|
+
const rootStats = await requireLstat(root);
|
|
625
|
+
if (assertRegularNode(rootStats, root) !== "directory") {
|
|
626
|
+
throw new ArtifactStoreError("source_kind_mismatch", `workspace_root must be a directory: ${root}`);
|
|
627
|
+
}
|
|
628
|
+
const child = relative(root, candidate);
|
|
629
|
+
let current = root;
|
|
630
|
+
for (const segment of child.split(sep).filter(Boolean)) {
|
|
631
|
+
current = join(current, segment);
|
|
632
|
+
const component = await requireLstat(current);
|
|
633
|
+
const kind = assertRegularNode(component, current);
|
|
634
|
+
if (current !== candidate && kind !== "directory") {
|
|
635
|
+
throw new ArtifactStoreError("source_not_found", `artifact source parent is not a directory: ${current}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const [realRoot, realCandidate] = await Promise.all([
|
|
639
|
+
realpath(root),
|
|
640
|
+
realpath(candidate),
|
|
641
|
+
]);
|
|
642
|
+
if (!isContained(realRoot, realCandidate)) {
|
|
643
|
+
throw new ArtifactStoreError("source_outside_workspace", `artifact source resolves outside its workspace: ${sourcePath}`);
|
|
644
|
+
}
|
|
645
|
+
const normalized = relative(root, candidate).split(sep).join("/") || ".";
|
|
646
|
+
return {
|
|
647
|
+
workspace_path: root,
|
|
648
|
+
real_workspace_path: realRoot,
|
|
649
|
+
lexical_path: candidate,
|
|
650
|
+
absolute_path: realCandidate,
|
|
651
|
+
relative_path: normalized,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
async assertSourceResolutionStable(source) {
|
|
655
|
+
try {
|
|
656
|
+
const workspaceStats = await lstat(source.workspace_path, { bigint: true });
|
|
657
|
+
if (assertRegularNode(workspaceStats, source.workspace_path) !== "directory") {
|
|
658
|
+
throw new Error("workspace root is no longer a directory");
|
|
659
|
+
}
|
|
660
|
+
const child = relative(source.workspace_path, source.lexical_path);
|
|
661
|
+
let current = source.workspace_path;
|
|
662
|
+
for (const segment of child.split(sep).filter(Boolean)) {
|
|
663
|
+
current = join(current, segment);
|
|
664
|
+
const stats = await lstat(current, { bigint: true });
|
|
665
|
+
const kind = assertRegularNode(stats, current);
|
|
666
|
+
if (current !== source.lexical_path && kind !== "directory") {
|
|
667
|
+
throw new Error(`source parent is no longer a directory: ${current}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const [realWorkspace, realSource] = await Promise.all([
|
|
671
|
+
realpath(source.workspace_path),
|
|
672
|
+
realpath(source.lexical_path),
|
|
673
|
+
]);
|
|
674
|
+
if (realWorkspace !== source.real_workspace_path ||
|
|
675
|
+
realSource !== source.absolute_path ||
|
|
676
|
+
!isContained(realWorkspace, realSource)) {
|
|
677
|
+
throw new Error("source realpath changed");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// Node has no openat-style descriptor-relative traversal. These repeated
|
|
682
|
+
// checks detect ordinary drift; the service must also keep the attempt
|
|
683
|
+
// workspace namespace quiescent for the entire capture transaction.
|
|
684
|
+
throw new ArtifactStoreError("source_drift", `artifact source resolution changed during capture: ${source.relative_path}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async captureGitCommit(workspaceRoot, commitOid) {
|
|
688
|
+
const observed = await this.inspectGitCommit(workspaceRoot, commitOid);
|
|
689
|
+
const stored = {
|
|
690
|
+
schema_version: GIT_COMMIT_ARTIFACT_SCHEMA_VERSION,
|
|
691
|
+
kind: "git_commit",
|
|
692
|
+
repository_id: observed.repository_id,
|
|
693
|
+
object_format: observed.object_format,
|
|
694
|
+
commit_oid: observed.commit_oid,
|
|
695
|
+
};
|
|
696
|
+
const bytes = canonicalBytes(stored);
|
|
697
|
+
const artifactId = gitCommitIdentity(stored);
|
|
698
|
+
await this.publishNoReplace(bytes, this.gitCommitObjectPath(artifactId));
|
|
699
|
+
return {
|
|
700
|
+
artifact_id: artifactId,
|
|
701
|
+
kind: "git_commit",
|
|
702
|
+
size_bytes: bytes.byteLength,
|
|
703
|
+
repository_id: observed.repository_id,
|
|
704
|
+
object_format: observed.object_format,
|
|
705
|
+
commit_oid: observed.commit_oid,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
async inspectGitCommit(workspaceRoot, commitOid) {
|
|
709
|
+
const rootStats = await requireLstat(workspaceRoot);
|
|
710
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
711
|
+
throw new ArtifactStoreError("git_repository_invalid", "git_commit workspace_root must be a real directory");
|
|
712
|
+
}
|
|
713
|
+
const realWorkspaceRoot = await realpath(workspaceRoot);
|
|
714
|
+
const [worktreeRootValue, commonDirectoryValue, objectFormatValue] = await Promise.all([
|
|
715
|
+
gitOutput(realWorkspaceRoot, ["rev-parse", "--show-toplevel"], "Git worktree root"),
|
|
716
|
+
gitOutput(realWorkspaceRoot, ["rev-parse", "--git-common-dir"], "Git common directory"),
|
|
717
|
+
gitOutput(realWorkspaceRoot, ["rev-parse", "--show-object-format"], "Git object format"),
|
|
718
|
+
]);
|
|
719
|
+
const realWorktreeRoot = await realpath(worktreeRootValue);
|
|
720
|
+
if (!isContained(realWorktreeRoot, realWorkspaceRoot)) {
|
|
721
|
+
throw new ArtifactStoreError("git_repository_invalid", "git_commit workspace_root is outside the resolved Git worktree");
|
|
722
|
+
}
|
|
723
|
+
if (objectFormatValue !== "sha1" && objectFormatValue !== "sha256") {
|
|
724
|
+
throw new ArtifactStoreError("git_repository_invalid", `unsupported Git object format: ${objectFormatValue}`);
|
|
725
|
+
}
|
|
726
|
+
const objectFormat = objectFormatValue;
|
|
727
|
+
if (!gitCommitPattern(objectFormat).test(commitOid)) {
|
|
728
|
+
throw new ArtifactStoreError("git_commit_invalid", `git_commit source_path must be a full lowercase ${objectFormat} commit id`);
|
|
729
|
+
}
|
|
730
|
+
const resolvedCommit = await gitOutput(realWorkspaceRoot, ["rev-parse", "--verify", `${commitOid}^{commit}`], "Git commit object", "git_commit_invalid");
|
|
731
|
+
if (resolvedCommit !== commitOid) {
|
|
732
|
+
throw new ArtifactStoreError("git_commit_invalid", "git_commit source_path must identify the exact commit object without indirection");
|
|
733
|
+
}
|
|
734
|
+
const objectType = await gitOutput(realWorkspaceRoot, ["cat-file", "-t", commitOid], "Git object type", "git_commit_invalid");
|
|
735
|
+
if (objectType !== "commit") {
|
|
736
|
+
throw new ArtifactStoreError("git_commit_invalid", "git_commit source_path does not identify a commit object");
|
|
737
|
+
}
|
|
738
|
+
const commonDirectory = isAbsolute(commonDirectoryValue)
|
|
739
|
+
? commonDirectoryValue
|
|
740
|
+
: resolve(realWorkspaceRoot, commonDirectoryValue);
|
|
741
|
+
let realCommonDirectory;
|
|
742
|
+
try {
|
|
743
|
+
realCommonDirectory = await realpath(commonDirectory);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
throw new ArtifactStoreError("git_repository_invalid", "resolved Git common directory is unavailable");
|
|
747
|
+
}
|
|
748
|
+
const repositoryId = sha256(canonicalBytes({
|
|
749
|
+
object_format: objectFormat,
|
|
750
|
+
common_directory: realCommonDirectory,
|
|
751
|
+
}));
|
|
752
|
+
return {
|
|
753
|
+
repository_id: repositoryId,
|
|
754
|
+
object_format: objectFormat,
|
|
755
|
+
commit_oid: commitOid,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
async captureFile(path) {
|
|
759
|
+
const first = await stableFileRead(path, this.options.max_file_size_bytes);
|
|
760
|
+
await this.beforeSourceRevalidation(path, "file");
|
|
761
|
+
let second;
|
|
762
|
+
try {
|
|
763
|
+
second = await stableFileRead(path, this.options.max_file_size_bytes);
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
throw new ArtifactStoreError("source_drift", `file artifact source changed during capture: ${path}`);
|
|
767
|
+
}
|
|
768
|
+
if (!signaturesEqual(first.signature, second.signature) ||
|
|
769
|
+
!first.bytes.equals(second.bytes)) {
|
|
770
|
+
throw new ArtifactStoreError("source_drift", `file artifact source changed during capture: ${path}`);
|
|
771
|
+
}
|
|
772
|
+
const artifactId = sha256(first.bytes);
|
|
773
|
+
await this.publishNoReplace(first.bytes, this.fileObjectPath(artifactId));
|
|
774
|
+
return {
|
|
775
|
+
artifact_id: artifactId,
|
|
776
|
+
kind: "file",
|
|
777
|
+
size_bytes: first.bytes.byteLength,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
async captureDirectory(path) {
|
|
781
|
+
const first = await this.scanDirectory(path, true);
|
|
782
|
+
await this.beforeSourceRevalidation(path, "directory");
|
|
783
|
+
let second;
|
|
784
|
+
try {
|
|
785
|
+
second = await this.scanDirectory(path, false);
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
if (error instanceof ArtifactStoreError && error.code === "limit_exceeded") {
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
throw new ArtifactStoreError("source_drift", `directory artifact source changed during capture: ${path}`);
|
|
792
|
+
}
|
|
793
|
+
if (canonicalizeJson(first.manifest) !== canonicalizeJson(second.manifest) ||
|
|
794
|
+
!this.signatureMapsEqual(first.signatures, second.signatures)) {
|
|
795
|
+
throw new ArtifactStoreError("source_drift", `directory artifact source changed during capture: ${path}`);
|
|
796
|
+
}
|
|
797
|
+
const artifactId = directoryIdentity(first.manifest);
|
|
798
|
+
const stored = {
|
|
799
|
+
schema_version: DIRECTORY_ARTIFACT_SCHEMA_VERSION,
|
|
800
|
+
kind: "directory",
|
|
801
|
+
size_bytes: first.size_bytes,
|
|
802
|
+
manifest: first.manifest,
|
|
803
|
+
};
|
|
804
|
+
const storedBytes = canonicalBytes(stored);
|
|
805
|
+
if (storedBytes.byteLength > this.options.max_manifest_size_bytes) {
|
|
806
|
+
throw new ArtifactStoreError("limit_exceeded", "directory artifact manifest exceeds the configured size limit");
|
|
807
|
+
}
|
|
808
|
+
await this.publishNoReplace(storedBytes, this.directoryObjectPath(artifactId));
|
|
809
|
+
return {
|
|
810
|
+
artifact_id: artifactId,
|
|
811
|
+
kind: "directory",
|
|
812
|
+
size_bytes: first.size_bytes,
|
|
813
|
+
manifest: first.manifest,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
async scanDirectory(root, publishFiles) {
|
|
817
|
+
const entries = [];
|
|
818
|
+
const signatures = new Map();
|
|
819
|
+
let sizeBytes = 0;
|
|
820
|
+
const walk = async (directory, relativeDirectory) => {
|
|
821
|
+
const before = await requireLstat(directory);
|
|
822
|
+
if (assertRegularNode(before, directory) !== "directory") {
|
|
823
|
+
throw new ArtifactStoreError("source_kind_mismatch", `expected a directory artifact source: ${directory}`);
|
|
824
|
+
}
|
|
825
|
+
const names = await readdir(directory);
|
|
826
|
+
names.sort(comparePosixPath);
|
|
827
|
+
for (const name of names) {
|
|
828
|
+
if (name.includes("\\")) {
|
|
829
|
+
throw new ArtifactStoreError("special_file_not_allowed", `backslash is not allowed in portable artifact paths: ${join(directory, name)}`);
|
|
830
|
+
}
|
|
831
|
+
const absolutePath = join(directory, name);
|
|
832
|
+
const relativePath = relativeDirectory
|
|
833
|
+
? `${relativeDirectory}/${name}`
|
|
834
|
+
: name;
|
|
835
|
+
const nodeStats = await requireLstat(absolutePath);
|
|
836
|
+
const kind = assertRegularNode(nodeStats, absolutePath);
|
|
837
|
+
if (entries.length >= this.options.max_directory_entries) {
|
|
838
|
+
throw new ArtifactStoreError("limit_exceeded", `directory artifact exceeds ${this.options.max_directory_entries} entries`);
|
|
839
|
+
}
|
|
840
|
+
if (kind === "directory") {
|
|
841
|
+
entries.push({ kind: "directory", path: relativePath });
|
|
842
|
+
await walk(absolutePath, relativePath);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const file = await stableFileRead(absolutePath, this.options.max_file_size_bytes);
|
|
846
|
+
const artifactId = sha256(file.bytes);
|
|
847
|
+
if (publishFiles) {
|
|
848
|
+
await this.publishNoReplace(file.bytes, this.fileObjectPath(artifactId));
|
|
849
|
+
}
|
|
850
|
+
sizeBytes += file.bytes.byteLength;
|
|
851
|
+
if (!Number.isSafeInteger(sizeBytes) ||
|
|
852
|
+
sizeBytes > this.options.max_directory_size_bytes) {
|
|
853
|
+
throw new ArtifactStoreError("limit_exceeded", `directory artifact exceeds ${this.options.max_directory_size_bytes} bytes`);
|
|
854
|
+
}
|
|
855
|
+
entries.push({
|
|
856
|
+
kind: "file",
|
|
857
|
+
path: relativePath,
|
|
858
|
+
artifact_id: artifactId,
|
|
859
|
+
size_bytes: file.bytes.byteLength,
|
|
860
|
+
});
|
|
861
|
+
signatures.set(relativePath, file.signature);
|
|
862
|
+
}
|
|
863
|
+
const after = await requireLstat(directory);
|
|
864
|
+
if (!signaturesEqual(signatureOf(before), signatureOf(after))) {
|
|
865
|
+
throw new ArtifactStoreError("source_drift", `directory changed while it was read: ${directory}`);
|
|
866
|
+
}
|
|
867
|
+
signatures.set(relativeDirectory || ".", signatureOf(after));
|
|
868
|
+
};
|
|
869
|
+
await walk(root, "");
|
|
870
|
+
entries.sort((left, right) => comparePosixPath(left.path, right.path));
|
|
871
|
+
return { manifest: entries, size_bytes: sizeBytes, signatures };
|
|
872
|
+
}
|
|
873
|
+
signatureMapsEqual(left, right) {
|
|
874
|
+
if (left.size !== right.size)
|
|
875
|
+
return false;
|
|
876
|
+
for (const [path, signature] of left) {
|
|
877
|
+
const other = right.get(path);
|
|
878
|
+
if (!other || !signaturesEqual(signature, other))
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
883
|
+
async publishNoReplace(bytes, target) {
|
|
884
|
+
const temporary = join(this.temporaryDirectory(), `${randomUUID()}.tmp`);
|
|
885
|
+
let handle;
|
|
886
|
+
try {
|
|
887
|
+
handle = await open(temporary, "wx", 0o600);
|
|
888
|
+
await handle.writeFile(bytes);
|
|
889
|
+
await handle.chmod(STORED_FILE_MODE);
|
|
890
|
+
await handle.sync();
|
|
891
|
+
await handle.close();
|
|
892
|
+
handle = undefined;
|
|
893
|
+
while (true) {
|
|
894
|
+
try {
|
|
895
|
+
await link(temporary, target);
|
|
896
|
+
await syncDirectory(dirname(target));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
if (!isErrno(error, "EEXIST"))
|
|
901
|
+
throw error;
|
|
902
|
+
await syncDirectory(dirname(target));
|
|
903
|
+
let existingStats;
|
|
904
|
+
try {
|
|
905
|
+
existingStats = await lstat(target, { bigint: true });
|
|
906
|
+
}
|
|
907
|
+
catch (readError) {
|
|
908
|
+
if (isErrno(readError, "ENOENT"))
|
|
909
|
+
continue;
|
|
910
|
+
throw readError;
|
|
911
|
+
}
|
|
912
|
+
if (!existingStats.isFile() ||
|
|
913
|
+
existingStats.isSymbolicLink() ||
|
|
914
|
+
(existingStats.mode & 146n) !== 0n) {
|
|
915
|
+
throw new ArtifactStoreError("artifact_corrupt", `artifact object path is not a regular file: ${target}`);
|
|
916
|
+
}
|
|
917
|
+
let existingHandle;
|
|
918
|
+
try {
|
|
919
|
+
existingHandle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
920
|
+
}
|
|
921
|
+
catch (readError) {
|
|
922
|
+
if (isErrno(readError, "ENOENT"))
|
|
923
|
+
continue;
|
|
924
|
+
throw new ArtifactStoreError("artifact_corrupt", `artifact object cannot be opened safely: ${target}`);
|
|
925
|
+
}
|
|
926
|
+
let existing;
|
|
927
|
+
try {
|
|
928
|
+
const openedBefore = await existingHandle.stat({ bigint: true });
|
|
929
|
+
if (!openedBefore.isFile() ||
|
|
930
|
+
(openedBefore.mode & 146n) !== 0n ||
|
|
931
|
+
!stableObjectIdentityEqual(signatureOf(existingStats), signatureOf(openedBefore))) {
|
|
932
|
+
throw new ArtifactStoreError("artifact_corrupt", `artifact object changed before CAS validation: ${target}`);
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
existing = await readBounded(existingHandle, bytes.byteLength, target);
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
throw new ArtifactStoreError("artifact_corrupt", `artifact object conflicts with immutable content: ${target}`);
|
|
939
|
+
}
|
|
940
|
+
const openedAfter = await existingHandle.stat({ bigint: true });
|
|
941
|
+
let afterPath;
|
|
942
|
+
try {
|
|
943
|
+
afterPath = await lstat(target, { bigint: true });
|
|
944
|
+
}
|
|
945
|
+
catch (readError) {
|
|
946
|
+
if (isErrno(readError, "ENOENT"))
|
|
947
|
+
continue;
|
|
948
|
+
throw readError;
|
|
949
|
+
}
|
|
950
|
+
if (!afterPath.isFile() ||
|
|
951
|
+
afterPath.isSymbolicLink() ||
|
|
952
|
+
(afterPath.mode & 146n) !== 0n ||
|
|
953
|
+
!stableObjectIdentityEqual(signatureOf(openedBefore), signatureOf(openedAfter)) ||
|
|
954
|
+
!stableObjectIdentityEqual(signatureOf(openedAfter), signatureOf(afterPath))) {
|
|
955
|
+
throw new ArtifactStoreError("artifact_corrupt", `artifact object changed during CAS validation: ${target}`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
await existingHandle.close();
|
|
960
|
+
}
|
|
961
|
+
if (!existing.equals(bytes)) {
|
|
962
|
+
throw new ArtifactStoreError("artifact_corrupt", `artifact object conflicts with immutable content: ${target}`);
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
finally {
|
|
969
|
+
await handle?.close().catch(() => undefined);
|
|
970
|
+
await rm(temporary, { force: true });
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
async readFileObject(artifactId, required) {
|
|
974
|
+
const path = this.fileObjectPath(artifactId);
|
|
975
|
+
let stats;
|
|
976
|
+
try {
|
|
977
|
+
stats = await lstat(path, { bigint: true });
|
|
978
|
+
}
|
|
979
|
+
catch (error) {
|
|
980
|
+
if (isErrno(error, "ENOENT") && !required)
|
|
981
|
+
return null;
|
|
982
|
+
if (isErrno(error, "ENOENT")) {
|
|
983
|
+
throw new ArtifactStoreError("artifact_not_found", `file artifact not found: ${artifactId}`);
|
|
984
|
+
}
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
if (!stats.isFile() ||
|
|
988
|
+
stats.isSymbolicLink() ||
|
|
989
|
+
(stats.mode & 146n) !== 0n) {
|
|
990
|
+
throw new ArtifactStoreError("artifact_corrupt", `file artifact object is not a regular file: ${artifactId}`);
|
|
991
|
+
}
|
|
992
|
+
if (stats.size > BigInt(this.options.max_file_size_bytes)) {
|
|
993
|
+
throw new ArtifactStoreError("artifact_corrupt", `file artifact exceeds the configured size limit: ${artifactId}`);
|
|
994
|
+
}
|
|
995
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
996
|
+
let bytes;
|
|
997
|
+
try {
|
|
998
|
+
bytes = await readBounded(handle, this.options.max_file_size_bytes, path);
|
|
999
|
+
const after = await handle.stat({ bigint: true });
|
|
1000
|
+
if (!signaturesEqual(signatureOf(stats), signatureOf(after))) {
|
|
1001
|
+
throw new ArtifactStoreError("artifact_corrupt", `file artifact changed while it was read: ${artifactId}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
finally {
|
|
1005
|
+
await handle.close();
|
|
1006
|
+
}
|
|
1007
|
+
if (sha256(bytes) !== artifactId) {
|
|
1008
|
+
throw new ArtifactStoreError("artifact_corrupt", `file artifact digest mismatch: ${artifactId}`);
|
|
1009
|
+
}
|
|
1010
|
+
return bytes;
|
|
1011
|
+
}
|
|
1012
|
+
async readDirectoryObject(artifactId, required) {
|
|
1013
|
+
const path = this.directoryObjectPath(artifactId);
|
|
1014
|
+
let stats;
|
|
1015
|
+
try {
|
|
1016
|
+
stats = await lstat(path, { bigint: true });
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
if (isErrno(error, "ENOENT") && !required)
|
|
1020
|
+
return null;
|
|
1021
|
+
if (isErrno(error, "ENOENT")) {
|
|
1022
|
+
throw new ArtifactStoreError("artifact_not_found", `directory artifact not found: ${artifactId}`);
|
|
1023
|
+
}
|
|
1024
|
+
throw error;
|
|
1025
|
+
}
|
|
1026
|
+
if (!stats.isFile() ||
|
|
1027
|
+
stats.isSymbolicLink() ||
|
|
1028
|
+
(stats.mode & 146n) !== 0n) {
|
|
1029
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact object is not a regular file: ${artifactId}`);
|
|
1030
|
+
}
|
|
1031
|
+
if (stats.size > BigInt(this.options.max_manifest_size_bytes)) {
|
|
1032
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact manifest exceeds the configured size limit: ${artifactId}`);
|
|
1033
|
+
}
|
|
1034
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1035
|
+
let bytes;
|
|
1036
|
+
try {
|
|
1037
|
+
bytes = await readBounded(handle, this.options.max_manifest_size_bytes, path);
|
|
1038
|
+
const after = await handle.stat({ bigint: true });
|
|
1039
|
+
if (!signaturesEqual(signatureOf(stats), signatureOf(after))) {
|
|
1040
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact changed while it was read: ${artifactId}`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
finally {
|
|
1044
|
+
await handle.close();
|
|
1045
|
+
}
|
|
1046
|
+
let parsed;
|
|
1047
|
+
try {
|
|
1048
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact manifest is not valid JSON: ${artifactId}`);
|
|
1052
|
+
}
|
|
1053
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1054
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact wrapper is invalid: ${artifactId}`);
|
|
1055
|
+
}
|
|
1056
|
+
const candidate = parsed;
|
|
1057
|
+
if (!exactObjectKeys(candidate, [
|
|
1058
|
+
"kind",
|
|
1059
|
+
"manifest",
|
|
1060
|
+
"schema_version",
|
|
1061
|
+
"size_bytes",
|
|
1062
|
+
]) ||
|
|
1063
|
+
candidate.schema_version !== DIRECTORY_ARTIFACT_SCHEMA_VERSION ||
|
|
1064
|
+
candidate.kind !== "directory") {
|
|
1065
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact wrapper schema is invalid: ${artifactId}`);
|
|
1066
|
+
}
|
|
1067
|
+
let canonical;
|
|
1068
|
+
try {
|
|
1069
|
+
canonical = canonicalBytes(parsed);
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact wrapper is not canonical JSON: ${artifactId}`);
|
|
1073
|
+
}
|
|
1074
|
+
if (!bytes.equals(canonical)) {
|
|
1075
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact wrapper bytes are not canonical: ${artifactId}`);
|
|
1076
|
+
}
|
|
1077
|
+
const manifest = validateManifest(candidate.manifest, candidate.size_bytes, this.options.max_directory_entries);
|
|
1078
|
+
if (candidate.size_bytes > this.options.max_directory_size_bytes) {
|
|
1079
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact exceeds the configured size limit: ${artifactId}`);
|
|
1080
|
+
}
|
|
1081
|
+
if (directoryIdentity(manifest) !== artifactId) {
|
|
1082
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact digest mismatch: ${artifactId}`);
|
|
1083
|
+
}
|
|
1084
|
+
for (const entry of manifest) {
|
|
1085
|
+
if (entry.kind !== "file")
|
|
1086
|
+
continue;
|
|
1087
|
+
let file;
|
|
1088
|
+
try {
|
|
1089
|
+
file = await this.readFileBytes(entry.artifact_id);
|
|
1090
|
+
}
|
|
1091
|
+
catch (error) {
|
|
1092
|
+
if (error instanceof ArtifactStoreError) {
|
|
1093
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact references an invalid file object at ${entry.path}`);
|
|
1094
|
+
}
|
|
1095
|
+
throw error;
|
|
1096
|
+
}
|
|
1097
|
+
if (file.byteLength !== entry.size_bytes) {
|
|
1098
|
+
throw new ArtifactStoreError("artifact_corrupt", `directory artifact file size mismatch at ${entry.path}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
artifact_id: artifactId,
|
|
1103
|
+
kind: "directory",
|
|
1104
|
+
size_bytes: candidate.size_bytes,
|
|
1105
|
+
manifest,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
async readGitCommitObject(artifactId, required) {
|
|
1109
|
+
const path = this.gitCommitObjectPath(artifactId);
|
|
1110
|
+
let stats;
|
|
1111
|
+
try {
|
|
1112
|
+
stats = await lstat(path, { bigint: true });
|
|
1113
|
+
}
|
|
1114
|
+
catch (error) {
|
|
1115
|
+
if (isErrno(error, "ENOENT") && !required)
|
|
1116
|
+
return null;
|
|
1117
|
+
if (isErrno(error, "ENOENT")) {
|
|
1118
|
+
throw new ArtifactStoreError("artifact_not_found", `git_commit artifact not found: ${artifactId}`);
|
|
1119
|
+
}
|
|
1120
|
+
throw error;
|
|
1121
|
+
}
|
|
1122
|
+
if (!stats.isFile()
|
|
1123
|
+
|| stats.isSymbolicLink()
|
|
1124
|
+
|| (stats.mode & 146n) !== 0n) {
|
|
1125
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit artifact object is not a regular file: ${artifactId}`);
|
|
1126
|
+
}
|
|
1127
|
+
if (stats.size > BigInt(this.options.max_manifest_size_bytes)) {
|
|
1128
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit artifact exceeds the configured size limit: ${artifactId}`);
|
|
1129
|
+
}
|
|
1130
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1131
|
+
let bytes;
|
|
1132
|
+
try {
|
|
1133
|
+
bytes = await readBounded(handle, this.options.max_manifest_size_bytes, path);
|
|
1134
|
+
const after = await handle.stat({ bigint: true });
|
|
1135
|
+
if (!signaturesEqual(signatureOf(stats), signatureOf(after))) {
|
|
1136
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit artifact changed while it was read: ${artifactId}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
finally {
|
|
1140
|
+
await handle.close();
|
|
1141
|
+
}
|
|
1142
|
+
let parsed;
|
|
1143
|
+
try {
|
|
1144
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit artifact wrapper is not JSON: ${artifactId}`);
|
|
1148
|
+
}
|
|
1149
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1150
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit artifact wrapper schema is invalid: ${artifactId}`);
|
|
1151
|
+
}
|
|
1152
|
+
const candidate = parsed;
|
|
1153
|
+
if (!exactObjectKeys(candidate, [
|
|
1154
|
+
"commit_oid",
|
|
1155
|
+
"kind",
|
|
1156
|
+
"object_format",
|
|
1157
|
+
"repository_id",
|
|
1158
|
+
"schema_version",
|
|
1159
|
+
])
|
|
1160
|
+
|| candidate.schema_version !== GIT_COMMIT_ARTIFACT_SCHEMA_VERSION
|
|
1161
|
+
|| candidate.kind !== "git_commit"
|
|
1162
|
+
|| (candidate.object_format !== "sha1" && candidate.object_format !== "sha256")
|
|
1163
|
+
|| typeof candidate.repository_id !== "string"
|
|
1164
|
+
|| !SHA256_PATTERN.test(candidate.repository_id)
|
|
1165
|
+
|| typeof candidate.commit_oid !== "string"
|
|
1166
|
+
|| !gitCommitPattern(candidate.object_format).test(candidate.commit_oid)) {
|
|
1167
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit artifact wrapper schema is invalid: ${artifactId}`);
|
|
1168
|
+
}
|
|
1169
|
+
const stored = candidate;
|
|
1170
|
+
const canonical = canonicalBytes(stored);
|
|
1171
|
+
if (!bytes.equals(canonical) || gitCommitIdentity(stored) !== artifactId) {
|
|
1172
|
+
throw new ArtifactStoreError("artifact_corrupt", `git_commit artifact digest mismatch: ${artifactId}`);
|
|
1173
|
+
}
|
|
1174
|
+
return {
|
|
1175
|
+
artifact_id: artifactId,
|
|
1176
|
+
kind: "git_commit",
|
|
1177
|
+
size_bytes: bytes.byteLength,
|
|
1178
|
+
repository_id: stored.repository_id,
|
|
1179
|
+
object_format: stored.object_format,
|
|
1180
|
+
commit_oid: stored.commit_oid,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
export class ArtifactMaterializer {
|
|
1185
|
+
store;
|
|
1186
|
+
constructor(store) {
|
|
1187
|
+
this.store = store;
|
|
1188
|
+
}
|
|
1189
|
+
async materialize(request) {
|
|
1190
|
+
return this.materializeWithExistingTargetPolicy(request, "conflict");
|
|
1191
|
+
}
|
|
1192
|
+
/** Reuses an existing target only after verifying the complete immutable artifact. */
|
|
1193
|
+
async materializeReplaySafe(request) {
|
|
1194
|
+
return this.materializeWithExistingTargetPolicy(request, "reuse_if_identical");
|
|
1195
|
+
}
|
|
1196
|
+
async materializeWithExistingTargetPolicy(request, existingTargetPolicy) {
|
|
1197
|
+
assertArtifactKind(request.artifact_kind);
|
|
1198
|
+
if (!request.destination_root.trim()) {
|
|
1199
|
+
throw new ArtifactStoreError("invalid_request", "destination_root must be a non-empty string");
|
|
1200
|
+
}
|
|
1201
|
+
if (!request.binding_id.trim()
|
|
1202
|
+
|| Buffer.byteLength(request.binding_id, "utf8") > MAX_BINDING_ID_BYTES
|
|
1203
|
+
|| /[\u0000-\u001f\u007f]/u.test(request.binding_id)) {
|
|
1204
|
+
throw new ArtifactStoreError("invalid_request", "binding_id must be a non-empty printable identifier within the size limit");
|
|
1205
|
+
}
|
|
1206
|
+
if (request.artifact_kind === "git_commit") {
|
|
1207
|
+
throw new ArtifactStoreError("unsupported_artifact_kind", "git_commit artifacts are not supported in S1");
|
|
1208
|
+
}
|
|
1209
|
+
const destinationRoot = resolve(request.destination_root);
|
|
1210
|
+
await mkdir(destinationRoot, { recursive: true, mode: DIRECTORY_MODE });
|
|
1211
|
+
const rootStats = await lstat(destinationRoot, { bigint: true });
|
|
1212
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
1213
|
+
throw new ArtifactStoreError("unsafe_destination", `materialization root must be a real directory: ${destinationRoot}`);
|
|
1214
|
+
}
|
|
1215
|
+
await chmod(destinationRoot, DIRECTORY_MODE);
|
|
1216
|
+
const realRoot = await realpath(destinationRoot);
|
|
1217
|
+
const bindingPathSegment = `binding-${createHash("sha256")
|
|
1218
|
+
.update(request.binding_id, "utf8")
|
|
1219
|
+
.digest("hex")}`;
|
|
1220
|
+
const target = join(realRoot, bindingPathSegment);
|
|
1221
|
+
if (!isContained(realRoot, target)) {
|
|
1222
|
+
throw new ArtifactStoreError("unsafe_destination", `materialized path escapes its destination root: ${target}`);
|
|
1223
|
+
}
|
|
1224
|
+
const object = await this.store.require(request);
|
|
1225
|
+
if (object.kind === "file") {
|
|
1226
|
+
await this.materializeFile(object, target, existingTargetPolicy);
|
|
1227
|
+
}
|
|
1228
|
+
else if (object.kind === "directory") {
|
|
1229
|
+
await this.materializeDirectory(object, target, existingTargetPolicy);
|
|
1230
|
+
}
|
|
1231
|
+
else {
|
|
1232
|
+
throw new ArtifactStoreError("unsupported_artifact_kind", "git_commit artifacts are delivered as typed identities, not filesystem materializations");
|
|
1233
|
+
}
|
|
1234
|
+
return {
|
|
1235
|
+
artifact_id: object.artifact_id,
|
|
1236
|
+
artifact_kind: object.kind,
|
|
1237
|
+
materialized_path: target,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
async beforePostCopyVerification(_materializedPath, _object) { }
|
|
1241
|
+
async materializeFile(object, target, existingTargetPolicy) {
|
|
1242
|
+
const bytes = await this.store.readFileBytes(object.artifact_id);
|
|
1243
|
+
let handle;
|
|
1244
|
+
try {
|
|
1245
|
+
handle = await open(target, "wx", MATERIALIZED_FILE_MODE);
|
|
1246
|
+
}
|
|
1247
|
+
catch (error) {
|
|
1248
|
+
if (isErrno(error, "EEXIST")) {
|
|
1249
|
+
if (existingTargetPolicy === "reuse_if_identical") {
|
|
1250
|
+
await this.verifyExistingMaterialization(target, object);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
throw new ArtifactStoreError("materialization_conflict", `materialized artifact path already exists: ${target}`);
|
|
1254
|
+
}
|
|
1255
|
+
throw error;
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
try {
|
|
1259
|
+
await handle.writeFile(bytes);
|
|
1260
|
+
await handle.sync();
|
|
1261
|
+
}
|
|
1262
|
+
finally {
|
|
1263
|
+
await handle.close();
|
|
1264
|
+
}
|
|
1265
|
+
await chmod(target, MATERIALIZED_FILE_MODE);
|
|
1266
|
+
await this.beforePostCopyVerification(target, object);
|
|
1267
|
+
await this.verifyMaterializedFile(target, object);
|
|
1268
|
+
}
|
|
1269
|
+
catch (error) {
|
|
1270
|
+
await handle.close().catch(() => undefined);
|
|
1271
|
+
await rm(target, { force: true });
|
|
1272
|
+
throw error;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
async materializeDirectory(object, target, existingTargetPolicy) {
|
|
1276
|
+
try {
|
|
1277
|
+
await mkdir(target, { mode: DIRECTORY_MODE });
|
|
1278
|
+
}
|
|
1279
|
+
catch (error) {
|
|
1280
|
+
if (isErrno(error, "EEXIST")) {
|
|
1281
|
+
if (existingTargetPolicy === "reuse_if_identical") {
|
|
1282
|
+
await this.verifyExistingMaterialization(target, object);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
throw new ArtifactStoreError("materialization_conflict", `materialized artifact path already exists: ${target}`);
|
|
1286
|
+
}
|
|
1287
|
+
throw error;
|
|
1288
|
+
}
|
|
1289
|
+
try {
|
|
1290
|
+
const manifest = object.manifest ?? [];
|
|
1291
|
+
const directories = [];
|
|
1292
|
+
for (const entry of manifest) {
|
|
1293
|
+
const path = join(target, ...entry.path.split("/"));
|
|
1294
|
+
if (entry.kind === "directory") {
|
|
1295
|
+
await mkdir(path, { mode: DIRECTORY_MODE });
|
|
1296
|
+
directories.push(path);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const bytes = await this.store.readFileBytes(entry.artifact_id);
|
|
1300
|
+
const handle = await open(path, "wx", MATERIALIZED_FILE_MODE);
|
|
1301
|
+
try {
|
|
1302
|
+
await handle.writeFile(bytes);
|
|
1303
|
+
await handle.sync();
|
|
1304
|
+
}
|
|
1305
|
+
finally {
|
|
1306
|
+
await handle.close();
|
|
1307
|
+
}
|
|
1308
|
+
await chmod(path, MATERIALIZED_FILE_MODE);
|
|
1309
|
+
}
|
|
1310
|
+
directories.sort((left, right) => right.length - left.length);
|
|
1311
|
+
for (const directory of directories) {
|
|
1312
|
+
await chmod(directory, MATERIALIZED_DIRECTORY_MODE);
|
|
1313
|
+
}
|
|
1314
|
+
await chmod(target, MATERIALIZED_DIRECTORY_MODE);
|
|
1315
|
+
await this.beforePostCopyVerification(target, object);
|
|
1316
|
+
await this.verifyMaterializedDirectory(target, object);
|
|
1317
|
+
}
|
|
1318
|
+
catch (error) {
|
|
1319
|
+
await removeMaterializedTree(target);
|
|
1320
|
+
throw error;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async verifyExistingMaterialization(target, object) {
|
|
1324
|
+
try {
|
|
1325
|
+
if (object.kind === "file") {
|
|
1326
|
+
await this.verifyMaterializedFile(target, object);
|
|
1327
|
+
}
|
|
1328
|
+
else if (object.kind === "directory") {
|
|
1329
|
+
await this.verifyMaterializedDirectory(target, object);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
throw new ArtifactStoreError("unsupported_artifact_kind", "git_commit artifacts do not use filesystem materialization");
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
catch {
|
|
1336
|
+
throw new ArtifactStoreError("materialization_conflict", `existing materialization does not match ${object.kind} artifact ${object.artifact_id}: ${target}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
async verifyMaterializedFile(path, object) {
|
|
1340
|
+
const stats = await lstat(path, { bigint: true });
|
|
1341
|
+
if (!stats.isFile() ||
|
|
1342
|
+
stats.isSymbolicLink() ||
|
|
1343
|
+
(stats.mode & 146n) !== 0n) {
|
|
1344
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized file is not a read-only regular file: ${path}`);
|
|
1345
|
+
}
|
|
1346
|
+
const bytes = await readForMaterializationVerification(path, object.size_bytes);
|
|
1347
|
+
if (bytes.byteLength !== object.size_bytes ||
|
|
1348
|
+
sha256(bytes) !== object.artifact_id) {
|
|
1349
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized file content does not match ${object.artifact_id}`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
async verifyMaterializedDirectory(root, object) {
|
|
1353
|
+
const entries = [];
|
|
1354
|
+
let sizeBytes = 0;
|
|
1355
|
+
const walk = async (directory, relativeDirectory) => {
|
|
1356
|
+
const directoryStats = await lstat(directory, { bigint: true });
|
|
1357
|
+
if (!directoryStats.isDirectory() ||
|
|
1358
|
+
directoryStats.isSymbolicLink() ||
|
|
1359
|
+
(directoryStats.mode & 146n) !== 0n) {
|
|
1360
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized directory is not read-only: ${directory}`);
|
|
1361
|
+
}
|
|
1362
|
+
const names = await readdir(directory);
|
|
1363
|
+
names.sort(comparePosixPath);
|
|
1364
|
+
for (const name of names) {
|
|
1365
|
+
if (entries.length >= (object.manifest?.length ?? 0)) {
|
|
1366
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized directory contains unexpected entries: ${root}`);
|
|
1367
|
+
}
|
|
1368
|
+
const path = join(directory, name);
|
|
1369
|
+
const relativePath = relativeDirectory
|
|
1370
|
+
? `${relativeDirectory}/${name}`
|
|
1371
|
+
: name;
|
|
1372
|
+
const stats = await lstat(path, { bigint: true });
|
|
1373
|
+
if (stats.isSymbolicLink()) {
|
|
1374
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized artifact contains a symbolic link: ${path}`);
|
|
1375
|
+
}
|
|
1376
|
+
if (stats.isDirectory()) {
|
|
1377
|
+
entries.push({ kind: "directory", path: relativePath });
|
|
1378
|
+
await walk(path, relativePath);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
if (!stats.isFile() || (stats.mode & 146n) !== 0n) {
|
|
1382
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized artifact contains an invalid file: ${path}`);
|
|
1383
|
+
}
|
|
1384
|
+
const bytes = await readForMaterializationVerification(path, object.size_bytes - sizeBytes);
|
|
1385
|
+
sizeBytes += bytes.byteLength;
|
|
1386
|
+
if (sizeBytes > object.size_bytes) {
|
|
1387
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized directory exceeds expected size: ${root}`);
|
|
1388
|
+
}
|
|
1389
|
+
entries.push({
|
|
1390
|
+
kind: "file",
|
|
1391
|
+
path: relativePath,
|
|
1392
|
+
artifact_id: sha256(bytes),
|
|
1393
|
+
size_bytes: bytes.byteLength,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
await walk(root, "");
|
|
1398
|
+
entries.sort((left, right) => comparePosixPath(left.path, right.path));
|
|
1399
|
+
if (sizeBytes !== object.size_bytes ||
|
|
1400
|
+
canonicalizeJson(entries) !== canonicalizeJson(object.manifest ?? []) ||
|
|
1401
|
+
directoryIdentity(entries) !== object.artifact_id) {
|
|
1402
|
+
throw new ArtifactStoreError("materialization_verification_failed", `materialized directory content does not match ${object.artifact_id}`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
async function removeMaterializedTree(path) {
|
|
1407
|
+
const makeWritable = async (candidate) => {
|
|
1408
|
+
let stats;
|
|
1409
|
+
try {
|
|
1410
|
+
stats = await lstat(candidate, { bigint: true });
|
|
1411
|
+
}
|
|
1412
|
+
catch (error) {
|
|
1413
|
+
if (isErrno(error, "ENOENT"))
|
|
1414
|
+
return;
|
|
1415
|
+
throw error;
|
|
1416
|
+
}
|
|
1417
|
+
if (stats.isSymbolicLink())
|
|
1418
|
+
return;
|
|
1419
|
+
if (!stats.isDirectory()) {
|
|
1420
|
+
await chmod(candidate, 0o600);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
await chmod(candidate, DIRECTORY_MODE);
|
|
1424
|
+
for (const name of await readdir(candidate)) {
|
|
1425
|
+
await makeWritable(join(candidate, name));
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
try {
|
|
1429
|
+
await makeWritable(path);
|
|
1430
|
+
await rm(path, { recursive: true, force: true });
|
|
1431
|
+
}
|
|
1432
|
+
catch {
|
|
1433
|
+
// Cleanup must not replace the integrity error that caused it.
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
export { ArtifactStore as ImmutableArtifactStore };
|