@nowcrew/daemon 0.6.19 → 0.6.21
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/dist/atomic-no-replace-rename.js +91 -0
- package/dist/completion-retransmitter-logging.js +16 -0
- package/dist/completion-retransmitter.js +39 -4
- package/dist/control-plane-url.js +4 -2
- package/dist/directory-projection-publication.js +105 -0
- package/dist/directory-projection.js +20 -4
- package/dist/execution-journal.js +40 -4
- package/dist/execution-posix-stop-proof.js +82 -0
- package/dist/execution-runner.js +68 -8
- package/dist/local-executor.js +67 -52
- package/dist/machine-info.js +8 -5
- package/dist/project-skills/capability.js +109 -0
- package/dist/project-skills/controller-convergence.js +57 -0
- package/dist/project-skills/controller.js +80 -24
- package/dist/project-skills/initialized-reconciler.js +4 -4
- package/dist/project-skills/projection-state-domain.js +19 -2
- package/dist/project-skills/projection-state-store.js +3 -2
- package/dist/project-skills/projection-state-transaction.js +5 -1
- package/dist/project-skills/projection-state.js +1 -1
- package/dist/project-skills/reconciler.js +275 -102
- package/dist/project-skills/runtime-launch.js +102 -0
- package/dist/project-skills/runtime-root-bootstrap.js +47 -0
- package/dist/project-skills/runtime-root-domain.js +268 -0
- package/dist/project-skills/runtime-root-gc.js +293 -0
- package/dist/project-skills/runtime-root-lease-artifact.js +46 -0
- package/dist/project-skills/runtime-root-leases.js +487 -0
- package/dist/project-skills/runtime-root-source-identity.js +60 -0
- package/dist/project-skills/runtime-root-startup.js +49 -0
- package/dist/project-skills/runtime-root-state-artifact-domain.js +143 -0
- package/dist/project-skills/runtime-root-state-index.js +356 -0
- package/dist/project-skills/runtime-root-store.js +722 -0
- package/dist/project-skills/serve-capability.js +28 -0
- package/dist/project-skills/serve-startup.js +22 -0
- package/dist/project-skills/types.js +1 -0
- package/dist/runtimes/codex-home-migration-cli.js +26 -0
- package/dist/runtimes/codex-home-migration.js +112 -0
- package/dist/runtimes/codex-home.js +200 -17
- package/dist/serve.js +60 -79
- package/dist/supervised-runtime.js +1 -5
- package/package.json +2 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
export class AtomicNoReplaceRenameError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(code);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "AtomicNoReplaceRenameError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const RENAME_NOREPLACE = 1;
|
|
11
|
+
const RENAME_EXCL = 0x0000_0004;
|
|
12
|
+
const MOVEFILE_WRITE_THROUGH = 0x0000_0008;
|
|
13
|
+
const ERROR_FILE_EXISTS = 80;
|
|
14
|
+
const ERROR_ALREADY_EXISTS = 183;
|
|
15
|
+
const AT_FDCWD = -100;
|
|
16
|
+
let cached = null;
|
|
17
|
+
let unavailable = false;
|
|
18
|
+
const loadKoffi = () => {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- optional koffi has no bundled TS declarations.
|
|
20
|
+
const candidate = createRequire(import.meta.url)("koffi");
|
|
21
|
+
return candidate;
|
|
22
|
+
};
|
|
23
|
+
const posixApi = (koffi, library, declaration, invoke) => {
|
|
24
|
+
const native = koffi.load(library).func(declaration);
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
rename: (source, destination) => invoke(native, source, destination) === 0,
|
|
27
|
+
error: () => koffi.errno(),
|
|
28
|
+
destinationExists: (code) => code === koffi.os.errno.EEXIST || code === koffi.os.errno.ENOTEMPTY,
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const bindNativeApi = () => {
|
|
32
|
+
const koffi = loadKoffi();
|
|
33
|
+
if (process.platform === "darwin") {
|
|
34
|
+
return posixApi(koffi, "libSystem.B.dylib", "int renamex_np(const char *source, const char *destination, uint32_t flags)", (native, source, destination) => native(source, destination, RENAME_EXCL));
|
|
35
|
+
}
|
|
36
|
+
if (process.platform === "linux") {
|
|
37
|
+
return posixApi(koffi, "libc.so.6", "int renameat2(int olddirfd, const char *source, int newdirfd, const char *destination, uint32_t flags)", (native, source, destination) => native(AT_FDCWD, source, AT_FDCWD, destination, RENAME_NOREPLACE));
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === "win32") {
|
|
40
|
+
const library = koffi.load("kernel32.dll");
|
|
41
|
+
const moveFileEx = library.func("bool __stdcall MoveFileExW(str16 source, str16 destination, uint32_t flags)");
|
|
42
|
+
const getLastError = library.func("uint32_t __stdcall GetLastError()");
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
rename: (source, destination) => Boolean(moveFileEx(source, destination, MOVEFILE_WRITE_THROUGH)),
|
|
45
|
+
error: () => getLastError(),
|
|
46
|
+
destinationExists: (code) => code === ERROR_FILE_EXISTS || code === ERROR_ALREADY_EXISTS,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_unavailable");
|
|
50
|
+
};
|
|
51
|
+
const nativeApi = () => {
|
|
52
|
+
if (cached !== null)
|
|
53
|
+
return cached;
|
|
54
|
+
if (unavailable)
|
|
55
|
+
throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_unavailable");
|
|
56
|
+
try {
|
|
57
|
+
cached = bindNativeApi();
|
|
58
|
+
return cached;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
unavailable = true;
|
|
62
|
+
if (error instanceof AtomicNoReplaceRenameError)
|
|
63
|
+
throw error;
|
|
64
|
+
throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_unavailable");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/** Loads the mandatory native binding without mutating the filesystem. */
|
|
68
|
+
export function probeAtomicNoReplaceRenameReadiness() {
|
|
69
|
+
try {
|
|
70
|
+
nativeApi();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Atomically renames one filesystem object without replacing any destination occupant. macOS uses
|
|
79
|
+
* `renamex_np(RENAME_EXCL)`, Linux uses `renameat2(RENAME_NOREPLACE)`, and Windows uses MoveFileExW
|
|
80
|
+
* without `MOVEFILE_REPLACE_EXISTING`. Missing native support fails closed.
|
|
81
|
+
*/
|
|
82
|
+
export async function atomicRenameNoReplace(source, destination) {
|
|
83
|
+
const api = nativeApi();
|
|
84
|
+
if (!api.rename(source, destination)) {
|
|
85
|
+
const code = api.error();
|
|
86
|
+
if (api.destinationExists(code))
|
|
87
|
+
return "destination_exists";
|
|
88
|
+
throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_failed");
|
|
89
|
+
}
|
|
90
|
+
return "published";
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { dslog } from "./slog.js";
|
|
2
|
+
export function completionRetransmitterOptions(execution) {
|
|
3
|
+
return {
|
|
4
|
+
...(execution?.completionRetryDelaysMs === undefined ? {} : { retryDelaysMs: execution.completionRetryDelaysMs }),
|
|
5
|
+
...(execution?.completionRetryMaxAttempts === undefined ? {} : { maxAttempts: execution.completionRetryMaxAttempts }),
|
|
6
|
+
...(execution?.completionRetryMaxAgeMs === undefined ? {} : { maxAgeMs: execution.completionRetryMaxAgeMs }),
|
|
7
|
+
onAttempt: ({ kind, executionId, attempt, nextDelayMs }) => {
|
|
8
|
+
dslog(kind === "sent" ? "execution.completion_sent" : "execution.completion_retried", kind === "sent" ? "execution completion 已发送" : "execution completion 未确认,已重传", { execution_id: executionId, attempt, next_delay_ms: nextDelayMs });
|
|
9
|
+
},
|
|
10
|
+
onRetryExhausted: ({ executionId, attempts, reason }) => {
|
|
11
|
+
dslog("execution.completion_retry_exhausted", "execution completion 重试已耗尽,仍未收到 ACK", {
|
|
12
|
+
level: "ERROR", execution_id: executionId, attempts, reason,
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const DEFAULT_DELAYS = Object.freeze([1_000, 2_000, 4_000, 8_000, 16_000, 30_000]);
|
|
2
2
|
export function createCompletionRetransmitter(options) {
|
|
3
3
|
const delays = options.retryDelaysMs?.length ? [...options.retryDelaysMs] : [...DEFAULT_DELAYS];
|
|
4
|
+
const maxAttempts = options.maxAttempts ?? 20;
|
|
5
|
+
const maxAgeMs = options.maxAgeMs ?? 10 * 60 * 1_000;
|
|
4
6
|
const pending = new Map();
|
|
7
|
+
const exhausted = new Set();
|
|
5
8
|
let active = false;
|
|
6
9
|
let stopped = false;
|
|
7
10
|
const delayFor = (attempts) => delays[Math.min(Math.max(attempts - 1, 0), delays.length - 1)];
|
|
@@ -10,11 +13,31 @@ export function createCompletionRetransmitter(options) {
|
|
|
10
13
|
clearTimeout(entry.timer);
|
|
11
14
|
entry.timer = null;
|
|
12
15
|
};
|
|
16
|
+
const expire = (entry, reason) => {
|
|
17
|
+
clear(entry);
|
|
18
|
+
pending.delete(entry.frame.executionId);
|
|
19
|
+
exhausted.add(entry.frame.executionId);
|
|
20
|
+
options.onRetryExhausted?.({
|
|
21
|
+
executionId: entry.frame.executionId,
|
|
22
|
+
attempts: entry.attempts,
|
|
23
|
+
reason,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const ageRemainingMs = (entry) => maxAgeMs - (Date.now() - entry.startedAtMs);
|
|
13
27
|
function schedule(entry) {
|
|
14
28
|
clear(entry);
|
|
15
29
|
if (!active || stopped)
|
|
16
30
|
return;
|
|
17
|
-
|
|
31
|
+
if (entry.attempts >= maxAttempts) {
|
|
32
|
+
expire(entry, "max_attempts");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const remainingMs = ageRemainingMs(entry);
|
|
36
|
+
if (remainingMs <= 0) {
|
|
37
|
+
expire(entry, "max_age");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const delay = Math.min(delayFor(entry.attempts), remainingMs);
|
|
18
41
|
entry.timer = setTimeout(() => {
|
|
19
42
|
entry.timer = null;
|
|
20
43
|
attempt(entry);
|
|
@@ -24,10 +47,18 @@ export function createCompletionRetransmitter(options) {
|
|
|
24
47
|
function attempt(entry) {
|
|
25
48
|
if (!active || stopped)
|
|
26
49
|
return;
|
|
50
|
+
if (entry.attempts >= maxAttempts) {
|
|
51
|
+
expire(entry, "max_attempts");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (ageRemainingMs(entry) <= 0) {
|
|
55
|
+
expire(entry, "max_age");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
27
58
|
const accepted = options.send(entry.frame);
|
|
28
59
|
if (accepted) {
|
|
29
60
|
entry.attempts += 1;
|
|
30
|
-
const nextDelayMs = delayFor(entry.attempts);
|
|
61
|
+
const nextDelayMs = Math.min(delayFor(entry.attempts), Math.max(ageRemainingMs(entry), 0));
|
|
31
62
|
options.onAttempt?.({
|
|
32
63
|
kind: entry.attempts === 1 ? "sent" : "retried",
|
|
33
64
|
executionId: entry.frame.executionId,
|
|
@@ -35,13 +66,17 @@ export function createCompletionRetransmitter(options) {
|
|
|
35
66
|
nextDelayMs,
|
|
36
67
|
});
|
|
37
68
|
}
|
|
69
|
+
if (entry.attempts >= maxAttempts) {
|
|
70
|
+
expire(entry, "max_attempts");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
38
73
|
schedule(entry);
|
|
39
74
|
}
|
|
40
75
|
return {
|
|
41
76
|
track(frame) {
|
|
42
|
-
if (stopped || pending.has(frame.executionId))
|
|
77
|
+
if (stopped || pending.has(frame.executionId) || exhausted.has(frame.executionId))
|
|
43
78
|
return;
|
|
44
|
-
const entry = { frame, attempts: 0, timer: null };
|
|
79
|
+
const entry = { frame, startedAtMs: Date.now(), attempts: 0, timer: null };
|
|
45
80
|
pending.set(frame.executionId, entry);
|
|
46
81
|
if (active)
|
|
47
82
|
attempt(entry);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { executionBackendCapability } from "./execution-backend.js";
|
|
2
2
|
import { daemonCapabilities, EXECUTION_PROTOCOL } from "./machine-info.js";
|
|
3
|
-
import { PROJECT_SKILLS_CAPABILITY } from "./project-skills/types.js";
|
|
3
|
+
import { PROJECT_SKILLS_CAPABILITY, PROJECT_SKILL_PROJECTION_V2_CAPABILITY, } from "./project-skills/types.js";
|
|
4
4
|
export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe, projectSkillsAvailable = true, capabilities = daemonCapabilities(runtimePlatform)) {
|
|
5
5
|
const query = new URLSearchParams({ key: machineToken });
|
|
6
6
|
if (executionBackendCapability(runtimePlatform, jobObjectProbe).supported) {
|
|
@@ -8,7 +8,9 @@ export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform =
|
|
|
8
8
|
query.set("execution_max", String(EXECUTION_PROTOCOL.max));
|
|
9
9
|
}
|
|
10
10
|
for (const capability of capabilities) {
|
|
11
|
-
if (
|
|
11
|
+
if (projectSkillsAvailable
|
|
12
|
+
|| (capability !== PROJECT_SKILLS_CAPABILITY
|
|
13
|
+
&& capability !== PROJECT_SKILL_PROJECTION_V2_CAPABILITY)) {
|
|
12
14
|
query.append("capability", capability);
|
|
13
15
|
}
|
|
14
16
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { posix, win32 } from "node:path";
|
|
2
|
+
export class DirectoryProjectionPublicationError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(code);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "DirectoryProjectionPublicationError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const fail = (code) => {
|
|
11
|
+
throw new DirectoryProjectionPublicationError(code);
|
|
12
|
+
};
|
|
13
|
+
const hasTraversal = (value, platform) => (platform === "win32" ? value.split(/[\\/]/u) : value.split("/"))
|
|
14
|
+
.some((component) => component === "..");
|
|
15
|
+
const isFullyQualifiedWindowsPath = (value) => {
|
|
16
|
+
if (/^\\\\[?.]\\/u.test(value))
|
|
17
|
+
return false;
|
|
18
|
+
if (/^[A-Za-z]:[\\/]/u.test(value))
|
|
19
|
+
return true;
|
|
20
|
+
return /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(value);
|
|
21
|
+
};
|
|
22
|
+
const normalizeAbsolute = (value, platform, path) => {
|
|
23
|
+
if (value.length === 0 || value.includes("\0") || hasTraversal(value, platform)) {
|
|
24
|
+
return fail("directory_projection_path_invalid");
|
|
25
|
+
}
|
|
26
|
+
if (platform === "win32" ? !isFullyQualifiedWindowsPath(value) : !path.isAbsolute(value)) {
|
|
27
|
+
return fail("directory_projection_path_invalid");
|
|
28
|
+
}
|
|
29
|
+
return path.normalize(value);
|
|
30
|
+
};
|
|
31
|
+
const samePath = (left, right, path) => path.relative(left, right) === "";
|
|
32
|
+
const isWithin = (parent, candidate, path) => {
|
|
33
|
+
const relative = path.relative(parent, candidate);
|
|
34
|
+
return relative !== ""
|
|
35
|
+
&& relative !== ".."
|
|
36
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
37
|
+
&& !path.isAbsolute(relative);
|
|
38
|
+
};
|
|
39
|
+
const sameComponent = (left, right, path) => (path === win32 ? left.toLowerCase() : left) === (path === win32 ? right.toLowerCase() : right);
|
|
40
|
+
const publicationRootNamesMatch = (stagingRoot, finalRoot, path) => {
|
|
41
|
+
const finalName = path.basename(finalRoot);
|
|
42
|
+
const stagingName = path.basename(stagingRoot);
|
|
43
|
+
const expectedPrefix = `.${finalName}-next-`;
|
|
44
|
+
const comparableStaging = path === win32 ? stagingName.toLowerCase() : stagingName;
|
|
45
|
+
const comparablePrefix = path === win32 ? expectedPrefix.toLowerCase() : expectedPrefix;
|
|
46
|
+
return finalName.length > 0
|
|
47
|
+
&& comparableStaging.startsWith(comparablePrefix)
|
|
48
|
+
&& /^[A-Za-z0-9-]{1,128}$/u.test(stagingName.slice(expectedPrefix.length));
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Maps a projection target through one immutable whole-root publication. Both roots must be direct
|
|
52
|
+
* siblings, the staging name must bind the exact final basename and a bounded nonce, and the target
|
|
53
|
+
* must be strictly below staging. No filesystem access or path canonicalization is performed.
|
|
54
|
+
*/
|
|
55
|
+
export function mapDirectoryProjectionFinalTarget(target, publication, platform) {
|
|
56
|
+
const path = platform === "win32" ? win32 : posix;
|
|
57
|
+
const normalizedTarget = normalizeAbsolute(target, platform, path);
|
|
58
|
+
const stagingRoot = normalizeAbsolute(publication.stagingRoot, platform, path);
|
|
59
|
+
const finalRoot = normalizeAbsolute(publication.finalRoot, platform, path);
|
|
60
|
+
if (!samePath(path.parse(stagingRoot).root, path.parse(finalRoot).root, path)) {
|
|
61
|
+
return fail("directory_projection_cross_volume");
|
|
62
|
+
}
|
|
63
|
+
if (!samePath(path.dirname(stagingRoot), path.dirname(finalRoot), path)
|
|
64
|
+
|| samePath(stagingRoot, finalRoot, path)
|
|
65
|
+
|| isWithin(stagingRoot, finalRoot, path)
|
|
66
|
+
|| isWithin(finalRoot, stagingRoot, path)
|
|
67
|
+
|| !publicationRootNamesMatch(stagingRoot, finalRoot, path)
|
|
68
|
+
|| !isWithin(stagingRoot, normalizedTarget, path)) {
|
|
69
|
+
return fail("directory_projection_path_overlap");
|
|
70
|
+
}
|
|
71
|
+
const relative = path.relative(stagingRoot, normalizedTarget);
|
|
72
|
+
const mapped = path.join(finalRoot, relative);
|
|
73
|
+
if (!isWithin(finalRoot, mapped, path))
|
|
74
|
+
return fail("directory_projection_path_overlap");
|
|
75
|
+
return mapped;
|
|
76
|
+
}
|
|
77
|
+
/** Exact marker lineage check used after the whole staging root has moved to its final UUID path. */
|
|
78
|
+
export function hasDirectoryProjectionPublicationLineage(operationTarget, finalTarget, platform) {
|
|
79
|
+
const path = platform === "win32" ? win32 : posix;
|
|
80
|
+
let operation;
|
|
81
|
+
let final;
|
|
82
|
+
try {
|
|
83
|
+
operation = normalizeAbsolute(operationTarget, platform, path);
|
|
84
|
+
final = normalizeAbsolute(finalTarget, platform, path);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
let stagingRoot = operation;
|
|
90
|
+
let finalRoot = final;
|
|
91
|
+
while (sameComponent(path.basename(stagingRoot), path.basename(finalRoot), path)) {
|
|
92
|
+
const nextStaging = path.dirname(stagingRoot);
|
|
93
|
+
const nextFinal = path.dirname(finalRoot);
|
|
94
|
+
if (samePath(nextStaging, stagingRoot, path) || samePath(nextFinal, finalRoot, path))
|
|
95
|
+
return false;
|
|
96
|
+
stagingRoot = nextStaging;
|
|
97
|
+
finalRoot = nextFinal;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return samePath(mapDirectoryProjectionFinalTarget(operation, { stagingRoot, finalRoot }, platform), final, path);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -3,6 +3,7 @@ import { chmod, cp, lstat, mkdir, open, readFile, readlink, realpath, readdir, r
|
|
|
3
3
|
import { posix, win32 } from "node:path";
|
|
4
4
|
import { durableAtomicPrivateWrite, durableDirectorySync, durablePrivateUnlink, } from "./atomic-private-write.js";
|
|
5
5
|
import { readExactDirectoryIdentity, sameExactDirectoryIdentity, validExactDirectoryIdentity, } from "./directory-projection-identity.js";
|
|
6
|
+
import { hasDirectoryProjectionPublicationLineage } from "./directory-projection-publication.js";
|
|
6
7
|
export class DirectoryProjectionError extends Error {
|
|
7
8
|
code;
|
|
8
9
|
constructor(code) {
|
|
@@ -148,6 +149,9 @@ const normalizedMarker = (marker, path) => {
|
|
|
148
149
|
const hasPermittedFinalTarget = (operationTarget, finalTarget, path) => {
|
|
149
150
|
if (samePath(operationTarget, finalTarget, path))
|
|
150
151
|
return true;
|
|
152
|
+
const platform = path === win32 ? "win32" : "linux";
|
|
153
|
+
if (hasDirectoryProjectionPublicationLineage(operationTarget, finalTarget, platform))
|
|
154
|
+
return true;
|
|
151
155
|
if (!sameComponent(path.basename(operationTarget), path.basename(finalTarget), path))
|
|
152
156
|
return false;
|
|
153
157
|
const operationRoot = path.dirname(operationTarget);
|
|
@@ -824,18 +828,29 @@ export async function isManagedDirectoryProjectionCopy(target, platform, options
|
|
|
824
828
|
return false;
|
|
825
829
|
const path = platform === "win32" ? win32 : posix;
|
|
826
830
|
let normalizedTarget;
|
|
831
|
+
let normalizedFinalTarget;
|
|
827
832
|
try {
|
|
828
833
|
normalizedTarget = normalizeAbsolute(target, platform, path);
|
|
834
|
+
normalizedFinalTarget = options.finalTarget === undefined
|
|
835
|
+
? undefined
|
|
836
|
+
: normalizeAbsolute(options.finalTarget, platform, path);
|
|
829
837
|
}
|
|
830
838
|
catch {
|
|
831
839
|
return false;
|
|
832
840
|
}
|
|
833
841
|
const projectionFs = resolveFileSystem(options);
|
|
834
842
|
const info = await tryLstat(projectionFs, normalizedTarget);
|
|
843
|
+
const marker = await readMarker(projectionFs, normalizedTarget, path);
|
|
844
|
+
const normalized = normalizedMarker(marker, path);
|
|
835
845
|
return info !== null
|
|
836
846
|
&& info.isDirectory()
|
|
837
847
|
&& !info.isSymbolicLink()
|
|
838
|
-
&&
|
|
848
|
+
&& normalized !== null
|
|
849
|
+
&& (normalizedFinalTarget === undefined
|
|
850
|
+
? markerMatchesFinalTarget(marker, normalizedTarget, path)
|
|
851
|
+
: samePath(normalized.target, normalizedTarget, path)
|
|
852
|
+
&& samePath(normalized.finalTarget, normalizedFinalTarget, path)
|
|
853
|
+
&& hasPermittedFinalTarget(normalizedTarget, normalizedFinalTarget, path));
|
|
839
854
|
}
|
|
840
855
|
/**
|
|
841
856
|
* Projects one absolute directory without exposing host paths in errors.
|
|
@@ -847,9 +862,10 @@ export async function isManagedDirectoryProjectionCopy(target, platform, options
|
|
|
847
862
|
* rejected before IO.
|
|
848
863
|
*
|
|
849
864
|
* Windows copy ownership requires a version-2 marker whose normalized `finalTarget` exactly equals the
|
|
850
|
-
* inspected target. A distinct operation target is accepted only
|
|
851
|
-
* `.skills-next-<id
|
|
852
|
-
* incomplete, POSIX, or mismatched markers never
|
|
865
|
+
* inspected target. A distinct operation target is accepted only when the exact relative suffix maps
|
|
866
|
+
* through a bounded same-parent staging root: either the legacy `.skills-next-<id>` switch or an immutable
|
|
867
|
+
* Runtime root `.<rootId>-next-<id>` publication. Legacy, incomplete, POSIX, or mismatched markers never
|
|
868
|
+
* authorize refresh or deletion.
|
|
853
869
|
*
|
|
854
870
|
* Copy fallback never follows source links, rejects every absolute link form, and permits a relative link
|
|
855
871
|
* only when its completed staging target is both lexically and physically contained by that staging tree.
|
|
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { ExecutionCompletedSchema, EffectivePermissionSchema, } from "./execution-protocol.js";
|
|
6
6
|
import { JournalLockedError, createJournalLease, } from "./execution-journal-lock.js";
|
|
7
|
+
import { provePosixProcessGroupStopped } from "./execution-posix-stop-proof.js";
|
|
7
8
|
export { JournalLockedError, JournalLockCorruptionError } from "./execution-journal-lock.js";
|
|
8
9
|
const ExecutionIdSchema = z.string().uuid();
|
|
9
10
|
const TimestampSchema = z.string().datetime({ offset: true });
|
|
@@ -239,6 +240,22 @@ export function createProcessController(dependencies = {}) {
|
|
|
239
240
|
};
|
|
240
241
|
return {
|
|
241
242
|
inspectIdentity,
|
|
243
|
+
ownedTreeExists: dependencies.ownedTreeExists ?? (async (pid) => {
|
|
244
|
+
if (!supervisorTree || platform === "win32")
|
|
245
|
+
return (await inspectIdentity(pid)) !== null;
|
|
246
|
+
try {
|
|
247
|
+
process.kill(-pid, 0);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
const code = errorCode(error);
|
|
252
|
+
if (code === "ESRCH")
|
|
253
|
+
return false;
|
|
254
|
+
if (code === "EPERM")
|
|
255
|
+
return true;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
242
259
|
signal: dependencies.signal ?? (async (pid, signal) => {
|
|
243
260
|
if (!supervisorTree) {
|
|
244
261
|
process.kill(pid, signal);
|
|
@@ -470,6 +487,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
|
|
|
470
487
|
}
|
|
471
488
|
};
|
|
472
489
|
const pruneInternal = async () => {
|
|
490
|
+
const protectedExecutionIds = new Set(options.protectedExecutionIds?.() ?? []);
|
|
473
491
|
const entries = await readAll();
|
|
474
492
|
const acknowledged = entries
|
|
475
493
|
.filter((entry) => (entry.state === "completed" || entry.state === "interrupted")
|
|
@@ -477,9 +495,12 @@ export function createExecutionJournal(agentsRoot, options = {}) {
|
|
|
477
495
|
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)
|
|
478
496
|
|| left.executionId.localeCompare(right.executionId));
|
|
479
497
|
const cutoff = now().valueOf() - retentionDays * 24 * 60 * 60 * 1_000;
|
|
480
|
-
const
|
|
481
|
-
const
|
|
482
|
-
const
|
|
498
|
+
const unprotected = acknowledged.filter((entry) => !protectedExecutionIds.has(entry.executionId));
|
|
499
|
+
const protectedCount = acknowledged.length - unprotected.length;
|
|
500
|
+
const expired = unprotected.filter((entry) => Date.parse(entry.updatedAt) < cutoff);
|
|
501
|
+
const retained = unprotected.filter((entry) => Date.parse(entry.updatedAt) >= cutoff);
|
|
502
|
+
const unprotectedLimit = Math.max(0, maxEntries - protectedCount);
|
|
503
|
+
const overLimit = retained.slice(0, Math.max(0, retained.length - unprotectedLimit));
|
|
483
504
|
const toDelete = new Set([...expired, ...overLimit].map((entry) => entry.executionId));
|
|
484
505
|
for (const executionId of toDelete) {
|
|
485
506
|
await unlink(recordPath(executionId));
|
|
@@ -546,12 +567,16 @@ export function createExecutionJournal(agentsRoot, options = {}) {
|
|
|
546
567
|
});
|
|
547
568
|
};
|
|
548
569
|
const inspectForRecovery = async (pid) => {
|
|
570
|
+
let identity;
|
|
549
571
|
try {
|
|
550
|
-
|
|
572
|
+
identity = await processController.inspectIdentity(pid);
|
|
551
573
|
}
|
|
552
574
|
catch (error) {
|
|
553
575
|
throw new JournalRecoveryError(`Failed to inspect running process ${pid}`, error);
|
|
554
576
|
}
|
|
577
|
+
if (identity?.trim() === "")
|
|
578
|
+
throw new JournalRecoveryError(`Process ${pid} has no identity`);
|
|
579
|
+
return identity;
|
|
555
580
|
};
|
|
556
581
|
const waitForRecovery = async (milliseconds, phase) => {
|
|
557
582
|
try {
|
|
@@ -566,6 +591,17 @@ export function createExecutionJournal(agentsRoot, options = {}) {
|
|
|
566
591
|
throw new JournalRecoveryError(`Execution ${entry.executionId} lacks process identity`);
|
|
567
592
|
}
|
|
568
593
|
const { pid, processIdentity } = entry;
|
|
594
|
+
if (platform !== "win32") {
|
|
595
|
+
await provePosixProcessGroupStopped({
|
|
596
|
+
pid,
|
|
597
|
+
expectedIdentity: processIdentity,
|
|
598
|
+
controller: processController,
|
|
599
|
+
terminationGraceMs,
|
|
600
|
+
killVerificationDelayMs,
|
|
601
|
+
failure: (message, cause) => new JournalRecoveryError(message, cause),
|
|
602
|
+
});
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
569
605
|
const initialIdentity = await inspectForRecovery(pid);
|
|
570
606
|
if (initialIdentity === null || initialIdentity !== processIdentity)
|
|
571
607
|
return;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
function errorCode(error) {
|
|
2
|
+
return error instanceof Error && "code" in error && (typeof error.code === "string"
|
|
3
|
+
|| typeof error.code === "number")
|
|
4
|
+
? error.code
|
|
5
|
+
: undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Proves a POSIX supervisor process group is gone before its journal is made terminal.
|
|
9
|
+
* POSIX does not reuse a PGID while that group still exists. A live leader with a
|
|
10
|
+
* different identity therefore proves the journal-owned group has already ended. A
|
|
11
|
+
* missing leader while the PGID remains live is not an ownership proof: there is no
|
|
12
|
+
* portable primitive that atomically binds a later group signal to the journal owner,
|
|
13
|
+
* so recovery fails closed without signaling that group.
|
|
14
|
+
*/
|
|
15
|
+
export async function provePosixProcessGroupStopped(options) {
|
|
16
|
+
const { controller, expectedIdentity, failure, pid } = options;
|
|
17
|
+
const inspect = async () => {
|
|
18
|
+
let identity;
|
|
19
|
+
try {
|
|
20
|
+
identity = await controller.inspectIdentity(pid);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw failure(`Failed to inspect running process ${pid}`, error);
|
|
24
|
+
}
|
|
25
|
+
if (identity !== null && identity.trim().length === 0) {
|
|
26
|
+
throw failure(`Running process ${pid} has an unverifiable identity`);
|
|
27
|
+
}
|
|
28
|
+
return identity;
|
|
29
|
+
};
|
|
30
|
+
const treeExists = async () => {
|
|
31
|
+
try {
|
|
32
|
+
return await controller.ownedTreeExists(pid);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw failure(`Failed to inspect owned process tree ${pid}`, error);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const wait = async (milliseconds, phase) => {
|
|
39
|
+
try {
|
|
40
|
+
await controller.wait(milliseconds);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw failure(`Failed while waiting ${phase}`, error);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const ownedGroupExists = async () => {
|
|
47
|
+
const currentIdentity = await inspect();
|
|
48
|
+
if (currentIdentity !== null && currentIdentity !== expectedIdentity)
|
|
49
|
+
return false;
|
|
50
|
+
const exists = await treeExists();
|
|
51
|
+
if (!exists)
|
|
52
|
+
return false;
|
|
53
|
+
if (currentIdentity === null) {
|
|
54
|
+
throw failure(`Cannot prove ownership of live process group ${pid} without its leader`);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
58
|
+
const signal = async (requestedSignal) => {
|
|
59
|
+
try {
|
|
60
|
+
await controller.signal(pid, requestedSignal);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (errorCode(error) === "ESRCH" && !(await ownedGroupExists()))
|
|
65
|
+
return true;
|
|
66
|
+
throw failure(`Failed to ${requestedSignal === "SIGTERM" ? "terminate" : "kill"} process ${pid}`, error);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (!(await ownedGroupExists()))
|
|
70
|
+
return;
|
|
71
|
+
if (await signal("SIGTERM"))
|
|
72
|
+
return;
|
|
73
|
+
await wait(options.terminationGraceMs, "for process-group termination");
|
|
74
|
+
if (!(await ownedGroupExists()))
|
|
75
|
+
return;
|
|
76
|
+
if (await signal("SIGKILL"))
|
|
77
|
+
return;
|
|
78
|
+
await wait(options.killVerificationDelayMs, "to verify process-group kill");
|
|
79
|
+
if (!(await ownedGroupExists()))
|
|
80
|
+
return;
|
|
81
|
+
throw failure(`Process group ${pid} still exists after SIGKILL`);
|
|
82
|
+
}
|