@cotal-ai/manager 0.140.8 → 0.141.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
@@ -2,10 +2,10 @@ import { createHash, randomUUID, randomBytes } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { credsAuthenticator } from "@nats-io/transport-node";
4
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
5
- import { isAbsolute, join, dirname, resolve } from "node:path";
5
+ import { isAbsolute, join, dirname, resolve, sep } from "node:path";
6
6
  import { parse as parseYaml } from "yaml";
7
7
  import Headless from "@xterm/headless";
8
- import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, fenceLifecycleAcl, DeprovisionError, addDeprovisionAccounting, isConcreteChannel, isChannelFamily, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, bindIssuedStores, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, oneLine, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, renderOnceTask, } from "@cotal-ai/core";
8
+ import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, assertValidChannel, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, fenceLifecycleAcl, DeprovisionError, addDeprovisionAccounting, isConcreteChannel, isChannelFamily, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, bindIssuedStores, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, oneLine, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, runDriverCaller, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannel, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, capabilityWithin, grantOverreach, grantParts, isIssuedCaller, issuedSpawnerStanding, withIssuerSession, renderOnceTask, } from "@cotal-ai/core";
9
9
  import { loadLaunchPersonaFile, PersonaSourceError } from "@cotal-ai/workspace";
10
10
  import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimIdentityRecord, claimManagerSiblingIdentities, connectorInstallHint, DEFAULT_SEAT_CAP, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, probeLiveness, remintDaemonCreds, resolveAgentType, resolveOnPath, runtimeInstallHint, createManagerInstanceIdentity, SEAT_CAP_UNLIMITED, spaceKey, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
11
11
  import { createRuntime, exitProof, isCustodialRuntime, pollProvedExit, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, watchProvedExit, } from "./runtime/index.js";
@@ -13,10 +13,10 @@ import { AttachEndpoint } from "./attach-endpoint.js";
13
13
  import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers, withScopedEndpoint } from "./endpoint-evict.js";
14
14
  import { makeManagerHolderLivenessProbe } from "./holder-liveness.js";
15
15
  import { GateReconcileRefused, reconcileEndpointGate } from "./reconcile-gate.js";
16
- import { launchSpecForRun, materializePersona, launchAgentToStartOpts, parseLaunchSpec, persistLaunchSpec, readContinuityAssignment, writeContinuityAssignment } from "./launch.js";
16
+ import { inlinePersonaDir, inlineSnapshotPath, launchSpecForRun, materializePersona, launchAgentToStartOpts, removeInlineSnapshot, writeInlineSnapshot, parseLaunchSpec, persistLaunchSpec, readContinuityAssignment, writeContinuityAssignment } from "./launch.js";
17
17
  import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
18
18
  import { controlShutdown } from "./control-shutdown.js";
19
- import { RunHosting } from "./run-hosting.js";
19
+ import { RunHosting, servedRequestSubject } from "./run-hosting.js";
20
20
  import { enrolledAcl } from "./remote-authority.js";
21
21
  import { rejectionText } from "./rejection-text.js";
22
22
  import { controlSession } from "./control-session.js";
@@ -307,6 +307,7 @@ const FREE_SLOT_CAUSE_TEXT = {
307
307
  "carried-fork-refused": "this manager stopped it: it recorded no fork of the carried transcript",
308
308
  "resume-custody-unrecorded": "this manager stopped it: its custody reference could not be recorded on resume",
309
309
  "supervise-record-failed": "this manager stopped it: its supervise policy could not be recorded for a manager restart",
310
+ "inline-snapshot-changed": "this manager retired it instead of restarting it: its inline persona snapshot changed after launch",
310
311
  };
311
312
  /** The stop family's sentences — the same job {@link FREE_SLOT_CAUSE_TEXT} does for the string
312
313
  * causes, beside it so both stay the single place a cause becomes a sentence. A requested stop's
@@ -335,6 +336,65 @@ function onceStopReason(cause) {
335
336
  default: return cause;
336
337
  }
337
338
  }
339
+ /** Why a revoked run starts no helper, the same words wherever the marker is read. */
340
+ function revokedRunRefusal(runId, revoked) {
341
+ return `run ${runId} was revoked by ${revoked.by} (${revoked.reason}); a revoked run starts no helper (SPEC 14.8)`;
342
+ }
343
+ const INLINE_PERSONA_KEYS = ["prompt", "role", "model", "variant", "description", "subscribe", "allowSubscribe", "allowPublish", "capabilities", "save"];
344
+ /** Read an inline persona into the definition a catalog file would load as, refusing what a file
345
+ * load refuses plus any shape the closed record does not take. The body is literal text: nothing in
346
+ * it becomes a field, so a body that starts with `---` stays body. */
347
+ function inlinePersonaDef(name, raw) {
348
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
349
+ throw new Error("persona: expected an object");
350
+ const rec = raw;
351
+ const extra = Object.keys(rec).filter((k) => !INLINE_PERSONA_KEYS.includes(k));
352
+ if (extra.length)
353
+ throw new Error(`persona: unknown key ${extra[0]}; it takes ${INLINE_PERSONA_KEYS.join(", ")}`);
354
+ const text = (k) => {
355
+ const v = rec[k];
356
+ if (v === undefined)
357
+ return undefined;
358
+ if (typeof v !== "string" || !v.trim())
359
+ throw new Error(`persona.${k}: expected a non-blank string`);
360
+ return v;
361
+ };
362
+ const list = (k) => {
363
+ const v = rec[k];
364
+ if (v === undefined)
365
+ return undefined;
366
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
367
+ throw new Error(`persona.${k}: expected an array of strings`);
368
+ return [...v];
369
+ };
370
+ const prompt = text("prompt");
371
+ if (prompt === undefined)
372
+ throw new Error("persona.prompt: required; it is the persona body");
373
+ if (rec.save !== undefined && typeof rec.save !== "boolean")
374
+ throw new Error("persona.save: expected a boolean");
375
+ const def = {
376
+ name,
377
+ persona: prompt.trim(),
378
+ role: text("role"),
379
+ model: text("model"),
380
+ variant: text("variant"),
381
+ description: text("description"),
382
+ subscribe: list("subscribe"),
383
+ allowSubscribe: list("allowSubscribe"),
384
+ allowPublish: list("allowPublish"),
385
+ capabilities: list("capabilities"),
386
+ };
387
+ // The checks a file load makes on these fields: channel names, and the read set within the read ACL.
388
+ try {
389
+ for (const ch of [...(def.subscribe ?? []), ...(def.allowSubscribe ?? []), ...(def.allowPublish ?? [])])
390
+ assertValidChannel(ch);
391
+ resolveReadAcl(def.subscribe ?? [], def.allowSubscribe);
392
+ }
393
+ catch (e) {
394
+ throw new Error(`persona: ${rejectionText(e)}`);
395
+ }
396
+ return { def, save: rec.save === true };
397
+ }
338
398
  const TURN_PARTICIPATION_TTL_MS = 5 * 60_000;
339
399
  function participantKey(owner, actor, uid) {
340
400
  return `${owner}.${actor}.${uid}`;
@@ -2295,6 +2355,8 @@ export class Manager {
2295
2355
  return `imperative launch options have no non-secret durable source (${entry.launch.unresolvedLaunchOptionKeys.join(", ")})`;
2296
2356
  if (!entry.dependencies.some((path) => resolve(path) === resolve(entry.launch.source.configPath)))
2297
2357
  return `launch config is not declared as a retained dependency: ${entry.launch.source.configPath}`;
2358
+ if (entry.launch.source.kind === "inline" && resolve(entry.launch.source.configPath) !== resolve(inlineSnapshotPath(this.workspaceRoot, entry.identity.lifecycleUid)))
2359
+ return `inline persona snapshot ${entry.launch.source.configPath} is not this lifecycle's snapshot path`;
2298
2360
  if (entry.launch.source.kind === "manifest" && entry.launch.source.runId) {
2299
2361
  const specPath = join(this.workspaceRoot, ".cotal", "run", `${entry.launch.source.runId}.json`);
2300
2362
  if (!entry.dependencies.some((path) => resolve(path) === resolve(specPath)))
@@ -2473,7 +2535,11 @@ export class Manager {
2473
2535
  }
2474
2536
  }));
2475
2537
  // Deprovision EVERY snapshot entry regardless of whether its stop failed (allSettled + a loud log).
2476
- await Promise.allSettled(managed.filter((a) => !a.suppressCleanup).map((a) => this.deprovision(this.teardownTarget(a)).catch((e) => console.error(`deprovision ${a.name} (${a.id}) on shutdown: ${rejectionText(e)}`))));
2538
+ await Promise.allSettled(managed.filter((a) => !a.suppressCleanup).map((a) => {
2539
+ // Retired here on every mesh, the open mesh included, which runs no teardown of its own.
2540
+ const settled = a.launch.source.kind !== "inline" || this.dropInlineSnapshot(a.name, a.lifecycleUid);
2541
+ return this.deprovision({ ...this.teardownTarget(a), ...(settled ? { snapshotSettled: true } : {}) }).catch((e) => console.error(`deprovision ${a.name} (${a.id}) on shutdown: ${rejectionText(e)}`));
2542
+ }));
2477
2543
  if (failures.length)
2478
2544
  throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
2479
2545
  }
@@ -3510,7 +3576,7 @@ export class Manager {
3510
3576
  // follows owner-domain reach on a user mesh. Resolve the caller's admin tier once for
3511
3577
  // the residual cross-owner case; opStart decides before connector resolution or grant
3512
3578
  // mutation. Static meshes resolve true and nothing changes.
3513
- spawn: (ctx) => this.loggedSpawnRefusal(ctx, () => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller)))),
3579
+ spawn: (ctx) => this.loggedSpawnRefusal(ctx, () => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller, servedRequestSubject(this.space, ctx))))),
3514
3580
  despawn: (ctx) => this.serveGated(ctx, async () => {
3515
3581
  const a = targetAgent(ctx);
3516
3582
  const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
@@ -3575,7 +3641,7 @@ export class Manager {
3575
3641
  // authenticated caller's own, so a run withdraws only the turns it submitted.
3576
3642
  cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
3577
3643
  stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
3578
- definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
3644
+ definePersona: (ctx) => this.serveGated(ctx, async () => unwrap(await this.opDefinePersona(args(ctx), callerOf(ctx), false, ctx.subject.caller))),
3579
3645
  removePersona: (ctx) => this.serveGated(ctx, () => {
3580
3646
  const r = this.opRemovePersona(args(ctx), callerOf(ctx), false);
3581
3647
  if (!r.ok)
@@ -3872,6 +3938,12 @@ export class Manager {
3872
3938
  })
3873
3939
  .finally(() => this.releaseLifecycle());
3874
3940
  }
3941
+ /** The served spawn a hosted enrollment forwards. A run's spawn also names its run, so the host
3942
+ * bounds the helper by what the run's starter held when the run was admitted (SPEC 14.8). */
3943
+ servedSpawnRequest(served, caller) {
3944
+ const run = caller !== undefined ? this.runHosting?.runAdmission(caller) : undefined;
3945
+ return { served, ...(run !== undefined ? { runId: run.runId } : {}) };
3946
+ }
3875
3947
  /** USER-MODE spawn provisioning (the gate-1 counterpart to the static mint block): resolve the
3876
3948
  * OWNER (ctl caller's principal, or the manifest's stamped owner — never a payload field),
3877
3949
  * pre-create the principal-keyed durables + ACL row on the ephemeral provisioner, author the
@@ -4044,6 +4116,7 @@ export class Manager {
4044
4116
  allowSubscribe: opts.allowSubscribe,
4045
4117
  ...(opts.allowPublish !== undefined ? { allowPublish: opts.allowPublish } : {}),
4046
4118
  },
4119
+ ...(opts.spawnRequest !== undefined ? { spawn: opts.spawnRequest } : {}),
4047
4120
  });
4048
4121
  if (material.actor !== name)
4049
4122
  throw new Error(`the host enrolled actor "${material.actor}", not the requested "${name}"`);
@@ -4279,8 +4352,28 @@ export class Manager {
4279
4352
  // process is already gone, so this must never block the slot free or throw into the caller — it runs
4280
4353
  // detached, and a failure is logged loudly (never swallowed), not retried. The `agents` guard above
4281
4354
  // makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
4282
- if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence))
4355
+ if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence)) {
4356
+ // An inline seat's snapshot goes with the seat on every mesh. An open mesh runs no teardown
4357
+ // below, and a preservation cut suppresses this, so a resumed seat still finds its snapshot.
4358
+ // Settled means the file is gone or the seat never had one. A removal that failed is left to
4359
+ // the teardown, which retries it and reports what it could not remove.
4360
+ if (a.launch.source.kind !== "inline" || this.dropInlineSnapshot(a.name, a.lifecycleUid))
4361
+ target.snapshotSettled = true;
4283
4362
  this.trackDeprovision(target);
4363
+ }
4364
+ }
4365
+ /** Remove one lifecycle's inline snapshot by the path its uid derives, never by a recorded one, so a
4366
+ * resume inventory cannot point the removal at another file. Returns whether the file is gone. A
4367
+ * failure is logged, never thrown, because the callers must still schedule the teardown. */
4368
+ dropInlineSnapshot(name, lifecycleUid) {
4369
+ try {
4370
+ removeInlineSnapshot(this.workspaceRoot, lifecycleUid);
4371
+ return true;
4372
+ }
4373
+ catch (e) {
4374
+ console.error(`! ${name}: its inline persona snapshot could not be removed: ${rejectionText(e)}`);
4375
+ return false;
4376
+ }
4284
4377
  }
4285
4378
  /** A live agent's teardown target, built field by field so the rest of its record (handle, seed,
4286
4379
  * restart state) never rides into the detached teardown or its single-flight map. */
@@ -4911,6 +5004,33 @@ export class Manager {
4911
5004
  }
4912
5005
  throw new Error(`replacement did not prove session ${expected} (${last})`);
4913
5006
  }
5007
+ /** Why an inline seat's snapshot no longer matches its launch, or `undefined` when it does or the
5008
+ * seat is not inline. The recorded path and the relaunch's path must both be this lifecycle's
5009
+ * snapshot path, as the resume cut requires, and the file there must still be a private regular
5010
+ * file with the recorded digest. */
5011
+ inlineSnapshotChange(a) {
5012
+ const source = a.launch.source;
5013
+ if (source.kind !== "inline")
5014
+ return undefined;
5015
+ const path = resolve(inlineSnapshotPath(this.workspaceRoot, a.lifecycleUid));
5016
+ if (resolve(source.configPath) !== path)
5017
+ return `its recorded inline persona snapshot ${source.configPath} is not this lifecycle's snapshot path`;
5018
+ if (a.restart !== undefined && resolve(a.restart.opts.configPath ?? "") !== path)
5019
+ return `its relaunch persona ${a.restart.opts.configPath} is not this lifecycle's snapshot path`;
5020
+ try {
5021
+ const st = lstatSync(path);
5022
+ if (!st.isFile() || st.isSymbolicLink())
5023
+ return `its inline persona snapshot ${path} is not a regular file`;
5024
+ if (process.platform !== "win32" && (st.mode & 0o077) !== 0)
5025
+ return `its inline persona snapshot ${path} is readable by others`;
5026
+ if (this.fileDigest(path) !== source.configSha256)
5027
+ return `its inline persona snapshot ${path} changed after launch`;
5028
+ }
5029
+ catch (e) {
5030
+ return `its inline persona snapshot is unavailable: ${rejectionText(e)}`;
5031
+ }
5032
+ return undefined;
5033
+ }
4914
5034
  /** Restart one managed process in place. Identity, lifecycle, credentials, durables, children,
4915
5035
  * and the manager row remain owned; only the process handle/control endpoint change. Budget
4916
5036
  * comes from `restart.policy` when spawn carried `supervise`; otherwise the Pi session-recovery
@@ -4938,6 +5058,19 @@ export class Manager {
4938
5058
  release();
4939
5059
  return;
4940
5060
  }
5061
+ // An inline seat relaunches from its snapshot, so one that changed after launch, or a recorded
5062
+ // path that names another file, would start a session its spawn never accepted. Every in-place
5063
+ // restart passes here; the resume cut and a successor's restart make the same two checks in
5064
+ // inventoryReferenceError.
5065
+ const changed = this.inlineSnapshotChange(a);
5066
+ if (changed) {
5067
+ console.error(`! ${a.name}: ${changed} - retiring the managed seat instead of restarting it`);
5068
+ restart.armed = false;
5069
+ this.freeSlot(a, true, "inline-snapshot-changed");
5070
+ this.reapChildrenOf(this.managedPrincipal(a));
5071
+ release();
5072
+ return;
5073
+ }
4941
5074
  restart.recovering = true;
4942
5075
  void (async () => {
4943
5076
  let replacement;
@@ -5134,7 +5267,7 @@ export class Manager {
5134
5267
  return false;
5135
5268
  }
5136
5269
  /** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
5137
- opStart(args, caller, admin, hooks, route = "inst", authenticatedCaller) {
5270
+ opStart(args, caller, admin, hooks, route = "inst", authenticatedCaller, served) {
5138
5271
  // Opaque launch options, when present, must be a mapping — a raw control message could send a
5139
5272
  // scalar/array (the CLI never does). Core doesn't interpret the keys; the connector validates them.
5140
5273
  if (args.launchOptions !== undefined && (typeof args.launchOptions !== "object" || args.launchOptions === null || Array.isArray(args.launchOptions)))
@@ -5241,6 +5374,9 @@ export class Manager {
5241
5374
  role: args.role,
5242
5375
  config: args.config,
5243
5376
  identity: args.identity,
5377
+ // Passed as sent: spawnPersona validates the record, the one place every door reads it.
5378
+ persona: args.persona,
5379
+ ...(typeof args.runId === "string" ? { runId: args.runId } : {}),
5244
5380
  model: args.model,
5245
5381
  variant: args.variant,
5246
5382
  launchOptions: args.launchOptions,
@@ -5261,6 +5397,7 @@ export class Manager {
5261
5397
  ...(once !== undefined ? { once } : {}),
5262
5398
  ...(typeof args.runtime === "string" ? { runtime: args.runtime.trim() } : {}),
5263
5399
  route,
5400
+ ...(served !== undefined ? { served } : {}),
5264
5401
  }, caller, hooks, authenticatedCaller);
5265
5402
  }
5266
5403
  /** The realpath + directory checks a placed spawn directory must pass on this manager's host,
@@ -5671,6 +5808,39 @@ export class Manager {
5671
5808
  const ref = opts.name.trim();
5672
5809
  if (!ref)
5673
5810
  return { error: "name required" };
5811
+ // An inline persona is its own definition, read here like a file so every later check sees a
5812
+ // def. It has no file until its snapshot is written, once the seat's lifecycle uid is final.
5813
+ if (opts.persona !== undefined) {
5814
+ if (opts.resolved || opts.config !== undefined || opts.identity !== undefined)
5815
+ return { error: "persona: an inline persona names the helper itself, so it takes no config, identity or manifest launch", code: "bad-request" };
5816
+ const refErr = this.nameError(ref);
5817
+ if (refErr)
5818
+ return { error: refErr };
5819
+ let inline;
5820
+ try {
5821
+ inline = inlinePersonaDef(ref, opts.persona);
5822
+ }
5823
+ catch (e) {
5824
+ return { error: rejectionText(e), code: "bad-request" };
5825
+ }
5826
+ // A save under a name the catalog already holds is refused here, before anything is allocated,
5827
+ // so the refusal starts nothing. The create-only write after the grant settles a race.
5828
+ if (inline.save) {
5829
+ const target = agentFilePath(this.workspaceRoot, ref);
5830
+ let taken = true;
5831
+ try {
5832
+ lstatSync(target);
5833
+ }
5834
+ catch (e) {
5835
+ if (e.code !== "ENOENT")
5836
+ return { error: `persona.save: ${rejectionText(e)}` };
5837
+ taken = false;
5838
+ }
5839
+ if (taken)
5840
+ return { error: `persona.save: the catalog already holds a persona named "${ref}" (${target}). Spawn that persona by name, or pick another name to save this one under` };
5841
+ }
5842
+ return { ref, def: inline.def, agent: resolveAgentType({ flag: opts.agent, pin: undefined, callerDefault: opts.defaultAgent }), inline: { save: inline.save } };
5843
+ }
5674
5844
  // A bare ref maps to `.cotal/agents/<ref>.md`, so it must be a safe token (no path traversal); a
5675
5845
  // `--config` path is validated by existsSync below instead.
5676
5846
  if (!opts.config) {
@@ -5696,6 +5866,11 @@ export class Manager {
5696
5866
  if (!existsSync(configPath))
5697
5867
  return { error: `no persona "${ref}" in the catalog of manager ${this.managerInstanceId} on host ${hostname()}: ${configPath} not found; create it there` };
5698
5868
  }
5869
+ // An inline snapshot belongs to its own seat's lifecycle, which removes it at retirement and
5870
+ // refuses a restart once it changed, so no other launch may read it. Compared after symlinks
5871
+ // resolve, for a config path and a catalog entry alike.
5872
+ if (!opts.resolved && this.insideInlineDir(configPath))
5873
+ return { error: `${configPath} is an inline persona's snapshot, which belongs to the seat it was written for; spawn a saved persona by name, or pass the persona inline` };
5699
5874
  // Load the persona ONCE, here, ahead of the connector choice. The file's `agent:` pin feeds the
5700
5875
  // harness resolution below; the spawn path's identity/ACL profile block consumes the SAME def (no
5701
5876
  // second load). A manifest launch (`resolved`) materializes its own transient persona and the
@@ -5723,6 +5898,155 @@ export class Manager {
5723
5898
  }
5724
5899
  return { ref, configPath, def, agent: resolveAgentType({ flag: opts.agent, pin: def?.agent, callerDefault: opts.defaultAgent }) };
5725
5900
  }
5901
+ /** Whether a persona path resolves into the inline snapshot directory, the directory itself
5902
+ * included. A directory that does not exist holds nothing. */
5903
+ insideInlineDir(path) {
5904
+ const dir = inlinePersonaDir(this.workspaceRoot);
5905
+ if (!existsSync(dir))
5906
+ return false;
5907
+ const real = realpathSync(path);
5908
+ const realDir = realpathSync(dir);
5909
+ return real === realDir || real.startsWith(realDir + sep);
5910
+ }
5911
+ /** The run a spawn acts for (SPEC 14.8). The run this manager drives under `caller` answers from
5912
+ * memory. On a static mesh, a spawn from a run hosted elsewhere names its run, whether the program
5913
+ * placed it here or the class queue delivered it, and its caller must be that run's driver, whose
5914
+ * triple derives from the run id; the run's admission and revocation marker are then read
5915
+ * leader-served, so the helper is bounded as the hosting manager bounds it. Elsewhere the run id
5916
+ * is not read: a user-auth run's issuing host bounds the helper, and an open mesh bounds none.
5917
+ * `undefined` for a spawn that acts for no run. */
5918
+ async spawnRun(caller, runId) {
5919
+ const hosted = caller !== undefined ? this.runHosting?.runAdmission(caller) : undefined;
5920
+ if (hosted !== undefined) {
5921
+ if (runId !== undefined && runId !== hosted.runId)
5922
+ return { refused: `it names run ${runId}, but its caller drives run ${hosted.runId}` };
5923
+ return { admission: hosted, hosted: true };
5924
+ }
5925
+ if (runId === undefined || this.meshMode !== "static")
5926
+ return undefined;
5927
+ let driver;
5928
+ try {
5929
+ driver = caller !== undefined ? runDriverCaller(runId, caller.owner) : undefined;
5930
+ }
5931
+ catch (e) {
5932
+ return { refused: `runId ${JSON.stringify(runId)} is not a run id (${rejectionText(e)})` };
5933
+ }
5934
+ if (driver === undefined || driver.actor !== caller.actor || driver.uid !== caller.uid)
5935
+ return { refused: `it names run ${runId}, but its caller is not that run's driver` };
5936
+ if (this.runHosting === undefined)
5937
+ return { refused: `run ${runId} asked this manager for a helper, but this manager hosts no runs, so it cannot read the run's admission` };
5938
+ let view;
5939
+ try {
5940
+ view = await this.runHosting.admission(runId);
5941
+ }
5942
+ catch (e) {
5943
+ return { refused: `run ${runId}'s admission and revocation marker could not be read (${rejectionText(e)}), so it starts no helper (SPEC 14.8)` };
5944
+ }
5945
+ if (view.revoked !== undefined)
5946
+ return { refused: revokedRunRefusal(runId, view.revoked) };
5947
+ return { admission: view.admission, hosted: false };
5948
+ }
5949
+ /**
5950
+ * Resolve what bounds a spawn's grant on a STATIC mesh: the caller's issued evidence, read the way
5951
+ * keyed runs read it. A user-auth mesh bounds the mint on its ledger instead, and an open mesh
5952
+ * mints no grant, so both return `exempt`. So does a call with no authenticated caller, which is
5953
+ * the manager's own composition root (roster boot, a manifest `launch`, which a static mesh mints
5954
+ * only into operator instruments). A workflow run's spawn, whether this manager hosts the run or
5955
+ * its spawn reached this manager from another host ({@link spawnRun}), is bounded by the run's admission (SPEC 14.8):
5956
+ * the ceiling it recorded, at the starter's coordinate, unless the starter was exempt then. That
5957
+ * ceiling is the starter's evidence as resolved at admission, or for a local run the channels the
5958
+ * operator named. Any other caller with no issued generation, or whose evidence does not resolve,
5959
+ * hands on no grant. The evidence's own standing decides the rest.
5960
+ */
5961
+ async staticSpawnBound(caller, run) {
5962
+ if (this.meshMode !== "static" || caller === undefined)
5963
+ return { kind: "exempt" };
5964
+ // The driver's own credential is not the run's authority, and the starter's evidence may have
5965
+ // expired or changed since: the admission recorded both the ceiling and the verdict.
5966
+ if (run !== undefined) {
5967
+ const admission = run.admission;
5968
+ // Read leader-served before each of the run's spawns, so a run revoked mid-step starts no
5969
+ // helper, and a marker this manager cannot read refuses the spawn too. The marker of a run
5970
+ // hosted elsewhere was read with its admission.
5971
+ let refusal;
5972
+ if (run.hosted)
5973
+ try {
5974
+ const revoked = (await this.runHosting.admission(admission.runId)).revoked;
5975
+ if (revoked !== undefined)
5976
+ refusal = revokedRunRefusal(admission.runId, revoked);
5977
+ }
5978
+ catch (e) {
5979
+ refusal = `run ${admission.runId}'s revocation marker could not be read (${rejectionText(e)}), so it starts no helper (SPEC 14.8)`;
5980
+ }
5981
+ if (refusal !== undefined)
5982
+ return { kind: "refused", reason: refusal };
5983
+ if (admission.spawnExempt)
5984
+ return { kind: "exempt" };
5985
+ // The helper is projected at the admitted coordinate, which needs a lifecycle token. A local
5986
+ // admission written before its uid was one records the CLI's own holder id, and bounds nothing.
5987
+ try {
5988
+ assertLifecycleToken(admission.caller.uid);
5989
+ }
5990
+ catch {
5991
+ return { kind: "refused", reason: `run ${admission.runId}'s admission records caller uid ${JSON.stringify(admission.caller.uid)}, which is not a lifecycle token, so its helpers cannot be measured and it starts none. Start the run again (SPEC 14.8)` };
5992
+ }
5993
+ return { kind: "bounded", caller: admission.caller, ceiling: admission.ceiling, run: admission.runId };
5994
+ }
5995
+ if (!isIssuedCaller(caller))
5996
+ return { kind: "none", reason: "its credential carries no issued generation (an unissued mint or a legacy rail)" };
5997
+ const ref = { space: this.space, owner: caller.owner, actor: caller.actor, uid: caller.uid, generation: caller.generation };
5998
+ try {
5999
+ const { evidence } = await withIssuerSession({ servers: this.servers ?? DEFAULT_SERVER, space: this.space, auth: this.auth, tls: false }, (s) => s.store.resolve(ref, s.sourceIsLive));
6000
+ return issuedSpawnerStanding(evidence) === "bounded" ? { kind: "bounded", caller, ceiling: evidence.permissions } : { kind: "exempt" };
6001
+ }
6002
+ catch (e) {
6003
+ return { kind: "none", reason: `its issued evidence does not resolve: ${rejectionText(e)}` };
6004
+ }
6005
+ }
6006
+ /** The refusal for a grant its static spawner does not hold, or `undefined` when it holds all of
6007
+ * it. Each part is named, so the caller can drop exactly what overreached. `armsEvents` is whether
6008
+ * the helper's own event plane will be minted: that row is keyed on a principal allocated later,
6009
+ * so it is measured as the spawner's own, the rule the ledger applies. `door` names the command
6010
+ * being bounded: a persona definition is bounded against its definer as a spawn is. */
6011
+ spawnBoundRefusal(bound, grant, armsEvents, door = "spawn") {
6012
+ if (bound.kind === "exempt")
6013
+ return undefined;
6014
+ if (bound.kind === "refused")
6015
+ return `${door}: ${bound.reason}`;
6016
+ let over;
6017
+ let eventsOver;
6018
+ try {
6019
+ over = bound.kind === "none" ? grantParts(grant) : grantOverreach(this.space, bound.caller, bound.ceiling, grant);
6020
+ eventsOver = armsEvents && (bound.kind === "none"
6021
+ || grantOverreach(this.space, bound.caller, bound.ceiling, { allowPublish: [eventChannel({ owner: bound.caller.owner, actor: bound.caller.actor })] }).length > 0);
6022
+ }
6023
+ catch (e) {
6024
+ return `${door}: ${rejectionText(e)}`;
6025
+ }
6026
+ if (!over.length && !eventsOver)
6027
+ return undefined;
6028
+ const named = [
6029
+ // A role is handed on by a spawner whose credential binds that role's task queue: its own
6030
+ // role, or a `role:<r>` token in its capabilities.
6031
+ ...over.map((o) => o.field === "role" ? `role ${o.value} (a spawner hands on a role it was given as its own or as the \`role:${o.value}\` capability)` : `${o.field} ${o.value}`),
6032
+ ...(eventsOver ? ["events (the helper's own event plane; pass events: false to spawn without it)"] : []),
6033
+ ].join(", ");
6034
+ if (door === "define-persona")
6035
+ return bound.kind === "none"
6036
+ ? `define-persona: the definer holds no issued evidence (SPEC 13.15), so its persona can grant nothing: ${bound.reason}. This persona asks for ${named}. ` +
6037
+ `Define it without these, or start the definer under a manager (\`cotal spawn --detach\`), whose seats carry issued evidence.`
6038
+ : `define-persona: this persona grants more than the definer holds: ${named}. A definer hands on only what its own issued credential carries (SPEC 13.15). ` +
6039
+ `Drop these from the persona, or have the operator widen the definer's own persona.`;
6040
+ if (bound.kind === "bounded" && bound.run !== undefined)
6041
+ return `spawn: this spawn hands its helper more than run ${bound.run}'s starter held when the run was admitted: ${named}. ` +
6042
+ `A run hands on only what its admission recorded: its starter's issued credential, or for a \`--local\` run the channels named by \`--admit-read\` and \`--admit-publish\` (SPEC 14.8). ` +
6043
+ `Drop these from the persona or the spawn, or start the run from a caller that holds them.`;
6044
+ return bound.kind === "none"
6045
+ ? `spawn: the spawner holds no issued evidence (SPEC 13.15), so it hands on no grant: ${bound.reason}. This spawn asks for ${named}. ` +
6046
+ `Spawn a persona that names none of these, or start the spawner under a manager (\`cotal spawn --detach\`), whose seats carry issued evidence.`
6047
+ : `spawn: this spawn hands its helper more than the spawner holds: ${named}. A spawner hands on only what its own issued credential carries (SPEC 13.15). ` +
6048
+ `Drop these from the persona or the spawn, or have the operator widen the spawner's own persona.`;
6049
+ }
5726
6050
  async startAgentActive(opts, spawner, hooks, authenticatedCaller) {
5727
6051
  // Before the first await, so the grant, the launch, the retained form and the restart slot all read
5728
6052
  // the lists and the restart policy as admitted.
@@ -5759,7 +6083,11 @@ export class Manager {
5759
6083
  const persona = await this.spawnPersona(opts, spawner, authenticatedCaller);
5760
6084
  if ("error" in persona)
5761
6085
  return { ok: false, error: persona.error, ...(persona.code ? { code: persona.code } : {}) };
5762
- const { ref, configPath, def, agent } = persona;
6086
+ const { ref, def, agent, inline } = persona;
6087
+ // An inline persona's file is its snapshot, written once the lifecycle uid is final; until then
6088
+ // a refusal names the persona instead of a path.
6089
+ let configPath = persona.configPath;
6090
+ const where = configPath ?? `inline persona "${ref}"`;
5763
6091
  // Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
5764
6092
  // `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
5765
6093
  // below so that span stays fully SYNCHRONOUS and atomic. On the manifest binary this also honors a
@@ -5777,6 +6105,11 @@ export class Manager {
5777
6105
  throw new EpEnvelopeError("failed-precondition", error, undefined, "not-executed");
5778
6106
  return { ok: false, error };
5779
6107
  }
6108
+ // Read here, beside the connector, so the capacity check and the reserve below take no new await.
6109
+ const run = await this.spawnRun(authenticatedCaller, opts.runId);
6110
+ if (run !== undefined && "refused" in run)
6111
+ return { ok: false, error: `spawn: ${run.refused}` };
6112
+ const bound = await this.staticSpawnBound(authenticatedCaller, run);
5780
6113
  const readinessTimeoutMs = connector.readinessTimeoutMs ?? this.readinessTimeoutMs;
5781
6114
  if (!Number.isSafeInteger(readinessTimeoutMs) || readinessTimeoutMs <= 0)
5782
6115
  return { ok: false, error: `connector ${connector.name} declares invalid readinessTimeoutMs ${JSON.stringify(connector.readinessTimeoutMs)}; expected a positive safe integer` };
@@ -5898,6 +6231,8 @@ export class Manager {
5898
6231
  let variant = opts.variant;
5899
6232
  let launchOptions = opts.launchOptions;
5900
6233
  let prompt = opts.prompt;
6234
+ // The read set the persona and the overrides name, before `join` adds to it: what a saved copy keeps.
6235
+ let ownSubscribe;
5901
6236
  if (opts.resolved) {
5902
6237
  // A manifest launch is the access + identity authority: imperative overrides arriving
5903
6238
  // alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
@@ -5932,13 +6267,26 @@ export class Manager {
5932
6267
  // when the door sent one). The spawner's own event channel is dropped from it: that plane is
5933
6268
  // foreign to the child, and the guard below refuses any spawn that asks for one. The joined
5934
6269
  // set is never copied from the spawner: a helper starts on what its persona lists plus what
5935
- // the caller passes in `join`, and nothing else.
6270
+ // the caller passes in `join`, and nothing else. The ACL is the caller's own report, so on a
6271
+ // static mesh it is clamped to what the caller's evidence shows it holds, and a caller with
6272
+ // no evidence gets no default at all.
5936
6273
  const personaNamesChannels = def.subscribe !== undefined || def.allowSubscribe !== undefined || def.allowPublish !== undefined;
5937
- const scoped = !personaNamesChannels && opts.callerAcl ? {
5938
- allowSubscribe: opts.callerAcl.allowSubscribe.filter((ch) => eventChannelPrincipal(ch) === null),
5939
- allowPublish: opts.callerAcl.allowPublish.filter((ch) => eventChannelPrincipal(ch) === null),
5940
- } : undefined;
6274
+ const heldChannels = (field, channels) => {
6275
+ const own = channels.filter((ch) => eventChannelPrincipal(ch) === null);
6276
+ return bound.kind === "bounded" ? own.filter((ch) => grantOverreach(this.space, bound.caller, bound.ceiling, { [field]: [ch] }).length === 0) : own;
6277
+ };
6278
+ let scoped;
6279
+ try {
6280
+ scoped = !personaNamesChannels && opts.callerAcl && bound.kind !== "none" ? {
6281
+ allowSubscribe: heldChannels("allowSubscribe", opts.callerAcl.allowSubscribe),
6282
+ allowPublish: heldChannels("allowPublish", opts.callerAcl.allowPublish),
6283
+ } : undefined;
6284
+ }
6285
+ catch (e) {
6286
+ return { ok: false, error: `callerAcl: ${rejectionText(e)}` };
6287
+ }
5941
6288
  const base = opts.subscribe ?? def.subscribe;
6289
+ ownSubscribe = base;
5942
6290
  subscribe = opts.join?.length ? [...new Set([...(base ?? []), ...opts.join])] : base;
5943
6291
  allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? scoped?.allowSubscribe ?? [];
5944
6292
  allowPublish = opts.allowPublish ?? def.allowPublish ?? scoped?.allowPublish;
@@ -5957,8 +6305,43 @@ export class Manager {
5957
6305
  allowSubscribe = resolveReadAcl(subscribe ?? [], allowSubscribe);
5958
6306
  }
5959
6307
  catch (e) {
5960
- return { ok: false, error: opts.resolved ? `launch agent: ${rejectionText(e)}` : `persona ${configPath}: ${rejectionText(e)}` };
6308
+ return { ok: false, error: opts.resolved ? `launch agent: ${rejectionText(e)}` : `persona ${where}: ${rejectionText(e)}` };
5961
6309
  }
6310
+ // The AG-UI event plane is on unless the launch explicitly opted out. A connector that cannot
6311
+ // emit one is refused further down, before anything is minted.
6312
+ const events = this.eventsRequired || opts.events !== false;
6313
+ // THE DELEGATION BOUND, on the grant as it will be minted: every channel and capability the
6314
+ // helper gets, from the persona, the raw overrides and the `callerAcl` default alike, and its own
6315
+ // event plane, so no door reaches the mint around it. Before any reserve or mint, so a refusal
6316
+ // leaves nothing.
6317
+ const boundRefusal = this.spawnBoundRefusal(bound, {
6318
+ allowSubscribe,
6319
+ ...(allowPublish !== undefined ? { allowPublish } : {}),
6320
+ ...(role !== undefined ? { role } : {}),
6321
+ ...(capabilities !== undefined ? { capabilities } : {}),
6322
+ }, events);
6323
+ if (boundRefusal)
6324
+ return { ok: false, error: boundRefusal };
6325
+ // A run saves for the caller it was admitted for, whose catalog reads then find the copy.
6326
+ const savedBy = authenticatedCaller === undefined ? undefined : this.runHosting?.admittedCaller(authenticatedCaller);
6327
+ // What a saved copy keeps: the grant just checked, without the helper's own event row, which each
6328
+ // later spawn derives for its own helper. The file names every channel field, so no later caller's
6329
+ // default fills one, and each later spawn of it is still bounded against that caller.
6330
+ const catalogCopy = inline?.save
6331
+ ? {
6332
+ name: ref,
6333
+ owner: savedBy !== undefined ? this.personaOwner(principalKey(savedBy.owner, savedBy.actor).key) : spawner === undefined ? undefined : this.personaOwner(spawner),
6334
+ role,
6335
+ model,
6336
+ variant,
6337
+ description: def?.description,
6338
+ persona: def?.persona,
6339
+ subscribe: [...(ownSubscribe ?? [])],
6340
+ allowSubscribe: [...allowSubscribe],
6341
+ allowPublish: [...(allowPublish ?? [])],
6342
+ capabilities: capabilities?.length ? [...capabilities] : undefined,
6343
+ }
6344
+ : undefined;
5962
6345
  // Checked after every path has resolved its selectors: a direct `startAgent` call skips the
5963
6346
  // `start` op's checks, and no connector may receive a blank one (#2862).
5964
6347
  if (model !== undefined && !model.trim())
@@ -5967,36 +6350,40 @@ export class Manager {
5967
6350
  return { ok: false, error: "variant: must not be empty" };
5968
6351
  const idErr = this.nameError(identityName);
5969
6352
  if (idErr)
5970
- return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${configPath}: ${idErr}` };
6353
+ return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${where}: ${idErr}` };
5971
6354
  // #966: a seat labelled `manager` that cannot spawn is a worker wearing a label, and the label
5972
6355
  // is what other agents ROUTE on (role is the anycast address). The gap is silent today because
5973
- // the mismatch is only discovered minutes in, when the seat first reaches for a worker. The
5974
- // persona write path takes content only (role/capabilities are POLICY), so a peer-defined
5975
- // persona can never fix this by declaring the capability — the only honest outcomes are "the
5976
- // persona grants spawn" or "say so now". Refuse at accept, before any reserve/mint, naming the
5977
- // exact remediation for each author: an operator edits the file to add the grant; a peer that
5978
- // defined the persona over the wire must ask one, because capabilities cannot be declared over
5979
- // the wire by design. The check keys on the EFFECTIVE role (a spawn-time role: override wins
6356
+ // the mismatch is only discovered minutes in, when the seat first reaches for a worker. Refuse
6357
+ // at accept, before any reserve/mint, naming the remediation for each author: an operator edits
6358
+ // the file to add the grant, and the persona's owner may redefine it with the grant only if the
6359
+ // owner holds spawn, because define-persona bounds capabilities against the definer. The
6360
+ // check keys on the EFFECTIVE role (a spawn-time role: override wins
5980
6361
  // over the file, mirroring the resolution above), not the file's, so the label other agents
5981
6362
  // will actually see is the label that is held to account. `manager` is the label that MEANS
5982
6363
  // spawn-holding: naming it literally keeps this the narrow guard for the observed harm, not a
5983
6364
  // vocabulary any caller can trigger with a synonym (roles are free-form and mostly
5984
6365
  // non-manager: this workspace's own catalog carries worker/reviewer/orchestrator seats with no
5985
6366
  // spawn grant, all legitimate).
6367
+ if (role === "manager" && !capabilities?.includes("spawn") && inline)
6368
+ return {
6369
+ ok: false,
6370
+ error: `inline persona "${ref}" would join with role "manager" but no spawn capability: it presents as a manager ` +
6371
+ `while being unable to seat workers. Add \`capabilities: [spawn]\` to the inline persona (a spawner hands on only what it holds), or give it another role.`,
6372
+ };
5986
6373
  if (role === "manager" && !capabilities?.includes("spawn"))
5987
6374
  return {
5988
6375
  ok: false,
5989
6376
  error: `persona "${ref}" would join with role "manager" but no spawn capability: it presents as a manager ` +
5990
6377
  `while being unable to seat workers, and it would discover that only on first use. ` +
5991
- `An operator adds \`capabilities: [spawn]\` to ${configPath}; a peer-defined persona ` +
5992
- `(cotal_persona) cannot declare capabilities by design — ask an operator to grant it.`,
6378
+ `An operator adds \`capabilities: [spawn]\` to ${configPath}, or the persona's owner redefines it ` +
6379
+ `with \`capabilities: [spawn]\` (cotal_persona) if the owner holds spawn.`,
5993
6380
  };
5994
6381
  // #581: the operator's per-role model allowlist, judged on the EFFECTIVE role and model resolved
5995
6382
  // above, before any reserve or mint. This is where the launch is performed, so no caller can
5996
6383
  // skip it, and a definition pinned to a stale model (or to none) is refused here instead of
5997
6384
  // serving on it.
5998
6385
  const policyRefusal = modelPolicyRefusal(cotalConfig, {
5999
- persona: opts.resolved ? `manifest agent "${ref}"` : `persona "${ref}" (${configPath})`,
6386
+ persona: opts.resolved ? `manifest agent "${ref}"` : configPath ? `persona "${ref}" (${configPath})` : where,
6000
6387
  role, model, variant,
6001
6388
  // A manifest launch carries its own declared model/variant in these options
6002
6389
  // (launchAgentToStartOpts), so there they are the definition's field, never a flag.
@@ -6044,9 +6431,9 @@ export class Manager {
6044
6431
  const unportable = runtime.spawnDelegated ? Object.keys(launchOptions ?? {}).filter((k) => typeof launchOptions[k] !== "string") : [];
6045
6432
  if (unportable.length)
6046
6433
  return { ok: false, error: `runtime "${runtime.kind}" starts seats outside this host, so it cannot pass non-string launch options (${unportable.join(", ")})` };
6047
- // The AG-UI event plane is on unless the launch explicitly opted out. Refused HERE, before
6048
- // anything is minted: a connector that cannot
6049
- // emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
6434
+ // An event plane the connector cannot emit is refused HERE, before anything is minted: a
6435
+ // connector that cannot emit must fail before provisioning rather than after, as an
6436
+ // unsupported `resume` does.
6050
6437
  // The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
6051
6438
  // the principal's owner is resolved further down, so deriving it from anything in scope here
6052
6439
  // would mean guessing at the identity the child will actually connect as. It is added at the
@@ -6054,7 +6441,6 @@ export class Manager {
6054
6441
  const eventsRefused = this.eventsRefusal(connector, opts.events);
6055
6442
  if (eventsRefused)
6056
6443
  return { ok: false, error: eventsRefused };
6057
- const events = this.eventsRequired || opts.events !== false;
6058
6444
  // The roster the allocation consults must reflect the initial presence snapshot,
6059
6445
  // or a spawn immediately after manager boot races an already-live unmanaged peer and re-opens the
6060
6446
  // very collision black-hole this closes. Await the snapshot (bounded internally, fail-safe on an
@@ -6111,6 +6497,9 @@ export class Manager {
6111
6497
  let provisioned;
6112
6498
  // A carried resume's seat-private home, removed by the finally unless a live seat took it.
6113
6499
  let seatHome;
6500
+ // The lifecycle whose inline snapshot this spawn wrote: removed by the finally unless a live seat
6501
+ // took it. A write that failed sets nothing, so the finally never removes a file it did not make.
6502
+ let snapshotUid;
6114
6503
  // Taken right before the try so its finally is the only release; a refusal belongs above the
6115
6504
  // allocation, where there is no name to give back.
6116
6505
  hooks?.onEffect?.();
@@ -6228,6 +6617,7 @@ export class Manager {
6228
6617
  label: ref,
6229
6618
  lifecycleUid,
6230
6619
  delegatedIntent: opts.delegatedIntent,
6620
+ ...(opts.served !== undefined ? { spawnRequest: this.servedSpawnRequest(opts.served, authenticatedCaller) } : {}),
6231
6621
  // Hold the name at the UID the host enrolled for the intent's user before any later local
6232
6622
  // step runs, so no later failure, its refusal included, releases a lifecycle that only that
6233
6623
  // user's retirement intent retires.
@@ -6323,6 +6713,18 @@ export class Manager {
6323
6713
  // The realpath the admission check above already resolved (#385 item 2) — never re-resolved here,
6324
6714
  // so the admission refusal and the launch cwd can never disagree on the same request.
6325
6715
  const cwd = resolvedCwd ?? this.workspaceRoot;
6716
+ // An inline persona's snapshot, and its catalog copy when saved, are written here: after the
6717
+ // grant (the static issuance, the user-auth ledger, a hosted enrollment that may replace the
6718
+ // uid) and before launch, so a refused grant leaves neither file and the snapshot carries the
6719
+ // final uid. A copy that loses a create race throws, and the finally rolls the grant back.
6720
+ if (inline) {
6721
+ configPath = writeInlineSnapshot(this.workspaceRoot, lifecycleUid, { name, role, model, variant, description: def.description, body: def.persona });
6722
+ snapshotUid = lifecycleUid;
6723
+ if (catalogCopy)
6724
+ saveAgentFile(agentFilePath(this.workspaceRoot, ref), catalogCopy, { createOnly: true });
6725
+ }
6726
+ if (configPath === undefined)
6727
+ throw new Error("internal: the persona file is missing at launch");
6326
6728
  const configSha256 = this.fileDigest(configPath);
6327
6729
  const manifestPath = opts.launchRef
6328
6730
  ? join(this.workspaceRoot, ".cotal", "run", `${opts.launchRef.runId}.json`)
@@ -6425,7 +6827,9 @@ export class Manager {
6425
6827
  configSha256,
6426
6828
  manifestSha256,
6427
6829
  }
6428
- : { kind: "persona", ref: opts.config ? identityName : ref, configPath, configSha256 },
6830
+ : inline
6831
+ ? { kind: "inline", ref, configPath, configSha256 }
6832
+ : { kind: "persona", ref: opts.config ? identityName : ref, configPath, configSha256 },
6429
6833
  cwd,
6430
6834
  model,
6431
6835
  variant,
@@ -6486,6 +6890,7 @@ export class Manager {
6486
6890
  // orphan-rollback in `finally` no longer applies to it.
6487
6891
  provisioned = undefined;
6488
6892
  seatHome = undefined;
6893
+ snapshotUid = undefined;
6489
6894
  // #159 B1: reply on a REAL outcome, not a timer. Wait for the agent to actually join the mesh
6490
6895
  // (presence) → started, the child to exit → failed (with its last output; already reaped), or
6491
6896
  // neither in time → uncertain. `✓ started` therefore means "it joined", never just "a process
@@ -6651,10 +7056,15 @@ export class Manager {
6651
7056
  this.reservedLive.delete(name);
6652
7057
  if (seatHome)
6653
7058
  rmSync(seatHome, { recursive: true, force: true });
7059
+ // A failed removal is logged, never thrown, so the grant's teardown below is still scheduled.
7060
+ const snapshotGone = snapshotUid === undefined || this.dropInlineSnapshot(name, snapshotUid);
6654
7061
  // Minted but never handed to a live slot (buildLaunch / runtime.spawn threw after mint) → tear the
6655
7062
  // orphan down (detached, fail-loud) so a failed spawn leaves no creds/durables behind (#159 B).
6656
7063
  if (provisioned) {
6657
- const orphan = provisioned;
7064
+ // Settled when this spawn wrote no snapshot or removed the one it wrote. A file its write
7065
+ // refused is not this lifecycle's, so the terminal leaves the uid's path alone; a removal
7066
+ // that failed is the terminal's to retry and report.
7067
+ const orphan = { ...provisioned, ...(snapshotGone ? { snapshotSettled: true } : {}) };
6658
7068
  // The host enrolled this UID for the intent's user, so only that user's retirement intent
6659
7069
  // retires it, and its name stays held for retireDelegatedAgent.
6660
7070
  const p = orphan.delegated ? parsePrincipalKey(orphan.id) : null;
@@ -10610,6 +11020,12 @@ export class Manager {
10610
11020
  await attemptCleanup(failed, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
10611
11021
  await attemptCleanup(failed, `file ${creds}`, () => rmSync(creds, { force: true }));
10612
11022
  }
11023
+ // The lifecycle's inline snapshot, when no earlier step settled it. Keyed by the uid, so a boot
11024
+ // sweep or a reconcile that knows only the slot row removes it too.
11025
+ if (!a.snapshotSettled) {
11026
+ const snapshot = inlineSnapshotPath(this.workspaceRoot, a.lifecycleUid);
11027
+ await attemptCleanup(failed, `file ${snapshot}`, () => removeInlineSnapshot(this.workspaceRoot, a.lifecycleUid));
11028
+ }
10613
11029
  await attemptCleanup(failed, "broker teardown", async () => {
10614
11030
  try {
10615
11031
  const accounting = await this.deprovisionBroker(a);
@@ -11475,15 +11891,17 @@ export class Manager {
11475
11891
  /** Persist a peer-defined persona as config. After this, `start name` auto-discovers
11476
11892
  * .cotal/agents/<name>.md and the connector applies its persona/model at spawn.
11477
11893
  *
11478
- * CONTENT vs POLICY: `capabilities` and `owner` still have no slot, so a peer cannot
11479
- * self-grant spawn or claim ownership. Channel grants, role, and agent are content: they may
11894
+ * CONTENT vs POLICY: `owner` has no slot, so a peer cannot claim ownership, and the
11895
+ * capabilities the written persona carries, a saved list a redefine keeps included, are bounded
11896
+ * against the definer before the write, so a peer can put only what it holds into a persona that
11897
+ * a wider spawner might later launch. Channel grants, role, and agent are content: they may
11480
11898
  * arrive as explicit arguments or as a leading frontmatter block inside `persona`. A prompt
11481
11899
  * that starts with `---` is parsed and merged (explicit args win) so the written file has one
11482
11900
  * frontmatter block; a malformed block is refused by name rather than wrapped. A fresh name is
11483
11901
  * created with the owner {@link personaOwner} records. Redefining an EXISTING file is allowed on
11484
11902
  * the privileged tier only if `file.owner == caller`, else admin is required. Fail-closed: an
11485
11903
  * ownerless file (legacy / operator-written) is admin-only. */
11486
- opDefinePersona(args, caller, admin) {
11904
+ async opDefinePersona(args, caller, admin, authenticated) {
11487
11905
  if (this.execution === "none")
11488
11906
  throw new EpEnvelopeError("unimplemented", this.executionRefusal());
11489
11907
  const name = String(args.name ?? "").trim();
@@ -11497,49 +11915,107 @@ export class Manager {
11497
11915
  return { ok: false, error: "persona required" };
11498
11916
  const model = args.model !== undefined && args.model !== null && String(args.model) !== "" ? String(args.model) : undefined;
11499
11917
  const path = agentFilePath(this.workspaceRoot, name);
11500
- let existing;
11501
- if (existsSync(path)) {
11502
- try {
11503
- existing = loadAgentFile(path);
11504
- }
11505
- catch (e) {
11506
- return { ok: false, error: rejectionText(e) };
11507
- }
11508
- if (!admin && existing.owner !== caller) {
11509
- const owner = existing.owner ? `owned by ${existing.owner}` : "operator-owned (legacy file - no agent owner)";
11510
- return { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` };
11511
- }
11512
- }
11513
11918
  const strList = (v) => {
11514
11919
  if (v === undefined || v === null)
11515
11920
  return undefined;
11516
11921
  return (Array.isArray(v) ? v : [v]).map((x) => String(x));
11517
11922
  };
11518
- let def;
11519
- try {
11520
- def = composeWirePersona({
11521
- name,
11522
- owner: this.personaOwner(caller),
11523
- prompt: persona,
11524
- model,
11525
- role: args.role !== undefined && args.role !== null && String(args.role) !== "" ? String(args.role) : undefined,
11526
- agent: args.agent !== undefined && args.agent !== null && String(args.agent) !== "" ? String(args.agent) : undefined,
11527
- subscribe: strList(args.subscribe),
11528
- allowSubscribe: strList(args.allowSubscribe),
11529
- allowPublish: strList(args.allowPublish),
11530
- }, existing);
11531
- }
11532
- catch (e) {
11533
- return { ok: false, error: rejectionText(e) };
11923
+ const capabilities = strList(args.capabilities);
11924
+ // Read, authorize and compose against the catalog as it is now. Nothing yields between this and
11925
+ // the write, so no other call in this manager can change the file in between. The handler does
11926
+ // not serialize with other processes; their writes are outside this single-manager guarantee.
11927
+ const compose = () => {
11928
+ let existing;
11929
+ if (existsSync(path)) {
11930
+ try {
11931
+ existing = loadAgentFile(path);
11932
+ }
11933
+ catch (e) {
11934
+ return { reply: { ok: false, error: rejectionText(e) } };
11935
+ }
11936
+ if (!admin && existing.owner !== caller) {
11937
+ const owner = existing.owner ? `owned by ${existing.owner}` : "operator-owned (legacy file - no agent owner)";
11938
+ return { reply: { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` } };
11939
+ }
11940
+ }
11941
+ try {
11942
+ return {
11943
+ def: composeWirePersona({
11944
+ name,
11945
+ owner: this.personaOwner(caller),
11946
+ prompt: persona,
11947
+ model,
11948
+ role: args.role !== undefined && args.role !== null && String(args.role) !== "" ? String(args.role) : undefined,
11949
+ agent: args.agent !== undefined && args.agent !== null && String(args.agent) !== "" ? String(args.agent) : undefined,
11950
+ subscribe: strList(args.subscribe),
11951
+ allowSubscribe: strList(args.allowSubscribe),
11952
+ allowPublish: strList(args.allowPublish),
11953
+ capabilities,
11954
+ }, existing),
11955
+ };
11956
+ }
11957
+ catch (e) {
11958
+ return { reply: { ok: false, error: rejectionText(e) } };
11959
+ }
11960
+ };
11961
+ let composed = compose();
11962
+ if ("reply" in composed)
11963
+ return composed.reply;
11964
+ // The bound reads the persona as written, so a redefine that omits `capabilities` has the list it
11965
+ // keeps checked too. Resolving the definer's grant yields, so the catalog is read again after it.
11966
+ if (composed.def.capabilities?.length) {
11967
+ const refusal = await this.definerCapabilityRefusal(caller, authenticated);
11968
+ composed = compose();
11969
+ if ("reply" in composed)
11970
+ return composed.reply;
11971
+ const kept = composed.def.capabilities ?? [];
11972
+ const refused = kept.length ? refusal(kept) : undefined;
11973
+ if (refused)
11974
+ return {
11975
+ ok: false,
11976
+ code: "permission-denied",
11977
+ error: capabilities === undefined
11978
+ ? `${refused} These come from the saved persona, which a redefine without \`capabilities\` keeps; pass the capabilities the definer holds, or [] to clear them.`
11979
+ : refused,
11980
+ };
11534
11981
  }
11535
11982
  try {
11536
- saveAgentFile(path, def);
11983
+ saveAgentFile(path, composed.def);
11537
11984
  }
11538
11985
  catch (e) {
11539
11986
  return { ok: false, error: rejectionText(e) };
11540
11987
  }
11541
11988
  return { ok: true, data: { name, path } };
11542
11989
  }
11990
+ /** Resolve what a persona's definer may put into it, as a check that returns the refusal for
11991
+ * capabilities it does not hold, or `undefined` when it holds them all. The check itself does not
11992
+ * yield. An open mesh mints no credential, so nothing is bounded there. A static definer is
11993
+ * bounded by its issued evidence, as a spawner is; a user-auth definer by its own ledger row's
11994
+ * scope, under the ledger's token rule, with `admin` exempt. A hosted manager reads no ledger, so
11995
+ * it refuses. A later spawn of the persona is bounded again against its own spawner. */
11996
+ async definerCapabilityRefusal(caller, authenticated) {
11997
+ if (this.meshMode === "open")
11998
+ return () => undefined;
11999
+ if (this.meshMode === "static") {
12000
+ const run = await this.spawnRun(authenticated, undefined);
12001
+ const bound = await this.staticSpawnBound(authenticated, run !== undefined && "admission" in run ? run : undefined);
12002
+ return (capabilities) => this.spawnBoundRefusal(bound, { capabilities }, false, "define-persona");
12003
+ }
12004
+ if (this.remoteAuthority)
12005
+ return () => "define-persona: this manager's user authority is hosted, so it cannot read the definer's grant and its persona can grant no capabilities.";
12006
+ const p = parsePrincipalKey(caller);
12007
+ const scope = p ? await resolveAuthProvider().actorScope({ dir: userAuthStateDir(this.workspaceRoot, this.space), owner: p.owner, actor: p.actor }) : undefined;
12008
+ return (capabilities) => {
12009
+ if (scope?.includes("admin"))
12010
+ return undefined;
12011
+ const over = capabilities.filter((c) => !capabilityWithin(scope ?? [], c));
12012
+ if (!over.length)
12013
+ return undefined;
12014
+ return scope === undefined
12015
+ ? `define-persona: the definer ${caller} has no grant in this space, so its persona can grant nothing. This persona asks for capabilities ${over.join(", ")}.`
12016
+ : `define-persona: this persona grants more than the definer holds: capabilities ${over.join(", ")} beyond [${scope.join(", ") || "none"}]. Drop these from the persona, or have the operator widen the definer's grant.`;
12017
+ };
12018
+ }
11543
12019
  /** Remove one machine-local persona definition without touching any running lifecycle that was
11544
12020
  * launched from it. Removal accepts the catalog's safe filename alphabet without applying the
11545
12021
  * current spawn-name length or user-mode actor rules, so an obsolete invalid-name entry remains