@cotal-ai/manager 0.152.0 → 0.154.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
@@ -5,7 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSy
5
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, 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";
8
+ import { CotalEndpoint, canonicalJson, copyPersonaEndpoints, lowerPersonaEndpoints, parsePersonaEndpoints, 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";
@@ -264,6 +264,25 @@ function ownLists(form) {
264
264
  ...(form.shareTools && { shareTools: [...form.shareTools] }),
265
265
  };
266
266
  }
267
+ /** The inline record is admitted the same way, one level deeper: the record itself, its lists, and
268
+ * each `endpoints` entry with its commands. It is still untrusted here, so a shape the validator
269
+ * will refuse is copied as far as it goes and refused where it is read. */
270
+ function ownPersona(record) {
271
+ if (record === null || typeof record !== "object" || Array.isArray(record))
272
+ return record;
273
+ const owned = ownLists({ ...record });
274
+ const endpoints = owned.endpoints;
275
+ if (Array.isArray(endpoints))
276
+ owned.endpoints = endpoints.map((e) => {
277
+ if (e === null || typeof e !== "object" || Array.isArray(e))
278
+ return e;
279
+ const entry = { ...e };
280
+ if (Array.isArray(entry.commands))
281
+ entry.commands = [...entry.commands];
282
+ return entry;
283
+ });
284
+ return owned;
285
+ }
267
286
  /** `start()`'s refusal when this manager instance's lease is held (#3915): by another live process
268
287
  * on this workspace root, or by a holder on another root. A restart whose predecessor on this root
269
288
  * is dead, this process's own earlier start included, waits the key out instead (#4092). The
@@ -331,7 +350,7 @@ function onceStopReason(cause) {
331
350
  function revokedRunRefusal(runId, revoked) {
332
351
  return `run ${runId} was revoked by ${revoked.by} (${revoked.reason}); a revoked run starts no helper (SPEC 14.8)`;
333
352
  }
334
- const INLINE_PERSONA_KEYS = ["prompt", "role", "model", "variant", "description", "subscribe", "allowSubscribe", "allowPublish", "capabilities", "save"];
353
+ const INLINE_PERSONA_KEYS = ["prompt", "role", "model", "variant", "description", "subscribe", "allowSubscribe", "allowPublish", "capabilities", "endpoints", "save"];
335
354
  /** Read an inline persona into the definition a catalog file would load as, refusing what a file
336
355
  * load refuses plus any shape the closed record does not take. The body is literal text: nothing in
337
356
  * it becomes a field, so a body that starts with `---` stays body. */
@@ -374,6 +393,7 @@ function inlinePersonaDef(name, raw) {
374
393
  allowSubscribe: list("allowSubscribe"),
375
394
  allowPublish: list("allowPublish"),
376
395
  capabilities: list("capabilities"),
396
+ endpoints: parsePersonaEndpoints(rec.endpoints, "persona"),
377
397
  };
378
398
  // The checks a file load makes on these fields: channel names, and the read set within the read ACL.
379
399
  try {
@@ -386,6 +406,11 @@ function inlinePersonaDef(name, raw) {
386
406
  }
387
407
  return { def, save: rec.save === true };
388
408
  }
409
+ /** The refusal a user-auth mesh gives a persona that names `endpoints`. */
410
+ function userMeshEndpointsRefusal(door) {
411
+ return `${door}: this mesh has per-user auth, where endpoint access comes from the service owner's grants and a persona cannot hand it on (SPEC 13.15). ` +
412
+ "Drop `endpoints` from the persona, and ask the operator to grant the helper with `cotal service-endpoint grant` once it runs.";
413
+ }
389
414
  const TURN_PARTICIPATION_TTL_MS = 5 * 60_000;
390
415
  function participantKey(owner, actor, uid) {
391
416
  return `${owner}.${actor}.${uid}`;
@@ -533,6 +558,7 @@ export class Manager {
533
558
  secretStoreIdentity;
534
559
  /** See {@link ManagerOptions.installedExtensions}. */
535
560
  installedExtensions;
561
+ expectedConnectorGeneration;
536
562
  executionState;
537
563
  /** One backend per kind a seat has asked for, beside {@link runtime}. The manager's own runtime
538
564
  * stays the default and is never replaced by a later seat's choice. */
@@ -815,6 +841,7 @@ export class Manager {
815
841
  leaseStopping = false;
816
842
  /** The class-2 renewal owner's half-TTL schedule; armed only on auth meshes. */
817
843
  credRenewTimer;
844
+ resumeCredRenewal;
818
845
  /** #2073: the `endpoint-serve` credential's OWN renewal, armed from its `[renewAt, exp)` window
819
846
  * (`credsRenewalDelayMs`) rather than left to `credRenewTimer`'s boot-phased TTL/4 tick, which can
820
847
  * miss the window by ordering. Re-armed at registration and after every successful re-mint;
@@ -871,6 +898,8 @@ export class Manager {
871
898
  return dialerFor(servers)({ ...opts, servers });
872
899
  }
873
900
  constructor(opts) {
901
+ if (opts.expectedConnectorGeneration !== undefined && opts.expectedConnectorGeneration !== MANAGER_VERSION)
902
+ throw new Error(`expected connector generation ${opts.expectedConnectorGeneration} differs from this Manager's loaded generation ${MANAGER_VERSION}; restart from the installed CLI`);
874
903
  if (opts.onServiceIdentity !== undefined && typeof opts.onServiceIdentity !== "function")
875
904
  throw new Error("manager onServiceIdentity must be a trusted embedding callback");
876
905
  if (opts.execution !== undefined && opts.execution !== "runtime" && opts.execution !== "none")
@@ -906,6 +935,7 @@ export class Manager {
906
935
  if (opts.secretStore)
907
936
  this.secretStoreIdentity = injectedSecretStoreIdentity(opts.secretStore);
908
937
  this.installedExtensions = opts.installedExtensions ?? false;
938
+ this.expectedConnectorGeneration = opts.expectedConnectorGeneration;
909
939
  this.pooled = opts.pooled === true;
910
940
  this.executionState = opts.execution === "none" ? { kind: "none" } : { kind: "runtime" };
911
941
  if (!this.remoteAuthority) {
@@ -1467,24 +1497,29 @@ export class Manager {
1467
1497
  catch { }
1468
1498
  }
1469
1499
  const intervalMs = Math.min(credRenewIntervalMs(5 * 60), credRenewIntervalMs(standingTtl));
1470
- this.credRenewTimer = setInterval(async () => {
1471
- try {
1472
- if (this.remoteAuthority?.renewStandingBundle)
1473
- await this.renewRemoteStandingBundle();
1474
- else
1475
- await this.renewRemoteExecutor();
1476
- }
1477
- catch (e) {
1478
- console.error(`! remote manager renewal pass failed: ${rejectionText(e)}`);
1479
- }
1480
- try {
1481
- await this.runHosting?.renew();
1482
- }
1483
- catch (e) {
1484
- console.error(`! remote run-hosting renewal pass failed: ${rejectionText(e)}`);
1485
- }
1486
- }, intervalMs);
1487
- this.credRenewTimer.unref?.();
1500
+ this.resumeCredRenewal = () => {
1501
+ if (this.credRenewTimer || this.leaseStopping)
1502
+ return;
1503
+ this.credRenewTimer = setInterval(async () => {
1504
+ try {
1505
+ if (this.remoteAuthority?.renewStandingBundle)
1506
+ await this.renewRemoteStandingBundle();
1507
+ else
1508
+ await this.renewRemoteExecutor();
1509
+ }
1510
+ catch (e) {
1511
+ console.error(`! remote manager renewal pass failed: ${rejectionText(e)}`);
1512
+ }
1513
+ try {
1514
+ await this.runHosting?.renew();
1515
+ }
1516
+ catch (e) {
1517
+ console.error(`! remote run-hosting renewal pass failed: ${rejectionText(e)}`);
1518
+ }
1519
+ }, intervalMs);
1520
+ this.credRenewTimer.unref?.();
1521
+ };
1522
+ this.resumeCredRenewal();
1488
1523
  }
1489
1524
  // stop() fences before it waits for an accepted startup reconciliation terminal. Once that
1490
1525
  // terminal drains, start() and stop() resume from the same await boundary; start must observe
@@ -2297,6 +2332,7 @@ export class Manager {
2297
2332
  this.credRenewTimer = setInterval(() => { void this.renewDaemonCreds(); }, credRenewIntervalMs(STANDING_RENEWABLE_TTL_SEC));
2298
2333
  this.credRenewTimer.unref?.();
2299
2334
  }
2335
+ this.resumeCredRenewal?.();
2300
2336
  // Exit watchers were suppressed while the fence stood: reconcile every child that died during
2301
2337
  // preparation now, or its slot/credential footprint would linger unreaped after the abort.
2302
2338
  for (const agent of [...this.agents.values()]) {
@@ -2484,6 +2520,7 @@ export class Manager {
2484
2520
  allowSubscribe: a.launch.allowSubscribe,
2485
2521
  allowPublish: a.launch.allowPublish,
2486
2522
  capabilities: a.launch.capabilities,
2523
+ ...(a.launch.endpoints?.length ? { endpoints: copyPersonaEndpoints(a.launch.endpoints) } : {}),
2487
2524
  events: a.launch.events,
2488
2525
  shareTools: a.launch.shareTools,
2489
2526
  forkSource: a.launch.forkSource,
@@ -5569,6 +5606,7 @@ export class Manager {
5569
5606
  * on MANIFEST membership FIRST — so a live `cotal ext remove` is honored even though the registry
5570
5607
  * still holds a connector imported earlier this session — then returns the registry hit or lazily
5571
5608
  * imports the providing package (transactional + single-flight, via the workspace primitive).
5609
+ * An expected generation also revalidates cached first-party providers under that lock.
5572
5610
  * Fail-loud with an install hint; no fallback. */
5573
5611
  async resolveConnector(name) {
5574
5612
  if (!this.installedExtensions)
@@ -5576,7 +5614,9 @@ export class Manager {
5576
5614
  if (!manifestExtensionNames("connector").includes(name))
5577
5615
  throw new Error(connectorInstallHint(name));
5578
5616
  const already = registry.all("connector").find((c) => c.name === name);
5579
- return already ?? materializeFromManifest({ kind: "connector", name }, { hint: (ref) => connectorInstallHint(ref.name) });
5617
+ if (this.expectedConnectorGeneration === undefined && already)
5618
+ return already;
5619
+ return materializeFromManifest({ kind: "connector", name }, { hint: (ref) => connectorInstallHint(ref.name), expectedGeneration: this.expectedConnectorGeneration });
5580
5620
  }
5581
5621
  /** Connector names the manager can spawn WITHOUT importing: the manifest on the published binary,
5582
5622
  * else whatever a composition root registered. Drives the `models` catalog enumeration. */
@@ -6179,6 +6219,8 @@ export class Manager {
6179
6219
  opts = ownLists(opts);
6180
6220
  if (opts.resolved)
6181
6221
  opts.resolved = ownLists(opts.resolved);
6222
+ if (opts.persona !== undefined)
6223
+ opts.persona = ownPersona(opts.persona);
6182
6224
  if (opts.supervise) {
6183
6225
  opts.supervise = { restarts: opts.supervise.restarts, windowMs: opts.supervise.windowMs };
6184
6226
  // The budget is checked here, where it is held, not only at the wire: an in-process caller of
@@ -6355,6 +6397,10 @@ export class Manager {
6355
6397
  let allowSubscribe;
6356
6398
  let allowPublish;
6357
6399
  let capabilities;
6400
+ // The endpoint commands the persona hands on (SPEC 13.15), as the persona names them and as the
6401
+ // mint grants them. A manifest launch carries none: its schema has no slot for them.
6402
+ let personaEndpoints;
6403
+ let endpoints = [];
6358
6404
  let model = opts.model;
6359
6405
  let variant = opts.variant;
6360
6406
  let launchOptions = opts.launchOptions;
@@ -6419,6 +6465,13 @@ export class Manager {
6419
6465
  allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? scoped?.allowSubscribe ?? [];
6420
6466
  allowPublish = opts.allowPublish ?? def.allowPublish ?? scoped?.allowPublish;
6421
6467
  capabilities = def.capabilities;
6468
+ personaEndpoints = copyPersonaEndpoints(def.endpoints);
6469
+ // A user-auth mesh grants endpoint access through the service owner's records, which a spawn
6470
+ // cannot write (SPEC 13.15), so a persona that names the field is refused there, `[]` included,
6471
+ // before any bound or exemption reads it.
6472
+ if (personaEndpoints !== undefined && this.meshMode === "user")
6473
+ return { ok: false, error: userMeshEndpointsRefusal("spawn") };
6474
+ endpoints = lowerPersonaEndpoints(personaEndpoints);
6422
6475
  // #651: fold the persona's model into the launch record, mirroring the variant line below
6423
6476
  // and the manifest branch above. Without this, a persona-file model (the common pin source)
6424
6477
  // never reaches `launch.model`, so the connector runs the seat on it while `ps --wide`/`--json`
@@ -6447,6 +6500,7 @@ export class Manager {
6447
6500
  ...(allowPublish !== undefined ? { allowPublish } : {}),
6448
6501
  ...(role !== undefined ? { role } : {}),
6449
6502
  ...(capabilities !== undefined ? { capabilities } : {}),
6503
+ ...(endpoints.length ? { endpoints } : {}),
6450
6504
  }, events);
6451
6505
  if (boundRefusal)
6452
6506
  return { ok: false, error: boundRefusal };
@@ -6468,6 +6522,7 @@ export class Manager {
6468
6522
  allowSubscribe: [...allowSubscribe],
6469
6523
  allowPublish: [...(allowPublish ?? [])],
6470
6524
  capabilities: capabilities?.length ? [...capabilities] : undefined,
6525
+ endpoints: copyPersonaEndpoints(personaEndpoints?.length ? personaEndpoints : undefined),
6471
6526
  }
6472
6527
  : undefined;
6473
6528
  // Checked after every path has resolved its selectors: a direct `startAgent` call skips the
@@ -6827,6 +6882,7 @@ export class Manager {
6827
6882
  allowPublish,
6828
6883
  role,
6829
6884
  capabilities,
6885
+ ...(endpoints.length ? { endpointCapabilities: endpoints } : {}),
6830
6886
  lifecycleUid,
6831
6887
  expiresAt: exp,
6832
6888
  issued,
@@ -6902,6 +6958,9 @@ export class Manager {
6902
6958
  allowSubscribe,
6903
6959
  allowPublish,
6904
6960
  capabilities,
6961
+ // Always the list the launch resolved, `[]` included, so the session never reads a persona
6962
+ // file that may have changed since.
6963
+ endpoints: copyPersonaEndpoints(personaEndpoints) ?? [],
6905
6964
  events,
6906
6965
  cwd,
6907
6966
  };
@@ -6977,6 +7036,7 @@ export class Manager {
6977
7036
  allowSubscribe,
6978
7037
  allowPublish,
6979
7038
  capabilities,
7039
+ ...(personaEndpoints?.length ? { endpoints: copyPersonaEndpoints(personaEndpoints) } : {}),
6980
7040
  events,
6981
7041
  shareTools: opts.shareTools,
6982
7042
  forkSource: opts.resume,
@@ -7707,6 +7767,7 @@ export class Manager {
7707
7767
  allowSubscribe: entry.launch.allowSubscribe,
7708
7768
  allowPublish: entry.launch.allowPublish,
7709
7769
  capabilities: entry.launch.capabilities,
7770
+ endpoints: copyPersonaEndpoints(entry.launch.endpoints) ?? [],
7710
7771
  events: entry.launch.events,
7711
7772
  cwd: entry.launch.cwd,
7712
7773
  };
@@ -7791,6 +7852,7 @@ export class Manager {
7791
7852
  allowSubscribe: entry.launch.allowSubscribe,
7792
7853
  allowPublish: entry.launch.allowPublish,
7793
7854
  capabilities: entry.launch.capabilities,
7855
+ ...(entry.launch.endpoints?.length ? { endpoints: copyPersonaEndpoints(entry.launch.endpoints) } : {}),
7794
7856
  events: entry.launch.events,
7795
7857
  shareTools: entry.launch.shareTools,
7796
7858
  forkSource: entry.launch.forkSource,
@@ -8477,6 +8539,8 @@ export class Manager {
8477
8539
  await nc.drain().catch(() => nc.close());
8478
8540
  }
8479
8541
  }
8542
+ /** Parent-local inventory read. It does not enter maintenance or change admission. */
8543
+ hostedSeatCount() { return this.agents.size; }
8480
8544
  /** The served manager-level health summary. Given an owner, `agentCount` counts only that owner's
8481
8545
  * seats, as `ps` lists them ({@link psOwnerFilter}), so a person reading a shared Manager does not
8482
8546
  * learn how many seats other owners hold on it. */
@@ -11053,6 +11117,7 @@ export class Manager {
11053
11117
  allowSubscribe: entry.launch.allowSubscribe,
11054
11118
  allowPublish: entry.launch.allowPublish,
11055
11119
  capabilities: entry.launch.capabilities,
11120
+ ...(entry.launch.endpoints?.length ? { endpoints: copyPersonaEndpoints(entry.launch.endpoints) } : {}),
11056
11121
  events: entry.launch.events,
11057
11122
  shareTools: entry.launch.shareTools,
11058
11123
  forkSource: entry.launch.forkSource,
@@ -11505,6 +11570,7 @@ export class Manager {
11505
11570
  allowPublish: a.launch.allowPublish,
11506
11571
  role: a.role,
11507
11572
  capabilities: a.launch.capabilities,
11573
+ ...(a.launch.endpoints?.length ? { endpointCapabilities: lowerPersonaEndpoints(a.launch.endpoints) } : {}),
11508
11574
  lifecycleUid: a.lifecycleUid,
11509
11575
  expiresAt: exp,
11510
11576
  issued,
@@ -12081,6 +12147,13 @@ export class Manager {
12081
12147
  return (Array.isArray(v) ? v : [v]).map((x) => String(x));
12082
12148
  };
12083
12149
  const capabilities = strList(args.capabilities);
12150
+ let endpointArg;
12151
+ try {
12152
+ endpointArg = parsePersonaEndpoints(args.endpoints, "define-persona");
12153
+ }
12154
+ catch (e) {
12155
+ return { ok: false, error: rejectionText(e) };
12156
+ }
12084
12157
  // Read, authorize and compose against the catalog as it is now. Nothing yields between this and
12085
12158
  // the write, so no other call in this manager can change the file in between. The handler does
12086
12159
  // not serialize with other processes; their writes are outside this single-manager guarantee.
@@ -12111,6 +12184,7 @@ export class Manager {
12111
12184
  allowSubscribe: strList(args.allowSubscribe),
12112
12185
  allowPublish: strList(args.allowPublish),
12113
12186
  capabilities,
12187
+ endpoints: copyPersonaEndpoints(endpointArg),
12114
12188
  }, existing),
12115
12189
  };
12116
12190
  }
@@ -12121,23 +12195,34 @@ export class Manager {
12121
12195
  let composed = compose();
12122
12196
  if ("reply" in composed)
12123
12197
  return composed.reply;
12124
- // The bound reads the persona as written, so a redefine that omits `capabilities` has the list it
12125
- // keeps checked too. Resolving the definer's grant yields, so the catalog is read again after it.
12126
- if (composed.def.capabilities?.length) {
12127
- const refusal = await this.definerCapabilityRefusal(caller, authenticated);
12198
+ // The bound reads the persona as written, so a redefine that omits `capabilities` or `endpoints`
12199
+ // has the list it keeps checked too. Resolving the definer's grant yields, so the catalog is read
12200
+ // again after it.
12201
+ if (composed.def.capabilities?.length || composed.def.endpoints !== undefined) {
12202
+ const refusal = await this.definerGrantRefusal(caller, authenticated);
12128
12203
  composed = compose();
12129
12204
  if ("reply" in composed)
12130
12205
  return composed.reply;
12131
12206
  const kept = composed.def.capabilities ?? [];
12132
- const refused = kept.length ? refusal(kept) : undefined;
12133
- if (refused)
12207
+ const keptEndpoints = lowerPersonaEndpoints(composed.def.endpoints);
12208
+ // A user-auth persona may not name the field at all; elsewhere `[]` hands on nothing to bound.
12209
+ const namesEndpoints = composed.def.endpoints !== undefined;
12210
+ const refused = kept.length || keptEndpoints.length || (namesEndpoints && this.meshMode === "user")
12211
+ ? refusal({ capabilities: kept, endpoints: keptEndpoints, namesEndpoints })
12212
+ : undefined;
12213
+ if (refused) {
12214
+ const fromSaved = [
12215
+ ...(capabilities === undefined && kept.length ? ["capabilities"] : []),
12216
+ ...(endpointArg === undefined && composed.def.endpoints?.length ? ["endpoints"] : []),
12217
+ ];
12134
12218
  return {
12135
12219
  ok: false,
12136
12220
  code: "permission-denied",
12137
- error: capabilities === undefined
12138
- ? `${refused} These come from the saved persona, which a redefine without \`capabilities\` keeps; pass the capabilities the definer holds, or [] to clear them.`
12221
+ error: fromSaved.length
12222
+ ? `${refused} ${fromSaved.map((f) => `\`${f}\``).join(" and ")} come${fromSaved.length === 1 ? "s" : ""} from the saved persona, which a redefine that omits ${fromSaved.length === 1 ? "it keeps" : "them keeps"}; pass what the definer holds, or [] to clear ${fromSaved.length === 1 ? "it" : "them"}.`
12139
12223
  : refused,
12140
12224
  };
12225
+ }
12141
12226
  }
12142
12227
  try {
12143
12228
  saveAgentFile(path, composed.def);
@@ -12148,24 +12233,29 @@ export class Manager {
12148
12233
  return { ok: true, data: { name, path } };
12149
12234
  }
12150
12235
  /** Resolve what a persona's definer may put into it, as a check that returns the refusal for
12151
- * capabilities it does not hold, or `undefined` when it holds them all. The check itself does not
12152
- * yield. An open mesh mints no credential, so nothing is bounded there. A static definer is
12153
- * bounded by its issued evidence, as a spawner is; a user-auth definer by its own ledger row's
12154
- * scope, under the ledger's token rule, with `admin` exempt. A hosted manager reads no ledger, so
12236
+ * capabilities or endpoint commands it does not hold, or `undefined` when it holds them all. The
12237
+ * check itself does not yield. An open mesh mints no credential, so nothing is bounded there. A
12238
+ * static definer is bounded by its issued evidence, as a spawner is; a user-auth definer by its
12239
+ * own ledger row's scope, under the ledger's token rule, with `admin` exempt. A user-auth persona
12240
+ * cannot name `endpoints` at all, `[]` included (SPEC 13.15). A hosted manager reads no ledger, so
12155
12241
  * it refuses. A later spawn of the persona is bounded again against its own spawner. */
12156
- async definerCapabilityRefusal(caller, authenticated) {
12242
+ async definerGrantRefusal(caller, authenticated) {
12157
12243
  if (this.meshMode === "open")
12158
12244
  return () => undefined;
12159
12245
  if (this.meshMode === "static") {
12160
12246
  const run = await this.spawnRun(authenticated, undefined);
12161
12247
  const bound = await this.staticSpawnBound(authenticated, run !== undefined && "admission" in run ? run : undefined);
12162
- return (capabilities) => this.spawnBoundRefusal(bound, { capabilities }, false, "define-persona");
12248
+ return ({ capabilities, endpoints }) => this.spawnBoundRefusal(bound, { capabilities, ...(endpoints.length ? { endpoints } : {}) }, false, "define-persona");
12163
12249
  }
12164
12250
  if (this.remoteAuthority)
12165
- 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.";
12251
+ return ({ capabilities, namesEndpoints }) => namesEndpoints
12252
+ ? userMeshEndpointsRefusal("define-persona")
12253
+ : capabilities.length ? "define-persona: this manager's user authority is hosted, so it cannot read the definer's grant and its persona can grant no capabilities." : undefined;
12166
12254
  const p = parsePrincipalKey(caller);
12167
12255
  const scope = p ? await resolveAuthProvider().actorScope({ dir: userAuthStateDir(this.workspaceRoot, this.space), owner: p.owner, actor: p.actor }) : undefined;
12168
- return (capabilities) => {
12256
+ return ({ capabilities, namesEndpoints }) => {
12257
+ if (namesEndpoints)
12258
+ return userMeshEndpointsRefusal("define-persona");
12169
12259
  if (scope?.includes("admin"))
12170
12260
  return undefined;
12171
12261
  const over = capabilities.filter((c) => !capabilityWithin(scope ?? [], c));