totemlayer 0.1.63 → 0.1.67

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.
@@ -1,10 +1,11 @@
1
- import { access, realpath, stat } from "node:fs/promises";
1
+ import { access, copyFile, cp, mkdir, realpath, stat } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { dirname, isAbsolute, join, delimiter } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { isolationError } from "./inference-broker.mjs";
6
6
 
7
7
  const entry = fileURLToPath(new URL("./isolated-agent-entry.mjs", import.meta.url));
8
+ const darwinSupervisor = fileURLToPath(new URL("./native/darwin-lifetime", import.meta.url));
8
9
  const safePath = (path) => isAbsolute(path) && !/[\x00-\x1f]/u.test(path);
9
10
  async function exists(path) {
10
11
  try {
@@ -105,8 +106,7 @@ export function seatbeltProfile({ root, readPaths, codeRoots = [], port }) {
105
106
  return (
106
107
  `(version 1)\n(deny default)\n` +
107
108
  `(allow process-fork process-exec)\n` +
108
- // CoreFoundation's managed-preference checks need POSIX account lookup.
109
- // This is libinfo only: preference daemons, Keychain and owner files stay denied.
109
+ // Account lookup only; preferences, Keychain and owner files remain denied.
110
110
  `(allow mach-lookup (global-name "com.apple.system.opendirectoryd.libinfo"))\n` +
111
111
  `(allow sysctl-read ${systemInfo.map((name) => `(sysctl-name ${quote(name)})`).join(" ")} (sysctl-name-prefix "hw.optional."))\n` +
112
112
  `(allow file-read-metadata ${[...metadata].map((path) => `(literal ${quote(path)})`).join(" ")})\n` +
@@ -130,12 +130,12 @@ export async function isolatedInvocation({
130
130
  }) {
131
131
  const backend = await isolationBackend(platform);
132
132
  root = await realpath(root);
133
- const executable = await resolveAgentExecutable(command, environment);
134
- const node = await realpath(process.execPath);
133
+ let executable = await resolveAgentExecutable(command, environment);
134
+ let node = await realpath(process.execPath);
135
135
  args = await Promise.all(
136
136
  args.map((arg) => (typeof arg === "string" && isAbsolute(arg) ? realpath(arg) : arg)),
137
137
  );
138
- const readPaths = [
138
+ let readPaths = [
139
139
  ...new Set(
140
140
  await Promise.all(
141
141
  [
@@ -154,7 +154,38 @@ export async function isolatedInvocation({
154
154
  // Installed package code is a trusted executable dependency, never a user home
155
155
  // or project directory. Standalone scripts expose only their exact code files.
156
156
  const packageMatch = executable.match(/^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//u);
157
- const codeRoots = packageMatch ? [packageMatch[1]] : [];
157
+ let codeRoots = packageMatch ? [packageMatch[1]] : [];
158
+ if (backend === "seatbelt") {
159
+ // launchd does not inherit the caller's access to TCC-protected folders.
160
+ // Stage only the code already approved above, preserving its relative layout.
161
+ const protectedCode =
162
+ /^\/(?:System\/Volumes\/Data\/)?Users\/[^/]+\/(?:Documents|Desktop|Downloads|Library\/(?:Mobile Documents|CloudStorage))(?:\/|$)/u;
163
+ const staged = new Map();
164
+ const destination = (path) => join(root, "code", path.slice(1));
165
+ for (const path of codeRoots.filter((path) => protectedCode.test(path))) {
166
+ await mkdir(dirname(destination(path)), { recursive: true });
167
+ // Do not dereference package symlinks into unapproved owner files.
168
+ await cp(path, destination(path), {
169
+ recursive: true,
170
+ dereference: false,
171
+ verbatimSymlinks: true,
172
+ });
173
+ staged.set(path, destination(path));
174
+ }
175
+ for (const path of readPaths.filter((path) => protectedCode.test(path))) {
176
+ const target = destination(path);
177
+ if (!codeRoots.some((parent) => path.startsWith(`${parent}/`) && staged.has(parent))) {
178
+ await mkdir(dirname(target), { recursive: true });
179
+ await copyFile(path, target);
180
+ }
181
+ staged.set(path, target);
182
+ }
183
+ executable = staged.get(executable) ?? executable;
184
+ node = staged.get(node) ?? node;
185
+ args = args.map((arg) => staged.get(arg) ?? arg);
186
+ readPaths = readPaths.map((path) => staged.get(path) ?? path);
187
+ codeRoots = codeRoots.map((path) => staged.get(path) ?? path);
188
+ }
158
189
  const childEnv = {
159
190
  PATH: `${dirname(node)}:/usr/local/bin:/usr/bin:/bin`,
160
191
  HOME: root,
@@ -168,10 +199,21 @@ export async function isolatedInvocation({
168
199
  LANG: "en_US.UTF-8",
169
200
  ...env,
170
201
  };
171
- if (backend === "seatbelt")
202
+ if (backend === "seatbelt") {
203
+ try {
204
+ await access(darwinSupervisor, constants.X_OK);
205
+ } catch {
206
+ throw isolationError(
207
+ "ISOLATION_UNAVAILABLE",
208
+ "The macOS process lifetime supervisor is missing. Reinstall the CLI before running native agents.",
209
+ );
210
+ }
172
211
  return {
173
- command: "/usr/bin/sandbox-exec",
212
+ command: darwinSupervisor,
174
213
  args: [
214
+ "--status-fd=3",
215
+ "--",
216
+ "/usr/bin/sandbox-exec",
175
217
  "-p",
176
218
  seatbeltProfile({ root, readPaths, codeRoots, port: broker?.port ?? null }),
177
219
  executable,
@@ -180,7 +222,9 @@ export async function isolatedInvocation({
180
222
  env: childEnv,
181
223
  cwd: root,
182
224
  inheritSystemEnvironment: false,
225
+ managedLifecycle: "darwin-coalition",
183
226
  };
227
+ }
184
228
  const bargs = [
185
229
  "--die-with-parent",
186
230
  "--new-session",
@@ -0,0 +1,131 @@
1
+ import { gzipSync, gunzipSync } from "node:zlib";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, readdir } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+
6
+ export const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
7
+ export const MAX_RUNTIME_BYTES = 256 * 1024 * 1024;
8
+ const MAX_FILES = 12000;
9
+ const invalid = () => new Error("Invalid runtime update archive.");
10
+ function archivePath(value) {
11
+ if (
12
+ typeof value !== "string" ||
13
+ Buffer.byteLength(value) > 255 ||
14
+ !value.startsWith("node_modules/") ||
15
+ /[\\:\u0000-\u001f\u007f]/.test(value) ||
16
+ value.split("/").some((part) => !part || part === "." || part === "..")
17
+ )
18
+ throw invalid();
19
+ return value;
20
+ }
21
+ function octal(buffer, start, length, value) {
22
+ const text = value.toString(8);
23
+ if (text.length >= length) throw invalid();
24
+ buffer.write(text.padStart(length - 1, "0") + "\0", start, length, "ascii");
25
+ }
26
+ function checksum(header) {
27
+ let sum = 0;
28
+ for (let index = 0; index < 512; index++) sum += index >= 148 && index < 156 ? 32 : header[index];
29
+ return sum;
30
+ }
31
+ export async function buildRuntimeArchive(root) {
32
+ const chunks = [];
33
+ let count = 0,
34
+ total = 0;
35
+ async function walk(relative) {
36
+ const names = (await readdir(join(root, relative))).sort();
37
+ for (const name of names) {
38
+ const path = archivePath(relative + "/" + name);
39
+ const info = await lstat(join(root, path));
40
+ if (info.isSymbolicLink()) throw invalid();
41
+ if (info.isDirectory()) {
42
+ await walk(path);
43
+ continue;
44
+ }
45
+ if (!info.isFile() || ++count > MAX_FILES || (total += info.size) > MAX_RUNTIME_BYTES)
46
+ throw invalid();
47
+ const data = await readFile(join(root, path));
48
+ const header = Buffer.alloc(512);
49
+ const split = path.lastIndexOf("/");
50
+ const short = Buffer.byteLength(path) <= 100 ? path : path.slice(split + 1);
51
+ const prefix = short === path ? "" : path.slice(0, split);
52
+ if (Buffer.byteLength(short) > 100 || Buffer.byteLength(prefix) > 155) throw invalid();
53
+ header.write(short, 0, 100, "utf8");
54
+ octal(header, 100, 8, info.mode & 0o111 ? 0o700 : 0o600);
55
+ octal(header, 108, 8, 0);
56
+ octal(header, 116, 8, 0);
57
+ octal(header, 124, 12, data.length);
58
+ octal(header, 136, 12, 0);
59
+ header.write("0", 156);
60
+ header.write("ustar\0", 257);
61
+ header.write("00", 263);
62
+ header.write(prefix, 345, 155, "utf8");
63
+ octal(header, 148, 8, checksum(header));
64
+ chunks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
65
+ }
66
+ }
67
+ await walk("node_modules");
68
+ chunks.push(Buffer.alloc(1024));
69
+ const tar = Buffer.concat(chunks);
70
+ if (tar.length > MAX_RUNTIME_BYTES) throw invalid();
71
+ const archive = gzipSync(tar, { level: 9 });
72
+ if (archive.length > MAX_ARCHIVE_BYTES) throw invalid();
73
+ return archive;
74
+ }
75
+ export async function extractRuntimeArchive(archive, destination) {
76
+ if (archive.length > MAX_ARCHIVE_BYTES) throw invalid();
77
+ const data = gunzipSync(archive, { maxOutputLength: MAX_RUNTIME_BYTES });
78
+ const seen = new Set();
79
+ let offset = 0,
80
+ ended = false;
81
+ const text = (buffer, start, size) =>
82
+ buffer
83
+ .subarray(start, start + size)
84
+ .toString("utf8")
85
+ .replace(/\0.*$/s, "");
86
+ const number = (buffer, start, size) => {
87
+ const value = text(buffer, start, size).trim();
88
+ if (!/^[0-7]+$/.test(value)) throw invalid();
89
+ const result = Number.parseInt(value, 8);
90
+ if (!Number.isSafeInteger(result) || result < 0) throw invalid();
91
+ return result;
92
+ };
93
+ while (offset + 512 <= data.length) {
94
+ const header = data.subarray(offset, offset + 512);
95
+ if (header.every((value) => value === 0)) {
96
+ ended = true;
97
+ break;
98
+ }
99
+ if (
100
+ text(header, 257, 6) !== "ustar" ||
101
+ ![0, 48].includes(header[156]) ||
102
+ text(header, 157, 100) ||
103
+ number(header, 148, 8) !== checksum(header)
104
+ )
105
+ throw invalid();
106
+ const prefix = text(header, 345, 155);
107
+ const path = archivePath((prefix ? prefix + "/" : "") + text(header, 0, 100));
108
+ const size = number(header, 124, 12);
109
+ const mode = number(header, 100, 8);
110
+ if (seen.has(path) || seen.size >= MAX_FILES || offset + 512 + size > data.length)
111
+ throw invalid();
112
+ seen.add(path);
113
+ const target = join(destination, path);
114
+ // Every parent is created inside a new, private staging directory.
115
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
116
+ const handle = await open(
117
+ target,
118
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
119
+ mode & 0o111 ? 0o700 : 0o600,
120
+ );
121
+ try {
122
+ await handle.writeFile(data.subarray(offset + 512, offset + 512 + size));
123
+ } finally {
124
+ await handle.close();
125
+ }
126
+ offset += 512 + Math.ceil(size / 512) * 512;
127
+ }
128
+ if (!ended || seen.size === 0 || data.subarray(offset).some((value) => value !== 0))
129
+ throw invalid();
130
+ return seen.size;
131
+ }
@@ -7,6 +7,9 @@ const MISSION_ERROR_CODES = new Map([
7
7
  ]);
8
8
 
9
9
  export const RUNTIME_ERROR_CODES = new Set([
10
+ "LISTENER_STARTUP_UNAVAILABLE",
11
+ "LISTENER_HEARTBEAT_TIMEOUT",
12
+ "LISTENER_PROCESS_UNVERIFIED",
10
13
  ...MISSION_ERROR_CODES.values(),
11
14
  "AGENT_START_FAILED",
12
15
  "AGENT_EXIT_FAILED",
@@ -60,15 +60,50 @@ export async function privateWrite(path, value) {
60
60
  export async function ensureInstalledRuntime(env = process.env) {
61
61
  const root = runtimeRoot(env);
62
62
  await privateDirectory(root);
63
+ await privateDirectory(join(root, "runtime"));
64
+ const { withRuntimeUpdateLock } = await import("./runtime-update.mjs");
65
+ return withRuntimeUpdateLock(root, () => installRuntimeUnlocked(env), { waitMs: 20000 });
66
+ }
67
+ async function installRuntimeUnlocked(env) {
68
+ const root = runtimeRoot(env);
69
+ const { compareRuntimeVersions } = await import("./runtime-update.mjs");
70
+ let previousRecord = {};
63
71
  let previousVersion;
64
72
  try {
65
- previousVersion = (await privateRead(join(root, "runtime", "current.json"))).version;
73
+ previousRecord = await privateRead(join(root, "runtime", "current.json"));
74
+ previousVersion = previousRecord.version;
66
75
  } catch (error) {
67
76
  if (error.code !== "ENOENT") throw error;
68
77
  }
69
78
  const metadata = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
70
79
  if (!/^\d+\.\d+\.\d+$/.test(metadata.version)) throw new Error("Invalid runtime version.");
71
80
  const runtime = join(root, "runtime");
81
+ // A pinned bootstrap is still useful after an update; it must never downgrade the managed copy.
82
+ if (previousVersion && compareRuntimeVersions(previousVersion, metadata.version) > 0) {
83
+ const installed = JSON.parse(
84
+ await readFile(
85
+ join(
86
+ root,
87
+ "runtime-packages",
88
+ previousVersion,
89
+ "node_modules",
90
+ "totemlayer",
91
+ "package.json",
92
+ ),
93
+ "utf8",
94
+ ),
95
+ );
96
+ if (installed.name !== "totemlayer" || installed.version !== previousVersion)
97
+ throw new Error("Invalid managed runtime installation.");
98
+ return {
99
+ root,
100
+ cli: join(runtime, "totem.mjs"),
101
+ supervisor: join(runtime, "supervisor.mjs"),
102
+ directory: runtime,
103
+ version: previousVersion,
104
+ upgraded: false,
105
+ };
106
+ }
72
107
  const modules = join(root, "runtime-packages", metadata.version, "node_modules");
73
108
  const destination = join(modules, "totemlayer");
74
109
  await privateDirectory(runtime);
@@ -118,6 +153,11 @@ export async function ensureInstalledRuntime(env = process.env) {
118
153
  }
119
154
  if (!selected) throw new Error("Install Totem's runtime dependencies before starting it.");
120
155
  const dependencyManifest = JSON.parse(await readFile(join(selected, "package.json"), "utf8"));
156
+ if (
157
+ dependency.from === packageRoot &&
158
+ dependencyManifest.version !== metadata.dependencies[dependency.name]
159
+ )
160
+ throw new Error("Runtime dependency does not match the pinned release version.");
121
161
  if (copied.has(dependency.name)) {
122
162
  if (copied.get(dependency.name) !== dependencyManifest.version)
123
163
  throw new Error("Conflicting runtime dependency versions.");
@@ -169,7 +209,7 @@ if (process.env.TOTEM_RUNTIME_ENV_LOADED === entry) {
169
209
  catch (error) { if (error.code !== 'ENOENT') throw error; }
170
210
  }
171
211
  // Node reads proxy opt-in and extra CA configuration at process startup.
172
- let stopping = false, child;
212
+ let stopping = false, child, failures = 0;
173
213
  const controller = new AbortController();
174
214
  const handlers = ['SIGTERM', 'SIGINT'].map((signal) => [signal, () => {
175
215
  stopping = true; controller.abort(); child?.kill(signal);
@@ -185,7 +225,22 @@ if (process.env.TOTEM_RUNTIME_ENV_LOADED === entry) {
185
225
  child.once('error', () => resolve(1));
186
226
  child.once('exit', (code) => resolve(code ?? 1));
187
227
  });
188
- ${target === "automatic-runtime.mjs" ? `if (code === 0) break;\n if (!stopping) await delay(Date.now() - started < 30000 ? 30000 : 1000, undefined, { signal: controller.signal }).catch(() => {});` : "process.exitCode = code;"}
228
+ ${
229
+ target === "automatic-runtime.mjs"
230
+ ? `if (code === 0) break;
231
+ if (!stopping && code !== 75 && Date.now() - started < 30000) failures++;
232
+ else failures = 0;
233
+ if (!stopping && failures >= 3) {
234
+ try {
235
+ const latest = JSON.parse(await readFile(file, 'utf8'));
236
+ const { rollbackRuntimeUpdate } = await import(pathToFileURL(join(root, '..', 'runtime-packages', version, 'node_modules', 'totemlayer', 'bin', 'runtime-update.mjs')));
237
+ await rollbackRuntimeUpdate(latest.version, { ...environment, TOTEM_CONFIG_DIR: join(root, '..') });
238
+ } catch {}
239
+ failures = 0;
240
+ }
241
+ if (!stopping) await delay(code === 75 ? 100 : Date.now() - started < 30000 ? 30000 : 1000, undefined, { signal: controller.signal }).catch(() => {});`
242
+ : "process.exitCode = code;"
243
+ }
189
244
  } while (${target === "automatic-runtime.mjs" ? "!stopping" : "false"});
190
245
  for (const [signal, handler] of handlers) process.removeListener(signal, handler);
191
246
  }
@@ -205,7 +260,11 @@ if (process.env.TOTEM_RUNTIME_ENV_LOADED === entry) {
205
260
  await handle.close();
206
261
  }
207
262
  }
208
- await privateWrite(join(runtime, "current.json"), { version: metadata.version });
263
+ if (previousVersion !== metadata.version)
264
+ await privateWrite(join(runtime, "current.json"), {
265
+ version: metadata.version,
266
+ ...(previousVersion ? { previousVersion } : {}),
267
+ });
209
268
  return {
210
269
  root,
211
270
  cli,
@@ -503,6 +562,22 @@ export async function installRuntimeService(runtime, env = process.env, options
503
562
  await run("systemctl", ["--user", "daemon-reload"]);
504
563
  await run("systemctl", ["--user", "enable", "--now", unit]);
505
564
  if (runtime.upgraded) await run("systemctl", ["--user", "restart", unit]);
565
+ const enabled = await run("systemctl", ["--user", "is-enabled", unit]);
566
+ const active = await run("systemctl", ["--user", "is-active", unit]);
567
+ const loaded = await run("systemctl", [
568
+ "--user",
569
+ "show",
570
+ unit,
571
+ "--property=ExecStart",
572
+ "--value",
573
+ ]);
574
+ if (
575
+ enabled.stdout.trim() !== "enabled" ||
576
+ active.stdout.trim() !== "active" ||
577
+ !loaded.stdout.includes(node) ||
578
+ !loaded.stdout.includes(runtime.supervisor)
579
+ )
580
+ throw new Error("The exact persistent listener service is not enabled and active.");
506
581
  return acknowledged({ mode: "systemd-user", persistent: true, label: unit, path });
507
582
  }
508
583
  if (platform === "win32") {
@@ -513,6 +588,17 @@ export async function installRuntimeService(runtime, env = process.env, options
513
588
  } catch {
514
589
  /* Ephemeral/headless hosts often have no user service manager. */
515
590
  }
591
+ if (windowsRegistration?.mode !== "startup-shortcut")
592
+ return {
593
+ mode: "unavailable",
594
+ persistent: false,
595
+ code:
596
+ startupCode ??
597
+ windowsRegistration?.code ??
598
+ (platform === "darwin" || platform === "win32"
599
+ ? "STARTUP_REGISTRATION_FAILED"
600
+ : "LISTENER_STARTUP_UNAVAILABLE"),
601
+ };
516
602
  const child = (options.spawn ?? spawn)(node, [runtime.supervisor], {
517
603
  env: { ...env, TOTEM_CONFIG_DIR: runtime.root },
518
604
  stdio: "ignore",
@@ -521,13 +607,5 @@ export async function installRuntimeService(runtime, env = process.env, options
521
607
  });
522
608
  child.on("error", () => {});
523
609
  child.unref();
524
- return windowsRegistration?.mode === "startup-shortcut"
525
- ? acknowledged({ mode: "startup-shortcut", persistent: true, label: task })
526
- : {
527
- mode: "background-supervisor",
528
- persistent: false,
529
- ...(["win32", "darwin"].includes(platform)
530
- ? { code: startupCode ?? windowsRegistration?.code ?? "STARTUP_REGISTRATION_FAILED" }
531
- : {}),
532
- };
610
+ return acknowledged({ mode: "startup-shortcut", persistent: true, label: task });
533
611
  }
@@ -0,0 +1,118 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { lstat, rename, readdir, unlink, rmdir, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { powershellInvocation } from "./platform-client.mjs";
7
+ import { privateDirectory, privateRead, privateWrite } from "./runtime-installation.mjs";
8
+
9
+ const execute = promisify(execFile);
10
+ export async function processIdentity(pid) {
11
+ if (!Number.isSafeInteger(pid) || pid <= 1) return null;
12
+ try {
13
+ process.kill(pid, 0);
14
+ } catch (error) {
15
+ if (error.code === "ESRCH") return null;
16
+ throw error;
17
+ }
18
+ // PID reuse across reboot must not make an abandoned lock permanent. A failed
19
+ // process inspection is uncertainty, never evidence that another owner died.
20
+ const invocation =
21
+ process.platform === "win32"
22
+ ? powershellInvocation(
23
+ `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}';if(!$p){exit 1};$p|Select-Object ProcessId,CreationDate,CommandLine|ConvertTo-Json -Compress`,
24
+ process.env,
25
+ )
26
+ : { command: "ps", args: ["-p", String(pid), "-o", "lstart=", "-o", "args="] };
27
+ const result = await execute(invocation.command, invocation.args, {
28
+ encoding: "utf8",
29
+ timeout: 3000,
30
+ maxBuffer: 8192,
31
+ });
32
+ if (!result.stdout.trim()) throw new Error("Cannot verify the supervisor process identity.");
33
+ return result.stdout.trim();
34
+ }
35
+ export async function readSupervisorOwner(lock) {
36
+ const info = await lstat(lock);
37
+ if (
38
+ !info.isDirectory() ||
39
+ info.isSymbolicLink() ||
40
+ (process.getuid && info.uid !== process.getuid()) ||
41
+ (process.platform !== "win32" && (info.mode & 0o777) !== 0o700)
42
+ )
43
+ throw new Error("Unsafe supervisor lock.");
44
+ const names = await readdir(lock);
45
+ if (!names.length) return null;
46
+ if (names.length !== 1 || !/^(?:owner|owner-[a-f0-9-]{36})\.json$/.test(names[0]))
47
+ throw new Error("The supervisor lock contains unrecognized state.");
48
+ const owner = await privateRead(join(lock, names[0]));
49
+ if (
50
+ !Number.isSafeInteger(owner?.pid) ||
51
+ owner.pid <= 1 ||
52
+ (names[0] !== "owner.json" &&
53
+ (names[0] !== `owner-${owner.token}.json` ||
54
+ typeof owner.identity !== "string" ||
55
+ !owner.identity))
56
+ )
57
+ throw new Error("Invalid supervisor owner.");
58
+ return { file: names[0], owner };
59
+ }
60
+ export async function acquireSupervisorLock(directory, options = {}) {
61
+ const lock = join(directory, "supervisor.lock");
62
+ const stage = join(directory, `supervisor-start-${randomUUID()}`);
63
+ const token = randomUUID();
64
+ const ownerFile = `owner-${token}.json`;
65
+ const identity = options.processIdentity ?? processIdentity;
66
+ await privateDirectory(directory);
67
+ const owner = { pid: process.pid, token, identity: await identity(process.pid) };
68
+ if (!owner.identity) throw new Error("Cannot verify this supervisor process.");
69
+ await privateDirectory(stage);
70
+ await privateWrite(join(stage, ownerFile), owner);
71
+ const removeObserved = async (file) => {
72
+ if (file)
73
+ await unlink(join(lock, file)).catch((error) => {
74
+ if (error.code !== "ENOENT") throw error;
75
+ });
76
+ // Never recursively delete or rename the shared lock path. A replacement
77
+ // contains its own unique owner file and makes rmdir fail with ENOTEMPTY.
78
+ await rmdir(lock).catch((error) => {
79
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
80
+ });
81
+ };
82
+ try {
83
+ for (let attempt = 0; attempt < 3; attempt++) {
84
+ const snapshot = await lstat(lock).catch((error) => {
85
+ if (error.code !== "ENOENT") throw error;
86
+ return null;
87
+ });
88
+ if (snapshot) {
89
+ const current = await readSupervisorOwner(lock).catch((error) => {
90
+ if (error.code !== "ENOENT") throw error;
91
+ return undefined;
92
+ });
93
+ if (current === undefined) continue;
94
+ if (!current) {
95
+ if (Date.now() - snapshot.mtimeMs < (options.emptyLockGraceMs ?? 30000))
96
+ throw new Error("Another supervisor is starting. Wait before retrying.");
97
+ } else {
98
+ const actual = await identity(current.owner.pid);
99
+ if (actual && (!current.owner.identity || current.owner.identity === actual))
100
+ throw Object.assign(new Error("The Totem supervisor is already running."), {
101
+ code: "SUPERVISOR_ALREADY_RUNNING",
102
+ });
103
+ }
104
+ await removeObserved(current?.file);
105
+ }
106
+ try {
107
+ // Publish owner and lock together. New installations never expose an empty lock.
108
+ await rename(stage, lock);
109
+ return () => removeObserved(ownerFile);
110
+ } catch (error) {
111
+ if (!["EEXIST", "ENOTEMPTY", "ENOENT"].includes(error.code)) throw error;
112
+ }
113
+ }
114
+ throw new Error("Another supervisor is starting. Wait before retrying.");
115
+ } finally {
116
+ await rm(stage, { recursive: true, force: true });
117
+ }
118
+ }