totemlayer 0.1.3 → 0.1.5
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 +36 -11
- package/bin/agent-profile.mjs +466 -0
- package/bin/agent-runner.mjs +609 -0
- package/bin/agent-setup.mjs +659 -0
- package/bin/collaboration-companion.mjs +14 -33
- package/bin/collaboration-listener.mjs +13 -27
- package/bin/device-approval.mjs +93 -0
- package/bin/docker-test-sandbox.mjs +61 -0
- package/bin/local-mcp.mjs +1 -1
- package/bin/local-model-provider.mjs +29 -0
- package/bin/mcp-install.mjs +257 -14
- package/bin/mcp.mjs +1 -1
- package/bin/mission-agent-service.mjs +441 -0
- package/bin/mission-agent-session.mjs +188 -0
- package/bin/mission-supervisor.mjs +166 -0
- package/bin/mission-worker.mjs +3383 -0
- package/bin/network-runtime.mjs +86 -27
- package/bin/network.mjs +96 -5
- package/bin/platform-client.mjs +84 -0
- package/bin/sdk/action-types.js +1 -0
- package/bin/sdk/actions.js +84 -0
- package/bin/sdk/index.js +75 -0
- package/bin/sdk/request-options.js +36 -0
- package/bin/sdk/transport.js +8 -3
- package/bin/totem.mjs +23 -2
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
|
|
9
9
|
import { listenerStateDirectory } from "./collaboration-listener.mjs";
|
|
10
|
+
import { confirmOwnerOnDevice } from "./device-approval.mjs";
|
|
11
|
+
import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
|
|
10
12
|
import { inspectLocalRepository } from "./repository.mjs";
|
|
11
13
|
|
|
12
14
|
const runFile = promisify(execFile);
|
|
@@ -42,33 +44,16 @@ async function sourceCheckout(root) {
|
|
|
42
44
|
return { ...repository, remote, source };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
async function confirmOnMac({ peerAddress, provider, repository }) {
|
|
46
|
-
if (process.platform !== "darwin")
|
|
47
|
-
throw new Error("The no-terminal approval dialog currently supports macOS only.");
|
|
48
|
-
const message = `Enable ${provider} to handle incoming coding tasks from ${peerAddress} in ${repository}? Totem will make an isolated checkout. Only allow a peer you trust. No merges or deployments are authorized.`;
|
|
49
|
-
try {
|
|
50
|
-
const { stdout } = await runFile(
|
|
51
|
-
"osascript",
|
|
52
|
-
[
|
|
53
|
-
"-e",
|
|
54
|
-
"on run argv",
|
|
55
|
-
"-e",
|
|
56
|
-
'display dialog (item 1 of argv) buttons {"Cancel", "Enable"} default button "Cancel" with title "Totem collaboration"',
|
|
57
|
-
"-e",
|
|
58
|
-
"end run",
|
|
59
|
-
message,
|
|
60
|
-
],
|
|
61
|
-
{ timeout: 120_000, encoding: "utf8" },
|
|
62
|
-
);
|
|
63
|
-
return stdout.includes("button returned:Enable");
|
|
64
|
-
} catch {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
47
|
async function checkProvider(provider) {
|
|
48
|
+
const invocation =
|
|
49
|
+
process.platform === "win32"
|
|
50
|
+
? installedClientInvocation(provider, ["--version"])
|
|
51
|
+
: { command: provider, args: ["--version"] };
|
|
70
52
|
try {
|
|
71
|
-
await runFile(
|
|
53
|
+
await runFile(invocation.command, invocation.args, {
|
|
54
|
+
timeout: 10_000,
|
|
55
|
+
maxBuffer: 16 * 1024,
|
|
56
|
+
});
|
|
72
57
|
} catch {
|
|
73
58
|
throw new Error(
|
|
74
59
|
`${provider} is not available to Totem on this computer. Install and sign in to it first.`,
|
|
@@ -231,7 +216,7 @@ export async function previewManagedCollaboration(options) {
|
|
|
231
216
|
baseCommit: repository.headCommit,
|
|
232
217
|
provider: options.provider,
|
|
233
218
|
effect:
|
|
234
|
-
"After
|
|
219
|
+
"After on-device owner approval, Totem creates a private clean clone and keeps a local listener running for only this peer and repository. Incoming tasks may start this owner's AI client. No merge or deployment authority is granted.",
|
|
235
220
|
};
|
|
236
221
|
}
|
|
237
222
|
|
|
@@ -242,7 +227,7 @@ export async function startManagedCollaboration(options) {
|
|
|
242
227
|
await (options.checkProvider ?? checkProvider)(options.provider);
|
|
243
228
|
const previous = await readJson(paths.status);
|
|
244
229
|
if (running(previous)) return managedCollaborationStatus(options);
|
|
245
|
-
const approved = await (options.confirmOwner ??
|
|
230
|
+
const approved = await (options.confirmOwner ?? confirmOwnerOnDevice)({
|
|
246
231
|
peerAddress: options.peerAddress,
|
|
247
232
|
provider: options.provider,
|
|
248
233
|
repository: repository.repository,
|
|
@@ -279,14 +264,10 @@ export async function startManagedCollaboration(options) {
|
|
|
279
264
|
cwd: paths.checkout,
|
|
280
265
|
detached: true,
|
|
281
266
|
stdio: "ignore",
|
|
282
|
-
env: {
|
|
283
|
-
PATH: process.env.PATH,
|
|
284
|
-
HOME: process.env.HOME,
|
|
285
|
-
CODEX_HOME: process.env.CODEX_HOME,
|
|
286
|
-
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
|
267
|
+
env: localClientEnvironment(process.env, {
|
|
287
268
|
TOTEM_BASE_URL: options.baseUrl,
|
|
288
269
|
TOTEM_NETWORK_API_KEY_FILE: resolve(options.keyFile),
|
|
289
|
-
},
|
|
270
|
+
}),
|
|
290
271
|
});
|
|
291
272
|
let spawnError;
|
|
292
273
|
child.once?.("error", (error) => {
|
|
@@ -6,6 +6,7 @@ import { join, resolve } from "node:path";
|
|
|
6
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
7
|
|
|
8
8
|
import { inspectLocalRepository } from "./repository.mjs";
|
|
9
|
+
import { localClientEnvironment, streamingClientInvocation } from "./platform-client.mjs";
|
|
9
10
|
|
|
10
11
|
const MAX_RUNS_PER_TASK = 16;
|
|
11
12
|
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
@@ -78,13 +79,14 @@ export function collaborationPrompt(task, message, peerAddress) {
|
|
|
78
79
|
return prompt;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function runnerCommand(provider, sessionId) {
|
|
82
|
+
function runnerCommand(provider, sessionId, platform = process.platform) {
|
|
83
|
+
let invocation;
|
|
82
84
|
if (provider === "codex")
|
|
83
|
-
|
|
85
|
+
invocation = sessionId
|
|
84
86
|
? { command: "codex", args: ["exec", "resume", "--json", sessionId, "-"] }
|
|
85
87
|
: { command: "codex", args: ["exec", "--sandbox", "workspace-write", "--json", "-"] };
|
|
86
|
-
if (provider === "claude")
|
|
87
|
-
|
|
88
|
+
else if (provider === "claude")
|
|
89
|
+
invocation = {
|
|
88
90
|
command: "claude",
|
|
89
91
|
args: [
|
|
90
92
|
"-p",
|
|
@@ -97,31 +99,14 @@ function runnerCommand(provider, sessionId) {
|
|
|
97
99
|
...(sessionId ? ["--resume", sessionId] : []),
|
|
98
100
|
],
|
|
99
101
|
};
|
|
100
|
-
throw new Error("Choose --provider codex or --provider claude.");
|
|
102
|
+
else throw new Error("Choose --provider codex or --provider claude.");
|
|
103
|
+
return platform === "win32"
|
|
104
|
+
? streamingClientInvocation(invocation.command, invocation.args)
|
|
105
|
+
: invocation;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
function runnerEnvironment(repositoryRoot, environment = process.env) {
|
|
104
|
-
|
|
105
|
-
"PATH",
|
|
106
|
-
"HOME",
|
|
107
|
-
"USER",
|
|
108
|
-
"TMPDIR",
|
|
109
|
-
"LANG",
|
|
110
|
-
"LC_ALL",
|
|
111
|
-
"CODEX_HOME",
|
|
112
|
-
"CLAUDE_CONFIG_DIR",
|
|
113
|
-
"XDG_CONFIG_HOME",
|
|
114
|
-
"XDG_DATA_HOME",
|
|
115
|
-
"XDG_RUNTIME_DIR",
|
|
116
|
-
"TOTEM_NETWORK_API_KEY_FILE",
|
|
117
|
-
"TOTEM_BASE_URL",
|
|
118
|
-
];
|
|
119
|
-
return {
|
|
120
|
-
...Object.fromEntries(
|
|
121
|
-
allowed.filter((key) => environment[key]).map((key) => [key, environment[key]]),
|
|
122
|
-
),
|
|
123
|
-
TOTEM_REPOSITORY_ROOT: repositoryRoot,
|
|
124
|
-
};
|
|
109
|
+
return localClientEnvironment(environment, { TOTEM_REPOSITORY_ROOT: repositoryRoot });
|
|
125
110
|
}
|
|
126
111
|
|
|
127
112
|
export async function runLocalCollaborationAgent({
|
|
@@ -131,9 +116,10 @@ export async function runLocalCollaborationAgent({
|
|
|
131
116
|
sessionId,
|
|
132
117
|
signal,
|
|
133
118
|
timeoutMs = DEFAULT_RUN_TIMEOUT_MS,
|
|
119
|
+
platform = process.platform,
|
|
134
120
|
spawnProcess = spawn,
|
|
135
121
|
}) {
|
|
136
|
-
const { command, args } = runnerCommand(provider, sessionId);
|
|
122
|
+
const { command, args } = runnerCommand(provider, sessionId, platform);
|
|
137
123
|
return new Promise((resolveRun, rejectRun) => {
|
|
138
124
|
const child = spawnProcess(command, args, {
|
|
139
125
|
cwd: repositoryRoot,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import { powershellInvocation } from "./platform-client.mjs";
|
|
5
|
+
|
|
6
|
+
const defaultRunFile = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
function approvalMessage({ peerAddress, provider, repository }) {
|
|
9
|
+
return `Allow ${provider} to handle incoming coding tasks from ${peerAddress} in ${repository}? Totem will use an isolated checkout. The peer can start your local AI client with its existing tools and permissions. Only allow a peer you trust. No merges or deployments are authorized.`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function confirmOwnerOnDevice(
|
|
13
|
+
request,
|
|
14
|
+
{ platform = process.platform, environment = process.env, runFile = defaultRunFile } = {},
|
|
15
|
+
) {
|
|
16
|
+
const message = approvalMessage(request);
|
|
17
|
+
if (platform === "darwin") {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await runFile(
|
|
20
|
+
"osascript",
|
|
21
|
+
[
|
|
22
|
+
"-e",
|
|
23
|
+
"on run argv",
|
|
24
|
+
"-e",
|
|
25
|
+
'display dialog (item 1 of argv) buttons {"Cancel", "Enable"} default button "Cancel" with title "Totem collaboration"',
|
|
26
|
+
"-e",
|
|
27
|
+
"end run",
|
|
28
|
+
message,
|
|
29
|
+
],
|
|
30
|
+
{ timeout: 120_000, encoding: "utf8" },
|
|
31
|
+
);
|
|
32
|
+
return stdout.includes("button returned:Enable");
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error?.code === 1) return false;
|
|
35
|
+
throw new Error("Totem could not show the macOS owner approval dialog.", { cause: error });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (platform === "win32") {
|
|
39
|
+
const encodedMessage = Buffer.from(message, "utf8").toString("base64");
|
|
40
|
+
const script = `$ErrorActionPreference = 'Stop'\nAdd-Type -AssemblyName System.Windows.Forms\n$message = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedMessage}'))\n$choice = [System.Windows.Forms.MessageBox]::Show($message, 'Totem collaboration', [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning, [System.Windows.Forms.MessageBoxDefaultButton]::Button2)\nif ($choice -eq [System.Windows.Forms.DialogResult]::Yes) { [Console]::WriteLine('ENABLE') } else { [Console]::WriteLine('CANCEL') }`;
|
|
41
|
+
const invocation = powershellInvocation(script, environment);
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = await runFile(invocation.command, ["-Sta", ...invocation.args], {
|
|
44
|
+
timeout: 120_000,
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
});
|
|
47
|
+
if (stdout.trim() === "ENABLE") return true;
|
|
48
|
+
if (stdout.trim() === "CANCEL") return false;
|
|
49
|
+
throw new Error("The Windows approval dialog did not return a decision.");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error("Totem could not show the Windows owner approval dialog.", { cause: error });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (platform === "linux") {
|
|
55
|
+
if (!environment.DISPLAY && !environment.WAYLAND_DISPLAY)
|
|
56
|
+
throw new Error(
|
|
57
|
+
"Automatic approval needs a Linux desktop session. In a headless session, use the explicit `totem network work` listener instead.",
|
|
58
|
+
);
|
|
59
|
+
for (const [command, args] of [
|
|
60
|
+
[
|
|
61
|
+
"zenity",
|
|
62
|
+
[
|
|
63
|
+
"--question",
|
|
64
|
+
"--title=Totem collaboration",
|
|
65
|
+
`--text=${message}`,
|
|
66
|
+
"--no-markup",
|
|
67
|
+
"--ok-label=Enable",
|
|
68
|
+
"--cancel-label=Cancel",
|
|
69
|
+
],
|
|
70
|
+
],
|
|
71
|
+
["kdialog", ["--title", "Totem collaboration", "--warningyesno", message]],
|
|
72
|
+
]) {
|
|
73
|
+
try {
|
|
74
|
+
await runFile(command, args, { timeout: 120_000, encoding: "utf8" });
|
|
75
|
+
return true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error?.code === "ENOENT") continue;
|
|
78
|
+
if (
|
|
79
|
+
error?.code === 1 &&
|
|
80
|
+
!/cannot open display|failed to open display/i.test(error.stderr ?? "")
|
|
81
|
+
)
|
|
82
|
+
return false;
|
|
83
|
+
throw new Error("Totem could not show the Linux owner approval dialog.", {
|
|
84
|
+
cause: error,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Install zenity or kdialog to approve collaboration from a Linux chat, or use the explicit `totem network work` listener.",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
throw new Error("Totem does not have an owner approval dialog for this platform.");
|
|
93
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export const NODE_TEST_IMAGE =
|
|
5
|
+
"node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43";
|
|
6
|
+
|
|
7
|
+
/** Only an isolated repository is mounted. No credentials or Docker socket enter tests. */
|
|
8
|
+
export async function runDockerNodeTests(checkout, signal, runFile) {
|
|
9
|
+
const root = await realpath(checkout);
|
|
10
|
+
if (root.includes(",") || /[\r\n]/.test(root)) throw new Error("Unsupported sandbox mount path.");
|
|
11
|
+
const name = `totem-node-test-${randomUUID()}`;
|
|
12
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 1000;
|
|
13
|
+
const gid = typeof process.getgid === "function" ? process.getgid() : 1000;
|
|
14
|
+
try {
|
|
15
|
+
const result = await runFile(
|
|
16
|
+
"docker",
|
|
17
|
+
[
|
|
18
|
+
"run",
|
|
19
|
+
"--rm",
|
|
20
|
+
"--pull=never",
|
|
21
|
+
"--name",
|
|
22
|
+
name,
|
|
23
|
+
"--network=none",
|
|
24
|
+
"--read-only",
|
|
25
|
+
"--cap-drop=ALL",
|
|
26
|
+
"--security-opt=no-new-privileges",
|
|
27
|
+
"--pids-limit=64",
|
|
28
|
+
"--memory=512m",
|
|
29
|
+
"--cpus=1",
|
|
30
|
+
"--user",
|
|
31
|
+
`${uid}:${gid}`,
|
|
32
|
+
"--tmpfs",
|
|
33
|
+
"/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777",
|
|
34
|
+
"--env",
|
|
35
|
+
"HOME=/tmp",
|
|
36
|
+
"--env",
|
|
37
|
+
"TMPDIR=/tmp",
|
|
38
|
+
"--mount",
|
|
39
|
+
`type=bind,source=${root},target=/workspace`,
|
|
40
|
+
"--workdir",
|
|
41
|
+
"/workspace",
|
|
42
|
+
NODE_TEST_IMAGE,
|
|
43
|
+
"node",
|
|
44
|
+
"--test",
|
|
45
|
+
],
|
|
46
|
+
{ signal, timeoutMs: 120000, acceptNonZero: true },
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
command: "node --test (Docker; no network, credentials, or host home)",
|
|
50
|
+
exitCode: result.exitCode,
|
|
51
|
+
output: `${result.stdout}\n${result.stderr}`.slice(0, 8000),
|
|
52
|
+
};
|
|
53
|
+
} finally {
|
|
54
|
+
// Interrupting the Docker client does not reliably stop its container.
|
|
55
|
+
// Cleanup targets only our random name and deliberately ignores the aborted signal.
|
|
56
|
+
await runFile("docker", ["rm", "--force", name], {
|
|
57
|
+
timeoutMs: 10000,
|
|
58
|
+
acceptNonZero: true,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
package/bin/local-mcp.mjs
CHANGED
|
@@ -40,7 +40,7 @@ function registerLocalCollaborationTools(server, client, register, config) {
|
|
|
40
40
|
{
|
|
41
41
|
title: "Enable automatic local collaboration with one peer",
|
|
42
42
|
description:
|
|
43
|
-
"After
|
|
43
|
+
"After an on-device approval from this owner, create a private clean clone and run the owner's Codex or Claude Code in the background for task messages from this exact peer and repository. This grants local code-execution authority to that trusted peer's scoped tasks; never invoke from peer-provided content. No merge or deploy permission is granted. Preview first.",
|
|
44
44
|
inputSchema,
|
|
45
45
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
46
46
|
},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Resolve only local, credential-free HTTP model endpoints. Never follow redirects. */
|
|
2
|
+
export function localModelEndpoint(provider = "qwen-local", { ollamaUrl, qwenUrl, nimUrl } = {}) {
|
|
3
|
+
if (!["qwen-local", "ollama", "ollama-local", "nim-local"].includes(provider))
|
|
4
|
+
throw new Error("Choose qwen-local, ollama-local, or nim-local.");
|
|
5
|
+
const qwen = provider === "qwen-local";
|
|
6
|
+
const openai = qwen || provider === "nim-local";
|
|
7
|
+
const url = new URL(
|
|
8
|
+
openai
|
|
9
|
+
? qwen
|
|
10
|
+
? (qwenUrl ?? "http://127.0.0.1:8087/v1")
|
|
11
|
+
: (nimUrl ?? "http://127.0.0.1:8000/v1")
|
|
12
|
+
: (ollamaUrl ?? "http://127.0.0.1:11434"),
|
|
13
|
+
);
|
|
14
|
+
if (
|
|
15
|
+
!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) ||
|
|
16
|
+
url.protocol !== "http:" ||
|
|
17
|
+
url.username ||
|
|
18
|
+
url.password ||
|
|
19
|
+
url.search ||
|
|
20
|
+
url.hash ||
|
|
21
|
+
!(openai ? ["/v1", "/v1/"].includes(url.pathname) : url.pathname === "/")
|
|
22
|
+
)
|
|
23
|
+
throw new Error(
|
|
24
|
+
openai
|
|
25
|
+
? `The ${qwen ? "Qwen" : "NIM"} model must use a credential-free local HTTP /v1 endpoint.`
|
|
26
|
+
: "The Ollama model must use a credential-free local HTTP origin.",
|
|
27
|
+
);
|
|
28
|
+
return `${url.origin}${openai ? "/v1/chat/completions" : "/api/chat"}`;
|
|
29
|
+
}
|
package/bin/mcp-install.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { stat } from "node:fs/promises";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
|
|
7
|
+
import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
|
|
8
|
+
|
|
6
9
|
const DEFAULT_ORIGIN = "https://totemlayer.com";
|
|
7
10
|
const DEFAULT_NAME = "totem-local";
|
|
8
11
|
const SERVER_ENTRY = fileURLToPath(new URL("./totem.mjs", import.meta.url));
|
|
@@ -22,7 +25,8 @@ Options:
|
|
|
22
25
|
This installs a user-level local stdio MCP connection. It never puts the key
|
|
23
26
|
value in a command, client config, or repository. It will not overwrite an
|
|
24
27
|
existing MCP server. Restart the AI app afterward and ask for totem_get_identity.
|
|
25
|
-
On macOS/Linux the key file must be mode 0600.
|
|
28
|
+
On macOS/Linux the key file must be mode 0600. On Windows, protect it with
|
|
29
|
+
your user's filesystem ACL. The package must remain installed.
|
|
26
30
|
`;
|
|
27
31
|
|
|
28
32
|
function fail(message) {
|
|
@@ -76,16 +80,20 @@ function parseInstallArgs(args, environment) {
|
|
|
76
80
|
return { client, keyFile: resolve(keyFile), name, baseUrl: url.origin };
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
async function validateKeyFile(path) {
|
|
83
|
+
async function validateKeyFile(path, platform) {
|
|
80
84
|
const info = await stat(path);
|
|
81
85
|
if (!info.isFile() || info.size === 0 || info.size > 8 * 1024)
|
|
82
86
|
fail("The Totem agent key must be a nonempty regular file no larger than 8 KiB.");
|
|
83
|
-
if (
|
|
87
|
+
if (platform !== "win32" && (info.mode & 0o077) !== 0)
|
|
84
88
|
fail("The Totem agent key file must be private (mode 0600). Fix its permissions and retry.");
|
|
85
89
|
}
|
|
86
90
|
|
|
87
|
-
function runClient(command, args, environment) {
|
|
88
|
-
const
|
|
91
|
+
function runClient(command, args, environment, runtime) {
|
|
92
|
+
const invocation =
|
|
93
|
+
runtime.platform === "win32"
|
|
94
|
+
? installedClientInvocation(command, args, environment)
|
|
95
|
+
: { command, args };
|
|
96
|
+
const result = runtime.spawnProcess(invocation.command, invocation.args, {
|
|
89
97
|
encoding: "utf8",
|
|
90
98
|
env: environment,
|
|
91
99
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -99,17 +107,14 @@ function runClient(command, args, environment) {
|
|
|
99
107
|
return result;
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
export async function installTotemMcp(args, environment = process.env) {
|
|
110
|
+
export async function installTotemMcp(args, environment = process.env, runtime = {}) {
|
|
103
111
|
const options = parseInstallArgs(args, environment);
|
|
104
112
|
if (options.help) return MCP_INSTALL_HELP;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"Automatic MCP setup is not yet supported on Windows. Use the manual stdio MCP configuration in the Totem CLI README.",
|
|
108
|
-
);
|
|
109
|
-
await validateKeyFile(options.keyFile);
|
|
113
|
+
runtime = { platform: process.platform, spawnProcess: spawnSync, ...runtime };
|
|
114
|
+
await validateKeyFile(options.keyFile, runtime.platform);
|
|
110
115
|
|
|
111
116
|
const command = options.client;
|
|
112
|
-
const existing = runClient(command, ["mcp", "get", options.name], environment);
|
|
117
|
+
const existing = runClient(command, ["mcp", "get", options.name], environment, runtime);
|
|
113
118
|
if (existing.status === 0)
|
|
114
119
|
fail(
|
|
115
120
|
`${options.name} already exists in ${command}. No configuration was changed. Choose another --name or inspect the existing connection first.`,
|
|
@@ -141,15 +146,253 @@ export async function installTotemMcp(args, environment = process.env) {
|
|
|
141
146
|
SERVER_ENTRY,
|
|
142
147
|
"mcp",
|
|
143
148
|
];
|
|
144
|
-
const added = runClient(command, addArgs, environment);
|
|
149
|
+
const added = runClient(command, addArgs, environment, runtime);
|
|
145
150
|
if (added.status !== 0)
|
|
146
151
|
fail(
|
|
147
152
|
`${command} could not add ${options.name}. No Totem key value was sent. Check the client's MCP setup and retry.`,
|
|
148
153
|
);
|
|
149
|
-
const verified = runClient(command, ["mcp", "get", options.name], environment);
|
|
154
|
+
const verified = runClient(command, ["mcp", "get", options.name], environment, runtime);
|
|
150
155
|
if (verified.status !== 0)
|
|
151
156
|
fail(
|
|
152
157
|
`${command} reported an install, but ${options.name} could not be verified. Inspect its MCP settings before using it.`,
|
|
153
158
|
);
|
|
154
159
|
return `Connected ${options.name} to ${command} at ${options.baseUrl}. Restart ${command}, then ask it to call totem_get_identity. The Totem CLI package must remain installed.\n`;
|
|
155
160
|
}
|
|
161
|
+
|
|
162
|
+
/** Guided setup owns a distinct entry per profile; the manual installer retains its default. */
|
|
163
|
+
export function managedMcpName(profileName) {
|
|
164
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(profileName) || profileName.length > 64)
|
|
165
|
+
fail("Choose a valid saved profile before configuring MCP.");
|
|
166
|
+
const plain = `totem-${profileName}`;
|
|
167
|
+
return plain.length <= 64
|
|
168
|
+
? plain
|
|
169
|
+
: `totem_${profileName.slice(0, 24)}_${createHash("sha256").update(profileName).digest("hex").slice(0, 32)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function managedArgs(profile, name) {
|
|
173
|
+
const variables = [
|
|
174
|
+
"--env",
|
|
175
|
+
`TOTEM_BASE_URL=${profile.baseUrl}`,
|
|
176
|
+
"--env",
|
|
177
|
+
`TOTEM_NETWORK_API_KEY_FILE=${profile.credentialFile}`,
|
|
178
|
+
];
|
|
179
|
+
return profile.client === "codex"
|
|
180
|
+
? ["mcp", "add", name, ...variables, "--", process.execPath, SERVER_ENTRY, "mcp"]
|
|
181
|
+
: [
|
|
182
|
+
"mcp",
|
|
183
|
+
"add",
|
|
184
|
+
...variables,
|
|
185
|
+
"--scope",
|
|
186
|
+
"user",
|
|
187
|
+
"--transport",
|
|
188
|
+
"stdio",
|
|
189
|
+
name,
|
|
190
|
+
"--",
|
|
191
|
+
process.execPath,
|
|
192
|
+
SERVER_ENTRY,
|
|
193
|
+
"mcp",
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function matchesManagedEntry(entry, profile, name) {
|
|
198
|
+
const transport = entry?.transport;
|
|
199
|
+
const expectedEnvironment = {
|
|
200
|
+
TOTEM_BASE_URL: profile.baseUrl,
|
|
201
|
+
TOTEM_NETWORK_API_KEY_FILE: profile.credentialFile,
|
|
202
|
+
};
|
|
203
|
+
return (
|
|
204
|
+
entry?.name === name &&
|
|
205
|
+
entry.enabled !== false &&
|
|
206
|
+
!entry.disabled_reason &&
|
|
207
|
+
["enabled_tools", "disabled_tools", "startup_timeout_sec", "tool_timeout_sec"].every(
|
|
208
|
+
(key) => entry[key] == null,
|
|
209
|
+
) &&
|
|
210
|
+
transport?.type === "stdio" &&
|
|
211
|
+
transport.command === process.execPath &&
|
|
212
|
+
JSON.stringify(transport.args) === JSON.stringify([SERVER_ENTRY, "mcp"]) &&
|
|
213
|
+
transport.cwd == null &&
|
|
214
|
+
(transport.env_vars == null ||
|
|
215
|
+
(Array.isArray(transport.env_vars) && transport.env_vars.length === 0)) &&
|
|
216
|
+
transport.env &&
|
|
217
|
+
Object.keys(transport.env).length === 2 &&
|
|
218
|
+
Object.entries(expectedEnvironment).every(([key, value]) => transport.env[key] === value) &&
|
|
219
|
+
Object.keys(transport).every((key) =>
|
|
220
|
+
["type", "command", "args", "env", "env_vars", "cwd"].includes(key),
|
|
221
|
+
) &&
|
|
222
|
+
Object.keys(entry).every((key) =>
|
|
223
|
+
[
|
|
224
|
+
"name",
|
|
225
|
+
"enabled",
|
|
226
|
+
"disabled_reason",
|
|
227
|
+
"transport",
|
|
228
|
+
"enabled_tools",
|
|
229
|
+
"disabled_tools",
|
|
230
|
+
"startup_timeout_sec",
|
|
231
|
+
"tool_timeout_sec",
|
|
232
|
+
].includes(key),
|
|
233
|
+
)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function managedCall(profile, args, environment, runtime) {
|
|
238
|
+
try {
|
|
239
|
+
const result = runtime.runClient
|
|
240
|
+
? await runtime.runClient(profile.client, args, environment)
|
|
241
|
+
: runClient(profile.client, args, environment, {
|
|
242
|
+
platform: process.platform,
|
|
243
|
+
spawnProcess: spawnSync,
|
|
244
|
+
...runtime,
|
|
245
|
+
});
|
|
246
|
+
return { ...result, status: result.status ?? 0 };
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return { status: 1, stdout: "", stderr: typeof error?.stderr === "string" ? error.stderr : "" };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function inspectEntry(profile, environment, runtime) {
|
|
253
|
+
const name = managedMcpName(profile.profileName);
|
|
254
|
+
const response = await managedCall(
|
|
255
|
+
profile,
|
|
256
|
+
["mcp", "get", name, ...(profile.client === "codex" ? ["--json"] : [])],
|
|
257
|
+
environment,
|
|
258
|
+
runtime,
|
|
259
|
+
);
|
|
260
|
+
if (response.status !== 0)
|
|
261
|
+
return {
|
|
262
|
+
name,
|
|
263
|
+
state: /no mcp server|not found|does not exist/i.test(
|
|
264
|
+
`${response.stdout}\n${response.stderr}`,
|
|
265
|
+
)
|
|
266
|
+
? "not_configured"
|
|
267
|
+
: "unavailable",
|
|
268
|
+
};
|
|
269
|
+
if (profile.client !== "codex") return { name, state: "unverified" };
|
|
270
|
+
try {
|
|
271
|
+
return { name, state: "present", entry: JSON.parse(response.stdout) };
|
|
272
|
+
} catch {
|
|
273
|
+
return { name, state: "unverified" };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function reconnectAdvice(profile, name) {
|
|
278
|
+
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.`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function inspectManagedTotemMcp(profile, environment = process.env, runtime = {}) {
|
|
282
|
+
const inspected = await inspectEntry(profile, localClientEnvironment(environment), runtime);
|
|
283
|
+
if (
|
|
284
|
+
inspected.state === "present" &&
|
|
285
|
+
matchesManagedEntry(inspected.entry, profile, inspected.name)
|
|
286
|
+
)
|
|
287
|
+
return {
|
|
288
|
+
name: inspected.name,
|
|
289
|
+
state: "configured_unverified",
|
|
290
|
+
message: `The ${inspected.name} settings match this profile. Verify client sign-in and tool connectivity in the AI app with totem_get_identity.`,
|
|
291
|
+
};
|
|
292
|
+
if (inspected.state === "not_configured")
|
|
293
|
+
return {
|
|
294
|
+
name: inspected.name,
|
|
295
|
+
state: "not_configured",
|
|
296
|
+
message: `Run totem setup --profile ${profile.profileName} --install-mcp to add this profile's connection.`,
|
|
297
|
+
};
|
|
298
|
+
return {
|
|
299
|
+
name: inspected.name,
|
|
300
|
+
state: "needs_attention",
|
|
301
|
+
message: `${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)}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function installManagedTotemMcp(
|
|
306
|
+
profile,
|
|
307
|
+
previousProfile,
|
|
308
|
+
environment = process.env,
|
|
309
|
+
runtime = {},
|
|
310
|
+
) {
|
|
311
|
+
if (!["codex", "claude"].includes(profile.client))
|
|
312
|
+
fail("Choose an existing AI app for MCP setup.");
|
|
313
|
+
try {
|
|
314
|
+
await validateKeyFile(profile.credentialFile, runtime.platform ?? process.platform);
|
|
315
|
+
} catch {
|
|
316
|
+
return {
|
|
317
|
+
name: managedMcpName(profile.profileName),
|
|
318
|
+
state: "needs_attention",
|
|
319
|
+
message:
|
|
320
|
+
"The saved private key file is unavailable or unsafe. Check this profile before changing MCP settings.",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const safeEnvironment = localClientEnvironment(environment);
|
|
324
|
+
const inspected = await inspectEntry(profile, safeEnvironment, runtime);
|
|
325
|
+
const { name } = inspected;
|
|
326
|
+
const guidance = reconnectAdvice(profile, name);
|
|
327
|
+
if (inspected.state === "not_configured") {
|
|
328
|
+
try {
|
|
329
|
+
await (runtime.install ?? installTotemMcp)(
|
|
330
|
+
[
|
|
331
|
+
profile.client,
|
|
332
|
+
"--key-file",
|
|
333
|
+
profile.credentialFile,
|
|
334
|
+
"--base-url",
|
|
335
|
+
profile.baseUrl,
|
|
336
|
+
"--name",
|
|
337
|
+
name,
|
|
338
|
+
],
|
|
339
|
+
safeEnvironment,
|
|
340
|
+
runtime,
|
|
341
|
+
);
|
|
342
|
+
if (profile.client === "codex") {
|
|
343
|
+
const after = await inspectEntry(profile, safeEnvironment, runtime);
|
|
344
|
+
if (after.state !== "present" || !matchesManagedEntry(after.entry, profile, name))
|
|
345
|
+
return {
|
|
346
|
+
name,
|
|
347
|
+
state: "needs_attention",
|
|
348
|
+
message: `The new MCP settings could not be verified against this profile. ${guidance}`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
name,
|
|
353
|
+
state: "installed_unverified",
|
|
354
|
+
message: `${name} settings were saved. Restart the AI app and call totem_get_identity to verify its connection.`,
|
|
355
|
+
};
|
|
356
|
+
} catch {
|
|
357
|
+
return {
|
|
358
|
+
name,
|
|
359
|
+
state: "needs_attention",
|
|
360
|
+
message: `The connection is saved, but MCP installation could not be completed. ${guidance}`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (inspected.state === "present" && matchesManagedEntry(inspected.entry, profile, name))
|
|
365
|
+
return {
|
|
366
|
+
name,
|
|
367
|
+
state: "configured_unverified",
|
|
368
|
+
message: `${name} already matches this profile; nothing changed. Verify its identity in your AI app with totem_get_identity.`,
|
|
369
|
+
};
|
|
370
|
+
const ownsPrevious =
|
|
371
|
+
previousProfile?.profileName === profile.profileName &&
|
|
372
|
+
previousProfile.client === profile.client &&
|
|
373
|
+
inspected.state === "present" &&
|
|
374
|
+
matchesManagedEntry(inspected.entry, previousProfile, name);
|
|
375
|
+
if (!ownsPrevious)
|
|
376
|
+
return {
|
|
377
|
+
name,
|
|
378
|
+
state: "needs_attention",
|
|
379
|
+
message: `The existing entry was preserved because its ownership and settings could not be verified. ${guidance}`,
|
|
380
|
+
};
|
|
381
|
+
// Codex add can update this exact named entry. No remove occurs: unsupported client
|
|
382
|
+
// versions fail safely, and an exact read-back is required before reporting an update.
|
|
383
|
+
const updated = await managedCall(profile, managedArgs(profile, name), safeEnvironment, runtime);
|
|
384
|
+
if (updated.status === 0) {
|
|
385
|
+
const after = await inspectEntry(profile, safeEnvironment, runtime);
|
|
386
|
+
if (after.state === "present" && matchesManagedEntry(after.entry, profile, name))
|
|
387
|
+
return {
|
|
388
|
+
name,
|
|
389
|
+
state: "updated_unverified",
|
|
390
|
+
message: `${name} now references this profile's current private key. Restart the AI app and verify it with totem_get_identity.`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
name,
|
|
395
|
+
state: "needs_attention",
|
|
396
|
+
message: `The managed MCP update could not be verified. ${guidance}`,
|
|
397
|
+
};
|
|
398
|
+
}
|