@cotal-ai/manager 0.10.0 → 0.11.0
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/dist/attach-endpoint.js +1 -1
- package/dist/authorize.d.ts +62 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +65 -0
- package/dist/authorize.js.map +1 -0
- package/dist/commands.js +3 -3
- package/dist/commands.js.map +1 -1
- package/dist/launch.d.ts +5 -3
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +33 -14
- package/dist/launch.js.map +1 -1
- package/dist/manager.d.ts +59 -7
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +421 -61
- package/dist/manager.js.map +1 -1
- package/dist/runtime/index.js +2 -2
- package/dist/runtime/windows-launch.js +2 -2
- package/package.json +4 -4
package/dist/manager.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { existsSync, rmSync } from "node:fs";
|
|
2
3
|
import { join, dirname, resolve } from "node:path";
|
|
3
|
-
import { CotalEndpoint, DEFAULT_SERVER, MANAGER_LEASE_TTL_MS, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, loadAgentFile, loadCotalConfig, mintCreds, mkSecretDir, newIdentity, parseShareSelection, provisionAgent, registry, saveAgentFile, writeSecretFile, subjectMatches, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, } from "@cotal-ai/core";
|
|
4
|
-
import { authDir, defaultAgentType, findCotalRoot, loadSpaceAuth, resolveOnPath } from "@cotal-ai/workspace";
|
|
4
|
+
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_TTL_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, loadAgentFile, loadCotalConfig, mintCreds, mkSecretDir, newIdentity, parsePrincipalKey, parseShareSelection, principalKey, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, writeSecretFile, subjectMatches, CONTROL_PRIVILEGED, CONTROL_SELF_SERVICE, CONTROL_ADMIN, } from "@cotal-ai/core";
|
|
5
|
+
import { agentAuthState, authDir, defaultAgentType, findCotalRoot, loadMeshes, loadSpaceAuth, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, userAuthStateDir, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
5
6
|
import { createRuntime, } from "./runtime/index.js";
|
|
6
7
|
import { AttachEndpoint } from "./attach-endpoint.js";
|
|
7
8
|
import { launchSpecForRun, materializePersona, launchAgentToStartOpts } from "./launch.js";
|
|
9
|
+
import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
|
|
8
10
|
import { controlShutdown } from "./control-shutdown.js";
|
|
9
11
|
/** Concurrency ceiling — the manager refuses to hold more than this many live + in-flight +
|
|
10
12
|
* cooling slots at once (P4a). Bounds a fork-bomb: spawn is a full agent process per call. */
|
|
@@ -27,6 +29,22 @@ export const READINESS_TIMEOUT_MS = 30_000;
|
|
|
27
29
|
* `.catch`. Generous over the helper's 5s connect timeout to allow the two consumer-deletes + ACL purge
|
|
28
30
|
* + drain on a healthy-but-slow broker. */
|
|
29
31
|
const DEPROVISION_TIMEOUT_MS = 15_000;
|
|
32
|
+
/** Sentinel owner-filter value that matches NO agent's `userOwner` (owner tokens never contain a
|
|
33
|
+
* dash) — what {@link Manager.psOwnerFilter} returns for an unparseable caller so a malformed
|
|
34
|
+
* principal fail-closes to an empty `ps` instead of an unbounded one. */
|
|
35
|
+
const NO_OWNER_MATCHES = "-no-owner-";
|
|
36
|
+
/** Run the agent's bearer argv once, pre-launch — the end-to-end auth preflight (state dir, daemon,
|
|
37
|
+
* ledger row, secret). Its stderr is the provider command's operator-exact sentence; surface it
|
|
38
|
+
* verbatim as the spawn refusal. */
|
|
39
|
+
function execBearerPreflight(argv) {
|
|
40
|
+
return new Promise((res, rej) => {
|
|
41
|
+
execFile(argv[0], argv.slice(1), { timeout: 30_000, maxBuffer: 64 * 1024 }, (err, _stdout, stderr) => {
|
|
42
|
+
if (err)
|
|
43
|
+
return rej(new Error(stderr.trim() || err.message));
|
|
44
|
+
res();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
30
48
|
/** Reject `p` with `Error(msg)` if it hasn't settled within `ms`; clears the timer when `p` settles so it
|
|
31
49
|
* never keeps the loop alive. Used to bound the detached deprovision so its fail-loud log is guaranteed. */
|
|
32
50
|
function withTimeout(p, ms, msg) {
|
|
@@ -64,9 +82,14 @@ export class Manager {
|
|
|
64
82
|
* field so a test can shorten it (the join/exit signals are event-driven; only the backstop is timed).
|
|
65
83
|
* Production leaves it at the constant. */
|
|
66
84
|
readinessTimeoutMs = READINESS_TIMEOUT_MS;
|
|
85
|
+
/** True on a USER-AUTH space (the on-disk marker; cross-checked against the registry at start).
|
|
86
|
+
* Gates the whole spawn path: user mode grants ledger actors + bearer plumbing, never static mints. */
|
|
87
|
+
userMode = false;
|
|
67
88
|
leaseInfo;
|
|
68
89
|
leaseRevision;
|
|
69
90
|
leaseTimer;
|
|
91
|
+
/** The class-2 renewal owner's half-TTL schedule (D5 slice 5); armed only on auth meshes. */
|
|
92
|
+
credRenewTimer;
|
|
70
93
|
constructor(opts) {
|
|
71
94
|
this.space = opts.space;
|
|
72
95
|
this.servers = opts.servers;
|
|
@@ -89,16 +112,34 @@ export class Manager {
|
|
|
89
112
|
// In auth mode the manager is just another user in the space's account — it mints
|
|
90
113
|
// itself creds from the same signing key it uses for the agents it spawns.
|
|
91
114
|
this.auth = loadSpaceAuth(authDir(this.workspaceRoot));
|
|
115
|
+
// USER-MODE detection is FAIL-CLOSED on the on-disk marker (the space-scoped state dir), never
|
|
116
|
+
// on the mutable mesh registry alone — registry drift/tamper must not let a user-auth space
|
|
117
|
+
// take the static self-mint branch. A marker/registry disagreement is a refused start with the
|
|
118
|
+
// repair, not a guess.
|
|
119
|
+
this.userMode = existsSync(userAuthStateDir(this.workspaceRoot, this.space));
|
|
120
|
+
const recorded = loadMeshes().find((m) => m.space === this.space);
|
|
121
|
+
if (recorded && (recorded.mode === "user") !== this.userMode)
|
|
122
|
+
throw new Error(`mesh registry says space "${this.space}" is ${recorded.mode}-mode but the on-disk user-auth marker ${this.userMode ? "exists" : "is missing"} (${userAuthStateDir(this.workspaceRoot, this.space)}) - \`cotal down\` and re-\`cotal up\` this space to reconcile before running a manager`);
|
|
123
|
+
if (this.userMode && !recorded)
|
|
124
|
+
throw new Error(`space "${this.space}" has user-auth state on disk but no mesh registry entry - a user-mode manager needs the authoritative record (\`cotal up\` writes it before the control plane); \`cotal up --user-auth\` this space, or remove the stale ${userAuthStateDir(this.workspaceRoot, this.space)}`);
|
|
125
|
+
if (this.userMode && !this.auth)
|
|
126
|
+
throw new Error(`space "${this.space}" has user-auth state but no auth.json under ${authDir(this.workspaceRoot)} - the pre-flip manager still needs the space trust bundle; re-run \`cotal up --user-auth\` here`);
|
|
92
127
|
let creds;
|
|
93
128
|
let id;
|
|
94
129
|
if (this.auth) {
|
|
95
130
|
const identity = newIdentity();
|
|
131
|
+
const auth = this.auth;
|
|
96
132
|
id = identity.id;
|
|
97
133
|
// The long-lived SUPERVISOR cred (closure (ii), residual 2): serve the three control tiers, hold the
|
|
98
134
|
// singleton lease (open-only), publish + watch presence — and nothing else. Provisioning runs on an
|
|
99
135
|
// EPHEMERAL provisioner connection per spawn (withProvisioner); destructive purge mints a PURGER per
|
|
100
136
|
// call. So the always-on daemon holds no DM/DLV read, no consumer-create, no stream-admin tamper.
|
|
101
|
-
|
|
137
|
+
//
|
|
138
|
+
// STANDING RENEWAL (D5 slice 5, class 1): the manager holds the DATA signing seed, so it is its
|
|
139
|
+
// own renewal owner — the cred rides the endpoint's SOURCE seam and self-remints (same identity,
|
|
140
|
+
// pinned by the endpoint) ahead of each bounded supervisor JWT's expiry. A copied supervisor
|
|
141
|
+
// cred is broker-dead within the matrix TTL.
|
|
142
|
+
creds = () => mintCreds(auth, identity, "supervisor");
|
|
102
143
|
}
|
|
103
144
|
this.ep = new CotalEndpoint({
|
|
104
145
|
space: this.space,
|
|
@@ -135,7 +176,7 @@ export class Manager {
|
|
|
135
176
|
await this.ep.stop();
|
|
136
177
|
await this.attach.stop();
|
|
137
178
|
throw new Error(held
|
|
138
|
-
? `a manager already serves space "${this.space}" (id ${held.holder}, ${held.runtime}, pid ${held.pid}, root ${held.root})
|
|
179
|
+
? `a manager already serves space "${this.space}" (id ${held.holder}, ${held.runtime}, pid ${held.pid}, root ${held.root}) - stop it first; one manager per space`
|
|
139
180
|
: `could not acquire the manager lease for space "${this.space}": ${e.message}`);
|
|
140
181
|
}
|
|
141
182
|
this.leaseTimer = setInterval(() => { void this.renewLease(); }, MANAGER_LEASE_TTL_MS / 2);
|
|
@@ -153,12 +194,53 @@ export class Manager {
|
|
|
153
194
|
this.ep.serveControl(CONTROL_PRIVILEGED, (req) => this.handle(req, CONTROL_PRIVILEGED), { boundReply: true });
|
|
154
195
|
this.ep.serveControl(CONTROL_SELF_SERVICE, (req) => this.handle(req, CONTROL_SELF_SERVICE), { boundReply: true });
|
|
155
196
|
this.ep.serveControl(CONTROL_ADMIN, (req) => this.handle(req, CONTROL_ADMIN), { boundReply: true });
|
|
197
|
+
// D5 slice 5 class 2: the manager is the CLASS-2 RENEWAL OWNER — the one control-plane process
|
|
198
|
+
// that is resident in EVERY mesh mode (foreground `up`, `up --detach`, same-root refresh) and
|
|
199
|
+
// holds the signer. Ordered initial pass NOW (ensureControlPlane starts delivery BEFORE the
|
|
200
|
+
// manager, so the daemon's launch-time creds write always precedes this — no write race), then
|
|
201
|
+
// every half-TTL: re-sign the daemon creds files for their EXISTING nkeys, request the explicit
|
|
202
|
+
// `reloadCreds` adoption on the delivery-admin rail, and persist the audit record doctor renders.
|
|
203
|
+
if (this.auth) {
|
|
204
|
+
await this.renewDaemonCreds();
|
|
205
|
+
this.credRenewTimer = setInterval(() => { void this.renewDaemonCreds(); }, (STANDING_RENEWABLE_TTL_SEC / 2) * 1000);
|
|
206
|
+
this.credRenewTimer.unref?.();
|
|
207
|
+
}
|
|
156
208
|
// Plane-3 (durable backstop) is NOT the manager's job — the manager only manages agent lifecycle.
|
|
157
209
|
// The server-side delivery daemon hosts the fan-out writer + trusted reader, owns the durable
|
|
158
210
|
// membership registry, and serves the runtime durable join/leave/list ops (on `ctl.delivery`). The
|
|
159
211
|
// manager records each agent's read ACL at spawn (`commitAcl`, in provisionAgent) so the daemon can
|
|
160
212
|
// re-authorize it; that is the only Plane-3 state the manager touches, and it rides minting.
|
|
161
213
|
}
|
|
214
|
+
/** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
|
|
215
|
+
* for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
|
|
216
|
+
* delivery-admin rail and persist the audit record (`.cotal/renewal.json`) that `cotal doctor auth`
|
|
217
|
+
* renders — so "file re-signed" and "daemon adopted" are distinguishable states. A missing daemon
|
|
218
|
+
* (no responder) is recorded honestly: the daemon's 75% source re-read remains the adoption backstop.
|
|
219
|
+
* Never throws — renewal failure must be LOUD (log + record), not fatal to the supervisor. */
|
|
220
|
+
async renewDaemonCreds() {
|
|
221
|
+
try {
|
|
222
|
+
const results = await remintDaemonCreds(this.workspaceRoot);
|
|
223
|
+
const resigned = results.filter((r) => r.ok);
|
|
224
|
+
let adoption;
|
|
225
|
+
if (resigned.length) {
|
|
226
|
+
try {
|
|
227
|
+
const reply = await this.ep.requestDeliveryAdmin("reloadCreds", {});
|
|
228
|
+
adoption = reply.ok ? { ok: true, detail: reply.data } : { ok: false, error: reply.error };
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
adoption = { ok: false, error: `no delivery-admin responder (${e.message}) - the daemon's 75% re-read backstop adopts the re-signed file` };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const r of results.filter((x) => !x.ok && !x.skipped))
|
|
235
|
+
console.error(`! credential renewal: could not re-sign ${r.file}: ${r.error} - the daemon dies loud at this cred's expiry unless it is reminted`);
|
|
236
|
+
if (adoption && !adoption.ok)
|
|
237
|
+
console.error(`! credential renewal: daemon adoption failed: ${adoption.error}`);
|
|
238
|
+
writeRenewalRecord(this.workspaceRoot, { ts: new Date().toISOString(), owner: "manager", results, adoption });
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
console.error(`! credential renewal pass failed: ${e.message}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
162
244
|
/** Tear down every managed agent's footprint — the shared teardown for EVERY manager-exit path (#159
|
|
163
245
|
* B2): graceful {@link stop} AND the fail-closed lease-loss exit ({@link renewLease}). A manager exit is
|
|
164
246
|
* a mass agent-exit, and without this its agents' footprints (creds files + `dm_`/`dlv_` durables + ACL
|
|
@@ -182,6 +264,8 @@ export class Manager {
|
|
|
182
264
|
async stop() {
|
|
183
265
|
if (this.leaseTimer)
|
|
184
266
|
clearInterval(this.leaseTimer);
|
|
267
|
+
if (this.credRenewTimer)
|
|
268
|
+
clearInterval(this.credRenewTimer);
|
|
185
269
|
await this.teardownManagedAgents(); // reap agents BEFORE releasing the lease/endpoints (#159 B2)
|
|
186
270
|
await this.ep.releaseManagerLease(this.leaseRevision);
|
|
187
271
|
await this.ep.stop();
|
|
@@ -198,7 +282,7 @@ export class Manager {
|
|
|
198
282
|
this.leaseRevision = await this.ep.renewManagerLease(this.leaseInfo, this.leaseRevision);
|
|
199
283
|
}
|
|
200
284
|
catch (e) {
|
|
201
|
-
console.error(`! manager lost its singleton lease for space "${this.space}" (${e.message})
|
|
285
|
+
console.error(`! manager lost its singleton lease for space "${this.space}" (${e.message}) - shutting down to avoid two managers serving it`);
|
|
202
286
|
if (this.leaseTimer)
|
|
203
287
|
clearInterval(this.leaseTimer);
|
|
204
288
|
// Tear down our managed agents' footprints too (#159 B2) — this exit path leaks them otherwise. Do
|
|
@@ -247,14 +331,17 @@ export class Manager {
|
|
|
247
331
|
// Spawn is a privileged-tier op; reaching it via admin is fine (admin ⊇ privileged powers).
|
|
248
332
|
return this.opStart(args, caller);
|
|
249
333
|
case "launch":
|
|
250
|
-
// SECURITY: manifest launch is operator-only (admin tier). It is
|
|
251
|
-
// — it boots an operator-authored, coordinated policy set from a
|
|
252
|
-
// ownership ledger — so a merely spawn-capable agent (which CAN
|
|
253
|
-
// subject) must not reach it. Gate at the handler like `purge`;
|
|
254
|
-
// boundary because `spawn` grants privileged-subject publish and
|
|
255
|
-
|
|
334
|
+
// SECURITY: on a STATIC mesh, manifest launch is operator-only (admin tier). It is
|
|
335
|
+
// higher-power than `start` — it boots an operator-authored, coordinated policy set from a
|
|
336
|
+
// run spec and underpins the ownership ledger — so a merely spawn-capable agent (which CAN
|
|
337
|
+
// publish to the privileged subject) must not reach it. Gate at the handler like `purge`;
|
|
338
|
+
// the subject alone isn't a boundary because `spawn` grants privileged-subject publish and
|
|
339
|
+
// dispatch is by op here. On a USER mesh, a spawn-scoped operator deploys THEIR OWN team on
|
|
340
|
+
// the privileged tier: opLaunch enforces owner-equality (the spec's apply-time stamped
|
|
341
|
+
// owner === the subject-pinned caller's owner) BEFORE any side effect.
|
|
342
|
+
if (!admin && !this.userMode)
|
|
256
343
|
return { ok: false, error: "launch is admin-only; not allowed on the privileged subject" };
|
|
257
|
-
return this.opLaunch(args, caller);
|
|
344
|
+
return this.opLaunch(args, caller, admin);
|
|
258
345
|
case "stop": {
|
|
259
346
|
if (!name)
|
|
260
347
|
return { ok: false, error: "self-stop not allowed on privileged subject; send it on the self-service subject" };
|
|
@@ -271,9 +358,16 @@ export class Manager {
|
|
|
271
358
|
case "attach":
|
|
272
359
|
return this.opAttach(args, caller, admin);
|
|
273
360
|
case "ps":
|
|
274
|
-
|
|
361
|
+
// USER mesh, privileged tier: `ps` lists only the CALLER's own owner-domain (the admin tier
|
|
362
|
+
// OR a fresh ledger `admin` scope sees all) — cross-owner agent metadata (principals,
|
|
363
|
+
// personas, auth health) is operator-grade. Fail-closed: an unparseable caller sees nothing.
|
|
364
|
+
// Static meshes are unchanged.
|
|
365
|
+
return { ok: true, data: this.list(await this.psOwnerFilter(caller, admin)) };
|
|
366
|
+
case "models":
|
|
367
|
+
return this.opModels(args);
|
|
275
368
|
case "status": {
|
|
276
|
-
|
|
369
|
+
// Same owner-domain bound as `ps`: a cross-owner target reads as absent, never as metadata.
|
|
370
|
+
const a = this.list(await this.psOwnerFilter(caller, admin)).find((x) => x.name === name);
|
|
277
371
|
return a ? { ok: true, data: a } : { ok: false, error: `no agent "${name}"` };
|
|
278
372
|
}
|
|
279
373
|
default:
|
|
@@ -282,15 +376,27 @@ export class Manager {
|
|
|
282
376
|
}
|
|
283
377
|
/** Collapsed despawn/attach authorization (P4b). The caller already reached the privileged or
|
|
284
378
|
* admin tier (cred-gated). On the admin tier any named target is allowed (operator). On the
|
|
285
|
-
* privileged tier a named target is allowed
|
|
286
|
-
*
|
|
287
|
-
*
|
|
379
|
+
* privileged tier a named target is allowed if it's the caller's OWN child (`spawner ==
|
|
380
|
+
* caller`) — and, on a user mesh, if it runs under the CALLER'S OWNER (owner-domain) or the
|
|
381
|
+
* caller's ledger row holds `admin`, read fresh. The policy is the pure
|
|
382
|
+
* {@link authorizeNamedControl}; this wrapper only binds the manager's state (the mode flag +
|
|
383
|
+
* the provider-backed ledger read — a build with no provider authorizes nothing extra,
|
|
384
|
+
* fail-closed via the policy's catch). Error string when denied, `undefined` when allowed. */
|
|
288
385
|
authorizeNamed(target, caller, admin) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
386
|
+
return authorizeNamedControl({
|
|
387
|
+
target: { name: target.name, spawner: target.spawner, userOwner: target.userOwner },
|
|
388
|
+
caller,
|
|
389
|
+
admin,
|
|
390
|
+
userMode: this.userMode,
|
|
391
|
+
scopeOf: (owner, actor) => resolveAuthProvider().actorScope({ dir: userAuthStateDir(this.workspaceRoot, this.space), owner, actor }),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/** The wire PRINCIPAL dot-form a managed agent's presence/control identity carries: user-mode
|
|
395
|
+
* entries already store it in `id`; static mints store the raw nkey there (the durable/teardown
|
|
396
|
+
* key), so the wire form derives under DEV_OWNER. Every comparison against an AUTHENTICATED wire
|
|
397
|
+
* id (presence card.id, control from.id) must go through this, never raw `a.id`. */
|
|
398
|
+
managedPrincipal(a) {
|
|
399
|
+
return a.userOwner ? a.id : principalKey(DEV_OWNER, a.id).key;
|
|
294
400
|
}
|
|
295
401
|
/** Self-despawn (P2b): stop the managed agent whose id == the authenticated caller. The
|
|
296
402
|
* no-name self-op can only ever resolve to the caller's OWN managed entry (ids are unique
|
|
@@ -298,7 +404,7 @@ export class Manager {
|
|
|
298
404
|
* hitting another agent. Non-managed callers (human CLI, the manager itself, observers) find
|
|
299
405
|
* no match and get a loud error, not a silent no-op. */
|
|
300
406
|
opStopSelf(callerId, args) {
|
|
301
|
-
const target = [...this.agents.values()].find((a) => a
|
|
407
|
+
const target = [...this.agents.values()].find((a) => this.managedPrincipal(a) === callerId);
|
|
302
408
|
if (!target)
|
|
303
409
|
return { ok: false, error: `self-stop: caller ${callerId} is not a managed agent` };
|
|
304
410
|
const graceful = args.graceful !== false;
|
|
@@ -332,6 +438,97 @@ export class Manager {
|
|
|
332
438
|
console.error(`stop ${a.name} (${a.id}): ${e.message}`);
|
|
333
439
|
}
|
|
334
440
|
}
|
|
441
|
+
/** USER-MODE spawn provisioning (the gate-1 counterpart to the static mint block): resolve the
|
|
442
|
+
* OWNER (ctl caller's principal, or the manifest's stamped owner — never a payload field),
|
|
443
|
+
* pre-create the principal-keyed durables + ACL row on the ephemeral provisioner, author the
|
|
444
|
+
* ledger grant (the upsert ROTATES the per-agent secret on every start — a non-running agent
|
|
445
|
+
* never holds a standing mint secret), materialize the 0600 secret/sentinel files, and
|
|
446
|
+
* PREFLIGHT the bearer chain once — the spawned agent must never be the first to discover a
|
|
447
|
+
* dead auth plane. Every failure is returned as the refusal sentence, with the grant + files
|
|
448
|
+
* rolled back. */
|
|
449
|
+
async provisionUserAgent(name, opts) {
|
|
450
|
+
const spawnerPr = opts.spawner ? parsePrincipalKey(opts.spawner) : null;
|
|
451
|
+
const owner = opts.specOwner ?? (spawnerPr && spawnerPr.owner.startsWith("u_") ? spawnerPr.owner : undefined);
|
|
452
|
+
if (!owner)
|
|
453
|
+
return {
|
|
454
|
+
error: `user-auth space "${this.space}": no owner for this spawn - call it from a user-mode session (\`cotal login\` then \`cotal spawn\`), or apply a manifest as a logged-in operator`,
|
|
455
|
+
};
|
|
456
|
+
let provider;
|
|
457
|
+
try {
|
|
458
|
+
provider = resolveAuthProvider();
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
return { error: e.message };
|
|
462
|
+
}
|
|
463
|
+
const dir = userAuthStateDir(this.workspaceRoot, this.space);
|
|
464
|
+
// The agent's capability scope rides its ledger row (act.scope in every bearer) — same
|
|
465
|
+
// vocabulary as static capabilities; the broker maps them to the ctl tiers. `role:<r>` tokens
|
|
466
|
+
// pass through too (a persona may hold delegable roles) — the ledger's envelope walk still
|
|
467
|
+
// attenuates every one of these against the spawner chain.
|
|
468
|
+
const scope = (opts.capabilities ?? []).filter((c) => c === "spawn" || c === "admin" || /^role:[A-Za-z0-9_-]+$/.test(c));
|
|
469
|
+
const credsDir = join(authDir(this.workspaceRoot), "creds");
|
|
470
|
+
const tokenPath = join(credsDir, `${name}.actor-token`);
|
|
471
|
+
const sentinelPath = join(credsDir, `${name}.sentinel.creds`);
|
|
472
|
+
const healthPath = join(credsDir, `${name}.auth-health.json`);
|
|
473
|
+
try {
|
|
474
|
+
// The GRANT first — it is the envelope-rule enforcement point (a delegation must sit within
|
|
475
|
+
// the spawner's own grant), so a refused delegation exits here having touched nothing beyond
|
|
476
|
+
// the ledger: no durables, no broker footprint, nothing for a corrected respawn to race.
|
|
477
|
+
const grant = await provider.grantAgent({
|
|
478
|
+
dir,
|
|
479
|
+
space: this.space,
|
|
480
|
+
owner,
|
|
481
|
+
actor: name,
|
|
482
|
+
scope,
|
|
483
|
+
allowSubscribe: opts.allowSubscribe,
|
|
484
|
+
allowPublish: opts.allowPublish ?? [],
|
|
485
|
+
role: opts.role,
|
|
486
|
+
parent: spawnerPr ? opts.spawner : undefined,
|
|
487
|
+
label: opts.label,
|
|
488
|
+
});
|
|
489
|
+
// Durables + ACL row, principal-keyed — the same onboarding as static agents minus the mint
|
|
490
|
+
// (a user agent's credential is its bearer, minted by the callout per connect).
|
|
491
|
+
await this.withProvisioner((prov) => provisionAgentDurables(prov, { owner, actor: name }, {
|
|
492
|
+
subscribe: opts.subscribe,
|
|
493
|
+
allowSubscribe: opts.allowSubscribe,
|
|
494
|
+
role: opts.role,
|
|
495
|
+
}));
|
|
496
|
+
mkSecretDir(credsDir);
|
|
497
|
+
writeSecretFile(tokenPath, grant.actorToken);
|
|
498
|
+
writeSecretFile(sentinelPath, grant.sentinelCreds);
|
|
499
|
+
rmSync(healthPath, { force: true }); // a fresh start opens a fresh health window
|
|
500
|
+
const bearerCmd = [
|
|
501
|
+
// The manager's own invocation prefix (node + loader flags + the cotal entry) — the agent
|
|
502
|
+
// process execs this argv for every bearer, so it must resolve from ANY cwd. Correct
|
|
503
|
+
// whenever the manager runs under a real `cotal` entry (supervise/up); a test constructing
|
|
504
|
+
// Manager directly never reaches this branch (user meshes boot through the CLI).
|
|
505
|
+
process.execPath,
|
|
506
|
+
...process.execArgv,
|
|
507
|
+
process.argv[1],
|
|
508
|
+
provider.agentBearerCommand,
|
|
509
|
+
"--dir", dir,
|
|
510
|
+
"--space", this.space,
|
|
511
|
+
"--owner", owner,
|
|
512
|
+
"--actor", name,
|
|
513
|
+
"--token-file", tokenPath,
|
|
514
|
+
"--health-file", healthPath,
|
|
515
|
+
];
|
|
516
|
+
await execBearerPreflight(bearerCmd);
|
|
517
|
+
return { owner, launch: { owner, actor: name, sentinelCredsPath: sentinelPath, bearerCmd } };
|
|
518
|
+
}
|
|
519
|
+
catch (e) {
|
|
520
|
+
// Roll back everything this attempt materialized — a refused spawn must leave no standing
|
|
521
|
+
// secret, no ledger row, no durable footprint — and AWAIT the broker teardown: the caller
|
|
522
|
+
// may respawn the moment it reads the refusal, and a detached teardown would race (and
|
|
523
|
+
// delete) that fresh spawn's just-provisioned durables.
|
|
524
|
+
await provider.revokeAgent({ dir, owner, actor: name }).catch(() => { });
|
|
525
|
+
rmSync(tokenPath, { force: true });
|
|
526
|
+
rmSync(sentinelPath, { force: true });
|
|
527
|
+
rmSync(healthPath, { force: true });
|
|
528
|
+
await this.deprovision({ id: principalKey(owner, name).key, name, userOwner: owner }).catch((err) => console.error(`rollback deprovision ${name}: ${err.message}`));
|
|
529
|
+
return { error: `agent auth preflight failed for "${name}": ${e.message}` };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
335
532
|
/** Drop a live agent's slot. When `floor` is set and the agent died young (lived less than
|
|
336
533
|
* MIN_LIFETIME), push a cooling stamp so the freed slot still counts toward the ceiling until it
|
|
337
534
|
* expires — flooring the RECYCLE, not the call, so both free paths (despawn + exit/reap) are
|
|
@@ -349,10 +546,10 @@ export class Manager {
|
|
|
349
546
|
// makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
|
|
350
547
|
void this.deprovision(a).catch((e) => console.error(`deprovision ${a.name} (${a.id}): ${e.message}`));
|
|
351
548
|
}
|
|
352
|
-
/** Tear down a departed agent's minted footprint (#159 B2, auth mode): its
|
|
353
|
-
* (`
|
|
549
|
+
/** Tear down a departed agent's minted footprint (#159 B2, auth mode): its local-principal durables
|
|
550
|
+
* (`dm_local-<id>`, `dlv_local-<id>`), its read-ACL row, and its creds file — everything the spawn's
|
|
354
551
|
* `provisionAgent` + creds-write left behind. Mints an EPHEMERAL, TARGET-PINNED `deprovisioner` cred
|
|
355
|
-
* (mirrors the ephemeral `provisioner`/`purger`): it can delete only THIS agent's
|
|
552
|
+
* (mirrors the ephemeral `provisioner`/`purger`): it can delete only THIS agent's local-principal footprint,
|
|
356
553
|
* never a peer's and never the role-shared `svc_<role>` (which its siblings still bind). Open mesh →
|
|
357
554
|
* no-op (nothing was minted). Idempotent at the broker (missing consumer / ACL row = no-op) and on
|
|
358
555
|
* disk (`force` tolerates an absent creds file, e.g. a ledgered deploy that wrote none).
|
|
@@ -368,6 +565,25 @@ export class Manager {
|
|
|
368
565
|
// departed agent, so it must not survive even if the broker teardown below fails or times out. The
|
|
369
566
|
// teardown mints its OWN deprovisioner cred (not this file), so removing it early is independent.
|
|
370
567
|
rmSync(join(authDir(this.workspaceRoot), "creds", `${a.name}.creds`), { force: true });
|
|
568
|
+
if (a.userOwner) {
|
|
569
|
+
// USER MODE: this teardown IS revocation, not just footprint reduction — the ledger row is
|
|
570
|
+
// the agent's standing mint authority, so delete it (next exchange refused, next connect
|
|
571
|
+
// denied) and shred the secret/sentinel/health files. A copied actor token dies here; a
|
|
572
|
+
// still-LIVE connection ends at its bearer-bound JWT expiry (≤ the agent TTL).
|
|
573
|
+
const credsDir = join(authDir(this.workspaceRoot), "creds");
|
|
574
|
+
for (const f of [`${a.name}.actor-token`, `${a.name}.sentinel.creds`, `${a.name}.auth-health.json`])
|
|
575
|
+
rmSync(join(credsDir, f), { force: true });
|
|
576
|
+
try {
|
|
577
|
+
await resolveAuthProvider().revokeAgent({
|
|
578
|
+
dir: userAuthStateDir(this.workspaceRoot, this.space),
|
|
579
|
+
owner: a.userOwner,
|
|
580
|
+
actor: a.name,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
console.error(`revoke agent grant ${a.name}: ${e.message}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
371
587
|
const creds = await mintCreds(this.auth, newIdentity(), "deprovisioner", { deprovisionTarget: a.id });
|
|
372
588
|
// Bound the detached broker teardown so a wedged broker can't leave the deprovision promise pending
|
|
373
589
|
// forever with no log — the timeout rejects into freeSlot's fail-loud `.catch` (paired with the
|
|
@@ -433,6 +649,12 @@ export class Manager {
|
|
|
433
649
|
// but a raw control message could otherwise slip an empty value through and silently start fresh.
|
|
434
650
|
if (args.resume !== undefined && !String(args.resume).trim())
|
|
435
651
|
return Promise.resolve({ ok: false, error: "resume: session id must not be empty" });
|
|
652
|
+
if (args.variant !== undefined && !String(args.variant).trim())
|
|
653
|
+
return Promise.resolve({ ok: false, error: "variant: must not be empty" });
|
|
654
|
+
// Opaque launch options, when present, must be a mapping — a raw control message could send a
|
|
655
|
+
// scalar/array (the CLI never does). Core doesn't interpret the keys; the connector validates them.
|
|
656
|
+
if (args.launchOptions !== undefined && (typeof args.launchOptions !== "object" || args.launchOptions === null || Array.isArray(args.launchOptions)))
|
|
657
|
+
return Promise.resolve({ ok: false, error: "launchOptions: expected a key:value mapping" });
|
|
436
658
|
// ACL overrides arrive as string arrays or not at all — a malformed value is a bad request,
|
|
437
659
|
// not something to coerce (no fallbacks).
|
|
438
660
|
const strList = (v, flag) => {
|
|
@@ -458,6 +680,8 @@ export class Manager {
|
|
|
458
680
|
config: args.config ? String(args.config) : undefined,
|
|
459
681
|
identity: args.identity ? String(args.identity) : undefined,
|
|
460
682
|
model: args.model ? String(args.model) : undefined,
|
|
683
|
+
variant: args.variant ? String(args.variant) : undefined,
|
|
684
|
+
launchOptions: args.launchOptions,
|
|
461
685
|
resume: args.resume ? String(args.resume) : undefined,
|
|
462
686
|
transcript: typeof args.transcript === "boolean" ? args.transcript : undefined,
|
|
463
687
|
cwd: args.cwd ? String(args.cwd) : undefined,
|
|
@@ -468,6 +692,70 @@ export class Manager {
|
|
|
468
692
|
shareTools: args.shareTools !== undefined ? String(args.shareTools) : undefined,
|
|
469
693
|
}, caller);
|
|
470
694
|
}
|
|
695
|
+
/** Return connector-provided model catalogs for selector UIs. Optional by connector: a host with no
|
|
696
|
+
* local model-list API reports `supported:false` rather than blocking the manager. */
|
|
697
|
+
async opModels(args) {
|
|
698
|
+
const requested = String(args.agent ?? "").trim();
|
|
699
|
+
const refresh = args.refresh === true;
|
|
700
|
+
const one = async (connector) => {
|
|
701
|
+
if (!connector.listModels)
|
|
702
|
+
return { agent: connector.name, supported: false, models: [] };
|
|
703
|
+
const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
|
|
704
|
+
if (missing.length)
|
|
705
|
+
return {
|
|
706
|
+
agent: connector.name,
|
|
707
|
+
supported: true,
|
|
708
|
+
models: [],
|
|
709
|
+
error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found`,
|
|
710
|
+
};
|
|
711
|
+
try {
|
|
712
|
+
const catalog = await connector.listModels({ refresh });
|
|
713
|
+
return { agent: connector.name, supported: true, ...catalog };
|
|
714
|
+
}
|
|
715
|
+
catch (e) {
|
|
716
|
+
return { agent: connector.name, supported: true, models: [], error: e.message };
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
if (requested) {
|
|
720
|
+
let connector;
|
|
721
|
+
try {
|
|
722
|
+
connector = registry.resolve("connector", requested);
|
|
723
|
+
}
|
|
724
|
+
catch (e) {
|
|
725
|
+
return { ok: false, error: e.message };
|
|
726
|
+
}
|
|
727
|
+
const result = await one(connector);
|
|
728
|
+
return result.error ? { ok: false, error: result.error } : { ok: true, data: result };
|
|
729
|
+
}
|
|
730
|
+
return { ok: true, data: await Promise.all(registry.all("connector").map(one)) };
|
|
731
|
+
}
|
|
732
|
+
/** The owner-domain bound on `ps`/`status` metadata: on a USER mesh, a privileged-tier caller
|
|
733
|
+
* sees only agents under its OWN subject-pinned owner. Two ways to see ALL owners: the admin
|
|
734
|
+
* TIER (operator), or a fresh ledger `admin` SCOPE on the caller's row — the SAME authority that
|
|
735
|
+
* lets `stop`/`attach` reach cross-owner agents ({@link authorizeNamedControl}). Without this the
|
|
736
|
+
* two surfaces disagree: an admin operator could cross-owner stop an agent it could not list.
|
|
737
|
+
* Read fresh so a revoked admin loses visibility on its next call; a read failure and an
|
|
738
|
+
* unparseable caller both fall closed (own-owner / matches-nothing). Static meshes are unbounded. */
|
|
739
|
+
async psOwnerFilter(caller, admin) {
|
|
740
|
+
if (!this.userMode || admin)
|
|
741
|
+
return undefined;
|
|
742
|
+
const key = parsePrincipalKey(caller);
|
|
743
|
+
if (!key)
|
|
744
|
+
return NO_OWNER_MATCHES;
|
|
745
|
+
try {
|
|
746
|
+
const scope = await resolveAuthProvider().actorScope({
|
|
747
|
+
dir: userAuthStateDir(this.workspaceRoot, this.space),
|
|
748
|
+
owner: key.owner,
|
|
749
|
+
actor: key.actor,
|
|
750
|
+
});
|
|
751
|
+
if (scope?.includes("admin"))
|
|
752
|
+
return undefined;
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
/* unreadable ledger authorizes nothing extra: fall through to the own-owner bound */
|
|
756
|
+
}
|
|
757
|
+
return key.owner;
|
|
758
|
+
}
|
|
471
759
|
/** Boot one resolved agent from a mesh-manifest launch spec, for `cotal spawn -f` onto a RUNNING
|
|
472
760
|
* manager. The request carries a `{ runId, name }`, NEVER a path: the manager derives + validates
|
|
473
761
|
* `.cotal/run/<runId>.json` itself ({@link launchSpecForRun} — token-safe id, no-follow,
|
|
@@ -475,8 +763,9 @@ export class Manager {
|
|
|
475
763
|
* agent's transient persona, and spawns via the same `startAgent({ resolved })` path as
|
|
476
764
|
* `supervise --launch`. The reply is enriched for the ownership ledger: the SPAWNED
|
|
477
765
|
* (collision-numbered) name + nkey id creds are filed under, plus the manifest `requested` name,
|
|
478
|
-
* `runId`, and resolved `hash`.
|
|
479
|
-
|
|
766
|
+
* `runId`, and resolved `hash`. USER mesh: a privileged-tier launch is owner-equality-authorized
|
|
767
|
+
* (spec owner === caller owner) before any side effect; the admin tier keeps operator behavior. */
|
|
768
|
+
async opLaunch(args, caller, admin) {
|
|
480
769
|
const runId = String(args.runId ?? "").trim();
|
|
481
770
|
const name = String(args.name ?? "").trim();
|
|
482
771
|
if (!runId || !name)
|
|
@@ -491,6 +780,18 @@ export class Manager {
|
|
|
491
780
|
const la = spec.agents.find((a) => a.name === name);
|
|
492
781
|
if (!la)
|
|
493
782
|
return { ok: false, error: `no agent "${name}" in launch spec for run ${runId}` };
|
|
783
|
+
// USER mesh: a manifest launch runs under the spec's apply-time owner, never the ctl caller —
|
|
784
|
+
// fail loud on a spec without one rather than guess (core `MeshLaunchSpec.owner`).
|
|
785
|
+
if (this.userMode && !spec.owner)
|
|
786
|
+
return { ok: false, error: `user-auth space "${this.space}": launch spec for run ${runId} carries no owner - re-apply the manifest as a logged-in operator` };
|
|
787
|
+
if (this.userMode) {
|
|
788
|
+
// Privileged-tier user-mode launch: owner-equality (spec owner === caller owner), decided by
|
|
789
|
+
// the pure policy BEFORE materializePersona or any other side effect, so a denied
|
|
790
|
+
// cross-owner launch writes nothing. Admin tier passes through it unchanged.
|
|
791
|
+
const denied = authorizeLaunch({ specOwner: spec.owner, caller, admin, runId });
|
|
792
|
+
if (denied)
|
|
793
|
+
return { ok: false, error: denied };
|
|
794
|
+
}
|
|
494
795
|
let configPath;
|
|
495
796
|
try {
|
|
496
797
|
configPath = materializePersona(this.workspaceRoot, runId, la);
|
|
@@ -498,7 +799,7 @@ export class Manager {
|
|
|
498
799
|
catch (e) {
|
|
499
800
|
return { ok: false, error: e.message };
|
|
500
801
|
}
|
|
501
|
-
const reply = await this.startAgent(launchAgentToStartOpts(la, configPath), caller);
|
|
802
|
+
const reply = await this.startAgent(launchAgentToStartOpts(la, configPath, spec.owner), caller);
|
|
502
803
|
if (reply.ok)
|
|
503
804
|
// `data.name` stays the spawned (numbered) identity — what creds are filed under and the ledger
|
|
504
805
|
// keys on; `requested`/`runId`/`hash` give the CLI the manifest name + drift hash for the ledger.
|
|
@@ -548,7 +849,7 @@ export class Manager {
|
|
|
548
849
|
else {
|
|
549
850
|
configPath = agentFilePath(this.workspaceRoot, ref);
|
|
550
851
|
if (!existsSync(configPath))
|
|
551
|
-
return { ok: false, error: `no persona "${ref}"
|
|
852
|
+
return { ok: false, error: `no persona "${ref}" - ${configPath} not found; create it or pass --config (see \`cotal personas list\`)` };
|
|
552
853
|
}
|
|
553
854
|
// Connector + harness preflight before reserving a slot or minting — a missing connector or a
|
|
554
855
|
// missing `claude`/`opencode` binary fails here with a clear name, not obscurely at process
|
|
@@ -562,14 +863,14 @@ export class Manager {
|
|
|
562
863
|
}
|
|
563
864
|
const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
|
|
564
865
|
if (missing.length)
|
|
565
|
-
return { ok: false, error: `${agent} harness needs ${missing.join(", ")} on PATH
|
|
866
|
+
return { ok: false, error: `${agent} harness needs ${missing.join(", ")} on PATH - not found` };
|
|
566
867
|
// Resume is a connector capability: reject an unsupported resume HERE, before the reserve/mint, so
|
|
567
868
|
// it can never provision creds + durables and then throw at buildLaunch (mint-then-orphan). Same
|
|
568
869
|
// reject-before-side-effects window as the harness preflight above; buildLaunch stays the backstop.
|
|
569
870
|
if (opts.resume && !connector.supportsResume)
|
|
570
871
|
return { ok: false, error: `${agent} connector does not support resuming an existing session (resume)` };
|
|
571
872
|
// Resolve the launch profile: IDENTITY (free-form `name:`) + role + read/post ACL + capabilities
|
|
572
|
-
// + model. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
|
|
873
|
+
// + model/variant. Either from a fully-resolved manifest launch object (`opts.resolved`, whose `config`
|
|
573
874
|
// is a materialized transient persona — the file is NOT the access authority), or from the
|
|
574
875
|
// persona file. The number rides the IDENTITY (socrates → socrates-2), not the file ref — a
|
|
575
876
|
// redelivered identical spawn yields a fresh numbered agent (MAX_AGENTS bounds the blast radius).
|
|
@@ -580,6 +881,8 @@ export class Manager {
|
|
|
580
881
|
let allowPublish;
|
|
581
882
|
let capabilities;
|
|
582
883
|
let model = opts.model;
|
|
884
|
+
let variant = opts.variant;
|
|
885
|
+
let launchOptions = opts.launchOptions;
|
|
583
886
|
if (opts.resolved) {
|
|
584
887
|
// A manifest launch is the access + identity authority: imperative overrides arriving
|
|
585
888
|
// alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
|
|
@@ -593,6 +896,8 @@ export class Manager {
|
|
|
593
896
|
allowPublish = r.allowPublish;
|
|
594
897
|
capabilities = r.capabilities;
|
|
595
898
|
model = opts.model ?? r.model;
|
|
899
|
+
variant = opts.variant ?? r.variant;
|
|
900
|
+
launchOptions = mergeLaunchOptions(r.launchOptions, opts.launchOptions);
|
|
596
901
|
}
|
|
597
902
|
else {
|
|
598
903
|
let def;
|
|
@@ -616,10 +921,14 @@ export class Manager {
|
|
|
616
921
|
allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? subscribe ?? ["general"];
|
|
617
922
|
allowPublish = opts.allowPublish ?? def.allowPublish;
|
|
618
923
|
capabilities = def.capabilities;
|
|
924
|
+
variant = opts.variant ?? def.variant;
|
|
925
|
+
launchOptions = mergeLaunchOptions(def.launchOptions, opts.launchOptions);
|
|
619
926
|
}
|
|
620
927
|
const idErr = this.nameError(identityName);
|
|
621
928
|
if (idErr)
|
|
622
929
|
return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${configPath}: ${idErr}` };
|
|
930
|
+
if (variant && !connector.supportsModelVariant)
|
|
931
|
+
return { ok: false, error: `${agent} connector does not support model variants (variant)` };
|
|
623
932
|
const name = this.uniqueName(identityName);
|
|
624
933
|
this.reserved.add(name);
|
|
625
934
|
// Transcript mirroring (opt-in: `--transcript` / COTAL_TRANSCRIPT_DEFAULT=1) → grant the agent pub
|
|
@@ -640,6 +949,11 @@ export class Manager {
|
|
|
640
949
|
// Set once the agent's creds + durables are minted; cleared the moment a live slot takes ownership
|
|
641
950
|
// (`agents.set`, after which freeSlot deprovisions on exit). If it survives to `finally`, the spawn
|
|
642
951
|
// threw AFTER minting (buildLaunch / runtime.spawn) — tear the orphan down so no footprint leaks (#159 B).
|
|
952
|
+
// Set once the agent's footprint (durables + creds, or the user-mode grant + secret files)
|
|
953
|
+
// exists; cleared when a live slot takes ownership. If it survives to `finally`, the spawn threw
|
|
954
|
+
// AFTER provisioning (buildLaunch / runtime.spawn) — the orphan-rollback tears it down. Carries
|
|
955
|
+
// `userOwner` for a user-mode spawn so that rollback runs the revoke+shred branch, not just the
|
|
956
|
+
// static durable teardown (the freelance found this window leaking the managed grant + files).
|
|
643
957
|
let provisioned;
|
|
644
958
|
try {
|
|
645
959
|
// A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
|
|
@@ -649,7 +963,28 @@ export class Manager {
|
|
|
649
963
|
// spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
|
|
650
964
|
// subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
|
|
651
965
|
let credsPath;
|
|
652
|
-
|
|
966
|
+
let userLaunch;
|
|
967
|
+
let userOwner;
|
|
968
|
+
if (this.userMode) {
|
|
969
|
+
const prep = await this.provisionUserAgent(name, {
|
|
970
|
+
spawner,
|
|
971
|
+
specOwner: opts.owner,
|
|
972
|
+
subscribe,
|
|
973
|
+
allowSubscribe,
|
|
974
|
+
allowPublish,
|
|
975
|
+
role,
|
|
976
|
+
capabilities,
|
|
977
|
+
label: ref,
|
|
978
|
+
});
|
|
979
|
+
if ("error" in prep) {
|
|
980
|
+
this.reserved.delete(name);
|
|
981
|
+
return { ok: false, error: prep.error };
|
|
982
|
+
}
|
|
983
|
+
userLaunch = prep.launch;
|
|
984
|
+
userOwner = prep.owner;
|
|
985
|
+
provisioned = { id: principalKey(prep.owner, name).key, name, userOwner: prep.owner };
|
|
986
|
+
}
|
|
987
|
+
else if (this.auth) {
|
|
653
988
|
// Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped creds
|
|
654
989
|
// — the shared onboarding step (provisionAgent). It runs on a short-lived PROVISIONER connection
|
|
655
990
|
// (NOT the supervisor's long-lived endpoint), so the DM/DLV consumer-create surface exists only
|
|
@@ -678,11 +1013,16 @@ export class Manager {
|
|
|
678
1013
|
space: this.space,
|
|
679
1014
|
name,
|
|
680
1015
|
role,
|
|
681
|
-
|
|
1016
|
+
// User mode: the principal IS the identity (the endpoint derives card.id from owner+actor);
|
|
1017
|
+
// no nkey id, no static creds.
|
|
1018
|
+
id: userLaunch ? undefined : identity.id,
|
|
682
1019
|
creds: credsPath,
|
|
1020
|
+
userAuth: userLaunch,
|
|
683
1021
|
servers: this.servers,
|
|
684
1022
|
configPath,
|
|
685
1023
|
model,
|
|
1024
|
+
variant,
|
|
1025
|
+
launchOptions,
|
|
686
1026
|
// Fork an existing session into the mesh. Taken straight from `opts.resume` (the imperative
|
|
687
1027
|
// control arg), never from `opts.resolved` — so the manifest launch path carries no resume by
|
|
688
1028
|
// construction. An unsupported connector throws here before any process is spawned.
|
|
@@ -708,8 +1048,8 @@ export class Manager {
|
|
|
708
1048
|
name,
|
|
709
1049
|
role,
|
|
710
1050
|
agent,
|
|
711
|
-
id: identity.id,
|
|
712
|
-
seed: identity.seed,
|
|
1051
|
+
id: userLaunch ? principalKey(userLaunch.owner, name).key : identity.id,
|
|
1052
|
+
...(userLaunch ? { userOwner } : { seed: identity.seed }),
|
|
713
1053
|
spawner: spawner ?? this.ep.ref().id,
|
|
714
1054
|
startedAt: Date.now(),
|
|
715
1055
|
handle,
|
|
@@ -731,7 +1071,10 @@ export class Manager {
|
|
|
731
1071
|
this.watchExit(managed);
|
|
732
1072
|
if (!readiness.ok)
|
|
733
1073
|
return { ok: false, error: readiness.detail }; // uncertain — non-success, but kept
|
|
734
|
-
|
|
1074
|
+
// Reply with the id the slot actually carries (user-mode: the owner.actor principal —
|
|
1075
|
+
// presence, ps, and the manifest ownership ledger all key on it; the throwaway static nkey
|
|
1076
|
+
// would never match and down -f would treat the agent as foreign).
|
|
1077
|
+
return { ok: true, data: { name, role, agent, id: managed.id, mode: handle.kind } };
|
|
735
1078
|
}
|
|
736
1079
|
catch (e) {
|
|
737
1080
|
// Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
|
|
@@ -770,7 +1113,11 @@ export class Manager {
|
|
|
770
1113
|
/* tmux/cmux stream no exit — presence-or-backstop only */
|
|
771
1114
|
}
|
|
772
1115
|
const s = session;
|
|
773
|
-
|
|
1116
|
+
// Presence cards carry the wire PRINCIPAL dot-form (`<owner>.<actor>`), never a raw nkey — match
|
|
1117
|
+
// through managedPrincipal or a static launch can never be seen joining (every static spawn would
|
|
1118
|
+
// resolve "uncertain"; caught by the lifecycle e2e).
|
|
1119
|
+
const wanted = this.managedPrincipal(a);
|
|
1120
|
+
const joined = () => this.ep.getRoster().some((p) => p.card.id === wanted && p.status !== "offline");
|
|
774
1121
|
return await new Promise((resolve) => {
|
|
775
1122
|
let done = false;
|
|
776
1123
|
let timer;
|
|
@@ -798,13 +1145,13 @@ export class Manager {
|
|
|
798
1145
|
void (async () => {
|
|
799
1146
|
const tail = this.tail(await s.backlog());
|
|
800
1147
|
this.onAgentExit(a);
|
|
801
|
-
finish({ ok: false, detail: `${a.name} exited on launch${tail ? `
|
|
1148
|
+
finish({ ok: false, detail: `${a.name} exited on launch${tail ? ` - last output: ${tail}` : ""}` });
|
|
802
1149
|
})();
|
|
803
1150
|
};
|
|
804
1151
|
timer = setTimeout(() => finish({
|
|
805
1152
|
ok: false,
|
|
806
1153
|
uncertain: true,
|
|
807
|
-
detail: `${a.name} (${a.id}): launch status uncertain
|
|
1154
|
+
detail: `${a.name} (${a.id}): launch status uncertain - no process exit and no mesh presence within ${Math.round(this.readinessTimeoutMs / 1000)}s; it may still be booting or stuck before connector startup. Inspect with \`cotal attach ${a.name}\` / \`cotal ps\`, or stop it to clean up.`,
|
|
808
1155
|
}), this.readinessTimeoutMs);
|
|
809
1156
|
unsubExit = s ? s.onExit(onExit) : () => { };
|
|
810
1157
|
this.ep.on("presence", onPresence);
|
|
@@ -857,12 +1204,12 @@ export class Manager {
|
|
|
857
1204
|
this.cooling = this.cooling.filter((stamp) => stamp > now);
|
|
858
1205
|
return this.cooling.length;
|
|
859
1206
|
}
|
|
860
|
-
opStop(args, caller, admin) {
|
|
1207
|
+
async opStop(args, caller, admin) {
|
|
861
1208
|
const name = String(args.name ?? "").trim();
|
|
862
1209
|
const a = this.agents.get(name);
|
|
863
1210
|
if (!a)
|
|
864
1211
|
return { ok: false, error: `no agent "${name}"` };
|
|
865
|
-
const denied = this.authorizeNamed(a, caller, admin);
|
|
1212
|
+
const denied = await this.authorizeNamed(a, caller, admin);
|
|
866
1213
|
if (denied)
|
|
867
1214
|
return { ok: false, error: denied };
|
|
868
1215
|
const graceful = args.graceful !== false;
|
|
@@ -950,7 +1297,7 @@ export class Manager {
|
|
|
950
1297
|
return { ok: false, error: e.message };
|
|
951
1298
|
}
|
|
952
1299
|
if (!admin && def.owner !== caller) {
|
|
953
|
-
const owner = def.owner ? `owned by ${def.owner}` : "operator-owned (legacy file
|
|
1300
|
+
const owner = def.owner ? `owned by ${def.owner}` : "operator-owned (legacy file - no agent owner)";
|
|
954
1301
|
return { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` };
|
|
955
1302
|
}
|
|
956
1303
|
// PATCH content: overwrite model only when provided, so a persona-only redefine can't wipe an existing model.
|
|
@@ -971,14 +1318,14 @@ export class Manager {
|
|
|
971
1318
|
}
|
|
972
1319
|
return { ok: true, data: { name, path } };
|
|
973
1320
|
}
|
|
974
|
-
opAttach(args, caller, admin) {
|
|
1321
|
+
async opAttach(args, caller, admin) {
|
|
975
1322
|
const name = String(args.name ?? "").trim();
|
|
976
1323
|
const a = this.agents.get(name);
|
|
977
1324
|
if (!a)
|
|
978
1325
|
return { ok: false, error: `no agent "${name}"` };
|
|
979
|
-
// attach grants terminal read+write — same
|
|
980
|
-
// privileged tier, any agent on admin.
|
|
981
|
-
const denied = this.authorizeNamed(a, caller, admin);
|
|
1326
|
+
// attach grants terminal read+write — same scoping as despawn: own child (and, on a user
|
|
1327
|
+
// mesh, the caller's owner-domain) on the privileged tier, any agent on admin.
|
|
1328
|
+
const denied = await this.authorizeNamed(a, caller, admin);
|
|
982
1329
|
if (denied)
|
|
983
1330
|
return { ok: false, error: denied };
|
|
984
1331
|
// Only pty streams over the WS attach endpoint. tmux/cmux are watched natively, and
|
|
@@ -995,21 +1342,34 @@ export class Manager {
|
|
|
995
1342
|
return { ok: true, data: { ws: this.attach.url(name) } };
|
|
996
1343
|
}
|
|
997
1344
|
/** Managed agents cross-referenced with live presence (the manager sees the roster). */
|
|
998
|
-
|
|
1345
|
+
/** `ownerFilter`: restrict to agents whose spawn-time stored `userOwner` equals it (the ps/status
|
|
1346
|
+
* owner-domain bound); undefined = unbounded. {@link NO_OWNER_MATCHES} matches nothing. */
|
|
1347
|
+
list(ownerFilter) {
|
|
999
1348
|
const roster = new Map(this.ep.getRoster().map((p) => [p.card.name, p]));
|
|
1000
|
-
return [...this.agents.values()].map((a) =>
|
|
1001
|
-
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1349
|
+
return [...this.agents.values()].filter((a) => ownerFilter === undefined || a.userOwner === ownerFilter).map((a) => {
|
|
1350
|
+
// USER MODE: a detached agent's bearer-refresh death is silent everywhere except here — its
|
|
1351
|
+
// bearer command writes each attempt's outcome to the health file, and `ps` renders it
|
|
1352
|
+
// FAIL-CLOSED: a failed record is the failure + repair sentence; a missing/malformed or
|
|
1353
|
+
// stale record on a live agent is auth-unknown/auth-stale, NEVER silently healthy.
|
|
1354
|
+
const health = a.userOwner
|
|
1355
|
+
? agentAuthState(join(authDir(this.workspaceRoot), "creds", `${a.name}.auth-health.json`))
|
|
1356
|
+
: undefined;
|
|
1357
|
+
return {
|
|
1358
|
+
name: a.name,
|
|
1359
|
+
// The spawned agent's id (nkey, or the user-mode principal) — lets an operator tool (e.g.
|
|
1360
|
+
// `cotal down -f`) match a ledger entry by name AND id before stopping, so it never stops a
|
|
1361
|
+
// same-named foreign agent.
|
|
1362
|
+
id: a.id,
|
|
1363
|
+
role: a.role,
|
|
1364
|
+
agent: a.agent,
|
|
1365
|
+
space: this.space,
|
|
1366
|
+
mode: a.handle.kind,
|
|
1367
|
+
status: a.handle.status(),
|
|
1368
|
+
uptimeMs: Date.now() - a.startedAt,
|
|
1369
|
+
mesh: roster.get(a.name)?.status ?? "absent",
|
|
1370
|
+
...(health && health.state !== "ok" ? { authHealth: health.state, authReason: health.reason } : {}),
|
|
1371
|
+
};
|
|
1372
|
+
});
|
|
1013
1373
|
}
|
|
1014
1374
|
}
|
|
1015
1375
|
//# sourceMappingURL=manager.js.map
|