@tickernelz/paperclip-pro-adapter-utils 2026.1006.0 → 2026.1006.2
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/README.md +13 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts +4 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.js +29 -0
- package/dist/acpx-engine/ephemeral-session-environment.js.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts +2 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js +177 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js.map +1 -0
- package/dist/acpx-engine/execute.d.ts +2 -6
- package/dist/acpx-engine/execute.d.ts.map +1 -1
- package/dist/acpx-engine/execute.js +125 -62
- package/dist/acpx-engine/execute.js.map +1 -1
- package/dist/acpx-engine/execute.test.js +299 -3
- package/dist/acpx-engine/execute.test.js.map +1 -1
- package/dist/acpx-engine/session-codec.d.ts.map +1 -1
- package/dist/acpx-engine/session-codec.js +1 -0
- package/dist/acpx-engine/session-codec.js.map +1 -1
- package/dist/acpx-engine/settlement-characterization.test.js +105 -0
- package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
- package/dist/acpx-engine/spawn-smoke.test.js +16 -7
- package/dist/acpx-engine/spawn-smoke.test.js.map +1 -1
- package/dist/acpx-engine/startup-cancellation.d.ts +1 -0
- package/dist/acpx-engine/startup-cancellation.d.ts.map +1 -1
- package/dist/acpx-engine/startup-cancellation.js +7 -2
- package/dist/acpx-engine/startup-cancellation.js.map +1 -1
- package/dist/acpx-engine/terminal-session-failure.d.ts +18 -0
- package/dist/acpx-engine/terminal-session-failure.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.js +106 -0
- package/dist/acpx-engine/terminal-session-failure.js.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts +2 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.js +139 -0
- package/dist/acpx-engine/terminal-session-failure.test.js.map +1 -0
- package/dist/acpx-engine/turn-characterization.test.js +4 -3
- package/dist/acpx-engine/turn-characterization.test.js.map +1 -1
- package/dist/command-managed-runtime.d.ts +2 -0
- package/dist/command-managed-runtime.d.ts.map +1 -1
- package/dist/command-managed-runtime.js +2 -0
- package/dist/command-managed-runtime.js.map +1 -1
- package/dist/command-redaction.d.ts +2 -0
- package/dist/command-redaction.d.ts.map +1 -1
- package/dist/command-redaction.js +20 -10
- package/dist/command-redaction.js.map +1 -1
- package/dist/command-redaction.test.js +20 -1
- package/dist/command-redaction.test.js.map +1 -1
- package/dist/directory-merge-lock.test.d.ts +2 -0
- package/dist/directory-merge-lock.test.d.ts.map +1 -0
- package/dist/directory-merge-lock.test.js +166 -0
- package/dist/directory-merge-lock.test.js.map +1 -0
- package/dist/execution-phase.d.ts +10 -0
- package/dist/execution-phase.d.ts.map +1 -0
- package/dist/execution-phase.js +27 -0
- package/dist/execution-phase.js.map +1 -0
- package/dist/execution-phase.test.d.ts +2 -0
- package/dist/execution-phase.test.d.ts.map +1 -0
- package/dist/execution-phase.test.js +26 -0
- package/dist/execution-phase.test.js.map +1 -0
- package/dist/execution-target-sandbox.test.js +49 -7
- package/dist/execution-target-sandbox.test.js.map +1 -1
- package/dist/execution-target-stdin-race.test.js +245 -2
- package/dist/execution-target-stdin-race.test.js.map +1 -1
- package/dist/execution-target.d.ts +6 -0
- package/dist/execution-target.d.ts.map +1 -1
- package/dist/execution-target.js +74 -22
- package/dist/execution-target.js.map +1 -1
- package/dist/git-workspace-sync.d.ts +15 -5
- package/dist/git-workspace-sync.d.ts.map +1 -1
- package/dist/git-workspace-sync.js +267 -124
- package/dist/git-workspace-sync.js.map +1 -1
- package/dist/git-workspace-sync.test.js +296 -20
- package/dist/git-workspace-sync.test.js.map +1 -1
- package/dist/github-launcher.d.ts.map +1 -1
- package/dist/github-launcher.js +26 -11
- package/dist/github-launcher.js.map +1 -1
- package/dist/github-launcher.test.js +34 -1
- package/dist/github-launcher.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paperclip-runner-permissions.d.ts +28 -1
- package/dist/paperclip-runner-permissions.d.ts.map +1 -1
- package/dist/paperclip-runner-permissions.js +9 -1
- package/dist/paperclip-runner-permissions.js.map +1 -1
- package/dist/prompt-sections.test.d.ts +2 -0
- package/dist/prompt-sections.test.d.ts.map +1 -0
- package/dist/prompt-sections.test.js +53 -0
- package/dist/prompt-sections.test.js.map +1 -0
- package/dist/provider-stopped-boundary.d.ts +7 -0
- package/dist/provider-stopped-boundary.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.js +22 -0
- package/dist/provider-stopped-boundary.js.map +1 -0
- package/dist/provider-stopped-boundary.test.d.ts +2 -0
- package/dist/provider-stopped-boundary.test.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.test.js +76 -0
- package/dist/provider-stopped-boundary.test.js.map +1 -0
- package/dist/remote-managed-runtime.d.ts +2 -0
- package/dist/remote-managed-runtime.d.ts.map +1 -1
- package/dist/remote-managed-runtime.js +3 -1
- package/dist/remote-managed-runtime.js.map +1 -1
- package/dist/remote-managed-runtime.test.js +25 -0
- package/dist/remote-managed-runtime.test.js.map +1 -1
- package/dist/sandbox-callback-bridge.d.ts +2 -0
- package/dist/sandbox-callback-bridge.d.ts.map +1 -1
- package/dist/sandbox-callback-bridge.js +34 -10
- package/dist/sandbox-callback-bridge.js.map +1 -1
- package/dist/sandbox-callback-bridge.test.js +106 -3
- package/dist/sandbox-callback-bridge.test.js.map +1 -1
- package/dist/sandbox-managed-runtime.d.ts +3 -0
- package/dist/sandbox-managed-runtime.d.ts.map +1 -1
- package/dist/sandbox-managed-runtime.js +749 -718
- package/dist/sandbox-managed-runtime.js.map +1 -1
- package/dist/sandbox-managed-runtime.test.js +171 -2
- package/dist/sandbox-managed-runtime.test.js.map +1 -1
- package/dist/server-utils.d.ts +43 -1
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +103 -31
- package/dist/server-utils.js.map +1 -1
- package/dist/server-utils.test.js +235 -45
- package/dist/server-utils.test.js.map +1 -1
- package/dist/session-compaction.d.ts.map +1 -1
- package/dist/session-compaction.js +6 -0
- package/dist/session-compaction.js.map +1 -1
- package/dist/ssh-fixture.test.js +22 -0
- package/dist/ssh-fixture.test.js.map +1 -1
- package/dist/ssh.d.ts +2 -0
- package/dist/ssh.d.ts.map +1 -1
- package/dist/ssh.js +2 -2
- package/dist/ssh.js.map +1 -1
- package/dist/test-fixtures/prompt-context.d.ts +54 -0
- package/dist/test-fixtures/prompt-context.d.ts.map +1 -0
- package/dist/test-fixtures/prompt-context.js +55 -0
- package/dist/test-fixtures/prompt-context.js.map +1 -0
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace-git-stream.d.ts +19 -0
- package/dist/workspace-git-stream.d.ts.map +1 -0
- package/dist/workspace-git-stream.js +99 -0
- package/dist/workspace-git-stream.js.map +1 -0
- package/dist/workspace-git-stream.test.d.ts +2 -0
- package/dist/workspace-git-stream.test.d.ts.map +1 -0
- package/dist/workspace-git-stream.test.js +71 -0
- package/dist/workspace-git-stream.test.js.map +1 -0
- package/dist/workspace-manifest.d.ts +61 -0
- package/dist/workspace-manifest.d.ts.map +1 -0
- package/dist/workspace-manifest.js +257 -0
- package/dist/workspace-manifest.js.map +1 -0
- package/dist/workspace-manifest.test.d.ts +2 -0
- package/dist/workspace-manifest.test.d.ts.map +1 -0
- package/dist/workspace-manifest.test.js +160 -0
- package/dist/workspace-manifest.test.js.map +1 -0
- package/dist/workspace-restore-diagnostics.d.ts +34 -0
- package/dist/workspace-restore-diagnostics.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.js +238 -0
- package/dist/workspace-restore-diagnostics.js.map +1 -0
- package/dist/workspace-restore-diagnostics.test.d.ts +2 -0
- package/dist/workspace-restore-diagnostics.test.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.test.js +258 -0
- package/dist/workspace-restore-diagnostics.test.js.map +1 -0
- package/dist/workspace-restore-merge.d.ts +47 -6
- package/dist/workspace-restore-merge.d.ts.map +1 -1
- package/dist/workspace-restore-merge.js +382 -114
- package/dist/workspace-restore-merge.js.map +1 -1
- package/dist/workspace-restore-merge.test.js +253 -4
- package/dist/workspace-restore-merge.test.js.map +1 -1
- package/dist/workspace-restore-result.d.ts +6 -0
- package/dist/workspace-restore-result.d.ts.map +1 -0
- package/dist/workspace-restore-result.js +72 -0
- package/dist/workspace-restore-result.js.map +1 -0
- package/dist/workspace-restore-result.test.d.ts +2 -0
- package/dist/workspace-restore-result.test.d.ts.map +1 -0
- package/dist/workspace-restore-result.test.js +74 -0
- package/dist/workspace-restore-result.test.js.map +1 -0
- package/dist/workspace-restore-teardown.d.ts.map +1 -1
- package/dist/workspace-restore-teardown.js +5 -3
- package/dist/workspace-restore-teardown.js.map +1 -1
- package/dist/workspace-restore-teardown.test.js +24 -0
- package/dist/workspace-restore-teardown.test.js.map +1 -1
- package/node_modules/acpx/dist/live-checkpoint-ClPCSdrW.js +4 -3
- package/package.json +2 -2
|
@@ -1,9 +1,33 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { constants as fsConstants, promises as fs } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { createWorkspaceManifest, WorkspaceManifestMap, workspacePathMatcher } from "./workspace-manifest.js";
|
|
5
7
|
import { shouldExcludePath } from "./exclude-patterns.js";
|
|
6
8
|
import { resolvePaperclipInstanceRootForAdapter } from "./server-utils.js";
|
|
9
|
+
const ownedDirectorySnapshots = new WeakMap();
|
|
10
|
+
export async function disposeDirectorySnapshot(snapshot) {
|
|
11
|
+
if (!snapshot)
|
|
12
|
+
return;
|
|
13
|
+
if (snapshot.entries instanceof WorkspaceManifestMap) {
|
|
14
|
+
snapshot.entries.close();
|
|
15
|
+
const ownedDirectory = ownedDirectorySnapshots.get(snapshot);
|
|
16
|
+
ownedDirectorySnapshots.delete(snapshot);
|
|
17
|
+
if (ownedDirectory)
|
|
18
|
+
await fs.rm(ownedDirectory, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function parseManifestEntry(value) {
|
|
22
|
+
const result = parseSnapshotEntry(JSON.parse(value));
|
|
23
|
+
if (!result)
|
|
24
|
+
throw new Error("Invalid workspace baseline entry");
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
/** Call only after the controller validates a persisted manifest's path and digest. */
|
|
28
|
+
export function openDirectorySnapshot(value) {
|
|
29
|
+
return { exclude: value.exclude, entries: new WorkspaceManifestMap(value.entries, parseManifestEntry), ignoredPaths: value.ignoredPaths };
|
|
30
|
+
}
|
|
7
31
|
function isSafeSnapshotRelativePath(value) {
|
|
8
32
|
if (!value || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
|
9
33
|
return false;
|
|
@@ -30,6 +54,11 @@ function parseSnapshotEntry(value) {
|
|
|
30
54
|
return null;
|
|
31
55
|
}
|
|
32
56
|
export function serializeDirectorySnapshot(snapshot) {
|
|
57
|
+
if (snapshot.entries instanceof WorkspaceManifestMap)
|
|
58
|
+
return {
|
|
59
|
+
version: 2, exclude: [...snapshot.exclude], entries: snapshot.entries.manifest,
|
|
60
|
+
...(snapshot.ignoredPaths ? { ignoredPaths: snapshot.ignoredPaths } : {}),
|
|
61
|
+
};
|
|
33
62
|
return {
|
|
34
63
|
version: 1,
|
|
35
64
|
exclude: [...snapshot.exclude],
|
|
@@ -65,9 +94,13 @@ export function parseDirectorySnapshot(value) {
|
|
|
65
94
|
};
|
|
66
95
|
}
|
|
67
96
|
export function directorySnapshotSha256(snapshot) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
97
|
+
if (!(snapshot.entries instanceof WorkspaceManifestMap))
|
|
98
|
+
return createHash("sha256")
|
|
99
|
+
.update(JSON.stringify(serializeDirectorySnapshot(snapshot))).digest("hex");
|
|
100
|
+
const digest = createHash("sha256").update("workspace-baseline-v2\0").update(JSON.stringify(snapshot.exclude));
|
|
101
|
+
for (const entry of snapshot.entries)
|
|
102
|
+
digest.update(JSON.stringify(entry)).update("\0");
|
|
103
|
+
return digest.digest("hex");
|
|
71
104
|
}
|
|
72
105
|
async function hashFile(filePath) {
|
|
73
106
|
return await new Promise((resolve, reject) => {
|
|
@@ -78,38 +111,25 @@ async function hashFile(filePath) {
|
|
|
78
111
|
stream.on("end", () => resolve(hash.digest("hex")));
|
|
79
112
|
});
|
|
80
113
|
}
|
|
81
|
-
async function walkDirectory(root, exclude, relative = ""
|
|
114
|
+
async function* walkDirectory(root, exclude, ignored, relative = "") {
|
|
82
115
|
const current = relative ? path.join(root, relative) : root;
|
|
83
|
-
const
|
|
84
|
-
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
85
|
-
for (const entry of entries) {
|
|
116
|
+
for await (const entry of await fs.opendir(current)) {
|
|
86
117
|
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
|
87
|
-
if (shouldExcludePath(nextRelative, exclude))
|
|
118
|
+
if (shouldExcludePath(nextRelative, exclude) || ignored.matches(nextRelative))
|
|
88
119
|
continue;
|
|
89
120
|
const fullPath = path.join(root, nextRelative);
|
|
90
121
|
const stats = await fs.lstat(fullPath);
|
|
91
|
-
if (!stats.isDirectory() && !stats.isSymbolicLink() && !stats.isFile()) {
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
122
|
if (stats.isDirectory()) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
continue;
|
|
123
|
+
yield [nextRelative, { kind: "dir" }];
|
|
124
|
+
yield* walkDirectory(root, exclude, ignored, nextRelative);
|
|
98
125
|
}
|
|
99
|
-
if (stats.isSymbolicLink()) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
continue;
|
|
126
|
+
else if (stats.isSymbolicLink()) {
|
|
127
|
+
yield [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
|
|
128
|
+
}
|
|
129
|
+
else if (stats.isFile()) {
|
|
130
|
+
yield [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
|
|
105
131
|
}
|
|
106
|
-
out.set(nextRelative, {
|
|
107
|
-
kind: "file",
|
|
108
|
-
mode: stats.mode,
|
|
109
|
-
hash: await hashFile(fullPath),
|
|
110
|
-
});
|
|
111
132
|
}
|
|
112
|
-
return out;
|
|
113
133
|
}
|
|
114
134
|
async function readSnapshotEntry(root, relative) {
|
|
115
135
|
const fullPath = path.join(root, relative);
|
|
@@ -151,7 +171,69 @@ function entriesMatch(left, right) {
|
|
|
151
171
|
}
|
|
152
172
|
return false;
|
|
153
173
|
}
|
|
154
|
-
const
|
|
174
|
+
const LOCK_WAIT_MS = 30_000;
|
|
175
|
+
const LOCK_DIAGNOSTIC_READ_TIMEOUT_MS = 100;
|
|
176
|
+
const activeDirectoryMergeLocks = new Set();
|
|
177
|
+
const MAX_LOCK_DIAGNOSTIC_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
178
|
+
/** Evidence only: neither process age nor this module's holder set can prove
|
|
179
|
+
* that a lock in another process or PID namespace is safe to reclaim. */
|
|
180
|
+
async function directoryMergeLockDiagnostics(lockDir, waitMs, ownerPath) {
|
|
181
|
+
const diagnostics = {
|
|
182
|
+
ownerState: "unknown",
|
|
183
|
+
knownLocalHolder: activeDirectoryMergeLocks.has(lockDir),
|
|
184
|
+
waitMs: Math.min(MAX_LOCK_DIAGNOSTIC_AGE_MS, Math.max(0, Math.floor(waitMs))),
|
|
185
|
+
};
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
let timeout;
|
|
188
|
+
try {
|
|
189
|
+
// Abort is best effort: race the read as well so a stalled filesystem
|
|
190
|
+
// cannot keep the original lock timeout from reaching its caller.
|
|
191
|
+
const raw = await Promise.race([
|
|
192
|
+
fs.readFile(ownerPath, { encoding: "utf8", signal: controller.signal }),
|
|
193
|
+
new Promise((resolve) => {
|
|
194
|
+
timeout = setTimeout(() => resolve(undefined), LOCK_DIAGNOSTIC_READ_TIMEOUT_MS);
|
|
195
|
+
}),
|
|
196
|
+
]);
|
|
197
|
+
if (raw === undefined)
|
|
198
|
+
return diagnostics;
|
|
199
|
+
let owner;
|
|
200
|
+
try {
|
|
201
|
+
owner = JSON.parse(raw);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
diagnostics.ownerState = "invalid";
|
|
205
|
+
return diagnostics;
|
|
206
|
+
}
|
|
207
|
+
if (!owner || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
|
|
208
|
+
diagnostics.ownerState = "invalid";
|
|
209
|
+
return diagnostics;
|
|
210
|
+
}
|
|
211
|
+
const pid = owner.pid;
|
|
212
|
+
diagnostics.ownerSameProcess = pid === process.pid;
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, 0);
|
|
215
|
+
diagnostics.ownerState = "alive";
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
diagnostics.ownerState = error.code === "ESRCH" ? "dead" : "unknown";
|
|
219
|
+
}
|
|
220
|
+
const createdAt = typeof owner.createdAt === "string" ? Date.parse(owner.createdAt) : NaN;
|
|
221
|
+
const ageMs = Date.now() - createdAt;
|
|
222
|
+
if (Number.isFinite(ageMs) && ageMs >= 0) {
|
|
223
|
+
diagnostics.ownerAgeMs = Math.min(MAX_LOCK_DIAGNOSTIC_AGE_MS, Math.floor(ageMs));
|
|
224
|
+
if (pid === process.pid)
|
|
225
|
+
diagnostics.ownerPredatesProcess = ageMs > process.uptime() * 1000 + 1000;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
diagnostics.ownerState = error.code === "ENOENT" ? "missing" : "unknown";
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
controller.abort();
|
|
234
|
+
}
|
|
235
|
+
return diagnostics;
|
|
236
|
+
}
|
|
155
237
|
/**
|
|
156
238
|
* The stable `code` a lock-timeout error carries, so a caller can identify it
|
|
157
239
|
* without matching on the error message text (the message embeds the lock
|
|
@@ -163,8 +245,9 @@ export const WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE = "ERR_WORKSPACE_RESTORE_LOCK_T
|
|
|
163
245
|
* `EACCES` and `EPERM` to a permission failure, the merge-lock timeout
|
|
164
246
|
* (matched by {@link WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE}, never by the error
|
|
165
247
|
* message text) to a lock-timeout failure, and every other error to a generic
|
|
166
|
-
* failure.
|
|
167
|
-
*
|
|
248
|
+
* failure. The known Daytona confinement diagnostic also identifies unsafe
|
|
249
|
+
* archives across plugin transports that retain only a message. Never returns
|
|
250
|
+
* raw messages, paths or process IDs.
|
|
168
251
|
*/
|
|
169
252
|
export function classifyWorkspaceRestoreFailure(error) {
|
|
170
253
|
const code = error && typeof error === "object" ? error.code : undefined;
|
|
@@ -172,6 +255,16 @@ export function classifyWorkspaceRestoreFailure(error) {
|
|
|
172
255
|
return "restore_permission_denied";
|
|
173
256
|
if (code === WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE)
|
|
174
257
|
return "restore_lock_timeout";
|
|
258
|
+
const message = error instanceof Error ? error.message : "";
|
|
259
|
+
const archiveRefused = /Daytona syncOut refusing (?:tarball (?:with an unparseable entry listing|(?:link whose target|member that) escapes the extraction dir)|unparseable or ambiguous (?:sym|hard)link entry)/.test(message);
|
|
260
|
+
const outboundPathRefused = /Daytona sync source path (?:is not a confined absolute path|escapes the workspace remote dir):/.test(message);
|
|
261
|
+
// These are the fail-closed guard's own exit codes. Transport/command failures
|
|
262
|
+
// with other exit codes retain the existing transient failure policy.
|
|
263
|
+
const outboundGuardRefused = /Daytona outbound symlink-escape guard command failed \(exit (?:40|41|42|44|45)\)/.test(message);
|
|
264
|
+
if (code === "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" ||
|
|
265
|
+
archiveRefused || outboundPathRefused || outboundGuardRefused) {
|
|
266
|
+
return "restore_unsafe_archive";
|
|
267
|
+
}
|
|
175
268
|
return "restore_failed";
|
|
176
269
|
}
|
|
177
270
|
/**
|
|
@@ -188,67 +281,99 @@ export function describeWorkspaceRestoreFailure(code) {
|
|
|
188
281
|
return "the restore could not write to the workspace (permission denied)";
|
|
189
282
|
case "restore_lock_timeout":
|
|
190
283
|
return "the restore timed out waiting for the workspace merge lock";
|
|
284
|
+
case "restore_unsafe_archive":
|
|
285
|
+
return "the archive contains an unsafe link or path; workspace repair is required";
|
|
191
286
|
case "restore_failed":
|
|
192
287
|
return "the restore failed";
|
|
193
288
|
}
|
|
194
289
|
}
|
|
195
|
-
async function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return true;
|
|
290
|
+
async function acquireDirectoryMergeLock(lockDir, operation, waitMs = LOCK_WAIT_MS) {
|
|
291
|
+
const startedAt = performance.now();
|
|
292
|
+
const deadline = Date.now() + waitMs;
|
|
293
|
+
const databasePath = `${lockDir}.sqlite`;
|
|
294
|
+
const ownerPath = `${lockDir}.owner.json`;
|
|
295
|
+
async function waitForLock(diagnosticOwnerPath) {
|
|
296
|
+
if (Date.now() >= deadline) {
|
|
297
|
+
const timeoutError = new Error(`Timed out waiting for workspace restore lock at ${lockDir}`);
|
|
298
|
+
timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE;
|
|
299
|
+
// Keep the original timeout if the diagnostic read itself fails.
|
|
300
|
+
timeoutError.workspaceRestoreLock = await directoryMergeLockDiagnostics(lockDir, performance.now() - startedAt, diagnosticOwnerPath).catch(() => undefined);
|
|
301
|
+
if (operation && timeoutError.workspaceRestoreLock)
|
|
302
|
+
timeoutError.workspaceRestoreLock.operation = operation;
|
|
303
|
+
throw timeoutError;
|
|
210
304
|
}
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
211
306
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
307
|
+
// SQLite's RESERVED file lock is the authority, including across processes
|
|
308
|
+
// and PID namespaces. It is released by the OS on a crash. The empty database
|
|
309
|
+
// is permanent: unlinking it would let contenders lock different inodes.
|
|
310
|
+
// node:sqlite is already required for workspace manifests; no native add-on
|
|
311
|
+
// or external flock command is needed on macOS, Linux, or Windows.
|
|
312
|
+
const databaseStat = await fs.lstat(databasePath).catch((error) => {
|
|
313
|
+
if (error.code === "ENOENT")
|
|
314
|
+
return null;
|
|
315
|
+
throw error;
|
|
316
|
+
});
|
|
317
|
+
if (databaseStat && (!databaseStat.isFile() || databaseStat.isSymbolicLink() || databaseStat.nlink !== 1)) {
|
|
318
|
+
throw new Error("Directory merge lock database is not a plain, unshared file.");
|
|
222
319
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
320
|
+
// Let SQLite create and manage every descriptor for this inode. On POSIX,
|
|
321
|
+
// closing a raw fs.open descriptor could release another connection's locks.
|
|
322
|
+
// The parent is private (0700), including while a new file is chmodded.
|
|
323
|
+
const database = new DatabaseSync(databasePath, { allowExtension: false });
|
|
324
|
+
try {
|
|
325
|
+
await fs.chmod(databasePath, 0o600);
|
|
326
|
+
// Never block the event loop while another async operation holds the lock.
|
|
327
|
+
database.exec("PRAGMA busy_timeout=0;");
|
|
328
|
+
while (true) {
|
|
329
|
+
try {
|
|
330
|
+
database.exec("BEGIN IMMEDIATE;");
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
const code = error.errcode;
|
|
335
|
+
if (typeof code !== "number" || (code & 0xff) !== 5)
|
|
336
|
+
throw error; // SQLITE_BUSY
|
|
337
|
+
await waitForLock(ownerPath);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Old processes do not participate in the SQLite protocol. Never infer
|
|
341
|
+
// that a legacy owner is dead from PID existence, age, or missing metadata.
|
|
342
|
+
// Drain old writers before upgrading. A leftover legacy directory requires
|
|
343
|
+
// explicit offline cleanup; a live legacy holder can still release normally.
|
|
344
|
+
while (await fs.lstat(lockDir).then(() => true, (error) => {
|
|
345
|
+
if (error.code === "ENOENT")
|
|
346
|
+
return false;
|
|
347
|
+
throw error;
|
|
348
|
+
}))
|
|
349
|
+
await waitForLock(path.join(lockDir, "owner.json"));
|
|
350
|
+
const owner = await fs.open(ownerPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW, 0o600);
|
|
227
351
|
try {
|
|
228
|
-
await
|
|
229
|
-
await fs.writeFile(path.join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
|
|
230
|
-
return async () => {
|
|
231
|
-
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
232
|
-
};
|
|
352
|
+
await owner.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
|
|
233
353
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
354
|
+
finally {
|
|
355
|
+
await owner.close();
|
|
356
|
+
}
|
|
357
|
+
activeDirectoryMergeLocks.add(lockDir);
|
|
358
|
+
let released = false;
|
|
359
|
+
return async () => {
|
|
360
|
+
if (released)
|
|
361
|
+
return;
|
|
362
|
+
released = true;
|
|
363
|
+
try {
|
|
364
|
+
// This sidecar is diagnostic only. A failed removal cannot retain
|
|
365
|
+
// ownership, and the next holder replaces it while holding the DB lock.
|
|
366
|
+
await fs.unlink(ownerPath).catch(() => undefined);
|
|
244
367
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
throw timeoutError;
|
|
368
|
+
finally {
|
|
369
|
+
activeDirectoryMergeLocks.delete(lockDir);
|
|
370
|
+
database.close();
|
|
249
371
|
}
|
|
250
|
-
|
|
251
|
-
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
database.close();
|
|
376
|
+
throw error;
|
|
252
377
|
}
|
|
253
378
|
}
|
|
254
379
|
const DIRECTORY_MERGE_LOCK_ROOT_MODE = 0o700;
|
|
@@ -316,13 +441,16 @@ async function resolveDirectoryMergeLockRoot(env = process.env) {
|
|
|
316
441
|
}
|
|
317
442
|
return lockRoot;
|
|
318
443
|
}
|
|
319
|
-
export async function withDirectoryMergeLock(targetDir, fn, env = process.env
|
|
444
|
+
export async function withDirectoryMergeLock(targetDir, fn, env = process.env, diagnosticOperation,
|
|
445
|
+
// Test seam only: overrides how long acquisition waits before it reports a
|
|
446
|
+
// timeout. Production callers must omit this and keep the real budget.
|
|
447
|
+
waitMs = LOCK_WAIT_MS) {
|
|
320
448
|
// Canonicalize before we hash or lock: a retargeted symlink must not let the
|
|
321
449
|
// lock protect one directory while the caller mutates another.
|
|
322
450
|
const canonicalTargetDir = await fs.realpath(targetDir);
|
|
323
451
|
const lockRoot = await resolveDirectoryMergeLockRoot(env);
|
|
324
452
|
const lockKey = createHash("sha256").update(canonicalTargetDir).digest("hex");
|
|
325
|
-
const releaseLock = await acquireDirectoryMergeLock(path.join(lockRoot, `${lockKey}.lock`));
|
|
453
|
+
const releaseLock = await acquireDirectoryMergeLock(path.join(lockRoot, `${lockKey}.lock`), diagnosticOperation, waitMs);
|
|
326
454
|
try {
|
|
327
455
|
return await fn(canonicalTargetDir);
|
|
328
456
|
}
|
|
@@ -345,50 +473,190 @@ async function copySnapshotEntry(sourceDir, targetDir, relative, entry) {
|
|
|
345
473
|
return;
|
|
346
474
|
}
|
|
347
475
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
348
|
-
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
|
|
349
476
|
if (entry.kind === "symlink") {
|
|
477
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
350
478
|
await fs.symlink(entry.target, targetPath);
|
|
351
479
|
return;
|
|
352
480
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
});
|
|
356
|
-
|
|
481
|
+
// An interrupted restore must not leave a truncated current file. Keep the
|
|
482
|
+
// incoming tree until its owner records success; exact retries deduplicate.
|
|
483
|
+
const temporary = path.join(path.dirname(targetPath), `.paperclip-merge-${randomUUID()}`);
|
|
484
|
+
try {
|
|
485
|
+
await fs.copyFile(sourcePath, temporary, fsConstants.COPYFILE_FICLONE).catch(async () => {
|
|
486
|
+
await fs.copyFile(sourcePath, temporary);
|
|
487
|
+
});
|
|
488
|
+
await fs.chmod(temporary, entry.mode);
|
|
489
|
+
const file = await fs.open(temporary, "r");
|
|
490
|
+
try {
|
|
491
|
+
await file.sync();
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
await file.close();
|
|
495
|
+
}
|
|
496
|
+
const existing = await fs.lstat(targetPath).catch(() => null);
|
|
497
|
+
if (existing?.isDirectory())
|
|
498
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
499
|
+
await fs.rename(temporary, targetPath);
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
await fs.rm(temporary, { force: true });
|
|
503
|
+
}
|
|
357
504
|
}
|
|
358
505
|
export async function captureDirectorySnapshot(rootDir, options = {}) {
|
|
359
506
|
const exclude = [...new Set(options.exclude ?? [])];
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
507
|
+
const ignored = workspacePathMatcher(options.ignoredPaths);
|
|
508
|
+
let writer = null;
|
|
509
|
+
try {
|
|
510
|
+
writer = options.diskBacked ? await createWorkspaceManifest("paperclip-workspace-baseline-") : null;
|
|
511
|
+
const memory = new Map();
|
|
512
|
+
for await (const [relative, entry] of walkDirectory(rootDir, exclude, ignored)) {
|
|
513
|
+
if (writer)
|
|
514
|
+
writer.add("baseline", relative, JSON.stringify(entry));
|
|
515
|
+
else
|
|
516
|
+
memory.set(relative, entry);
|
|
517
|
+
}
|
|
518
|
+
const manifest = writer?.paths("baseline");
|
|
519
|
+
writer?.close();
|
|
520
|
+
const snapshot = {
|
|
521
|
+
exclude, ignoredPaths: options.ignoredPaths,
|
|
522
|
+
entries: manifest ? new WorkspaceManifestMap(manifest, parseManifestEntry) : memory,
|
|
523
|
+
};
|
|
524
|
+
if (writer)
|
|
525
|
+
ownedDirectorySnapshots.set(snapshot, path.dirname(writer.filePath));
|
|
526
|
+
return snapshot;
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
writer?.close(false);
|
|
530
|
+
if (writer)
|
|
531
|
+
await fs.rm(path.dirname(writer.filePath), { recursive: true, force: true });
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
ignored.close();
|
|
536
|
+
}
|
|
364
537
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
.
|
|
373
|
-
for (const [relative, baselineEntry] of deletedLeafEntries) {
|
|
374
|
-
if (!entriesMatch(current.entries.get(relative), baselineEntry))
|
|
538
|
+
/** A disk-backed subset for independent nested-repository merges. */
|
|
539
|
+
export async function selectDirectorySnapshot(snapshot, options) {
|
|
540
|
+
const writer = await createWorkspaceManifest("paperclip-workspace-baseline-");
|
|
541
|
+
try {
|
|
542
|
+
for (const [relative, entry] of snapshot.entries) {
|
|
543
|
+
if (options.prefix && !relative.startsWith(options.prefix))
|
|
544
|
+
continue;
|
|
545
|
+
if (options.omit?.some((omit) => relative === omit || relative.startsWith(`${omit}/`)))
|
|
375
546
|
continue;
|
|
376
|
-
|
|
547
|
+
writer.add("baseline", options.prefix ? relative.slice(options.prefix.length) : relative, JSON.stringify(entry));
|
|
377
548
|
}
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
549
|
+
const result = { exclude: options.exclude, ignoredPaths: options.ignoredPaths,
|
|
550
|
+
entries: new WorkspaceManifestMap(writer.paths("baseline"), parseManifestEntry) };
|
|
551
|
+
writer.close();
|
|
552
|
+
ownedDirectorySnapshots.set(result, path.dirname(writer.filePath));
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
writer.close(false);
|
|
557
|
+
await fs.rm(path.dirname(writer.filePath), { recursive: true, force: true });
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function orderedEntries(snapshot, reverse = false) {
|
|
562
|
+
if (snapshot.entries instanceof WorkspaceManifestMap)
|
|
563
|
+
return snapshot.entries.entries(reverse);
|
|
564
|
+
return [...snapshot.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0) * (reverse ? -1 : 1));
|
|
565
|
+
}
|
|
566
|
+
export class DirectoryMergeConflict extends Error {
|
|
567
|
+
paths;
|
|
568
|
+
code = "DIRECTORY_MERGE_CONFLICT";
|
|
569
|
+
constructor(paths) {
|
|
570
|
+
super("Directory contents changed concurrently");
|
|
571
|
+
this.paths = paths;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/** Preflight the entire delta before writing. Identical replays are safe after
|
|
575
|
+
* an interrupted apply; unrelated edits are left alone. No history is retained. */
|
|
576
|
+
export function directoryMergeConflicts(baseline, source, current) {
|
|
577
|
+
const same = (a, b) => (!a && !b) || entriesMatch(a, b);
|
|
578
|
+
const conflicts = new Set();
|
|
579
|
+
function* changedPaths() {
|
|
580
|
+
for (const [name] of baseline.entries)
|
|
581
|
+
yield name;
|
|
582
|
+
for (const [name] of source.entries)
|
|
583
|
+
if (!baseline.entries.has(name))
|
|
584
|
+
yield name;
|
|
585
|
+
}
|
|
586
|
+
for (const relative of changedPaths()) {
|
|
587
|
+
const before = baseline.entries.get(relative);
|
|
588
|
+
const incoming = source.entries.get(relative);
|
|
589
|
+
const present = current.entries.get(relative);
|
|
590
|
+
if (same(before, incoming) || same(incoming, present))
|
|
591
|
+
continue;
|
|
592
|
+
if (!same(before, present))
|
|
593
|
+
conflicts.add(relative);
|
|
594
|
+
// A parent removed/replaced by another writer must never be traversed.
|
|
595
|
+
for (let parent = path.posix.dirname(relative); parent !== "."; parent = path.posix.dirname(parent)) {
|
|
596
|
+
if (current.entries.get(parent)?.kind !== "dir" &&
|
|
597
|
+
!same(current.entries.get(parent), baseline.entries.get(parent)))
|
|
598
|
+
conflicts.add(parent);
|
|
383
599
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
600
|
+
}
|
|
601
|
+
// Stream each current entry once. A replacement must not remove children
|
|
602
|
+
// omitted from the baseline, including excluded or newly created files.
|
|
603
|
+
for (const [child, entry] of current.entries) {
|
|
604
|
+
if (same(entry, baseline.entries.get(child)) || same(entry, source.entries.get(child)))
|
|
605
|
+
continue;
|
|
606
|
+
for (let parent = path.posix.dirname(child); parent !== "."; parent = path.posix.dirname(parent)) {
|
|
607
|
+
if (baseline.entries.get(parent)?.kind === "dir" && source.entries.get(parent)?.kind !== "dir") {
|
|
608
|
+
conflicts.add(child);
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
389
611
|
}
|
|
390
|
-
|
|
391
|
-
|
|
612
|
+
}
|
|
613
|
+
return [...conflicts].sort();
|
|
614
|
+
}
|
|
615
|
+
export async function mergeDirectoryWithBaseline(input) {
|
|
616
|
+
const options = { exclude: input.baseline.exclude, ignoredPaths: input.baseline.ignoredPaths, diskBacked: true };
|
|
617
|
+
const source = input.snapshots?.source ?? await captureDirectorySnapshot(input.sourceDir, options);
|
|
618
|
+
try {
|
|
619
|
+
await withDirectoryMergeLock(input.targetDir, async (canonicalTargetDir) => {
|
|
620
|
+
await input.beforeApply?.();
|
|
621
|
+
// Strict preflight must see excluded children before a directory is
|
|
622
|
+
// replaced. The merge still applies only the filtered source/baseline.
|
|
623
|
+
const current = input.snapshots?.current ?? await captureDirectorySnapshot(canonicalTargetDir, input.conflictPolicy === "reject" ? { exclude: [], diskBacked: true } : options);
|
|
624
|
+
try {
|
|
625
|
+
if (input.conflictPolicy === "reject") {
|
|
626
|
+
const conflicts = directoryMergeConflicts(input.baseline, source, current);
|
|
627
|
+
if (conflicts.length)
|
|
628
|
+
throw new DirectoryMergeConflict(conflicts);
|
|
629
|
+
}
|
|
630
|
+
for (const [relative, baselineEntry] of orderedEntries(input.baseline)) {
|
|
631
|
+
if (baselineEntry.kind === "dir" || source.entries.has(relative))
|
|
632
|
+
continue;
|
|
633
|
+
if (!entriesMatch(current.entries.get(relative), baselineEntry))
|
|
634
|
+
continue;
|
|
635
|
+
await fs.rm(path.join(canonicalTargetDir, relative), { recursive: true, force: true });
|
|
636
|
+
}
|
|
637
|
+
// Reverse path order visits descendants before their parent directory.
|
|
638
|
+
for (const [relative, entry] of orderedEntries(input.baseline, true)) {
|
|
639
|
+
if (entry.kind === "dir" && !source.entries.has(relative))
|
|
640
|
+
await fs.rmdir(path.join(canonicalTargetDir, relative)).catch((error) => {
|
|
641
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY" && error.code !== "ENOTDIR")
|
|
642
|
+
throw error;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
for (const [relative, entry] of orderedEntries(source)) {
|
|
646
|
+
if (!entriesMatch(input.baseline.entries.get(relative), entry) &&
|
|
647
|
+
!(input.conflictPolicy === "reject" && entriesMatch(current.entries.get(relative), entry)))
|
|
648
|
+
await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry);
|
|
649
|
+
}
|
|
650
|
+
await input.afterApply?.();
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
await disposeDirectorySnapshot(current);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
await disposeDirectorySnapshot(source);
|
|
659
|
+
}
|
|
392
660
|
}
|
|
393
661
|
export async function directoryEntryMatchesBaseline(rootDir, relative, baselineEntry) {
|
|
394
662
|
return entriesMatch(await readSnapshotEntry(rootDir, relative), baselineEntry);
|