@youdie006/prodex 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +418 -0
- package/dist/banner.d.ts +5 -0
- package/dist/banner.js +47 -0
- package/dist/banner.js.map +1 -0
- package/dist/bundle.d.ts +15 -0
- package/dist/bundle.js +30 -0
- package/dist/bundle.js.map +1 -0
- package/dist/chatgpt-browser.d.ts +119 -0
- package/dist/chatgpt-browser.js +857 -0
- package/dist/chatgpt-browser.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +3502 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +58 -0
- package/dist/config.js +277 -0
- package/dist/config.js.map +1 -0
- package/dist/http-mcp.d.ts +20 -0
- package/dist/http-mcp.js +236 -0
- package/dist/http-mcp.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-tools.d.ts +395 -0
- package/dist/mcp-tools.js +167 -0
- package/dist/mcp-tools.js.map +1 -0
- package/dist/mcp.d.ts +37 -0
- package/dist/mcp.js +229 -0
- package/dist/mcp.js.map +1 -0
- package/dist/repo-write.d.ts +47 -0
- package/dist/repo-write.js +427 -0
- package/dist/repo-write.js.map +1 -0
- package/dist/repo.d.ts +27 -0
- package/dist/repo.js +386 -0
- package/dist/repo.js.map +1 -0
- package/dist/safe-file.d.ts +25 -0
- package/dist/safe-file.js +295 -0
- package/dist/safe-file.js.map +1 -0
- package/dist/schema.d.ts +402 -0
- package/dist/schema.js +109 -0
- package/dist/schema.js.map +1 -0
- package/dist/store.d.ts +157 -0
- package/dist/store.js +1402 -0
- package/dist/store.js.map +1 -0
- package/docs/claude.md +130 -0
- package/docs/clients.md +75 -0
- package/docs/http-mcp.md +223 -0
- package/package.json +67 -0
- package/scripts/release-check.mjs +436 -0
- package/scripts/release-pack.mjs +481 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,1402 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { constants, existsSync } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { assertRepoRelativePath } from "./repo.js";
|
|
6
|
+
import { readVerifiedUtf8File, writeVerifiedUtf8File } from "./safe-file.js";
|
|
7
|
+
import { makeBridgeId, nowIso, ReceiptSchema, ResultSchema, SCHEMA_VERSION, SessionSchema, TaskSchema } from "./schema.js";
|
|
8
|
+
let storeTestHooks = {};
|
|
9
|
+
export function setBridgeStoreTestHooks(hooks) {
|
|
10
|
+
storeTestHooks = hooks;
|
|
11
|
+
}
|
|
12
|
+
const TASK_ID_PATTERN = /^task_\d{8}_\d{6}_[a-z0-9-]+$/;
|
|
13
|
+
const SESSION_ID_PATTERN = /^sess_\d{8}_\d{6}_[a-z0-9-]+$/;
|
|
14
|
+
const RECEIPT_ID_PATTERN = /^receipt_\d{8}_\d{6}_[a-z0-9-]+$/;
|
|
15
|
+
const BRIDGE_DIRECTORY_MODE = 0o700;
|
|
16
|
+
const BRIDGE_FILE_MODE = 0o600;
|
|
17
|
+
const RECEIPT_INTEGRITY_KEY_BYTES = 32;
|
|
18
|
+
const FETCHABLE_RESULT_ARTIFACT_PREFIXES = [".bridge/artifacts/pro-consults/", ".bridge/artifacts/results/"];
|
|
19
|
+
export const MAX_FETCHABLE_RESULT_ARTIFACT_BYTES = 100_000;
|
|
20
|
+
const MAX_BRIDGE_ARTIFACT_READ_BYTES = 1_000_000;
|
|
21
|
+
export class BridgeStore {
|
|
22
|
+
root;
|
|
23
|
+
bridgeDir;
|
|
24
|
+
constructor(root = process.cwd()) {
|
|
25
|
+
this.root = root;
|
|
26
|
+
this.bridgeDir = path.join(root, ".bridge");
|
|
27
|
+
}
|
|
28
|
+
async ensure() {
|
|
29
|
+
await ensurePrivateDirectory(this.bridgeDir, "Bridge directory");
|
|
30
|
+
await Promise.all([
|
|
31
|
+
ensurePrivateDirectory(this.dir("tasks"), "Bridge storage directory .bridge/tasks"),
|
|
32
|
+
ensurePrivateDirectory(this.dir("results"), "Bridge storage directory .bridge/results"),
|
|
33
|
+
ensurePrivateDirectory(this.dir("sessions"), "Bridge storage directory .bridge/sessions"),
|
|
34
|
+
ensurePrivateDirectory(this.dir("artifacts"), "Bridge storage directory .bridge/artifacts"),
|
|
35
|
+
ensurePrivateDirectory(this.dir("receipts"), "Bridge storage directory .bridge/receipts")
|
|
36
|
+
]);
|
|
37
|
+
await this.assertStorageDirsAreRealDirectories();
|
|
38
|
+
await this.ensureBridgeGitignore();
|
|
39
|
+
await this.ensureReceiptIntegrityKey();
|
|
40
|
+
}
|
|
41
|
+
dir(kind) {
|
|
42
|
+
return path.join(this.bridgeDir, kind);
|
|
43
|
+
}
|
|
44
|
+
receiptIntegrityKeyPath() {
|
|
45
|
+
return path.join(this.bridgeDir, "receipt-key.local");
|
|
46
|
+
}
|
|
47
|
+
async createTask(input) {
|
|
48
|
+
await this.ensure();
|
|
49
|
+
const timestamp = nowIso();
|
|
50
|
+
const taskFiles = validateTaskFiles(input.files ?? []);
|
|
51
|
+
const task = await this.createWithUniqueId("task", input.title, "tasks", (id) => TaskSchema.parse({
|
|
52
|
+
schema_version: SCHEMA_VERSION,
|
|
53
|
+
id,
|
|
54
|
+
source: input.source,
|
|
55
|
+
status: "new",
|
|
56
|
+
title: input.title,
|
|
57
|
+
prompt: input.prompt,
|
|
58
|
+
repo_id: input.repo_id ?? "default",
|
|
59
|
+
files: taskFiles,
|
|
60
|
+
provenance: input.provenance,
|
|
61
|
+
created_at: timestamp,
|
|
62
|
+
updated_at: timestamp
|
|
63
|
+
}));
|
|
64
|
+
try {
|
|
65
|
+
await this.writeReceipt({
|
|
66
|
+
kind: "task_created",
|
|
67
|
+
task_id: task.id,
|
|
68
|
+
summary: `Created task ${task.id}`
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
try {
|
|
73
|
+
await this.deleteRecordIfPresent("tasks", task.id);
|
|
74
|
+
}
|
|
75
|
+
catch (cleanupError) {
|
|
76
|
+
throw new Error(`${errorMessage(error)} (also failed to clean up task record: ${errorMessage(cleanupError)})`);
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
return task;
|
|
81
|
+
}
|
|
82
|
+
async listTasks(status) {
|
|
83
|
+
await this.ensure();
|
|
84
|
+
const tasks = await this.readAll("tasks", parseTaskRecord);
|
|
85
|
+
return tasks
|
|
86
|
+
.filter((task) => (status ? task.status === status : true))
|
|
87
|
+
.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
88
|
+
}
|
|
89
|
+
async listTasksReadOnly(status) {
|
|
90
|
+
if (!(await this.hasReadyStorageDirReadOnly("tasks")))
|
|
91
|
+
return [];
|
|
92
|
+
const tasks = await this.readAll("tasks", parseTaskRecord, { cleanupTempHardLinks: false });
|
|
93
|
+
return tasks
|
|
94
|
+
.filter((task) => (status ? task.status === status : true))
|
|
95
|
+
.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
96
|
+
}
|
|
97
|
+
async getTask(taskId) {
|
|
98
|
+
return this.parseRecord("tasks", taskId, await this.readRecordJson("tasks", taskId), parseTaskRecord);
|
|
99
|
+
}
|
|
100
|
+
async getTaskReadOnly(taskId) {
|
|
101
|
+
return this.parseRecord("tasks", taskId, await this.readRecordJson("tasks", taskId, { cleanupTempHardLinks: false }), parseTaskRecord);
|
|
102
|
+
}
|
|
103
|
+
async claimTask(taskId, claimedBy) {
|
|
104
|
+
const task = await this.getTask(taskId);
|
|
105
|
+
if (task.status !== "new") {
|
|
106
|
+
throw new Error(`Task ${taskId} is ${task.status}, not new`);
|
|
107
|
+
}
|
|
108
|
+
const updated = TaskSchema.parse({
|
|
109
|
+
...task,
|
|
110
|
+
status: "claimed",
|
|
111
|
+
claimed_by: claimedBy,
|
|
112
|
+
claimed_at: nowIso(),
|
|
113
|
+
updated_at: nowIso()
|
|
114
|
+
});
|
|
115
|
+
await this.writeRecordJson("tasks", taskId, updated);
|
|
116
|
+
try {
|
|
117
|
+
await this.writeReceipt({
|
|
118
|
+
kind: "task_claimed",
|
|
119
|
+
task_id: taskId,
|
|
120
|
+
summary: `Claimed task ${taskId} by ${claimedBy}`
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
try {
|
|
125
|
+
await this.writeRecordJson("tasks", taskId, task);
|
|
126
|
+
}
|
|
127
|
+
catch (cleanupError) {
|
|
128
|
+
throw new Error(`${errorMessage(error)} (also failed to restore task claim state: ${errorMessage(cleanupError)})`);
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
return updated;
|
|
133
|
+
}
|
|
134
|
+
async completeTask(taskId, input) {
|
|
135
|
+
const task = await this.getTask(taskId);
|
|
136
|
+
assertFetchableResultArtifacts(input.artifacts ?? []);
|
|
137
|
+
const retryResult = ResultSchema.parse({
|
|
138
|
+
schema_version: SCHEMA_VERSION,
|
|
139
|
+
task_id: taskId,
|
|
140
|
+
status: input.status,
|
|
141
|
+
summary: input.summary,
|
|
142
|
+
artifacts: input.artifacts ?? [],
|
|
143
|
+
commands: input.commands ?? [],
|
|
144
|
+
warnings: input.warnings ?? [],
|
|
145
|
+
blocker: input.blocker,
|
|
146
|
+
created_at: nowIso()
|
|
147
|
+
});
|
|
148
|
+
if (task.status === "done" || task.status === "blocked") {
|
|
149
|
+
const existingResult = await this.getResult(taskId).catch((error) => {
|
|
150
|
+
if (isErrorCode(error, "ENOENT"))
|
|
151
|
+
throw terminalTaskMissingResultError(task);
|
|
152
|
+
throw error;
|
|
153
|
+
});
|
|
154
|
+
if (await this.hasTrustedTaskCompletionReceipt(taskId, existingResult)) {
|
|
155
|
+
throw new Error(`Task ${taskId} is already ${task.status} and cannot be finalized again`);
|
|
156
|
+
}
|
|
157
|
+
assertResultMatchesRetry(taskId, existingResult, retryResult);
|
|
158
|
+
await this.assertResultArtifactsUnchanged(existingResult);
|
|
159
|
+
await this.writeTaskCompletionReceipt(taskId, existingResult);
|
|
160
|
+
return existingResult;
|
|
161
|
+
}
|
|
162
|
+
const existingResult = await this.getResult(taskId).catch((error) => {
|
|
163
|
+
if (isErrorCode(error, "ENOENT"))
|
|
164
|
+
return undefined;
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
if (existingResult) {
|
|
168
|
+
assertResultMatchesRetry(taskId, existingResult, retryResult);
|
|
169
|
+
await this.assertResultArtifactsUnchanged(existingResult);
|
|
170
|
+
const updated = TaskSchema.parse({
|
|
171
|
+
...task,
|
|
172
|
+
status: existingResult.status,
|
|
173
|
+
provenance: input.provenance ? { ...task.provenance, ...input.provenance } : task.provenance,
|
|
174
|
+
blocker: existingResult.blocker,
|
|
175
|
+
result_path: `.bridge/results/${taskId}.json`,
|
|
176
|
+
updated_at: nowIso()
|
|
177
|
+
});
|
|
178
|
+
await this.writeRecordJson("tasks", taskId, updated);
|
|
179
|
+
await this.writeTaskCompletionReceiptOrRestoreTask(taskId, existingResult, task);
|
|
180
|
+
return existingResult;
|
|
181
|
+
}
|
|
182
|
+
const artifacts = await this.withResultArtifactHashes(input.artifacts ?? []);
|
|
183
|
+
const result = ResultSchema.parse({
|
|
184
|
+
...retryResult,
|
|
185
|
+
artifacts
|
|
186
|
+
});
|
|
187
|
+
const wroteResult = await this.writeNewRecordJson("results", taskId, result);
|
|
188
|
+
const finalResult = wroteResult ? result : await this.getResult(taskId);
|
|
189
|
+
if (!wroteResult) {
|
|
190
|
+
assertResultMatchesRetry(taskId, finalResult, retryResult);
|
|
191
|
+
}
|
|
192
|
+
await this.assertResultArtifactsUnchanged(finalResult);
|
|
193
|
+
const updated = TaskSchema.parse({
|
|
194
|
+
...task,
|
|
195
|
+
status: finalResult.status,
|
|
196
|
+
provenance: input.provenance ? { ...task.provenance, ...input.provenance } : task.provenance,
|
|
197
|
+
blocker: finalResult.blocker,
|
|
198
|
+
result_path: `.bridge/results/${taskId}.json`,
|
|
199
|
+
updated_at: nowIso()
|
|
200
|
+
});
|
|
201
|
+
await this.writeRecordJson("tasks", taskId, updated);
|
|
202
|
+
await this.writeTaskCompletionReceiptOrRestoreTask(taskId, finalResult, task);
|
|
203
|
+
return finalResult;
|
|
204
|
+
}
|
|
205
|
+
async listResults() {
|
|
206
|
+
await this.ensure();
|
|
207
|
+
return (await this.readAll("results", parseResultRecord)).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.task_id.localeCompare(b.task_id));
|
|
208
|
+
}
|
|
209
|
+
async listResultsReadOnly() {
|
|
210
|
+
if (!(await this.hasReadyStorageDirReadOnly("results")))
|
|
211
|
+
return [];
|
|
212
|
+
return (await this.readAll("results", parseResultRecord, { cleanupTempHardLinks: false })).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.task_id.localeCompare(b.task_id));
|
|
213
|
+
}
|
|
214
|
+
async listFinalizedResultsReadOnly() {
|
|
215
|
+
const results = await this.listResultsReadOnly();
|
|
216
|
+
for (const result of results) {
|
|
217
|
+
await this.assertTrustedTaskCompletionReceiptReadOnly(result.task_id, result);
|
|
218
|
+
}
|
|
219
|
+
return results;
|
|
220
|
+
}
|
|
221
|
+
async getResult(taskId) {
|
|
222
|
+
return this.parseRecord("results", taskId, await this.readRecordJson("results", taskId), parseResultRecord);
|
|
223
|
+
}
|
|
224
|
+
async getResultReadOnly(taskId) {
|
|
225
|
+
return this.parseRecord("results", taskId, await this.readRecordJson("results", taskId, { cleanupTempHardLinks: false }), parseResultRecord);
|
|
226
|
+
}
|
|
227
|
+
async getFinalizedResultReadOnly(taskId) {
|
|
228
|
+
const result = await this.getResultReadOnly(taskId);
|
|
229
|
+
await this.assertTrustedTaskCompletionReceiptReadOnly(taskId, result);
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
async resealResult(taskId) {
|
|
233
|
+
const task = await this.getTask(taskId);
|
|
234
|
+
if (task.status !== "done" && task.status !== "blocked") {
|
|
235
|
+
throw new Error(`Task ${taskId} is ${task.status}, not done or blocked`);
|
|
236
|
+
}
|
|
237
|
+
const result = await this.getResult(taskId);
|
|
238
|
+
if (result.status !== task.status) {
|
|
239
|
+
throw new Error(`Result ${taskId} status ${result.status} does not match task status ${task.status}`);
|
|
240
|
+
}
|
|
241
|
+
await this.assertResultArtifactsUnchanged(result);
|
|
242
|
+
if (await this.hasTrustedTaskCompletionReceipt(taskId, result)) {
|
|
243
|
+
throw new Error(`Result ${taskId} already has a trusted task_completed receipt`);
|
|
244
|
+
}
|
|
245
|
+
await this.assertHasTrustedLegacyCompletionReceiptForReseal(taskId, result);
|
|
246
|
+
return {
|
|
247
|
+
result,
|
|
248
|
+
receipt: await this.writeTaskCompletionReceipt(taskId, result)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
async readFinalizedResultArtifactText(taskId, artifactPath, options = {}) {
|
|
252
|
+
return this.readResultArtifactText(taskId, artifactPath, { ...options, readOnly: true });
|
|
253
|
+
}
|
|
254
|
+
async readResultArtifactText(taskId, artifactPath, options = {}) {
|
|
255
|
+
const result = options.readOnly ? await this.getResultReadOnly(taskId) : await this.getResult(taskId);
|
|
256
|
+
if (options.readOnly) {
|
|
257
|
+
await this.assertTrustedTaskCompletionReceiptReadOnly(taskId, result);
|
|
258
|
+
}
|
|
259
|
+
const artifacts = result.artifacts.filter((artifact) => artifact.role === "result");
|
|
260
|
+
const artifact = artifactPath ? artifacts.find((item) => item.path === artifactPath) : artifacts.length === 1 ? artifacts[0] : undefined;
|
|
261
|
+
if (!artifact) {
|
|
262
|
+
if (artifactPath)
|
|
263
|
+
throw new Error(`Result artifact not found for ${taskId}: ${artifactPath}`);
|
|
264
|
+
if (artifacts.length === 0)
|
|
265
|
+
throw new Error(`Result ${taskId} has no result artifacts`);
|
|
266
|
+
throw new Error(`Result ${taskId} has multiple result artifacts; pass one artifact path: ${artifacts.map((item) => item.path).join(", ")}`);
|
|
267
|
+
}
|
|
268
|
+
const normalizedArtifactPath = path.posix.normalize(artifact.path.replaceAll("\\", "/"));
|
|
269
|
+
if (!isFetchableResultArtifactPath(normalizedArtifactPath) || normalizedArtifactPath !== artifact.path) {
|
|
270
|
+
throw new Error(`Artifact is not a fetchable result artifact for ${taskId}: ${artifact.path}`);
|
|
271
|
+
}
|
|
272
|
+
const content = await this.readArtifactText(artifact.path, options);
|
|
273
|
+
if (artifact.sha256 && sha256(content) !== artifact.sha256) {
|
|
274
|
+
throw new Error(`Result artifact changed after finalization for ${taskId}: ${artifact.path} sha256 mismatch`);
|
|
275
|
+
}
|
|
276
|
+
return { artifact, content };
|
|
277
|
+
}
|
|
278
|
+
async withResultArtifactHashes(artifacts) {
|
|
279
|
+
const withHashes = [];
|
|
280
|
+
for (const artifact of artifacts) {
|
|
281
|
+
if (artifact.role !== "result") {
|
|
282
|
+
withHashes.push(artifact);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const content = await this.readArtifactText(artifact.path);
|
|
286
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
287
|
+
if (bytes > MAX_FETCHABLE_RESULT_ARTIFACT_BYTES) {
|
|
288
|
+
throw new Error(`Result artifact is too large to fetch (${bytes} bytes > ${MAX_FETCHABLE_RESULT_ARTIFACT_BYTES} bytes): ${artifact.path}`);
|
|
289
|
+
}
|
|
290
|
+
withHashes.push({ ...artifact, bytes, sha256: sha256(content) });
|
|
291
|
+
}
|
|
292
|
+
return withHashes;
|
|
293
|
+
}
|
|
294
|
+
async assertResultArtifactsUnchanged(result) {
|
|
295
|
+
for (const artifact of result.artifacts) {
|
|
296
|
+
if (artifact.role !== "result" || !artifact.sha256)
|
|
297
|
+
continue;
|
|
298
|
+
const normalizedArtifactPath = path.posix.normalize(artifact.path.replaceAll("\\", "/"));
|
|
299
|
+
if (!isFetchableResultArtifactPath(normalizedArtifactPath) || normalizedArtifactPath !== artifact.path) {
|
|
300
|
+
throw new Error(`Artifact is not a fetchable result artifact for ${result.task_id}: ${artifact.path}`);
|
|
301
|
+
}
|
|
302
|
+
let content;
|
|
303
|
+
try {
|
|
304
|
+
content = await this.readArtifactText(artifact.path);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
throw new Error(`Result artifact changed after finalization for ${result.task_id}: ${artifact.path} ${errorMessage(error)}`);
|
|
308
|
+
}
|
|
309
|
+
if (sha256(content) !== artifact.sha256) {
|
|
310
|
+
throw new Error(`Result artifact changed after finalization for ${result.task_id}: ${artifact.path} sha256 mismatch`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async writeSession(input) {
|
|
315
|
+
await this.ensure();
|
|
316
|
+
const timestamp = nowIso();
|
|
317
|
+
if (input.id === undefined) {
|
|
318
|
+
return this.createWithUniqueId("sess", input.task_id ?? input.direction, "sessions", (id) => SessionSchema.parse({
|
|
319
|
+
schema_version: SCHEMA_VERSION,
|
|
320
|
+
id,
|
|
321
|
+
direction: input.direction,
|
|
322
|
+
backend: input.backend,
|
|
323
|
+
project: input.project,
|
|
324
|
+
thread: input.thread,
|
|
325
|
+
task_id: input.task_id,
|
|
326
|
+
status: input.status ?? "preview",
|
|
327
|
+
blocker: input.blocker,
|
|
328
|
+
warnings: input.warnings ?? [],
|
|
329
|
+
created_at: timestamp,
|
|
330
|
+
last_used_at: timestamp
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
const existing = await this.getSession(input.id).catch(() => undefined);
|
|
334
|
+
const session = SessionSchema.parse({
|
|
335
|
+
schema_version: SCHEMA_VERSION,
|
|
336
|
+
id: input.id,
|
|
337
|
+
direction: input.direction,
|
|
338
|
+
backend: input.backend,
|
|
339
|
+
project: input.project,
|
|
340
|
+
thread: input.thread,
|
|
341
|
+
task_id: input.task_id,
|
|
342
|
+
status: input.status ?? "preview",
|
|
343
|
+
blocker: input.blocker,
|
|
344
|
+
warnings: input.warnings ?? [],
|
|
345
|
+
created_at: existing?.created_at ?? timestamp,
|
|
346
|
+
last_used_at: timestamp
|
|
347
|
+
});
|
|
348
|
+
await this.writeRecordJson("sessions", input.id, session);
|
|
349
|
+
return session;
|
|
350
|
+
}
|
|
351
|
+
async getSession(sessionId) {
|
|
352
|
+
return this.parseRecord("sessions", sessionId, await this.readRecordJson("sessions", sessionId), parseSessionRecord);
|
|
353
|
+
}
|
|
354
|
+
async getSessionReadOnly(sessionId) {
|
|
355
|
+
return this.parseRecord("sessions", sessionId, await this.readRecordJson("sessions", sessionId, { cleanupTempHardLinks: false }), parseSessionRecord);
|
|
356
|
+
}
|
|
357
|
+
async listSessions(status) {
|
|
358
|
+
await this.ensure();
|
|
359
|
+
const sessions = await this.readAll("sessions", parseSessionRecord);
|
|
360
|
+
return sessions
|
|
361
|
+
.filter((session) => (status ? session.status === status : true))
|
|
362
|
+
.sort((a, b) => b.last_used_at.localeCompare(a.last_used_at) || b.id.localeCompare(a.id));
|
|
363
|
+
}
|
|
364
|
+
async listSessionsReadOnly(status) {
|
|
365
|
+
if (!(await this.hasReadyStorageDirReadOnly("sessions")))
|
|
366
|
+
return [];
|
|
367
|
+
const sessions = await this.readAll("sessions", parseSessionRecord, { cleanupTempHardLinks: false });
|
|
368
|
+
return sessions
|
|
369
|
+
.filter((session) => (status ? session.status === status : true))
|
|
370
|
+
.sort((a, b) => b.last_used_at.localeCompare(a.last_used_at) || b.id.localeCompare(a.id));
|
|
371
|
+
}
|
|
372
|
+
async getReceipt(receiptId) {
|
|
373
|
+
return this.parseRecord("receipts", receiptId, await this.readRecordJson("receipts", receiptId), parseReceiptRecord);
|
|
374
|
+
}
|
|
375
|
+
async getTrustedReceipt(receiptId) {
|
|
376
|
+
const receipt = await this.getReceipt(receiptId);
|
|
377
|
+
await this.assertReceiptIntegrity(receipt);
|
|
378
|
+
return receipt;
|
|
379
|
+
}
|
|
380
|
+
async deleteReceiptIfPresent(receiptId) {
|
|
381
|
+
await this.ensure();
|
|
382
|
+
await this.deleteRecordIfPresent("receipts", receiptId);
|
|
383
|
+
}
|
|
384
|
+
async getReceiptReadOnly(receiptId) {
|
|
385
|
+
return this.parseRecord("receipts", receiptId, await this.readRecordJson("receipts", receiptId, { cleanupTempHardLinks: false }), parseReceiptRecord);
|
|
386
|
+
}
|
|
387
|
+
async listReceipts(input = {}) {
|
|
388
|
+
await this.ensure();
|
|
389
|
+
const receipts = (await this.readAll("receipts", parseReceiptRecord))
|
|
390
|
+
.filter((receipt) => (input.kind ? receipt.kind === input.kind : true))
|
|
391
|
+
.filter((receipt) => (input.task_id ? receipt.task_id === input.task_id : true))
|
|
392
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
|
|
393
|
+
return Promise.all(receipts.map((receipt) => this.redactReceiptForDisplay(receipt)));
|
|
394
|
+
}
|
|
395
|
+
async listReceiptsReadOnly(input = {}) {
|
|
396
|
+
if (!(await this.hasReadyStorageDirReadOnly("receipts")))
|
|
397
|
+
return [];
|
|
398
|
+
const receipts = (await this.readAll("receipts", parseReceiptRecord, { cleanupTempHardLinks: false }))
|
|
399
|
+
.filter((receipt) => (input.kind ? receipt.kind === input.kind : true))
|
|
400
|
+
.filter((receipt) => (input.task_id ? receipt.task_id === input.task_id : true))
|
|
401
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
|
|
402
|
+
return Promise.all(receipts.map((receipt) => this.redactReceiptForDisplay(receipt)));
|
|
403
|
+
}
|
|
404
|
+
async getReceiptForDisplay(receiptId) {
|
|
405
|
+
return this.redactReceiptForDisplay(await this.getReceipt(receiptId));
|
|
406
|
+
}
|
|
407
|
+
async getReceiptForDisplayReadOnly(receiptId) {
|
|
408
|
+
return this.redactReceiptForDisplay(await this.getReceiptReadOnly(receiptId));
|
|
409
|
+
}
|
|
410
|
+
async hasTrustedTaskCompletionReceipt(taskId, result) {
|
|
411
|
+
const receipts = (await this.readAll("receipts", parseReceiptRecord)).filter((receipt) => receipt.kind === "task_completed" && receipt.task_id === taskId);
|
|
412
|
+
for (const receipt of receipts) {
|
|
413
|
+
try {
|
|
414
|
+
await this.assertReceiptIntegrity(receipt, {
|
|
415
|
+
remediation: "Task completion receipts must be generated by this local bridge"
|
|
416
|
+
});
|
|
417
|
+
assertReceiptResultDigest(receipt, result);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
async assertTrustedTaskCompletionReceiptReadOnly(taskId, result) {
|
|
427
|
+
if (!(await this.hasReadyStorageDirReadOnly("receipts"))) {
|
|
428
|
+
throw untrustedResultError(this.root, taskId, "has no trusted task_completed receipt");
|
|
429
|
+
}
|
|
430
|
+
const receipts = (await this.readAll("receipts", parseReceiptRecord, { cleanupTempHardLinks: false })).filter((receipt) => receipt.kind === "task_completed" && receipt.task_id === taskId);
|
|
431
|
+
if (receipts.length === 0) {
|
|
432
|
+
throw untrustedResultError(this.root, taskId, "has no trusted task_completed receipt");
|
|
433
|
+
}
|
|
434
|
+
const failures = [];
|
|
435
|
+
for (const receipt of receipts) {
|
|
436
|
+
try {
|
|
437
|
+
await this.assertReceiptIntegrity(receipt, {
|
|
438
|
+
remediation: "Task completion receipts must be generated by this local bridge"
|
|
439
|
+
});
|
|
440
|
+
assertReceiptResultDigest(receipt, result);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
failures.push(errorMessage(error));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
throw untrustedResultError(this.root, taskId, `has an untrusted task_completed receipt: ${failures[0] ?? "local integrity unavailable"}`);
|
|
448
|
+
}
|
|
449
|
+
async writeTaskCompletionReceipt(taskId, result) {
|
|
450
|
+
return this.writeReceipt({
|
|
451
|
+
kind: "task_completed",
|
|
452
|
+
task_id: taskId,
|
|
453
|
+
summary: `${result.status === "done" ? "Completed" : "Blocked"} task ${taskId}`,
|
|
454
|
+
metadata: {
|
|
455
|
+
result_sha256: resultDigest(result)
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
async writeTaskCompletionReceiptOrRestoreTask(taskId, result, previousTask) {
|
|
460
|
+
try {
|
|
461
|
+
await this.writeTaskCompletionReceipt(taskId, result);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
try {
|
|
465
|
+
await this.writeRecordJson("tasks", taskId, previousTask);
|
|
466
|
+
}
|
|
467
|
+
catch (cleanupError) {
|
|
468
|
+
throw new Error(`${errorMessage(error)} (also failed to restore task completion state: ${errorMessage(cleanupError)})`);
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async assertHasTrustedLegacyCompletionReceiptForReseal(taskId, result) {
|
|
474
|
+
const receipts = (await this.readAll("receipts", parseReceiptRecord))
|
|
475
|
+
.filter((receipt) => receipt.kind === "task_completed" && receipt.task_id === taskId)
|
|
476
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
|
|
477
|
+
if (receipts.length === 0) {
|
|
478
|
+
throw untrustedResultError(this.root, taskId, "has no trusted task_completed receipt to reseal");
|
|
479
|
+
}
|
|
480
|
+
const failures = [];
|
|
481
|
+
const legacyReceiptIds = [];
|
|
482
|
+
for (const receipt of receipts) {
|
|
483
|
+
try {
|
|
484
|
+
await this.assertReceiptIntegrity(receipt, {
|
|
485
|
+
remediation: "Only locally signed legacy task_completed receipts can be resealed"
|
|
486
|
+
});
|
|
487
|
+
assertLegacyCompletionReceiptCanBeResealed(receipt, result);
|
|
488
|
+
legacyReceiptIds.push(receipt.id);
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
failures.push(errorMessage(error));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (legacyReceiptIds.length === 1)
|
|
495
|
+
return;
|
|
496
|
+
if (legacyReceiptIds.length > 1) {
|
|
497
|
+
throw untrustedResultError(this.root, taskId, `has multiple locally signed legacy task_completed receipts to reseal (${legacyReceiptIds.join(", ")}); move extras aside, then retry`);
|
|
498
|
+
}
|
|
499
|
+
throw untrustedResultError(this.root, taskId, `has no locally trusted legacy task_completed receipt to reseal: ${failures[0] ?? "local integrity unavailable"}`);
|
|
500
|
+
}
|
|
501
|
+
async writeArtifactText(relativePath, content) {
|
|
502
|
+
await this.ensure();
|
|
503
|
+
await this.assertBridgeDirIsRealDirectory();
|
|
504
|
+
await this.assertArtifactsDirIsRealDirectory();
|
|
505
|
+
const artifactPath = this.resolveArtifactPath(relativePath);
|
|
506
|
+
await this.ensureArtifactParentDirectory(path.dirname(artifactPath));
|
|
507
|
+
await writeVerifiedUtf8File(artifactPath, content, async () => {
|
|
508
|
+
await this.assertArtifactParentDirectory(path.dirname(artifactPath));
|
|
509
|
+
await this.assertArtifactTargetInsideIfExists(artifactPath);
|
|
510
|
+
}, { create: true, mode: BRIDGE_FILE_MODE });
|
|
511
|
+
return this.relativeToRoot(artifactPath);
|
|
512
|
+
}
|
|
513
|
+
async hasArtifactText(relativePath) {
|
|
514
|
+
const artifactPath = this.resolveArtifactPath(relativePath);
|
|
515
|
+
try {
|
|
516
|
+
await this.assertBridgeDirIsRealDirectory();
|
|
517
|
+
await this.assertArtifactsDirIsRealDirectory();
|
|
518
|
+
await this.assertArtifactParentDirectory(path.dirname(artifactPath));
|
|
519
|
+
await this.assertArtifactTargetInside(artifactPath);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
if (isErrorCode(error, "ENOENT"))
|
|
524
|
+
return false;
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async deleteArtifactTextIfPresent(relativePath) {
|
|
529
|
+
await this.assertBridgeDirIsRealDirectory();
|
|
530
|
+
await this.assertArtifactsDirIsRealDirectory();
|
|
531
|
+
const artifactPath = this.resolveArtifactPath(relativePath);
|
|
532
|
+
const parentPath = path.dirname(artifactPath);
|
|
533
|
+
await this.assertArtifactParentDirectory(parentPath);
|
|
534
|
+
const parentHandle = await openNoFollowDirectory(parentPath, "Artifact directory");
|
|
535
|
+
try {
|
|
536
|
+
const targetPath = path.join(directoryFdPath(parentHandle.fd), path.basename(artifactPath));
|
|
537
|
+
let stat;
|
|
538
|
+
try {
|
|
539
|
+
stat = await lstat(targetPath);
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (isErrorCode(error, "ENOENT"))
|
|
543
|
+
return;
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
547
|
+
throw new Error("Artifact path must be a regular file and must not be a symlink");
|
|
548
|
+
}
|
|
549
|
+
await rm(targetPath, { force: true });
|
|
550
|
+
await this.assertArtifactParentDirectory(parentPath);
|
|
551
|
+
}
|
|
552
|
+
finally {
|
|
553
|
+
await parentHandle.close();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async readArtifactText(relativePath, options = {}) {
|
|
557
|
+
await this.assertBridgeDirIsRealDirectory();
|
|
558
|
+
await this.assertArtifactsDirIsRealDirectory();
|
|
559
|
+
const artifactPath = this.resolveArtifactPath(relativePath);
|
|
560
|
+
await this.assertArtifactParentDirectory(path.dirname(artifactPath));
|
|
561
|
+
return readVerifiedUtf8File(artifactPath, () => this.assertArtifactTargetInside(artifactPath), {
|
|
562
|
+
maxBytes: options.maxBytes ?? MAX_BRIDGE_ARTIFACT_READ_BYTES
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async writeReceipt(input) {
|
|
566
|
+
await this.ensure();
|
|
567
|
+
return this.createWithUniqueId("receipt", input.kind, "receipts", async (id) => {
|
|
568
|
+
const unsigned = ReceiptSchema.parse({
|
|
569
|
+
schema_version: SCHEMA_VERSION,
|
|
570
|
+
id,
|
|
571
|
+
created_at: nowIso(),
|
|
572
|
+
...input
|
|
573
|
+
});
|
|
574
|
+
return ReceiptSchema.parse({
|
|
575
|
+
...unsigned,
|
|
576
|
+
integrity: {
|
|
577
|
+
algorithm: "hmac-sha256",
|
|
578
|
+
digest: await this.receiptDigest(unsigned)
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
async assertReceiptIntegrity(receipt, options = {
|
|
584
|
+
remediation: "Recreate the write dry-run before applying or staging."
|
|
585
|
+
}) {
|
|
586
|
+
const remediation = options.remediation ?? "Recreate the write dry-run before applying or staging.";
|
|
587
|
+
if (receipt.integrity?.algorithm !== "hmac-sha256") {
|
|
588
|
+
throw new Error(`Receipt ${receipt.id} is missing local integrity seal. ${remediation}`);
|
|
589
|
+
}
|
|
590
|
+
const expected = await this.receiptDigest(receipt);
|
|
591
|
+
if (!safeHexEqual(receipt.integrity.digest, expected)) {
|
|
592
|
+
throw new Error(`Receipt ${receipt.id} failed local integrity verification. ${remediation}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async redactReceiptForDisplay(receipt) {
|
|
596
|
+
return redactReceiptForDisplay(receipt, await this.receiptIntegrityInspectionStatus(receipt));
|
|
597
|
+
}
|
|
598
|
+
async receiptIntegrityInspectionStatus(receipt) {
|
|
599
|
+
if (receipt.integrity?.algorithm !== "hmac-sha256") {
|
|
600
|
+
return { trusted: false, reason: "missing local integrity seal" };
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
const expected = await this.receiptDigest(receipt);
|
|
604
|
+
if (!safeHexEqual(receipt.integrity.digest, expected)) {
|
|
605
|
+
return { trusted: false, reason: "local integrity verification failed" };
|
|
606
|
+
}
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
611
|
+
return { trusted: false, reason: "missing local integrity key" };
|
|
612
|
+
}
|
|
613
|
+
return { trusted: false, reason: `local integrity unavailable: ${errorMessage(error)}` };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async receiptDigest(receipt) {
|
|
617
|
+
const key = await this.readReceiptIntegrityKey();
|
|
618
|
+
return createHmac("sha256", Buffer.from(key, "hex")).update(canonicalJson(stripReceiptIntegrity(receipt))).digest("hex");
|
|
619
|
+
}
|
|
620
|
+
async ensureReceiptIntegrityKey() {
|
|
621
|
+
try {
|
|
622
|
+
return await this.readReceiptIntegrityKey();
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
if (!isErrorCode(error, "ENOENT"))
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
628
|
+
const key = randomBytes(RECEIPT_INTEGRITY_KEY_BYTES).toString("hex");
|
|
629
|
+
await writeVerifiedUtf8File(this.receiptIntegrityKeyPath(), `${key}\n`, () => this.assertReceiptIntegrityKeyTargetSafe(), {
|
|
630
|
+
create: true,
|
|
631
|
+
mode: BRIDGE_FILE_MODE
|
|
632
|
+
});
|
|
633
|
+
return key;
|
|
634
|
+
}
|
|
635
|
+
async readReceiptIntegrityKey() {
|
|
636
|
+
const key = (await readVerifiedUtf8File(this.receiptIntegrityKeyPath(), () => this.assertReceiptIntegrityKeyTargetSafe({ allowMissing: false }), {
|
|
637
|
+
mode: BRIDGE_FILE_MODE
|
|
638
|
+
})).trim();
|
|
639
|
+
if (!/^[a-f0-9]{64}$/.test(key)) {
|
|
640
|
+
throw new Error(".bridge/receipt-key.local is corrupt. Move it aside and recreate write receipts.");
|
|
641
|
+
}
|
|
642
|
+
return key;
|
|
643
|
+
}
|
|
644
|
+
async ensureBridgeGitignore() {
|
|
645
|
+
const ignorePath = path.join(this.bridgeDir, ".gitignore");
|
|
646
|
+
let current = "";
|
|
647
|
+
try {
|
|
648
|
+
current = await readVerifiedUtf8File(ignorePath, () => this.assertBridgeGitignoreTargetSafe());
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
if (!isErrorCode(error, "ENOENT"))
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
const required = [
|
|
655
|
+
"tasks/*.json",
|
|
656
|
+
"results/*.json",
|
|
657
|
+
"sessions/*.json",
|
|
658
|
+
"receipts/*.json",
|
|
659
|
+
"artifacts/*",
|
|
660
|
+
"config.local.json",
|
|
661
|
+
"receipt-key.local",
|
|
662
|
+
"!.gitignore"
|
|
663
|
+
];
|
|
664
|
+
const lines = new Set(current.split(/\r?\n/).filter(Boolean));
|
|
665
|
+
for (const line of required)
|
|
666
|
+
lines.add(line);
|
|
667
|
+
await writeVerifiedUtf8File(ignorePath, `${Array.from(lines).join("\n")}\n`, () => this.assertBridgeGitignoreTargetSafe(), {
|
|
668
|
+
create: true
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
async hasReadyBridgeStorageReadOnly() {
|
|
672
|
+
try {
|
|
673
|
+
await this.assertStorageDirsAreRealDirectories();
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
if (isErrorCode(error, "ENOENT"))
|
|
678
|
+
return false;
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async hasReadyStorageDirReadOnly(kind) {
|
|
683
|
+
try {
|
|
684
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
if (isErrorCode(error, "ENOENT"))
|
|
689
|
+
return false;
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Allocate a record id and write it with an exclusive create, retrying with a -N suffix on
|
|
694
|
+
// collision, so id allocation and the write are a single atomic step. The previous check-then-
|
|
695
|
+
// write (exists() loop + overwrite) was a TOCTOU: two concurrent creates with the same
|
|
696
|
+
// timestamp+title both saw "absent" and resolved to the same id, silently overwriting one
|
|
697
|
+
// record under the normal Codex+Claude shared-.bridge mode. This mirrors the exclusive-create
|
|
698
|
+
// path already used for results.
|
|
699
|
+
async createWithUniqueId(prefix, title, kind, build) {
|
|
700
|
+
for (let attempt = 1;; attempt += 1) {
|
|
701
|
+
const base = makeBridgeId(prefix, title);
|
|
702
|
+
const id = attempt === 1 ? base : `${base}-${attempt}`;
|
|
703
|
+
const record = await build(id);
|
|
704
|
+
if (await this.writeNewRecordJson(kind, id, record))
|
|
705
|
+
return record;
|
|
706
|
+
if (attempt >= 10000) {
|
|
707
|
+
throw new Error(`Unable to allocate a unique ${prefix} id under .bridge/${kind} after ${attempt} attempts`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
pathFor(kind, id) {
|
|
712
|
+
assertBridgeRecordId(kind, id);
|
|
713
|
+
return path.join(this.dir(kind), `${id}.json`);
|
|
714
|
+
}
|
|
715
|
+
async readAll(kind, parseRecord, options = {}) {
|
|
716
|
+
const dir = this.dir(kind);
|
|
717
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
718
|
+
const items = [];
|
|
719
|
+
for (const entry of entries) {
|
|
720
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
721
|
+
continue;
|
|
722
|
+
const id = entry.name.replace(/\.json$/, "");
|
|
723
|
+
if (!isBridgeRecordId(kind, id))
|
|
724
|
+
continue;
|
|
725
|
+
items.push(this.parseRecord(kind, id, await this.readRecordJson(kind, id, options), parseRecord));
|
|
726
|
+
}
|
|
727
|
+
return items;
|
|
728
|
+
}
|
|
729
|
+
parseRecord(kind, id, value, parseRecord) {
|
|
730
|
+
try {
|
|
731
|
+
return parseRecord(id, value);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
throw recordCorruptError(kind, id, this.pathFor(kind, id), this.root, error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async readRecordJson(kind, id, options = {}) {
|
|
738
|
+
const filePath = this.pathFor(kind, id);
|
|
739
|
+
try {
|
|
740
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
741
|
+
if (options.cleanupTempHardLinks ?? true) {
|
|
742
|
+
await this.cleanupRecordTempHardLinks(kind, filePath);
|
|
743
|
+
}
|
|
744
|
+
const raw = await readVerifiedUtf8File(filePath, () => this.assertRecordTargetInside(kind, filePath));
|
|
745
|
+
try {
|
|
746
|
+
return JSON.parse(raw);
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
throw recordCorruptError(kind, id, filePath, this.root, error);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
754
|
+
throw recordNotFoundError(kind, id);
|
|
755
|
+
}
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async writeRecordJson(kind, id, value) {
|
|
760
|
+
const filePath = this.pathFor(kind, id);
|
|
761
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
762
|
+
await this.assertRecordTargetInsideIfExists(kind, filePath);
|
|
763
|
+
await this.writeJson(kind, filePath, value);
|
|
764
|
+
await this.assertRecordTargetInside(kind, filePath);
|
|
765
|
+
}
|
|
766
|
+
async writeNewRecordJson(kind, id, value) {
|
|
767
|
+
const filePath = this.pathFor(kind, id);
|
|
768
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
769
|
+
const wrote = await this.writeJsonIfAbsent(kind, filePath, value);
|
|
770
|
+
if (!wrote)
|
|
771
|
+
return false;
|
|
772
|
+
await this.assertRecordTargetInside(kind, filePath);
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
async writeJson(kind, filePath, value) {
|
|
776
|
+
await this.writeTextByRename(kind, filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
777
|
+
}
|
|
778
|
+
async writeJsonIfAbsent(kind, filePath, value) {
|
|
779
|
+
return await this.writeTextByCreateExclusive(kind, filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
780
|
+
}
|
|
781
|
+
async writeTextByRename(kind, filePath, content) {
|
|
782
|
+
if (hasStableDirectoryFdPaths()) {
|
|
783
|
+
await this.writeTextByStableStorageRename(kind, filePath, content);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
throw new Error("Bridge record writes require stable directory file descriptor paths on this platform.");
|
|
787
|
+
}
|
|
788
|
+
async writeTextByCreateExclusive(kind, filePath, content) {
|
|
789
|
+
if (hasStableDirectoryFdPaths()) {
|
|
790
|
+
return await this.writeTextByStableStorageLinkIfAbsent(kind, filePath, content);
|
|
791
|
+
}
|
|
792
|
+
throw new Error("Bridge record writes require stable directory file descriptor paths on this platform.");
|
|
793
|
+
}
|
|
794
|
+
async deleteRecordIfPresent(kind, id) {
|
|
795
|
+
const filePath = this.pathFor(kind, id);
|
|
796
|
+
const fileName = path.basename(filePath);
|
|
797
|
+
const expectedDir = this.dir(kind);
|
|
798
|
+
if (path.dirname(filePath) !== expectedDir) {
|
|
799
|
+
throw new Error(`Bridge record path must stay under .bridge/${kind}`);
|
|
800
|
+
}
|
|
801
|
+
const bridgeHandle = await openNoFollowDirectory(this.bridgeDir, "Bridge directory");
|
|
802
|
+
try {
|
|
803
|
+
const storageHandle = await openNoFollowDirectory(path.join(directoryFdPath(bridgeHandle.fd), kind), `Bridge storage directory .bridge/${kind}`);
|
|
804
|
+
try {
|
|
805
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
806
|
+
const targetPath = path.join(directoryFdPath(storageHandle.fd), fileName);
|
|
807
|
+
let stat;
|
|
808
|
+
try {
|
|
809
|
+
stat = await lstat(targetPath);
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
if (isErrorCode(error, "ENOENT"))
|
|
813
|
+
return;
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
817
|
+
throw new Error(`Bridge record path for .bridge/${kind} must be a regular file and must not be a symlink`);
|
|
818
|
+
}
|
|
819
|
+
await rm(targetPath, { force: true });
|
|
820
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
await storageHandle.close();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
finally {
|
|
827
|
+
await bridgeHandle.close();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async writeTextByStableStorageRename(kind, filePath, content) {
|
|
831
|
+
const fileName = path.basename(filePath);
|
|
832
|
+
const expectedDir = this.dir(kind);
|
|
833
|
+
if (path.dirname(filePath) !== expectedDir) {
|
|
834
|
+
throw new Error(`Bridge record path must stay under .bridge/${kind}`);
|
|
835
|
+
}
|
|
836
|
+
const bridgeHandle = await openNoFollowDirectory(this.bridgeDir, "Bridge directory");
|
|
837
|
+
try {
|
|
838
|
+
const storageHandle = await openNoFollowDirectory(path.join(directoryFdPath(bridgeHandle.fd), kind), `Bridge storage directory .bridge/${kind}`);
|
|
839
|
+
try {
|
|
840
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
841
|
+
const storageFdPath = directoryFdPath(storageHandle.fd);
|
|
842
|
+
const targetPath = path.join(storageFdPath, fileName);
|
|
843
|
+
await assertRegularFileIfExists(targetPath, `Bridge record path for .bridge/${kind}`);
|
|
844
|
+
const tmpPath = path.join(storageFdPath, `.${fileName}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
|
|
845
|
+
try {
|
|
846
|
+
await writeVerifiedUtf8File(tmpPath, content, async () => assertOpenDirectoryHandle(storageHandle), {
|
|
847
|
+
create: true,
|
|
848
|
+
mode: BRIDGE_FILE_MODE
|
|
849
|
+
});
|
|
850
|
+
await storeTestHooks.beforeRecordRename?.(kind, filePath);
|
|
851
|
+
await rename(tmpPath, targetPath);
|
|
852
|
+
await assertRegularFileIfExists(targetPath, `Bridge record path for .bridge/${kind}`);
|
|
853
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
857
|
+
throw error;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
await storageHandle.close();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
finally {
|
|
865
|
+
await bridgeHandle.close();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
async writeTextByStableStorageLinkIfAbsent(kind, filePath, content) {
|
|
869
|
+
const fileName = path.basename(filePath);
|
|
870
|
+
const expectedDir = this.dir(kind);
|
|
871
|
+
if (path.dirname(filePath) !== expectedDir) {
|
|
872
|
+
throw new Error(`Bridge record path must stay under .bridge/${kind}`);
|
|
873
|
+
}
|
|
874
|
+
const bridgeHandle = await openNoFollowDirectory(this.bridgeDir, "Bridge directory");
|
|
875
|
+
try {
|
|
876
|
+
const storageHandle = await openNoFollowDirectory(path.join(directoryFdPath(bridgeHandle.fd), kind), `Bridge storage directory .bridge/${kind}`);
|
|
877
|
+
try {
|
|
878
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
879
|
+
const storageFdPath = directoryFdPath(storageHandle.fd);
|
|
880
|
+
const targetPath = path.join(storageFdPath, fileName);
|
|
881
|
+
const tmpPath = path.join(storageFdPath, `.${fileName}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
|
|
882
|
+
let linked = false;
|
|
883
|
+
try {
|
|
884
|
+
await writeVerifiedUtf8File(tmpPath, content, async () => assertOpenDirectoryHandle(storageHandle), {
|
|
885
|
+
create: true,
|
|
886
|
+
exclusive: true,
|
|
887
|
+
mode: BRIDGE_FILE_MODE
|
|
888
|
+
});
|
|
889
|
+
try {
|
|
890
|
+
await storeTestHooks.beforeRecordRename?.(kind, filePath);
|
|
891
|
+
await link(tmpPath, targetPath);
|
|
892
|
+
}
|
|
893
|
+
catch (error) {
|
|
894
|
+
if (isErrorCode(error, "EEXIST")) {
|
|
895
|
+
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
900
|
+
linked = true;
|
|
901
|
+
await rm(tmpPath, { force: true });
|
|
902
|
+
await assertRegularFileIfExists(targetPath, `Bridge record path for .bridge/${kind}`);
|
|
903
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
904
|
+
}
|
|
905
|
+
catch (error) {
|
|
906
|
+
if (!linked)
|
|
907
|
+
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
finally {
|
|
912
|
+
await storageHandle.close();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
await bridgeHandle.close();
|
|
917
|
+
}
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
async cleanupRecordTempHardLinks(kind, filePath) {
|
|
921
|
+
const fileName = path.basename(filePath);
|
|
922
|
+
const expectedDir = this.dir(kind);
|
|
923
|
+
if (path.dirname(filePath) !== expectedDir) {
|
|
924
|
+
throw new Error(`Bridge record path must stay under .bridge/${kind}`);
|
|
925
|
+
}
|
|
926
|
+
const bridgeHandle = await openNoFollowDirectory(this.bridgeDir, "Bridge directory");
|
|
927
|
+
try {
|
|
928
|
+
const storageHandle = await openNoFollowDirectory(path.join(directoryFdPath(bridgeHandle.fd), kind), `Bridge storage directory .bridge/${kind}`);
|
|
929
|
+
try {
|
|
930
|
+
const storageFdPath = directoryFdPath(storageHandle.fd);
|
|
931
|
+
const targetPath = path.join(storageFdPath, fileName);
|
|
932
|
+
let targetStat;
|
|
933
|
+
try {
|
|
934
|
+
targetStat = await lstat(targetPath);
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
if (isErrorCode(error, "ENOENT"))
|
|
938
|
+
return;
|
|
939
|
+
throw error;
|
|
940
|
+
}
|
|
941
|
+
if (targetStat.isSymbolicLink() || !targetStat.isFile() || targetStat.nlink <= 1)
|
|
942
|
+
return;
|
|
943
|
+
await storeTestHooks.beforeRecordTempCleanup?.(kind, filePath);
|
|
944
|
+
const tempPrefix = `.${fileName}.`;
|
|
945
|
+
const entries = await readdir(storageFdPath, { withFileTypes: true });
|
|
946
|
+
for (const entry of entries) {
|
|
947
|
+
if (!entry.isFile() || !entry.name.startsWith(tempPrefix) || !entry.name.endsWith(".tmp"))
|
|
948
|
+
continue;
|
|
949
|
+
const tempPath = path.join(storageFdPath, entry.name);
|
|
950
|
+
const tempStat = await lstat(tempPath).catch(() => undefined);
|
|
951
|
+
if (!tempStat?.isFile() || tempStat.isSymbolicLink())
|
|
952
|
+
continue;
|
|
953
|
+
if (tempStat.dev === targetStat.dev && tempStat.ino === targetStat.ino) {
|
|
954
|
+
await rm(tempPath, { force: true });
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
await storageHandle.close();
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
finally {
|
|
963
|
+
await bridgeHandle.close();
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
resolveArtifactPath(relativePath) {
|
|
967
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
968
|
+
if (!normalized.startsWith(".bridge/artifacts/")) {
|
|
969
|
+
throw new Error("Artifact path must be under .bridge/artifacts");
|
|
970
|
+
}
|
|
971
|
+
const resolved = path.resolve(this.root, normalized);
|
|
972
|
+
const relative = path.relative(this.dir("artifacts"), resolved);
|
|
973
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
974
|
+
throw new Error("Artifact path must stay under .bridge/artifacts");
|
|
975
|
+
}
|
|
976
|
+
return resolved;
|
|
977
|
+
}
|
|
978
|
+
relativeToRoot(filePath) {
|
|
979
|
+
return path.relative(this.root, filePath).replaceAll(path.sep, "/");
|
|
980
|
+
}
|
|
981
|
+
async assertArtifactTargetInside(filePath) {
|
|
982
|
+
const stat = await lstat(filePath);
|
|
983
|
+
if (stat.isSymbolicLink()) {
|
|
984
|
+
throw new Error("Artifact path must not be a symlink");
|
|
985
|
+
}
|
|
986
|
+
await this.assertRealPathInsideArtifacts(filePath);
|
|
987
|
+
}
|
|
988
|
+
async assertArtifactsDirIsRealDirectory() {
|
|
989
|
+
await this.assertStorageDirIsRealDirectory("artifacts");
|
|
990
|
+
}
|
|
991
|
+
async ensureArtifactParentDirectory(parentPath) {
|
|
992
|
+
await this.walkArtifactParentDirectory(parentPath, true);
|
|
993
|
+
}
|
|
994
|
+
async assertArtifactParentDirectory(parentPath) {
|
|
995
|
+
await this.walkArtifactParentDirectory(parentPath, false);
|
|
996
|
+
}
|
|
997
|
+
async walkArtifactParentDirectory(parentPath, createMissing) {
|
|
998
|
+
const artifactsDir = this.dir("artifacts");
|
|
999
|
+
const relative = path.relative(artifactsDir, parentPath);
|
|
1000
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1001
|
+
throw new Error("Artifact path must stay under .bridge/artifacts");
|
|
1002
|
+
}
|
|
1003
|
+
let current = artifactsDir;
|
|
1004
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
1005
|
+
current = path.join(current, segment);
|
|
1006
|
+
if (createMissing) {
|
|
1007
|
+
await this.ensureRealDirectorySegment(current);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
await this.assertRealDirectorySegment(current);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
async ensureRealDirectorySegment(dirPath) {
|
|
1015
|
+
try {
|
|
1016
|
+
const stat = await lstat(dirPath);
|
|
1017
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1018
|
+
throw new Error("Artifact path must stay under .bridge/artifacts");
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
const maybe = error;
|
|
1023
|
+
if (maybe.code !== "ENOENT")
|
|
1024
|
+
throw error;
|
|
1025
|
+
await mkdir(dirPath, { mode: BRIDGE_DIRECTORY_MODE });
|
|
1026
|
+
const stat = await lstat(dirPath);
|
|
1027
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1028
|
+
throw new Error("Artifact path must stay under .bridge/artifacts");
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
await chmodPrivateDirectory(dirPath, "Artifact directory");
|
|
1032
|
+
}
|
|
1033
|
+
async assertRealDirectorySegment(dirPath) {
|
|
1034
|
+
const stat = await lstat(dirPath);
|
|
1035
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1036
|
+
throw new Error("Artifact path must stay under .bridge/artifacts");
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
async assertBridgeDirIsRealDirectory() {
|
|
1040
|
+
const stat = await lstat(this.bridgeDir);
|
|
1041
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1042
|
+
throw new Error("Bridge directory must be a real directory");
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
async assertBridgeGitignoreTargetSafe(options = { allowMissing: true }) {
|
|
1046
|
+
await this.assertBridgeDirIsRealDirectory();
|
|
1047
|
+
const ignorePath = path.join(this.bridgeDir, ".gitignore");
|
|
1048
|
+
try {
|
|
1049
|
+
const stat = await lstat(ignorePath);
|
|
1050
|
+
if (stat.isSymbolicLink()) {
|
|
1051
|
+
throw new Error(".bridge/.gitignore must not be a symlink");
|
|
1052
|
+
}
|
|
1053
|
+
if (!stat.isFile()) {
|
|
1054
|
+
throw new Error(".bridge/.gitignore must be a regular file");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
catch (error) {
|
|
1058
|
+
if (isErrorCode(error, "ENOENT") && options.allowMissing !== false)
|
|
1059
|
+
return;
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async assertReceiptIntegrityKeyTargetSafe(options = { allowMissing: true }) {
|
|
1064
|
+
await this.assertBridgeDirIsRealDirectory();
|
|
1065
|
+
const keyPath = this.receiptIntegrityKeyPath();
|
|
1066
|
+
try {
|
|
1067
|
+
const stat = await lstat(keyPath);
|
|
1068
|
+
if (stat.isSymbolicLink()) {
|
|
1069
|
+
throw new Error(".bridge/receipt-key.local must not be a symlink");
|
|
1070
|
+
}
|
|
1071
|
+
if (!stat.isFile()) {
|
|
1072
|
+
throw new Error(".bridge/receipt-key.local must be a regular file");
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
if (isErrorCode(error, "ENOENT") && options.allowMissing !== false)
|
|
1077
|
+
return;
|
|
1078
|
+
throw error;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
async assertStorageDirsAreRealDirectories() {
|
|
1082
|
+
await Promise.all(["tasks", "results", "sessions", "artifacts", "receipts"].map((kind) => this.assertStorageDirIsRealDirectory(kind)));
|
|
1083
|
+
}
|
|
1084
|
+
async assertStorageDirIsRealDirectory(kind) {
|
|
1085
|
+
await this.assertBridgeDirIsRealDirectory();
|
|
1086
|
+
const stat = await lstat(this.dir(kind));
|
|
1087
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1088
|
+
throw new Error(`Bridge storage directory .bridge/${kind} must be a real directory`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
async assertRecordTargetInside(kind, filePath) {
|
|
1092
|
+
await this.assertStorageDirIsRealDirectory(kind);
|
|
1093
|
+
const stat = await lstat(filePath);
|
|
1094
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1095
|
+
throw new Error(`Bridge record path for .bridge/${kind} must be a regular file and must not be a symlink`);
|
|
1096
|
+
}
|
|
1097
|
+
await this.assertRealPathInsideStorageDir(kind, filePath);
|
|
1098
|
+
}
|
|
1099
|
+
async assertRecordTargetInsideIfExists(kind, filePath) {
|
|
1100
|
+
try {
|
|
1101
|
+
await this.assertRecordTargetInside(kind, filePath);
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
const maybe = error;
|
|
1105
|
+
if (maybe.code !== "ENOENT")
|
|
1106
|
+
throw error;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
async assertArtifactTargetInsideIfExists(filePath) {
|
|
1110
|
+
try {
|
|
1111
|
+
await this.assertArtifactTargetInside(filePath);
|
|
1112
|
+
}
|
|
1113
|
+
catch (error) {
|
|
1114
|
+
const maybe = error;
|
|
1115
|
+
if (maybe.code !== "ENOENT")
|
|
1116
|
+
throw error;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
async assertRealPathInsideArtifacts(filePath) {
|
|
1120
|
+
const [realArtifacts, realTarget] = await Promise.all([realpath(this.dir("artifacts")), realpath(filePath)]);
|
|
1121
|
+
const relative = path.relative(realArtifacts, realTarget);
|
|
1122
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1123
|
+
throw new Error("Artifact path must stay under .bridge/artifacts");
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
async assertRealPathInsideStorageDir(kind, filePath) {
|
|
1127
|
+
const [realStorageDir, realTarget] = await Promise.all([realpath(this.dir(kind)), realpath(filePath)]);
|
|
1128
|
+
const relative = path.relative(realStorageDir, realTarget);
|
|
1129
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1130
|
+
throw new Error(`Bridge record path must stay under .bridge/${kind}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function validateTaskFiles(files) {
|
|
1135
|
+
for (const file of files) {
|
|
1136
|
+
assertRepoRelativePath(file.path);
|
|
1137
|
+
}
|
|
1138
|
+
return files;
|
|
1139
|
+
}
|
|
1140
|
+
function recordNotFoundError(kind, id) {
|
|
1141
|
+
const error = new Error(`${recordLabel(kind)} not found: ${id}`);
|
|
1142
|
+
error.code = "ENOENT";
|
|
1143
|
+
return error;
|
|
1144
|
+
}
|
|
1145
|
+
function recordCorruptError(kind, id, filePath, root, cause) {
|
|
1146
|
+
assertBridgeRecordId(kind, id);
|
|
1147
|
+
return new Error(`${recordLabel(kind)} record is corrupt: ${formatRecordPath(root, filePath)}. Move it aside or fix the JSON, then retry.`, {
|
|
1148
|
+
cause
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
function untrustedResultError(root, taskId, reason) {
|
|
1152
|
+
const error = new Error(`Result record is untrusted: ${formatRecordPath(root, path.join(root, ".bridge", "results", `${taskId}.json`))} ${reason}. If this is a locally signed legacy completion receipt, review .bridge/results/${taskId}.json, then run \`prodex results reseal ${taskId} --confirm-current-result\`. Retry the completion path or move the result record aside, then retry.`);
|
|
1153
|
+
error.code = "EUNTRUSTED_RESULT";
|
|
1154
|
+
error.taskId = taskId;
|
|
1155
|
+
return error;
|
|
1156
|
+
}
|
|
1157
|
+
function formatRecordPath(root, filePath) {
|
|
1158
|
+
const relative = path.relative(root, filePath);
|
|
1159
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
|
|
1160
|
+
return filePath;
|
|
1161
|
+
return relative.split(path.sep).join("/");
|
|
1162
|
+
}
|
|
1163
|
+
function recordLabel(kind) {
|
|
1164
|
+
switch (kind) {
|
|
1165
|
+
case "tasks":
|
|
1166
|
+
return "Task";
|
|
1167
|
+
case "results":
|
|
1168
|
+
return "Result";
|
|
1169
|
+
case "sessions":
|
|
1170
|
+
return "Session";
|
|
1171
|
+
case "receipts":
|
|
1172
|
+
return "Receipt";
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
function isErrorCode(error, code) {
|
|
1176
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1177
|
+
}
|
|
1178
|
+
function errorMessage(error) {
|
|
1179
|
+
return error instanceof Error ? error.message : String(error);
|
|
1180
|
+
}
|
|
1181
|
+
function isFetchableResultArtifactPath(normalizedPath) {
|
|
1182
|
+
return FETCHABLE_RESULT_ARTIFACT_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix));
|
|
1183
|
+
}
|
|
1184
|
+
function sha256(value) {
|
|
1185
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
1186
|
+
}
|
|
1187
|
+
function assertFetchableResultArtifacts(artifacts) {
|
|
1188
|
+
for (const artifact of artifacts) {
|
|
1189
|
+
if (artifact.role !== "result")
|
|
1190
|
+
continue;
|
|
1191
|
+
const normalizedPath = path.posix.normalize(artifact.path.replaceAll("\\", "/"));
|
|
1192
|
+
if (!isFetchableResultArtifactPath(normalizedPath) || normalizedPath !== artifact.path) {
|
|
1193
|
+
throw new Error(`Artifact is not a fetchable result artifact: ${artifact.path}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
function parseTaskRecord(expectedId, value) {
|
|
1198
|
+
const task = TaskSchema.parse(value);
|
|
1199
|
+
assertMatchingRecordIdentity("task", expectedId, task.id);
|
|
1200
|
+
return task;
|
|
1201
|
+
}
|
|
1202
|
+
function parseResultRecord(expectedTaskId, value) {
|
|
1203
|
+
const result = ResultSchema.parse(value);
|
|
1204
|
+
assertMatchingRecordIdentity("result", expectedTaskId, result.task_id, "task_id");
|
|
1205
|
+
return result;
|
|
1206
|
+
}
|
|
1207
|
+
function parseSessionRecord(expectedId, value) {
|
|
1208
|
+
const session = SessionSchema.parse(value);
|
|
1209
|
+
assertMatchingRecordIdentity("session", expectedId, session.id);
|
|
1210
|
+
return session;
|
|
1211
|
+
}
|
|
1212
|
+
function parseReceiptRecord(expectedId, value) {
|
|
1213
|
+
const receipt = ReceiptSchema.parse(value);
|
|
1214
|
+
assertMatchingRecordIdentity("receipt", expectedId, receipt.id);
|
|
1215
|
+
return receipt;
|
|
1216
|
+
}
|
|
1217
|
+
function assertMatchingRecordIdentity(kind, expectedId, actualId, field = "id") {
|
|
1218
|
+
if (actualId !== expectedId) {
|
|
1219
|
+
throw new Error(`${kind} ${field} ${actualId} does not match ${kind} record ${expectedId}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
function stripReceiptIntegrity(receipt) {
|
|
1223
|
+
const { integrity: _integrity, ...unsigned } = receipt;
|
|
1224
|
+
return unsigned;
|
|
1225
|
+
}
|
|
1226
|
+
function assertReceiptResultDigest(receipt, result) {
|
|
1227
|
+
const expected = resultDigest(result);
|
|
1228
|
+
const actual = receipt.metadata?.result_sha256;
|
|
1229
|
+
if (actual === undefined) {
|
|
1230
|
+
throw new Error(`Receipt ${receipt.id} is missing result_sha256`);
|
|
1231
|
+
}
|
|
1232
|
+
if (typeof actual !== "string" || !safeHexEqual(actual, expected)) {
|
|
1233
|
+
throw new Error(`Receipt ${receipt.id} does not match current result payload: result_sha256 mismatch`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
function assertLegacyCompletionReceiptCanBeResealed(receipt, result) {
|
|
1237
|
+
const actual = receipt.metadata?.result_sha256;
|
|
1238
|
+
if (actual === undefined)
|
|
1239
|
+
return;
|
|
1240
|
+
if (typeof actual === "string" && safeHexEqual(actual, resultDigest(result))) {
|
|
1241
|
+
throw new Error(`Receipt ${receipt.id} already matches current result payload`);
|
|
1242
|
+
}
|
|
1243
|
+
throw new Error(`Receipt ${receipt.id} is not a legacy completion receipt: result_sha256 is already present`);
|
|
1244
|
+
}
|
|
1245
|
+
function resultDigest(result) {
|
|
1246
|
+
return sha256(canonicalJson(result));
|
|
1247
|
+
}
|
|
1248
|
+
function canonicalJson(value) {
|
|
1249
|
+
return JSON.stringify(canonicalize(value));
|
|
1250
|
+
}
|
|
1251
|
+
function canonicalize(value) {
|
|
1252
|
+
if (Array.isArray(value))
|
|
1253
|
+
return value.map((item) => canonicalize(item));
|
|
1254
|
+
if (value && typeof value === "object") {
|
|
1255
|
+
const record = value;
|
|
1256
|
+
const canonical = {};
|
|
1257
|
+
for (const key of Object.keys(record).sort()) {
|
|
1258
|
+
if (record[key] !== undefined)
|
|
1259
|
+
canonical[key] = canonicalize(record[key]);
|
|
1260
|
+
}
|
|
1261
|
+
return canonical;
|
|
1262
|
+
}
|
|
1263
|
+
return value;
|
|
1264
|
+
}
|
|
1265
|
+
function safeHexEqual(left, right) {
|
|
1266
|
+
if (!/^[a-f0-9]{64}$/.test(left) || !/^[a-f0-9]{64}$/.test(right))
|
|
1267
|
+
return false;
|
|
1268
|
+
return timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex"));
|
|
1269
|
+
}
|
|
1270
|
+
function assertResultMatchesRetry(taskId, existing, retry) {
|
|
1271
|
+
if (JSON.stringify(resultRetryFingerprint(existing)) !== JSON.stringify(resultRetryFingerprint(retry))) {
|
|
1272
|
+
throw new Error(`Task ${taskId} already has a different result and cannot be finalized again`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
function terminalTaskMissingResultError(task) {
|
|
1276
|
+
return new Error(`Task ${task.id} is ${task.status} but .bridge/results/${task.id}.json is missing. Restore the result file, retry with the original completion record if you have it, or move the terminal task record aside, then retry.`);
|
|
1277
|
+
}
|
|
1278
|
+
function resultRetryFingerprint(result) {
|
|
1279
|
+
return {
|
|
1280
|
+
task_id: result.task_id,
|
|
1281
|
+
status: result.status,
|
|
1282
|
+
summary: result.summary,
|
|
1283
|
+
artifacts: result.artifacts.map(resultArtifactRetryFingerprint),
|
|
1284
|
+
commands: result.commands,
|
|
1285
|
+
warnings: result.warnings,
|
|
1286
|
+
blocker: result.blocker
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function resultArtifactRetryFingerprint(artifact) {
|
|
1290
|
+
return {
|
|
1291
|
+
path: artifact.path,
|
|
1292
|
+
role: artifact.role
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
async function openNoFollowDirectory(dirPath, label) {
|
|
1296
|
+
const noFollowFlag = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
1297
|
+
const directoryFlag = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
|
|
1298
|
+
try {
|
|
1299
|
+
const handle = await open(dirPath, constants.O_RDONLY | directoryFlag | noFollowFlag);
|
|
1300
|
+
try {
|
|
1301
|
+
await assertOpenDirectoryHandle(handle);
|
|
1302
|
+
return handle;
|
|
1303
|
+
}
|
|
1304
|
+
catch (error) {
|
|
1305
|
+
await handle.close().catch(() => undefined);
|
|
1306
|
+
throw error;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
catch (error) {
|
|
1310
|
+
const maybe = error;
|
|
1311
|
+
if (maybe.code === "ELOOP" || maybe.code === "ENOTDIR") {
|
|
1312
|
+
throw new Error(`${label} must be a real directory and must not be a symlink`);
|
|
1313
|
+
}
|
|
1314
|
+
throw error;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
async function ensurePrivateDirectory(dirPath, label) {
|
|
1318
|
+
await mkdir(dirPath, { recursive: true, mode: BRIDGE_DIRECTORY_MODE });
|
|
1319
|
+
await chmodPrivateDirectory(dirPath, label);
|
|
1320
|
+
}
|
|
1321
|
+
async function chmodPrivateDirectory(dirPath, label) {
|
|
1322
|
+
const handle = await openNoFollowDirectory(dirPath, label);
|
|
1323
|
+
try {
|
|
1324
|
+
await handle.chmod(BRIDGE_DIRECTORY_MODE);
|
|
1325
|
+
await assertOpenDirectoryHandle(handle);
|
|
1326
|
+
}
|
|
1327
|
+
finally {
|
|
1328
|
+
await handle.close();
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
async function assertOpenDirectoryHandle(handle) {
|
|
1332
|
+
const stat = await handle.stat();
|
|
1333
|
+
if (!stat.isDirectory()) {
|
|
1334
|
+
throw new Error("Bridge storage directory handle must remain a real directory");
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
async function assertRegularFileIfExists(filePath, label) {
|
|
1338
|
+
try {
|
|
1339
|
+
const stat = await lstat(filePath);
|
|
1340
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1341
|
+
throw new Error(`${label} must be a regular file and must not be a symlink`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
catch (error) {
|
|
1345
|
+
const maybe = error;
|
|
1346
|
+
if (maybe.code !== "ENOENT")
|
|
1347
|
+
throw error;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
function hasStableDirectoryFdPaths() {
|
|
1351
|
+
return directoryFdPathBase() !== undefined;
|
|
1352
|
+
}
|
|
1353
|
+
function directoryFdPath(fd) {
|
|
1354
|
+
const base = directoryFdPathBase();
|
|
1355
|
+
if (!base) {
|
|
1356
|
+
throw new Error("Bridge record writes require stable directory file descriptor paths on this platform.");
|
|
1357
|
+
}
|
|
1358
|
+
return `${base}/${fd}`;
|
|
1359
|
+
}
|
|
1360
|
+
function directoryFdPathBase() {
|
|
1361
|
+
if (storeTestHooks.disableDirectoryFdPaths)
|
|
1362
|
+
return undefined;
|
|
1363
|
+
if (existsSync("/proc/self/fd"))
|
|
1364
|
+
return "/proc/self/fd";
|
|
1365
|
+
if (existsSync("/dev/fd"))
|
|
1366
|
+
return "/dev/fd";
|
|
1367
|
+
return undefined;
|
|
1368
|
+
}
|
|
1369
|
+
function assertBridgeRecordId(kind, id) {
|
|
1370
|
+
if (!isBridgeRecordId(kind, id)) {
|
|
1371
|
+
throw new Error(`Invalid bridge record id for ${kind}: ${id}`);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
function isBridgeRecordId(kind, id) {
|
|
1375
|
+
const pattern = kind === "receipts" ? RECEIPT_ID_PATTERN : kind === "sessions" ? SESSION_ID_PATTERN : TASK_ID_PATTERN;
|
|
1376
|
+
return pattern.test(id);
|
|
1377
|
+
}
|
|
1378
|
+
function redactReceiptForDisplay(receipt, integrityStatus) {
|
|
1379
|
+
const metadata = { ...receipt.metadata };
|
|
1380
|
+
delete metadata.integrity_status;
|
|
1381
|
+
if (Object.hasOwn(metadata, "new_content")) {
|
|
1382
|
+
const inlineContent = metadata.new_content;
|
|
1383
|
+
delete metadata.new_content;
|
|
1384
|
+
metadata.new_content_redacted = {
|
|
1385
|
+
reason: "legacy inline replacement content",
|
|
1386
|
+
...(typeof inlineContent === "string" ? { bytes: Buffer.byteLength(inlineContent, "utf8") } : {})
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
if (Object.hasOwn(metadata, "diff")) {
|
|
1390
|
+
const diff = metadata.diff;
|
|
1391
|
+
delete metadata.diff;
|
|
1392
|
+
metadata.diff_redacted = {
|
|
1393
|
+
reason: "write preview diff",
|
|
1394
|
+
...(typeof diff === "string" ? { bytes: Buffer.byteLength(diff, "utf8") } : {})
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
if (integrityStatus) {
|
|
1398
|
+
metadata.integrity_status = integrityStatus;
|
|
1399
|
+
}
|
|
1400
|
+
return ReceiptSchema.parse({ ...receipt, metadata });
|
|
1401
|
+
}
|
|
1402
|
+
//# sourceMappingURL=store.js.map
|