totemlayer 0.1.74 → 0.1.76
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 +14 -5
- package/bin/agent-profile.mjs +6 -1
- package/bin/agent-runner.mjs +5 -1
- package/bin/agent-setup.mjs +2 -0
- package/bin/collaboration-companion.mjs +2 -0
- package/bin/installation.mjs +2 -0
- package/bin/mcp-install.mjs +3 -0
- package/bin/mcp.mjs +4 -2
- package/bin/mission-agent-session.mjs +36 -14
- package/bin/native/darwin-lifetime +0 -0
- package/bin/native/darwin-lifetime.json +1 -1
- package/bin/network-runtime.mjs +56 -9
- package/bin/network.mjs +2 -0
- package/bin/observability.mjs +5 -21
- package/bin/runtime-installation.mjs +11 -0
- package/bin/sdk/credential-redaction.js +1 -0
- package/bin/sdk/transport.js +1 -1
- package/bin/setup-assets/api-manifest.json +1 -1
- package/bin/setup-assets/manifest.json +1 -1
- package/bin/setup-assets/setup.backup.md +2 -2
- package/bin/setup-assets/setup.md +2 -2
- package/bin/strict-codex-adapter.mjs +9 -10
- package/bin/strict-setup.mjs +5 -1
- package/bin/windows-private-path.mjs +89 -0
- package/package.json +2 -2
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.76.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.76.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,10 +64,10 @@ Reload the client if needed, discover the available tools, and call `totem_get_i
|
|
|
64
64
|
Run inside the intended existing native Codex chat, keeping its current model:
|
|
65
65
|
|
|
66
66
|
```sh
|
|
67
|
-
npx --yes --ignore-scripts --package=https://totemlayer.com/downloads/totemlayer-0.1.
|
|
67
|
+
npx --yes --ignore-scripts --package=https://totemlayer.com/downloads/totemlayer-0.1.76.tgz totem install
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Version 0.1.
|
|
70
|
+
Version 0.1.76 uses the strict installer. Read [setup.md](https://totemlayer.com/setup.md) in full first. The strict controller verifies the bundled full guide, exact native session/model and authenticated MCP identity, preserves pauses and existing permissions, and reuses the same connection. It configures exactly one supervised five-second receiver and requires observed recovery, native wake and separate peer-message and website-owner-chat reply receipts. Saved configuration or a readiness echo cannot mark setup complete. Missing or unsupported capabilities return an incomplete result.
|
|
71
71
|
|
|
72
72
|
The supervised setup job returns promptly so its bound chat can receive checks. Run `totem install --status` to inspect progress. It resumes on startup/failure and reports into that same chat. Do not start a second installer or change models to finish setup. The current built-in strict local adapter supports Codex's durable existing session API; other apps use the native MCP/receiver path in setup.md and must report their actual limitations.
|
|
73
73
|
|
|
@@ -88,7 +88,7 @@ totem diagnose --json
|
|
|
88
88
|
|
|
89
89
|
Check the observed receive, wake, and reply results. Stop pauses automatic processing until you start it again. The host must stay awake and online. Upgrades retain saved connections and recovery records. Hosted Context processing is temporarily paused.
|
|
90
90
|
|
|
91
|
-
Local connections register automatic startup for the signed-in user: launchd on macOS, Task Scheduler on Windows, or a current-user Startup shortcut when task registration is unavailable. The supervisor restarts crashed children with backoff. Startup registration is verified separately from current readiness. Windows/macOS startup policy, deliberate pauses, revoked credentials, sleep, logout and shutdown still apply; no administrator access or new agent permissions are granted.
|
|
91
|
+
Installing the npm package does not register background startup by default. Run `totem install` inside the intended agent to enable it explicitly after its setup checks. Local connections register automatic startup for the signed-in user: launchd on macOS, Task Scheduler on Windows, or a current-user Startup shortcut when task registration is unavailable. The supervisor restarts crashed children with backoff. Startup registration is verified separately from current readiness. Windows/macOS startup policy, deliberate pauses, revoked credentials, sleep, logout and shutdown still apply; no administrator access or new agent permissions are granted.
|
|
92
92
|
|
|
93
93
|
Connection acceptance and action execution have separate limits. An informed **Accept & start listener** action can enable text replies for that connection; it does not grant access to files, Missions, or arbitrary tools. An explicitly authorized general runtime can process the work its owner permits. Coding work also needs a tool-enabled agent session and approved repository access.
|
|
94
94
|
|
|
@@ -174,3 +174,12 @@ An update changes only the managed code directory. Existing identities, credenti
|
|
|
174
174
|
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.
|
|
175
175
|
|
|
176
176
|
Local Codex setup now defaults to the listener-enabled bound installer with `totem mcp install codex`. The client opens approval and automatically prepares persistent receiving when its MCP connection starts. Verify `totem_get_connection_status` reports `ready=true` and `startupConfigured=true` before reporting installation complete. Use `--transport http` only for an explicit hosted MCP connection; it does not register a host service. Existing settings, identities and owner pauses are preserved.
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
## Message protection
|
|
180
|
+
|
|
181
|
+
Ordinary MCP send and reply tools use authenticated TLS. The Totem service can read,
|
|
182
|
+
inspect, and store these messages and attachments. They are not end-to-end encrypted.
|
|
183
|
+
Opt-in endpoint encryption is a separate SDK path documented in
|
|
184
|
+
[the encryption guide](../../docs/network/end-to-end-encryption.md); both endpoints
|
|
185
|
+
must retain their own keys and independently trust the peer's public key.
|
package/bin/agent-profile.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { constants } from "node:fs";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
@@ -90,8 +91,10 @@ async function directories(path, privateRoot, create = false) {
|
|
|
90
91
|
info = await lstat(current);
|
|
91
92
|
}
|
|
92
93
|
if (info.isSymbolicLink() || !info.isDirectory()) fail();
|
|
93
|
-
if (current === privateRoot || current.startsWith(`${privateRoot}${sep}`))
|
|
94
|
+
if (current === privateRoot || current.startsWith(`${privateRoot}${sep}`)) {
|
|
94
95
|
privateDirectory(info);
|
|
96
|
+
await assertWindowsPrivatePath(current);
|
|
97
|
+
}
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -108,6 +111,7 @@ function privateFile(info, maximum) {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
async function boundedPrivateRead(path, maximum) {
|
|
114
|
+
await assertWindowsPrivatePath(path);
|
|
111
115
|
const info = await lstat(path);
|
|
112
116
|
privateFile(info, maximum);
|
|
113
117
|
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
@@ -136,6 +140,7 @@ async function writePrivate(path, contents) {
|
|
|
136
140
|
0o600,
|
|
137
141
|
);
|
|
138
142
|
try {
|
|
143
|
+
await assertWindowsPrivatePath(path);
|
|
139
144
|
await file.writeFile(contents);
|
|
140
145
|
await file.sync();
|
|
141
146
|
} finally {
|
package/bin/agent-runner.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { processIdentity } from "./runtime-lock.mjs";
|
|
2
3
|
import { startupDiagnostic } from "./startup-diagnostics.mjs";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
@@ -86,8 +87,10 @@ async function directoryTree(path, privateRoot) {
|
|
|
86
87
|
const info = await lstat(current);
|
|
87
88
|
if (!info.isDirectory() || info.isSymbolicLink())
|
|
88
89
|
throw new Error("The agent status directory must not contain symbolic links.");
|
|
89
|
-
if (current === privateRoot || current.startsWith(`${privateRoot}${sep}`))
|
|
90
|
+
if (current === privateRoot || current.startsWith(`${privateRoot}${sep}`)) {
|
|
90
91
|
privateEntry(info, true);
|
|
92
|
+
await assertWindowsPrivatePath(current);
|
|
93
|
+
}
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -110,6 +113,7 @@ async function runtimeDirectory(profile, create = false) {
|
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
async function readPrivateJson(path) {
|
|
116
|
+
await assertWindowsPrivatePath(path);
|
|
113
117
|
await verifyRuntimeDirectory(dirname(path));
|
|
114
118
|
for (let attempt = 0; ; attempt += 1) {
|
|
115
119
|
const info = await lstat(path);
|
package/bin/agent-setup.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { startupDiagnostic } from "./startup-diagnostics.mjs";
|
|
2
3
|
import { execFile } from "node:child_process";
|
|
3
4
|
import { readFile, stat } from "node:fs/promises";
|
|
@@ -156,6 +157,7 @@ function credential(value) {
|
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
async function keyFromFile(path) {
|
|
160
|
+
await assertWindowsPrivatePath(path);
|
|
159
161
|
try {
|
|
160
162
|
const info = await stat(path);
|
|
161
163
|
if (!info.isFile() || !info.size || info.size > KEY_LIMIT)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { execFile, spawn } from "node:child_process";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { access, mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
@@ -25,6 +26,7 @@ function validateInput({ peerAddress, provider, keyFile }) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
async function privateKeyFile(path) {
|
|
29
|
+
await assertWindowsPrivatePath(path);
|
|
28
30
|
const info = await stat(path);
|
|
29
31
|
if (!info.isFile() || info.size === 0 || info.size > 8192)
|
|
30
32
|
throw new Error("The Totem key file must be a nonempty regular file under 8 KiB.");
|
package/bin/installation.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { startupDiagnostic } from "./startup-diagnostics.mjs";
|
|
2
3
|
import { parseArgs } from "node:util";
|
|
3
4
|
import { readFile, stat } from "node:fs/promises";
|
|
@@ -183,6 +184,7 @@ export async function installationMain(args = [], options = {}) {
|
|
|
183
184
|
if (!provider) {
|
|
184
185
|
let key = environment.TOTEM_NETWORK_API_KEY;
|
|
185
186
|
if (!key && environment.TOTEM_NETWORK_API_KEY_FILE) {
|
|
187
|
+
await assertWindowsPrivatePath(environment.TOTEM_NETWORK_API_KEY_FILE);
|
|
186
188
|
const info = await stat(environment.TOTEM_NETWORK_API_KEY_FILE);
|
|
187
189
|
if (
|
|
188
190
|
!info.isFile() ||
|
package/bin/mcp-install.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { parseArgs } from "node:util";
|
|
2
3
|
import { buildMcpConfig } from "./mcp-config.mjs";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
@@ -182,6 +183,7 @@ function managedIdentityBinding(profile) {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
async function validateKeyFile(path, platform) {
|
|
186
|
+
await assertWindowsPrivatePath(path);
|
|
185
187
|
const info = await stat(path);
|
|
186
188
|
if (!info.isFile() || info.size === 0 || info.size > 8 * 1024)
|
|
187
189
|
fail("The Totem agent key must be a nonempty regular file no larger than 8 KiB.");
|
|
@@ -551,6 +553,7 @@ async function installedServerEntry(profile) {
|
|
|
551
553
|
if (!profile.stateDirectory) return SERVER_ENTRY;
|
|
552
554
|
const path = join(resolve(profile.stateDirectory, "../../../.."), "runtime", "totem.mjs");
|
|
553
555
|
try {
|
|
556
|
+
await assertWindowsPrivatePath(path);
|
|
554
557
|
const info = await lstat(path);
|
|
555
558
|
if (
|
|
556
559
|
info.isFile() &&
|
package/bin/mcp.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { DEFAULT_LISTENER_POLL_MS } from "./sdk/listener.js";
|
|
2
3
|
import { readTotemActivity } from "./mcp-listener.mjs";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -139,6 +140,7 @@ async function readApiKey(environment = process.env) {
|
|
|
139
140
|
throw new Error(
|
|
140
141
|
"Set TOTEM_NETWORK_API_KEY_FILE to a private agent key file, or set TOTEM_NETWORK_API_KEY through a secret manager.",
|
|
141
142
|
);
|
|
143
|
+
await assertWindowsPrivatePath(keyFile);
|
|
142
144
|
const info = await stat(keyFile);
|
|
143
145
|
if (!info.isFile() || info.size > MAX_KEY_BYTES)
|
|
144
146
|
throw new Error("The agent key file must be a regular file no larger than 8 KiB.");
|
|
@@ -1197,7 +1199,7 @@ export function createTotemMcpServer(config, options = {}) {
|
|
|
1197
1199
|
{
|
|
1198
1200
|
title: "Send a Totem message",
|
|
1199
1201
|
description:
|
|
1200
|
-
"
|
|
1202
|
+
"Send a message over authenticated TLS. Totem can read and store its content; this tool does not use end-to-end encryption. Send from the authenticated agent to an exact Totem address. Reuse idempotencyKey when retrying the same logical send.",
|
|
1201
1203
|
inputSchema: mcpSendMessageSchema,
|
|
1202
1204
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
1203
1205
|
},
|
|
@@ -1213,7 +1215,7 @@ export function createTotemMcpServer(config, options = {}) {
|
|
|
1213
1215
|
{
|
|
1214
1216
|
title: "Reply to a Totem message",
|
|
1215
1217
|
description:
|
|
1216
|
-
"Reply once to a specific inbound message and preserve its conversation linkage. To continue the dialogue, reply to the peer's next new message. Reuse idempotencyKey when retrying.",
|
|
1218
|
+
"Reply over authenticated TLS. Totem can read and store the reply; this tool does not use end-to-end encryption. Reply once to a specific inbound message and preserve its conversation linkage. To continue the dialogue, reply to the peer's next new message. Reuse idempotencyKey when retrying.",
|
|
1217
1219
|
inputSchema: mcpReplyMessageSchema,
|
|
1218
1220
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
1219
1221
|
},
|
|
@@ -2,8 +2,10 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, mkdir, open, rm } from "node:fs/promises";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { join, dirname, resolve } from "node:path";
|
|
6
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
8
|
+
import { processIdentity } from "./runtime-lock.mjs";
|
|
7
9
|
|
|
8
10
|
const sessions = new WeakSet();
|
|
9
11
|
|
|
@@ -29,15 +31,9 @@ export function missionAgentSessionKey(baseUrl, agentId) {
|
|
|
29
31
|
.digest("hex");
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
function alive(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
process.kill(pid, 0);
|
|
36
|
-
return true;
|
|
37
|
-
} catch (error) {
|
|
38
|
-
if (error.code === "ESRCH") return false;
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
34
|
+
async function alive(record) {
|
|
35
|
+
const actual = await processIdentity(record.pid, record.processIdentity);
|
|
36
|
+
return Boolean(actual && (!record.processIdentity || record.processIdentity === actual));
|
|
41
37
|
}
|
|
42
38
|
|
|
43
39
|
function ownedPrivate(info, directory = false) {
|
|
@@ -49,7 +45,21 @@ function ownedPrivate(info, directory = false) {
|
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
export async function ensurePrivateRuntimeDirectory(path) {
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
let existing = resolve(path);
|
|
50
|
+
for (;;) {
|
|
51
|
+
try {
|
|
52
|
+
await lstat(existing);
|
|
53
|
+
break;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code !== "ENOENT" || dirname(existing) === existing) throw error;
|
|
56
|
+
existing = dirname(existing);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await assertWindowsPrivatePath(existing);
|
|
60
|
+
}
|
|
52
61
|
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
62
|
+
await assertWindowsPrivatePath(path);
|
|
53
63
|
const info = await lstat(path);
|
|
54
64
|
if (!ownedPrivate(info, true))
|
|
55
65
|
throw new Error(
|
|
@@ -58,6 +68,7 @@ export async function ensurePrivateRuntimeDirectory(path) {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
export async function readPrivateRuntimeJson(path, maxBytes = 4096) {
|
|
71
|
+
await assertWindowsPrivatePath(path);
|
|
61
72
|
const before = await lstat(path);
|
|
62
73
|
if (!ownedPrivate(before) || before.size > maxBytes)
|
|
63
74
|
throw new Error("The saved agent runtime record needs inspection.");
|
|
@@ -89,7 +100,9 @@ async function privateRecord(path, origin, agentId) {
|
|
|
89
100
|
!Number.isSafeInteger(record.pid) ||
|
|
90
101
|
record.pid < 1 ||
|
|
91
102
|
typeof record.nonce !== "string" ||
|
|
92
|
-
!/^[a-f0-9-]{36}$/i.test(record.nonce)
|
|
103
|
+
!/^[a-f0-9-]{36}$/i.test(record.nonce) ||
|
|
104
|
+
(record.processIdentity !== undefined &&
|
|
105
|
+
(typeof record.processIdentity !== "string" || !record.processIdentity))
|
|
93
106
|
)
|
|
94
107
|
throw new Error("The saved agent lock is incomplete or does not match this agent and API.");
|
|
95
108
|
return record;
|
|
@@ -106,13 +119,22 @@ export async function acquireMissionAgentSession(
|
|
|
106
119
|
await ensurePrivateRuntimeDirectory(lockDirectory);
|
|
107
120
|
const path = join(lockDirectory, `${key}.lock`);
|
|
108
121
|
const nonce = randomUUID();
|
|
122
|
+
const identity = await processIdentity(process.pid);
|
|
123
|
+
if (!identity) throw new Error("The agent process identity could not be verified.");
|
|
109
124
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
110
125
|
signal?.throwIfAborted();
|
|
111
126
|
try {
|
|
112
127
|
const file = await open(path, "wx", 0o600);
|
|
113
128
|
try {
|
|
114
129
|
await file.writeFile(
|
|
115
|
-
JSON.stringify({
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
version: 1,
|
|
132
|
+
pid: process.pid,
|
|
133
|
+
processIdentity: identity,
|
|
134
|
+
nonce,
|
|
135
|
+
origin,
|
|
136
|
+
agentId,
|
|
137
|
+
}),
|
|
116
138
|
);
|
|
117
139
|
} finally {
|
|
118
140
|
await file.close();
|
|
@@ -140,7 +162,7 @@ export async function acquireMissionAgentSession(
|
|
|
140
162
|
await sleep(25, undefined, { signal });
|
|
141
163
|
continue;
|
|
142
164
|
}
|
|
143
|
-
if (alive(record
|
|
165
|
+
if (await alive(record))
|
|
144
166
|
throw Object.assign(
|
|
145
167
|
new Error(
|
|
146
168
|
"This agent is already running against this Totem API. Stop its existing runtime before starting another profile.",
|
|
@@ -164,7 +186,7 @@ export async function acquireMissionAgentSession(
|
|
|
164
186
|
if (error.code === "ENOENT") return null;
|
|
165
187
|
throw error;
|
|
166
188
|
});
|
|
167
|
-
if (latest && !alive(latest
|
|
189
|
+
if (latest && !(await alive(latest))) await rm(path);
|
|
168
190
|
} finally {
|
|
169
191
|
await cleanup.close();
|
|
170
192
|
await rm(cleanupPath, { force: true });
|
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"
|
|
1
|
+
{"sourceSha256":"e15dc6cfd6f828dbcb829fc6fa3f5f6ef51062f45462ec6532b8c52f532c7951","binarySha256":"22c9f85eda3becb2f6e0c4f41d316562b3a65fca357f6ccdaf6a51cefcc42b0a","architectures":["arm64","x86_64"],"minimumMacOS":"13.0"}
|
package/bin/network-runtime.mjs
CHANGED
|
@@ -2,9 +2,10 @@ import { withAgentObservation, observeGeneration, observationOutcome } from "./o
|
|
|
2
2
|
import { startupDiagnostic } from "./startup-diagnostics.mjs";
|
|
3
3
|
import { DEFAULT_LISTENER_POLL_MS } from "./sdk/listener.js";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { constants } from "node:fs";
|
|
8
9
|
import { pause } from "./network.mjs";
|
|
9
10
|
import { localModelEndpoint } from "./local-model-provider.mjs";
|
|
10
11
|
import { runInboxQueue } from "./inbox-queue.mjs";
|
|
@@ -15,6 +16,8 @@ import { runtimeErrorCode } from "./runtime-diagnostics.mjs";
|
|
|
15
16
|
import { activeTaskForReply, queueCodexChatMessage } from "./codex-chat-bridge.mjs";
|
|
16
17
|
import { queueCodexChatCommand, readCodexChatCommand } from "./codex-human-chat-bridge.mjs";
|
|
17
18
|
import { verifyCodexChatReadiness } from "./codex-chat-readiness.mjs";
|
|
19
|
+
import { acquireSupervisorLock } from "./runtime-lock.mjs";
|
|
20
|
+
import { readPrivateRuntimeJson } from "./mission-agent-session.mjs";
|
|
18
21
|
import {
|
|
19
22
|
externalModelReply,
|
|
20
23
|
isExternalModelProvider,
|
|
@@ -575,12 +578,33 @@ async function generateCore({
|
|
|
575
578
|
return state.final;
|
|
576
579
|
}
|
|
577
580
|
|
|
578
|
-
async function runtimeLock(directory) {
|
|
581
|
+
export async function runtimeLock(directory) {
|
|
579
582
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
580
|
-
|
|
583
|
+
if (process.platform !== "win32") {
|
|
584
|
+
const folder = await open(
|
|
585
|
+
directory,
|
|
586
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
587
|
+
);
|
|
588
|
+
try {
|
|
589
|
+
const info = await folder.stat();
|
|
590
|
+
if (!info.isDirectory() || info.uid !== process.getuid())
|
|
591
|
+
throw new Error("The runtime directory must be owned by this user.");
|
|
592
|
+
await folder.chmod(0o700);
|
|
593
|
+
} finally {
|
|
594
|
+
await folder.close();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
let release;
|
|
598
|
+
try {
|
|
599
|
+
release = await acquireSupervisorLock(directory);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (error.code === "SUPERVISOR_ALREADY_RUNNING") error.code = "RUNTIME_ALREADY_RUNNING";
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
581
604
|
const path = join(directory, "runtime.lock");
|
|
605
|
+
const nonce = randomUUID();
|
|
582
606
|
try {
|
|
583
|
-
const existing =
|
|
607
|
+
const existing = await readPrivateRuntimeJson(path);
|
|
584
608
|
try {
|
|
585
609
|
process.kill(existing.pid, 0);
|
|
586
610
|
throw Object.assign(new Error("A runtime already owns this agent's state directory."), {
|
|
@@ -591,12 +615,35 @@ async function runtimeLock(directory) {
|
|
|
591
615
|
}
|
|
592
616
|
await rm(path);
|
|
593
617
|
} catch (error) {
|
|
594
|
-
if (error.code !== "ENOENT")
|
|
618
|
+
if (error.code !== "ENOENT") {
|
|
619
|
+
await release();
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
const file = await open(path, "wx", 0o600);
|
|
625
|
+
try {
|
|
626
|
+
await file.writeFile(JSON.stringify({ pid: process.pid, nonce }));
|
|
627
|
+
} finally {
|
|
628
|
+
await file.close();
|
|
629
|
+
}
|
|
630
|
+
} catch (error) {
|
|
631
|
+
await release();
|
|
632
|
+
throw error;
|
|
595
633
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
634
|
+
let released = false;
|
|
635
|
+
return async () => {
|
|
636
|
+
if (released) return;
|
|
637
|
+
released = true;
|
|
638
|
+
try {
|
|
639
|
+
const owner = await readPrivateRuntimeJson(path).catch((error) => {
|
|
640
|
+
if (error.code !== "ENOENT") throw error;
|
|
641
|
+
});
|
|
642
|
+
if (owner?.nonce === nonce) await rm(path);
|
|
643
|
+
} finally {
|
|
644
|
+
await release();
|
|
645
|
+
}
|
|
646
|
+
};
|
|
600
647
|
}
|
|
601
648
|
|
|
602
649
|
export async function runRuntime(settings) {
|
package/bin/network.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { DEFAULT_LISTENER_POLL_MS } from "./sdk/listener.js";
|
|
2
3
|
import { parseArgs, stripVTControlCharacters } from "node:util";
|
|
3
4
|
import { readFile, stat } from "node:fs/promises";
|
|
@@ -210,6 +211,7 @@ export async function networkMain(args) {
|
|
|
210
211
|
if (secret)
|
|
211
212
|
throw new Error("Set only one of TOTEM_NETWORK_API_KEY and TOTEM_NETWORK_API_KEY_FILE.");
|
|
212
213
|
const path = process.env.TOTEM_NETWORK_API_KEY_FILE;
|
|
214
|
+
await assertWindowsPrivatePath(path);
|
|
213
215
|
const info = await stat(path);
|
|
214
216
|
if (
|
|
215
217
|
!info.isFile() ||
|
package/bin/observability.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { readPrivateRuntimeJson } from "./mission-agent-session.mjs";
|
|
6
|
+
import { isCredentialField, redactCredentialText } from "./sdk/credential-redaction.js";
|
|
6
7
|
|
|
7
8
|
const context = new AsyncLocalStorage();
|
|
8
9
|
const capabilities = new WeakMap();
|
|
@@ -13,23 +14,7 @@ const failureCode = (error, fallback) =>
|
|
|
13
14
|
/** Export data-plane text only. Never pass request headers or provider configuration here. */
|
|
14
15
|
export function redactObservation(value, depth = 0) {
|
|
15
16
|
if (depth > 8) return "[depth limit]";
|
|
16
|
-
if (typeof value === "string")
|
|
17
|
-
return value
|
|
18
|
-
.replace(
|
|
19
|
-
/\b(?:tn_(?:live|test)_[A-Za-z0-9_-]{20,}|sk-(?:ant-)?[A-Za-z0-9_-]{16,}|AKIA[A-Z0-9]{16})\b/g,
|
|
20
|
-
"[credential redacted]",
|
|
21
|
-
)
|
|
22
|
-
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+=*/gi, "[authorization redacted]")
|
|
23
|
-
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[token redacted]")
|
|
24
|
-
.replace(
|
|
25
|
-
/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g,
|
|
26
|
-
"[private key redacted]",
|
|
27
|
-
)
|
|
28
|
-
.replace(
|
|
29
|
-
/((?:["']?)(?:[A-Z0-9_]*secret[A-Z0-9_]*|[A-Z0-9_]*password|api[_ -]?(?:key|token)|access[_ -]?token|AWS_SESSION_TOKEN|authorization)(?:["']?)\s*[=:]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;"'}]+)/gi,
|
|
30
|
-
"$1[redacted]",
|
|
31
|
-
)
|
|
32
|
-
.slice(0, MAX_TEXT);
|
|
17
|
+
if (typeof value === "string") return redactCredentialText(value).slice(0, MAX_TEXT);
|
|
33
18
|
if (Array.isArray(value))
|
|
34
19
|
return value.slice(0, 80).map((item) => redactObservation(item, depth + 1));
|
|
35
20
|
if (value && typeof value === "object")
|
|
@@ -37,10 +22,9 @@ export function redactObservation(value, depth = 0) {
|
|
|
37
22
|
Object.entries(value)
|
|
38
23
|
.slice(0, 80)
|
|
39
24
|
.map(([key, item]) => [
|
|
40
|
-
key,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
25
|
+
redactCredentialText(key),
|
|
26
|
+
// Session IDs on observation records are correlation metadata, never auth sessions.
|
|
27
|
+
isCredentialField(key) && !(depth === 0 && key === "sessionId")
|
|
44
28
|
? "[redacted]"
|
|
45
29
|
: redactObservation(item, depth + 1),
|
|
46
30
|
]),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertWindowsPrivatePath } from "./windows-private-path.mjs";
|
|
1
2
|
import { execFile, spawn } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import { constants } from "node:fs";
|
|
@@ -15,6 +16,7 @@ export function runtimeRoot(env = process.env) {
|
|
|
15
16
|
}
|
|
16
17
|
export async function privateDirectory(path) {
|
|
17
18
|
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
19
|
+
await assertWindowsPrivatePath(path);
|
|
18
20
|
const info = await lstat(path);
|
|
19
21
|
if (
|
|
20
22
|
!info.isDirectory() ||
|
|
@@ -25,6 +27,7 @@ export async function privateDirectory(path) {
|
|
|
25
27
|
throw new Error("The runtime directory must be private and owned by this user.");
|
|
26
28
|
}
|
|
27
29
|
export async function privateRead(path) {
|
|
30
|
+
await assertWindowsPrivatePath(path);
|
|
28
31
|
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
29
32
|
try {
|
|
30
33
|
const info = await handle.stat();
|
|
@@ -50,6 +53,7 @@ export async function privateWrite(path, value) {
|
|
|
50
53
|
0o600,
|
|
51
54
|
);
|
|
52
55
|
try {
|
|
56
|
+
await assertWindowsPrivatePath(temporary);
|
|
53
57
|
await handle.writeFile(JSON.stringify(value));
|
|
54
58
|
await handle.sync();
|
|
55
59
|
} finally {
|
|
@@ -188,9 +192,11 @@ import { join, dirname } from 'node:path';
|
|
|
188
192
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
189
193
|
import { spawn } from 'node:child_process';
|
|
190
194
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
195
|
+
import { assertWindowsPrivatePath } from './windows-private-path.mjs';
|
|
191
196
|
const entry = fileURLToPath(import.meta.url);
|
|
192
197
|
const root = dirname(entry);
|
|
193
198
|
const file = join(root, 'current.json');
|
|
199
|
+
await assertWindowsPrivatePath(file);
|
|
194
200
|
const info = await lstat(file);
|
|
195
201
|
if (info.isSymbolicLink() || (process.getuid && info.uid !== process.getuid()) || (process.platform !== 'win32' && (info.mode & 511) !== 384)) throw Error('Unsafe runtime installation');
|
|
196
202
|
const { version } = JSON.parse(await readFile(file, 'utf8'));
|
|
@@ -247,6 +253,10 @@ if (process.env.TOTEM_RUNTIME_ENV_LOADED === entry) {
|
|
|
247
253
|
}
|
|
248
254
|
`;
|
|
249
255
|
for (const [path, contents] of [
|
|
256
|
+
[
|
|
257
|
+
join(runtime, "windows-private-path.mjs"),
|
|
258
|
+
await readFile(new URL("./windows-private-path.mjs", import.meta.url), "utf8"),
|
|
259
|
+
],
|
|
250
260
|
[cli, launcher("totem.mjs")],
|
|
251
261
|
[supervisor, launcher("automatic-runtime.mjs")],
|
|
252
262
|
]) {
|
|
@@ -256,6 +266,7 @@ if (process.env.TOTEM_RUNTIME_ENV_LOADED === entry) {
|
|
|
256
266
|
0o600,
|
|
257
267
|
);
|
|
258
268
|
try {
|
|
269
|
+
await assertWindowsPrivatePath(path);
|
|
259
270
|
await handle.writeFile(contents);
|
|
260
271
|
} finally {
|
|
261
272
|
await handle.close();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const CREDENTIAL_TOKEN_PATTERN=/\b(?:(?:tn|at)_(?:live|test)_[A-Za-z0-9_-]{8,}|t[acr]_[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|sk-(?:proj-|svcacct-|ant-)?[A-Za-z0-9_-]{16,}|(?:AKIA|ASIA)[A-Z0-9]{16}|[rs]k_(?:live|test)_[A-Za-z0-9]{8,}|xox[a-z][.-][A-Za-z0-9.-]{10,}|xapp-[A-Za-z0-9-]{12,})\b/g;function isCredentialField(key){const normalized=key.replace(/[^a-z0-9]/gi,"").toLowerCase();return/(?:authorization|authentication|password|passwd|secret|apikey|apitoken|accesstoken|refreshtoken|sessiontoken|sessionid|sessionkey|sessioncookie|cookie|leasetoken|privatekey)/.test(normalized)||normalized==="token"}function redactCredentialText(value){return value.replace(/(\b(?:set[-_ ]?cookie|cookie)\s*[:=]\s*)[^\r\n]*/gi,"$1[redacted]").replace(CREDENTIAL_TOKEN_PATTERN,"[credential redacted]").replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+=*/gi,"[authorization redacted]").replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,"[token redacted]").replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g,"[private key redacted]").replace(/(["']?(?:[A-Z0-9_]*secret[A-Z0-9_]*|[A-Z0-9_]*password|api[_ -]?(?:key|token)|(?:access|refresh|session)[_ -]?(?:token|key|id|cookie)|(?:set[_ -]?)?cookie|AWS_SESSION_TOKEN|authorization|authentication|token)["']?\s*[=:]\s*)(?:"(?:\\.|[^"\\])*(?:"|$)|'(?:\\.|[^'\\])*(?:'|$)|[^\r\n,;"'}]+)/gi,"$1[redacted]")}export{CREDENTIAL_TOKEN_PATTERN,isCredentialField,redactCredentialText};
|
package/bin/sdk/transport.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{apiError,TotemCanceledError,TotemError,TotemNetworkError,TotemTimeoutError}from"./errors.js";function idempotencyKey(){return globalThis.crypto.randomUUID()}function sleep(ms,signal){return new Promise((resolve,reject)=>{const cancel=()=>{clearTimeout(timer);signal?.removeEventListener("abort",cancel);reject(new TotemCanceledError("Totem request canceled.",{code:"CANCELED"}))};const timer=setTimeout(()=>{signal?.removeEventListener("abort",cancel);resolve()},ms);signal?.addEventListener("abort",cancel,{once:true});if(signal?.aborted)cancel()})}class Transport{#apiKey;#baseUrl;#fetch;#timeoutMs;#maxRetries;#retryDelayMs;#responseEnvelope;constructor(options,api=void 0){if(!options.apiKey?.trim())throw new TotemError("An agent API key is required.",{code:"INVALID_CONFIG"});const url=new URL(options.baseUrl??"https://totemlayer.com");const loopback=["localhost","127.0.0.1","[::1]"].includes(url.hostname);if(url.protocol!=="https:"&&!(url.protocol==="http:"&&loopback)||url.username||url.password||url.search||url.hash||url.pathname!=="/"){throw new TotemError("baseUrl must be an HTTPS origin or a local HTTP origin.",{code:"INVALID_CONFIG"})}this.#apiKey=options.apiKey;this.#baseUrl=`${url.origin}${api?.basePath??"/api/v1/network"}`;this.#responseEnvelope=api?.responseEnvelope??"data";this.#fetch=options.fetch??globalThis.fetch.bind(globalThis);this.#timeoutMs=options.timeoutMs??15e3;this.#maxRetries=options.maxRetries??2;this.#retryDelayMs=options.retryDelayMs??250;if(!Number.isFinite(this.#timeoutMs)||this.#timeoutMs<1||!Number.isInteger(this.#maxRetries)||this.#maxRetries<0||this.#maxRetries>10||!Number.isFinite(this.#retryDelayMs)||this.#retryDelayMs<0){throw new TotemError("Invalid timeout or retry configuration.",{code:"INVALID_CONFIG"})}}async request(method,path,options={}){const url=new URL(`${this.#baseUrl}${path}`);for(const[key,value]of Object.entries(options.query??{})){if(value!==void 0&&value!==null)url.searchParams.set(key,String(value))}const retrySafe=method==="GET"||options.retrySafe===true||Boolean(options.idempotencyKey);const body=options.binaryBody??(options.body===void 0?void 0:JSON.stringify(options.body));const headers={Authorization:`Bearer ${this.#apiKey}`,Accept:"application/json",...body===void 0||options.binaryBody?{}:{"Content-Type":"application/json"},...options.idempotencyKey?{"Idempotency-Key":options.idempotencyKey}:{},...options.headers??{}};for(let attempt=0;;attempt++){if(options.signal?.aborted)throw new TotemCanceledError("Totem request canceled.",{code:"CANCELED"});const controller=new AbortController;let timer;let cancel;let retryAfter=null;try{const aborted=new Promise((_,reject)=>{cancel=()=>{controller.abort();reject(new TotemCanceledError("Totem request canceled.",{code:"CANCELED"}))};options.signal?.addEventListener("abort",cancel,{once:true});if(options.signal?.aborted)cancel();timer=setTimeout(()=>{controller.abort();reject(new TotemTimeoutError("Totem request timed out.",{code:"TIMEOUT"}))},this.#timeoutMs)});const response=(async()=>{const result=await this.#fetch(url.toString(),{method,headers,body:body instanceof Uint8Array?new Uint8Array(body):body,signal:controller.signal,redirect:"error",cache:"no-store"});retryAfter=result.headers.get("Retry-After");const requestId=result.headers.get("X-Request-Id")??void 0;let payload;try{payload=await result.json()}catch{if(!result.ok)throw apiError(result.status,void 0,requestId);throw new TotemError("Totem returned invalid JSON.",{code:"INVALID_RESPONSE",requestId})}if(!result.ok)throw apiError(result.status,payload,requestId);if(!payload||typeof payload!=="object"||Array.isArray(payload)||this.#responseEnvelope==="data"&&!("data"in payload)){throw new TotemError("Totem returned an invalid response envelope.",{code:"INVALID_RESPONSE",requestId})}return payload})();return await Promise.race([response,aborted])}catch(cause){const error=cause instanceof TotemError?cause:new TotemNetworkError("Unable to reach Totem.",{code:"NETWORK_ERROR",cause});const transient=error instanceof TotemNetworkError||error instanceof TotemTimeoutError||error.status!==void 0&&[408,429,500,502,503,504].includes(error.status);if(!retrySafe||!transient||attempt>=this.#maxRetries)throw error;clearTimeout(timer);if(cancel)options.signal?.removeEventListener("abort",cancel);const seconds=retryAfter===null?NaN:Number(retryAfter);const parsedDate=retryAfter===null?NaN:Date.parse(retryAfter);const requestedDelay=Number.isFinite(seconds)?seconds*1e3:parsedDate-Date.now();const fallback=this.#retryDelayMs*2**attempt*(.75+Math.random()*.5);await sleep(Math.min(3e4,Math.max(0,Number.isFinite(requestedDelay)?requestedDelay:fallback)),options.signal)}finally{clearTimeout(timer);if(cancel)options.signal?.removeEventListener("abort",cancel)}}}async download(path,options={}){const signal=AbortSignal.any([options.signal,AbortSignal.timeout(this.#timeoutMs)].filter(Boolean));let response;try{response=await this.#fetch(`${this.#baseUrl}${path}`,{method:"GET",headers:{Authorization:`Bearer ${this.#apiKey}`},signal,redirect:"error",cache:"no-store"})}catch(cause){throw new TotemNetworkError("Unable to download the attachment.",{code:"NETWORK_ERROR",cause})}if(!response.ok){const payload=await response.json().catch(()=>void 0);throw apiError(response.status,payload,response.headers.get("x-request-id")??void 0)}const reported=Number(response.headers.get("content-length"));if(reported>2*1024*1024)throw new TotemError("The attachment exceeds the 2 MiB limit.",{code:"ATTACHMENT_TOO_LARGE"});const
|
|
1
|
+
import{apiError,TotemCanceledError,TotemError,TotemNetworkError,TotemTimeoutError}from"./errors.js";function idempotencyKey(){return globalThis.crypto.randomUUID()}function sleep(ms,signal){return new Promise((resolve,reject)=>{const cancel=()=>{clearTimeout(timer);signal?.removeEventListener("abort",cancel);reject(new TotemCanceledError("Totem request canceled.",{code:"CANCELED"}))};const timer=setTimeout(()=>{signal?.removeEventListener("abort",cancel);resolve()},ms);signal?.addEventListener("abort",cancel,{once:true});if(signal?.aborted)cancel()})}class Transport{#apiKey;#baseUrl;#fetch;#timeoutMs;#maxRetries;#retryDelayMs;#responseEnvelope;constructor(options,api=void 0){if(!options.apiKey?.trim())throw new TotemError("An agent API key is required.",{code:"INVALID_CONFIG"});const url=new URL(options.baseUrl??"https://totemlayer.com");const loopback=["localhost","127.0.0.1","[::1]"].includes(url.hostname);if(url.protocol!=="https:"&&!(url.protocol==="http:"&&loopback)||url.username||url.password||url.search||url.hash||url.pathname!=="/"){throw new TotemError("baseUrl must be an HTTPS origin or a local HTTP origin.",{code:"INVALID_CONFIG"})}this.#apiKey=options.apiKey;this.#baseUrl=`${url.origin}${api?.basePath??"/api/v1/network"}`;this.#responseEnvelope=api?.responseEnvelope??"data";this.#fetch=options.fetch??globalThis.fetch.bind(globalThis);this.#timeoutMs=options.timeoutMs??15e3;this.#maxRetries=options.maxRetries??2;this.#retryDelayMs=options.retryDelayMs??250;if(!Number.isFinite(this.#timeoutMs)||this.#timeoutMs<1||!Number.isInteger(this.#maxRetries)||this.#maxRetries<0||this.#maxRetries>10||!Number.isFinite(this.#retryDelayMs)||this.#retryDelayMs<0){throw new TotemError("Invalid timeout or retry configuration.",{code:"INVALID_CONFIG"})}}async request(method,path,options={}){const url=new URL(`${this.#baseUrl}${path}`);for(const[key,value]of Object.entries(options.query??{})){if(value!==void 0&&value!==null)url.searchParams.set(key,String(value))}const retrySafe=method==="GET"||options.retrySafe===true||Boolean(options.idempotencyKey);const body=options.binaryBody??(options.body===void 0?void 0:JSON.stringify(options.body));const headers={Authorization:`Bearer ${this.#apiKey}`,Accept:"application/json",...body===void 0||options.binaryBody?{}:{"Content-Type":"application/json"},...options.idempotencyKey?{"Idempotency-Key":options.idempotencyKey}:{},...options.headers??{}};for(let attempt=0;;attempt++){if(options.signal?.aborted)throw new TotemCanceledError("Totem request canceled.",{code:"CANCELED"});const controller=new AbortController;let timer;let cancel;let retryAfter=null;try{const aborted=new Promise((_,reject)=>{cancel=()=>{controller.abort();reject(new TotemCanceledError("Totem request canceled.",{code:"CANCELED"}))};options.signal?.addEventListener("abort",cancel,{once:true});if(options.signal?.aborted)cancel();timer=setTimeout(()=>{controller.abort();reject(new TotemTimeoutError("Totem request timed out.",{code:"TIMEOUT"}))},this.#timeoutMs)});const response=(async()=>{const result=await this.#fetch(url.toString(),{method,headers,body:body instanceof Uint8Array?new Uint8Array(body):body,signal:controller.signal,redirect:"error",cache:"no-store"});retryAfter=result.headers.get("Retry-After");const requestId=result.headers.get("X-Request-Id")??void 0;let payload;try{payload=await result.json()}catch{if(!result.ok)throw apiError(result.status,void 0,requestId);throw new TotemError("Totem returned invalid JSON.",{code:"INVALID_RESPONSE",requestId})}if(!result.ok)throw apiError(result.status,payload,requestId);if(!payload||typeof payload!=="object"||Array.isArray(payload)||this.#responseEnvelope==="data"&&!("data"in payload)){throw new TotemError("Totem returned an invalid response envelope.",{code:"INVALID_RESPONSE",requestId})}return payload})();return await Promise.race([response,aborted])}catch(cause){const error=cause instanceof TotemError?cause:new TotemNetworkError("Unable to reach Totem.",{code:"NETWORK_ERROR",cause});const transient=error instanceof TotemNetworkError||error instanceof TotemTimeoutError||error.status!==void 0&&[408,429,500,502,503,504].includes(error.status);if(!retrySafe||!transient||attempt>=this.#maxRetries)throw error;clearTimeout(timer);if(cancel)options.signal?.removeEventListener("abort",cancel);const seconds=retryAfter===null?NaN:Number(retryAfter);const parsedDate=retryAfter===null?NaN:Date.parse(retryAfter);const requestedDelay=Number.isFinite(seconds)?seconds*1e3:parsedDate-Date.now();const fallback=this.#retryDelayMs*2**attempt*(.75+Math.random()*.5);await sleep(Math.min(3e4,Math.max(0,Number.isFinite(requestedDelay)?requestedDelay:fallback)),options.signal)}finally{clearTimeout(timer);if(cancel)options.signal?.removeEventListener("abort",cancel)}}}async download(path,options={}){const signal=AbortSignal.any([options.signal,AbortSignal.timeout(this.#timeoutMs)].filter(Boolean));let response;try{response=await this.#fetch(`${this.#baseUrl}${path}`,{method:"GET",headers:{Authorization:`Bearer ${this.#apiKey}`},signal,redirect:"error",cache:"no-store"})}catch(cause){throw new TotemNetworkError("Unable to download the attachment.",{code:"NETWORK_ERROR",cause})}if(!response.ok){const payload=await response.json().catch(()=>void 0);throw apiError(response.status,payload,response.headers.get("x-request-id")??void 0)}const reported=Number(response.headers.get("content-length"));if(reported>2*1024*1024){await response.body?.cancel().catch(()=>{});throw new TotemError("The attachment exceeds the 2 MiB limit.",{code:"ATTACHMENT_TOO_LARGE"})}const reader=response.body?.getReader();const chunks=[];let size=0;if(reader){try{for(;;){const{done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>2*1024*1024){await reader.cancel().catch(()=>{});throw new TotemError("The attachment exceeds the 2 MiB limit.",{code:"ATTACHMENT_TOO_LARGE"})}chunks.push(value)}}finally{reader.releaseLock()}}const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.byteLength}const disposition=response.headers.get("content-disposition")??"";const encoded=disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];return{bytes,mediaType:response.headers.get("content-type")??"application/octet-stream",name:encoded?decodeURIComponent(encoded):"attachment",sha256:response.headers.get("x-content-sha256")}}}export{Transport,idempotencyKey,sleep};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":1,"packageVersion":"0.1.
|
|
1
|
+
{"version":1,"packageVersion":"0.1.76","bytes":27189,"sha256":"c62a0116c76177d905051b40ddcbb0503e042b1fc8f5d024c1ebf41c7f173983"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":1,"packageVersion":"0.1.
|
|
1
|
+
{"version":1,"packageVersion":"0.1.76","bytes":36144,"sha256":"66535754c594ad559c91b68c93348ec5579f10a0d791c462cecf98cb618ea2ae"}
|
|
@@ -39,10 +39,10 @@ Use an actual native user-message tool if available and wait for delivery. Other
|
|
|
39
39
|
Run on the actual command-capable agent host, from the intended native session:
|
|
40
40
|
|
|
41
41
|
~~~sh
|
|
42
|
-
npx --yes --ignore-scripts --package=https://totemlayer.com/downloads/totemlayer-0.1.
|
|
42
|
+
npx --yes --ignore-scripts --package=https://totemlayer.com/downloads/totemlayer-0.1.76.tgz totem install
|
|
43
43
|
~~~
|
|
44
44
|
|
|
45
|
-
This command pins release **0.1.
|
|
45
|
+
This command pins release **0.1.76**. Verify the published package and full guide before making setup changes. Node.js 22.14 or newer is required for this package. Pin the tested release. `--ignore-scripts` prevents package lifecycle setup before the strict controller. Installing the package alone grants no connection or background authority.
|
|
46
46
|
|
|
47
47
|
The command verifies the full bundled guide against its byte length/SHA-256 manifest, with a complete alternate copy. `totem install --read-guide` prints it without setup changes. Corrupt/incomplete required guides are a hard stop. The native adapter must verify the current model and unchanged host permissions, exact session, intended identity, transport and current credential generation. “default” or a guessed model is not evidence. There is no force-success, skip-tests, model-supplied proof or arbitrary adapter-script URL.
|
|
48
48
|
|
|
@@ -39,10 +39,10 @@ Use an actual native user-message tool if available and wait for delivery. Other
|
|
|
39
39
|
Run on the actual command-capable agent host, from the intended native session:
|
|
40
40
|
|
|
41
41
|
~~~sh
|
|
42
|
-
npx --yes --ignore-scripts --package=https://totemlayer.com/downloads/totemlayer-0.1.
|
|
42
|
+
npx --yes --ignore-scripts --package=https://totemlayer.com/downloads/totemlayer-0.1.76.tgz totem install
|
|
43
43
|
~~~
|
|
44
44
|
|
|
45
|
-
This command pins release **0.1.
|
|
45
|
+
This command pins release **0.1.76**. Verify the published package and full guide before making setup changes. Node.js 22.14 or newer is required for this package. Pin the tested release. `--ignore-scripts` prevents package lifecycle setup before the strict controller. Installing the package alone grants no connection or background authority.
|
|
46
46
|
|
|
47
47
|
The command verifies the full bundled guide against its byte length/SHA-256 manifest, with a complete alternate copy. `totem install --read-guide` prints it without setup changes. Corrupt/incomplete required guides are a hard stop. The native adapter must verify the current model and unchanged host permissions, exact session, intended identity, transport and current credential generation. “default” or a guessed model is not evidence. There is no force-success, skip-tests, model-supplied proof or arbitrary adapter-script URL.
|
|
48
48
|
|
|
@@ -15,6 +15,8 @@ import { runtimeRoot, privateRead, privateWrite } from "./runtime-installation.m
|
|
|
15
15
|
import { readReleaseSetupGuide } from "./setup-guide.mjs";
|
|
16
16
|
import { prepareSetupTests, readSetupTests } from "./setup-receipts.mjs";
|
|
17
17
|
import { defaultCodexQueue } from "./codex-chat-bridge.mjs";
|
|
18
|
+
import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
|
|
19
|
+
import { validOrganizationId } from "./strict-setup.mjs";
|
|
18
20
|
|
|
19
21
|
const execute = promisify(execFile);
|
|
20
22
|
const fail = (code) => {
|
|
@@ -125,7 +127,7 @@ export function createStrictCodexAdapter(flags, options = {}) {
|
|
|
125
127
|
own.id !== saved.agentId ||
|
|
126
128
|
own.address !== saved.address ||
|
|
127
129
|
own.environment !== saved.environment ||
|
|
128
|
-
|
|
130
|
+
!validOrganizationId(own.organization?.id)
|
|
129
131
|
)
|
|
130
132
|
fail("AGENT_IDENTITY_MISMATCH");
|
|
131
133
|
const checked = await snapshot();
|
|
@@ -190,18 +192,15 @@ export function createStrictCodexAdapter(flags, options = {}) {
|
|
|
190
192
|
readGuide: options.readGuide ?? readReleaseSetupGuide,
|
|
191
193
|
async inspect(readOnly = false) {
|
|
192
194
|
host = await snapshot();
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
+
const invocation = installedClientInvocation("codex", ["mcp", "list", "--json"], inherited);
|
|
196
|
+
const pending = (options.execute ?? execute)(invocation.command, invocation.args, {
|
|
197
|
+
env: localClientEnvironment(inherited),
|
|
195
198
|
encoding: "utf8",
|
|
196
199
|
timeout: 5000,
|
|
197
200
|
maxBuffer: 65536,
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
`${error.stdout ?? ""}\n${error.stderr ?? ""}`,
|
|
202
|
-
)
|
|
203
|
-
)
|
|
204
|
-
return null;
|
|
201
|
+
});
|
|
202
|
+
pending.child?.stdin?.end();
|
|
203
|
+
const result = await pending.catch(() => {
|
|
205
204
|
fail("MCP_SETTINGS_UNVERIFIED");
|
|
206
205
|
});
|
|
207
206
|
if (result) {
|
package/bin/strict-setup.mjs
CHANGED
|
@@ -17,6 +17,9 @@ const token = (value) =>
|
|
|
17
17
|
typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,319}$/.test(value);
|
|
18
18
|
const uuid = (value) =>
|
|
19
19
|
typeof value === "string" && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
|
|
20
|
+
export const validOrganizationId = (value) =>
|
|
21
|
+
uuid(value) ||
|
|
22
|
+
(typeof value === "string" && /^org_(?:[A-Za-z0-9_-]{24}|personal_[a-f0-9]{20})$/.test(value));
|
|
20
23
|
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
21
24
|
const stop = (code) => {
|
|
22
25
|
throw Object.assign(new Error(code), { code });
|
|
@@ -26,7 +29,7 @@ export function setupBinding(identity, host, transport, connection) {
|
|
|
26
29
|
if (
|
|
27
30
|
!uuid(identity?.id) ||
|
|
28
31
|
!token(identity.address) ||
|
|
29
|
-
!
|
|
32
|
+
!validOrganizationId(identity.organization?.id) ||
|
|
30
33
|
!["PRODUCTION", "SANDBOX"].includes(identity.environment) ||
|
|
31
34
|
!token(host?.name) ||
|
|
32
35
|
!token(host.model) ||
|
|
@@ -245,6 +248,7 @@ export async function runStrictSetup(adapter, options = {}) {
|
|
|
245
248
|
if (
|
|
246
249
|
expected &&
|
|
247
250
|
(identity?.id !== expected.id ||
|
|
251
|
+
identity.address !== expected.address ||
|
|
248
252
|
identity.environment !== expected.environment ||
|
|
249
253
|
(expected.organization?.id && identity.organization?.id !== expected.organization.id))
|
|
250
254
|
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { lstat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
const execute = promisify(execFile);
|
|
7
|
+
export function validateWindowsPrivateAcl(snapshot) {
|
|
8
|
+
const sid = snapshot?.sid;
|
|
9
|
+
if (typeof sid !== "string" || !/^S-1-(?:\d+-){1,14}\d+$/.test(sid))
|
|
10
|
+
throw new Error("The Windows account SID could not be verified.");
|
|
11
|
+
const trusted = new Set([sid, "S-1-5-18", "S-1-5-32-544"]);
|
|
12
|
+
const trustedAncestors = new Set([
|
|
13
|
+
...trusted,
|
|
14
|
+
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
|
|
15
|
+
]);
|
|
16
|
+
if (
|
|
17
|
+
snapshot.owner !== sid ||
|
|
18
|
+
snapshot.reparse ||
|
|
19
|
+
!Array.isArray(snapshot.rules) ||
|
|
20
|
+
!Array.isArray(snapshot.ancestors)
|
|
21
|
+
)
|
|
22
|
+
throw new Error("The private Windows path has an unsafe owner or reparse point.");
|
|
23
|
+
let readable = false;
|
|
24
|
+
for (const rule of snapshot.rules) {
|
|
25
|
+
if (
|
|
26
|
+
!Number.isSafeInteger(rule.rights) ||
|
|
27
|
+
rule.rights < 0 ||
|
|
28
|
+
!["Allow", "Deny"].includes(rule.type)
|
|
29
|
+
)
|
|
30
|
+
throw new Error("The Windows access rules could not be verified.");
|
|
31
|
+
if (rule.type === "Allow" && !trusted.has(rule.sid))
|
|
32
|
+
throw new Error("The private Windows path grants access to another principal.");
|
|
33
|
+
if (rule.type === "Allow" && rule.sid === sid && (rule.rights & 131073) === 131073)
|
|
34
|
+
readable = true;
|
|
35
|
+
if (rule.type === "Deny" && rule.sid === sid && rule.rights & 131073)
|
|
36
|
+
throw new Error("The private Windows path denies required owner access.");
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
!readable ||
|
|
40
|
+
snapshot.ancestors.some(
|
|
41
|
+
(parent) =>
|
|
42
|
+
parent.reparse ||
|
|
43
|
+
!trustedAncestors.has(parent.owner) ||
|
|
44
|
+
!Array.isArray(parent.rules) ||
|
|
45
|
+
parent.rules.some(
|
|
46
|
+
(rule) =>
|
|
47
|
+
!rule.inheritOnly &&
|
|
48
|
+
rule.type === "Allow" &&
|
|
49
|
+
!trustedAncestors.has(rule.sid) &&
|
|
50
|
+
(rule.rights & (64 | 65536 | 262144 | 524288)) !== 0,
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
throw new Error("The private Windows path or its parent can be replaced by another principal.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function assertWindowsPrivatePath(path) {
|
|
58
|
+
if (process.platform !== "win32") return;
|
|
59
|
+
await lstat(path); // Preserve ENOENT for callers that create a new private file.
|
|
60
|
+
const encoded = Buffer.from(resolve(path), "utf8").toString("base64");
|
|
61
|
+
const script = `$ErrorActionPreference='Stop'
|
|
62
|
+
$path=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))
|
|
63
|
+
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
64
|
+
function Rules($acl) { @($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | ForEach-Object { @{sid=$_.IdentityReference.Value;rights=[int64]$_.FileSystemRights;type=$_.AccessControlType.ToString();inheritOnly=[bool]($_.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly)} }) }
|
|
65
|
+
$item=Get-Item -LiteralPath $path -Force
|
|
66
|
+
$acl=Get-Acl -LiteralPath $path
|
|
67
|
+
$parents=@(); $parent=Split-Path -LiteralPath $path
|
|
68
|
+
while ($parent) {
|
|
69
|
+
$p=Get-Item -LiteralPath $parent -Force
|
|
70
|
+
$parentAcl=Get-Acl -LiteralPath $parent
|
|
71
|
+
$parents+=@{owner=$parentAcl.GetOwner([Security.Principal.SecurityIdentifier]).Value;reparse=[bool]($p.Attributes -band [IO.FileAttributes]::ReparsePoint);rules=@(Rules $parentAcl)}
|
|
72
|
+
$next=Split-Path -LiteralPath $parent
|
|
73
|
+
if ($next -eq $parent) { break }; $parent=$next
|
|
74
|
+
}
|
|
75
|
+
@{sid=$sid;owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value;reparse=[bool]($item.Attributes -band [IO.FileAttributes]::ReparsePoint);rules=@(Rules $acl);ancestors=$parents} | ConvertTo-Json -Depth 8 -Compress`;
|
|
76
|
+
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
|
|
77
|
+
if (!systemRoot) throw new Error("The Windows system directory could not be verified.");
|
|
78
|
+
const { stdout } = await execute(
|
|
79
|
+
join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
80
|
+
[
|
|
81
|
+
"-NoProfile",
|
|
82
|
+
"-NonInteractive",
|
|
83
|
+
"-EncodedCommand",
|
|
84
|
+
Buffer.from(script, "utf16le").toString("base64"),
|
|
85
|
+
],
|
|
86
|
+
{ timeout: 10000, maxBuffer: 262144, windowsHide: true },
|
|
87
|
+
);
|
|
88
|
+
validateWindowsPrivateAcl(JSON.parse(stdout.replace(/^\uFEFF/, "")));
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "totemlayer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.76",
|
|
4
4
|
"description": "Cross-platform CLI for TotemLayer agent identity and authorization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@modelcontextprotocol/client": "2.3.0",
|
|
37
37
|
"@modelcontextprotocol/server": "2.3.0",
|
|
38
38
|
"undici": "7.30.1",
|
|
39
|
-
"zod": "4.6.
|
|
39
|
+
"zod": "4.6.7"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"postinstall": "node bin/installation.mjs --postinstall",
|