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.
- package/README.md +34 -4
- package/bin/agent-runner.mjs +91 -1
- package/bin/agent-setup.mjs +7 -1
- package/bin/automatic-runtime.mjs +30 -123
- package/bin/connected-agent-service.mjs +1 -1
- package/bin/installation.mjs +12 -8
- package/bin/managed-mcp.mjs +68 -0
- package/bin/native/darwin-lifetime +0 -0
- package/bin/native/darwin-lifetime.json +1 -0
- package/bin/network-runtime.mjs +1 -0
- package/bin/process-isolation.mjs +53 -9
- package/bin/runtime-archive.mjs +131 -0
- package/bin/runtime-diagnostics.mjs +3 -0
- package/bin/runtime-installation.mjs +91 -13
- package/bin/runtime-lock.mjs +118 -0
- package/bin/runtime-update.mjs +345 -0
- package/bin/sdk/inspector.js +1 -1
- package/bin/startup-diagnostics.mjs +13 -0
- package/bin/totem.mjs +11 -5
- package/bin/update-health.mjs +10 -0
- package/bin/update-trust.mjs +7 -0
- package/bin/worker-core/adapters.mjs +27 -6
- package/bin/worker-core/errors.mjs +4 -0
- package/package.json +3 -2
- package/scripts/build-native.mjs +60 -0
- package/scripts/prepare.mjs +1 -0
|
@@ -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
|
-
//
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|