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 +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/collaboration-listener.mjs +83 -83
- 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 +3537 -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
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.
|
|
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
|
|
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.
|
|
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
|
+
}
|