@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/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
- creds = await mintCreds(this.auth, identity, "supervisor");
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}) stop it first; one manager per space`
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}) shutting down to avoid two managers serving it`);
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 higher-power than `start`
251
- // — it boots an operator-authored, coordinated policy set from a run spec and underpins the
252
- // ownership ledger — so a merely spawn-capable agent (which CAN publish to the privileged
253
- // subject) must not reach it. Gate at the handler like `purge`; the subject alone isn't a
254
- // boundary because `spawn` grants privileged-subject publish and dispatch is by op here.
255
- if (!admin)
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
- return { ok: true, data: this.list() };
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
- const a = this.list().find((x) => x.name === name);
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 ONLY if it's the caller's OWN child
288
- * (`spawner == caller`) — so a spawn-capable peer can tear down what it spawned, never a peer's.
289
- * Returns an error string when denied, `undefined` when allowed. */
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
- if (admin)
292
- return undefined;
293
- if (target.spawner === caller)
294
- return undefined;
295
- return `not authorized: ${target.name} was not spawned by ${caller} (admin tier required)`;
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.id === callerId);
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 id-keyed durables
355
- * (`dm_<id>`, `dlv_<id>`), its read-ACL row, and its creds file — everything the spawn's
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 id-keyed footprint,
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 not found`,
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
- async opLaunch(args, caller) {
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}" ${configPath} not found; create it or pass --config (see \`cotal personas list\`)` };
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 not found` };
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
- if (this.auth) {
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
- id: identity.id,
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
- return { ok: true, data: { name, role, agent, id: identity.id, mode: handle.kind } };
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
- const joined = () => this.ep.getRoster().some((p) => p.card.id === a.id && p.status !== "offline");
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 ? ` last output: ${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 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.`,
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 no agent owner)";
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 own/admin scoping as despawn: own child on the
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
- list() {
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
- name: a.name,
1050
- // The spawned agent's nkey lets an operator tool (e.g. `cotal down -f`) match a ledger entry
1051
- // by name AND id before stopping, so it never stops a same-named foreign agent.
1052
- id: a.id,
1053
- role: a.role,
1054
- agent: a.agent,
1055
- space: this.space,
1056
- mode: a.handle.kind,
1057
- status: a.handle.status(),
1058
- uptimeMs: Date.now() - a.startedAt,
1059
- mesh: roster.get(a.name)?.status ?? "absent",
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