totemlayer 0.1.59 → 0.1.66

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.
@@ -0,0 +1,258 @@
1
+ import { access, copyFile, cp, mkdir, realpath, stat } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { dirname, isAbsolute, join, delimiter } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { isolationError } from "./inference-broker.mjs";
6
+
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));
9
+ const safePath = (path) => isAbsolute(path) && !/[\x00-\x1f]/u.test(path);
10
+ async function exists(path) {
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export async function resolveAgentExecutable(command, environment = process.env) {
20
+ const candidates = isAbsolute(command)
21
+ ? [command]
22
+ : (environment.PATH ?? "/usr/local/bin:/usr/bin:/bin")
23
+ .split(delimiter)
24
+ .filter(isAbsolute)
25
+ .map((root) => join(root, command));
26
+ for (const path of candidates) {
27
+ try {
28
+ await access(path, constants.X_OK);
29
+ const resolved = await realpath(path);
30
+ if ((await stat(resolved)).isFile()) return resolved;
31
+ } catch {
32
+ /* Next installation. */
33
+ }
34
+ }
35
+ throw isolationError(
36
+ "AGENT_NOT_INSTALLED",
37
+ "The selected agent executable is not installed on this host.",
38
+ );
39
+ }
40
+
41
+ /** A sandbox backend is a host capability, never a brand-specific allow/deny decision. */
42
+ export async function isolationBackend(platform = process.platform) {
43
+ if (platform === "darwin" && (await exists("/usr/bin/sandbox-exec"))) return "seatbelt";
44
+ if (platform === "linux" && (await exists("/usr/bin/bwrap"))) return "bubblewrap";
45
+ throw isolationError(
46
+ "ISOLATION_UNAVAILABLE",
47
+ "This host needs an OS isolation backend (macOS sandbox-exec or Linux bubblewrap) before automatic agent execution. No uncontained fallback is used.",
48
+ );
49
+ }
50
+
51
+ export function seatbeltProfile({ root, readPaths, codeRoots = [], port }) {
52
+ if (
53
+ ![root, ...readPaths, ...codeRoots].every(safePath) ||
54
+ (port !== null && (!Number.isInteger(port) || port < 1 || port > 65535))
55
+ )
56
+ throw isolationError("ISOLATION_CONFIG_INVALID", "Invalid sandbox paths or broker port.");
57
+ const quote = JSON.stringify;
58
+ const systemInfo = [
59
+ "hw.memsize",
60
+ "hw.pagesize",
61
+ "hw.pagesize_compat",
62
+ "hw.ncpu",
63
+ "hw.activecpu",
64
+ "hw.logicalcpu",
65
+ "hw.logicalcpu_max",
66
+ "hw.physicalcpu",
67
+ "hw.physicalcpu_max",
68
+ "hw.cpufamily",
69
+ "hw.cputype",
70
+ "hw.cpusubtype",
71
+ "hw.machine",
72
+ "hw.cachelinesize",
73
+ "kern.hostname",
74
+ "kern.version",
75
+ "kern.osrelease",
76
+ "kern.osversion",
77
+ "kern.ostype",
78
+ "kern.maxfilesperproc",
79
+ "kern.argmax",
80
+ "sysctl.proc_cputype",
81
+ ];
82
+ const metadata = new Set();
83
+ // Let a CLI distinguish absent system policy from an inaccessible policy.
84
+ // Existing policy contents stay denied unless explicitly provisioned.
85
+ for (const path of [
86
+ "/etc",
87
+ "/private/etc",
88
+ "/private/etc/codex",
89
+ "/private/etc/codex/requirements.toml",
90
+ "/private/etc/codex/managed_config.toml",
91
+ ])
92
+ metadata.add(path);
93
+ for (const path of [
94
+ root,
95
+ ...readPaths,
96
+ ...codeRoots,
97
+ "/System/Library",
98
+ "/usr/lib",
99
+ "/usr/share",
100
+ "/Library/Apple",
101
+ "/dev/null",
102
+ "/usr/bin/env",
103
+ ]) {
104
+ for (let parent = dirname(path); parent !== "/"; parent = dirname(parent)) metadata.add(parent);
105
+ }
106
+ return (
107
+ `(version 1)\n(deny default)\n` +
108
+ `(allow process-fork process-exec)\n` +
109
+ // Account lookup only; preferences, Keychain and owner files remain denied.
110
+ `(allow mach-lookup (global-name "com.apple.system.opendirectoryd.libinfo"))\n` +
111
+ `(allow sysctl-read ${systemInfo.map((name) => `(sysctl-name ${quote(name)})`).join(" ")} (sysctl-name-prefix "hw.optional."))\n` +
112
+ `(allow file-read-metadata ${[...metadata].map((path) => `(literal ${quote(path)})`).join(" ")})\n` +
113
+ `(allow signal (target self))\n` +
114
+ `(allow file-read* (literal "/") (subpath "/System/Library") (subpath "/usr/lib") (subpath "/usr/share") (subpath "/Library/Apple") (literal "/dev/null") (literal "/dev/urandom") (literal "/dev/random") (literal "/usr/bin/env") (subpath ${quote(root)}) ${readPaths.map((path) => `(literal ${quote(path)})`).join(" ")} ${codeRoots.map((path) => `(subpath ${quote(path)})`).join(" ")})\n` +
115
+ `(allow file-write* (subpath ${quote(root)}) (literal "/dev/null"))\n` +
116
+ (port ? `(allow network-outbound (remote tcp "localhost:${port}"))\n` : "")
117
+ );
118
+ }
119
+
120
+ /** Only executable code is readable. Owner home, auth files, sockets and repositories are absent. */
121
+ export async function isolatedInvocation({
122
+ command,
123
+ args,
124
+ root,
125
+ env,
126
+ broker,
127
+ environment = process.env,
128
+ platform = process.platform,
129
+ codePaths = [],
130
+ }) {
131
+ const backend = await isolationBackend(platform);
132
+ root = await realpath(root);
133
+ let executable = await resolveAgentExecutable(command, environment);
134
+ let node = await realpath(process.execPath);
135
+ args = await Promise.all(
136
+ args.map((arg) => (typeof arg === "string" && isAbsolute(arg) ? realpath(arg) : arg)),
137
+ );
138
+ let readPaths = [
139
+ ...new Set(
140
+ await Promise.all(
141
+ [
142
+ executable,
143
+ node,
144
+ ...codePaths,
145
+ ...args.filter((arg) => typeof arg === "string" && isAbsolute(arg)),
146
+ ].map((path) => realpath(path)),
147
+ ),
148
+ ),
149
+ ];
150
+ for (const path of readPaths) {
151
+ if (!safePath(path) || !(await stat(path)).isFile())
152
+ throw isolationError("ISOLATION_CONFIG_INVALID", "Agent code inputs must be explicit files.");
153
+ }
154
+ // Installed package code is a trusted executable dependency, never a user home
155
+ // or project directory. Standalone scripts expose only their exact code files.
156
+ const packageMatch = executable.match(/^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//u);
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
+ }
189
+ const childEnv = {
190
+ PATH: `${dirname(node)}:/usr/local/bin:/usr/bin:/bin`,
191
+ HOME: root,
192
+ CFFIXED_USER_HOME: root,
193
+ TMPDIR: root,
194
+ TMP: root,
195
+ TEMP: root,
196
+ XDG_CONFIG_HOME: join(root, "config"),
197
+ XDG_DATA_HOME: join(root, "data"),
198
+ XDG_CACHE_HOME: join(root, "cache"),
199
+ LANG: "en_US.UTF-8",
200
+ ...env,
201
+ };
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
+ }
211
+ return {
212
+ command: darwinSupervisor,
213
+ args: [
214
+ "--status-fd=3",
215
+ "--",
216
+ "/usr/bin/sandbox-exec",
217
+ "-p",
218
+ seatbeltProfile({ root, readPaths, codeRoots, port: broker?.port ?? null }),
219
+ executable,
220
+ ...args,
221
+ ],
222
+ env: childEnv,
223
+ cwd: root,
224
+ inheritSystemEnvironment: false,
225
+ managedLifecycle: "darwin-coalition",
226
+ };
227
+ }
228
+ const bargs = [
229
+ "--die-with-parent",
230
+ "--new-session",
231
+ "--unshare-all",
232
+ "--proc",
233
+ "/proc",
234
+ "--dev",
235
+ "/dev",
236
+ ];
237
+ for (const path of ["/usr/bin", "/usr/lib", "/bin", "/lib", "/lib64", "/etc/ssl/certs"])
238
+ if (await exists(path)) bargs.push("--ro-bind", path, path);
239
+ for (const path of readPaths) bargs.push("--ro-bind", path, path);
240
+ for (const path of codeRoots) bargs.push("--ro-bind", path, path);
241
+ bargs.push("--bind", root, root, "--chdir", root);
242
+ if (broker) {
243
+ bargs.push("--ro-bind", entry, entry, node, entry);
244
+ childEnv.AGENT_SESSION_CONFIG = JSON.stringify({
245
+ command: executable,
246
+ args,
247
+ cwd: root,
248
+ socketPath: broker.socketPath,
249
+ });
250
+ } else bargs.push(executable, ...args);
251
+ return {
252
+ command: "/usr/bin/bwrap",
253
+ args: bargs,
254
+ env: childEnv,
255
+ cwd: root,
256
+ inheritSystemEnvironment: false,
257
+ };
258
+ }
@@ -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
+ }
@@ -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,
@@ -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
+ }