totemlayer 0.1.30 → 0.1.38
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 +57 -260
- package/bin/agent-profile.mjs +10 -1
- package/bin/agent-runner.mjs +26 -1
- package/bin/agent-setup.mjs +5 -1
- package/bin/automatic-runtime.mjs +113 -21
- package/bin/codex-chat-bridge.mjs +259 -67
- package/bin/codex-chat-readiness.mjs +321 -0
- package/bin/connected-agent-service.mjs +19 -8
- package/bin/local-mcp.mjs +42 -6
- package/bin/mcp-approval-review.mjs +156 -0
- package/bin/mcp-identity-binding.mjs +52 -0
- package/bin/mcp-install.mjs +196 -28
- package/bin/mcp-runtime.mjs +169 -0
- package/bin/mcp.mjs +153 -21
- package/bin/mission-agent-service.mjs +2 -0
- package/bin/mission-worker.mjs +11 -1
- package/bin/network-runtime.mjs +16 -4
- package/bin/runtime-diagnostics.mjs +17 -1
- package/bin/runtime-installation.mjs +9 -5
- package/bin/sdk/types.d.ts +32 -0
- package/bin/totem.mjs +2 -2
- package/package.json +2 -2
package/bin/mcp-install.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
|
|
7
7
|
import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
|
|
8
8
|
import { runtimeRoot } from "./runtime-installation.mjs";
|
|
9
|
+
import { loadMcpIdentityBinding } from "./mcp-identity-binding.mjs";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_ORIGIN = "https://totemlayer.com";
|
|
11
12
|
const DEFAULT_NAME = "totem-local";
|
|
@@ -14,6 +15,7 @@ const SERVER_ENTRY = fileURLToPath(new URL("./totem.mjs", import.meta.url));
|
|
|
14
15
|
export const MCP_INSTALL_HELP = `Connect Totem to an AI app
|
|
15
16
|
|
|
16
17
|
Usage:
|
|
18
|
+
totem mcp install generic --key-file /absolute/path/to/agent.key
|
|
17
19
|
totem mcp install codex --key-file /absolute/path/to/agent.key
|
|
18
20
|
totem mcp install claude --key-file /absolute/path/to/agent.key
|
|
19
21
|
|
|
@@ -21,13 +23,25 @@ Options:
|
|
|
21
23
|
--base-url <origin> Totem origin (default: https://totemlayer.com)
|
|
22
24
|
--name <name> MCP server name (default: totem-local)
|
|
23
25
|
--key-file <path> Existing private agent key file (required)
|
|
26
|
+
--agent-id <UUID> Require this credential-derived agent identity
|
|
27
|
+
--environment <env> Require PRODUCTION or SANDBOX
|
|
28
|
+
--codex-thread <ID> Bind a new Codex listener to this owner-selected chat
|
|
29
|
+
--no-start Configure tools only; do not start a local listener
|
|
24
30
|
--help Show this guide
|
|
25
31
|
|
|
26
32
|
This installs a user-level local stdio MCP connection. It never puts the key
|
|
27
33
|
value in a command, client config, or repository. It will not overwrite an
|
|
28
34
|
existing MCP server. Restart the AI app afterward and ask for totem_get_identity.
|
|
35
|
+
Use --agent-id and --environment to reject an unintended agent or environment.
|
|
36
|
+
Codex/Claude installation also prepares this exact identity's supervised listener
|
|
37
|
+
for login and crash recovery. Existing stops and owner permissions remain in force.
|
|
29
38
|
On macOS/Linux the key file must be mode 0600. On Windows, protect it with
|
|
30
39
|
your user's filesystem ACL. The package must remain installed.
|
|
40
|
+
|
|
41
|
+
Use generic for any stdio MCP client: it prints JSON configuration without
|
|
42
|
+
changing client settings or starting a background listener. Adapt the outer
|
|
43
|
+
configuration wrapper to your client's schema. For hosted Streamable HTTP,
|
|
44
|
+
connect directly to https://totemlayer.com/mcp using OAuth or a Bearer agent key.
|
|
31
45
|
`;
|
|
32
46
|
|
|
33
47
|
function fail(message) {
|
|
@@ -37,14 +51,29 @@ function fail(message) {
|
|
|
37
51
|
function parseInstallArgs(args, environment) {
|
|
38
52
|
if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { help: true };
|
|
39
53
|
const [client, ...rest] = args;
|
|
40
|
-
if (
|
|
41
|
-
fail(
|
|
54
|
+
if (!["generic", "codex", "claude"].includes(client))
|
|
55
|
+
fail(
|
|
56
|
+
"Choose generic for any stdio MCP client, or codex or claude for automatic configuration. Run `totem mcp install --help` for examples.",
|
|
57
|
+
);
|
|
42
58
|
const values = {};
|
|
43
59
|
for (let index = 0; index < rest.length; index += 2) {
|
|
44
60
|
const option = rest[index];
|
|
61
|
+
if (option === "--no-start") {
|
|
62
|
+
if (values[option]) fail("--no-start was provided more than once.");
|
|
63
|
+
values[option] = true;
|
|
64
|
+
index -= 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
45
67
|
const value = rest[index + 1];
|
|
46
68
|
if (
|
|
47
|
-
![
|
|
69
|
+
![
|
|
70
|
+
"--key-file",
|
|
71
|
+
"--base-url",
|
|
72
|
+
"--name",
|
|
73
|
+
"--agent-id",
|
|
74
|
+
"--environment",
|
|
75
|
+
"--codex-thread",
|
|
76
|
+
].includes(option) ||
|
|
48
77
|
!value ||
|
|
49
78
|
value.startsWith("--")
|
|
50
79
|
)
|
|
@@ -78,7 +107,41 @@ function parseInstallArgs(args, environment) {
|
|
|
78
107
|
fail(
|
|
79
108
|
"--base-url must be a credential-free HTTPS origin (or HTTP loopback for local development).",
|
|
80
109
|
);
|
|
81
|
-
|
|
110
|
+
if (
|
|
111
|
+
values["--codex-thread"] &&
|
|
112
|
+
(client !== "codex" ||
|
|
113
|
+
!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(values["--codex-thread"]))
|
|
114
|
+
)
|
|
115
|
+
fail("--codex-thread requires codex and an owner-selected chat UUID.");
|
|
116
|
+
return {
|
|
117
|
+
client,
|
|
118
|
+
codexThreadId: values["--codex-thread"],
|
|
119
|
+
configDirectory: environment.TOTEM_CONFIG_DIR ? runtimeRoot(environment) : undefined,
|
|
120
|
+
noStart: values["--no-start"] === true || environment.TOTEM_AUTO_START === "0",
|
|
121
|
+
keyFile: resolve(keyFile),
|
|
122
|
+
name,
|
|
123
|
+
baseUrl: url.origin,
|
|
124
|
+
...loadMcpIdentityBinding({
|
|
125
|
+
TOTEM_EXPECTED_AGENT_ID: values["--agent-id"] ?? environment.TOTEM_EXPECTED_AGENT_ID,
|
|
126
|
+
TOTEM_EXPECTED_ENVIRONMENT: values["--environment"] ?? environment.TOTEM_EXPECTED_ENVIRONMENT,
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function identityEnvironment(binding) {
|
|
132
|
+
return {
|
|
133
|
+
...(binding.expectedAgentId ? { TOTEM_EXPECTED_AGENT_ID: binding.expectedAgentId } : {}),
|
|
134
|
+
...(binding.expectedEnvironment
|
|
135
|
+
? { TOTEM_EXPECTED_ENVIRONMENT: binding.expectedEnvironment }
|
|
136
|
+
: {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function managedIdentityBinding(profile) {
|
|
141
|
+
return loadMcpIdentityBinding({
|
|
142
|
+
TOTEM_EXPECTED_AGENT_ID: profile.agentId,
|
|
143
|
+
TOTEM_EXPECTED_ENVIRONMENT: profile.environment,
|
|
144
|
+
});
|
|
82
145
|
}
|
|
83
146
|
|
|
84
147
|
async function validateKeyFile(path, platform) {
|
|
@@ -114,22 +177,68 @@ export async function installTotemMcp(args, environment = process.env, runtime =
|
|
|
114
177
|
runtime = { platform: process.platform, spawnProcess: spawnSync, ...runtime };
|
|
115
178
|
await validateKeyFile(options.keyFile, runtime.platform);
|
|
116
179
|
|
|
180
|
+
if (options.client === "generic")
|
|
181
|
+
return (
|
|
182
|
+
JSON.stringify(
|
|
183
|
+
{
|
|
184
|
+
mcpServers: {
|
|
185
|
+
[options.name]: {
|
|
186
|
+
command: process.execPath,
|
|
187
|
+
args: [runtime.serverEntry ?? SERVER_ENTRY, "mcp"],
|
|
188
|
+
env: {
|
|
189
|
+
TOTEM_BASE_URL: options.baseUrl,
|
|
190
|
+
TOTEM_NETWORK_API_KEY_FILE: options.keyFile,
|
|
191
|
+
...identityEnvironment(options),
|
|
192
|
+
...(options.noStart ? { TOTEM_AUTO_START: "0" } : {}),
|
|
193
|
+
...(options.configDirectory ? { TOTEM_CONFIG_DIR: options.configDirectory } : {}),
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
null,
|
|
199
|
+
2,
|
|
200
|
+
) + "\n"
|
|
201
|
+
);
|
|
202
|
+
|
|
117
203
|
const command = options.client;
|
|
118
|
-
const
|
|
119
|
-
|
|
204
|
+
const getArgs = ["mcp", "get", options.name, ...(command === "codex" ? ["--json"] : [])];
|
|
205
|
+
const existing = runClient(command, getArgs, environment, runtime);
|
|
206
|
+
let sameEntry = false;
|
|
207
|
+
if (existing.status === 0 && command === "codex") {
|
|
208
|
+
try {
|
|
209
|
+
sameEntry = matchesManagedEntry(
|
|
210
|
+
JSON.parse(existing.stdout),
|
|
211
|
+
{
|
|
212
|
+
baseUrl: options.baseUrl,
|
|
213
|
+
credentialFile: options.keyFile,
|
|
214
|
+
agentId: options.expectedAgentId,
|
|
215
|
+
environment: options.expectedEnvironment,
|
|
216
|
+
noStart: options.noStart,
|
|
217
|
+
configDirectory: options.configDirectory,
|
|
218
|
+
},
|
|
219
|
+
options.name,
|
|
220
|
+
runtime.serverEntry ?? SERVER_ENTRY,
|
|
221
|
+
);
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
if (existing.status === 0 && !sameEntry)
|
|
120
225
|
fail(
|
|
121
226
|
`${options.name} already exists in ${command}. No configuration was changed. Choose another --name or inspect the existing connection first.`,
|
|
122
227
|
);
|
|
123
|
-
if (
|
|
228
|
+
if (
|
|
229
|
+
!sameEntry &&
|
|
230
|
+
!/no mcp server|not found|does not exist/i.test(`${existing.stderr}\n${existing.stdout}`)
|
|
231
|
+
)
|
|
124
232
|
fail(
|
|
125
233
|
`${command} could not check whether ${options.name} already exists. No configuration was changed.`,
|
|
126
234
|
);
|
|
127
|
-
const envArgs =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
235
|
+
const envArgs = Object.entries({
|
|
236
|
+
TOTEM_BASE_URL: options.baseUrl,
|
|
237
|
+
TOTEM_NETWORK_API_KEY_FILE: options.keyFile,
|
|
238
|
+
...identityEnvironment(options),
|
|
239
|
+
...(options.noStart ? { TOTEM_AUTO_START: "0" } : {}),
|
|
240
|
+
...(options.configDirectory ? { TOTEM_CONFIG_DIR: options.configDirectory } : {}),
|
|
241
|
+
}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
|
133
242
|
const addArgs =
|
|
134
243
|
options.client === "codex"
|
|
135
244
|
? [
|
|
@@ -156,17 +265,48 @@ export async function installTotemMcp(args, environment = process.env, runtime =
|
|
|
156
265
|
runtime.serverEntry ?? SERVER_ENTRY,
|
|
157
266
|
"mcp",
|
|
158
267
|
];
|
|
159
|
-
const added = runClient(command, addArgs, environment, runtime);
|
|
268
|
+
const added = sameEntry ? { status: 0 } : runClient(command, addArgs, environment, runtime);
|
|
160
269
|
if (added.status !== 0)
|
|
161
270
|
fail(
|
|
162
271
|
`${command} could not add ${options.name}. No Totem key value was sent. Check the client's MCP setup and retry.`,
|
|
163
272
|
);
|
|
164
|
-
const verified = runClient(command,
|
|
273
|
+
const verified = sameEntry ? existing : runClient(command, getArgs, environment, runtime);
|
|
165
274
|
if (verified.status !== 0)
|
|
166
275
|
fail(
|
|
167
276
|
`${command} reported an install, but ${options.name} could not be verified. Inspect its MCP settings before using it.`,
|
|
168
277
|
);
|
|
169
|
-
|
|
278
|
+
let listener = "";
|
|
279
|
+
if (!runtime.skipListenerSetup) {
|
|
280
|
+
try {
|
|
281
|
+
const config = await (runtime.loadConfig ?? (await import("./mcp.mjs")).loadTotemMcpConfig)({
|
|
282
|
+
TOTEM_BASE_URL: options.baseUrl,
|
|
283
|
+
TOTEM_NETWORK_API_KEY_FILE: options.keyFile,
|
|
284
|
+
...identityEnvironment(options),
|
|
285
|
+
});
|
|
286
|
+
const status = await (
|
|
287
|
+
runtime.prepareListener ?? (await import("./mcp-runtime.mjs")).ensureMcpListener
|
|
288
|
+
)(
|
|
289
|
+
config,
|
|
290
|
+
{ ...environment, ...(options.noStart ? { TOTEM_AUTO_START: "0" } : {}) },
|
|
291
|
+
{
|
|
292
|
+
allowCreate: true,
|
|
293
|
+
provider: options.client,
|
|
294
|
+
keyFile: options.keyFile,
|
|
295
|
+
codexThreadId: options.codexThreadId,
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
listener = ` Listener: ${status.state}${status.persistent === false ? " (login recovery is unavailable on this host)" : ""}.`;
|
|
299
|
+
if (["needs_attention", "setup_required"].includes(status.state))
|
|
300
|
+
fail(
|
|
301
|
+
"MCP settings were saved, but the listener needs attention. Run totem status or totem setup with this same agent's private key; no existing identity or permission was replaced.",
|
|
302
|
+
);
|
|
303
|
+
} catch {
|
|
304
|
+
fail(
|
|
305
|
+
"MCP settings were saved, but the listener could not confirm setup. Run totem status or totem setup with this same agent's private key; no existing identity or permission was replaced.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return `Connected ${options.name} to ${command} at ${options.baseUrl}.${listener} Restart ${command}, then ask it to call totem_get_identity. The Totem CLI package must remain installed.\n`;
|
|
170
310
|
}
|
|
171
311
|
|
|
172
312
|
/** Guided setup owns a distinct entry per profile; the manual installer retains its default. */
|
|
@@ -179,7 +319,7 @@ export function managedMcpName(profileName) {
|
|
|
179
319
|
: `totem_${profileName.slice(0, 24)}_${createHash("sha256").update(profileName).digest("hex").slice(0, 32)}`;
|
|
180
320
|
}
|
|
181
321
|
|
|
182
|
-
async function installedServerEntry(profile
|
|
322
|
+
async function installedServerEntry(profile) {
|
|
183
323
|
if (!profile.stateDirectory) return SERVER_ENTRY;
|
|
184
324
|
const path = join(resolve(profile.stateDirectory, "../../../.."), "runtime", "totem.mjs");
|
|
185
325
|
try {
|
|
@@ -199,12 +339,11 @@ async function installedServerEntry(profile, environment) {
|
|
|
199
339
|
}
|
|
200
340
|
|
|
201
341
|
function managedArgs(profile, name, entry = SERVER_ENTRY) {
|
|
202
|
-
const variables =
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
];
|
|
342
|
+
const variables = Object.entries({
|
|
343
|
+
TOTEM_BASE_URL: profile.baseUrl,
|
|
344
|
+
TOTEM_NETWORK_API_KEY_FILE: profile.credentialFile,
|
|
345
|
+
...identityEnvironment(managedIdentityBinding(profile)),
|
|
346
|
+
}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
|
208
347
|
return profile.client === "codex"
|
|
209
348
|
? ["mcp", "add", name, ...variables, "--", process.execPath, entry, "mcp"]
|
|
210
349
|
: [
|
|
@@ -228,6 +367,9 @@ function matchesManagedEntry(entry, profile, name, serverEntry = SERVER_ENTRY) {
|
|
|
228
367
|
const expectedEnvironment = {
|
|
229
368
|
TOTEM_BASE_URL: profile.baseUrl,
|
|
230
369
|
TOTEM_NETWORK_API_KEY_FILE: profile.credentialFile,
|
|
370
|
+
...identityEnvironment(managedIdentityBinding(profile)),
|
|
371
|
+
...(profile.noStart ? { TOTEM_AUTO_START: "0" } : {}),
|
|
372
|
+
...(profile.configDirectory ? { TOTEM_CONFIG_DIR: profile.configDirectory } : {}),
|
|
231
373
|
};
|
|
232
374
|
return (
|
|
233
375
|
entry?.name === name &&
|
|
@@ -243,7 +385,7 @@ function matchesManagedEntry(entry, profile, name, serverEntry = SERVER_ENTRY) {
|
|
|
243
385
|
(transport.env_vars == null ||
|
|
244
386
|
(Array.isArray(transport.env_vars) && transport.env_vars.length === 0)) &&
|
|
245
387
|
transport.env &&
|
|
246
|
-
Object.keys(transport.env).length ===
|
|
388
|
+
Object.keys(transport.env).length === Object.keys(expectedEnvironment).length &&
|
|
247
389
|
Object.entries(expectedEnvironment).every(([key, value]) => transport.env[key] === value) &&
|
|
248
390
|
Object.keys(transport).every((key) =>
|
|
249
391
|
["type", "command", "args", "env", "env_vars", "cwd"].includes(key),
|
|
@@ -307,8 +449,23 @@ function reconnectAdvice(profile, name) {
|
|
|
307
449
|
return `Inspect ${name} with \`${profile.client} mcp get ${name}\`. To replace that exact entry after reviewing it, run \`${profile.client} mcp remove ${name}\`, then \`totem setup --profile ${profile.profileName} --install-mcp\`. Other entries are preserved.`;
|
|
308
450
|
}
|
|
309
451
|
|
|
452
|
+
function missingBindingAdvice(entry, profile, name, serverEntry) {
|
|
453
|
+
const binding = managedIdentityBinding(profile);
|
|
454
|
+
if (
|
|
455
|
+
(binding.expectedAgentId || binding.expectedEnvironment) &&
|
|
456
|
+
matchesManagedEntry(
|
|
457
|
+
entry,
|
|
458
|
+
{ ...profile, agentId: undefined, environment: undefined },
|
|
459
|
+
name,
|
|
460
|
+
serverEntry,
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
return `The existing entry matches this profile's key reference and origin, but lacks its expected identity binding${binding.expectedAgentId ? ` for agent ${binding.expectedAgentId}` : ""}${binding.expectedEnvironment ? ` in ${binding.expectedEnvironment}` : ""}. It was preserved; review and repair that exact entry. `;
|
|
464
|
+
return "";
|
|
465
|
+
}
|
|
466
|
+
|
|
310
467
|
export async function inspectManagedTotemMcp(profile, environment = process.env, runtime = {}) {
|
|
311
|
-
const serverEntry = await installedServerEntry(profile
|
|
468
|
+
const serverEntry = await installedServerEntry(profile);
|
|
312
469
|
const inspected = await inspectEntry(profile, localClientEnvironment(environment), runtime);
|
|
313
470
|
if (
|
|
314
471
|
inspected.state === "present" &&
|
|
@@ -328,7 +485,7 @@ export async function inspectManagedTotemMcp(profile, environment = process.env,
|
|
|
328
485
|
return {
|
|
329
486
|
name: inspected.name,
|
|
330
487
|
state: "needs_attention",
|
|
331
|
-
message: `${inspected.state === "unavailable" ? "The client configuration could not be read." : "The existing entry could not be verified against this saved profile."}
|
|
488
|
+
message: `${missingBindingAdvice(inspected.entry, profile, inspected.name, serverEntry) || (inspected.state === "unavailable" ? "The client configuration could not be read. " : "The existing entry could not be verified against this saved profile. ")}${reconnectAdvice(profile, inspected.name)}`,
|
|
332
489
|
};
|
|
333
490
|
}
|
|
334
491
|
|
|
@@ -338,7 +495,7 @@ export async function installManagedTotemMcp(
|
|
|
338
495
|
environment = process.env,
|
|
339
496
|
runtime = {},
|
|
340
497
|
) {
|
|
341
|
-
const serverEntry = await installedServerEntry(profile
|
|
498
|
+
const serverEntry = await installedServerEntry(profile);
|
|
342
499
|
if (!["codex", "claude"].includes(profile.client))
|
|
343
500
|
fail("Choose an existing AI app for MCP setup.");
|
|
344
501
|
try {
|
|
@@ -366,9 +523,11 @@ export async function installManagedTotemMcp(
|
|
|
366
523
|
profile.baseUrl,
|
|
367
524
|
"--name",
|
|
368
525
|
name,
|
|
526
|
+
...(profile.agentId ? ["--agent-id", profile.agentId] : []),
|
|
527
|
+
...(profile.environment ? ["--environment", profile.environment] : []),
|
|
369
528
|
],
|
|
370
529
|
safeEnvironment,
|
|
371
|
-
{ ...runtime, serverEntry },
|
|
530
|
+
{ ...runtime, serverEntry, skipListenerSetup: true },
|
|
372
531
|
);
|
|
373
532
|
if (profile.client === "codex") {
|
|
374
533
|
const after = await inspectEntry(profile, safeEnvironment, runtime);
|
|
@@ -404,9 +563,18 @@ export async function installManagedTotemMcp(
|
|
|
404
563
|
state: "configured_unverified",
|
|
405
564
|
message: `${name} already matches this profile; nothing changed. Verify its identity in your AI app with totem_get_identity.`,
|
|
406
565
|
};
|
|
566
|
+
const missingBinding = missingBindingAdvice(inspected.entry, profile, name, serverEntry);
|
|
567
|
+
if (missingBinding)
|
|
568
|
+
return {
|
|
569
|
+
name,
|
|
570
|
+
state: "needs_attention",
|
|
571
|
+
message: `${missingBinding}${guidance}`,
|
|
572
|
+
};
|
|
407
573
|
const ownsPrevious =
|
|
408
574
|
previousProfile?.profileName === profile.profileName &&
|
|
409
575
|
previousProfile.client === profile.client &&
|
|
576
|
+
JSON.stringify(managedIdentityBinding(previousProfile)) ===
|
|
577
|
+
JSON.stringify(managedIdentityBinding(profile)) &&
|
|
410
578
|
inspected.state === "present" &&
|
|
411
579
|
matchesManagedEntry(
|
|
412
580
|
inspected.entry,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { opendir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Totem } from "./sdk/index.js";
|
|
4
|
+
import { readAgentCredential, readAgentProfile } from "./agent-profile.mjs";
|
|
5
|
+
import { runtimeRoot } from "./runtime-installation.mjs";
|
|
6
|
+
import { automaticEnabled } from "./automatic-runtime.mjs";
|
|
7
|
+
|
|
8
|
+
function listenerBinding(profile) {
|
|
9
|
+
return {
|
|
10
|
+
profile: profile.profileName,
|
|
11
|
+
agentId: profile.agentId,
|
|
12
|
+
environment: profile.environment,
|
|
13
|
+
codexThreadId: profile.codexThreadId ?? null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Recover only the exact credential-bound profile. MCP transport grants no new host authority. */
|
|
18
|
+
export async function ensureMcpListener(config, environment = process.env, options = {}) {
|
|
19
|
+
if (environment.TOTEM_AUTO_START === "0" || environment.CI)
|
|
20
|
+
return { state: "disabled_by_request", ready: false, persistent: false };
|
|
21
|
+
if (!options.keyFile)
|
|
22
|
+
return {
|
|
23
|
+
state: "setup_required",
|
|
24
|
+
ready: false,
|
|
25
|
+
message:
|
|
26
|
+
"Tools are connected. Use explicit local setup with this agent's private key file to configure a listener; hosted or secret-only MCP transport does not install host software.",
|
|
27
|
+
};
|
|
28
|
+
const client = options.client ?? new Totem({ ...config, timeoutMs: 5000, maxRetries: 0 });
|
|
29
|
+
const identity = await client.agent();
|
|
30
|
+
if (
|
|
31
|
+
!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(identity?.id ?? "") ||
|
|
32
|
+
!["PRODUCTION", "SANDBOX"].includes(identity?.environment) ||
|
|
33
|
+
(config.expectedAgentId && identity.id !== config.expectedAgentId) ||
|
|
34
|
+
(config.expectedEnvironment && identity.environment !== config.expectedEnvironment)
|
|
35
|
+
)
|
|
36
|
+
throw new Error("The MCP credential does not match the selected Totem identity.");
|
|
37
|
+
const profiles = [];
|
|
38
|
+
try {
|
|
39
|
+
const entries = await opendir(join(runtimeRoot(environment), "connections"));
|
|
40
|
+
for await (const entry of entries) {
|
|
41
|
+
if (!entry.isDirectory()) continue;
|
|
42
|
+
const profile = await readAgentProfile(entry.name, { environment });
|
|
43
|
+
if (
|
|
44
|
+
profile.agentId === identity.id &&
|
|
45
|
+
profile.baseUrl === config.baseUrl &&
|
|
46
|
+
profile.environment === identity.environment
|
|
47
|
+
)
|
|
48
|
+
profiles.push(profile);
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code !== "ENOENT") throw error;
|
|
52
|
+
}
|
|
53
|
+
if (profiles.length > 1)
|
|
54
|
+
return {
|
|
55
|
+
state: "needs_attention",
|
|
56
|
+
ready: false,
|
|
57
|
+
message:
|
|
58
|
+
"Multiple saved listeners match this identity. Select the intended profile with totem setup; no profile was changed.",
|
|
59
|
+
};
|
|
60
|
+
let profile = profiles[0];
|
|
61
|
+
if (profile && options.codexThreadId && profile.codexThreadId !== options.codexThreadId)
|
|
62
|
+
return {
|
|
63
|
+
state: "needs_attention",
|
|
64
|
+
ready: false,
|
|
65
|
+
message:
|
|
66
|
+
"The saved profile has a different executor/chat binding. Stop and explicitly repair that exact profile; it was not rebound.",
|
|
67
|
+
};
|
|
68
|
+
if (profile && (await readAgentCredential(profile)) !== config.apiKey)
|
|
69
|
+
return {
|
|
70
|
+
state: "needs_attention",
|
|
71
|
+
ready: false,
|
|
72
|
+
message:
|
|
73
|
+
"The saved listener uses another credential generation. Repair that exact profile with totem setup; its credential was preserved.",
|
|
74
|
+
};
|
|
75
|
+
if (!profile) {
|
|
76
|
+
if (!options.allowCreate || !["codex", "claude"].includes(options.provider) || !options.keyFile)
|
|
77
|
+
return {
|
|
78
|
+
state: "setup_required",
|
|
79
|
+
ready: false,
|
|
80
|
+
message:
|
|
81
|
+
"MCP tools are connected, but this identity has no saved listener on this computer. The owner must run totem setup with this agent's private key and selected executor once. Hosted MCP cannot install a local listener.",
|
|
82
|
+
};
|
|
83
|
+
const setup = options.setup ?? (await import("./agent-setup.mjs")).agentSetupMain;
|
|
84
|
+
let output = "";
|
|
85
|
+
const result = await setup(
|
|
86
|
+
[
|
|
87
|
+
"--profile",
|
|
88
|
+
identity.id,
|
|
89
|
+
"--provider",
|
|
90
|
+
options.provider,
|
|
91
|
+
"--key-file",
|
|
92
|
+
options.keyFile,
|
|
93
|
+
"--base-url",
|
|
94
|
+
config.baseUrl,
|
|
95
|
+
"--agent-id",
|
|
96
|
+
identity.id,
|
|
97
|
+
"--no-mcp",
|
|
98
|
+
"--json",
|
|
99
|
+
...(options.codexThreadId ? ["--codex-thread", options.codexThreadId] : []),
|
|
100
|
+
],
|
|
101
|
+
{
|
|
102
|
+
env: environment,
|
|
103
|
+
stdout: {
|
|
104
|
+
write(chunk) {
|
|
105
|
+
output = (output + chunk).slice(-65536);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
stderr: { write() {} },
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
if (result)
|
|
112
|
+
return {
|
|
113
|
+
state: "needs_attention",
|
|
114
|
+
ready: false,
|
|
115
|
+
message:
|
|
116
|
+
"MCP tools are connected. The listener setup did not confirm readiness; inspect totem status. No repository or peer permission was added.",
|
|
117
|
+
};
|
|
118
|
+
profile = await readAgentProfile(identity.id, { environment });
|
|
119
|
+
const created = JSON.parse(output);
|
|
120
|
+
if (!created.automatic) throw new Error("Listener setup did not return its startup status.");
|
|
121
|
+
return { ...created.automatic, ...listenerBinding(profile) };
|
|
122
|
+
}
|
|
123
|
+
if (!(await automaticEnabled(profile)))
|
|
124
|
+
return {
|
|
125
|
+
state: "paused",
|
|
126
|
+
ready: false,
|
|
127
|
+
...listenerBinding(profile),
|
|
128
|
+
message:
|
|
129
|
+
"This listener was explicitly stopped. Its opt-out remains in effect; totem start can resume it.",
|
|
130
|
+
};
|
|
131
|
+
const activate = options.activate ?? (await import("./installation.mjs")).activateConnection;
|
|
132
|
+
const status = await activate(profile, environment);
|
|
133
|
+
return { ...status, ...listenerBinding(profile) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Status reads never start workers, rotate credentials or change an owner's pause. */
|
|
137
|
+
export async function readMcpListenerStatus(config, environment, initial, options = {}) {
|
|
138
|
+
if (!initial.profile) return initial;
|
|
139
|
+
try {
|
|
140
|
+
const profile = await readAgentProfile(initial.profile, { environment });
|
|
141
|
+
if (
|
|
142
|
+
profile.agentId !== initial.agentId ||
|
|
143
|
+
profile.baseUrl !== config.baseUrl ||
|
|
144
|
+
profile.environment !== initial.environment ||
|
|
145
|
+
(profile.codexThreadId ?? null) !== initial.codexThreadId ||
|
|
146
|
+
(config.expectedEnvironment && profile.environment !== config.expectedEnvironment) ||
|
|
147
|
+
(await readAgentCredential(profile)) !== config.apiKey
|
|
148
|
+
)
|
|
149
|
+
throw new Error("Listener binding changed");
|
|
150
|
+
const status = { ...initial, ready: false, checkedAt: new Date().toISOString() };
|
|
151
|
+
if (!(await automaticEnabled(profile))) return { ...status, state: "paused" };
|
|
152
|
+
const readStatus =
|
|
153
|
+
options.readStatus ?? (await import("./agent-runner.mjs")).readAgentRuntimeStatus;
|
|
154
|
+
const current = await readStatus(profile);
|
|
155
|
+
return {
|
|
156
|
+
...status,
|
|
157
|
+
...current,
|
|
158
|
+
state: !current.running ? "offline" : current.ready ? "ready" : "needs_attention",
|
|
159
|
+
ready: Boolean(current.running && current.ready),
|
|
160
|
+
};
|
|
161
|
+
} catch {
|
|
162
|
+
return {
|
|
163
|
+
state: "needs_attention",
|
|
164
|
+
ready: false,
|
|
165
|
+
message:
|
|
166
|
+
"The saved listener binding or current status could not be verified. No startup was attempted.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|