totemlayer 0.1.63 → 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.
- package/README.md +34 -4
- package/bin/automatic-runtime.mjs +11 -116
- package/bin/managed-mcp.mjs +68 -0
- package/bin/native/darwin-lifetime +0 -0
- package/bin/native/darwin-lifetime.json +1 -0
- package/bin/process-isolation.mjs +53 -9
- package/bin/runtime-archive.mjs +131 -0
- package/bin/runtime-installation.mjs +63 -4
- package/bin/runtime-lock.mjs +118 -0
- package/bin/runtime-update.mjs +345 -0
- package/bin/sdk/inspector.js +1 -1
- 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
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ To configure execution later, rerun setup with `--provider` and the adapter's se
|
|
|
27
27
|
|
|
28
28
|
## Connect with automatic listener setup
|
|
29
29
|
|
|
30
|
-
Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://totemlayer.com/downloads/totemlayer-0.1.
|
|
30
|
+
Configure a local MCP server using command `npx` and arguments `--yes`, `--ignore-scripts`, `--package=https://totemlayer.com/downloads/totemlayer-0.1.66.tgz`, `totem`, `connect`, `--provider`, and `codex` (or `claude` for Claude Code). The host runs the connection itself; the owner does not run a separate listener command. Node.js 22.14 or newer and the selected signed-in client must be available.
|
|
31
31
|
|
|
32
32
|
During browser sign-in, `totem_get_connection_status` reports progress. Normal tools appear after the approved identity is pinned privately. Codex verifies identity, startup and the saved chat callback before receiving work; reconnect preserves bindings and pauses. Missing capabilities leave work queued. A remote HTTP connector cannot install local software.
|
|
33
33
|
|
|
@@ -45,7 +45,7 @@ For a hosted connection, add these settings to your client's MCP configuration:
|
|
|
45
45
|
For a local stdio connection, install the CLI with Node.js 22.14 or newer:
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
|
-
npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.
|
|
48
|
+
npm install --global --ignore-scripts https://totemlayer.com/downloads/totemlayer-0.1.66.tgz
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
Save your agent's connection key in a private file. Protect it with mode 0600 on macOS/Linux or your user's filesystem ACL on Windows. Configure these fields in your client's MCP settings:
|
|
@@ -64,7 +64,7 @@ Reload the client if needed, discover the available tools, and call `totem_get_i
|
|
|
64
64
|
Run the guided installer on the host where your agent will execute work:
|
|
65
65
|
|
|
66
66
|
```sh
|
|
67
|
-
npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.
|
|
67
|
+
npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.66.tgz totem install
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
The installer opens Totem sign-in and saves the approved connection privately. It checks your configured agent runtime, configures supported clients, and starts the listener when an authorized executor is available. The supervised listener is enabled by default for this setup and starts when you sign in to the computer on hosts with a supported user service manager. It recovers after a process exit and retries temporary connection failures. An explicit stop stays paused across restarts.
|
|
@@ -74,7 +74,7 @@ For manual setup, use `totem mcp install` with a supported client and the privat
|
|
|
74
74
|
For a host without a browser:
|
|
75
75
|
|
|
76
76
|
```sh
|
|
77
|
-
npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.
|
|
77
|
+
npx --yes --package=https://totemlayer.com/downloads/totemlayer-0.1.66.tgz totem login --device-login --no-browser
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
Open the printed link on your own device and compare the connection code. Preserve existing MCP entries; inspect conflicts before replacing them. If an executor is unavailable, the saved connection remains available while you complete runtime setup.
|
|
@@ -146,3 +146,33 @@ An AI app such as Instinct or Codex may reject a setup, start, or listener tool
|
|
|
146
146
|
Ask the owner to inspect the host app's approval or review controls for that operation. If the host offers no approval path or explanation, direct the owner to that host's support. Do not promise an approve-once button, infer permission from a "Full access" label, change agents or credentials, disable safeguards, or use another execution path to bypass the denial.
|
|
147
147
|
|
|
148
148
|
A command denied before execution cannot write Totem status. Saved configuration and verified identity do not prove that the worker started. Keep startup and message delivery marked unverified. When Totem itself observes an approval denial, it saves the cause and recovery and pauses automatic startup attempts. After the owner resolves the host decision, explicitly run totem start --profile <profile> once to resume, inspect totem status --profile <profile>, and verify an authorized incoming message and reply. Do not repeat setup or reauthorize credentials solely because of a host denial.
|
|
149
|
+
|
|
150
|
+
### Native turn cleanup
|
|
151
|
+
|
|
152
|
+
On macOS 13 or later, the CLI's bundled native supervisor starts a temporary
|
|
153
|
+
launchd job for each turn. Its descendants remain in one kernel resource
|
|
154
|
+
coalition even if they change sessions or process groups. Completion, cancellation,
|
|
155
|
+
timeouts, output limits, and loss of the calling process trigger cleanup; Totem
|
|
156
|
+
waits for the kernel to reap the coalition before settling the turn. Ordinary
|
|
157
|
+
subprocesses remain available within the same filesystem and network sandbox.
|
|
158
|
+
No VM or permanently installed launch service is needed. macOS must permit the
|
|
159
|
+
temporary job to start. A missing supervisor stops execution; a failure to confirm
|
|
160
|
+
cleanup does not become a successful result. Force-killing the trusted supervisor
|
|
161
|
+
itself is outside this guarantee.
|
|
162
|
+
When approved code is in a macOS protected folder such as Documents, the parent
|
|
163
|
+
copies only its explicit code files and approved executable package into the
|
|
164
|
+
temporary turn folder. Relative code layout is preserved and command arguments
|
|
165
|
+
are updated; unrelated owner files remain unavailable. Code that hardcodes its
|
|
166
|
+
original protected location must use its staged path instead.
|
|
167
|
+
|
|
168
|
+
Build the universal supervisor on macOS with `node packages/cli/scripts/build-native.mjs`. The CLI package includes the binary; installation requires no compiler.
|
|
169
|
+
|
|
170
|
+
## Automatic MCP and receiver updates
|
|
171
|
+
|
|
172
|
+
Install version 0.1.66 or newer once, or update the local plugin to that version. After that, the existing receiver checks for releases every 15 minutes while its host is running. Local MCP connections also check in the background. Downloads must have a valid Totem release signature and matching SHA-256 hash; dependencies are bundled and no package installation scripts run during an automatic update.
|
|
173
|
+
|
|
174
|
+
An update changes only the managed code directory. Existing identities, credentials, permissions, message cursors, journals, and receiver pauses remain in place. Receiver workers finish active work before upgrading. An open MCP session keeps its loaded tools until the host opens its next connection. Hosted MCP tools continue to update on the server.
|
|
175
|
+
|
|
176
|
+
`totem update status` shows the installed version and last check. `totem update check` checks now. `totem update pause` disables automatic downloads; `totem update resume` enables them. For a plugin connection, add its `--provider codex` or `--provider claude` selection. `TOTEM_AUTO_UPDATE=0` also disables checks. Pausing updates does not pause message receiving.
|
|
177
|
+
|
|
178
|
+
Unverified downloads and failed health checks leave working code installed. A receiver that repeatedly crashes immediately after an update falls back to its retained previous version and quarantines the failed version. An offline or sleeping computer checks after it resumes; updates do not wake a closed laptop or add a wake mechanism to an external AI host.
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
+
import { checkRuntimeUpdate } from "./runtime-update.mjs";
|
|
2
|
+
import { acquireSupervisorLock } from "./runtime-lock.mjs";
|
|
3
|
+
export { acquireSupervisorLock, processIdentity, readSupervisorOwner } from "./runtime-lock.mjs";
|
|
1
4
|
import { startupDiagnostic } from "./startup-diagnostics.mjs";
|
|
2
5
|
import { DEFAULT_LISTENER_POLL_MS } from "./sdk/listener.js";
|
|
3
|
-
import { opendir, rm, readFile
|
|
6
|
+
import { opendir, rm, readFile } from "node:fs/promises";
|
|
4
7
|
import { join } from "node:path";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
|
-
import { powershellInvocation } from "./platform-client.mjs";
|
|
9
8
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
10
9
|
import { fileURLToPath } from "node:url";
|
|
11
10
|
import { Totem } from "./sdk/index.js";
|
|
12
11
|
import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
|
|
13
12
|
import { agentStartMain, readAgentRuntimeStatus, requestStop } from "./agent-runner.mjs";
|
|
14
13
|
import {
|
|
15
|
-
privateDirectory,
|
|
16
14
|
privateRead,
|
|
17
15
|
privateWrite,
|
|
18
16
|
runtimeRoot,
|
|
@@ -194,116 +192,6 @@ export async function superviseOnce(env = process.env, options = {}) {
|
|
|
194
192
|
}
|
|
195
193
|
return results;
|
|
196
194
|
}
|
|
197
|
-
const execute = promisify(execFile);
|
|
198
|
-
export async function processIdentity(pid) {
|
|
199
|
-
if (!Number.isSafeInteger(pid) || pid <= 1) return null;
|
|
200
|
-
try {
|
|
201
|
-
process.kill(pid, 0);
|
|
202
|
-
} catch (error) {
|
|
203
|
-
if (error.code === "ESRCH") return null;
|
|
204
|
-
throw error;
|
|
205
|
-
}
|
|
206
|
-
// PID reuse across reboot must not make an abandoned lock permanent. A failed
|
|
207
|
-
// process inspection is uncertainty, never evidence that another owner died.
|
|
208
|
-
const invocation =
|
|
209
|
-
process.platform === "win32"
|
|
210
|
-
? powershellInvocation(
|
|
211
|
-
`$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}';if(!$p){exit 1};$p|Select-Object ProcessId,CreationDate,CommandLine|ConvertTo-Json -Compress`,
|
|
212
|
-
process.env,
|
|
213
|
-
)
|
|
214
|
-
: { command: "ps", args: ["-p", String(pid), "-o", "lstart=", "-o", "args="] };
|
|
215
|
-
const result = await execute(invocation.command, invocation.args, {
|
|
216
|
-
encoding: "utf8",
|
|
217
|
-
timeout: 3000,
|
|
218
|
-
maxBuffer: 8192,
|
|
219
|
-
});
|
|
220
|
-
if (!result.stdout.trim()) throw new Error("Cannot verify the supervisor process identity.");
|
|
221
|
-
return result.stdout.trim();
|
|
222
|
-
}
|
|
223
|
-
export async function readSupervisorOwner(lock) {
|
|
224
|
-
const info = await lstat(lock);
|
|
225
|
-
if (
|
|
226
|
-
!info.isDirectory() ||
|
|
227
|
-
info.isSymbolicLink() ||
|
|
228
|
-
(process.getuid && info.uid !== process.getuid()) ||
|
|
229
|
-
(process.platform !== "win32" && (info.mode & 0o777) !== 0o700)
|
|
230
|
-
)
|
|
231
|
-
throw new Error("Unsafe supervisor lock.");
|
|
232
|
-
const names = await readdir(lock);
|
|
233
|
-
if (!names.length) return null;
|
|
234
|
-
if (names.length !== 1 || !/^(?:owner|owner-[a-f0-9-]{36})\.json$/.test(names[0]))
|
|
235
|
-
throw new Error("The supervisor lock contains unrecognized state.");
|
|
236
|
-
const owner = await privateRead(join(lock, names[0]));
|
|
237
|
-
if (
|
|
238
|
-
!Number.isSafeInteger(owner?.pid) ||
|
|
239
|
-
owner.pid <= 1 ||
|
|
240
|
-
(names[0] !== "owner.json" &&
|
|
241
|
-
(names[0] !== `owner-${owner.token}.json` ||
|
|
242
|
-
typeof owner.identity !== "string" ||
|
|
243
|
-
!owner.identity))
|
|
244
|
-
)
|
|
245
|
-
throw new Error("Invalid supervisor owner.");
|
|
246
|
-
return { file: names[0], owner };
|
|
247
|
-
}
|
|
248
|
-
export async function acquireSupervisorLock(directory, options = {}) {
|
|
249
|
-
const lock = join(directory, "supervisor.lock");
|
|
250
|
-
const stage = join(directory, `supervisor-start-${randomUUID()}`);
|
|
251
|
-
const token = randomUUID();
|
|
252
|
-
const ownerFile = `owner-${token}.json`;
|
|
253
|
-
const identity = options.processIdentity ?? processIdentity;
|
|
254
|
-
await privateDirectory(directory);
|
|
255
|
-
const owner = { pid: process.pid, token, identity: await identity(process.pid) };
|
|
256
|
-
if (!owner.identity) throw new Error("Cannot verify this supervisor process.");
|
|
257
|
-
await privateDirectory(stage);
|
|
258
|
-
await privateWrite(join(stage, ownerFile), owner);
|
|
259
|
-
const removeObserved = async (file) => {
|
|
260
|
-
if (file)
|
|
261
|
-
await unlink(join(lock, file)).catch((error) => {
|
|
262
|
-
if (error.code !== "ENOENT") throw error;
|
|
263
|
-
});
|
|
264
|
-
// Never recursively delete or rename the shared lock path. A replacement
|
|
265
|
-
// contains its own unique owner file and makes rmdir fail with ENOTEMPTY.
|
|
266
|
-
await rmdir(lock).catch((error) => {
|
|
267
|
-
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
|
|
268
|
-
});
|
|
269
|
-
};
|
|
270
|
-
try {
|
|
271
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
272
|
-
const snapshot = await lstat(lock).catch((error) => {
|
|
273
|
-
if (error.code !== "ENOENT") throw error;
|
|
274
|
-
return null;
|
|
275
|
-
});
|
|
276
|
-
if (snapshot) {
|
|
277
|
-
const current = await readSupervisorOwner(lock).catch((error) => {
|
|
278
|
-
if (error.code !== "ENOENT") throw error;
|
|
279
|
-
return undefined;
|
|
280
|
-
});
|
|
281
|
-
if (current === undefined) continue;
|
|
282
|
-
if (!current) {
|
|
283
|
-
if (Date.now() - snapshot.mtimeMs < (options.emptyLockGraceMs ?? 30000))
|
|
284
|
-
throw new Error("Another supervisor is starting. Wait before retrying.");
|
|
285
|
-
} else {
|
|
286
|
-
const actual = await identity(current.owner.pid);
|
|
287
|
-
if (actual && (!current.owner.identity || current.owner.identity === actual))
|
|
288
|
-
throw Object.assign(new Error("The Totem supervisor is already running."), {
|
|
289
|
-
code: "SUPERVISOR_ALREADY_RUNNING",
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
await removeObserved(current?.file);
|
|
293
|
-
}
|
|
294
|
-
try {
|
|
295
|
-
// Publish owner and lock together. New installations never expose an empty lock.
|
|
296
|
-
await rename(stage, lock);
|
|
297
|
-
return () => removeObserved(ownerFile);
|
|
298
|
-
} catch (error) {
|
|
299
|
-
if (!["EEXIST", "ENOTEMPTY", "ENOENT"].includes(error.code)) throw error;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
throw new Error("Another supervisor is starting. Wait before retrying.");
|
|
303
|
-
} finally {
|
|
304
|
-
await rm(stage, { recursive: true, force: true });
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
195
|
export async function automaticRuntimeMain(env = process.env, options = {}) {
|
|
308
196
|
const directory = join(runtimeRoot(env), "runtime");
|
|
309
197
|
const releaseLock = await acquireSupervisorLock(directory, options);
|
|
@@ -336,6 +224,13 @@ export async function automaticRuntimeMain(env = process.env, options = {}) {
|
|
|
336
224
|
updatedAt: new Date().toISOString(),
|
|
337
225
|
});
|
|
338
226
|
await superviseOnce(environment, options);
|
|
227
|
+
await (options.checkUpdate ?? checkRuntimeUpdate)(environment).catch(() => {});
|
|
228
|
+
const installed = await privateRead(join(directory, "current.json")).catch(() => ({}));
|
|
229
|
+
if (installed.version && installed.version !== (options.packageVersion ?? PACKAGE_VERSION)) {
|
|
230
|
+
// The launcher reloads the supervisor; existing workers finish their active work first.
|
|
231
|
+
process.exitCode = 75;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
339
234
|
await sleep(options.intervalMs ?? DEFAULT_LISTENER_POLL_MS, undefined, {
|
|
340
235
|
signal: options.signal
|
|
341
236
|
? AbortSignal.any([controller.signal, options.signal])
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { pluginConnectionOptions } from "./plugin-connect.mjs";
|
|
5
|
+
import { ensureInstalledRuntime, privateRead, runtimeRoot } from "./runtime-installation.mjs";
|
|
6
|
+
import {
|
|
7
|
+
checkRuntimeUpdate,
|
|
8
|
+
compareRuntimeVersions,
|
|
9
|
+
readRuntimeUpdateStatus,
|
|
10
|
+
setRuntimeAutoUpdates,
|
|
11
|
+
} from "./runtime-update.mjs";
|
|
12
|
+
|
|
13
|
+
function selectedEnvironment(args, env) {
|
|
14
|
+
if (args[0] !== "connect") return env;
|
|
15
|
+
const selection = pluginConnectionOptions(args.slice(1), env);
|
|
16
|
+
return selection.help ? env : { ...env, TOTEM_CONFIG_DIR: selection.configDirectory };
|
|
17
|
+
}
|
|
18
|
+
export async function managedMcpMain(args, env = process.env) {
|
|
19
|
+
const managed = selectedEnvironment(args, env);
|
|
20
|
+
// Help is independent of installation and never starts a receive process.
|
|
21
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
22
|
+
const { pluginConnectMain } = await import("./plugin-connect.mjs");
|
|
23
|
+
return pluginConnectMain(args.slice(1), { env });
|
|
24
|
+
}
|
|
25
|
+
await ensureInstalledRuntime(managed);
|
|
26
|
+
const current = await privateRead(join(runtimeRoot(managed), "runtime", "current.json"));
|
|
27
|
+
compareRuntimeVersions(current.version, current.version);
|
|
28
|
+
const packageDirectory = join(
|
|
29
|
+
runtimeRoot(managed),
|
|
30
|
+
"runtime-packages",
|
|
31
|
+
current.version,
|
|
32
|
+
"node_modules",
|
|
33
|
+
"totemlayer",
|
|
34
|
+
);
|
|
35
|
+
const metadata = JSON.parse(await readFile(join(packageDirectory, "package.json"), "utf8"));
|
|
36
|
+
if (metadata.name !== "totemlayer" || metadata.version !== current.version)
|
|
37
|
+
throw new Error("Invalid managed MCP installation.");
|
|
38
|
+
const target = args[0] === "connect" ? "plugin-connect.mjs" : "local-mcp.mjs";
|
|
39
|
+
const implementation = await import(pathToFileURL(join(packageDirectory, "bin", target)));
|
|
40
|
+
if (args[0] === "connect") await implementation.pluginConnectMain(args.slice(1), { env });
|
|
41
|
+
else await implementation.mcpMain(env);
|
|
42
|
+
// Long-lived sessions stage code without replacing handlers during an active tool call.
|
|
43
|
+
// The next MCP connection loads that code; receiver services reload themselves when idle.
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const check = () => checkRuntimeUpdate(managed, { signal: controller.signal }).catch(() => {});
|
|
46
|
+
void check();
|
|
47
|
+
const timer = setInterval(() => {
|
|
48
|
+
void check();
|
|
49
|
+
}, 60000);
|
|
50
|
+
timer.unref();
|
|
51
|
+
process.stdin.once("end", () => {
|
|
52
|
+
clearInterval(timer);
|
|
53
|
+
controller.abort();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function runtimeUpdateMain(args, env = process.env) {
|
|
57
|
+
const command = args[0] ?? "check";
|
|
58
|
+
if (!["check", "status", "pause", "resume"].includes(command))
|
|
59
|
+
throw new Error("Use totem update check, status, pause, or resume.");
|
|
60
|
+
const rest = args.slice(1).filter((arg) => arg !== "--json");
|
|
61
|
+
const managed = rest.length ? selectedEnvironment(["connect", ...rest], env) : env;
|
|
62
|
+
await ensureInstalledRuntime(managed);
|
|
63
|
+
if (command === "pause" || command === "resume")
|
|
64
|
+
await setRuntimeAutoUpdates(command === "resume", managed);
|
|
65
|
+
if (command === "check" || command === "resume")
|
|
66
|
+
await checkRuntimeUpdate(managed, { force: true });
|
|
67
|
+
process.stdout.write(JSON.stringify(await readRuntimeUpdateStatus(managed)) + "\n");
|
|
68
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"fa6dad0474b12a9e8d4ba11ec1032166939ea330817a13de6cbdece265edbadf","architectures":["arm64","x86_64"],"minimumMacOS":"13.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
|
+
}
|
|
@@ -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,
|