totemlayer 0.1.2 → 0.1.3
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 +110 -2
- package/bin/collaboration-companion.mjs +328 -0
- package/bin/collaboration-listener.mjs +425 -0
- package/bin/local-mcp.mjs +97 -0
- package/bin/managed-collaboration.mjs +94 -0
- package/bin/mcp-install.mjs +155 -0
- package/bin/mcp.mjs +777 -0
- package/bin/network-runtime.mjs +685 -0
- package/bin/network.mjs +323 -0
- package/bin/presenter-mcp.mjs +238 -0
- package/bin/repository.d.mts +27 -0
- package/bin/repository.mjs +135 -0
- package/bin/sdk/errors.js +54 -0
- package/bin/sdk/index.js +307 -0
- package/bin/sdk/transport.js +197 -0
- package/bin/sdk/types.js +1 -0
- package/bin/totem.mjs +37 -1
- package/package.json +10 -2
- package/scripts/prepare.mjs +15 -0
package/bin/network.mjs
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { parseArgs, stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { Totem } from "./sdk/index.js";
|
|
4
|
+
import { runCollaborationListener } from "./collaboration-listener.mjs";
|
|
5
|
+
import { runRuntime } from "./network-runtime.mjs";
|
|
6
|
+
|
|
7
|
+
export const NETWORK_HELP = `Totem agent messaging
|
|
8
|
+
|
|
9
|
+
One-time setup in the Totem app:
|
|
10
|
+
Open Agents in the Totem app, register an agent, and create an API key.
|
|
11
|
+
Store its key in a private file (0600), then set TOTEM_NETWORK_API_KEY_FILE.
|
|
12
|
+
Alternatively set TOTEM_NETWORK_API_KEY. Never pass keys as command arguments.
|
|
13
|
+
Set TOTEM_BASE_URL to your Totem origin; HTTP is allowed only on loopback.
|
|
14
|
+
|
|
15
|
+
Local adoption:
|
|
16
|
+
export TOTEM_BASE_URL=http://localhost:3413
|
|
17
|
+
totem network me --json
|
|
18
|
+
totem network work --allow-agent peer@workspace.totem --provider codex
|
|
19
|
+
# Or use --provider claude after configuring Claude's Totem MCP server.
|
|
20
|
+
totem network run --provider ollama --model qwen3.5:9b
|
|
21
|
+
Open Chat in Totem and send your agent a message.
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
totem network me
|
|
25
|
+
totem network send --to <address> --text <text> [--conversation-hops <2-8>]
|
|
26
|
+
[--idempotency-key <key>]
|
|
27
|
+
totem network inbox [--all] [--cursor <cursor>] [--limit <1-100>]
|
|
28
|
+
totem network reply <message-id> --text <text> [--idempotency-key <key>]
|
|
29
|
+
totem network ack <message-id>
|
|
30
|
+
totem network listen [--once] [--poll-ms <milliseconds>]
|
|
31
|
+
totem network work --allow-agent <exact-peer-address> --provider codex|claude
|
|
32
|
+
[--repo-root <checkout>] [--check] [--once] [--poll-ms 5000]
|
|
33
|
+
totem network chat list [--cursor <cursor>] [--limit <1-100>]
|
|
34
|
+
totem network chat show <chat-id>
|
|
35
|
+
totem network chat updates <chat-id> --text <text> [--idempotency-key <key>]
|
|
36
|
+
totem network run --provider ollama --model <installed-model>
|
|
37
|
+
[--allow-agent <exact-address>]... [--auto-reply-hops <0-8>]
|
|
38
|
+
[--system-file <path>] [--once]
|
|
39
|
+
[--poll-ms 1000] [--model-timeout-ms 120000] [--reply-timeout-ms 90000]
|
|
40
|
+
[--ollama-url http://127.0.0.1:11434] [--state-dir <private-directory>]
|
|
41
|
+
|
|
42
|
+
All commands accept --json for machine-readable output and --base-url <origin>.
|
|
43
|
+
--text-file <path> can replace --text (UTF-8, at most 32 KiB).
|
|
44
|
+
listen prints incoming messages without replying or acknowledging; acknowledge
|
|
45
|
+
only after your own agent has handled them. Stream output is newline-delimited JSON.
|
|
46
|
+
work is an opt-in local listener for one peer and one Git checkout. It runs the
|
|
47
|
+
owner's installed Codex or Claude CLI on matching task messages, resumes that
|
|
48
|
+
client's task for later peer messages, and journals each message before launch.
|
|
49
|
+
It never silently retries an uncertain or failed model run. Configure Totem MCP
|
|
50
|
+
in that AI app first; neither a Totem key nor a peer message grants Git access.
|
|
51
|
+
--check validates the agent, peer, and checkout without processing messages.
|
|
52
|
+
run executes a real local model for human commands and new A2A messages. It renews
|
|
53
|
+
leases and heartbeats, and acknowledges only handled messages. By default it
|
|
54
|
+
ignores reply loops. A send with --conversation-hops and two runtimes with
|
|
55
|
+
--auto-reply-hops can continue a bounded exchange with exact allowed peers.
|
|
56
|
+
In human Chat, /ask <allowed-address> <message> routes directly and returns the
|
|
57
|
+
actual peer reply without relying on model tool selection. Both /ask and the
|
|
58
|
+
optional ask_agent model tool are restricted to --allow-agent addresses.
|
|
59
|
+
There are no shell, payment, or arbitrary tool actions. Model/DNS/email services
|
|
60
|
+
are not provided by this command. Ctrl+C stops requests; unfinished leases expire.
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
function numberOption(value, fallback, min, max, label) {
|
|
64
|
+
const result = value === undefined ? fallback : Number(value);
|
|
65
|
+
if (!Number.isInteger(result) || result < min || result > max)
|
|
66
|
+
throw Object.assign(new Error(`${label} must be an integer from ${min} to ${max}.`), {
|
|
67
|
+
code: "INVALID_ARGUMENT",
|
|
68
|
+
});
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseNetworkArgs(args) {
|
|
73
|
+
const parsed = parseArgs({
|
|
74
|
+
args,
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
strict: true,
|
|
77
|
+
options: {
|
|
78
|
+
help: { type: "boolean", short: "h" },
|
|
79
|
+
json: { type: "boolean" },
|
|
80
|
+
"base-url": { type: "string" },
|
|
81
|
+
to: { type: "string" },
|
|
82
|
+
text: { type: "string" },
|
|
83
|
+
"text-file": { type: "string" },
|
|
84
|
+
"idempotency-key": { type: "string" },
|
|
85
|
+
cursor: { type: "string" },
|
|
86
|
+
limit: { type: "string" },
|
|
87
|
+
all: { type: "boolean" },
|
|
88
|
+
once: { type: "boolean" },
|
|
89
|
+
check: { type: "boolean" },
|
|
90
|
+
"poll-ms": { type: "string" },
|
|
91
|
+
provider: { type: "string" },
|
|
92
|
+
model: { type: "string" },
|
|
93
|
+
"ollama-url": { type: "string" },
|
|
94
|
+
"allow-agent": { type: "string", multiple: true },
|
|
95
|
+
"conversation-hops": { type: "string" },
|
|
96
|
+
"auto-reply-hops": { type: "string" },
|
|
97
|
+
"system-file": { type: "string" },
|
|
98
|
+
"model-timeout-ms": { type: "string" },
|
|
99
|
+
"reply-timeout-ms": { type: "string" },
|
|
100
|
+
"state-dir": { type: "string" },
|
|
101
|
+
"repo-root": { type: "string" },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return { ...parsed.values, positionals: parsed.positionals };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function textInput(options) {
|
|
108
|
+
if (options.text !== undefined && options["text-file"] !== undefined)
|
|
109
|
+
throw new Error("Choose --text or --text-file, not both.");
|
|
110
|
+
let text = options.text;
|
|
111
|
+
if (options["text-file"]) {
|
|
112
|
+
if ((await stat(options["text-file"])).size > 32 * 1024)
|
|
113
|
+
throw new Error("Text files must not exceed 32 KiB.");
|
|
114
|
+
const content = await readFile(options["text-file"]);
|
|
115
|
+
if (content.byteLength > 32 * 1024) throw new Error("Text files must not exceed 32 KiB.");
|
|
116
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
117
|
+
}
|
|
118
|
+
if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > 32 * 1024)
|
|
119
|
+
throw new Error("Provide --text or --text-file containing 1–32768 bytes.");
|
|
120
|
+
return text;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function required(value, description) {
|
|
124
|
+
if (!value) throw new Error(`Provide ${description}.`);
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function safeOutput(value, secrets = []) {
|
|
129
|
+
let output = JSON.stringify(value);
|
|
130
|
+
for (const secret of secrets) if (secret) output = output.split(secret).join("[REDACTED]");
|
|
131
|
+
return output;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function networkMain(args) {
|
|
135
|
+
const json = args.includes("--json");
|
|
136
|
+
let secret = process.env.TOTEM_NETWORK_API_KEY;
|
|
137
|
+
const emit = (value) => process.stdout.write(`${safeOutput(value, [secret])}\n`);
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const stop = () => controller.abort();
|
|
140
|
+
try {
|
|
141
|
+
const options = parseNetworkArgs(args);
|
|
142
|
+
const [command, action, id] = options.positionals;
|
|
143
|
+
if (options.help || !command || command === "help") {
|
|
144
|
+
process.stdout.write(NETWORK_HELP);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (process.env.TOTEM_NETWORK_API_KEY_FILE) {
|
|
148
|
+
if (secret)
|
|
149
|
+
throw new Error("Set only one of TOTEM_NETWORK_API_KEY and TOTEM_NETWORK_API_KEY_FILE.");
|
|
150
|
+
const path = process.env.TOTEM_NETWORK_API_KEY_FILE;
|
|
151
|
+
const info = await stat(path);
|
|
152
|
+
if (
|
|
153
|
+
!info.isFile() ||
|
|
154
|
+
info.size > 8192 ||
|
|
155
|
+
(process.platform !== "win32" && (info.mode & 0o077) !== 0)
|
|
156
|
+
)
|
|
157
|
+
throw new Error("The key file must be a regular private file (chmod 600), at most 8 KiB.");
|
|
158
|
+
secret = (await readFile(path, "utf8")).trim();
|
|
159
|
+
}
|
|
160
|
+
if (!secret?.trim())
|
|
161
|
+
throw new Error(
|
|
162
|
+
"Set TOTEM_NETWORK_API_KEY or TOTEM_NETWORK_API_KEY_FILE to an agent API key from Agents.",
|
|
163
|
+
);
|
|
164
|
+
const baseUrl = options["base-url"] ?? process.env.TOTEM_BASE_URL;
|
|
165
|
+
const client = new Totem({ apiKey: secret, baseUrl });
|
|
166
|
+
const signal = controller.signal;
|
|
167
|
+
const query = {
|
|
168
|
+
limit: numberOption(options.limit, 50, 1, 100, "--limit"),
|
|
169
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
170
|
+
};
|
|
171
|
+
const write = { idempotencyKey: options["idempotency-key"], signal };
|
|
172
|
+
let result;
|
|
173
|
+
if (command === "me") result = await client.agent({ signal });
|
|
174
|
+
else if (command === "send")
|
|
175
|
+
result = await client.send(
|
|
176
|
+
{
|
|
177
|
+
to: required(options.to, "--to <address>"),
|
|
178
|
+
text: await textInput(options),
|
|
179
|
+
...(options["conversation-hops"] === undefined
|
|
180
|
+
? {}
|
|
181
|
+
: {
|
|
182
|
+
metadata: {
|
|
183
|
+
totemAutonomy: {
|
|
184
|
+
version: 1,
|
|
185
|
+
maxHops: numberOption(
|
|
186
|
+
options["conversation-hops"],
|
|
187
|
+
0,
|
|
188
|
+
2,
|
|
189
|
+
8,
|
|
190
|
+
"--conversation-hops",
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
write,
|
|
197
|
+
);
|
|
198
|
+
else if (command === "inbox") {
|
|
199
|
+
emit(
|
|
200
|
+
await client.messages.list(
|
|
201
|
+
{ ...query, direction: "inbound", ...(options.all ? {} : { unread: true }) },
|
|
202
|
+
{ signal },
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
} else if (command === "reply")
|
|
207
|
+
result = await client.messages.reply(
|
|
208
|
+
required(action, "a message ID"),
|
|
209
|
+
await textInput(options),
|
|
210
|
+
write,
|
|
211
|
+
);
|
|
212
|
+
else if (command === "ack")
|
|
213
|
+
result = await client.messages.ack(required(action, "a message ID"), { signal });
|
|
214
|
+
else if (command === "chat" && action === "list") {
|
|
215
|
+
emit(await client.chats.list(query, { signal }));
|
|
216
|
+
return;
|
|
217
|
+
} else if (command === "chat" && action === "show")
|
|
218
|
+
result = await client.chats.get(required(id, "a chat ID"), { ...query, signal });
|
|
219
|
+
else if (command === "chat" && action === "updates")
|
|
220
|
+
result = await client.chats.updates(
|
|
221
|
+
required(id, "a chat ID"),
|
|
222
|
+
await textInput(options),
|
|
223
|
+
write,
|
|
224
|
+
);
|
|
225
|
+
else if (command === "listen" || command === "run" || command === "work") {
|
|
226
|
+
process.once("SIGINT", stop);
|
|
227
|
+
process.once("SIGTERM", stop);
|
|
228
|
+
const interval = numberOption(options["poll-ms"], 1000, 100, 60000, "--poll-ms");
|
|
229
|
+
if (command === "work") {
|
|
230
|
+
if (!process.env.TOTEM_NETWORK_API_KEY_FILE)
|
|
231
|
+
throw new Error(
|
|
232
|
+
"work requires TOTEM_NETWORK_API_KEY_FILE so the local agent process never inherits a raw Totem key.",
|
|
233
|
+
);
|
|
234
|
+
if ((options["allow-agent"] ?? []).length !== 1)
|
|
235
|
+
throw new Error("work requires exactly one --allow-agent <exact-peer-address>.");
|
|
236
|
+
await runCollaborationListener({
|
|
237
|
+
client,
|
|
238
|
+
signal,
|
|
239
|
+
emit,
|
|
240
|
+
once: options.once,
|
|
241
|
+
checkOnly: options.check,
|
|
242
|
+
pollMs: numberOption(options["poll-ms"], 5000, 1000, 60000, "--poll-ms"),
|
|
243
|
+
peerAddress: options["allow-agent"][0],
|
|
244
|
+
provider: options.provider ?? "codex",
|
|
245
|
+
repositoryRoot: options["repo-root"] ?? process.cwd(),
|
|
246
|
+
stateDirectory: options["state-dir"],
|
|
247
|
+
});
|
|
248
|
+
} else if (command === "listen") {
|
|
249
|
+
do {
|
|
250
|
+
for await (const message of client.messages.iterate(
|
|
251
|
+
{ direction: "inbound", unread: true, limit: 100 },
|
|
252
|
+
{ signal },
|
|
253
|
+
))
|
|
254
|
+
emit({ type: "message", data: message });
|
|
255
|
+
if (!options.once) await pause(interval, signal);
|
|
256
|
+
} while (!signal.aborted && !options.once);
|
|
257
|
+
} else {
|
|
258
|
+
await runRuntime({
|
|
259
|
+
client,
|
|
260
|
+
signal,
|
|
261
|
+
emit,
|
|
262
|
+
once: options.once,
|
|
263
|
+
pollMs: interval,
|
|
264
|
+
baseUrl,
|
|
265
|
+
provider: options.provider,
|
|
266
|
+
model: options.model,
|
|
267
|
+
ollamaUrl: options["ollama-url"],
|
|
268
|
+
allowAgents: options["allow-agent"] ?? [],
|
|
269
|
+
autoReplyHops: numberOption(options["auto-reply-hops"], 0, 0, 8, "--auto-reply-hops"),
|
|
270
|
+
systemFile: options["system-file"],
|
|
271
|
+
stateDir: options["state-dir"],
|
|
272
|
+
modelTimeoutMs: numberOption(
|
|
273
|
+
options["model-timeout-ms"],
|
|
274
|
+
120000,
|
|
275
|
+
1000,
|
|
276
|
+
600000,
|
|
277
|
+
"--model-timeout-ms",
|
|
278
|
+
),
|
|
279
|
+
replyTimeoutMs: numberOption(
|
|
280
|
+
options["reply-timeout-ms"],
|
|
281
|
+
90000,
|
|
282
|
+
1000,
|
|
283
|
+
600000,
|
|
284
|
+
"--reply-timeout-ms",
|
|
285
|
+
),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
} else throw new Error("Unknown network command. Run totem network --help.");
|
|
290
|
+
emit({ data: result });
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (controller.signal.aborted) return;
|
|
293
|
+
const payload = {
|
|
294
|
+
error: {
|
|
295
|
+
code: error.code ?? "COMMAND_FAILED",
|
|
296
|
+
message: error.message ?? "The network command failed.",
|
|
297
|
+
...(error.requestId ? { requestId: error.requestId } : {}),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
if (json) emit(payload);
|
|
301
|
+
else process.stderr.write(`${stripVTControlCharacters(safeOutput(payload, [secret]))}\n`);
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
} finally {
|
|
304
|
+
process.removeListener("SIGINT", stop);
|
|
305
|
+
process.removeListener("SIGTERM", stop);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function pause(ms, signal) {
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
const abort = () => {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
signal?.removeEventListener("abort", abort);
|
|
314
|
+
reject(new Error("Runtime stopped."));
|
|
315
|
+
};
|
|
316
|
+
const timer = setTimeout(() => {
|
|
317
|
+
signal?.removeEventListener("abort", abort);
|
|
318
|
+
resolve();
|
|
319
|
+
}, ms);
|
|
320
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
321
|
+
if (signal?.aborted) abort();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
6
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const VERSION = "0.1.0";
|
|
11
|
+
const MAX_KEY_BYTES = 8 * 1024;
|
|
12
|
+
const DEFAULT_BASE_URL = "https://totemlayer.com";
|
|
13
|
+
|
|
14
|
+
function clean(value) {
|
|
15
|
+
return JSON.parse(JSON.stringify(value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function result(value) {
|
|
19
|
+
const data = clean(value);
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
22
|
+
structuredContent: { data },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toolError(error) {
|
|
27
|
+
const payload = {
|
|
28
|
+
error: {
|
|
29
|
+
code:
|
|
30
|
+
error && typeof error === "object" && typeof error.code === "string"
|
|
31
|
+
? error.code
|
|
32
|
+
: "TOTEM_PRESENTER_ERROR",
|
|
33
|
+
message: error instanceof Error ? error.message : "The Totem request failed.",
|
|
34
|
+
...(error && typeof error === "object" && typeof error.requestId === "string"
|
|
35
|
+
? { requestId: error.requestId }
|
|
36
|
+
: {}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
isError: true,
|
|
41
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
42
|
+
structuredContent: payload,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validatePresenterKey(value) {
|
|
47
|
+
const key = value.trim();
|
|
48
|
+
if (!/^at_(?:live|test)_[A-Za-z0-9_-]+$/.test(key)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"The configured credential is not a Totem presenter API key. Use the at_live_ or at_test_ key bound to this agent with credential.present access.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return key;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readPrivateFile(path) {
|
|
57
|
+
const info = await stat(path);
|
|
58
|
+
if (!info.isFile() || info.size > MAX_KEY_BYTES) {
|
|
59
|
+
throw new Error("The presenter key file must be a regular file no larger than 8 KiB.");
|
|
60
|
+
}
|
|
61
|
+
if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"The presenter key file must be private. Run chmod 600 on it before connecting.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return readFile(path, "utf8");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readMacOSKeychain(service, account) {
|
|
70
|
+
if (process.platform !== "darwin") {
|
|
71
|
+
throw new Error("macOS Keychain credentials are available only on macOS.");
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const { stdout } = await execFileAsync(
|
|
75
|
+
"/usr/bin/security",
|
|
76
|
+
["find-generic-password", "-s", service, "-a", account, "-w"],
|
|
77
|
+
{ encoding: "utf8", maxBuffer: MAX_KEY_BYTES },
|
|
78
|
+
);
|
|
79
|
+
return stdout;
|
|
80
|
+
} catch {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`No Totem presenter key was found in macOS Keychain for service ${service} and account ${account}.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readTotemPresenterKey(environment = process.env) {
|
|
88
|
+
const direct = environment.TOTEM_AGENT_API_KEY?.trim();
|
|
89
|
+
const keyFile = environment.TOTEM_AGENT_API_KEY_FILE?.trim();
|
|
90
|
+
const keychainService = environment.TOTEM_AGENT_KEYCHAIN_SERVICE?.trim();
|
|
91
|
+
const keychainAccount = environment.TOTEM_AGENT_KEYCHAIN_ACCOUNT?.trim();
|
|
92
|
+
const sources = [Boolean(direct), Boolean(keyFile), Boolean(keychainService)].filter(Boolean);
|
|
93
|
+
|
|
94
|
+
if (sources.length !== 1) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"Configure exactly one Totem presenter key source: TOTEM_AGENT_API_KEY, TOTEM_AGENT_API_KEY_FILE, or TOTEM_AGENT_KEYCHAIN_SERVICE.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (direct) return validatePresenterKey(direct);
|
|
101
|
+
if (keyFile) return validatePresenterKey(await readPrivateFile(keyFile));
|
|
102
|
+
if (!keychainService) throw new Error("A Totem presenter key source is required.");
|
|
103
|
+
if (!keychainAccount) {
|
|
104
|
+
throw new Error("TOTEM_AGENT_KEYCHAIN_ACCOUNT is required with the Keychain service.");
|
|
105
|
+
}
|
|
106
|
+
return validatePresenterKey(await readMacOSKeychain(keychainService, keychainAccount));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function baseUrl(environment) {
|
|
110
|
+
const value =
|
|
111
|
+
environment.TOTEM_URL?.trim() || environment.TOTEM_BASE_URL?.trim() || DEFAULT_BASE_URL;
|
|
112
|
+
const url = new URL(value);
|
|
113
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
114
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
115
|
+
throw new Error("TOTEM_URL must use HTTPS, except for an explicit localhost development URL.");
|
|
116
|
+
}
|
|
117
|
+
return url.origin;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function loadTotemPresenterMcpConfig(environment = process.env) {
|
|
121
|
+
const apiKey = await readTotemPresenterKey(environment);
|
|
122
|
+
return {
|
|
123
|
+
apiKey,
|
|
124
|
+
baseUrl: baseUrl(environment),
|
|
125
|
+
label: environment.TOTEM_AGENT_LABEL?.trim() || "Configured Totem agent",
|
|
126
|
+
environment: apiKey.startsWith("at_live_") ? "PRODUCTION" : "SANDBOX",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function parseResponse(response) {
|
|
131
|
+
const text = await response.text();
|
|
132
|
+
let payload;
|
|
133
|
+
try {
|
|
134
|
+
payload = text ? JSON.parse(text) : {};
|
|
135
|
+
} catch {
|
|
136
|
+
payload = {};
|
|
137
|
+
}
|
|
138
|
+
if (response.ok) return payload;
|
|
139
|
+
|
|
140
|
+
const apiError = payload && typeof payload === "object" ? payload.error : undefined;
|
|
141
|
+
const error = new Error(
|
|
142
|
+
apiError && typeof apiError.message === "string"
|
|
143
|
+
? apiError.message
|
|
144
|
+
: `Totem returned HTTP ${response.status}.`,
|
|
145
|
+
);
|
|
146
|
+
if (apiError && typeof apiError.code === "string") error.code = apiError.code;
|
|
147
|
+
if (apiError && typeof apiError.requestId === "string") error.requestId = apiError.requestId;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createTotemPresenterMcpServer(config, options = {}) {
|
|
152
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
153
|
+
const server = new McpServer(
|
|
154
|
+
{ name: "totem-presenter", version: VERSION },
|
|
155
|
+
{
|
|
156
|
+
instructions:
|
|
157
|
+
"This connector presents Totem credentials as the agent bound to its configured credential. The credential determines identity and cannot be overridden by tool input. Call totem_get_presenter_config to inspect the local configuration. Call totem_present_credential only for a receiver-provided challenge and an intended credential; it creates a short-lived signed proof but does not itself authorize or execute an external action.",
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
server.registerTool(
|
|
162
|
+
"totem_get_presenter_config",
|
|
163
|
+
{
|
|
164
|
+
title: "Get Totem presenter configuration",
|
|
165
|
+
description:
|
|
166
|
+
"Show the local label, Totem host, and environment for this presenter connector without revealing its key.",
|
|
167
|
+
inputSchema: z.object({}),
|
|
168
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
169
|
+
},
|
|
170
|
+
async () =>
|
|
171
|
+
result({
|
|
172
|
+
label: config.label,
|
|
173
|
+
baseUrl: config.baseUrl,
|
|
174
|
+
environment: config.environment,
|
|
175
|
+
capability: "credential.present",
|
|
176
|
+
identityBinding:
|
|
177
|
+
"Totem derives the actual presenter agent from the configured key when a proof is created.",
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
server.registerTool(
|
|
182
|
+
"totem_present_credential",
|
|
183
|
+
{
|
|
184
|
+
title: "Present a Totem credential",
|
|
185
|
+
description:
|
|
186
|
+
"Create a short-lived, challenge-bound proof using the agent identity bound to this connector's credential.",
|
|
187
|
+
inputSchema: z.object({
|
|
188
|
+
credentialId: z.string().trim().min(1).max(100),
|
|
189
|
+
challengeId: z.string().trim().min(1).max(100),
|
|
190
|
+
nonce: z
|
|
191
|
+
.string()
|
|
192
|
+
.trim()
|
|
193
|
+
.min(32)
|
|
194
|
+
.max(256)
|
|
195
|
+
.regex(/^[A-Za-z0-9_-]+$/),
|
|
196
|
+
}),
|
|
197
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
198
|
+
},
|
|
199
|
+
async (input) => {
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetchImpl(`${config.baseUrl}/api/v1/presentations`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: {
|
|
204
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
205
|
+
"content-type": "application/json",
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(input),
|
|
208
|
+
redirect: "error",
|
|
209
|
+
signal: AbortSignal.timeout(15_000),
|
|
210
|
+
});
|
|
211
|
+
return result(await parseResponse(response));
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return toolError(error);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
return server;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function presenterMcpMain(environment = process.env) {
|
|
222
|
+
try {
|
|
223
|
+
const config = await loadTotemPresenterMcpConfig(environment);
|
|
224
|
+
serveStdio(() => createTotemPresenterMcpServer(config), {
|
|
225
|
+
onerror: (error) => console.error(`Totem presenter MCP: ${error.message}`),
|
|
226
|
+
});
|
|
227
|
+
console.error(`Totem presenter MCP is ready for ${config.label}.`);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error(
|
|
230
|
+
error instanceof Error ? error.message : "Could not start the Totem presenter MCP server.",
|
|
231
|
+
);
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
237
|
+
await presenterMcpMain();
|
|
238
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface LocalRepository {
|
|
2
|
+
repository: string;
|
|
3
|
+
headCommit: string;
|
|
4
|
+
currentBranch: string | null;
|
|
5
|
+
clean: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RepoVerification extends LocalRepository {
|
|
9
|
+
repositoryMatches: boolean;
|
|
10
|
+
baseMatches: boolean;
|
|
11
|
+
branchMatches: boolean;
|
|
12
|
+
baseAncestor: boolean;
|
|
13
|
+
changedPaths: string[];
|
|
14
|
+
outOfScopePaths: string[];
|
|
15
|
+
locallyVerified: boolean;
|
|
16
|
+
remotelyVerified: false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function repositoryIdFromRemote(remote: string): string;
|
|
20
|
+
export function inspectLocalRepository(
|
|
21
|
+
environment?: Record<string, string | undefined>,
|
|
22
|
+
): Promise<LocalRepository>;
|
|
23
|
+
export function verifyLocalRepoClaim(
|
|
24
|
+
task: { repository: string; baseCommit: string },
|
|
25
|
+
claim: { repository: string; baseCommit: string; branch: string; paths: string[] },
|
|
26
|
+
environment?: Record<string, string | undefined>,
|
|
27
|
+
): Promise<RepoVerification>;
|