totemlayer 0.1.4 → 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 +24 -3
- package/bin/agent-profile.mjs +466 -0
- package/bin/agent-runner.mjs +609 -0
- package/bin/agent-setup.mjs +659 -0
- package/bin/docker-test-sandbox.mjs +61 -0
- package/bin/local-model-provider.mjs +29 -0
- package/bin/mcp-install.mjs +240 -1
- 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 +1 -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
|
@@ -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
|
+
}
|
|
@@ -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,9 +1,10 @@
|
|
|
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
|
|
|
6
|
-
import { installedClientInvocation } from "./platform-client.mjs";
|
|
7
|
+
import { installedClientInvocation, localClientEnvironment } from "./platform-client.mjs";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_ORIGIN = "https://totemlayer.com";
|
|
9
10
|
const DEFAULT_NAME = "totem-local";
|
|
@@ -157,3 +158,241 @@ export async function installTotemMcp(args, environment = process.env, runtime =
|
|
|
157
158
|
);
|
|
158
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`;
|
|
159
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
|
+
}
|
package/bin/mcp.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import * as z from "zod/v4";
|
|
|
8
8
|
import { Totem } from "./sdk/index.js";
|
|
9
9
|
import { inspectLocalRepository, verifyLocalRepoClaim } from "./repository.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.1.
|
|
11
|
+
const VERSION = "0.1.5";
|
|
12
12
|
const MAX_KEY_BYTES = 8 * 1024;
|
|
13
13
|
const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024;
|
|
14
14
|
const collaborationSchema = z
|