@cotal-ai/auth 0.65.0 → 0.67.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.
Files changed (58) hide show
  1. package/dist/auth-admin.js +1 -1
  2. package/dist/auth-admin.js.map +1 -1
  3. package/dist/credential-ledger.js +3 -3
  4. package/dist/credential-ledger.js.map +1 -1
  5. package/dist/idp.d.ts +3 -1
  6. package/dist/idp.d.ts.map +1 -1
  7. package/dist/idp.js +3 -2
  8. package/dist/idp.js.map +1 -1
  9. package/dist/issuer.d.ts +4 -2
  10. package/dist/issuer.d.ts.map +1 -1
  11. package/dist/issuer.js +11 -6
  12. package/dist/issuer.js.map +1 -1
  13. package/dist/ledger.d.ts +3 -0
  14. package/dist/ledger.d.ts.map +1 -1
  15. package/dist/ledger.js +7 -0
  16. package/dist/ledger.js.map +1 -1
  17. package/dist/managed-agent-enrollment.d.ts +2 -7
  18. package/dist/managed-agent-enrollment.d.ts.map +1 -1
  19. package/dist/managed-agent-enrollment.js.map +1 -1
  20. package/dist/manager-admin-authorization.d.ts +2 -6
  21. package/dist/manager-admin-authorization.d.ts.map +1 -1
  22. package/dist/manager-admin-authorization.js +2 -17
  23. package/dist/manager-admin-authorization.js.map +1 -1
  24. package/dist/manager-authority.d.ts +4 -15
  25. package/dist/manager-authority.d.ts.map +1 -1
  26. package/dist/manager-authority.js +22 -33
  27. package/dist/manager-authority.js.map +1 -1
  28. package/dist/manager-goal-index.d.ts +2 -6
  29. package/dist/manager-goal-index.d.ts.map +1 -1
  30. package/dist/manager-goal-index.js +2 -17
  31. package/dist/manager-goal-index.js.map +1 -1
  32. package/dist/manager-maintenance.d.ts +2 -6
  33. package/dist/manager-maintenance.d.ts.map +1 -1
  34. package/dist/manager-maintenance.js +2 -18
  35. package/dist/manager-maintenance.js.map +1 -1
  36. package/dist/permissions.js +2 -0
  37. package/dist/permissions.js.map +1 -1
  38. package/dist/platform-control.d.ts +2 -5
  39. package/dist/platform-control.d.ts.map +1 -1
  40. package/dist/platform-control.js.map +1 -1
  41. package/dist/provider.d.ts.map +1 -1
  42. package/dist/provider.js +13 -5
  43. package/dist/provider.js.map +1 -1
  44. package/dist/retained-manager-validation.d.ts +2 -6
  45. package/dist/retained-manager-validation.d.ts.map +1 -1
  46. package/dist/retained-manager-validation.js +2 -18
  47. package/dist/retained-manager-validation.js.map +1 -1
  48. package/dist/retirement-barrier.js +4 -4
  49. package/dist/retirement-barrier.js.map +1 -1
  50. package/dist/service.d.ts +24 -8
  51. package/dist/service.d.ts.map +1 -1
  52. package/dist/service.js +183 -40
  53. package/dist/service.js.map +1 -1
  54. package/dist/token.d.ts +15 -2
  55. package/dist/token.d.ts.map +1 -1
  56. package/dist/token.js +31 -5
  57. package/dist/token.js.map +1 -1
  58. package/package.json +3 -3
package/dist/service.js CHANGED
@@ -32,9 +32,9 @@
32
32
  * the public face is the credential itself: the human arm presents an EdDSA IdP JWT verified
33
33
  * against the pinned JWKS/issuer/audience; the agent arm presents an actorToken whose sha256 must
34
34
  * match a FRESH ledger row. Origin rejection, JSON-only bodies, the 64 KB bound, and no-CORS-ever
35
- * hold verbatim. Public views are channel-writer/channel-purger (admin-gated) and manager-caller
36
- * (instance-bound, with no added command scope). Other views stay loopback-only. The agent-secret
37
- * arm may request only manager-caller on either face;
35
+ * hold verbatim. Public views are channel-writer/channel-purger and the one-object transfer-writer
36
+ * (admin-gated) and manager-caller (instance-bound, with no added command scope). Other views stay
37
+ * loopback-only. The agent-secret arm may request only manager-caller on either face;
38
38
  * failures are bucketed per peer (`--exchange-trusted-proxy` opts into the last X-Forwarded-For
39
39
  * hop as the peer key; otherwise the socket remote address) in a bounded LRU, under a global
40
40
  * concurrent-admission cap and a hard request deadline — all of it pools SEPARATE from the
@@ -59,14 +59,14 @@ import { resolve } from "node:path";
59
59
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
60
60
  import { jetstreamManager } from "@nats-io/jetstream";
61
61
  import { Kvm } from "@nats-io/kv";
62
- import { contractDigest, contractRefToHex, contractStoreContext, endpointToken, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, authorizeServeGrant, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, SERVICE_READY, writeServiceStatus } from "@cotal-ai/core";
62
+ import { CotalEndpoint, deprovisionAgent, isConcreteChannel, provisionAgentDurables, contractDigest, contractRefToHex, contractStoreContext, endpointToken, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, registerServingInstance } from "@cotal-ai/core";
63
63
  import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
64
64
  import { decodeJwt } from "jose";
65
65
  import { deriveOwnerForIdpSubject, platformControlOwner } from "./derive.js";
66
66
  import { makePlatformControlAuthority, makePlatformControlReadiness, requireManagerAuthorityHolder } from "./platform-control.js";
67
67
  import { startAuthCallout } from "./callout.js";
68
68
  import { createIdpBridge, verifyIdpToken } from "./idp.js";
69
- import { PUBLIC_EXCHANGE_VIEWS } from "./token.js";
69
+ import { PUBLIC_EXCHANGE_VIEWS, assertTransferWriterClaim } from "./token.js";
70
70
  import { grantCoordinates, verifySessionRedemption } from "./session-redemption.js";
71
71
  import { pinnedJwksResolver } from "./issuer.js";
72
72
  import { calloutPermissions } from "./permissions.js";
@@ -93,7 +93,7 @@ import { makeDrainRepairers } from "./drain-repair.js";
93
93
  import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
94
94
  import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
95
95
  import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
96
- import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, findActorUnified, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
96
+ import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, grantManagedActor, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorAt, } from "./ledger.js";
97
97
  import { AUTH_PROVIDER_NAME, clearAuthServiceInfo, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
98
98
  /** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
99
99
  * retire floor (max-age + max bearer TTL) from it. */
@@ -123,6 +123,19 @@ const PUBLIC_MAX_IN_FLIGHT = 64; // global concurrent-admission cap on the publi
123
123
  const PUBLIC_DEADLINE_MS = 10_000; // hard wall-clock deadline per public request
124
124
  const HOST_FENCE_POLL_MS = 1_000; // how often awaitHostFence re-reads the host gate
125
125
  const OBSERVED_RUN_REQUEST_WINDOW_MS = 5 * 60_000; // how long an observed served resume or answer can still be forwarded
126
+ /** Enrollment and retirement preparation of one managed agent run one at a time, so neither acts on
127
+ * a grant the other is still writing or releasing. The chain lives at module level, keyed by the
128
+ * ledger directory, so two plane instances over one ledger share it. */
129
+ const MANAGED_ALIAS_CHAINS = new Map();
130
+ const serializedForAlias = (dir, owner, actor, fn) => {
131
+ const key = `${resolve(dir)}:${principalKey(owner, actor).key}`;
132
+ const run = (MANAGED_ALIAS_CHAINS.get(key) ?? Promise.resolve()).then(fn, fn);
133
+ const tail = run.then(() => undefined, () => undefined);
134
+ MANAGED_ALIAS_CHAINS.set(key, tail);
135
+ void tail.then(() => { if (MANAGED_ALIAS_CHAINS.get(key) === tail)
136
+ MANAGED_ALIAS_CHAINS.delete(key); });
137
+ return run;
138
+ };
126
139
  /** Package-internal policy step for the hosted retirement requester. The HTTP production door calls
127
140
  * this after deriving the owner and manager actors; broker-free smokes exercise the same decision. */
128
141
  export function authorizeRemoteManagerRetirement(args) {
@@ -152,9 +165,10 @@ function contractArtifactReader(values) {
152
165
  * registration that advertises them. The issuance gate is provisioned open once, on first sight
153
166
  * (§13.1); on a restart it already exists and `registerServiceInstance` freezes and re-registers
154
167
  * it, which advances the process epoch and fences the predecessor. A first registration stays at
155
- * epoch 0. It returns the epoch this registration committed, and refuses when its confirming read
168
+ * epoch 0. `register` is {@link registerServiceInstance}, or {@link registerServingInstance} for an
169
+ * instance that serves. It returns what `register` committed, and refuses when its confirming read
156
170
  * finds that a later registration of the same instance superseded it. */
157
- async function registerSelfAuthorizedInstance(nc, args) {
171
+ async function registerSelfAuthorizedInstance(nc, args, register) {
158
172
  const { space, endpoint, instanceId } = args;
159
173
  const authKv = await new Kvm(nc).open(epAuthBucket(space));
160
174
  const recordsKv = await new Kvm(nc).open(recordsBucket(space));
@@ -165,10 +179,11 @@ async function registerSelfAuthorizedInstance(nc, args) {
165
179
  await provisionEndpointGateOpen(authKv, { endpoint, instanceId, principal: args.principal });
166
180
  const barrier = endpointRegistrationBarrier(authKv, space, { endpoint, instanceId, opId: mintLifecycleUid() });
167
181
  const spec = { endpoint, owner: DEV_OWNER, clusterDigests: [args.clusterDigest], protocol: { v: 1 } };
168
- const { registrationRevision, processEpoch } = await registerServiceInstance(recordsKv, {
182
+ const registered = await register(recordsKv, {
169
183
  space, spec, instanceId, registrant: { owner: DEV_OWNER }, authority: selfNameAuthority(endpoint), barrier,
170
184
  readClusterArtifact: contractArtifactReader(args.artifacts),
171
185
  });
186
+ const { registrationRevision, processEpoch } = registered;
172
187
  // A second start of the same instance can complete between this reopen and this read. Its epoch
173
188
  // is not this start's to claim, and this start's epoch is already fenced. One that completes after
174
189
  // the read leaves this start returning its own epoch, which the gate fences as it fences any
@@ -176,7 +191,7 @@ async function registerSelfAuthorizedInstance(nc, args) {
176
191
  const observed = await serveIssuanceGateKv(authKv, space, { endpoint, instanceId }).observe();
177
192
  if (observed?.state !== "open" || observed.processEpoch !== processEpoch || observed.registrationRevision !== registrationRevision)
178
193
  throw new EpEnvelopeError("conflict", `the issuance gate of ${endpoint}/${instanceId} is no longer open at this registration (process epoch ${processEpoch}, revision ${registrationRevision}); a later barrier fenced this start (SPEC 13.1)`);
179
- return { authKv, recordsKv, registrationRevision, processEpoch };
194
+ return registered;
180
195
  }
181
196
  /**
182
197
  * Open the authority plane — the PRODUCTION connect/exchange composition (exported so the live
@@ -229,26 +244,11 @@ export async function openAuthAuthorityPlane(opts) {
229
244
  log,
230
245
  });
231
246
  try {
232
- const { authKv, recordsKv, registrationRevision, processEpoch } = await registerSelfAuthorizedInstance(regClient.nc, {
247
+ const { grant } = await registerSelfAuthorizedInstance(regClient.nc, {
233
248
  space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid,
234
249
  principal: principalKey(DEV_OWNER, authIdentity.serveIdentity.id).key,
235
250
  clusterDigest: artifacts.closureDigest, artifacts: values,
236
- });
237
- const readProcessEpoch = async () => {
238
- const g = await serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid }).observe();
239
- if (g === null)
240
- throw new Error(`no issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid}`);
241
- return g.processEpoch;
242
- };
243
- const grant = await authorizeServeGrant(recordsKv, {
244
- space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: processEpoch, holder: { owner: DEV_OWNER },
245
- authority: selfNameAuthority(AUTH_SERVICE_ENDPOINT), readClusterArtifact: contractArtifactReader(values), readProcessEpoch,
246
- });
247
- await writeServiceStatus(recordsKv, {
248
- endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: processEpoch,
249
- status: { state: SERVICE_READY, epoch: processEpoch, observedSpecRevision: registrationRevision },
250
- readProcessEpoch,
251
- });
251
+ }, registerServingInstance);
252
252
  authServeInstanceId = iid;
253
253
  authServeGrant = grant;
254
254
  }
@@ -590,6 +590,14 @@ export async function openAuthAuthorityPlane(opts) {
590
590
  observedRunRequests.delete(subject);
591
591
  return observed !== undefined && observed.expires > Date.now() ? observed.request : undefined;
592
592
  };
593
+ const observeManagerGate = async (instanceId) => serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe();
594
+ // A managed lifecycle's broker footprint, released under a deprovisioner pinned to that uid, so
595
+ // no call here can reach a same-name successor's durables.
596
+ const deprovisionManagedFootprint = async (owner, actor, lifecycleUid, memberChannels) => {
597
+ const targetId = principalKey(owner, actor).key;
598
+ const creds = await mintCreds(issuerAuth(), newIdentity(), "deprovisioner", { deprovisionTarget: { principal: targetId, lifecycleUid, memberChannels } });
599
+ await deprovisionAgent({ servers: server, space, targetId, lifecycleUid, memberChannels, creds });
600
+ };
593
601
  return {
594
602
  authorizeConnect: async (t) => {
595
603
  refuseIfFenced();
@@ -798,6 +806,13 @@ export async function openAuthAuthorityPlane(opts) {
798
806
  });
799
807
  return { credentials };
800
808
  }
809
+ if (r.operation === "transferReader") {
810
+ const expectedProof = remoteManagerRegistrationProof(owner, r);
811
+ if (r.registrationProof !== expectedProof)
812
+ throw new EpEnvelopeError("permission-denied", "manager-service transfer reader proof does not match this owner/lifecycle");
813
+ credentials.transferReader = await mintPublicUserJwt({ space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } }, r.transferReader.id, "transfer-reader", { principal: { owner, actor: actors.serve }, lifecycleUid: r.managerLifecycleUid, transferReader: { instanceId: r.instanceId } });
814
+ return { credentials };
815
+ }
801
816
  if (r.operation === "retire") {
802
817
  const expectedProof = remoteManagerRegistrationProof(owner, r);
803
818
  if (r.registrationProof !== expectedProof)
@@ -881,7 +896,8 @@ export async function openAuthAuthorityPlane(opts) {
881
896
  credentials.runMediator = await mintPublicUserJwt(auth, r.run.mediatorId, "run-mediator", {
882
897
  principal: { owner, actor: caller.actor },
883
898
  lifecycleUid: r.managerLifecycleUid,
884
- runMediator: binding,
899
+ // Renewed with the placement the attempt was issued with (authorizeRemoteRunAttempt).
900
+ runMediator: { ...binding, placement: { instanceId: r.instanceId } },
885
901
  expiresInSeconds: standingTtl,
886
902
  });
887
903
  return { credentials };
@@ -1017,6 +1033,121 @@ export async function openAuthAuthorityPlane(opts) {
1017
1033
  },
1018
1034
  });
1019
1035
  },
1036
+ enrollManagedAgent: async ({ owner, scope, request, sentinelCreds, agentBearerExchangeUrl }) => {
1037
+ refuseIfFenced();
1038
+ const r = await authorizeRemoteManagedAgentEnrollment({ owner, scope, proofSecret: dataAccount.signingSeed, space, request, observeManagerGate });
1039
+ const t = r.target;
1040
+ const subscribe = t.subscribe ?? [];
1041
+ const { lifecycleUid, row } = await serializedForAlias(opts.dir, owner, t.actor, async () => {
1042
+ // The token digest names one enrollment. A held row with this request's digest is that
1043
+ // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1044
+ // still runs, and taking its grant would strand it.
1045
+ const held = findManagedActor(opts.dir, owner, t.actor);
1046
+ if (held !== undefined && held.tokenHash !== t.tokenHash)
1047
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1048
+ // A fresh uid is never one a retirement tombstoned, and only the host can make that promise.
1049
+ const lifecycleUid = held?.lifecycleUid ?? mintLifecycleUid();
1050
+ // The grant first: its envelope walk against the supervising actor refuses an over-wide
1051
+ // request before any durable exists. A retry walks the held grant again: its spawner chain
1052
+ // may have narrowed since the first answer, and provisioning would restore what it took away.
1053
+ if (held !== undefined)
1054
+ assertWithinSpawnerGrant(opts.dir, held, "spawn");
1055
+ const row = held ?? grantManagedActor(opts.dir, {
1056
+ owner,
1057
+ actor: t.actor,
1058
+ scope: t.capabilities ?? [],
1059
+ allowSubscribe: t.allowSubscribe ?? [],
1060
+ allowPublish: t.allowPublish ?? [],
1061
+ ...(t.role !== undefined ? { role: t.role } : {}),
1062
+ ...(t.label !== undefined ? { label: t.label } : {}),
1063
+ parent: principalKey(owner, r.actor).key,
1064
+ lifecycleUid,
1065
+ tokenHash: t.tokenHash,
1066
+ });
1067
+ try {
1068
+ const identity = newIdentity();
1069
+ const provisioner = new CotalEndpoint({
1070
+ space,
1071
+ servers: server,
1072
+ channels: [],
1073
+ creds: await mintCreds(issuerAuth(), identity, "provisioner"),
1074
+ card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
1075
+ registerPresence: false,
1076
+ watchPresence: false,
1077
+ watchChannels: false,
1078
+ consume: false,
1079
+ });
1080
+ await provisioner.start();
1081
+ try {
1082
+ await provisionAgentDurables(provisioner, { owner, actor: t.actor, lifecycleUid }, { subscribe, allowSubscribe: row.allowSubscribe, role: row.role });
1083
+ }
1084
+ finally {
1085
+ await provisioner.stop();
1086
+ }
1087
+ }
1088
+ catch (e) {
1089
+ // A retried enrollment's first answer may already have reached the participant, so its
1090
+ // grant stays. A grant this call wrote gave the participant no uid, so it can never be
1091
+ // retired: it and any durable it got must go now.
1092
+ if (held === undefined) {
1093
+ revokeManagedActorAt(opts.dir, owner, t.actor, lifecycleUid);
1094
+ await deprovisionManagedFootprint(owner, t.actor, lifecycleUid, []).catch((err) => opts.log(`auth-service: refused enrollment of ${owner}/${t.actor} left durables at ${lifecycleUid}: ${err instanceof Error ? err.message : String(err)}`));
1095
+ }
1096
+ throw e;
1097
+ }
1098
+ return { lifecycleUid, row };
1099
+ });
1100
+ return {
1101
+ v: 1,
1102
+ kind: "manager-managed-agent-enrollment",
1103
+ space,
1104
+ owner,
1105
+ actor: r.actor,
1106
+ instanceId: r.instanceId,
1107
+ managerLifecycleUid: r.managerLifecycleUid,
1108
+ requestId: r.requestId,
1109
+ registrationProof: r.registrationProof,
1110
+ serveEpoch: r.serveEpoch,
1111
+ material: {
1112
+ owner,
1113
+ actor: t.actor,
1114
+ lifecycleUid,
1115
+ sentinelCreds,
1116
+ subscribe,
1117
+ allowSubscribe: row.allowSubscribe,
1118
+ allowPublish: row.allowPublish,
1119
+ agentBearerExchangeUrl,
1120
+ },
1121
+ };
1122
+ },
1123
+ prepareManagedAgentRetirement: async ({ owner, scope, request }) => {
1124
+ refuseIfFenced();
1125
+ const r = await authorizeRemoteManagedAgentPrepareRetirement({ owner, scope, proofSecret: dataAccount.signingSeed, space, request, observeManagerGate });
1126
+ const { actor, lifecycleUid } = r.target;
1127
+ await serializedForAlias(opts.dir, owner, actor, async () => {
1128
+ // Only a grant still at the target uid is this lifecycle's. A row at another uid belongs to a
1129
+ // successor, and an absent row means an earlier attempt already released both halves.
1130
+ const row = findManagedActor(opts.dir, owner, actor);
1131
+ if (row?.lifecycleUid === lifecycleUid) {
1132
+ // Footprint before grant: while the row stands, a retry still knows which channels to purge.
1133
+ await deprovisionManagedFootprint(owner, actor, lifecycleUid, row.allowSubscribe.filter(isConcreteChannel));
1134
+ revokeManagedActorAt(opts.dir, owner, actor, lifecycleUid);
1135
+ }
1136
+ });
1137
+ return {
1138
+ v: 1,
1139
+ kind: "manager-managed-agent-prepare-retirement",
1140
+ space,
1141
+ owner,
1142
+ actor: r.actor,
1143
+ instanceId: r.instanceId,
1144
+ managerLifecycleUid: r.managerLifecycleUid,
1145
+ requestId: r.requestId,
1146
+ target: r.target,
1147
+ opId: r.opId,
1148
+ prepared: true,
1149
+ };
1150
+ },
1020
1151
  verifyManagedAgentRuntime: async ({ owner, request }) => {
1021
1152
  refuseIfFenced();
1022
1153
  const args = {
@@ -1217,7 +1348,7 @@ export async function openAuthAuthorityPlane(opts) {
1217
1348
  // The host is minted no serve credential, so the gate binds a principal nothing holds.
1218
1349
  principal: principalKey(DEV_OWNER, `host_serve_${instanceId}`).key,
1219
1350
  clusterDigest: host.clusterDigest, artifacts: host.artifacts,
1220
- })).processEpoch;
1351
+ }, registerServiceInstance)).processEpoch;
1221
1352
  }
1222
1353
  finally {
1223
1354
  await regClient.close();
@@ -1273,7 +1404,7 @@ async function resumeOpenOperations(reg, evictPrincipal, retirement, log) {
1273
1404
  // Owed by the gate: a freeze by THIS op is the barrier's live claim (the bar of every
1274
1405
  // barrier). A takeover completed under this predicate reopens the gate, so its completed
1275
1406
  // intent stays skipped exactly as before.
1276
- let owed = gate !== undefined && gate.row.state === "frozen" && gate.row.op?.opId === it.opId;
1407
+ let owed = gate !== undefined && gate.row.state === "frozen" && gate.row.op.opId === it.opId;
1277
1408
  // #878: a retirement whose gate terminal landed by THIS op but whose head terminal did not.
1278
1409
  // The head decides the completed cell: `retired` at this uid means the barrier's last step ran
1279
1410
  // (skip), and a head at ANOTHER uid means a successor already replaced the retired predecessor
@@ -1281,7 +1412,7 @@ async function resumeOpenOperations(reg, evictPrincipal, retirement, log) {
1281
1412
  // the wedged crash window, and `active` at this uid is a backward head (impossible; a head
1282
1413
  // never un-retires) — both resume, so the barrier either finishes the tail or refuses loud; an
1283
1414
  // absent head is corruption the barrier refuses loud (a head is never deleted, SPEC 13.12).
1284
- if (!owed && it.kind === "retirement" && gate !== undefined && gate.row.state === "retired" && gate.row.op?.opId === it.opId) {
1415
+ if (!owed && it.kind === "retirement" && gate !== undefined && gate.row.state === "retired" && gate.row.op.opId === it.opId) {
1285
1416
  const head = await readLifecycleHeadForOperation(reg, it.owner, it.actor);
1286
1417
  owed = head === undefined
1287
1418
  || (head.mapping.lifecycleUid === it.lifecycleUid && head.mapping.state !== "retired");
@@ -1536,6 +1667,9 @@ async function startAuthContext(o) {
1536
1667
  mintConnectCredential: plane.mintConnectCredential,
1537
1668
  });
1538
1669
  const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
1670
+ // Set once the public face binds. An enrolled child's bearer exchanges there without the
1671
+ // loopback capability, so enrollment needs that face.
1672
+ let publicUrl;
1539
1673
  const failures = []; // rolling-window timestamps of REFUSED exchanges
1540
1674
  const badCaps = []; // rolling-window timestamps of invalid-capability attempts
1541
1675
  const ctx = {
@@ -1549,6 +1683,12 @@ async function startAuthContext(o) {
1549
1683
  verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
1550
1684
  verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
1551
1685
  verifyManagedAgentRuntime: plane.verifyManagedAgentRuntime,
1686
+ enrollManagedAgent: (args) => {
1687
+ if (publicUrl === undefined)
1688
+ throw new EpEnvelopeError("failed-precondition", "managed agent enrollment needs the public exchange face, where the enrolled agent's bearer exchanges - start the auth service with --exchange-public-port");
1689
+ return plane.enrollManagedAgent({ ...args, sentinelCreds: callout.sentinelCreds, agentBearerExchangeUrl: publicUrl });
1690
+ },
1691
+ prepareManagedAgentRetirement: plane.prepareManagedAgentRetirement,
1552
1692
  scanManagerGoalIndex: plane.scanManagerGoalIndex,
1553
1693
  authorizeManagerAdmin: plane.authorizeManagerAdmin,
1554
1694
  admitManagerRun: plane.admitManagerRun,
@@ -1576,7 +1716,6 @@ async function startAuthContext(o) {
1576
1716
  const url = `http://127.0.0.1:${boundPort}`;
1577
1717
  // The optional PUBLIC face: its own server, its own closed route table, its own budgets — also
1578
1718
  // loopback-bound (the operator's reverse proxy terminates TLS and forwards here).
1579
- let publicUrl;
1580
1719
  if (o.publicFace !== undefined) {
1581
1720
  const face = o.publicFace;
1582
1721
  // The discovery bundle is GENERATED from the daemon's own recorded config — the pinned IdP,
@@ -1766,13 +1905,12 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
1766
1905
  if (typeof request.actor !== "string")
1767
1906
  throw new Error("manager-service authority request requires an actor");
1768
1907
  const row = ledgerAuthorizeGrant(ctx.dir)(owner, request.actor);
1769
- // #1972: the managed-agent lifecycle operations mutate host storage (a database row, JetStream
1770
- // durables, a ledger grant), and stock holds none of that composition. A host platform intercepts
1771
- // these kinds on its own public route and calls the loopback verify-enrollment door for the
1772
- // decision. Refusing here keeps them from falling through to `managerServiceAuthority`, which
1773
- // would answer a manager-lifecycle phase for an agent-lifecycle request.
1774
- if (request.kind === "manager-managed-agent-enrollment" || request.kind === "manager-managed-agent-prepare-retirement")
1775
- throw new EpEnvelopeError("unimplemented", "managed agent enrollment and retirement preparation must be handled by host platform interception");
1908
+ // #1972: this service owns the ledger and the space's provisioning authority, so it is the host
1909
+ // that enrolls a remote manager's managed agent and prepares its terminal release.
1910
+ if (request.kind === "manager-managed-agent-enrollment")
1911
+ return ctx.enrollManagedAgent({ owner, scope: row.scope ?? [], request: body.request });
1912
+ if (request.kind === "manager-managed-agent-prepare-retirement")
1913
+ return ctx.prepareManagedAgentRetirement({ owner, scope: row.scope ?? [], request: body.request });
1776
1914
  // The hosted runtime kinds read and drive host-owned intent state that stock does not hold.
1777
1915
  if (request.kind === "manager-managed-agent-runtime-create" || request.kind === "manager-managed-agent-runtime-status")
1778
1916
  throw new EpEnvelopeError("unimplemented", "managed agent runtime create and status must be handled by host platform interception");
@@ -2125,9 +2263,11 @@ async function handleExchange(req, res, ctx, policy) {
2125
2263
  const peer = policy.peerKey(req);
2126
2264
  const peerThrottled = policy.throttled(ctx, peer);
2127
2265
  const body = await readJsonBody(req);
2128
- const { idpToken, actor, actorToken, owner, ttlSec, view, managerInstanceId, sessionGrant } = body;
2266
+ const { idpToken, actor, actorToken, owner, ttlSec, view, managerInstanceId, sessionGrant, transferWriter } = body;
2129
2267
  if ((view === "session-caller") !== (sessionGrant !== undefined))
2130
2268
  return send(res, 400, { error: 'view "session-caller" and sessionGrant come together or not at all' });
2269
+ if ((view === "transfer-writer") !== (transferWriter !== undefined))
2270
+ return send(res, 400, { error: 'view "transfer-writer" and transferWriter come together or not at all' });
2131
2271
  if (ttlSec !== undefined && typeof ttlSec !== "number")
2132
2272
  return send(res, 400, { error: "ttlSec must be a number" });
2133
2273
  if (view !== undefined && typeof view !== "string")
@@ -2210,12 +2350,15 @@ async function handleExchange(req, res, ctx, policy) {
2210
2350
  // principal holds THAT redeemed session. The bridge's `verifySession` hook runs the decision on
2211
2351
  // the principal it derived, before signing; the callout re-runs it at the mint.
2212
2352
  const session = view === "session-caller" ? grantCoordinates(sessionGrant) : undefined;
2353
+ if (transferWriter !== undefined)
2354
+ assertTransferWriterClaim(transferWriter);
2213
2355
  const r = await ctx.bridge.exchange(idpToken, {
2214
2356
  actor,
2215
2357
  ttlSec,
2216
2358
  view: view,
2217
2359
  managerInstanceId: selectedManagerInstanceId,
2218
2360
  ...(session ? { verifySession: (p) => ctx.verifySession(p, session) } : {}),
2361
+ ...(transferWriter !== undefined ? { transferWriter } : {}),
2219
2362
  });
2220
2363
  return send(res, 200, r);
2221
2364
  }