totemlayer 0.1.4 → 0.1.6

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 CHANGED
@@ -26,7 +26,7 @@ another program; machine-readable output remains undecorated JSON.
26
26
  ╭───╯╰────╮ ╺┳╸┏━┓╺┳╸┏━╸┏┳┓
27
27
  ╰────╮╭────╯ ┃ ┃ ┃ ┃ ┣╸ ┃┃┃
28
28
  ╰╯ ╹ ┗━┛ ╹ ┗━╸╹ ╹
29
- TotemLayer CLI 0.1.4 · cross-company agent authorization
29
+ TotemLayer CLI 0.1.5 · cross-company agent authorization
30
30
  ```
31
31
 
32
32
  For a one-off run without a global install:
@@ -43,6 +43,23 @@ The package is distributed under the included TotemLayer CLI Use License, which
43
43
  permits anyone to install and use unmodified copies with TotemLayer-compatible
44
44
  services without exposing or licensing the private server application.
45
45
 
46
+ ## Guided agent setup (local preview)
47
+
48
+ This checkout adds one-time connection profiles and automatic pickup of approved Mission work. These commands require the matching local package; they are not yet available in the published npm release.
49
+
50
+ ```sh
51
+ totem setup --profile my-agent --agent-id YOUR_AGENT_UUID --base-url https://your-totem-service.example
52
+ totem status --profile my-agent
53
+ totem start --profile my-agent --background
54
+ totem stop --profile my-agent
55
+ ```
56
+
57
+ Create an agent and connection key in the Totem app first. Setup asks for the key privately, verifies the identity, and saves a private connection. It can approve explicitly chosen local repository paths. Noninteractive setup accepts a private `--key-file`; key values are never command arguments. Existing settings require explicit `--replace` before changing.
58
+
59
+ The automatic Mission worker uses an already running local Qwen model. It receives consented assignments without a Mission ID, retains recovery journals, and continues within the current permissions and limits. `--background` keeps the worker independent of the terminal, but the computer and model must remain running. After a computer restart, start the saved profile again. Setup and status never start a model or worker themselves.
60
+
61
+ Choosing Codex or Claude connects MCP tools and can install a profile-specific connection with explicit `--install-mcp`. Setup checks existing settings before updating them and gives named recovery instructions when they cannot be verified. Restart the AI app and call `totem_get_identity` to verify its connection. Those connections do not run the automatic Mission worker in this preview. Ordinary Chat has its own `totem network run` command. Status reports connection, repository, model, and worker readiness separately; coding readiness requires an approved checkout, while other Mission work can run without one.
62
+
46
63
  ## Before the first production request
47
64
 
48
65
  Each participating company completes its one-time setup at
@@ -273,10 +290,10 @@ uses stdio and writes protocol messages only to stdout; operational errors go to
273
290
  export TOTEM_BASE_URL=http://localhost:3413
274
291
  export TOTEM_NETWORK_API_KEY_FILE="$HOME/.totem-agent-key" # chmod 600 this file
275
292
  totem network me --json
276
- totem network run --provider ollama --model qwen3.5:9b
293
+ totem network run --provider qwen-local --qwen-url http://127.0.0.1:8087/v1 --model qwen3.5-9b
277
294
  ```
278
295
 
279
- Register an agent and create its key in **Agents** (`/communications?view=setup`). Open **Chat** in Totem to talk to the running agent. Ollama and the named model must already be installed; the CLI does not download them. Personal agents do not require a company domain.
296
+ Register an agent and create its key in **Agents** (`/communications?view=setup`). Open **Chat** in Totem to talk to the running agent. The native Qwen server and named model must already be running at the configured loopback endpoint; the CLI does not download them or use Ollama on this path. Personal agents do not require a company domain.
280
297
 
281
298
  Commands include `me`, `send`, `inbox`, `reply`, `ack`, `listen`, `chat list`, `chat show`, and `chat updates`. Output is JSON; `--json` also emits errors as JSON on stdout. `listen` is an at-least-once unread stream with explicit acknowledgment. `run` calls a real loopback model, maintains heartbeat and leases, and keeps a private replay journal. Use `--allow-agent <exact-address>` to allow its bounded `ask_agent` tool. No arbitrary shell or external action tools are installed.
282
299
 
@@ -285,3 +302,7 @@ Packing this package inside the source repository builds and embeds the current
285
302
  the package has no installation scripts. This local addition has not been published to npm. See
286
303
  `docs/network/agent-runtime.md` in the source repository for the full adoption guide and local
287
304
  package installation steps.
305
+
306
+ ## Repository test isolation
307
+
308
+ Version 0.1.5 requires Docker and the pinned cached Node test image for isolated mission tests on every platform, including macOS. Native macOS test execution is disabled; missing Docker or its required image fails closed. Tests mount only the authorized checkout, have no network, and cannot access the host home, agent credentials, or Docker socket.
@@ -0,0 +1,466 @@
1
+ import { constants } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
6
+
7
+ import { localModelEndpoint } from "./local-model-provider.mjs";
8
+ import { repositoryIdFromRemote } from "./repository.mjs";
9
+
10
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
11
+ const KEY = /^tn_(test|live)_[A-Za-z0-9_-]{43}$/;
12
+ const MAX_PROFILE_BYTES = 32768;
13
+ const profileBindings = new WeakMap();
14
+
15
+ function fail(code = "AGENT_PROFILE_INVALID") {
16
+ const messages = {
17
+ AGENT_PROFILE_INVALID: "The private agent profile is invalid or unsafe.",
18
+ AGENT_PROFILE_NOT_FOUND: "The named agent connection has not been configured.",
19
+ AGENT_PROFILE_EXISTS:
20
+ "This connection already has different settings or credentials. Explicit replacement is required.",
21
+ AGENT_PROFILE_BUSY:
22
+ "Another setup or worker start may be updating this profile. Wait for it to finish. If this persists after those processes have stopped, inspect this profile's private profile.lock file before recovery. The lock is never cleared automatically.",
23
+ AGENT_PROFILE_RUNNING:
24
+ "This agent connection is running. Stop it before replacing its settings or credentials.",
25
+ AGENT_PROFILE_IO: "The private agent connection could not be safely read or saved.",
26
+ };
27
+ throw Object.assign(new Error(messages[code] ?? messages.AGENT_PROFILE_INVALID), { code });
28
+ }
29
+
30
+ function validName(name) {
31
+ if (
32
+ typeof name !== "string" ||
33
+ name.length > 64 ||
34
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) ||
35
+ /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/.test(name)
36
+ )
37
+ fail();
38
+ return name;
39
+ }
40
+
41
+ function absolutePath(value) {
42
+ if (
43
+ typeof value !== "string" ||
44
+ value.length > 4096 ||
45
+ /[\x00-\x1f\x7f]/.test(value) ||
46
+ !isAbsolute(value) ||
47
+ resolve(value) !== value
48
+ )
49
+ fail();
50
+ return value;
51
+ }
52
+
53
+ export function connectionDirectory(profile = "default", environment = process.env) {
54
+ const root = absolutePath(environment.TOTEM_CONFIG_DIR ?? join(homedir(), ".totem"));
55
+ if (root === parse(root).root) fail();
56
+ return join(root, "connections", validName(profile));
57
+ }
58
+
59
+ function owned(info) {
60
+ return typeof process.getuid !== "function" || info.uid === process.getuid();
61
+ }
62
+
63
+ function privateDirectory(info) {
64
+ if (
65
+ !info.isDirectory() ||
66
+ info.isSymbolicLink() ||
67
+ !owned(info) ||
68
+ (process.platform !== "win32" && (info.mode & 0o777) !== 0o700)
69
+ )
70
+ fail();
71
+ }
72
+
73
+ async function directories(path, privateRoot, create = false) {
74
+ absolutePath(path);
75
+ let current = parse(path).root;
76
+ for (const part of path.slice(current.length).split(sep).filter(Boolean)) {
77
+ current = join(current, part);
78
+ let info;
79
+ try {
80
+ info = await lstat(current);
81
+ } catch (error) {
82
+ if (error.code !== "ENOENT" || !create) throw error;
83
+ try {
84
+ await mkdir(current, { mode: 0o700 });
85
+ } catch (createError) {
86
+ if (createError.code !== "EEXIST") throw createError;
87
+ }
88
+ info = await lstat(current);
89
+ }
90
+ if (info.isSymbolicLink() || !info.isDirectory()) fail();
91
+ if (current === privateRoot || current.startsWith(`${privateRoot}${sep}`))
92
+ privateDirectory(info);
93
+ }
94
+ }
95
+
96
+ function privateFile(info, maximum) {
97
+ if (
98
+ !info.isFile() ||
99
+ info.isSymbolicLink() ||
100
+ info.nlink !== 1 ||
101
+ !owned(info) ||
102
+ (process.platform !== "win32" && (info.mode & 0o777) !== 0o600) ||
103
+ info.size > maximum
104
+ )
105
+ fail();
106
+ }
107
+
108
+ async function boundedPrivateRead(path, maximum) {
109
+ const info = await lstat(path);
110
+ privateFile(info, maximum);
111
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
112
+ try {
113
+ const actual = await file.stat();
114
+ privateFile(actual, maximum);
115
+ if (actual.ino !== info.ino || actual.dev !== info.dev || actual.size > maximum) fail();
116
+ const buffer = Buffer.alloc(maximum + 1);
117
+ let length = 0;
118
+ while (length < buffer.length) {
119
+ const { bytesRead } = await file.read(buffer, length, buffer.length - length, length);
120
+ if (!bytesRead) break;
121
+ length += bytesRead;
122
+ }
123
+ if (length > maximum) fail();
124
+ return buffer.subarray(0, length).toString("utf8");
125
+ } finally {
126
+ await file.close();
127
+ }
128
+ }
129
+
130
+ async function writePrivate(path, contents) {
131
+ const file = await open(
132
+ path,
133
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
134
+ 0o600,
135
+ );
136
+ try {
137
+ await file.writeFile(contents);
138
+ await file.sync();
139
+ } finally {
140
+ await file.close();
141
+ }
142
+ }
143
+
144
+ function origin(value) {
145
+ if (typeof value !== "string" || value.length > 2048 || /[\s\\]/.test(value)) fail();
146
+ const url = new URL(value);
147
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
148
+ if (
149
+ (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
150
+ url.username ||
151
+ url.password ||
152
+ url.search ||
153
+ url.hash ||
154
+ url.pathname !== "/"
155
+ )
156
+ fail();
157
+ return url.origin;
158
+ }
159
+
160
+ function credentialValue(value, environment) {
161
+ if (
162
+ typeof value !== "string" ||
163
+ !KEY.test(value) ||
164
+ !value.startsWith(environment === "SANDBOX" ? "tn_test_" : "tn_live_")
165
+ )
166
+ fail();
167
+ return value;
168
+ }
169
+
170
+ async function normalizedSettings(input, directory) {
171
+ if (!input || typeof input !== "object" || Array.isArray(input)) fail();
172
+ const allowed = new Set([
173
+ "baseUrl",
174
+ "agentId",
175
+ "address",
176
+ "environment",
177
+ "provider",
178
+ "model",
179
+ "qwenUrl",
180
+ "repositoryRoots",
181
+ "stateDirectory",
182
+ "client",
183
+ ]);
184
+ if (Object.keys(input).some((key) => !allowed.has(key))) fail();
185
+ const baseUrl = origin(input.baseUrl);
186
+ if (typeof input.agentId !== "string" || !UUID.test(input.agentId)) fail();
187
+ if (
188
+ typeof input.address !== "string" ||
189
+ input.address.length > 320 ||
190
+ !/^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9.-]*$/.test(input.address) ||
191
+ !["SANDBOX", "PRODUCTION"].includes(input.environment)
192
+ )
193
+ fail();
194
+ const provider = input.provider ?? "qwen-local";
195
+ const model = input.model ?? "qwen3.5-9b";
196
+ if (
197
+ provider !== "qwen-local" ||
198
+ typeof model !== "string" ||
199
+ !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model)
200
+ )
201
+ fail();
202
+ if (
203
+ input.qwenUrl !== undefined &&
204
+ (typeof input.qwenUrl !== "string" ||
205
+ input.qwenUrl.length > 2048 ||
206
+ /[\s\\]/.test(input.qwenUrl))
207
+ )
208
+ fail();
209
+ const endpoint = new URL(localModelEndpoint(provider, { qwenUrl: input.qwenUrl }));
210
+ const qwenUrl = `${endpoint.origin}/v1`;
211
+ if (input.client !== undefined && !["codex", "claude"].includes(input.client)) fail();
212
+ const roots = input.repositoryRoots ?? [];
213
+ if (!Array.isArray(roots) || roots.length > 32) fail();
214
+ const ids = new Set();
215
+ const repositoryRoots = [];
216
+ for (const entry of roots) {
217
+ if (
218
+ !entry ||
219
+ typeof entry !== "object" ||
220
+ Array.isArray(entry) ||
221
+ Object.keys(entry).some((key) => !["repositoryId", "root"].includes(key))
222
+ )
223
+ fail();
224
+ if (
225
+ typeof entry.repositoryId !== "string" ||
226
+ entry.repositoryId.length > 500 ||
227
+ repositoryIdFromRemote(`https://${entry.repositoryId}`) !== entry.repositoryId ||
228
+ ids.has(entry.repositoryId.toLowerCase())
229
+ )
230
+ fail();
231
+ const root = absolutePath(entry.root);
232
+ await directories(root, directory);
233
+ if ((await realpath(root)) !== root) fail();
234
+ ids.add(entry.repositoryId.toLowerCase());
235
+ repositoryRoots.push({ repositoryId: entry.repositoryId, root });
236
+ }
237
+ repositoryRoots.sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
238
+ const scope = createHash("sha256")
239
+ .update(JSON.stringify([baseUrl, input.agentId]))
240
+ .digest("hex");
241
+ const stateDirectory = join(directory, "state", scope);
242
+ if (input.stateDirectory !== undefined && input.stateDirectory !== stateDirectory) fail();
243
+ return {
244
+ baseUrl,
245
+ agentId: input.agentId,
246
+ address: input.address,
247
+ environment: input.environment,
248
+ provider,
249
+ model,
250
+ qwenUrl,
251
+ repositoryRoots,
252
+ stateDirectory,
253
+ ...(input.client === undefined ? {} : { client: input.client }),
254
+ };
255
+ }
256
+
257
+ function publicProfile(name, directory, settings, generation, serialized) {
258
+ const profile = Object.freeze({
259
+ version: 1,
260
+ profileName: name,
261
+ ...settings,
262
+ repositoryRoots: Object.freeze(settings.repositoryRoots.map((entry) => Object.freeze(entry))),
263
+ credentialFile: join(directory, "credentials", generation, "agent.key"),
264
+ });
265
+ profileBindings.set(profile, { directory, serialized });
266
+ return profile;
267
+ }
268
+
269
+ async function readStored(name, directory, privateRoot) {
270
+ await directories(directory, privateRoot);
271
+ const text = await boundedPrivateRead(join(directory, "profile.json"), MAX_PROFILE_BYTES);
272
+ const record = JSON.parse(text);
273
+ if (
274
+ !record ||
275
+ record.version !== 1 ||
276
+ record.profileName !== name ||
277
+ !UUID.test(record.credentialGeneration ?? "")
278
+ )
279
+ fail();
280
+ const credentialGeneration = record.credentialGeneration;
281
+ const input = Object.fromEntries(
282
+ Object.entries(record).filter(
283
+ ([key]) => !["version", "profileName", "credentialGeneration"].includes(key),
284
+ ),
285
+ );
286
+ const settings = await normalizedSettings(input, directory);
287
+ const expected = JSON.stringify({
288
+ version: 1,
289
+ profileName: name,
290
+ ...settings,
291
+ credentialGeneration,
292
+ });
293
+ if (JSON.stringify(record) !== expected) fail();
294
+ await directories(join(directory, "credentials", credentialGeneration), privateRoot);
295
+ await directories(settings.stateDirectory, privateRoot);
296
+ const profile = publicProfile(name, directory, settings, credentialGeneration, expected);
297
+ await readAgentCredential(profile);
298
+ return { profile, settings, serialized: expected };
299
+ }
300
+
301
+ function safeFailure(error) {
302
+ if (typeof error?.code === "string" && error.code.startsWith("AGENT_PROFILE_")) throw error;
303
+ fail("AGENT_PROFILE_IO");
304
+ }
305
+
306
+ export async function readAgentProfile(profileName = "default", options = {}) {
307
+ try {
308
+ const directory = connectionDirectory(profileName, options.environment);
309
+ try {
310
+ return (await readStored(profileName, directory, dirname(dirname(directory)))).profile;
311
+ } catch (error) {
312
+ if (error.code === "ENOENT") fail("AGENT_PROFILE_NOT_FOUND");
313
+ throw error;
314
+ }
315
+ } catch (error) {
316
+ safeFailure(error);
317
+ }
318
+ }
319
+
320
+ export async function readAgentCredential(profile) {
321
+ try {
322
+ const binding = profileBindings.get(profile);
323
+ if (!binding) fail();
324
+ const privateRoot = dirname(dirname(binding.directory));
325
+ await directories(dirname(profile.credentialFile), privateRoot);
326
+ await directories(profile.stateDirectory, privateRoot);
327
+ const current = await boundedPrivateRead(
328
+ join(binding.directory, "profile.json"),
329
+ MAX_PROFILE_BYTES,
330
+ );
331
+ if (current !== binding.serialized) fail();
332
+ return credentialValue(
333
+ await boundedPrivateRead(profile.credentialFile, 128),
334
+ profile.environment,
335
+ );
336
+ } catch (error) {
337
+ safeFailure(error);
338
+ }
339
+ }
340
+
341
+ /** Register a runner under the same short-lived lock used to replace its connection. */
342
+ export async function withAgentProfileLock(profile, operation) {
343
+ let lock;
344
+ let lockPath;
345
+ try {
346
+ const binding = profileBindings.get(profile);
347
+ if (!binding || typeof operation !== "function") fail();
348
+ await directories(binding.directory, dirname(dirname(binding.directory)));
349
+ lockPath = join(binding.directory, "profile.lock");
350
+ try {
351
+ lock = await open(
352
+ lockPath,
353
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
354
+ 0o600,
355
+ );
356
+ } catch (error) {
357
+ if (error.code === "EEXIST") fail("AGENT_PROFILE_BUSY");
358
+ throw error;
359
+ }
360
+ // A replacement may have completed after live authentication but before registration.
361
+ await readAgentCredential(profile);
362
+ } catch (error) {
363
+ if (lock) {
364
+ await lock.close().catch(() => {});
365
+ await unlink(lockPath).catch(() => {});
366
+ }
367
+ safeFailure(error);
368
+ }
369
+ try {
370
+ return await operation();
371
+ } finally {
372
+ await lock.close().catch(() => {});
373
+ await unlink(lockPath).catch(() => {});
374
+ }
375
+ }
376
+
377
+ export async function saveAgentProfile(profileName = "default", input, credential, options = {}) {
378
+ let lock;
379
+ let lockPath;
380
+ let temporary;
381
+ try {
382
+ const directory = connectionDirectory(profileName, options.environment);
383
+ const privateRoot = dirname(dirname(directory));
384
+ const settings = await normalizedSettings(input, directory);
385
+ credentialValue(credential, settings.environment);
386
+ if (options.replace !== undefined && typeof options.replace !== "boolean") fail();
387
+ await directories(directory, privateRoot, true);
388
+ lockPath = join(directory, "profile.lock");
389
+ try {
390
+ lock = await open(
391
+ lockPath,
392
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
393
+ 0o600,
394
+ );
395
+ } catch (error) {
396
+ if (error.code === "EEXIST") fail("AGENT_PROFILE_BUSY");
397
+ throw error;
398
+ }
399
+ let existing;
400
+ try {
401
+ existing = await readStored(profileName, directory, privateRoot);
402
+ } catch (error) {
403
+ // Only absence of the manifest permits first creation; missing key data is corruption.
404
+ if (error.code !== "ENOENT") throw error;
405
+ try {
406
+ await lstat(join(directory, "profile.json"));
407
+ fail();
408
+ } catch (manifestError) {
409
+ if (manifestError.code !== "ENOENT") throw manifestError;
410
+ }
411
+ }
412
+ if (existing) {
413
+ const unchanged =
414
+ JSON.stringify(existing.settings) === JSON.stringify(settings) &&
415
+ (await readAgentCredential(existing.profile)) === credential;
416
+ if (unchanged) return existing.profile;
417
+ if (!options.replace) fail("AGENT_PROFILE_EXISTS");
418
+ // Shared with initial runner registration. Never orphan a live old identity or
419
+ // silently change local repository permissions underneath an active session.
420
+ const { readAgentRuntimeStatus } = await import("./agent-runner.mjs");
421
+ const runtime = await readAgentRuntimeStatus(existing.profile);
422
+ if (runtime.running) fail("AGENT_PROFILE_RUNNING");
423
+ }
424
+ const generation = randomUUID();
425
+ const keyDirectory = join(directory, "credentials", generation);
426
+ const serialized = JSON.stringify({
427
+ version: 1,
428
+ profileName,
429
+ ...settings,
430
+ credentialGeneration: generation,
431
+ });
432
+ if (Buffer.byteLength(serialized) > MAX_PROFILE_BYTES) fail();
433
+ await directories(keyDirectory, privateRoot, true);
434
+ await writePrivate(join(keyDirectory, "agent.key"), credential);
435
+ await directories(settings.stateDirectory, privateRoot, true);
436
+ temporary = join(directory, `.profile-${randomUUID()}.tmp`);
437
+ await writePrivate(temporary, serialized);
438
+ await directories(directory, privateRoot);
439
+ if (existing) {
440
+ if (
441
+ (await boundedPrivateRead(join(directory, "profile.json"), MAX_PROFILE_BYTES)) !==
442
+ existing.serialized
443
+ )
444
+ fail();
445
+ } else {
446
+ try {
447
+ await lstat(join(directory, "profile.json"));
448
+ fail();
449
+ } catch (error) {
450
+ if (error.code !== "ENOENT") throw error;
451
+ }
452
+ }
453
+ // The manifest is the only commit point. Previous private key generations are retained.
454
+ await rename(temporary, join(directory, "profile.json"));
455
+ temporary = undefined;
456
+ return publicProfile(profileName, directory, settings, generation, serialized);
457
+ } catch (error) {
458
+ safeFailure(error);
459
+ } finally {
460
+ if (temporary) await unlink(temporary).catch(() => {});
461
+ if (lock) {
462
+ await lock.close().catch(() => {});
463
+ await unlink(lockPath).catch(() => {});
464
+ }
465
+ }
466
+ }