@cotal-ai/manager 0.10.1 → 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 +2 -1
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +28 -12
- package/dist/launch.js.map +1 -1
- package/dist/manager.d.ts +54 -7
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +373 -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,11 +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)) };
|
|
275
366
|
case "models":
|
|
276
367
|
return this.opModels(args);
|
|
277
368
|
case "status": {
|
|
278
|
-
|
|
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);
|
|
279
371
|
return a ? { ok: true, data: a } : { ok: false, error: `no agent "${name}"` };
|
|
280
372
|
}
|
|
281
373
|
default:
|
|
@@ -284,15 +376,27 @@ export class Manager {
|
|
|
284
376
|
}
|
|
285
377
|
/** Collapsed despawn/attach authorization (P4b). The caller already reached the privileged or
|
|
286
378
|
* admin tier (cred-gated). On the admin tier any named target is allowed (operator). On the
|
|
287
|
-
* privileged tier a named target is allowed
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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. */
|
|
290
385
|
authorizeNamed(target, caller, admin) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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;
|
|
296
400
|
}
|
|
297
401
|
/** Self-despawn (P2b): stop the managed agent whose id == the authenticated caller. The
|
|
298
402
|
* no-name self-op can only ever resolve to the caller's OWN managed entry (ids are unique
|
|
@@ -300,7 +404,7 @@ export class Manager {
|
|
|
300
404
|
* hitting another agent. Non-managed callers (human CLI, the manager itself, observers) find
|
|
301
405
|
* no match and get a loud error, not a silent no-op. */
|
|
302
406
|
opStopSelf(callerId, args) {
|
|
303
|
-
const target = [...this.agents.values()].find((a) => a
|
|
407
|
+
const target = [...this.agents.values()].find((a) => this.managedPrincipal(a) === callerId);
|
|
304
408
|
if (!target)
|
|
305
409
|
return { ok: false, error: `self-stop: caller ${callerId} is not a managed agent` };
|
|
306
410
|
const graceful = args.graceful !== false;
|
|
@@ -334,6 +438,97 @@ export class Manager {
|
|
|
334
438
|
console.error(`stop ${a.name} (${a.id}): ${e.message}`);
|
|
335
439
|
}
|
|
336
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
|
+
}
|
|
337
532
|
/** Drop a live agent's slot. When `floor` is set and the agent died young (lived less than
|
|
338
533
|
* MIN_LIFETIME), push a cooling stamp so the freed slot still counts toward the ceiling until it
|
|
339
534
|
* expires — flooring the RECYCLE, not the call, so both free paths (despawn + exit/reap) are
|
|
@@ -351,10 +546,10 @@ export class Manager {
|
|
|
351
546
|
// makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
|
|
352
547
|
void this.deprovision(a).catch((e) => console.error(`deprovision ${a.name} (${a.id}): ${e.message}`));
|
|
353
548
|
}
|
|
354
|
-
/** Tear down a departed agent's minted footprint (#159 B2, auth mode): its
|
|
355
|
-
* (`
|
|
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
|
|
356
551
|
* `provisionAgent` + creds-write left behind. Mints an EPHEMERAL, TARGET-PINNED `deprovisioner` cred
|
|
357
|
-
* (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,
|
|
358
553
|
* never a peer's and never the role-shared `svc_<role>` (which its siblings still bind). Open mesh →
|
|
359
554
|
* no-op (nothing was minted). Idempotent at the broker (missing consumer / ACL row = no-op) and on
|
|
360
555
|
* disk (`force` tolerates an absent creds file, e.g. a ledgered deploy that wrote none).
|
|
@@ -370,6 +565,25 @@ export class Manager {
|
|
|
370
565
|
// departed agent, so it must not survive even if the broker teardown below fails or times out. The
|
|
371
566
|
// teardown mints its OWN deprovisioner cred (not this file), so removing it early is independent.
|
|
372
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
|
+
}
|
|
373
587
|
const creds = await mintCreds(this.auth, newIdentity(), "deprovisioner", { deprovisionTarget: a.id });
|
|
374
588
|
// Bound the detached broker teardown so a wedged broker can't leave the deprovision promise pending
|
|
375
589
|
// forever with no log — the timeout rejects into freeSlot's fail-loud `.catch` (paired with the
|
|
@@ -437,6 +651,10 @@ export class Manager {
|
|
|
437
651
|
return Promise.resolve({ ok: false, error: "resume: session id must not be empty" });
|
|
438
652
|
if (args.variant !== undefined && !String(args.variant).trim())
|
|
439
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" });
|
|
440
658
|
// ACL overrides arrive as string arrays or not at all — a malformed value is a bad request,
|
|
441
659
|
// not something to coerce (no fallbacks).
|
|
442
660
|
const strList = (v, flag) => {
|
|
@@ -463,6 +681,7 @@ export class Manager {
|
|
|
463
681
|
identity: args.identity ? String(args.identity) : undefined,
|
|
464
682
|
model: args.model ? String(args.model) : undefined,
|
|
465
683
|
variant: args.variant ? String(args.variant) : undefined,
|
|
684
|
+
launchOptions: args.launchOptions,
|
|
466
685
|
resume: args.resume ? String(args.resume) : undefined,
|
|
467
686
|
transcript: typeof args.transcript === "boolean" ? args.transcript : undefined,
|
|
468
687
|
cwd: args.cwd ? String(args.cwd) : undefined,
|
|
@@ -487,7 +706,7 @@ export class Manager {
|
|
|
487
706
|
agent: connector.name,
|
|
488
707
|
supported: true,
|
|
489
708
|
models: [],
|
|
490
|
-
error: `${connector.name} harness needs ${missing.join(", ")} on PATH
|
|
709
|
+
error: `${connector.name} harness needs ${missing.join(", ")} on PATH - not found`,
|
|
491
710
|
};
|
|
492
711
|
try {
|
|
493
712
|
const catalog = await connector.listModels({ refresh });
|
|
@@ -510,6 +729,33 @@ export class Manager {
|
|
|
510
729
|
}
|
|
511
730
|
return { ok: true, data: await Promise.all(registry.all("connector").map(one)) };
|
|
512
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
|
+
}
|
|
513
759
|
/** Boot one resolved agent from a mesh-manifest launch spec, for `cotal spawn -f` onto a RUNNING
|
|
514
760
|
* manager. The request carries a `{ runId, name }`, NEVER a path: the manager derives + validates
|
|
515
761
|
* `.cotal/run/<runId>.json` itself ({@link launchSpecForRun} — token-safe id, no-follow,
|
|
@@ -517,8 +763,9 @@ export class Manager {
|
|
|
517
763
|
* agent's transient persona, and spawns via the same `startAgent({ resolved })` path as
|
|
518
764
|
* `supervise --launch`. The reply is enriched for the ownership ledger: the SPAWNED
|
|
519
765
|
* (collision-numbered) name + nkey id creds are filed under, plus the manifest `requested` name,
|
|
520
|
-
* `runId`, and resolved `hash`.
|
|
521
|
-
|
|
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) {
|
|
522
769
|
const runId = String(args.runId ?? "").trim();
|
|
523
770
|
const name = String(args.name ?? "").trim();
|
|
524
771
|
if (!runId || !name)
|
|
@@ -533,6 +780,18 @@ export class Manager {
|
|
|
533
780
|
const la = spec.agents.find((a) => a.name === name);
|
|
534
781
|
if (!la)
|
|
535
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
|
+
}
|
|
536
795
|
let configPath;
|
|
537
796
|
try {
|
|
538
797
|
configPath = materializePersona(this.workspaceRoot, runId, la);
|
|
@@ -540,7 +799,7 @@ export class Manager {
|
|
|
540
799
|
catch (e) {
|
|
541
800
|
return { ok: false, error: e.message };
|
|
542
801
|
}
|
|
543
|
-
const reply = await this.startAgent(launchAgentToStartOpts(la, configPath), caller);
|
|
802
|
+
const reply = await this.startAgent(launchAgentToStartOpts(la, configPath, spec.owner), caller);
|
|
544
803
|
if (reply.ok)
|
|
545
804
|
// `data.name` stays the spawned (numbered) identity — what creds are filed under and the ledger
|
|
546
805
|
// keys on; `requested`/`runId`/`hash` give the CLI the manifest name + drift hash for the ledger.
|
|
@@ -590,7 +849,7 @@ export class Manager {
|
|
|
590
849
|
else {
|
|
591
850
|
configPath = agentFilePath(this.workspaceRoot, ref);
|
|
592
851
|
if (!existsSync(configPath))
|
|
593
|
-
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\`)` };
|
|
594
853
|
}
|
|
595
854
|
// Connector + harness preflight before reserving a slot or minting — a missing connector or a
|
|
596
855
|
// missing `claude`/`opencode` binary fails here with a clear name, not obscurely at process
|
|
@@ -604,7 +863,7 @@ export class Manager {
|
|
|
604
863
|
}
|
|
605
864
|
const missing = (connector.requires ?? []).filter((bin) => !resolveOnPath(bin));
|
|
606
865
|
if (missing.length)
|
|
607
|
-
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` };
|
|
608
867
|
// Resume is a connector capability: reject an unsupported resume HERE, before the reserve/mint, so
|
|
609
868
|
// it can never provision creds + durables and then throw at buildLaunch (mint-then-orphan). Same
|
|
610
869
|
// reject-before-side-effects window as the harness preflight above; buildLaunch stays the backstop.
|
|
@@ -623,6 +882,7 @@ export class Manager {
|
|
|
623
882
|
let capabilities;
|
|
624
883
|
let model = opts.model;
|
|
625
884
|
let variant = opts.variant;
|
|
885
|
+
let launchOptions = opts.launchOptions;
|
|
626
886
|
if (opts.resolved) {
|
|
627
887
|
// A manifest launch is the access + identity authority: imperative overrides arriving
|
|
628
888
|
// alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
|
|
@@ -637,6 +897,7 @@ export class Manager {
|
|
|
637
897
|
capabilities = r.capabilities;
|
|
638
898
|
model = opts.model ?? r.model;
|
|
639
899
|
variant = opts.variant ?? r.variant;
|
|
900
|
+
launchOptions = mergeLaunchOptions(r.launchOptions, opts.launchOptions);
|
|
640
901
|
}
|
|
641
902
|
else {
|
|
642
903
|
let def;
|
|
@@ -661,6 +922,7 @@ export class Manager {
|
|
|
661
922
|
allowPublish = opts.allowPublish ?? def.allowPublish;
|
|
662
923
|
capabilities = def.capabilities;
|
|
663
924
|
variant = opts.variant ?? def.variant;
|
|
925
|
+
launchOptions = mergeLaunchOptions(def.launchOptions, opts.launchOptions);
|
|
664
926
|
}
|
|
665
927
|
const idErr = this.nameError(identityName);
|
|
666
928
|
if (idErr)
|
|
@@ -687,6 +949,11 @@ export class Manager {
|
|
|
687
949
|
// Set once the agent's creds + durables are minted; cleared the moment a live slot takes ownership
|
|
688
950
|
// (`agents.set`, after which freeSlot deprovisions on exit). If it survives to `finally`, the spawn
|
|
689
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).
|
|
690
957
|
let provisioned;
|
|
691
958
|
try {
|
|
692
959
|
// A stable nkey identity assigned at spawn: the public key is the agent's card.id (threaded via
|
|
@@ -696,7 +963,28 @@ export class Manager {
|
|
|
696
963
|
// spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
|
|
697
964
|
// subscribe/allowSubscribe (read) + allowPublish (post, default-deny).
|
|
698
965
|
let credsPath;
|
|
699
|
-
|
|
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) {
|
|
700
988
|
// Pre-create the agent's bind-only chat (+ DM + role TASK) durables and mint its scoped creds
|
|
701
989
|
// — the shared onboarding step (provisionAgent). It runs on a short-lived PROVISIONER connection
|
|
702
990
|
// (NOT the supervisor's long-lived endpoint), so the DM/DLV consumer-create surface exists only
|
|
@@ -725,12 +1013,16 @@ export class Manager {
|
|
|
725
1013
|
space: this.space,
|
|
726
1014
|
name,
|
|
727
1015
|
role,
|
|
728
|
-
|
|
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,
|
|
729
1019
|
creds: credsPath,
|
|
1020
|
+
userAuth: userLaunch,
|
|
730
1021
|
servers: this.servers,
|
|
731
1022
|
configPath,
|
|
732
1023
|
model,
|
|
733
1024
|
variant,
|
|
1025
|
+
launchOptions,
|
|
734
1026
|
// Fork an existing session into the mesh. Taken straight from `opts.resume` (the imperative
|
|
735
1027
|
// control arg), never from `opts.resolved` — so the manifest launch path carries no resume by
|
|
736
1028
|
// construction. An unsupported connector throws here before any process is spawned.
|
|
@@ -756,8 +1048,8 @@ export class Manager {
|
|
|
756
1048
|
name,
|
|
757
1049
|
role,
|
|
758
1050
|
agent,
|
|
759
|
-
id: identity.id,
|
|
760
|
-
seed: identity.seed,
|
|
1051
|
+
id: userLaunch ? principalKey(userLaunch.owner, name).key : identity.id,
|
|
1052
|
+
...(userLaunch ? { userOwner } : { seed: identity.seed }),
|
|
761
1053
|
spawner: spawner ?? this.ep.ref().id,
|
|
762
1054
|
startedAt: Date.now(),
|
|
763
1055
|
handle,
|
|
@@ -779,7 +1071,10 @@ export class Manager {
|
|
|
779
1071
|
this.watchExit(managed);
|
|
780
1072
|
if (!readiness.ok)
|
|
781
1073
|
return { ok: false, error: readiness.detail }; // uncertain — non-success, but kept
|
|
782
|
-
|
|
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 } };
|
|
783
1078
|
}
|
|
784
1079
|
catch (e) {
|
|
785
1080
|
// Failure after reserve (provision / launch threw): the slot was never live, so no cold-start
|
|
@@ -818,7 +1113,11 @@ export class Manager {
|
|
|
818
1113
|
/* tmux/cmux stream no exit — presence-or-backstop only */
|
|
819
1114
|
}
|
|
820
1115
|
const s = session;
|
|
821
|
-
|
|
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");
|
|
822
1121
|
return await new Promise((resolve) => {
|
|
823
1122
|
let done = false;
|
|
824
1123
|
let timer;
|
|
@@ -846,13 +1145,13 @@ export class Manager {
|
|
|
846
1145
|
void (async () => {
|
|
847
1146
|
const tail = this.tail(await s.backlog());
|
|
848
1147
|
this.onAgentExit(a);
|
|
849
|
-
finish({ ok: false, detail: `${a.name} exited on launch${tail ? `
|
|
1148
|
+
finish({ ok: false, detail: `${a.name} exited on launch${tail ? ` - last output: ${tail}` : ""}` });
|
|
850
1149
|
})();
|
|
851
1150
|
};
|
|
852
1151
|
timer = setTimeout(() => finish({
|
|
853
1152
|
ok: false,
|
|
854
1153
|
uncertain: true,
|
|
855
|
-
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.`,
|
|
856
1155
|
}), this.readinessTimeoutMs);
|
|
857
1156
|
unsubExit = s ? s.onExit(onExit) : () => { };
|
|
858
1157
|
this.ep.on("presence", onPresence);
|
|
@@ -905,12 +1204,12 @@ export class Manager {
|
|
|
905
1204
|
this.cooling = this.cooling.filter((stamp) => stamp > now);
|
|
906
1205
|
return this.cooling.length;
|
|
907
1206
|
}
|
|
908
|
-
opStop(args, caller, admin) {
|
|
1207
|
+
async opStop(args, caller, admin) {
|
|
909
1208
|
const name = String(args.name ?? "").trim();
|
|
910
1209
|
const a = this.agents.get(name);
|
|
911
1210
|
if (!a)
|
|
912
1211
|
return { ok: false, error: `no agent "${name}"` };
|
|
913
|
-
const denied = this.authorizeNamed(a, caller, admin);
|
|
1212
|
+
const denied = await this.authorizeNamed(a, caller, admin);
|
|
914
1213
|
if (denied)
|
|
915
1214
|
return { ok: false, error: denied };
|
|
916
1215
|
const graceful = args.graceful !== false;
|
|
@@ -998,7 +1297,7 @@ export class Manager {
|
|
|
998
1297
|
return { ok: false, error: e.message };
|
|
999
1298
|
}
|
|
1000
1299
|
if (!admin && def.owner !== caller) {
|
|
1001
|
-
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)";
|
|
1002
1301
|
return { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` };
|
|
1003
1302
|
}
|
|
1004
1303
|
// PATCH content: overwrite model only when provided, so a persona-only redefine can't wipe an existing model.
|
|
@@ -1019,14 +1318,14 @@ export class Manager {
|
|
|
1019
1318
|
}
|
|
1020
1319
|
return { ok: true, data: { name, path } };
|
|
1021
1320
|
}
|
|
1022
|
-
opAttach(args, caller, admin) {
|
|
1321
|
+
async opAttach(args, caller, admin) {
|
|
1023
1322
|
const name = String(args.name ?? "").trim();
|
|
1024
1323
|
const a = this.agents.get(name);
|
|
1025
1324
|
if (!a)
|
|
1026
1325
|
return { ok: false, error: `no agent "${name}"` };
|
|
1027
|
-
// attach grants terminal read+write — same
|
|
1028
|
-
// privileged tier, any agent on admin.
|
|
1029
|
-
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);
|
|
1030
1329
|
if (denied)
|
|
1031
1330
|
return { ok: false, error: denied };
|
|
1032
1331
|
// Only pty streams over the WS attach endpoint. tmux/cmux are watched natively, and
|
|
@@ -1043,21 +1342,34 @@ export class Manager {
|
|
|
1043
1342
|
return { ok: true, data: { ws: this.attach.url(name) } };
|
|
1044
1343
|
}
|
|
1045
1344
|
/** Managed agents cross-referenced with live presence (the manager sees the roster). */
|
|
1046
|
-
|
|
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) {
|
|
1047
1348
|
const roster = new Map(this.ep.getRoster().map((p) => [p.card.name, p]));
|
|
1048
|
-
return [...this.agents.values()].map((a) =>
|
|
1049
|
-
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
+
});
|
|
1061
1373
|
}
|
|
1062
1374
|
}
|
|
1063
1375
|
//# sourceMappingURL=manager.js.map
|