opencode-feature-factory 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 +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
import { closeSync, constants as FS_CONSTANTS, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { timestamp } from "./utils.js";
|
|
6
|
+
import { writeProtectedJsonAtomicSync } from "./hardening/atomic-write.js";
|
|
7
|
+
import {
|
|
8
|
+
PROCESS_INSPECTOR,
|
|
9
|
+
inspectProcessIdentity as inspectVerifiedProcessIdentity,
|
|
10
|
+
probeLegacyBooleanLiveness,
|
|
11
|
+
signalVerifiedProcess,
|
|
12
|
+
verifyProcessIdentity,
|
|
13
|
+
} from "./hardening/process-verification.js";
|
|
14
|
+
|
|
15
|
+
export const PROCESS_EVIDENCE_FILE = "process.json";
|
|
16
|
+
export const PROCESS_EVIDENCE_KIND = "opencode-process";
|
|
17
|
+
export const PROCESS_EVIDENCE_SCHEMA_VERSION = 1;
|
|
18
|
+
export const PROCESS_EVIDENCE_SIGNAL = "SIGTERM";
|
|
19
|
+
export const LAUNCH_CLAIM_DIR = "process-launch.lock";
|
|
20
|
+
export const LAUNCH_CLAIM_FILE = "owner.json";
|
|
21
|
+
export const LAUNCH_CLAIM_REF = `${LAUNCH_CLAIM_DIR}/${LAUNCH_CLAIM_FILE}`;
|
|
22
|
+
export const LAUNCH_CLAIM_KIND = "opencode-launch-claim";
|
|
23
|
+
export const LAUNCH_CLAIM_SCHEMA_VERSION = 1;
|
|
24
|
+
export const LAUNCH_FENCE_KIND = "opencode-launch-fence";
|
|
25
|
+
export const LAUNCH_CLAIM_PHASES = Object.freeze(["foreground-live", "predecessor-active", "predecessor-released", "spawning"]);
|
|
26
|
+
export const LAUNCH_KINDS = Object.freeze(["approval-handoff", "resume-foreground", "resume-detached", "start-resume-foreground", "start-resume-detached"]);
|
|
27
|
+
|
|
28
|
+
const PROCESS_STATES = new Set(["running", "cancelled", "failed-closed", "exited"]);
|
|
29
|
+
const DEFAULT_INSPECTOR = PROCESS_INSPECTOR;
|
|
30
|
+
const DEFAULT_CANCEL_WAIT_MS = 5000;
|
|
31
|
+
const CANCEL_POLL_INTERVAL_MS = 200;
|
|
32
|
+
const LAUNCH_PHASE_SET = new Set(LAUNCH_CLAIM_PHASES);
|
|
33
|
+
const LAUNCH_KIND_SET = new Set(LAUNCH_KINDS);
|
|
34
|
+
|
|
35
|
+
export function processEvidencePath(runDir) {
|
|
36
|
+
return join(resolve(runDir), PROCESS_EVIDENCE_FILE);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function processEvidenceProcessesDir(runDir) {
|
|
40
|
+
return join(resolve(runDir), "processes");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function launchClaimPath(runDir) {
|
|
44
|
+
return join(resolve(runDir), LAUNCH_CLAIM_DIR, LAUNCH_CLAIM_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inspect a transient launch claim without following either claim entry. Claims
|
|
49
|
+
* are deliberately never reclaimed here: an invalid or unprovable owner is
|
|
50
|
+
* durable evidence for manual reconciliation, not permission to relaunch.
|
|
51
|
+
*/
|
|
52
|
+
export function inspectLaunchClaim(runDir, opts = {}) {
|
|
53
|
+
const root = resolve(runDir);
|
|
54
|
+
const dir = join(root, LAUNCH_CLAIM_DIR);
|
|
55
|
+
const file = join(dir, LAUNCH_CLAIM_FILE);
|
|
56
|
+
let rootStat;
|
|
57
|
+
let dirStat;
|
|
58
|
+
try {
|
|
59
|
+
rootStat = lstatSync(root);
|
|
60
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return invalidLaunchClaim(file, "run directory must be a non-symlink directory");
|
|
61
|
+
dirStat = lstatSync(dir);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code === "ENOENT") return { ok: false, missing: true, reason: "missing launch claim", path: file, claim: null, owner_status: "absent", identity: null, hash: null };
|
|
64
|
+
return invalidLaunchClaim(file, `launch claim directory is inaccessible: ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return invalidLaunchClaim(file, "launch claim directory must be a non-symlink directory", claimIdentity(dirStat));
|
|
67
|
+
opts.onLaunchClaimDirectoryInspected?.({ root, dir, file, identity: claimIdentity(dirStat) });
|
|
68
|
+
|
|
69
|
+
let fd;
|
|
70
|
+
let fileStat;
|
|
71
|
+
let hash = null;
|
|
72
|
+
try {
|
|
73
|
+
fd = openSync(file, FS_CONSTANTS.O_RDONLY | (FS_CONSTANTS.O_NOFOLLOW || 0));
|
|
74
|
+
fileStat = fstatSync(fd);
|
|
75
|
+
if (!fileStat.isFile()) return invalidLaunchClaim(file, "launch claim owner must be a regular file", claimIdentity(dirStat, fileStat));
|
|
76
|
+
const bytes = readFileSync(fd);
|
|
77
|
+
hash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
78
|
+
assertStableClaimPath(root, dir, file, rootStat, dirStat, fileStat);
|
|
79
|
+
const claim = JSON.parse(bytes.toString("utf8"));
|
|
80
|
+
const validation = validateLaunchClaim(claim, { ...opts, runDir: root });
|
|
81
|
+
if (!validation.ok) return { ...validation, missing: false, path: file, owner_status: "invalid", identity: claimIdentity(dirStat, fileStat), hash };
|
|
82
|
+
const ownerStatus = inspectClaimOwner(validation.claim, opts);
|
|
83
|
+
assertStableClaimPath(root, dir, file, rootStat, dirStat, fileStat);
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
missing: false,
|
|
87
|
+
reason: null,
|
|
88
|
+
path: file,
|
|
89
|
+
claim: validation.claim,
|
|
90
|
+
owner_status: ownerStatus,
|
|
91
|
+
identity: claimIdentity(dirStat, fileStat),
|
|
92
|
+
hash,
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code === "LAUNCH_CLAIM_CHANGED") return invalidLaunchClaim(file, "launch claim identity changed during inspection");
|
|
96
|
+
if (error?.code === "ENOENT") return invalidLaunchClaim(file, "launch claim directory is ownerless", claimIdentity(dirStat));
|
|
97
|
+
return invalidLaunchClaim(file, `invalid launch claim: ${error.message}`, claimIdentity(dirStat, fileStat), hash);
|
|
98
|
+
} finally {
|
|
99
|
+
if (fd !== undefined) closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function validateLaunchClaim(claim, opts = {}) {
|
|
104
|
+
const errors = [];
|
|
105
|
+
if (!plainObject(claim)) return invalidClaim("launch claim must be a JSON object");
|
|
106
|
+
if (claim.schema_version !== LAUNCH_CLAIM_SCHEMA_VERSION) errors.push("schema_version must be 1");
|
|
107
|
+
if (claim.kind !== LAUNCH_CLAIM_KIND) errors.push("kind must be opencode-launch-claim");
|
|
108
|
+
if (!nonEmptyString(claim.run_id)) errors.push("run_id must be a non-empty string");
|
|
109
|
+
if (nonEmptyString(opts.runId) && claim.run_id !== opts.runId) errors.push("run_id must match requested run");
|
|
110
|
+
if (!nonEmptyString(claim.execution_id)) errors.push("execution_id must be a non-empty string");
|
|
111
|
+
if (!LAUNCH_KIND_SET.has(claim.launch_kind)) errors.push("launch_kind is invalid");
|
|
112
|
+
if (!LAUNCH_PHASE_SET.has(claim.phase)) errors.push("phase is invalid");
|
|
113
|
+
if (!positivePid(claim.pid)) errors.push("pid must be a positive integer");
|
|
114
|
+
if (!nonEmptyString(claim.hostname)) errors.push("hostname must be a non-empty string");
|
|
115
|
+
if (!validTimestamp(claim.acquired_at)) errors.push("acquired_at must be an ISO timestamp");
|
|
116
|
+
if (!plainObject(claim.identity)) errors.push("identity must be an object");
|
|
117
|
+
else {
|
|
118
|
+
for (const field of ["inspector", "start_marker", "command_name", "cwd"]) if (!nonEmptyString(claim.identity[field])) errors.push(`identity.${field} must be a non-empty string`);
|
|
119
|
+
if (nonEmptyString(claim.identity?.cwd) && !isAbsolute(claim.identity.cwd)) errors.push("identity.cwd must be absolute");
|
|
120
|
+
}
|
|
121
|
+
if (!(claim.approval === null || plainObject(claim.approval))) errors.push("approval must be null or an object");
|
|
122
|
+
if (!nonEmptyString(claim.nonce) || !/^[A-Za-z0-9_-]{16,128}$/u.test(claim.nonce)) errors.push("nonce must be an opaque safe token");
|
|
123
|
+
if (errors.length) return invalidClaim(`invalid launch claim: ${errors.join("; ")}`);
|
|
124
|
+
return { ok: true, reason: null, claim };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function acquireLaunchClaim(runDir, input = {}, opts = {}) {
|
|
128
|
+
const root = resolve(runDir);
|
|
129
|
+
const fence = acquireLaunchFence(root, "launch", opts);
|
|
130
|
+
if (!fence.acquired) {
|
|
131
|
+
return { acquired: false, ok: false, missing: false, reason: "launch fence is held", path: launchClaimPath(root), claim: null, owner_status: "indeterminate", identity: null, hash: null, launch_fence_ref: fence.path };
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return acquireLaunchClaimFenced(root, input, opts);
|
|
135
|
+
} finally {
|
|
136
|
+
if (!releaseLaunchFence(fence)) throw new Error("launch fence release failed");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function acquireLaunchClaimFenced(root, input = {}, opts = {}) {
|
|
141
|
+
const dir = join(root, LAUNCH_CLAIM_DIR);
|
|
142
|
+
const file = join(dir, LAUNCH_CLAIM_FILE);
|
|
143
|
+
assertContainedLaunchPath(root, dir);
|
|
144
|
+
const existing = inspectLaunchClaim(root, { ...opts, runId: input.runId });
|
|
145
|
+
if (!existing.missing) return { acquired: false, ...existing };
|
|
146
|
+
const pid = positivePid(input.pid) ? input.pid : process.pid;
|
|
147
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
148
|
+
const inspected = inspectProcessForEvidence(pid, opts);
|
|
149
|
+
const verified = requireVerifiedProcessIdentity(inspected, cwd);
|
|
150
|
+
const claim = {
|
|
151
|
+
schema_version: LAUNCH_CLAIM_SCHEMA_VERSION,
|
|
152
|
+
kind: LAUNCH_CLAIM_KIND,
|
|
153
|
+
run_id: input.runId,
|
|
154
|
+
execution_id: input.executionId || randomUUID(),
|
|
155
|
+
launch_kind: input.launchKind,
|
|
156
|
+
phase: input.phase || "foreground-live",
|
|
157
|
+
pid,
|
|
158
|
+
hostname: input.hostname || hostname(),
|
|
159
|
+
acquired_at: timestamp(input.now),
|
|
160
|
+
identity: {
|
|
161
|
+
inspector: stringOrDefault(inspected.inspector, DEFAULT_INSPECTOR),
|
|
162
|
+
start_marker: verified.startMarker,
|
|
163
|
+
command_name: verified.commandName,
|
|
164
|
+
cwd,
|
|
165
|
+
},
|
|
166
|
+
approval: input.approval ?? null,
|
|
167
|
+
nonce: input.nonce || randomUUID(),
|
|
168
|
+
};
|
|
169
|
+
const validation = validateLaunchClaim(claim, { runId: input.runId });
|
|
170
|
+
if (!validation.ok) throw new Error(validation.reason);
|
|
171
|
+
try {
|
|
172
|
+
mkdirSync(dir);
|
|
173
|
+
const dirStat = lstatSync(dir);
|
|
174
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error("launch claim directory identity is invalid");
|
|
175
|
+
writeFileSync(file, `${JSON.stringify(claim, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error?.code === "EEXIST") return { acquired: false, ...inspectLaunchClaim(root, { ...opts, runId: input.runId }) };
|
|
178
|
+
try {
|
|
179
|
+
if (!existsSync(file)) rmSync(dir, { force: true });
|
|
180
|
+
} catch { /* preserve unexpected evidence */ }
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
const observed = inspectLaunchClaim(root, { ...opts, runId: input.runId });
|
|
184
|
+
if (!observed.ok || observed.claim.nonce !== claim.nonce) throw new Error("launch claim publication could not be verified");
|
|
185
|
+
return { acquired: true, ...observed, token: claim.nonce };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function acquireLaunchFence(runDir, ownerKind, opts = {}) {
|
|
189
|
+
if (!["launch", "cleanup"].includes(ownerKind)) throw new Error("launch fence owner kind is invalid");
|
|
190
|
+
const requestedRoot = resolve(runDir);
|
|
191
|
+
const runStat = lstatSync(requestedRoot);
|
|
192
|
+
if (!runStat.isDirectory() || runStat.isSymbolicLink()) throw new Error("run directory must be a non-symlink directory");
|
|
193
|
+
const root = realpathSync(requestedRoot);
|
|
194
|
+
const physicalRunStat = lstatSync(root);
|
|
195
|
+
if (!sameIdentity(physicalRunStat, runStat) || !physicalRunStat.isDirectory() || physicalRunStat.isSymbolicLink()) {
|
|
196
|
+
throw new Error("launch fence run directory identity changed");
|
|
197
|
+
}
|
|
198
|
+
const namespace = resolveLaunchFenceNamespace(root, opts);
|
|
199
|
+
const fenceRoot = ensureLaunchFenceRoot(requestedRoot, root, runStat, namespace);
|
|
200
|
+
const key = createHash("sha256").update(root, "utf8").digest("hex");
|
|
201
|
+
const path = join(fenceRoot.path, key);
|
|
202
|
+
const ownerPath = join(path, "owner.json");
|
|
203
|
+
const nonce = opts.launchFenceNonce || randomUUID();
|
|
204
|
+
opts.onLaunchFenceReadyToAcquire?.({ requestedRoot, root, fenceRoot: fenceRoot.path, path, identity: claimIdentity(runStat) });
|
|
205
|
+
try {
|
|
206
|
+
mkdirSync(path, { mode: 0o700 });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error?.code === "EEXIST") return { acquired: false, path, owner_kind: ownerKind };
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
const dirStat = lstatSync(path);
|
|
212
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error("launch fence directory identity is invalid");
|
|
213
|
+
const owner = {
|
|
214
|
+
schema_version: 1,
|
|
215
|
+
kind: LAUNCH_FENCE_KIND,
|
|
216
|
+
run_path_hash: `sha256:${key}`,
|
|
217
|
+
owner_kind: ownerKind,
|
|
218
|
+
nonce,
|
|
219
|
+
pid: process.pid,
|
|
220
|
+
hostname: opts.hostname || hostname(),
|
|
221
|
+
acquired_at: timestamp(opts.now),
|
|
222
|
+
};
|
|
223
|
+
let ownerStat;
|
|
224
|
+
try {
|
|
225
|
+
assertLaunchFenceRunIdentity(requestedRoot, root, runStat);
|
|
226
|
+
assertLaunchFenceNamespace(fenceRoot);
|
|
227
|
+
const writeOwner = opts.writeLaunchFenceOwner || writeFileSync;
|
|
228
|
+
writeOwner(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
229
|
+
const inspectOwner = opts.inspectLaunchFenceOwner || lstatSync;
|
|
230
|
+
ownerStat = inspectOwner(ownerPath);
|
|
231
|
+
if (!ownerStat.isFile() || ownerStat.isSymbolicLink()) throw new Error("launch fence owner identity is invalid");
|
|
232
|
+
assertLaunchFenceNamespace(fenceRoot);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
removeFailedLaunchFence(path, dirStat, nonce);
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
return { acquired: true, path, owner_path: ownerPath, owner_kind: ownerKind, nonce, identity: claimIdentity(dirStat, ownerStat) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function releaseLaunchFence(fence) {
|
|
241
|
+
if (!fence?.acquired || !fence.identity || !nonEmptyString(fence.nonce)) return false;
|
|
242
|
+
const path = resolve(fence.path);
|
|
243
|
+
const ownerPath = join(path, "owner.json");
|
|
244
|
+
let fd;
|
|
245
|
+
try {
|
|
246
|
+
const dirStat = lstatSync(path);
|
|
247
|
+
if (!sameIdentity(dirStat, fence.identity.dir) || dirStat.isSymbolicLink() || !dirStat.isDirectory()) return false;
|
|
248
|
+
fd = openSync(ownerPath, FS_CONSTANTS.O_RDONLY | (FS_CONSTANTS.O_NOFOLLOW || 0));
|
|
249
|
+
const ownerStat = fstatSync(fd);
|
|
250
|
+
if (!sameIdentity(ownerStat, fence.identity.file) || !ownerStat.isFile()) return false;
|
|
251
|
+
const owner = JSON.parse(readFileSync(fd, "utf8"));
|
|
252
|
+
const pathnameStat = lstatSync(ownerPath);
|
|
253
|
+
if (!sameIdentity(pathnameStat, ownerStat) || pathnameStat.isSymbolicLink() || owner?.kind !== LAUNCH_FENCE_KIND || owner?.nonce !== fence.nonce) return false;
|
|
254
|
+
const quarantine = join(dirnameForClaimDir(path), `.${basename(path)}.quarantine-${randomUUID()}`);
|
|
255
|
+
renameSync(path, quarantine);
|
|
256
|
+
const movedDir = lstatSync(quarantine);
|
|
257
|
+
const movedOwner = lstatSync(join(quarantine, "owner.json"));
|
|
258
|
+
if (!sameIdentity(movedDir, fence.identity.dir) || !sameIdentity(movedOwner, fence.identity.file)) throw new Error("launch fence identity changed during release");
|
|
259
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
} finally {
|
|
264
|
+
if (fd !== undefined) closeSync(fd);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function transitionLaunchClaimPhase(runDir, nonce, phase, updates = {}, opts = {}) {
|
|
269
|
+
if (!LAUNCH_PHASE_SET.has(phase)) throw new Error("invalid launch claim phase");
|
|
270
|
+
const observed = inspectLaunchClaim(runDir, opts);
|
|
271
|
+
if (!observed.ok || observed.claim.nonce !== nonce) throw new Error("launch claim exact-token ownership mismatch");
|
|
272
|
+
if (opts.expectedPhase && observed.claim.phase !== opts.expectedPhase) throw new Error(`launch claim phase must be ${opts.expectedPhase}`);
|
|
273
|
+
const next = { ...observed.claim, ...updates, phase, nonce: observed.claim.nonce };
|
|
274
|
+
const validation = validateLaunchClaim(next, { runId: observed.claim.run_id });
|
|
275
|
+
if (!validation.ok) throw new Error(validation.reason);
|
|
276
|
+
replaceExactClaim(runDir, observed, next);
|
|
277
|
+
const confirmed = inspectLaunchClaim(runDir, { ...opts, runId: next.run_id });
|
|
278
|
+
if (!confirmed.ok || confirmed.claim.nonce !== nonce || confirmed.claim.phase !== phase) throw new Error("launch claim phase transition could not be verified");
|
|
279
|
+
return confirmed;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function releaseLaunchClaim(runDir, nonce, opts = {}) {
|
|
283
|
+
const observed = inspectLaunchClaim(runDir, opts);
|
|
284
|
+
if (!observed.ok || observed.claim.nonce !== nonce) return false;
|
|
285
|
+
if (opts.expectedPhase && observed.claim.phase !== opts.expectedPhase) return false;
|
|
286
|
+
const dir = join(resolve(runDir), LAUNCH_CLAIM_DIR);
|
|
287
|
+
const quarantine = join(resolve(runDir), `.process-launch.lock-quarantine-${randomUUID()}`);
|
|
288
|
+
const before = lstatSync(dir);
|
|
289
|
+
if (before.dev !== observed.identity.dir.dev || before.ino !== observed.identity.dir.ino) return false;
|
|
290
|
+
renameSync(dir, quarantine);
|
|
291
|
+
const moved = inspectLaunchClaimAt(quarantine, { ...opts, runId: observed.claim.run_id });
|
|
292
|
+
if (!moved.ok || moved.claim.nonce !== nonce || moved.identity.dir.dev !== observed.identity.dir.dev || moved.identity.dir.ino !== observed.identity.dir.ino) {
|
|
293
|
+
throw new Error("launch claim cleanup identity changed");
|
|
294
|
+
}
|
|
295
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function inspectProcessEvidence(runDir, opts = {}) {
|
|
300
|
+
const read = readProcessEvidence(runDir, opts);
|
|
301
|
+
if (!read.ok) return read;
|
|
302
|
+
const logValidation = validateProcessLogEntry(runDir, read.evidence.log_ref);
|
|
303
|
+
if (!logValidation.ok) return { ok: false, missing: false, reason: logValidation.reason, path: read.path, evidence: read.evidence };
|
|
304
|
+
const verification = read.evidence.state === "running" ? verifyEvidenceProcess(read.evidence, opts) : null;
|
|
305
|
+
return { ...read, verification };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function readProcessEvidence(runDir, opts = {}) {
|
|
309
|
+
const root = resolve(runDir);
|
|
310
|
+
const file = processEvidencePath(root);
|
|
311
|
+
if (!existsSync(file)) return { ok: false, missing: true, reason: "missing process evidence", path: file, evidence: null };
|
|
312
|
+
let fd;
|
|
313
|
+
try {
|
|
314
|
+
const rootStat = lstatSync(root);
|
|
315
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("run directory must be a non-symlink directory");
|
|
316
|
+
fd = openSync(file, FS_CONSTANTS.O_RDONLY | (FS_CONSTANTS.O_NOFOLLOW || 0));
|
|
317
|
+
if (!fstatSync(fd).isFile()) throw new Error("process evidence must be a regular file");
|
|
318
|
+
const evidence = JSON.parse(readFileSync(fd, "utf8"));
|
|
319
|
+
const validation = validateProcessEvidence(evidence, { ...opts, runDir });
|
|
320
|
+
if (!validation.ok) return { ok: false, missing: false, reason: validation.reason, path: file, evidence };
|
|
321
|
+
return { ok: true, missing: false, reason: null, path: file, evidence: validation.evidence };
|
|
322
|
+
} catch (error) {
|
|
323
|
+
return { ok: false, missing: false, reason: `invalid process evidence: ${error.message}`, path: file, evidence: null };
|
|
324
|
+
} finally {
|
|
325
|
+
if (fd !== undefined) closeSync(fd);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function inspectProcessEvidenceForCleanup(runDir, opts = {}) {
|
|
330
|
+
const read = readProcessEvidenceForCleanup(runDir);
|
|
331
|
+
if (read.missing) return cleanupProcessInspection("missing", null, read.reason, null);
|
|
332
|
+
if (!read.ok) return cleanupProcessInspection("invalid", read.evidence, read.reason, read.hash);
|
|
333
|
+
|
|
334
|
+
const evidence = read.evidence;
|
|
335
|
+
if (nonEmptyString(opts.runId) && evidence.run_id !== opts.runId) {
|
|
336
|
+
return cleanupProcessInspection("mismatched", evidence, "process evidence run_id does not match requested run", read.hash);
|
|
337
|
+
}
|
|
338
|
+
if (evidence.state !== "running") {
|
|
339
|
+
return cleanupProcessInspection("absent", evidence, `process evidence state is ${evidence.state}`, read.hash);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const verification = verifyEvidenceProcess(evidence, opts);
|
|
343
|
+
if (verification.status === "live-and-matching") return cleanupProcessInspection("live-matching", evidence, null, read.hash);
|
|
344
|
+
if (verification.status === "absent") return cleanupProcessInspection("absent", evidence, verification.reason, read.hash);
|
|
345
|
+
if (verification.status === "mismatched") return cleanupProcessInspection("mismatched", evidence, verification.reason, read.hash);
|
|
346
|
+
return cleanupProcessInspection("indeterminate", evidence, verification.reason, read.hash);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function cleanupProcessInspection(state, evidence, reason, hash) {
|
|
350
|
+
return { state, evidence, reason: reason || null, hash: hash || null };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function readProcessEvidenceForCleanup(runDir) {
|
|
354
|
+
const file = processEvidencePath(runDir);
|
|
355
|
+
let descriptor;
|
|
356
|
+
let hash = null;
|
|
357
|
+
try {
|
|
358
|
+
descriptor = openSync(file, FS_CONSTANTS.O_RDONLY | (FS_CONSTANTS.O_NOFOLLOW || 0));
|
|
359
|
+
if (!fstatSync(descriptor).isFile()) {
|
|
360
|
+
return { ok: false, missing: false, reason: "process evidence must be a regular file", evidence: null };
|
|
361
|
+
}
|
|
362
|
+
const bytes = readFileSync(descriptor);
|
|
363
|
+
hash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
364
|
+
const evidence = JSON.parse(bytes.toString("utf8"));
|
|
365
|
+
const validation = validateProcessEvidence(evidence, { runDir });
|
|
366
|
+
if (!validation.ok) return { ok: false, missing: false, reason: validation.reason, evidence, hash };
|
|
367
|
+
return { ok: true, missing: false, reason: null, evidence: validation.evidence, hash };
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (error?.code === "ENOENT") return { ok: false, missing: true, reason: "missing process evidence", evidence: null };
|
|
370
|
+
return { ok: false, missing: false, reason: `invalid process evidence: ${error.message}`, evidence: null, hash };
|
|
371
|
+
} finally {
|
|
372
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function writeProcessEvidence(runDir, evidence) {
|
|
377
|
+
const file = processEvidencePath(runDir);
|
|
378
|
+
mkdirSync(resolve(runDir), { recursive: true });
|
|
379
|
+
const validation = validateProcessEvidence(evidence, { runDir });
|
|
380
|
+
if (!validation.ok) throw new Error(validation.reason);
|
|
381
|
+
writeProtectedJsonAtomicSync(resolve(runDir), PROCESS_EVIDENCE_FILE, validation.evidence, {
|
|
382
|
+
commit: "replace-regular",
|
|
383
|
+
mode: 0o666,
|
|
384
|
+
});
|
|
385
|
+
return file;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function validateProcessEvidence(evidence, opts = {}) {
|
|
389
|
+
const errors = [];
|
|
390
|
+
if (!plainObject(evidence)) return invalid("process evidence must be a JSON object");
|
|
391
|
+
if (evidence.schema_version !== PROCESS_EVIDENCE_SCHEMA_VERSION) errors.push("schema_version must be 1");
|
|
392
|
+
if (evidence.kind !== PROCESS_EVIDENCE_KIND) errors.push("kind must be opencode-process");
|
|
393
|
+
if (!nonEmptyString(evidence.run_id)) errors.push("run_id must be a non-empty string");
|
|
394
|
+
if (nonEmptyString(opts.runId) && evidence.run_id !== opts.runId) errors.push("run_id must match requested run");
|
|
395
|
+
if (!nonEmptyString(evidence.execution_id)) errors.push("execution_id must be a non-empty string");
|
|
396
|
+
if (!positivePid(evidence.pid)) errors.push("pid must be a positive integer");
|
|
397
|
+
if (!validTimestamp(evidence.started_at)) errors.push("started_at must be an ISO timestamp");
|
|
398
|
+
if (!validTimestamp(evidence.updated_at)) errors.push("updated_at must be an ISO timestamp");
|
|
399
|
+
if (!PROCESS_STATES.has(evidence.state)) errors.push("state must be one of running, cancelled, failed-closed, exited");
|
|
400
|
+
if (!nonEmptyString(evidence.cwd) || !isAbsolute(evidence.cwd)) errors.push("cwd must be an absolute path");
|
|
401
|
+
if (!plainObject(evidence.identity)) {
|
|
402
|
+
errors.push("identity must be an object");
|
|
403
|
+
} else {
|
|
404
|
+
if (!nonEmptyString(evidence.identity.inspector)) errors.push("identity.inspector must be a non-empty string");
|
|
405
|
+
if (!nonEmptyString(evidence.identity.start_marker)) errors.push("identity.start_marker must be a non-empty string");
|
|
406
|
+
else if (String(evidence.identity.start_marker).startsWith("unverified:")) errors.push("identity.start_marker must be verifiable process evidence");
|
|
407
|
+
if (!nonEmptyString(evidence.identity.command_name)) errors.push("identity.command_name must be a non-empty string");
|
|
408
|
+
}
|
|
409
|
+
if (!validProcessLogRef(evidence.log_ref)) errors.push("log_ref must stay under processes/");
|
|
410
|
+
if (!(evidence.cancel === null || plainObject(evidence.cancel))) errors.push("cancel must be null or an object");
|
|
411
|
+
if (errors.length) return invalid(`invalid process evidence: ${errors.join("; ")}`);
|
|
412
|
+
return { ok: true, reason: null, evidence };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function assertDetachedProcessEvidenceWritable(runDir, input = {}) {
|
|
416
|
+
const current = readProcessEvidence(runDir, { ...input, runId: input.runId });
|
|
417
|
+
if (current.missing) return;
|
|
418
|
+
if (!current.ok) throw new Error(`refusing to overwrite invalid process evidence: ${current.reason}`);
|
|
419
|
+
if (current.evidence.state !== "running") return;
|
|
420
|
+
|
|
421
|
+
const verification = verifyEvidenceProcess(current.evidence, input);
|
|
422
|
+
if (verification.status === "absent") return;
|
|
423
|
+
if (verification.status === "live-and-matching") {
|
|
424
|
+
throw new Error(`refusing to overwrite live running process evidence for run '${current.evidence.run_id}' pid ${current.evidence.pid}`);
|
|
425
|
+
}
|
|
426
|
+
throw new Error(`refusing to overwrite running process evidence because stale/exited state could not be proven: ${verification.reason}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function recordDetachedProcessEvidence(runDir, input = {}) {
|
|
430
|
+
assertDetachedProcessEvidenceWritable(runDir, input);
|
|
431
|
+
const startedAt = timestamp(input.now);
|
|
432
|
+
const inspected = inspectProcessForEvidence(input.pid, input);
|
|
433
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
434
|
+
const verified = requireVerifiedProcessIdentity(inspected, cwd);
|
|
435
|
+
const identity = {
|
|
436
|
+
inspector: stringOrDefault(inspected.inspector, DEFAULT_INSPECTOR),
|
|
437
|
+
start_marker: verified.startMarker,
|
|
438
|
+
command_name: verified.commandName,
|
|
439
|
+
};
|
|
440
|
+
const evidence = {
|
|
441
|
+
schema_version: PROCESS_EVIDENCE_SCHEMA_VERSION,
|
|
442
|
+
kind: PROCESS_EVIDENCE_KIND,
|
|
443
|
+
run_id: input.runId,
|
|
444
|
+
execution_id: input.executionId || randomUUID(),
|
|
445
|
+
pid: input.pid,
|
|
446
|
+
started_at: startedAt,
|
|
447
|
+
updated_at: startedAt,
|
|
448
|
+
state: "running",
|
|
449
|
+
cwd,
|
|
450
|
+
identity,
|
|
451
|
+
log_ref: input.logRef,
|
|
452
|
+
cancel: null,
|
|
453
|
+
};
|
|
454
|
+
writeProcessEvidence(runDir, evidence);
|
|
455
|
+
return evidence;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function cancelProcessFromEvidence(runDir, opts = {}) {
|
|
459
|
+
const read = readProcessEvidence(runDir, opts);
|
|
460
|
+
if (!read.ok) return failClosed(opts.runId, read.reason, read.missing ? null : PROCESS_EVIDENCE_FILE);
|
|
461
|
+
|
|
462
|
+
const evidence = read.evidence;
|
|
463
|
+
if (evidence.state === "cancelled") {
|
|
464
|
+
return {
|
|
465
|
+
ok: true,
|
|
466
|
+
run_id: evidence.run_id,
|
|
467
|
+
status: "cancelled",
|
|
468
|
+
pid: evidence.pid,
|
|
469
|
+
signal: PROCESS_EVIDENCE_SIGNAL,
|
|
470
|
+
process_ref: PROCESS_EVIDENCE_FILE,
|
|
471
|
+
updated: false,
|
|
472
|
+
signaled: false,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (evidence.state !== "running") return failClosed(evidence.run_id, `process evidence state is ${evidence.state}`, PROCESS_EVIDENCE_FILE);
|
|
476
|
+
|
|
477
|
+
const signalResult = await signalEvidenceProcess(evidence, opts);
|
|
478
|
+
const verification = signalResult.verification;
|
|
479
|
+
if (verification?.status === "absent") {
|
|
480
|
+
// Already gone (including a prior cancel that was signaled but not yet
|
|
481
|
+
// confirmed): confirm the cancellation without signaling anything.
|
|
482
|
+
return confirmCancelled(runDir, evidence, {
|
|
483
|
+
requestedAt: evidence.cancel?.requested_at ?? timestamp(opts.now),
|
|
484
|
+
confirmedAt: timestamp(opts.now),
|
|
485
|
+
signaled: false,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (signalResult.status === "not-signaled") {
|
|
489
|
+
return failClosed(evidence.run_id, verification?.reason || signalResult.reason, PROCESS_EVIDENCE_FILE, evidence.pid);
|
|
490
|
+
}
|
|
491
|
+
if (signalResult.status === "signal-failed") {
|
|
492
|
+
return failClosed(evidence.run_id, `signal failed: ${signalResult.reason}`, PROCESS_EVIDENCE_FILE, evidence.pid);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const requestedAt = timestamp(opts.now);
|
|
496
|
+
|
|
497
|
+
// SIGTERM is a request, not an exit. Recording state 'cancelled' unblocks a
|
|
498
|
+
// relaunch, so only do it once the process is proven gone; a hung process
|
|
499
|
+
// that ignores SIGTERM must not fail open into concurrent duplicate runs.
|
|
500
|
+
const waitMs = normalizeCancelWait(opts.cancelWaitMs);
|
|
501
|
+
const pollClock = resolvePollClock(opts);
|
|
502
|
+
const pollSleep = resolvePollSleep(opts);
|
|
503
|
+
const started = readPollClock(pollClock);
|
|
504
|
+
const deadline = started === null ? null : started + waitMs;
|
|
505
|
+
const maximumPolls = Math.ceil(waitMs / CANCEL_POLL_INTERVAL_MS) + 1;
|
|
506
|
+
for (let count = 0; count < maximumPolls; count += 1) {
|
|
507
|
+
const observed = verifyEvidenceProcess(evidence, opts);
|
|
508
|
+
if (observed.status === "absent") {
|
|
509
|
+
return confirmCancelled(runDir, evidence, { requestedAt, confirmedAt: timestamp(), signaled: true });
|
|
510
|
+
}
|
|
511
|
+
const current = readPollClock(pollClock);
|
|
512
|
+
if (deadline === null || current === null) break;
|
|
513
|
+
const remaining = deadline - current;
|
|
514
|
+
if (remaining <= 0) break;
|
|
515
|
+
try {
|
|
516
|
+
await pollSleep(Math.min(CANCEL_POLL_INTERVAL_MS, remaining));
|
|
517
|
+
} catch {
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const pendingAt = timestamp();
|
|
523
|
+
writeProcessEvidence(runDir, {
|
|
524
|
+
...evidence,
|
|
525
|
+
updated_at: pendingAt,
|
|
526
|
+
cancel: {
|
|
527
|
+
requested_at: requestedAt,
|
|
528
|
+
signal: PROCESS_EVIDENCE_SIGNAL,
|
|
529
|
+
confirmed_at: null,
|
|
530
|
+
result: "pending",
|
|
531
|
+
reason: `process still alive ${waitMs}ms after ${PROCESS_EVIDENCE_SIGNAL}`,
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
run_id: evidence.run_id,
|
|
537
|
+
status: "cancel-pending",
|
|
538
|
+
reason: `pid ${evidence.pid} is still alive ${waitMs}ms after ${PROCESS_EVIDENCE_SIGNAL}; re-run factory cancel to confirm exit, or stop the process manually`,
|
|
539
|
+
pid: evidence.pid,
|
|
540
|
+
signal: PROCESS_EVIDENCE_SIGNAL,
|
|
541
|
+
process_ref: PROCESS_EVIDENCE_FILE,
|
|
542
|
+
updated: true,
|
|
543
|
+
signaled: true,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function confirmCancelled(runDir, evidence, { requestedAt, confirmedAt, signaled }) {
|
|
548
|
+
writeProcessEvidence(runDir, {
|
|
549
|
+
...evidence,
|
|
550
|
+
updated_at: confirmedAt,
|
|
551
|
+
state: "cancelled",
|
|
552
|
+
cancel: {
|
|
553
|
+
requested_at: requestedAt,
|
|
554
|
+
signal: PROCESS_EVIDENCE_SIGNAL,
|
|
555
|
+
confirmed_at: confirmedAt,
|
|
556
|
+
result: "cancelled",
|
|
557
|
+
reason: null,
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
return {
|
|
561
|
+
ok: true,
|
|
562
|
+
run_id: evidence.run_id,
|
|
563
|
+
status: "cancelled",
|
|
564
|
+
pid: evidence.pid,
|
|
565
|
+
signal: PROCESS_EVIDENCE_SIGNAL,
|
|
566
|
+
process_ref: PROCESS_EVIDENCE_FILE,
|
|
567
|
+
updated: true,
|
|
568
|
+
signaled,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function normalizeCancelWait(value) {
|
|
573
|
+
if (value === undefined || value === null) return DEFAULT_CANCEL_WAIT_MS;
|
|
574
|
+
const wait = Number(value);
|
|
575
|
+
if (!Number.isInteger(wait) || wait < 0) throw new Error("cancelWaitMs must be a non-negative integer");
|
|
576
|
+
return wait;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function resolvePollClock(opts) {
|
|
580
|
+
if (typeof opts.clock === "function") return opts.clock;
|
|
581
|
+
if (typeof opts.clockFn === "function") return opts.clockFn;
|
|
582
|
+
return Date.now;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function resolvePollSleep(opts) {
|
|
586
|
+
if (typeof opts.sleep === "function") return opts.sleep;
|
|
587
|
+
if (typeof opts.sleepFn === "function") return opts.sleepFn;
|
|
588
|
+
return (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function readPollClock(clock) {
|
|
592
|
+
try {
|
|
593
|
+
const value = clock();
|
|
594
|
+
return Number.isFinite(value) ? value : null;
|
|
595
|
+
} catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function inspectProcessIdentity(pid, opts = {}) {
|
|
601
|
+
const inspected = inspectVerifiedProcessIdentity(pid, processVerificationOptions(opts));
|
|
602
|
+
if (inspected.status === "live" && inspected.identity) {
|
|
603
|
+
return legacyIdentity(inspected.identity);
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
inspector: DEFAULT_INSPECTOR,
|
|
608
|
+
reason: legacyInspectionReason(inspected, opts),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function compareProcessIdentity(evidence, inspected) {
|
|
613
|
+
if (!inspected?.ok) return { ok: false, reason: inspected?.reason || "stale pid" };
|
|
614
|
+
if (!positivePid(inspected.pid) || inspected.pid !== evidence.pid) return { ok: false, reason: "inspected pid mismatch" };
|
|
615
|
+
if (!nonEmptyString(inspected.inspector) || inspected.inspector !== evidence.identity.inspector) return { ok: false, reason: "unsupported inspector" };
|
|
616
|
+
if (String(inspected.start_marker || "") !== evidence.identity.start_marker) return { ok: false, reason: "process start marker mismatch" };
|
|
617
|
+
if (normalizeCommandName(inspected.command_name || "") !== normalizeCommandName(evidence.identity.command_name)) return { ok: false, reason: "process command mismatch" };
|
|
618
|
+
if (resolve(String(inspected.cwd || "")) !== resolve(evidence.cwd)) return { ok: false, reason: "process cwd mismatch" };
|
|
619
|
+
return { ok: true };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function verifyEvidenceProcess(evidence, opts = {}) {
|
|
623
|
+
const injected = resolveLegacyInspector(opts);
|
|
624
|
+
if (!injected) {
|
|
625
|
+
const verified = verifyProcessIdentity(expectedProcessIdentity(evidence), processVerificationOptions(opts));
|
|
626
|
+
return { ...verified, reason: verificationReason(verified) };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const context = legacyVerificationContext(evidence, opts, injected);
|
|
630
|
+
const verified = verifyProcessIdentity(context.expected, context.options);
|
|
631
|
+
return {
|
|
632
|
+
...verified,
|
|
633
|
+
status: context.state.comparison && !context.state.comparison.ok ? "mismatched" : verified.status,
|
|
634
|
+
reason: context.state.comparison && !context.state.comparison.ok
|
|
635
|
+
? context.state.comparison.reason
|
|
636
|
+
: context.state.reason || verificationReason(verified),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function signalEvidenceProcess(evidence, opts) {
|
|
641
|
+
const injected = resolveLegacyInspector(opts);
|
|
642
|
+
const context = injected
|
|
643
|
+
? legacyVerificationContext(evidence, opts, injected)
|
|
644
|
+
: { expected: expectedProcessIdentity(evidence), options: processVerificationOptions(opts), state: null };
|
|
645
|
+
const signaled = await signalVerifiedProcess(context.expected, {
|
|
646
|
+
...context.options,
|
|
647
|
+
signal: PROCESS_EVIDENCE_SIGNAL,
|
|
648
|
+
waitForExitMs: 0,
|
|
649
|
+
});
|
|
650
|
+
const verified = signaled.verification;
|
|
651
|
+
if (!verified) return signaled;
|
|
652
|
+
return {
|
|
653
|
+
...signaled,
|
|
654
|
+
verification: {
|
|
655
|
+
...verified,
|
|
656
|
+
status: context.state?.comparison && !context.state.comparison.ok ? "mismatched" : verified.status,
|
|
657
|
+
reason: context.state?.comparison && !context.state.comparison.ok
|
|
658
|
+
? context.state.comparison.reason
|
|
659
|
+
: context.state?.reason || verificationReason(verified),
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function inspectProcessForEvidence(pid, opts) {
|
|
665
|
+
const injected = resolveLegacyInspector(opts);
|
|
666
|
+
if (!injected) return inspectProcessIdentity(pid, opts);
|
|
667
|
+
|
|
668
|
+
const initial = callLegacyInspector(injected, pid);
|
|
669
|
+
if (legacyInspectionStatus(initial) !== "live") return legacyInspectionFailure(initial);
|
|
670
|
+
if (
|
|
671
|
+
!nonEmptyString(initial.start_marker)
|
|
672
|
+
|| !nonEmptyString(initial.command_name)
|
|
673
|
+
|| !nonEmptyString(initial.cwd)
|
|
674
|
+
) return initial;
|
|
675
|
+
const expected = {
|
|
676
|
+
pid,
|
|
677
|
+
cwd: initial.cwd,
|
|
678
|
+
identity: {
|
|
679
|
+
inspector: stringOrDefault(initial.inspector, DEFAULT_INSPECTOR),
|
|
680
|
+
start_marker: initial.start_marker,
|
|
681
|
+
command_name: initial.command_name,
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
const context = legacyVerificationContext(expected, opts, injected);
|
|
685
|
+
const verified = verifyProcessIdentity(context.expected, context.options);
|
|
686
|
+
if (verified.status !== "live-and-matching" || (context.state.comparison && !context.state.comparison.ok)) {
|
|
687
|
+
return {
|
|
688
|
+
ok: false,
|
|
689
|
+
inspector: DEFAULT_INSPECTOR,
|
|
690
|
+
reason: context.state.comparison?.reason || context.state.reason || verificationReason(verified),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return context.state.inspected;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function legacyVerificationContext(evidence, opts, inspector) {
|
|
697
|
+
const state = { inspected: null, comparison: null, reason: null };
|
|
698
|
+
const options = {
|
|
699
|
+
...processVerificationOptions(opts),
|
|
700
|
+
platform: "linux",
|
|
701
|
+
platformFn: () => "linux",
|
|
702
|
+
livenessProbe: (pid) => {
|
|
703
|
+
const inspected = callLegacyInspector(inspector, pid);
|
|
704
|
+
state.inspected = inspected;
|
|
705
|
+
const status = legacyInspectionStatus(inspected);
|
|
706
|
+
if (status !== "live") {
|
|
707
|
+
state.reason = inspected?.reason || (status === "absent" ? "stale pid (ESRCH: no such process)" : "process liveness unknown");
|
|
708
|
+
return { status };
|
|
709
|
+
}
|
|
710
|
+
state.comparison = compareProcessIdentity(evidence, inspected);
|
|
711
|
+
if (!state.comparison.ok) return { status: "indeterminate" };
|
|
712
|
+
return { status: "live" };
|
|
713
|
+
},
|
|
714
|
+
procReadFile: (path) => path.endsWith("/stat")
|
|
715
|
+
? syntheticLinuxStat(evidence.pid)
|
|
716
|
+
: `${state.inspected?.command_name || ""}\n`,
|
|
717
|
+
procReadlink: () => state.inspected?.cwd || "",
|
|
718
|
+
};
|
|
719
|
+
return {
|
|
720
|
+
state,
|
|
721
|
+
options,
|
|
722
|
+
expected: {
|
|
723
|
+
pid: evidence.pid,
|
|
724
|
+
cwd: evidence.cwd,
|
|
725
|
+
identity: {
|
|
726
|
+
inspector: PROCESS_INSPECTOR,
|
|
727
|
+
start_marker: "linux-procfs:1",
|
|
728
|
+
command_name: evidence.identity.command_name,
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function expectedProcessIdentity(evidence) {
|
|
735
|
+
return {
|
|
736
|
+
pid: evidence.pid,
|
|
737
|
+
cwd: evidence.cwd,
|
|
738
|
+
identity: {
|
|
739
|
+
inspector: evidence.identity.inspector,
|
|
740
|
+
start_marker: evidence.identity.start_marker,
|
|
741
|
+
command_name: evidence.identity.command_name,
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function syntheticLinuxStat(pid) {
|
|
747
|
+
return `${pid} (compat) S ${Array(18).fill("0").join(" ")} 1\n`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function legacyIdentity(identity) {
|
|
751
|
+
return {
|
|
752
|
+
ok: true,
|
|
753
|
+
inspector: identity.inspector,
|
|
754
|
+
pid: identity.pid,
|
|
755
|
+
start_marker: identity.start_marker,
|
|
756
|
+
command_name: identity.command_name,
|
|
757
|
+
cwd: identity.cwd,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function legacyInspectionFailure(inspected) {
|
|
762
|
+
return {
|
|
763
|
+
ok: false,
|
|
764
|
+
inspector: stringOrDefault(inspected?.inspector, DEFAULT_INSPECTOR),
|
|
765
|
+
reason: inspected?.reason || (legacyInspectionStatus(inspected) === "absent" ? "stale pid (ESRCH: no such process)" : "process liveness unknown"),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function legacyInspectionStatus(inspected) {
|
|
770
|
+
if (inspected?.status === "live" || inspected?.status === "absent" || inspected?.status === "indeterminate") return inspected.status;
|
|
771
|
+
if (inspected?.ok === true) return "live";
|
|
772
|
+
if (inspected?.ok === false && (inspected.code === "ESRCH" || /\bESRCH\b/u.test(String(inspected.reason || "")))) {
|
|
773
|
+
return "absent";
|
|
774
|
+
}
|
|
775
|
+
return "indeterminate";
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function callLegacyInspector(inspector, pid) {
|
|
779
|
+
try {
|
|
780
|
+
return inspector(pid);
|
|
781
|
+
} catch {
|
|
782
|
+
return { ok: false, inspector: DEFAULT_INSPECTOR, reason: "process inspection failed" };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function verificationReason(verified) {
|
|
787
|
+
if (verified?.status === "absent") return "stale pid (ESRCH: no such process)";
|
|
788
|
+
const field = verified?.mismatched_fields?.[0];
|
|
789
|
+
if (field === "pid") return "inspected pid mismatch";
|
|
790
|
+
if (field === "inspector") return "unsupported inspector";
|
|
791
|
+
if (field === "start_marker") return "process start marker mismatch";
|
|
792
|
+
if (field === "command_name") return "process command mismatch";
|
|
793
|
+
if (field === "cwd") return "process cwd mismatch";
|
|
794
|
+
return verified?.reason || "process identity could not be verified";
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function legacyInspectionReason(inspected, opts) {
|
|
798
|
+
if (inspected?.status === "absent") return "stale pid (ESRCH: no such process)";
|
|
799
|
+
if (inspected?.code === "LIVENESS_PERMISSION_DENIED") return "process liveness unknown: EPERM";
|
|
800
|
+
if (inspected?.code === "INVALID_PID") return "pid must be a positive integer";
|
|
801
|
+
if (inspected?.code === "PLATFORM_UNSUPPORTED") {
|
|
802
|
+
const platform = opts.platform || process.platform;
|
|
803
|
+
return `process inspector unsupported on platform ${platform}`;
|
|
804
|
+
}
|
|
805
|
+
return inspected?.reason || "process identity could not be verified";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function requireVerifiedProcessIdentity(inspected, expectedCwd) {
|
|
809
|
+
if (!inspected?.ok) throw new Error(`process evidence requires verifiable live process identity: ${inspected?.reason || "stale pid"}`);
|
|
810
|
+
const startMarker = String(inspected.start_marker ?? "").trim();
|
|
811
|
+
if (!startMarker) throw new Error("process evidence requires verifiable live process identity: missing process start marker");
|
|
812
|
+
const commandName = normalizeCommandName(inspected.command_name || "");
|
|
813
|
+
if (!commandName) throw new Error("process evidence requires verifiable live process identity: missing process command");
|
|
814
|
+
if (!nonEmptyString(inspected.cwd)) throw new Error("process evidence requires verifiable live process identity: missing process cwd");
|
|
815
|
+
if (resolve(String(inspected.cwd)) !== expectedCwd) throw new Error("process evidence requires verifiable live process identity: process cwd mismatch");
|
|
816
|
+
return { startMarker, commandName };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function resolveLegacyInspector(opts = {}) {
|
|
820
|
+
return typeof opts.inspectorFn === "function" ? opts.inspectorFn
|
|
821
|
+
: typeof opts.processInspectorFn === "function" ? opts.processInspectorFn
|
|
822
|
+
: null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function processVerificationOptions(opts = {}) {
|
|
826
|
+
const options = { ...opts };
|
|
827
|
+
if (
|
|
828
|
+
typeof opts.processAliveFn === "function"
|
|
829
|
+
&& typeof opts.livenessProbe !== "function"
|
|
830
|
+
&& typeof opts.livenessProbeFn !== "function"
|
|
831
|
+
&& typeof opts.processLivenessProbe !== "function"
|
|
832
|
+
) {
|
|
833
|
+
options.livenessProbe = (pid) => ({ status: probeLegacyBooleanLiveness(opts.processAliveFn, pid) });
|
|
834
|
+
}
|
|
835
|
+
if (typeof opts.commandRunnerFn === "function" && typeof opts.commandRunner !== "function") {
|
|
836
|
+
options.commandRunner = opts.commandRunnerFn;
|
|
837
|
+
}
|
|
838
|
+
return options;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function failClosed(runId, reason, processRef, pid = null) {
|
|
842
|
+
return {
|
|
843
|
+
ok: false,
|
|
844
|
+
run_id: runId || null,
|
|
845
|
+
status: "failed-closed",
|
|
846
|
+
reason,
|
|
847
|
+
pid,
|
|
848
|
+
process_ref: processRef,
|
|
849
|
+
signaled: false,
|
|
850
|
+
updated: false,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function invalidLaunchClaim(path, reason, identity = null, hash = null) {
|
|
855
|
+
return { ok: false, missing: false, reason, path, claim: null, owner_status: "invalid", identity, hash };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function invalidClaim(reason) {
|
|
859
|
+
return { ok: false, reason, claim: null };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function claimIdentity(dirStat, fileStat = null) {
|
|
863
|
+
return {
|
|
864
|
+
dir: { dev: dirStat.dev, ino: dirStat.ino },
|
|
865
|
+
file: fileStat ? { dev: fileStat.dev, ino: fileStat.ino } : null,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function assertStableClaimPath(root, dir, file, rootStat, dirStat, fileStat) {
|
|
870
|
+
try {
|
|
871
|
+
const currentRoot = lstatSync(root);
|
|
872
|
+
const currentDir = lstatSync(dir);
|
|
873
|
+
const currentFile = lstatSync(file);
|
|
874
|
+
if (!sameIdentity(currentRoot, rootStat) || currentRoot.isSymbolicLink() || !currentRoot.isDirectory()
|
|
875
|
+
|| !sameIdentity(currentDir, dirStat) || currentDir.isSymbolicLink() || !currentDir.isDirectory()
|
|
876
|
+
|| !sameIdentity(currentFile, fileStat) || currentFile.isSymbolicLink() || !currentFile.isFile()) {
|
|
877
|
+
throw launchClaimChangedError();
|
|
878
|
+
}
|
|
879
|
+
} catch (error) {
|
|
880
|
+
if (error?.code === "LAUNCH_CLAIM_CHANGED") throw error;
|
|
881
|
+
throw launchClaimChangedError();
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function launchClaimChangedError() {
|
|
886
|
+
const error = new Error("launch claim identity changed during inspection");
|
|
887
|
+
error.code = "LAUNCH_CLAIM_CHANGED";
|
|
888
|
+
return error;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function sameIdentity(actual, expected) {
|
|
892
|
+
return Boolean(actual && expected) && actual.dev === expected.dev && actual.ino === expected.ino;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function resolveLaunchFenceNamespace(runDir, opts = {}) {
|
|
896
|
+
let requested;
|
|
897
|
+
if (nonEmptyString(opts.launchFenceRoot)) requested = resolve(opts.launchFenceRoot);
|
|
898
|
+
else {
|
|
899
|
+
const factoryRoot = dirnameForClaimDir(runDir);
|
|
900
|
+
const parent = basename(factoryRoot) === "factory" ? dirnameForClaimDir(factoryRoot) : factoryRoot;
|
|
901
|
+
requested = join(parent, ".factory-launch-fences");
|
|
902
|
+
}
|
|
903
|
+
const parent = realpathSync(dirnameForClaimDir(requested));
|
|
904
|
+
const parentStat = lstatSync(parent);
|
|
905
|
+
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) throw new Error("launch fence parent must be a non-symlink directory");
|
|
906
|
+
return { parent, parent_identity: claimIdentity(parentStat).dir, path: join(parent, basename(requested)) };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function ensureLaunchFenceRoot(requestedRunDir, physicalRunDir, expectedRunStat, namespace) {
|
|
910
|
+
assertLaunchFenceParent(namespace);
|
|
911
|
+
try {
|
|
912
|
+
mkdirSync(namespace.path, { mode: 0o700 });
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (error?.code !== "EEXIST") throw error;
|
|
915
|
+
}
|
|
916
|
+
const fenceRootStat = lstatSync(namespace.path);
|
|
917
|
+
if (!fenceRootStat.isDirectory() || fenceRootStat.isSymbolicLink()) throw new Error("launch fence root must be a non-symlink directory");
|
|
918
|
+
const fenceRoot = { ...namespace, root_identity: claimIdentity(fenceRootStat).dir };
|
|
919
|
+
assertLaunchFenceNamespace(fenceRoot);
|
|
920
|
+
assertLaunchFenceRunIdentity(requestedRunDir, physicalRunDir, expectedRunStat);
|
|
921
|
+
return fenceRoot;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function assertLaunchFenceParent(namespace) {
|
|
925
|
+
try {
|
|
926
|
+
const parentStat = lstatSync(namespace.parent);
|
|
927
|
+
if (!sameIdentity(parentStat, namespace.parent_identity) || parentStat.isSymbolicLink() || !parentStat.isDirectory()
|
|
928
|
+
|| realpathSync(namespace.parent) !== namespace.parent) throw new Error("launch fence namespace identity changed");
|
|
929
|
+
} catch (error) {
|
|
930
|
+
if (error?.message === "launch fence namespace identity changed") throw error;
|
|
931
|
+
throw new Error("launch fence namespace identity changed");
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function assertLaunchFenceNamespace(namespace) {
|
|
936
|
+
assertLaunchFenceParent(namespace);
|
|
937
|
+
try {
|
|
938
|
+
const rootStat = lstatSync(namespace.path);
|
|
939
|
+
if (!sameIdentity(rootStat, namespace.root_identity) || rootStat.isSymbolicLink() || !rootStat.isDirectory()
|
|
940
|
+
|| realpathSync(namespace.path) !== namespace.path) throw new Error("launch fence namespace identity changed");
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (error?.message === "launch fence namespace identity changed") throw error;
|
|
943
|
+
throw new Error("launch fence namespace identity changed");
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function assertLaunchFenceRunIdentity(requestedRunDir, physicalRunDir, expectedRunStat) {
|
|
948
|
+
try {
|
|
949
|
+
const runStat = lstatSync(requestedRunDir);
|
|
950
|
+
const physicalRunStat = lstatSync(physicalRunDir);
|
|
951
|
+
if (!sameIdentity(runStat, expectedRunStat) || !sameIdentity(physicalRunStat, expectedRunStat)
|
|
952
|
+
|| runStat.isSymbolicLink() || !runStat.isDirectory()
|
|
953
|
+
|| physicalRunStat.isSymbolicLink() || !physicalRunStat.isDirectory()
|
|
954
|
+
|| realpathSync(requestedRunDir) !== physicalRunDir) {
|
|
955
|
+
throw new Error("launch fence run directory identity changed");
|
|
956
|
+
}
|
|
957
|
+
} catch (error) {
|
|
958
|
+
if (error?.message === "launch fence run directory identity changed") throw error;
|
|
959
|
+
throw new Error("launch fence run directory identity changed");
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function removeFailedLaunchFence(path, expectedStat, nonce) {
|
|
964
|
+
let fd;
|
|
965
|
+
try {
|
|
966
|
+
const current = lstatSync(path);
|
|
967
|
+
if (!sameIdentity(current, expectedStat) || current.isSymbolicLink() || !current.isDirectory()) return;
|
|
968
|
+
const ownerPath = join(path, "owner.json");
|
|
969
|
+
let ownerStat = null;
|
|
970
|
+
try {
|
|
971
|
+
fd = openSync(ownerPath, FS_CONSTANTS.O_RDONLY | (FS_CONSTANTS.O_NOFOLLOW || 0));
|
|
972
|
+
ownerStat = fstatSync(fd);
|
|
973
|
+
if (!ownerStat.isFile()) return;
|
|
974
|
+
const owner = JSON.parse(readFileSync(fd, "utf8"));
|
|
975
|
+
const pathnameStat = lstatSync(ownerPath);
|
|
976
|
+
if (!sameIdentity(pathnameStat, ownerStat) || pathnameStat.isSymbolicLink()
|
|
977
|
+
|| owner?.kind !== LAUNCH_FENCE_KIND || owner?.nonce !== nonce) return;
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error?.code !== "ENOENT") return;
|
|
980
|
+
}
|
|
981
|
+
const quarantine = join(dirnameForClaimDir(path), `.${basename(path)}.failed-${randomUUID()}`);
|
|
982
|
+
renameSync(path, quarantine);
|
|
983
|
+
const movedDir = lstatSync(quarantine);
|
|
984
|
+
if (!sameIdentity(movedDir, expectedStat)) return;
|
|
985
|
+
if (ownerStat) {
|
|
986
|
+
const movedOwner = lstatSync(join(quarantine, "owner.json"));
|
|
987
|
+
if (!sameIdentity(movedOwner, ownerStat)) return;
|
|
988
|
+
}
|
|
989
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
990
|
+
} catch {
|
|
991
|
+
// Preserve uncertain fence evidence fail closed.
|
|
992
|
+
} finally {
|
|
993
|
+
if (fd !== undefined) closeSync(fd);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function inspectClaimOwner(claim, opts = {}) {
|
|
998
|
+
if (claim.hostname !== (opts.hostname || hostname())) return "indeterminate";
|
|
999
|
+
const expected = {
|
|
1000
|
+
pid: claim.pid,
|
|
1001
|
+
cwd: claim.identity.cwd,
|
|
1002
|
+
identity: {
|
|
1003
|
+
inspector: claim.identity.inspector,
|
|
1004
|
+
start_marker: claim.identity.start_marker,
|
|
1005
|
+
command_name: claim.identity.command_name,
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
const injected = resolveLegacyInspector(opts);
|
|
1009
|
+
const verified = injected
|
|
1010
|
+
? verifyEvidenceProcess({ ...expected, identity: expected.identity }, opts)
|
|
1011
|
+
: verifyProcessIdentity(expected, processVerificationOptions(opts));
|
|
1012
|
+
if (verified.status === "live-and-matching") return "live";
|
|
1013
|
+
if (verified.status === "absent") return "dead";
|
|
1014
|
+
if (verified.status === "mismatched") return "mismatched";
|
|
1015
|
+
return "indeterminate";
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function assertContainedLaunchPath(runDir, candidate) {
|
|
1019
|
+
const prefix = runDir.endsWith(sep) ? runDir : `${runDir}${sep}`;
|
|
1020
|
+
if (!candidate.startsWith(prefix)) throw new Error("launch claim path escapes the run directory");
|
|
1021
|
+
const runStat = lstatSync(runDir);
|
|
1022
|
+
if (!runStat.isDirectory() || runStat.isSymbolicLink()) throw new Error("run directory must be a non-symlink directory");
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function replaceExactClaim(runDir, observed, next) {
|
|
1026
|
+
const root = resolve(runDir);
|
|
1027
|
+
const dir = join(root, LAUNCH_CLAIM_DIR);
|
|
1028
|
+
const file = join(dir, LAUNCH_CLAIM_FILE);
|
|
1029
|
+
const dirStat = lstatSync(dir);
|
|
1030
|
+
const fileStat = lstatSync(file);
|
|
1031
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory() || fileStat.isSymbolicLink() || !fileStat.isFile()) throw new Error("launch claim identity changed");
|
|
1032
|
+
if (dirStat.dev !== observed.identity.dir.dev || dirStat.ino !== observed.identity.dir.ino || fileStat.dev !== observed.identity.file.dev || fileStat.ino !== observed.identity.file.ino) {
|
|
1033
|
+
throw new Error("launch claim identity changed");
|
|
1034
|
+
}
|
|
1035
|
+
const temp = join(dir, `.owner-${randomUUID()}.tmp`);
|
|
1036
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
1037
|
+
try {
|
|
1038
|
+
const current = inspectLaunchClaim(root, { runId: next.run_id });
|
|
1039
|
+
if (!current.ok || current.claim.nonce !== observed.claim.nonce || current.identity.file.dev !== observed.identity.file.dev || current.identity.file.ino !== observed.identity.file.ino) {
|
|
1040
|
+
throw new Error("launch claim identity changed before phase transition");
|
|
1041
|
+
}
|
|
1042
|
+
renameSync(temp, file);
|
|
1043
|
+
} finally {
|
|
1044
|
+
rmSync(temp, { force: true });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function inspectLaunchClaimAt(dir, opts = {}) {
|
|
1049
|
+
const runDir = dirnameForClaimDir(dir);
|
|
1050
|
+
if (basename(dir) === LAUNCH_CLAIM_DIR) return inspectLaunchClaim(runDir, opts);
|
|
1051
|
+
const file = join(dir, LAUNCH_CLAIM_FILE);
|
|
1052
|
+
let fd;
|
|
1053
|
+
try {
|
|
1054
|
+
const rootStat = lstatSync(runDir);
|
|
1055
|
+
const dirStat = lstatSync(dir);
|
|
1056
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return invalidLaunchClaim(file, "launch claim quarantine is invalid");
|
|
1057
|
+
fd = openSync(file, FS_CONSTANTS.O_RDONLY | (FS_CONSTANTS.O_NOFOLLOW || 0));
|
|
1058
|
+
const fileStat = fstatSync(fd);
|
|
1059
|
+
if (!fileStat.isFile()) return invalidLaunchClaim(file, "launch claim owner must be regular");
|
|
1060
|
+
const claim = JSON.parse(readFileSync(fd, "utf8"));
|
|
1061
|
+
assertStableClaimPath(runDir, dir, file, rootStat, dirStat, fileStat);
|
|
1062
|
+
const validation = validateLaunchClaim(claim, opts);
|
|
1063
|
+
if (!validation.ok) return invalidLaunchClaim(file, validation.reason);
|
|
1064
|
+
return { ok: true, claim, identity: claimIdentity(dirStat, fileStat) };
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
return invalidLaunchClaim(file, error.message);
|
|
1067
|
+
} finally {
|
|
1068
|
+
if (fd !== undefined) closeSync(fd);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function dirnameForClaimDir(dir) {
|
|
1073
|
+
const normalized = resolve(dir);
|
|
1074
|
+
return normalized.slice(0, normalized.lastIndexOf(sep)) || sep;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function validProcessLogRef(ref) {
|
|
1078
|
+
if (!nonEmptyString(ref) || isAbsolute(ref) || ref.includes("\\") || ref.includes("\0")) return false;
|
|
1079
|
+
const normalized = normalize(ref).replaceAll(sep, "/");
|
|
1080
|
+
if (normalized === "." || normalized.startsWith("../") || normalized === "..") return false;
|
|
1081
|
+
if (!normalized.startsWith("processes/")) return false;
|
|
1082
|
+
return basename(normalized).length > 0 && normalized !== "processes";
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function validateProcessLogEntry(runDir, ref) {
|
|
1086
|
+
try {
|
|
1087
|
+
const root = resolve(runDir);
|
|
1088
|
+
const processes = join(root, "processes");
|
|
1089
|
+
const log = resolve(root, ref);
|
|
1090
|
+
const prefix = processes.endsWith(sep) ? processes : `${processes}${sep}`;
|
|
1091
|
+
if (!log.startsWith(prefix)) return { ok: false, reason: "process log escapes processes/" };
|
|
1092
|
+
const rootStat = lstatSync(root);
|
|
1093
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return { ok: false, reason: "run directory must be a non-symlink directory" };
|
|
1094
|
+
const relativeParts = log.slice(prefix.length).split(sep).filter(Boolean);
|
|
1095
|
+
let current = processes;
|
|
1096
|
+
const processesStat = lstatSync(processes);
|
|
1097
|
+
if (!processesStat.isDirectory() || processesStat.isSymbolicLink()) return { ok: false, reason: "processes directory must be a non-symlink directory" };
|
|
1098
|
+
for (const [index, part] of relativeParts.entries()) {
|
|
1099
|
+
current = join(current, part);
|
|
1100
|
+
const entry = lstatSync(current);
|
|
1101
|
+
if (entry.isSymbolicLink()) return { ok: false, reason: "process log ancestors must not be symlinks" };
|
|
1102
|
+
if (index < relativeParts.length - 1 && !entry.isDirectory()) return { ok: false, reason: "process log ancestor must be a directory" };
|
|
1103
|
+
if (index === relativeParts.length - 1 && !entry.isFile()) return { ok: false, reason: "process log must be a regular file" };
|
|
1104
|
+
}
|
|
1105
|
+
return { ok: true };
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
return { ok: false, reason: `process log is inaccessible: ${error.message}` };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function normalizeCommandName(value) {
|
|
1112
|
+
const text = String(value || "").trim();
|
|
1113
|
+
if (!text) return "";
|
|
1114
|
+
return basename(text.split(/\s+/u)[0]);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function positivePid(value) {
|
|
1118
|
+
return Number.isInteger(value) && value > 0;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function plainObject(value) {
|
|
1122
|
+
return value && typeof value === "object" && !Array.isArray(value);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function nonEmptyString(value) {
|
|
1126
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function stringOrDefault(value, fallback) {
|
|
1130
|
+
return nonEmptyString(value) ? String(value).trim() : fallback;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function validTimestamp(value) {
|
|
1134
|
+
return nonEmptyString(value) && Number.isFinite(Date.parse(value));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function invalid(reason) {
|
|
1138
|
+
return { ok: false, reason, evidence: null };
|
|
1139
|
+
}
|