@cjhyy/code-shell-server 0.9.48 → 0.9.49
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/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
- package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
- package/dist/mobile-remote/remote-host-manager.js +132 -56
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +7 -1
- package/dist/panels/execution-gate.js +26 -0
- package/dist/panels/management.js +49 -13
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +2 -18
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +1 -1
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/package.json +6 -6
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
/** A live Host cannot cross OS boots. Only this constant is cached; PID/start
|
|
7
|
+
* identity and process/group liveness must always be inspected afresh. */
|
|
8
|
+
export function createBootSessionReader(read, now = () => performance.now()) {
|
|
9
|
+
let boot = null;
|
|
10
|
+
let retryAt = -Infinity;
|
|
11
|
+
return () => {
|
|
12
|
+
if (boot)
|
|
13
|
+
return boot;
|
|
14
|
+
if (now() < retryAt)
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
const value = read().trim();
|
|
18
|
+
if (/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value)) {
|
|
19
|
+
boot = value;
|
|
20
|
+
return boot;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Failed inspection proves neither ownership nor death.
|
|
25
|
+
}
|
|
26
|
+
retryAt = now() + 1000;
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const darwinBootSession = createBootSessionReader(() => execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
timeout: 2000,
|
|
33
|
+
killSignal: "SIGKILL",
|
|
34
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
35
|
+
}));
|
|
36
|
+
const linuxBootSession = createBootSessionReader(() => readFileSync("/proc/sys/kernel/random/boot_id", "utf8"));
|
|
37
|
+
/** Machine identity is constant for this Host lifetime. Failed OS inspection may
|
|
38
|
+
* be slow, so retry once per minute rather than on each reconciliation tick. */
|
|
39
|
+
export function createMachineIdentityReader(read, platform = process.platform, now = () => performance.now()) {
|
|
40
|
+
let machineIdentity;
|
|
41
|
+
let retryAt = -Infinity;
|
|
42
|
+
return () => {
|
|
43
|
+
if (machineIdentity)
|
|
44
|
+
return machineIdentity;
|
|
45
|
+
if (now() < retryAt)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const value = read()?.trim();
|
|
49
|
+
if (value && /^(?:[a-f0-9]{32}|[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/i.test(value)) {
|
|
50
|
+
machineIdentity = createHash("sha256")
|
|
51
|
+
.update(`${platform}:${value.toLowerCase()}`)
|
|
52
|
+
.digest("hex");
|
|
53
|
+
return machineIdentity;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* unavailable machine evidence never proves a different boot is local */
|
|
58
|
+
}
|
|
59
|
+
retryAt = now() + 60_000;
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const localMachineIdentity = createMachineIdentityReader(() => process.platform === "linux"
|
|
64
|
+
? readFileSync("/etc/machine-id", "utf8")
|
|
65
|
+
: process.platform === "darwin"
|
|
66
|
+
? execFileSync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
timeout: 2000,
|
|
69
|
+
killSignal: "SIGKILL",
|
|
70
|
+
maxBuffer: 65536,
|
|
71
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
72
|
+
}).match(/"IOPlatformUUID"\s*=\s*"([a-f0-9-]+)"/i)?.[1]
|
|
73
|
+
: undefined);
|
|
74
|
+
export function serviceSystemIdentity() {
|
|
75
|
+
return {
|
|
76
|
+
host: hostname(),
|
|
77
|
+
boot: (process.platform === "linux" ? linuxBootSession() : darwinBootSession()) ?? undefined,
|
|
78
|
+
machine: localMachineIdentity(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function parseLinuxProcessStat(stat) {
|
|
82
|
+
const fields = stat
|
|
83
|
+
.slice(stat.lastIndexOf(")") + 2)
|
|
84
|
+
.trim()
|
|
85
|
+
.split(/\s+/);
|
|
86
|
+
const pid = Number(stat.slice(0, stat.indexOf(" "))), group = Number(fields[2]);
|
|
87
|
+
if (!Number.isSafeInteger(pid) ||
|
|
88
|
+
pid <= 0 ||
|
|
89
|
+
!Number.isSafeInteger(group) ||
|
|
90
|
+
group < 0 ||
|
|
91
|
+
!/^\d+$/.test(fields[19] ?? "") ||
|
|
92
|
+
!/^[A-Zt]$/.test(fields[0] ?? ""))
|
|
93
|
+
return null;
|
|
94
|
+
return { pid, group, state: fields[0], start: `linux-ticks:${fields[19]}` };
|
|
95
|
+
}
|
|
96
|
+
export function processIdentity(pid) {
|
|
97
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
98
|
+
return null;
|
|
99
|
+
try {
|
|
100
|
+
if (process.platform === "linux") {
|
|
101
|
+
const system = serviceSystemIdentity();
|
|
102
|
+
const stat = parseLinuxProcessStat(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
103
|
+
// Zombies have closed their descriptors and can never execute again. Their
|
|
104
|
+
// retained /proc entry is not a live worker identity waiting for a sleep.
|
|
105
|
+
return system.boot && stat && !["Z", "X"].includes(stat.state)
|
|
106
|
+
? { pid, ...system, start: stat.start }
|
|
107
|
+
: null;
|
|
108
|
+
}
|
|
109
|
+
if (process.platform !== "darwin")
|
|
110
|
+
return null;
|
|
111
|
+
// bootsessionuuid is stable under wall-clock/NTP adjustment; boottime is not.
|
|
112
|
+
const system = serviceSystemIdentity();
|
|
113
|
+
if (!system.boot)
|
|
114
|
+
return null;
|
|
115
|
+
const start = darwinProcessStart(pid);
|
|
116
|
+
return start ? { pid, start, ...system } : null;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Kernel unique process ID and exec version, never a wall-clock timestamp. */
|
|
123
|
+
export function darwinProcessStart(pid) {
|
|
124
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
// System JXA calls libproc without requiring Python, a compiler, an addon,
|
|
128
|
+
// or Apple Events to another application. Missing OS inspection fails closed.
|
|
129
|
+
const info = JSON.parse(execFileSync("/usr/bin/osascript", [
|
|
130
|
+
"-l",
|
|
131
|
+
"JavaScript",
|
|
132
|
+
"-e",
|
|
133
|
+
`
|
|
134
|
+
ObjC.import("Foundation");
|
|
135
|
+
ObjC.bindFunction("proc_pidinfo", ["int", ["int", "int", "uint64", "void *", "int"]]);
|
|
136
|
+
var data = $.NSMutableData.dataWithLength(56);
|
|
137
|
+
var count = $.proc_pidinfo(${pid}, 17, 0, data.mutableBytes, 56);
|
|
138
|
+
JSON.stringify({count: count, data: ObjC.unwrap(data.base64EncodedStringWithOptions(0))});
|
|
139
|
+
`,
|
|
140
|
+
], {
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
timeout: 2000,
|
|
143
|
+
killSignal: "SIGKILL",
|
|
144
|
+
maxBuffer: 4096,
|
|
145
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
146
|
+
}));
|
|
147
|
+
const bytes = Buffer.from(info.data, "base64");
|
|
148
|
+
if (info.count !== 56 || bytes.length !== 56)
|
|
149
|
+
return null;
|
|
150
|
+
const unique = bytes.readBigUInt64LE(16);
|
|
151
|
+
const version = bytes.readUInt32LE(32);
|
|
152
|
+
return unique ? `darwin-unique:${unique}:${version}` : null;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function identityStatus(identity, inspect = processIdentity, system = inspect === processIdentity ? serviceSystemIdentity() : inspect(process.pid)) {
|
|
159
|
+
const locality = identityLocality(identity, system);
|
|
160
|
+
if (locality === "old-boot")
|
|
161
|
+
return "dead";
|
|
162
|
+
if (locality !== "same-boot")
|
|
163
|
+
return "unknown";
|
|
164
|
+
try {
|
|
165
|
+
process.kill(identity.pid, 0);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return error.code === "ESRCH" ? "dead" : "unknown";
|
|
169
|
+
}
|
|
170
|
+
const current = inspect(identity.pid);
|
|
171
|
+
if (!current && process.platform === "linux") {
|
|
172
|
+
try {
|
|
173
|
+
const stat = parseLinuxProcessStat(readFileSync(`/proc/${identity.pid}/stat`, "utf8"));
|
|
174
|
+
if (stat && ["Z", "X"].includes(stat.state))
|
|
175
|
+
return "dead";
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* an unreadable /proc entry does not prove death */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Legacy nonce-only records cannot prove PID reuse. Never kill or steal them.
|
|
182
|
+
if (!current ||
|
|
183
|
+
!identity.boot ||
|
|
184
|
+
!/^(?:darwin-unique:\d+:\d+|linux-ticks:\d+)$/.test(identity.start))
|
|
185
|
+
return "unknown";
|
|
186
|
+
return current.boot === identity.boot && current.start === identity.start ? "alive" : "dead";
|
|
187
|
+
}
|
|
188
|
+
function identityLocality(identity, system) {
|
|
189
|
+
if (!identity.boot || !system?.boot)
|
|
190
|
+
return "unknown";
|
|
191
|
+
if (identity.machine && system.machine && identity.machine !== system.machine)
|
|
192
|
+
return "unknown";
|
|
193
|
+
if (identity.boot === system.boot)
|
|
194
|
+
return "same-boot";
|
|
195
|
+
return identity.machine && identity.machine === system.machine ? "old-boot" : "unknown";
|
|
196
|
+
}
|
|
197
|
+
/** Online migration is safe only inside the same verified OS boot. A legacy
|
|
198
|
+
* record first encountered after reboot has no proof it came from this machine. */
|
|
199
|
+
export function upgradeLocalServiceIdentity(identity, system = serviceSystemIdentity()) {
|
|
200
|
+
return !identity.machine && system.machine && identityLocality(identity, system) === "same-boot"
|
|
201
|
+
? { ...identity, machine: system.machine }
|
|
202
|
+
: identity;
|
|
203
|
+
}
|
|
204
|
+
export function identityGroupStatus(identity, system = serviceSystemIdentity(), inspectGroup = groupStatus) {
|
|
205
|
+
const locality = identityLocality(identity, system);
|
|
206
|
+
return locality === "old-boot"
|
|
207
|
+
? "dead"
|
|
208
|
+
: locality === "same-boot"
|
|
209
|
+
? inspectGroup(identity.pid)
|
|
210
|
+
: "unknown";
|
|
211
|
+
}
|
|
212
|
+
export const serviceIdentityInspector = {
|
|
213
|
+
current: processIdentity,
|
|
214
|
+
status: identityStatus,
|
|
215
|
+
};
|
|
216
|
+
export function groupStatus(pid) {
|
|
217
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
218
|
+
return "unknown";
|
|
219
|
+
try {
|
|
220
|
+
process.kill(-pid, 0);
|
|
221
|
+
if (process.platform !== "linux")
|
|
222
|
+
return "alive";
|
|
223
|
+
return linuxGroupStatus(pid);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
return error.code === "ESRCH" ? "dead" : "unknown";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** kill(0) includes unreaped zombies on Linux. Prove that every visible group
|
|
230
|
+
* member is terminal; incomplete /proc visibility remains unknown. */
|
|
231
|
+
export function linuxGroupStatus(pid, read = (path) => readFileSync(path, "utf8"), list = () => readdirSync("/proc")) {
|
|
232
|
+
// A task can disappear after enumeration or after opening its stat file.
|
|
233
|
+
// ENOENT and ESRCH both describe that absence; permissions/visibility errors
|
|
234
|
+
// do not. A short/invalid read gets one fresh observation, never a guessed
|
|
235
|
+
// identity: only a valid stat or confirmed absence can complete the scan.
|
|
236
|
+
const memberAt = (memberPid) => {
|
|
237
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
238
|
+
try {
|
|
239
|
+
const member = parseLinuxProcessStat(read(`/proc/${memberPid}/stat`));
|
|
240
|
+
if (member?.pid === memberPid)
|
|
241
|
+
return member;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (["ENOENT", "ESRCH"].includes(error.code ?? ""))
|
|
245
|
+
return null;
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw new Error("SERVICE_PROC_STAT_UNREADABLE");
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
const leader = memberAt(pid);
|
|
253
|
+
if (leader?.group === pid && !["Z", "X"].includes(leader.state))
|
|
254
|
+
return "alive";
|
|
255
|
+
const mounts = read("/proc/mounts")
|
|
256
|
+
.split("\n")
|
|
257
|
+
.map((line) => line.split(" "));
|
|
258
|
+
const mount = mounts.find((fields) => fields[1] === "/proc" && fields[2] === "proc");
|
|
259
|
+
if (!mount ||
|
|
260
|
+
mount[3].split(",").some((option) => option.startsWith("hidepid=") && option !== "hidepid=0"))
|
|
261
|
+
return "unknown";
|
|
262
|
+
if (parseLinuxProcessStat(read("/proc/self/stat"))?.pid !== process.pid)
|
|
263
|
+
return "unknown";
|
|
264
|
+
const scan = () => {
|
|
265
|
+
const terminal = new Map();
|
|
266
|
+
for (const name of list()) {
|
|
267
|
+
if (!/^\d+$/.test(name))
|
|
268
|
+
continue;
|
|
269
|
+
const member = memberAt(Number(name));
|
|
270
|
+
if (!member || member.group !== pid)
|
|
271
|
+
continue;
|
|
272
|
+
if (!["Z", "X"].includes(member.state))
|
|
273
|
+
return "alive";
|
|
274
|
+
terminal.set(member.pid, member.start);
|
|
275
|
+
}
|
|
276
|
+
return terminal;
|
|
277
|
+
};
|
|
278
|
+
const before = scan();
|
|
279
|
+
if (typeof before === "string")
|
|
280
|
+
return before;
|
|
281
|
+
if (!before.size)
|
|
282
|
+
return "unknown";
|
|
283
|
+
// /proc enumeration is not a snapshot: a member can fork after the first
|
|
284
|
+
// listing and become a zombie before stat. All first-pass members are now
|
|
285
|
+
// terminal, so enumerate afresh and require exactly the same start identities.
|
|
286
|
+
// A late descendant, reaped member or PID reuse keeps recovery conservative.
|
|
287
|
+
const after = scan();
|
|
288
|
+
if (typeof after === "string")
|
|
289
|
+
return after;
|
|
290
|
+
return before.size === after.size &&
|
|
291
|
+
[...before].every(([member, start]) => after.get(member) === start)
|
|
292
|
+
? "dead"
|
|
293
|
+
: "unknown";
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return "unknown";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export const backgroundServiceReadFormats = new Set(["panel.background-service/1"]);
|
|
300
|
+
export const backgroundServiceWriteFormats = new Set(["panel.background-service/1"]);
|
|
301
|
+
function syncDirectory(path) {
|
|
302
|
+
const file = openSync(path, "r");
|
|
303
|
+
try {
|
|
304
|
+
fsyncSync(file);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
closeSync(file);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function readLockOwner(path) {
|
|
311
|
+
try {
|
|
312
|
+
const info = lstatSync(path);
|
|
313
|
+
if (!info.isFile() || info.size === 0 || info.size > 8192)
|
|
314
|
+
throw new Error("owner is not a bounded regular file");
|
|
315
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
316
|
+
if (!value ||
|
|
317
|
+
!Number.isSafeInteger(value.pid) ||
|
|
318
|
+
value.pid <= 0 ||
|
|
319
|
+
typeof value.host !== "string" ||
|
|
320
|
+
!value.host ||
|
|
321
|
+
value.host.length > 255 ||
|
|
322
|
+
typeof value.start !== "string" ||
|
|
323
|
+
!value.start ||
|
|
324
|
+
value.start.length > 256 ||
|
|
325
|
+
(value.boot !== undefined &&
|
|
326
|
+
(typeof value.boot !== "string" || !value.boot || value.boot.length > 128)) ||
|
|
327
|
+
(value.machine !== undefined &&
|
|
328
|
+
(typeof value.machine !== "string" || !value.machine || value.machine.length > 128)))
|
|
329
|
+
throw new Error("invalid owner identity");
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
catch (cause) {
|
|
333
|
+
throw new Error("SERVICE_COORDINATOR_LOCK_CORRUPT: owner token is empty, damaged or unreadable; retained for diagnosis", { cause });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
export class BackgroundServiceStore {
|
|
337
|
+
root;
|
|
338
|
+
fault;
|
|
339
|
+
identities;
|
|
340
|
+
owner;
|
|
341
|
+
poisoned = false;
|
|
342
|
+
lockPending = false;
|
|
343
|
+
constructor(root, fault, identities = serviceIdentityInspector) {
|
|
344
|
+
this.root = root;
|
|
345
|
+
this.fault = fault;
|
|
346
|
+
this.identities = identities;
|
|
347
|
+
}
|
|
348
|
+
acquire() {
|
|
349
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
|
350
|
+
const lock = join(this.root, "coordinator.lock");
|
|
351
|
+
if (this.owner) {
|
|
352
|
+
if (!this.lockPending)
|
|
353
|
+
return;
|
|
354
|
+
const owners = readdirSync(lock);
|
|
355
|
+
if (owners.length !== 1 || owners[0] !== this.owner)
|
|
356
|
+
throw new Error("SERVICE_COORDINATOR_LOCK_CORRUPT: retained ownership changed");
|
|
357
|
+
readLockOwner(join(lock, this.owner));
|
|
358
|
+
// A previous rename succeeded but its directory fsync was uncertain. The
|
|
359
|
+
// same retained owner may finish durability; another owner cannot steal it.
|
|
360
|
+
syncDirectory(lock);
|
|
361
|
+
syncDirectory(this.root);
|
|
362
|
+
this.poisoned = false;
|
|
363
|
+
this.lockPending = false;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const identity = this.identities.current(process.pid);
|
|
367
|
+
if (!identity)
|
|
368
|
+
throw new Error("SERVICE_IDENTITY_UNAVAILABLE");
|
|
369
|
+
const token = `owner-${randomUUID()}.json`;
|
|
370
|
+
const stage = join(this.root, `.lock-${randomUUID()}`);
|
|
371
|
+
const install = () => {
|
|
372
|
+
renameSync(stage, lock);
|
|
373
|
+
this.owner = token;
|
|
374
|
+
this.poisoned = true;
|
|
375
|
+
this.lockPending = true;
|
|
376
|
+
this.fault?.("lock-rename");
|
|
377
|
+
syncDirectory(this.root);
|
|
378
|
+
this.fault?.("lock-root-fsync");
|
|
379
|
+
this.poisoned = false;
|
|
380
|
+
this.lockPending = false;
|
|
381
|
+
// Abandoned payloads were never authoritative. Only the elected writer
|
|
382
|
+
// cleans exact temporary-record names; stop tombstones are never removed.
|
|
383
|
+
for (const name of readdirSync(this.root))
|
|
384
|
+
if (/^\.[a-f0-9]{64}-[a-f0-9-]+\.tmp$/.test(name))
|
|
385
|
+
unlinkSync(join(this.root, name));
|
|
386
|
+
};
|
|
387
|
+
try {
|
|
388
|
+
mkdirSync(stage, { mode: 0o700 });
|
|
389
|
+
const file = openSync(join(stage, token), "wx", 0o600);
|
|
390
|
+
try {
|
|
391
|
+
writeFileSync(file, JSON.stringify(identity));
|
|
392
|
+
this.fault?.("lock-write");
|
|
393
|
+
fsyncSync(file);
|
|
394
|
+
this.fault?.("lock-file-fsync");
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
closeSync(file);
|
|
398
|
+
}
|
|
399
|
+
syncDirectory(stage);
|
|
400
|
+
this.fault?.("lock-stage-fsync");
|
|
401
|
+
try {
|
|
402
|
+
install();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error.code ?? ""))
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
const owners = readdirSync(lock);
|
|
410
|
+
if (owners.length !== 1 || !/^owner-[a-f0-9-]+\.json$/.test(owners[0]))
|
|
411
|
+
throw new Error("SERVICE_COORDINATOR_LOCK_CORRUPT: expected exactly one immutable owner token");
|
|
412
|
+
const previous = readLockOwner(join(lock, owners[0]));
|
|
413
|
+
if (this.identities.status(previous) !== "dead") {
|
|
414
|
+
if (!previous.machine && previous.boot && identity.boot && previous.boot !== identity.boot)
|
|
415
|
+
throw new Error("SERVICE_COORDINATOR_LEGACY_LOCALITY_UNCONFIRMED: old-boot token has no machine proof; retained, do not remove until its original Host and group are verified stopped");
|
|
416
|
+
throw new Error("SERVICE_COORDINATOR_BUSY");
|
|
417
|
+
}
|
|
418
|
+
// Exact immutable token only. Never rename an existing shared lock directory.
|
|
419
|
+
unlinkSync(join(lock, owners[0]));
|
|
420
|
+
syncDirectory(lock);
|
|
421
|
+
rmdirSync(lock);
|
|
422
|
+
syncDirectory(this.root);
|
|
423
|
+
install();
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
if (!this.owner) {
|
|
427
|
+
try {
|
|
428
|
+
unlinkSync(join(stage, token));
|
|
429
|
+
rmdirSync(stage);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
/* another owner wins */
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
available() {
|
|
438
|
+
return !!this.owner && !this.poisoned;
|
|
439
|
+
}
|
|
440
|
+
/** Only the retained exclusive owner may reload after an uncertain write. */
|
|
441
|
+
recover(validate) {
|
|
442
|
+
if (!this.owner || this.lockPending)
|
|
443
|
+
throw new Error("SERVICE_COORDINATOR_UNAVAILABLE");
|
|
444
|
+
const records = this.all();
|
|
445
|
+
for (const record of records)
|
|
446
|
+
validate?.(record);
|
|
447
|
+
this.poisoned = false;
|
|
448
|
+
return records;
|
|
449
|
+
}
|
|
450
|
+
quarantine() {
|
|
451
|
+
this.poisoned = true;
|
|
452
|
+
}
|
|
453
|
+
release() {
|
|
454
|
+
if (!this.owner)
|
|
455
|
+
return;
|
|
456
|
+
const token = this.owner;
|
|
457
|
+
this.owner = undefined;
|
|
458
|
+
try {
|
|
459
|
+
unlinkSync(join(this.root, "coordinator.lock", token));
|
|
460
|
+
syncDirectory(join(this.root, "coordinator.lock"));
|
|
461
|
+
rmdirSync(join(this.root, "coordinator.lock"));
|
|
462
|
+
syncDirectory(this.root);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
/* only our exact token */
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
key(scope) {
|
|
469
|
+
return createHash("sha256")
|
|
470
|
+
.update([scope.appId, scope.projectPath, scope.name].join("\0"))
|
|
471
|
+
.digest("hex");
|
|
472
|
+
}
|
|
473
|
+
read(key) {
|
|
474
|
+
try {
|
|
475
|
+
const bytes = readFileSync(join(this.root, `${key}.json`));
|
|
476
|
+
if (bytes.length > 5 * 1024 * 1024)
|
|
477
|
+
throw new Error("SERVICE_RECORD_TOO_LARGE");
|
|
478
|
+
const record = JSON.parse(bytes.toString());
|
|
479
|
+
if (!backgroundServiceReadFormats.has(record.format))
|
|
480
|
+
throw new Error("SERVICE_FORMAT_UNSUPPORTED");
|
|
481
|
+
return record;
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error.code === "ENOENT")
|
|
485
|
+
return undefined;
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
all() {
|
|
490
|
+
return readdirSync(this.root)
|
|
491
|
+
.filter((name) => /^[a-f0-9]{64}\.json$/.test(name))
|
|
492
|
+
.map((name) => this.read(name.slice(0, -5)));
|
|
493
|
+
}
|
|
494
|
+
commit(key, record) {
|
|
495
|
+
if (!this.owner || this.poisoned)
|
|
496
|
+
throw new Error("SERVICE_COORDINATOR_UNAVAILABLE");
|
|
497
|
+
if (!backgroundServiceWriteFormats.has(record.format))
|
|
498
|
+
throw new Error("SERVICE_FORMAT_UNSUPPORTED");
|
|
499
|
+
const data = JSON.stringify(record);
|
|
500
|
+
if (Buffer.byteLength(data) > 5 * 1024 * 1024)
|
|
501
|
+
throw new Error("SERVICE_RECORD_TOO_LARGE");
|
|
502
|
+
const temp = join(this.root, `.${key}-${randomUUID()}.tmp`);
|
|
503
|
+
let file;
|
|
504
|
+
try {
|
|
505
|
+
file = openSync(temp, "wx", 0o600);
|
|
506
|
+
writeFileSync(file, data);
|
|
507
|
+
this.fault?.("write");
|
|
508
|
+
fsyncSync(file);
|
|
509
|
+
this.fault?.("file-fsync");
|
|
510
|
+
closeSync(file);
|
|
511
|
+
file = undefined;
|
|
512
|
+
renameSync(temp, join(this.root, `${key}.json`));
|
|
513
|
+
this.fault?.("rename");
|
|
514
|
+
const directory = openSync(this.root, "r");
|
|
515
|
+
try {
|
|
516
|
+
fsyncSync(directory);
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
closeSync(directory);
|
|
520
|
+
}
|
|
521
|
+
this.fault?.("directory-fsync");
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
// A rename may already be durable. Never keep writing a stale in-memory generation.
|
|
525
|
+
this.poisoned = true;
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
if (file !== undefined)
|
|
530
|
+
closeSync(file);
|
|
531
|
+
try {
|
|
532
|
+
unlinkSync(temp);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
/* rename consumed the temporary file */
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|