@cotal-ai/auth 0.64.0 → 0.66.1

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/service.js CHANGED
@@ -59,7 +59,7 @@ 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 { contractRefToHex, contractStoreContext, 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";
@@ -78,7 +78,7 @@ import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
78
78
  import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
79
79
  import { validateRetainedManagedAgent } from "./continuity.js";
80
80
  import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
81
- import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, authRegistrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
81
+ import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
82
82
  import { authorizeConnectCredential } from "./connect-reader.js";
83
83
  import { ensureRootCredential } from "./root-credential.js";
84
84
  import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.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. */
@@ -121,7 +121,21 @@ const PUBLIC_FAILED_PER_MIN = 30; // per-PEER refused-exchange window (rolling m
121
121
  const PUBLIC_PEER_BUCKETS_MAX = 1024; // bounded LRU of per-peer failure buckets
122
122
  const PUBLIC_MAX_IN_FLIGHT = 64; // global concurrent-admission cap on the public listener
123
123
  const PUBLIC_DEADLINE_MS = 10_000; // hard wall-clock deadline per public request
124
+ const HOST_FENCE_POLL_MS = 1_000; // how often awaitHostFence re-reads the host gate
124
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
+ };
125
139
  /** Package-internal policy step for the hosted retirement requester. The HTTP production door calls
126
140
  * this after deriving the owner and manager actors; broker-free smokes exercise the same decision. */
127
141
  export function authorizeRemoteManagerRetirement(args) {
@@ -135,6 +149,50 @@ export function authorizeRemoteManagerRetirement(args) {
135
149
  if (args.gate.processEpoch !== args.serveEpoch)
136
150
  throw new EpEnvelopeError("conflict", `manager-service retirement serve epoch ${args.serveEpoch} is stale; current is ${args.gate.processEpoch}`);
137
151
  }
152
+ /** The §13.9 name authority in static mode: the plane holds the space signing seed, so it
153
+ * self-authorizes exactly one endpoint name for exactly DEV_OWNER, as the manager does for
154
+ * "manager". */
155
+ function selfNameAuthority(endpoint) {
156
+ return { authorize: (name, owner) => ({ authorized: name === endpoint && owner === DEV_OWNER, revision: 0 }) };
157
+ }
158
+ /** Each contract artifact by its §13.7 content digest. */
159
+ function contractArtifactReader(values) {
160
+ const store = new Map(values.map((v) => [contractDigest(v), v]));
161
+ return (digest) => store.get(digest);
162
+ }
163
+ /** The §13.7 registration ceremony for one instance of an endpoint this plane self-authorizes, on
164
+ * a connection scoped to that instance. The contract artifacts are published before the
165
+ * registration that advertises them. The issuance gate is provisioned open once, on first sight
166
+ * (§13.1); on a restart it already exists and `registerServiceInstance` freezes and re-registers
167
+ * it, which advances the process epoch and fences the predecessor. A first registration stays at
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
170
+ * finds that a later registration of the same instance superseded it. */
171
+ async function registerSelfAuthorizedInstance(nc, args, register) {
172
+ const { space, endpoint, instanceId } = args;
173
+ const authKv = await new Kvm(nc).open(epAuthBucket(space));
174
+ const recordsKv = await new Kvm(nc).open(recordsBucket(space));
175
+ const storeCtx = await contractStoreContext(nc, space);
176
+ for (const value of args.artifacts)
177
+ await publishContractArtifact(storeCtx, contractArtifactCanonicalBytes(value));
178
+ if ((await serveIssuanceGateKv(authKv, space, { endpoint, instanceId }).observe()) === null)
179
+ await provisionEndpointGateOpen(authKv, { endpoint, instanceId, principal: args.principal });
180
+ const barrier = endpointRegistrationBarrier(authKv, space, { endpoint, instanceId, opId: mintLifecycleUid() });
181
+ const spec = { endpoint, owner: DEV_OWNER, clusterDigests: [args.clusterDigest], protocol: { v: 1 } };
182
+ const registered = await register(recordsKv, {
183
+ space, spec, instanceId, registrant: { owner: DEV_OWNER }, authority: selfNameAuthority(endpoint), barrier,
184
+ readClusterArtifact: contractArtifactReader(args.artifacts),
185
+ });
186
+ const { registrationRevision, processEpoch } = registered;
187
+ // A second start of the same instance can complete between this reopen and this read. Its epoch
188
+ // is not this start's to claim, and this start's epoch is already fenced. One that completes after
189
+ // the read leaves this start returning its own epoch, which the gate fences as it fences any
190
+ // restart's predecessor; no read can close that window.
191
+ const observed = await serveIssuanceGateKv(authKv, space, { endpoint, instanceId }).observe();
192
+ if (observed?.state !== "open" || observed.processEpoch !== processEpoch || observed.registrationRevision !== registrationRevision)
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)`);
194
+ return registered;
195
+ }
138
196
  /**
139
197
  * Open the authority plane — the PRODUCTION connect/exchange composition (exported so the live
140
198
  * deny-new smoke exercises exactly what the daemon runs). Boot order is the readiness contract:
@@ -179,60 +237,18 @@ export async function openAuthAuthorityPlane(opts) {
179
237
  });
180
238
  const iid = authIdentity.instanceId;
181
239
  const artifacts = authClusterArtifacts();
182
- const store = new Map([
183
- [artifacts.rootDigest, artifacts.document],
184
- [artifacts.closureDigest, artifacts.manifest],
185
- ]);
186
- const readClusterArtifact = (digest) => store.get(digest);
187
- // §13.9 name authority, static mode: the auth plane holds the space signing seed, so it
188
- // self-authorizes exactly its own name ("auth") for exactly DEV_OWNER — mirrors the manager's
189
- // own self-authorization for "manager".
190
- const authority = {
191
- authorize: (name, owner) => ({ authorized: name === AUTH_SERVICE_ENDPOINT && owner === DEV_OWNER, revision: 0 }),
192
- };
193
- const servePrincipal = principalKey(DEV_OWNER, authIdentity.serveIdentity.id).key;
194
- const registrationOpId = mintLifecycleUid();
240
+ const values = [...authContractArtifactValues(), artifacts.document, artifacts.manifest];
195
241
  const regClient = await openAuthorityClient({
196
242
  server, space, dataAccount, label: `cotal:auth-registration:${space}`,
197
- grants: (id) => authRegistrationExecutorGrants(space, id, iid),
243
+ grants: (id) => registrationExecutorGrants(space, id, AUTH_SERVICE_ENDPOINT, iid),
198
244
  log,
199
245
  });
200
246
  try {
201
- const authKv = await new Kvm(regClient.nc).open(epAuthBucket(space));
202
- const recordsKv = await new Kvm(regClient.nc).open(recordsBucket(space));
203
- // §13.7 contract-artifact publication BEFORE the registration that advertises the digests.
204
- const storeCtx = await contractStoreContext(regClient.nc, space);
205
- for (const value of [...authContractArtifactValues(), artifacts.document, artifacts.manifest])
206
- await publishContractArtifact(storeCtx, contractArtifactCanonicalBytes(value));
207
- // §13.1 pre-registration: the issuance gate, born open@gen0, provisioned ONCE on the FIRST
208
- // registration. On a restart the gate already exists; `registerServiceInstance` below
209
- // freezes + re-registers it (advancing the epoch).
210
- if ((await serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid }).observe()) === null)
211
- await provisionEndpointGateOpen(authKv, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, principal: servePrincipal });
212
- const barrier = endpointRegistrationBarrier(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, opId: registrationOpId });
213
- const spec = { endpoint: AUTH_SERVICE_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
214
- const { registrationRevision } = await registerServiceInstance(recordsKv, {
215
- space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
216
- });
217
- const fence = serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid });
218
- const observed = await fence.observe();
219
- if (observed === null)
220
- throw new Error(`the issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid} vanished after registration`);
221
- const readProcessEpoch = async () => {
222
- const g = await fence.observe();
223
- if (g === null)
224
- throw new Error(`no issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid}`);
225
- return g.processEpoch;
226
- };
227
- const grant = await authorizeServeGrant(recordsKv, {
228
- space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
229
- holder: { owner: DEV_OWNER }, authority, readClusterArtifact, readProcessEpoch,
230
- });
231
- await writeServiceStatus(recordsKv, {
232
- endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
233
- status: { state: SERVICE_READY, epoch: observed.processEpoch, observedSpecRevision: registrationRevision },
234
- readProcessEpoch,
235
- });
247
+ const { grant } = await registerSelfAuthorizedInstance(regClient.nc, {
248
+ space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid,
249
+ principal: principalKey(DEV_OWNER, authIdentity.serveIdentity.id).key,
250
+ clusterDigest: artifacts.closureDigest, artifacts: values,
251
+ }, registerServingInstance);
236
252
  authServeInstanceId = iid;
237
253
  authServeGrant = grant;
238
254
  }
@@ -574,6 +590,14 @@ export async function openAuthAuthorityPlane(opts) {
574
590
  observedRunRequests.delete(subject);
575
591
  return observed !== undefined && observed.expires > Date.now() ? observed.request : undefined;
576
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
+ };
577
601
  return {
578
602
  authorizeConnect: async (t) => {
579
603
  refuseIfFenced();
@@ -1001,6 +1025,121 @@ export async function openAuthAuthorityPlane(opts) {
1001
1025
  },
1002
1026
  });
1003
1027
  },
1028
+ enrollManagedAgent: async ({ owner, scope, request, sentinelCreds, agentBearerExchangeUrl }) => {
1029
+ refuseIfFenced();
1030
+ const r = await authorizeRemoteManagedAgentEnrollment({ owner, scope, proofSecret: dataAccount.signingSeed, space, request, observeManagerGate });
1031
+ const t = r.target;
1032
+ const subscribe = t.subscribe ?? [];
1033
+ const { lifecycleUid, row } = await serializedForAlias(opts.dir, owner, t.actor, async () => {
1034
+ // The token digest names one enrollment. A held row with this request's digest is that
1035
+ // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1036
+ // still runs, and taking its grant would strand it.
1037
+ const held = findManagedActor(opts.dir, owner, t.actor);
1038
+ if (held !== undefined && held.tokenHash !== t.tokenHash)
1039
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1040
+ // A fresh uid is never one a retirement tombstoned, and only the host can make that promise.
1041
+ const lifecycleUid = held?.lifecycleUid ?? mintLifecycleUid();
1042
+ // The grant first: its envelope walk against the supervising actor refuses an over-wide
1043
+ // request before any durable exists. A retry walks the held grant again: its spawner chain
1044
+ // may have narrowed since the first answer, and provisioning would restore what it took away.
1045
+ if (held !== undefined)
1046
+ assertWithinSpawnerGrant(opts.dir, held, "spawn");
1047
+ const row = held ?? grantManagedActor(opts.dir, {
1048
+ owner,
1049
+ actor: t.actor,
1050
+ scope: t.capabilities ?? [],
1051
+ allowSubscribe: t.allowSubscribe ?? [],
1052
+ allowPublish: t.allowPublish ?? [],
1053
+ ...(t.role !== undefined ? { role: t.role } : {}),
1054
+ ...(t.label !== undefined ? { label: t.label } : {}),
1055
+ parent: principalKey(owner, r.actor).key,
1056
+ lifecycleUid,
1057
+ tokenHash: t.tokenHash,
1058
+ });
1059
+ try {
1060
+ const identity = newIdentity();
1061
+ const provisioner = new CotalEndpoint({
1062
+ space,
1063
+ servers: server,
1064
+ channels: [],
1065
+ creds: await mintCreds(issuerAuth(), identity, "provisioner"),
1066
+ card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
1067
+ registerPresence: false,
1068
+ watchPresence: false,
1069
+ watchChannels: false,
1070
+ consume: false,
1071
+ });
1072
+ await provisioner.start();
1073
+ try {
1074
+ await provisionAgentDurables(provisioner, { owner, actor: t.actor, lifecycleUid }, { subscribe, allowSubscribe: row.allowSubscribe, role: row.role });
1075
+ }
1076
+ finally {
1077
+ await provisioner.stop();
1078
+ }
1079
+ }
1080
+ catch (e) {
1081
+ // A retried enrollment's first answer may already have reached the participant, so its
1082
+ // grant stays. A grant this call wrote gave the participant no uid, so it can never be
1083
+ // retired: it and any durable it got must go now.
1084
+ if (held === undefined) {
1085
+ revokeManagedActorAt(opts.dir, owner, t.actor, lifecycleUid);
1086
+ 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)}`));
1087
+ }
1088
+ throw e;
1089
+ }
1090
+ return { lifecycleUid, row };
1091
+ });
1092
+ return {
1093
+ v: 1,
1094
+ kind: "manager-managed-agent-enrollment",
1095
+ space,
1096
+ owner,
1097
+ actor: r.actor,
1098
+ instanceId: r.instanceId,
1099
+ managerLifecycleUid: r.managerLifecycleUid,
1100
+ requestId: r.requestId,
1101
+ registrationProof: r.registrationProof,
1102
+ serveEpoch: r.serveEpoch,
1103
+ material: {
1104
+ owner,
1105
+ actor: t.actor,
1106
+ lifecycleUid,
1107
+ sentinelCreds,
1108
+ subscribe,
1109
+ allowSubscribe: row.allowSubscribe,
1110
+ allowPublish: row.allowPublish,
1111
+ agentBearerExchangeUrl,
1112
+ },
1113
+ };
1114
+ },
1115
+ prepareManagedAgentRetirement: async ({ owner, scope, request }) => {
1116
+ refuseIfFenced();
1117
+ const r = await authorizeRemoteManagedAgentPrepareRetirement({ owner, scope, proofSecret: dataAccount.signingSeed, space, request, observeManagerGate });
1118
+ const { actor, lifecycleUid } = r.target;
1119
+ await serializedForAlias(opts.dir, owner, actor, async () => {
1120
+ // Only a grant still at the target uid is this lifecycle's. A row at another uid belongs to a
1121
+ // successor, and an absent row means an earlier attempt already released both halves.
1122
+ const row = findManagedActor(opts.dir, owner, actor);
1123
+ if (row?.lifecycleUid === lifecycleUid) {
1124
+ // Footprint before grant: while the row stands, a retry still knows which channels to purge.
1125
+ await deprovisionManagedFootprint(owner, actor, lifecycleUid, row.allowSubscribe.filter(isConcreteChannel));
1126
+ revokeManagedActorAt(opts.dir, owner, actor, lifecycleUid);
1127
+ }
1128
+ });
1129
+ return {
1130
+ v: 1,
1131
+ kind: "manager-managed-agent-prepare-retirement",
1132
+ space,
1133
+ owner,
1134
+ actor: r.actor,
1135
+ instanceId: r.instanceId,
1136
+ managerLifecycleUid: r.managerLifecycleUid,
1137
+ requestId: r.requestId,
1138
+ target: r.target,
1139
+ opId: r.opId,
1140
+ prepared: true,
1141
+ };
1142
+ },
1004
1143
  verifyManagedAgentRuntime: async ({ owner, request }) => {
1005
1144
  refuseIfFenced();
1006
1145
  const args = {
@@ -1188,6 +1327,29 @@ export async function openAuthAuthorityPlane(opts) {
1188
1327
  const rec = await readSvcRecordLeader(recordsJsm, space, recordSpecKey(RECORD_KINDS.svc, ["manager", instanceId]));
1189
1328
  return { registered: rec !== undefined && !("deleted" in rec), gate: gate ?? null };
1190
1329
  },
1330
+ registerHostInstance: async (host, instanceId) => {
1331
+ refuseIfFenced();
1332
+ const regClient = await openAuthorityClient({
1333
+ server, space, dataAccount, label: `cotal:host-registration:${space}`,
1334
+ grants: (id) => registrationExecutorGrants(space, id, host.endpoint, instanceId),
1335
+ log,
1336
+ });
1337
+ try {
1338
+ return (await registerSelfAuthorizedInstance(regClient.nc, {
1339
+ space, endpoint: host.endpoint, instanceId,
1340
+ // The host is minted no serve credential, so the gate binds a principal nothing holds.
1341
+ principal: principalKey(DEV_OWNER, `host_serve_${instanceId}`).key,
1342
+ clusterDigest: host.clusterDigest, artifacts: host.artifacts,
1343
+ }, registerServiceInstance)).processEpoch;
1344
+ }
1345
+ finally {
1346
+ await regClient.close();
1347
+ }
1348
+ },
1349
+ observeEndpointGate: async (endpoint, instanceId) => {
1350
+ refuseIfFenced();
1351
+ return await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint, instanceId }).observe();
1352
+ },
1191
1353
  fenced,
1192
1354
  close: async () => {
1193
1355
  // Clean-close order (SPEC 13.13): the rail stops answering first, then scan-capable
@@ -1393,6 +1555,12 @@ export async function startAuthService(inputs) {
1393
1555
  throw new Error(`auth-service: port must be a port number, got ${port}`);
1394
1556
  if (inputs.platformControl !== undefined && typeof inputs.platformControl?.observeAssignment !== "function")
1395
1557
  throw new Error("auth-service: platformControl requires an observeAssignment function");
1558
+ const host = inputs.platformControl?.host;
1559
+ if (host !== undefined) {
1560
+ endpointToken(host.endpoint);
1561
+ if (!host.endpoint.includes("."))
1562
+ throw new Error(`auth-service: platformControl.host.endpoint "${host.endpoint}" must be a reverse-DNS name; single-label names are reserved (SPEC 13.2)`);
1563
+ }
1396
1564
  const badFace = inputs.publicFace && checkPublicFace(inputs.publicFace);
1397
1565
  if (badFace)
1398
1566
  throw new Error(badFace);
@@ -1491,6 +1659,9 @@ async function startAuthContext(o) {
1491
1659
  mintConnectCredential: plane.mintConnectCredential,
1492
1660
  });
1493
1661
  const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
1662
+ // Set once the public face binds. An enrolled child's bearer exchanges there without the
1663
+ // loopback capability, so enrollment needs that face.
1664
+ let publicUrl;
1494
1665
  const failures = []; // rolling-window timestamps of REFUSED exchanges
1495
1666
  const badCaps = []; // rolling-window timestamps of invalid-capability attempts
1496
1667
  const ctx = {
@@ -1504,6 +1675,12 @@ async function startAuthContext(o) {
1504
1675
  verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
1505
1676
  verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
1506
1677
  verifyManagedAgentRuntime: plane.verifyManagedAgentRuntime,
1678
+ enrollManagedAgent: (args) => {
1679
+ if (publicUrl === undefined)
1680
+ 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");
1681
+ return plane.enrollManagedAgent({ ...args, sentinelCreds: callout.sentinelCreds, agentBearerExchangeUrl: publicUrl });
1682
+ },
1683
+ prepareManagedAgentRetirement: plane.prepareManagedAgentRetirement,
1507
1684
  scanManagerGoalIndex: plane.scanManagerGoalIndex,
1508
1685
  authorizeManagerAdmin: plane.authorizeManagerAdmin,
1509
1686
  admitManagerRun: plane.admitManagerRun,
@@ -1531,7 +1708,6 @@ async function startAuthContext(o) {
1531
1708
  const url = `http://127.0.0.1:${boundPort}`;
1532
1709
  // The optional PUBLIC face: its own server, its own closed route table, its own budgets — also
1533
1710
  // loopback-bound (the operator's reverse proxy terminates TLS and forwards here).
1534
- let publicUrl;
1535
1711
  if (o.publicFace !== undefined) {
1536
1712
  const face = o.publicFace;
1537
1713
  // The discovery bundle is GENERATED from the daemon's own recorded config — the pinned IdP,
@@ -1593,6 +1769,7 @@ async function startAuthContext(o) {
1593
1769
  });
1594
1770
  const context = o.context ?? { accountPublicKey: keys.dataAccount.pub, lifecycleUid: "" };
1595
1771
  const platformControl = o.platformControl;
1772
+ const host = platformControl?.host;
1596
1773
  // The human route's retained-validation composition, for the platform holder.
1597
1774
  const validateRetainedForHolder = async (holder, request) => {
1598
1775
  const retained = await plane.validateRetainedAgent({ ...holder, request });
@@ -1670,6 +1847,29 @@ async function startAuthContext(o) {
1670
1847
  await plane.activateManagedLifecycle(target);
1671
1848
  },
1672
1849
  } : {}),
1850
+ ...(host !== undefined ? {
1851
+ registerHostIncarnation: async (instanceId) => {
1852
+ refuseUnlessReady();
1853
+ return { instanceId, processEpoch: await plane.registerHostInstance(host, instanceId) };
1854
+ },
1855
+ observeHostGate: async (instanceId) => {
1856
+ refuseUnlessReady();
1857
+ return await plane.observeEndpointGate(host.endpoint, instanceId);
1858
+ },
1859
+ awaitHostFence: async (instanceId, processEpoch) => {
1860
+ if (!Number.isSafeInteger(processEpoch) || processEpoch < 0)
1861
+ throw new EpEnvelopeError("bad-request", `processEpoch must be a non-negative integer, got ${String(processEpoch)}`);
1862
+ // No runtime credential may create a consumer on the auth bucket (SPEC 13.9), so the
1863
+ // gate cannot be watched; the hook polls its leader-served read.
1864
+ for (;;) {
1865
+ refuseUnlessReady();
1866
+ const gate = await plane.observeEndpointGate(host.endpoint, instanceId);
1867
+ if (gate?.state !== "open" || gate.processEpoch !== processEpoch)
1868
+ return gate;
1869
+ await new Promise((r) => setTimeout(r, HOST_FENCE_POLL_MS));
1870
+ }
1871
+ },
1872
+ } : {}),
1673
1873
  readiness() {
1674
1874
  if (state === "unavailable")
1675
1875
  return { state, context, cause: cause ?? "auth-service context is unavailable" };
@@ -1697,13 +1897,12 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
1697
1897
  if (typeof request.actor !== "string")
1698
1898
  throw new Error("manager-service authority request requires an actor");
1699
1899
  const row = ledgerAuthorizeGrant(ctx.dir)(owner, request.actor);
1700
- // #1972: the managed-agent lifecycle operations mutate host storage (a database row, JetStream
1701
- // durables, a ledger grant), and stock holds none of that composition. A host platform intercepts
1702
- // these kinds on its own public route and calls the loopback verify-enrollment door for the
1703
- // decision. Refusing here keeps them from falling through to `managerServiceAuthority`, which
1704
- // would answer a manager-lifecycle phase for an agent-lifecycle request.
1705
- if (request.kind === "manager-managed-agent-enrollment" || request.kind === "manager-managed-agent-prepare-retirement")
1706
- throw new EpEnvelopeError("unimplemented", "managed agent enrollment and retirement preparation must be handled by host platform interception");
1900
+ // #1972: this service owns the ledger and the space's provisioning authority, so it is the host
1901
+ // that enrolls a remote manager's managed agent and prepares its terminal release.
1902
+ if (request.kind === "manager-managed-agent-enrollment")
1903
+ return ctx.enrollManagedAgent({ owner, scope: row.scope ?? [], request: body.request });
1904
+ if (request.kind === "manager-managed-agent-prepare-retirement")
1905
+ return ctx.prepareManagedAgentRetirement({ owner, scope: row.scope ?? [], request: body.request });
1707
1906
  // The hosted runtime kinds read and drive host-owned intent state that stock does not hold.
1708
1907
  if (request.kind === "manager-managed-agent-runtime-create" || request.kind === "manager-managed-agent-runtime-status")
1709
1908
  throw new EpEnvelopeError("unimplemented", "managed agent runtime create and status must be handled by host platform interception");