@ory/argus 0.13.9 → 1.0.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 (133) hide show
  1. package/README.md +31 -45
  2. package/assets/commands/temporal-up.md +1 -1
  3. package/assets/skills/auth-setup/SKILL.md +1 -1
  4. package/assets/skills/local-dev/SKILL.md +17 -7
  5. package/assets/skills/ory-build-agent/SKILL.md +43 -97
  6. package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -18
  7. package/assets/skills/ory-temporal-worker/SKILL.md +38 -42
  8. package/assets/skills/permissions-onboarding/SKILL.md +131 -104
  9. package/dist/adapters.d.ts +93 -30
  10. package/dist/adapters.js +464 -136
  11. package/dist/agent-auth.d.ts +258 -68
  12. package/dist/agent-auth.js +998 -202
  13. package/dist/auth-store.d.ts +37 -2
  14. package/dist/auth-store.js +37 -3
  15. package/dist/auth.d.ts +40 -4
  16. package/dist/auth.js +247 -19
  17. package/dist/bash-parser.d.ts +98 -0
  18. package/dist/bash-parser.js +396 -0
  19. package/dist/branding.d.ts +128 -0
  20. package/dist/branding.js +151 -0
  21. package/dist/build-info.json +4 -4
  22. package/dist/cli-invocation.d.ts +1 -1
  23. package/dist/cli-invocation.js +2 -1
  24. package/dist/cli.d.ts +20 -29
  25. package/dist/cli.js +271 -278
  26. package/dist/client.d.ts +175 -138
  27. package/dist/client.js +672 -391
  28. package/dist/config.d.ts +249 -57
  29. package/dist/config.js +486 -62
  30. package/dist/context.d.ts +10 -0
  31. package/dist/context.js +21 -0
  32. package/dist/contract-suite.d.ts +8 -8
  33. package/dist/contract-suite.js +88 -69
  34. package/dist/denial.d.ts +36 -3
  35. package/dist/denial.js +79 -10
  36. package/dist/event-reporter.d.ts +77 -0
  37. package/dist/event-reporter.js +776 -0
  38. package/dist/external-registrations-main.d.ts +10 -0
  39. package/dist/external-registrations-main.js +38 -0
  40. package/dist/external-registrations.d.ts +79 -0
  41. package/dist/external-registrations.js +188 -0
  42. package/dist/help-cli.d.ts +39 -0
  43. package/dist/help-cli.js +55 -0
  44. package/dist/hook-timeout.d.ts +64 -0
  45. package/dist/hook-timeout.js +88 -0
  46. package/dist/index.d.ts +31 -19
  47. package/dist/index.js +182 -31
  48. package/dist/lifecycle.d.ts +3 -3
  49. package/dist/lifecycle.js +38 -6
  50. package/dist/local/cli.js +11 -6
  51. package/dist/local/configs.d.ts +74 -18
  52. package/dist/local/configs.js +291 -84
  53. package/dist/local/health.d.ts +14 -0
  54. package/dist/local/health.js +50 -4
  55. package/dist/local/index.d.ts +2 -2
  56. package/dist/local/index.js +24 -10
  57. package/dist/local/manager.d.ts +20 -1
  58. package/dist/local/manager.js +160 -39
  59. package/dist/local/ports.d.ts +158 -0
  60. package/dist/local/ports.js +443 -0
  61. package/dist/local/seed.d.ts +22 -25
  62. package/dist/local/seed.js +88 -56
  63. package/dist/logger.d.ts +54 -25
  64. package/dist/logger.js +329 -63
  65. package/dist/mcp.d.ts +2 -2
  66. package/dist/mcp.js +10 -5
  67. package/dist/mirror-bootstrap.d.ts +48 -0
  68. package/dist/mirror-bootstrap.js +254 -0
  69. package/dist/opl.d.ts +289 -0
  70. package/dist/opl.js +446 -0
  71. package/dist/permission-mode.d.ts +87 -0
  72. package/dist/permission-mode.js +307 -0
  73. package/dist/permissions-cli.d.ts +13 -49
  74. package/dist/permissions-cli.js +154 -348
  75. package/dist/permissions.d.ts +148 -38
  76. package/dist/permissions.js +591 -45
  77. package/dist/post-install.d.ts +33 -0
  78. package/dist/post-install.js +127 -0
  79. package/dist/read-credential.d.ts +65 -0
  80. package/dist/read-credential.js +86 -0
  81. package/dist/registry/cli.js +5 -2
  82. package/dist/registry/config.d.ts +0 -17
  83. package/dist/registry/config.js +0 -23
  84. package/dist/registry/index.d.ts +1 -1
  85. package/dist/registry/index.js +2 -2
  86. package/dist/registry/manager.d.ts +4 -21
  87. package/dist/registry/manager.js +83 -55
  88. package/dist/runtime-credential.d.ts +140 -0
  89. package/dist/runtime-credential.js +572 -0
  90. package/dist/runtime.d.ts +408 -0
  91. package/dist/runtime.js +748 -0
  92. package/dist/setup.d.ts +23 -28
  93. package/dist/setup.js +57 -84
  94. package/dist/status-cli.d.ts +29 -13
  95. package/dist/status-cli.js +124 -144
  96. package/dist/status-data.d.ts +195 -0
  97. package/dist/status-data.js +333 -0
  98. package/dist/status-system.d.ts +24 -0
  99. package/dist/status-system.js +56 -0
  100. package/dist/subject.d.ts +126 -20
  101. package/dist/subject.js +215 -30
  102. package/dist/testing.d.ts +74 -38
  103. package/dist/testing.js +185 -68
  104. package/dist/tool-catalog.d.ts +53 -11
  105. package/dist/tool-catalog.js +164 -13
  106. package/dist/tool-metadata.d.ts +7 -6
  107. package/dist/tool-metadata.js +6 -5
  108. package/dist/types.d.ts +11 -1
  109. package/dist/uninstall.d.ts +74 -19
  110. package/dist/uninstall.js +224 -49
  111. package/dist/user-login.d.ts +22 -16
  112. package/dist/user-login.js +67 -96
  113. package/dist/watch-cli.d.ts +6 -0
  114. package/dist/watch-cli.js +217 -0
  115. package/package.json +3 -11
  116. package/dist/dev.d.ts +0 -103
  117. package/dist/dev.js +0 -584
  118. package/dist/interactive-setup.d.ts +0 -165
  119. package/dist/interactive-setup.js +0 -1546
  120. package/dist/local/jaeger-main.d.ts +0 -13
  121. package/dist/local/jaeger-main.js +0 -85
  122. package/dist/local/jaeger.d.ts +0 -50
  123. package/dist/local/jaeger.js +0 -162
  124. package/dist/otel/exporter.d.ts +0 -17
  125. package/dist/otel/exporter.js +0 -12
  126. package/dist/otel/index.d.ts +0 -2
  127. package/dist/otel/index.js +0 -8
  128. package/dist/otel/otlp.d.ts +0 -103
  129. package/dist/otel/otlp.js +0 -385
  130. package/dist/tracer.d.ts +0 -190
  131. package/dist/tracer.js +0 -481
  132. package/dist/watch-sandbox.d.ts +0 -9
  133. package/dist/watch-sandbox.js +0 -81
@@ -4,47 +4,161 @@
4
4
  *
5
5
  * The "agent" is the AI process making calls on behalf of the human
6
6
  * user. Unlike the user, the agent never authenticates interactively.
7
- * The default path is **OAuth2 Dynamic Client Registration (RFC 7591)**:
8
- * on first run the agent registers itself with the project's
9
- * `/oauth2/register` endpoint, authenticated by the user's bearer token
10
- * (or an out-of-band initial access token), and persists the issued
11
- * credentials so subsequent runs reuse them.
7
+ * The normal path resolves a noninteractive runtime credential: an injected
8
+ * request authenticator, `ORY_AGENT_API_KEY`, or a Talos key enrolled with the
9
+ * already-authenticated user. Enrolled keys remain process-local.
12
10
  *
13
- * Resolution order:
14
- *
15
- * 1. ORY_AGENT_API_KEY — explicit static bearer
16
- * 2. ORY_AGENT_CLIENT_ID +
17
- * ORY_AGENT_CLIENT_SECRET — explicit static client_credentials
18
- * 3. Persisted dynamic credentials — reuse a prior DCR registration
19
- * 4. Fresh dynamic registration — POST /oauth2/register using the
20
- * user's bearer or
21
- * ORY_AGENT_REGISTRATION_TOKEN
22
- * 5. ORY_API_KEY (deprecated) — alias for ORY_AGENT_API_KEY
23
- * 6. <none> — fail-open; plugin runs without
24
- * agent identity in audit logs
25
- *
26
- * Token-exchange (RFC 8693) — deriving an agent token whose `sub` is
27
- * the user with an `act` claim showing the agent — is intentionally
28
- * deferred.
11
+ * Legacy DCR and client-credentials helpers remain exported temporarily so the
12
+ * monorepo and existing consumers compile, but the normal gates do not select
13
+ * them.
29
14
  */
30
15
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.AGENT_TOKEN_EXPIRY_SKEW_SEC = void 0;
16
+ exports.DEFAULT_SESSION_RETENTION = exports.AGENT_TOKEN_EXPIRY_SKEW_SEC = void 0;
17
+ exports.resolveRuntimeAgentCredentials = resolveRuntimeAgentCredentials;
32
18
  exports._resetAgentCredentialsCache = _resetAgentCredentialsCache;
19
+ exports.invalidateAgentAccessToken = invalidateAgentAccessToken;
33
20
  exports.loadSubAgentDynamicCredentials = loadSubAgentDynamicCredentials;
21
+ exports.loadAllSubAgentDynamicCredentials = loadAllSubAgentDynamicCredentials;
22
+ exports.loadSubAgentDynamicCredentialsByHarness = loadSubAgentDynamicCredentialsByHarness;
34
23
  exports.saveSubAgentDynamicCredentials = saveSubAgentDynamicCredentials;
35
24
  exports.clearSubAgentDynamicCredentials = clearSubAgentDynamicCredentials;
25
+ exports.clearSubAgentDynamicCredentialsForHarness = clearSubAgentDynamicCredentialsForHarness;
36
26
  exports.loadAgentDynamicCredentials = loadAgentDynamicCredentials;
27
+ exports.loadAgentDynamicCredentialsByHarness = loadAgentDynamicCredentialsByHarness;
37
28
  exports.saveAgentDynamicCredentials = saveAgentDynamicCredentials;
38
29
  exports.clearAgentDynamicCredentials = clearAgentDynamicCredentials;
30
+ exports.latestAgentRegistration = latestAgentRegistration;
31
+ exports.latestSubAgentSession = latestSubAgentSession;
32
+ exports.countAgentSessions = countAgentSessions;
33
+ exports.loadDelegationNodeId = loadDelegationNodeId;
34
+ exports.saveDelegationNodeId = saveDelegationNodeId;
35
+ exports.loadRetiredCredentials = loadRetiredCredentials;
36
+ exports.clearRetiredCredentials = clearRetiredCredentials;
37
+ exports.sessionRetention = sessionRetention;
38
+ exports.retireAgentSessions = retireAgentSessions;
39
+ exports.claimAgentDynamicCredentials = claimAgentDynamicCredentials;
40
+ exports.claimSubAgentDynamicCredentials = claimSubAgentDynamicCredentials;
39
41
  exports.revokeAgentDynamicClient = revokeAgentDynamicClient;
40
42
  exports.registerAgentClient = registerAgentClient;
41
43
  exports.resolveAgentCredentials = resolveAgentCredentials;
42
44
  exports.fetchClientCredentialsToken = fetchClientCredentialsToken;
43
45
  exports.ensureAgentIdentity = ensureAgentIdentity;
44
46
  exports.ensureSubAgentIdentity = ensureSubAgentIdentity;
47
+ const promises_1 = require("node:fs/promises");
45
48
  const node_os_1 = require("node:os");
49
+ const node_path_1 = require("node:path");
50
+ const promises_2 = require("node:timers/promises");
46
51
  const client_1 = require("@ory/client");
47
52
  const config_js_1 = require("./config.js");
53
+ const branding_js_1 = require("./branding.js");
54
+ const subject_js_1 = require("./subject.js");
55
+ const runtime_credential_js_1 = require("./runtime-credential.js");
56
+ const ENROLLMENT_LOCK_STALE_MS = 120_000;
57
+ const ENROLLMENT_LOCK_WAIT_MS = 35_000;
58
+ async function withEnrollmentLock(identity, work) {
59
+ const lockRoot = (0, node_path_1.join)((0, config_js_1.getDataDir)(), "runtime-credential-locks");
60
+ const lockPath = (0, node_path_1.join)(lockRoot, `${(0, runtime_credential_js_1.credentialAccount)(identity)}.lock`);
61
+ await (0, promises_1.mkdir)(lockRoot, { recursive: true, mode: 0o700 });
62
+ const deadline = Date.now() + ENROLLMENT_LOCK_WAIT_MS;
63
+ for (;;) {
64
+ try {
65
+ await (0, promises_1.mkdir)(lockPath, { mode: 0o700 });
66
+ break;
67
+ }
68
+ catch (error) {
69
+ if (error.code !== "EEXIST")
70
+ throw error;
71
+ try {
72
+ const info = await (0, promises_1.stat)(lockPath);
73
+ if (Date.now() - info.mtimeMs > ENROLLMENT_LOCK_STALE_MS) {
74
+ await (0, promises_1.rm)(lockPath, { recursive: true, force: true });
75
+ continue;
76
+ }
77
+ }
78
+ catch (statError) {
79
+ if (statError.code === "ENOENT")
80
+ continue;
81
+ throw statError;
82
+ }
83
+ if (Date.now() >= deadline)
84
+ throw new Error("Timed out waiting for runtime enrollment");
85
+ await (0, promises_2.setTimeout)(50);
86
+ }
87
+ }
88
+ try {
89
+ return await work();
90
+ }
91
+ finally {
92
+ await (0, promises_1.rm)(lockPath, { recursive: true, force: true });
93
+ }
94
+ }
95
+ /**
96
+ * Resolve the runtime credential used by the normal Agent Security path.
97
+ * This intentionally does not inspect OAuth client credentials or persisted DCR.
98
+ */
99
+ async function resolveRuntimeAgentCredentials(options = {}) {
100
+ const env = options.env ?? process.env;
101
+ const subject = options.subject ?? (0, subject_js_1.readAgentSubjectOverride)(env, []);
102
+ const injected = options.runtimeCredential?.kind === "injected"
103
+ ? options.runtimeCredential
104
+ : (0, runtime_credential_js_1.resolveRuntimeCredential)({
105
+ env: {},
106
+ requestAuthenticator: options.requestAuthenticator,
107
+ subject,
108
+ fetch: options.fetchImpl,
109
+ });
110
+ const configured = (0, runtime_credential_js_1.resolveRuntimeCredential)({ env, subject, fetch: options.fetchImpl });
111
+ const existing = injected ?? configured
112
+ ?? (options.runtimeCredential?.kind === "talos" ? options.runtimeCredential : undefined);
113
+ if (existing) {
114
+ return {
115
+ kind: existing.kind,
116
+ runtimeCredential: existing,
117
+ subject: existing.subject,
118
+ reason: existing.kind === "talos"
119
+ ? "Using Talos runtime credential"
120
+ : "Using injected runtime request authenticator",
121
+ warnings: [],
122
+ };
123
+ }
124
+ if (options.projectUrl) {
125
+ try {
126
+ const store = options.credentialStore ?? (0, runtime_credential_js_1.createOsTalosCredentialStore)();
127
+ const stored = await store.load({
128
+ projectUrl: options.projectUrl,
129
+ harness: options.harness?.trim() || config_js_1.UNKNOWN_HARNESS_KEY,
130
+ sessionId: options.sessionId?.trim() || config_js_1.SESSIONLESS_KEY,
131
+ kind: "agent",
132
+ });
133
+ if (stored) {
134
+ return {
135
+ kind: "talos",
136
+ runtimeCredential: (0, runtime_credential_js_1.talosRuntimeCredential)(stored.credential, {
137
+ subject: stored.actorId,
138
+ fetch: options.fetchImpl,
139
+ }),
140
+ subject: stored.actorId,
141
+ reason: "Using Talos runtime credential from the OS credential store",
142
+ warnings: [],
143
+ };
144
+ }
145
+ }
146
+ catch {
147
+ return {
148
+ kind: "none",
149
+ reason: "No noninteractive runtime credential is configured",
150
+ warnings: [
151
+ "Could not read the Talos runtime credential from the OS credential store",
152
+ ],
153
+ };
154
+ }
155
+ }
156
+ return {
157
+ kind: "none",
158
+ reason: "No noninteractive runtime credential is configured",
159
+ warnings: [],
160
+ };
161
+ }
48
162
  /**
49
163
  * Skew window before nominal expiry at which a cached client_credentials
50
164
  * token is considered stale. Matches the user-token skew in auth-store.
@@ -55,67 +169,409 @@ const ccCache = new Map();
55
169
  function _resetAgentCredentialsCache() {
56
170
  ccCache.clear();
57
171
  }
58
- // ─── Sub-agent persistence ───────────────────────────────────────────
59
- /** Load persisted sub-agent DCR credentials keyed by sub-agent type. */
60
- function loadSubAgentDynamicCredentials(subAgentType) {
61
- return (0, config_js_1.loadConfig)().agent?.subAgents?.[subAgentType];
172
+ /** Evict a bearer rejected by Agent Security without disturbing other sessions. */
173
+ function invalidateAgentAccessToken(rejectedToken) {
174
+ for (const [key, cached] of ccCache) {
175
+ if (cached.token === rejectedToken)
176
+ ccCache.delete(key);
177
+ }
178
+ }
179
+ // ─── Credential persistence ──────────────────────────────────────────
180
+ //
181
+ // `dynamic` is keyed by harness then session. `subAgents` adds the sub-agent
182
+ // type below the session. Processes handling different hooks in one session
183
+ // converge on the same credential through the config lock; another session
184
+ // receives a different DCR client.
185
+ /** Load persisted sub-agent DCR credentials for one harness + type. */
186
+ function loadSubAgentDynamicCredentials(harness, sessionOrType, subAgentType) {
187
+ const sessionKey = subAgentType === undefined ? config_js_1.SESSIONLESS_KEY : sessionOrType;
188
+ const type = subAgentType ?? sessionOrType;
189
+ return (0, config_js_1.loadConfig)().agent?.subAgents?.[harness]?.[sessionKey]?.[type];
62
190
  }
63
- /** Persist sub-agent DCR credentials under the given type key. */
64
- function saveSubAgentDynamicCredentials(subAgentType, creds) {
191
+ /**
192
+ * Load one harness's persisted sub-agent registrations, keyed by sub-agent type.
193
+ * Returns an empty object when none have been registered. Used by the status
194
+ * report to enumerate the sub-agent kinds this harness has resolved.
195
+ */
196
+ function loadAllSubAgentDynamicCredentials(harness, sessionKey = config_js_1.SESSIONLESS_KEY) {
197
+ return { ...((0, config_js_1.loadConfig)().agent?.subAgents?.[harness]?.[sessionKey] ?? {}) };
198
+ }
199
+ /**
200
+ * Every persisted sub-agent registration, keyed by harness, session, then type. Used by
201
+ * teardown and reporting paths that must reach beyond the harness they run under.
202
+ */
203
+ function loadSubAgentDynamicCredentialsByHarness() {
204
+ const stored = (0, config_js_1.loadConfig)().agent?.subAgents ?? {};
205
+ return Object.fromEntries(Object.entries(stored).map(([harness, bySession]) => [
206
+ harness,
207
+ Object.fromEntries(Object.entries(bySession).map(([session, byType]) => [session, { ...byType }])),
208
+ ]));
209
+ }
210
+ function saveSubAgentDynamicCredentials(harness, sessionOrType, typeOrCreds, maybeCreds) {
211
+ const sessionKey = maybeCreds ? sessionOrType : config_js_1.SESSIONLESS_KEY;
212
+ const subAgentType = maybeCreds ? typeOrCreds : sessionOrType;
213
+ const creds = maybeCreds ?? typeOrCreds;
65
214
  (0, config_js_1.mutateConfig)((current) => {
66
215
  const agent = { ...(current.agent ?? {}) };
67
216
  const subAgents = { ...(agent.subAgents ?? {}) };
68
- subAgents[subAgentType] = creds;
217
+ const bySession = { ...(subAgents[harness] ?? {}) };
218
+ bySession[sessionKey] = { ...(bySession[sessionKey] ?? {}), [subAgentType]: creds };
219
+ subAgents[harness] = bySession;
69
220
  agent.subAgents = subAgents;
70
221
  return { ...current, agent };
71
222
  });
72
223
  }
73
- /** Remove persisted sub-agent DCR credentials for the given type. */
74
- function clearSubAgentDynamicCredentials(subAgentType) {
75
- (0, config_js_1.mutateConfig)((current) => {
76
- const next = { ...current };
77
- if (!next.agent?.subAgents)
78
- return next;
79
- const agent = { ...next.agent };
80
- const subAgents = { ...agent.subAgents };
81
- delete subAgents[subAgentType];
82
- if (Object.keys(subAgents).length === 0)
83
- delete agent.subAgents;
224
+ /** Remove persisted sub-agent DCR credentials for one harness + type. */
225
+ function clearSubAgentDynamicCredentials(harness, sessionOrType, subAgentType) {
226
+ const sessionKey = subAgentType === undefined ? config_js_1.SESSIONLESS_KEY : sessionOrType;
227
+ const type = subAgentType ?? sessionOrType;
228
+ (0, config_js_1.mutateConfig)((current) => pruneAgentBlock(current, (agent) => {
229
+ const subAgents = { ...(agent.subAgents ?? {}) };
230
+ const bySession = { ...(subAgents[harness] ?? {}) };
231
+ const byType = { ...(bySession[sessionKey] ?? {}) };
232
+ delete byType[type];
233
+ if (Object.keys(byType).length === 0)
234
+ delete bySession[sessionKey];
84
235
  else
85
- agent.subAgents = subAgents;
86
- if (!agent.dynamic && !agent.subAgents)
87
- delete next.agent;
236
+ bySession[sessionKey] = byType;
237
+ if (Object.keys(bySession).length === 0)
238
+ delete subAgents[harness];
88
239
  else
89
- next.agent = agent;
240
+ subAgents[harness] = bySession;
241
+ agent.subAgents = subAgents;
242
+ }));
243
+ }
244
+ /** Remove every persisted sub-agent registration for one harness. */
245
+ function clearSubAgentDynamicCredentialsForHarness(harness) {
246
+ (0, config_js_1.mutateConfig)((current) => pruneAgentBlock(current, (agent) => {
247
+ const subAgents = { ...(agent.subAgents ?? {}) };
248
+ delete subAgents[harness];
249
+ agent.subAgents = subAgents;
250
+ }));
251
+ }
252
+ // ─── Dynamic registration persistence ────────────────────────────────
253
+ /** Load persisted DCR credentials for one harness session. */
254
+ function loadAgentDynamicCredentials(harness, sessionKey = config_js_1.SESSIONLESS_KEY) {
255
+ return (0, config_js_1.loadConfig)().agent?.dynamic?.[harness]?.[sessionKey];
256
+ }
257
+ /**
258
+ * Every persisted agent registration, keyed by harness then session.
259
+ * reporting paths that must reach beyond the harness they run under.
260
+ */
261
+ function loadAgentDynamicCredentialsByHarness() {
262
+ const stored = (0, config_js_1.loadConfig)().agent?.dynamic ?? {};
263
+ return Object.fromEntries(Object.entries(stored).map(([harness, bySession]) => [harness, { ...bySession }]));
264
+ }
265
+ function saveAgentDynamicCredentials(harness, sessionOrCreds, maybeCreds) {
266
+ const sessionKey = typeof sessionOrCreds === "string" ? sessionOrCreds : config_js_1.SESSIONLESS_KEY;
267
+ const creds = maybeCreds ?? sessionOrCreds;
268
+ (0, config_js_1.mutateConfig)((current) => {
269
+ const agent = { ...(current.agent ?? {}) };
270
+ const bySession = { ...(agent.dynamic?.[harness] ?? {}) };
271
+ bySession[sessionKey] = creds;
272
+ agent.dynamic = { ...(agent.dynamic ?? {}), [harness]: bySession };
273
+ return { ...current, agent };
274
+ });
275
+ }
276
+ /** Remove one session, or every session when no session key is supplied. */
277
+ function clearAgentDynamicCredentials(harness, sessionKey) {
278
+ (0, config_js_1.mutateConfig)((current) => pruneAgentBlock(current, (agent) => {
279
+ const dynamic = { ...(agent.dynamic ?? {}) };
280
+ if (sessionKey === undefined)
281
+ delete dynamic[harness];
282
+ else {
283
+ const bySession = { ...(dynamic[harness] ?? {}) };
284
+ delete bySession[sessionKey];
285
+ if (Object.keys(bySession).length === 0)
286
+ delete dynamic[harness];
287
+ else
288
+ dynamic[harness] = bySession;
289
+ }
290
+ agent.dynamic = dynamic;
291
+ }));
292
+ }
293
+ /** Most recently registered agent session for status/reporting. */
294
+ function latestAgentRegistration(harness) {
295
+ const entries = Object.entries((0, config_js_1.loadConfig)().agent?.dynamic?.[harness] ?? {}).sort(([aKey, a], [bKey, b]) => b.registeredAt - a.registeredAt || bKey.localeCompare(aKey));
296
+ const [sessionKey, credentials] = entries[0] ?? [];
297
+ return sessionKey && credentials ? { sessionKey, credentials } : undefined;
298
+ }
299
+ /** Newest session containing at least one sub-agent registration. */
300
+ function latestSubAgentSession(harness) {
301
+ return Object.entries((0, config_js_1.loadConfig)().agent?.subAgents?.[harness] ?? {})
302
+ .map(([sessionKey, byType]) => ({
303
+ sessionKey,
304
+ registeredAt: Math.max(...Object.values(byType).map((c) => c.registeredAt), 0),
305
+ }))
306
+ .sort((a, b) => b.registeredAt - a.registeredAt || b.sessionKey.localeCompare(a.sessionKey))[0]
307
+ ?.sessionKey;
308
+ }
309
+ function countAgentSessions(harness) {
310
+ return Object.keys((0, config_js_1.loadConfig)().agent?.dynamic?.[harness] ?? {}).length;
311
+ }
312
+ /**
313
+ * Apply `mutate` to a copy of the agent block, then drop any map (and the block
314
+ * itself) left empty. Every clear path needs the same collapse, and doing it
315
+ * once keeps an emptied `agent: {}` from lingering in the file.
316
+ */
317
+ function pruneAgentBlock(current, mutate) {
318
+ const next = { ...current };
319
+ if (!next.agent)
90
320
  return next;
321
+ const agent = { ...next.agent };
322
+ mutate(agent);
323
+ if (agent.dynamic && Object.keys(agent.dynamic).length === 0)
324
+ delete agent.dynamic;
325
+ if (agent.subAgents && Object.keys(agent.subAgents).length === 0)
326
+ delete agent.subAgents;
327
+ if (agent.retired && agent.retired.length === 0)
328
+ delete agent.retired;
329
+ if (!agent.dynamic && !agent.subAgents && !agent.retired)
330
+ delete next.agent;
331
+ else
332
+ next.agent = agent;
333
+ return next;
334
+ }
335
+ // ─── Delegation node ids (non-secret, independent of credentials) ───
336
+ /**
337
+ * The delegation node the broker assigned to this harness's `user → agent` edge
338
+ * for one session, or `undefined` if that session hasn't recorded one yet.
339
+ *
340
+ * Read by the sub-agent edge to pass as `delegated_by` — which is why it is
341
+ * looked up by session and not just by harness: the credential is shared across
342
+ * sessions but the node is not, so a sub-agent must hang off *its own* run's
343
+ * agent node.
344
+ */
345
+ function loadDelegationNodeId(harness, sessionKey) {
346
+ const config = (0, config_js_1.loadConfig)();
347
+ return config.delegationAnchors?.[harness]?.[sessionKey]
348
+ // Read the old location during the cutover; every subsequent save writes
349
+ // only the independent non-secret anchor map.
350
+ ?? config.agent?.dynamic?.[harness]?.[sessionKey]?.delegationNodeIds?.[sessionKey];
351
+ }
352
+ /**
353
+ * Record the broker-assigned delegation node for `(harness, session)`, trimming
354
+ * the map to the most recent {@link DELEGATION_NODE_HISTORY} entries.
355
+ *
356
+ * The anchor is not a credential and is safe to persist independently. A
357
+ * repeated idempotent edge does not rewrite the config file.
358
+ */
359
+ function saveDelegationNodeId(harness, sessionKey, nodeId) {
360
+ (0, config_js_1.mutateConfig)((current) => {
361
+ const existing = current.delegationAnchors?.[harness] ?? {};
362
+ if (existing[sessionKey] === nodeId)
363
+ return current;
364
+ // Re-insert rather than spread over the existing entry: a spread keeps an
365
+ // already-present key in its *original* position, so a session that
366
+ // re-recorded a changed node id would still be pruned as if it were the
367
+ // oldest. Deleting first moves it to the end, where the bound never reaches.
368
+ const merged = { ...existing };
369
+ delete merged[sessionKey];
370
+ merged[sessionKey] = nodeId;
371
+ // Insertion order is oldest-first, so dropping from the front keeps the
372
+ // newest entries. The session just written is last and always survives.
373
+ const keys = Object.keys(merged);
374
+ const kept = keys.slice(Math.max(0, keys.length - config_js_1.DELEGATION_NODE_HISTORY));
375
+ return {
376
+ ...current,
377
+ delegationAnchors: {
378
+ ...(current.delegationAnchors ?? {}),
379
+ [harness]: Object.fromEntries(kept.map((key) => [key, merged[key]])),
380
+ },
381
+ };
91
382
  });
92
383
  }
93
- // ─── Dynamic registration persistence ────────────────────────────────
94
- /** Load persisted DCR credentials from the shared config file. */
95
- function loadAgentDynamicCredentials() {
96
- return (0, config_js_1.loadConfig)().agent?.dynamic;
97
- }
98
- /** Persist DCR credentials. Existing credentials are replaced atomically. */
99
- function saveAgentDynamicCredentials(creds) {
100
- (0, config_js_1.mutateConfig)((current) => ({
101
- ...current,
102
- agent: { ...(current.agent ?? {}), dynamic: creds },
384
+ // ─── Revocation drain queue ──────────────────────────────────────────
385
+ //
386
+ // Session retention atomically removes an aged parent and its typed children
387
+ // from the active maps and moves them to `agent.retired`. They still exist on
388
+ // the project, so they must be revoked rather than forgotten.
389
+ /** Registrations queued for best-effort revocation. */
390
+ function loadRetiredCredentials() {
391
+ return [...((0, config_js_1.loadConfig)().agent?.retired ?? [])];
392
+ }
393
+ /**
394
+ * Remove `clientIds` from the drain queue. Called with the ones that were
395
+ * actually revoked; anything left stays queued for the next run, so a transient
396
+ * failure retries instead of orphaning the client.
397
+ */
398
+ function clearRetiredCredentials(clientIds) {
399
+ if (clientIds.length === 0)
400
+ return;
401
+ const done = new Set(clientIds);
402
+ (0, config_js_1.mutateConfig)((current) => pruneAgentBlock(current, (agent) => {
403
+ agent.retired = (agent.retired ?? []).filter((c) => !done.has(c.clientId));
103
404
  }));
104
405
  }
105
- /** Remove persisted DCR credentials (e.g. after revocation or unregister). */
106
- function clearAgentDynamicCredentials() {
406
+ /** Automatic retirement is opt-in because age alone cannot prove a session ended. */
407
+ exports.DEFAULT_SESSION_RETENTION = 0;
408
+ /** `0` disables automatic retirement. */
409
+ function sessionRetention(env = process.env) {
410
+ const raw = env.ORY_AGENT_SESSION_RETENTION?.trim();
411
+ if (!raw)
412
+ return exports.DEFAULT_SESSION_RETENTION;
413
+ if (!/^\d+$/.test(raw))
414
+ return exports.DEFAULT_SESSION_RETENTION;
415
+ const parsed = Number(raw);
416
+ return Number.isSafeInteger(parsed) ? parsed : exports.DEFAULT_SESSION_RETENTION;
417
+ }
418
+ /**
419
+ * Move aged-out agent sessions and all of their child credentials to the
420
+ * retirement queue atomically. They remain addressable until RFC 7592 cleanup
421
+ * succeeds, avoiding the orphaning behavior of the original pruning path.
422
+ */
423
+ function retireAgentSessions(harness, opts) {
424
+ if (opts.keep <= 0)
425
+ return 0;
426
+ let retiredCount = 0;
427
+ (0, config_js_1.mutateConfig)((current) => {
428
+ const bySession = current.agent?.dynamic?.[harness];
429
+ if (!bySession)
430
+ return current;
431
+ const ordered = Object.entries(bySession).sort(([aKey, a], [bKey, b]) => b.registeredAt - a.registeredAt || bKey.localeCompare(aKey));
432
+ const hasProtected = !!opts.protect && Object.hasOwn(bySession, opts.protect);
433
+ const keepOthers = Math.max(0, opts.keep - (hasProtected ? 1 : 0));
434
+ const doomed = ordered.filter(([key]) => key !== opts.protect).slice(keepOthers);
435
+ if (doomed.length === 0)
436
+ return current;
437
+ const agent = { ...current.agent };
438
+ const dynamic = { ...(agent.dynamic ?? {}) };
439
+ const kept = { ...bySession };
440
+ const subAgents = { ...(agent.subAgents ?? {}) };
441
+ const subBySession = { ...(subAgents[harness] ?? {}) };
442
+ const queue = [...(agent.retired ?? [])];
443
+ const queued = new Set(queue.map((c) => c.clientId));
444
+ for (const [sessionKey, creds] of doomed) {
445
+ if (!queued.has(creds.clientId))
446
+ queue.push({ ...creds, harness });
447
+ delete kept[sessionKey];
448
+ for (const child of Object.values(subBySession[sessionKey] ?? {})) {
449
+ if (!queued.has(child.clientId))
450
+ queue.push({ ...child, harness });
451
+ }
452
+ delete subBySession[sessionKey];
453
+ retiredCount++;
454
+ }
455
+ if (Object.keys(kept).length === 0)
456
+ delete dynamic[harness];
457
+ else
458
+ dynamic[harness] = kept;
459
+ if (Object.keys(subBySession).length === 0)
460
+ delete subAgents[harness];
461
+ else
462
+ subAgents[harness] = subBySession;
463
+ agent.dynamic = dynamic;
464
+ agent.subAgents = subAgents;
465
+ agent.retired = queue;
466
+ return { ...current, agent };
467
+ });
468
+ return retiredCount;
469
+ }
470
+ /** Retire one exact session and all children when its identity binding is stale. */
471
+ function retireAgentSession(harness, sessionKey) {
472
+ let retired = false;
107
473
  (0, config_js_1.mutateConfig)((current) => {
108
- const next = { ...current };
109
- if (!next.agent)
110
- return next;
111
- const agent = { ...next.agent };
112
- delete agent.dynamic;
113
- if (Object.keys(agent).length === 0)
114
- delete next.agent;
474
+ const credentials = current.agent?.dynamic?.[harness]?.[sessionKey];
475
+ if (!credentials)
476
+ return current;
477
+ const agent = { ...current.agent };
478
+ const dynamic = { ...(agent.dynamic ?? {}) };
479
+ const bySession = { ...(dynamic[harness] ?? {}) };
480
+ const subAgents = { ...(agent.subAgents ?? {}) };
481
+ const subBySession = { ...(subAgents[harness] ?? {}) };
482
+ const queue = [...(agent.retired ?? [])];
483
+ const queued = new Set(queue.map((item) => item.clientId));
484
+ if (!queued.has(credentials.clientId))
485
+ queue.push({ ...credentials, harness });
486
+ for (const child of Object.values(subBySession[sessionKey] ?? {})) {
487
+ if (!queued.has(child.clientId))
488
+ queue.push({ ...child, harness });
489
+ }
490
+ delete bySession[sessionKey];
491
+ delete subBySession[sessionKey];
492
+ if (Object.keys(bySession).length === 0)
493
+ delete dynamic[harness];
115
494
  else
116
- next.agent = agent;
117
- return next;
495
+ dynamic[harness] = bySession;
496
+ if (Object.keys(subBySession).length === 0)
497
+ delete subAgents[harness];
498
+ else
499
+ subAgents[harness] = subBySession;
500
+ agent.dynamic = dynamic;
501
+ agent.subAgents = subAgents;
502
+ agent.retired = queue;
503
+ retired = true;
504
+ return { ...current, agent };
505
+ });
506
+ return retired;
507
+ }
508
+ /**
509
+ * True when `existing` is a usable peer registration that should win over a
510
+ * client we just minted for the same key: it is a *different* client, issued
511
+ * against the same project, and usable for a client_credentials grant.
512
+ */
513
+ function isUsablePeer(existing, registered) {
514
+ return Boolean(existing &&
515
+ existing.clientId !== registered.clientId &&
516
+ existing.projectUrl === registered.projectUrl &&
517
+ existing.clientSecret &&
518
+ (!registered.delegation ||
519
+ (existing.delegation?.delegationType === registered.delegation.delegationType &&
520
+ existing.delegation.delegatedBy === registered.delegation.delegatedBy)));
521
+ }
522
+ /**
523
+ * Persist a freshly registered agent client, unless a concurrent peer already
524
+ * claimed the `(projectUrl, harness)` slot.
525
+ *
526
+ * Keying credentials by harness removes the cold-start collision *between*
527
+ * harnesses, but two instances of the *same* harness starting at once still
528
+ * target one key. Both POST `/oauth2/register`, and a plain last-writer-wins
529
+ * save would leave one client orphaned server-side (and its delegation edge
530
+ * dangling). The read and the write happen inside a single `mutateConfig`, so
531
+ * the loser sees the winner's registration and reports it back to be revoked.
532
+ */
533
+ function claimAgentDynamicCredentials(harness, sessionKey, registered) {
534
+ let claim = { credentials: registered, peer: false };
535
+ (0, config_js_1.mutateConfig)((current) => {
536
+ const existing = current.agent?.dynamic?.[harness]?.[sessionKey];
537
+ if (isUsablePeer(existing, registered)) {
538
+ claim = { credentials: existing, peer: true };
539
+ return current;
540
+ }
541
+ const agent = { ...(current.agent ?? {}) };
542
+ if (existing &&
543
+ existing.clientId !== registered.clientId &&
544
+ existing.projectUrl === registered.projectUrl) {
545
+ agent.retired = [
546
+ ...(agent.retired ?? []).filter((c) => c.clientId !== existing.clientId),
547
+ { ...existing, harness },
548
+ ];
549
+ }
550
+ const bySession = { ...(agent.dynamic?.[harness] ?? {}) };
551
+ bySession[sessionKey] = registered;
552
+ agent.dynamic = { ...(agent.dynamic ?? {}), [harness]: bySession };
553
+ return { ...current, agent };
118
554
  });
555
+ return claim;
556
+ }
557
+ /** {@link claimAgentDynamicCredentials} for a `(harness, subAgentType)` slot. */
558
+ function claimSubAgentDynamicCredentials(harness, sessionKey, subAgentType, registered) {
559
+ let claim = { credentials: registered, peer: false };
560
+ (0, config_js_1.mutateConfig)((current) => {
561
+ const existing = current.agent?.subAgents?.[harness]?.[sessionKey]?.[subAgentType];
562
+ if (isUsablePeer(existing, registered)) {
563
+ claim = { credentials: existing, peer: true };
564
+ return current;
565
+ }
566
+ const agent = { ...(current.agent ?? {}) };
567
+ const subAgents = { ...(agent.subAgents ?? {}) };
568
+ const bySession = { ...(subAgents[harness] ?? {}) };
569
+ bySession[sessionKey] = { ...(bySession[sessionKey] ?? {}), [subAgentType]: registered };
570
+ subAgents[harness] = bySession;
571
+ agent.subAgents = subAgents;
572
+ return { ...current, agent };
573
+ });
574
+ return claim;
119
575
  }
120
576
  /**
121
577
  * Best-effort RFC 7592 `DELETE` of a dynamically-registered OAuth2 client.
@@ -156,15 +612,33 @@ async function revokeAgentDynamicClient(creds) {
156
612
  * either by the user's interactive token (the natural bootstrap) or by
157
613
  * a pre-issued initial access token (`ORY_AGENT_REGISTRATION_TOKEN`).
158
614
  * Throws on any non-2xx response so callers can decide whether to
159
- * retry, fall back, or surface the error in the audit span. The
615
+ * retry, fall back, or surface the error in audit activity. The
160
616
  * `HTTP <status>` error message shape is preserved so callers and tests
161
617
  * can pattern-match on it regardless of the underlying transport.
162
618
  */
163
619
  async function registerAgentClient(args) {
164
620
  const host = (args.hostnameFn ?? node_os_1.hostname)() || "unknown-host";
165
- const clientName = args.harness
166
- ? `ory-agent-${args.harness}@${host}`
167
- : `ory-agent@${host}`;
621
+ const clientName = (0, branding_js_1.agentClientName)({
622
+ harness: args.harness,
623
+ host,
624
+ subAgentType: args.subAgentType,
625
+ sessionId: args.sessionId,
626
+ });
627
+ // Build the delegation anchor (if a delegator is known) and mirror it into the
628
+ // persisted credentials. This is the integrity anchor the Keto tuple is derived
629
+ // from. It is NOT sent to Ory: the public DCR endpoint rejects a `metadata`
630
+ // field (see the request body below), so the anchor lives only in local config.
631
+ const delegation = args.delegation
632
+ ? {
633
+ delegatedBy: args.delegation.delegatedBy,
634
+ delegationType: args.delegation.delegationType,
635
+ ...(args.harness ? { harness: args.harness } : {}),
636
+ ...(args.delegation.subAgentType
637
+ ? { subAgentType: args.delegation.subAgentType }
638
+ : {}),
639
+ delegatedAt: (args.nowFn ?? (() => new Date()))().toISOString(),
640
+ }
641
+ : undefined;
168
642
  const oidc = new client_1.OidcApi(new client_1.Configuration({
169
643
  basePath: args.projectUrl,
170
644
  accessToken: args.auth,
@@ -178,6 +652,14 @@ async function registerAgentClient(args) {
178
652
  response_types: ["token"],
179
653
  scope: "openid offline_access",
180
654
  token_endpoint_auth_method: "client_secret_post",
655
+ // NB: the delegation record is deliberately NOT sent as `metadata`.
656
+ // Ory Network / Hydra reject a `metadata` field on the public DCR
657
+ // endpoints — both RFC 7591 create and RFC 7592 update return
658
+ // `400 invalid_client_metadata: 'metadata' cannot be set for dynamic
659
+ // client registration` (metadata is admin-API-only). Sending it made
660
+ // every fresh registration fail, so the agent never got credentials.
661
+ // The delegation anchor is persisted locally instead (see the return
662
+ // value) and is never read back from the server side.
181
663
  },
182
664
  },
183
665
  // The generated SDK declares no security scheme for the public DCR create
@@ -185,7 +667,10 @@ async function registerAgentClient(args) {
185
667
  // Configuration's `accessToken`. Ory projects that gate DCR behind an
186
668
  // initial access token then reject the unauthenticated request and the
187
669
  // agent never gets credentials. Send the bearer explicitly.
188
- args.auth ? { headers: { Authorization: `Bearer ${args.auth}` } } : undefined);
670
+ {
671
+ ...(args.auth ? { headers: { Authorization: `Bearer ${args.auth}` } } : {}),
672
+ ...(args.signal ? { signal: args.signal } : {}),
673
+ });
189
674
  }
190
675
  catch (err) {
191
676
  const status = err?.response?.status;
@@ -206,34 +691,38 @@ async function registerAgentClient(args) {
206
691
  registeredAt: Math.floor(Date.now() / 1000),
207
692
  projectUrl: args.projectUrl,
208
693
  harness: args.harness,
694
+ clientName: client.client_name ?? clientName,
695
+ ...(delegation ? { delegation } : {}),
209
696
  };
210
697
  }
211
698
  /**
212
- * Resolve the agent's credentials from environment configuration and
213
- * persisted state. Never throws — failures collapse to `kind: "none"`
214
- * so the caller can fail open.
699
+ * Legacy OAuth/DCR resolver retained for source compatibility. Normal runtime
700
+ * gates use {@link resolveRuntimeAgentCredentials} instead.
215
701
  */
216
702
  async function resolveAgentCredentials(options = {}) {
217
703
  const env = options.env ?? process.env;
218
704
  const warnings = [];
705
+ // Read and validate once, not at each of the three grant paths below — the
706
+ // rule has to be the same whichever credential slot wins (#241).
707
+ const subjectOverride = (0, subject_js_1.readAgentSubjectOverride)(env, warnings);
219
708
  const fetchCcFn = options.fetchClientCredentialsTokenFn ?? fetchClientCredentialsToken;
220
709
  const registerFn = options.registerAgentClientFn ?? registerAgentClient;
221
710
  const loadDynamicFn = options.loadDynamicFn ?? loadAgentDynamicCredentials;
222
711
  const saveDynamicFn = options.saveDynamicFn ?? saveAgentDynamicCredentials;
223
712
  const clearDynamicFn = options.clearDynamicFn ?? clearAgentDynamicCredentials;
713
+ const revokeFn = options.revokeFn ?? revokeAgentDynamicClient;
224
714
  const skipCache = options.skipCache ?? false;
225
- // 1. Explicit static API key operator override.
226
- const apiKey = env.ORY_AGENT_API_KEY;
227
- if (apiKey && apiKey.length > 0) {
228
- return {
229
- kind: "api_key",
230
- token: apiKey,
231
- subject: env.ORY_AGENT_SUBJECT_ID,
232
- reason: "Resolved agent identity from ORY_AGENT_API_KEY",
233
- warnings,
234
- };
235
- }
236
- // 2. Explicit static client_credentials — operator override.
715
+ // Credentials are keyed by harness *and session*: each installed harness owns
716
+ // its own agent identity, and each session of that harness gets a distinct
717
+ // one. A caller with no harness (an SDK embedding, a one-off script) gets the
718
+ // shared `unknown` slot rather than silently hijacking a harness's identity;
719
+ // one with no session gets the single `sessionless` identity rather than a
720
+ // fresh client per call.
721
+ const harnessKey = options.harness?.trim() || config_js_1.UNKNOWN_HARNESS_KEY;
722
+ const sessionKey = options.sessionId?.trim() || config_js_1.SESSIONLESS_KEY;
723
+ // 1. Explicit static client_credentials — operator override. This is the
724
+ // only non-DCR path: it still yields a real OAuth2 access token, which is
725
+ // what the Keto `/user` read path authenticates with.
237
726
  const clientId = env.ORY_AGENT_CLIENT_ID;
238
727
  const clientSecret = env.ORY_AGENT_CLIENT_SECRET;
239
728
  if (clientId && clientSecret) {
@@ -245,21 +734,37 @@ async function resolveAgentCredentials(options = {}) {
245
734
  };
246
735
  }
247
736
  return await resolveClientCredentials({
248
- projectUrl: options.projectUrl,
737
+ projectUrl: options.projectUrl ?? (0, config_js_1.resolveConfig)().projectUrl,
249
738
  clientId,
250
739
  clientSecret,
251
740
  audience: env.ORY_AGENT_AUDIENCE,
252
741
  scope: env.ORY_AGENT_SCOPE,
253
- subjectOverride: env.ORY_AGENT_SUBJECT_ID,
742
+ subjectOverride,
254
743
  kind: "client_credentials",
255
744
  fetchFn: fetchCcFn,
256
745
  skipCache,
257
746
  warnings,
747
+ signal: options.signal,
258
748
  });
259
749
  }
260
- // 3. Persisted dynamic registration re-use a prior DCR run.
750
+ // 2. Re-use the DCR client for this exact harness session. Subprocess hook
751
+ // processes converge here; a new session has a different slot.
261
752
  if (options.projectUrl) {
262
- const persisted = loadDynamicFn();
753
+ let persisted = loadDynamicFn(harnessKey, sessionKey);
754
+ const expectedDelegator = options.userSubject ? `user:${options.userSubject}` : undefined;
755
+ if (persisted &&
756
+ expectedDelegator &&
757
+ (persisted.delegation?.delegationType !== "user-to-agent" ||
758
+ persisted.delegation.delegatedBy !== expectedDelegator)) {
759
+ warnings.push(`Persisted dynamic agent credentials belong to ${persisted.delegation?.delegatedBy ?? "an unknown user"}; registering a credential for ${expectedDelegator}.`);
760
+ if (options.loadDynamicFn || options.saveDynamicFn) {
761
+ clearDynamicFn(harnessKey, sessionKey);
762
+ }
763
+ else {
764
+ retireAgentSession(harnessKey, sessionKey);
765
+ }
766
+ persisted = undefined;
767
+ }
263
768
  if (persisted && persisted.projectUrl === options.projectUrl && persisted.clientSecret) {
264
769
  const result = await resolveClientCredentials({
265
770
  projectUrl: options.projectUrl,
@@ -267,11 +772,12 @@ async function resolveAgentCredentials(options = {}) {
267
772
  clientSecret: persisted.clientSecret,
268
773
  audience: env.ORY_AGENT_AUDIENCE,
269
774
  scope: env.ORY_AGENT_SCOPE,
270
- subjectOverride: env.ORY_AGENT_SUBJECT_ID,
775
+ subjectOverride,
271
776
  kind: "dynamic",
272
777
  fetchFn: fetchCcFn,
273
778
  skipCache,
274
779
  warnings,
780
+ signal: options.signal,
275
781
  });
276
782
  if (result.kind !== "none")
277
783
  return result;
@@ -283,7 +789,7 @@ async function resolveAgentCredentials(options = {}) {
283
789
  // blip. Keep the persisted creds and report the transient failure instead.
284
790
  if (result.authRejected) {
285
791
  warnings.push(`Persisted dynamic agent credentials rejected (${result.reason}); re-registering.`);
286
- clearDynamicFn();
792
+ clearDynamicFn(harnessKey, sessionKey);
287
793
  }
288
794
  else {
289
795
  warnings.push(`Persisted dynamic agent credentials temporarily unavailable (${result.reason}); keeping them for the next session.`);
@@ -291,32 +797,58 @@ async function resolveAgentCredentials(options = {}) {
291
797
  }
292
798
  }
293
799
  }
294
- // 4. Fresh dynamic registration — bootstrap using the user's bearer
800
+ // 3. Fresh dynamic registration — bootstrap using the user's bearer
295
801
  // or an out-of-band initial access token.
296
802
  const iat = options.userToken ?? env.ORY_AGENT_REGISTRATION_TOKEN;
297
- if (options.projectUrl && iat) {
803
+ if (options.allowRegistration !== false && options.projectUrl && iat) {
298
804
  try {
299
805
  const registered = await registerFn({
300
806
  projectUrl: options.projectUrl,
301
807
  auth: iat,
302
808
  harness: options.harness,
809
+ sessionId: sessionKey === config_js_1.SESSIONLESS_KEY ? undefined : sessionKey,
810
+ signal: options.signal,
811
+ ...(options.userSubject
812
+ ? {
813
+ delegation: {
814
+ delegatedBy: `user:${options.userSubject}`,
815
+ delegationType: "user-to-agent",
816
+ },
817
+ }
818
+ : {}),
303
819
  });
304
820
  if (!registered.clientSecret) {
305
821
  warnings.push("Dynamic registration returned no client_secret; cannot run client_credentials grant.");
306
822
  }
307
823
  else {
308
- saveDynamicFn(registered);
824
+ // Double-checked registration: a concurrent instance of this same
825
+ // harness may have claimed the slot while our POST was in flight. The
826
+ // claim re-reads under the config lock; if we lost, adopt the peer's
827
+ // client and revoke the one we just minted so it isn't orphaned.
828
+ const claim = claimRegistration(harnessKey, sessionKey, registered, {
829
+ loadDynamicFn,
830
+ saveDynamicFn,
831
+ useSharedStore: !options.loadDynamicFn && !options.saveDynamicFn,
832
+ });
833
+ if (claim.peer) {
834
+ warnings.push(`A concurrent process registered agent client ${claim.credentials.clientId} for ${harnessKey}/${sessionKey} first; adopting it and revoking ${registered.clientId}.`);
835
+ await revokeFn(registered).catch(() => {
836
+ /* best-effort cleanup; a failed revoke leaves an orphan, never an error */
837
+ });
838
+ }
839
+ const adopted = claim.credentials;
309
840
  const result = await resolveClientCredentials({
310
841
  projectUrl: options.projectUrl,
311
- clientId: registered.clientId,
312
- clientSecret: registered.clientSecret,
842
+ clientId: adopted.clientId,
843
+ clientSecret: adopted.clientSecret ?? registered.clientSecret,
313
844
  audience: env.ORY_AGENT_AUDIENCE,
314
845
  scope: env.ORY_AGENT_SCOPE,
315
- subjectOverride: env.ORY_AGENT_SUBJECT_ID,
846
+ subjectOverride,
316
847
  kind: "dynamic",
317
848
  fetchFn: fetchCcFn,
318
849
  skipCache: true, // freshly registered, no stale cache
319
850
  warnings,
851
+ signal: options.signal,
320
852
  });
321
853
  if (result.kind !== "none")
322
854
  return result;
@@ -327,24 +859,32 @@ async function resolveAgentCredentials(options = {}) {
327
859
  warnings.push(`Dynamic client registration failed: ${err instanceof Error ? err.message : String(err)}`);
328
860
  }
329
861
  }
330
- // 5. Deprecated ORY_API_KEY alias.
331
- const legacyApiKey = env.ORY_API_KEY;
332
- if (legacyApiKey && legacyApiKey.length > 0) {
333
- warnings.push("ORY_API_KEY is deprecated for agent authentication — set ORY_AGENT_API_KEY instead");
334
- return {
335
- kind: "api_key",
336
- token: legacyApiKey,
337
- subject: env.ORY_AGENT_SUBJECT_ID,
338
- reason: "Resolved agent identity from ORY_API_KEY (deprecated alias)",
339
- warnings,
340
- };
341
- }
342
862
  return {
343
863
  kind: "none",
344
- reason: "No agent credentials configured. Run with a user session (ORY_USER_LOGIN=true) or set ORY_AGENT_API_KEY / ORY_AGENT_CLIENT_ID + ORY_AGENT_CLIENT_SECRET / ORY_AGENT_REGISTRATION_TOKEN.",
864
+ reason: "No agent credentials configured. Run with a user session (the interactive user login runs every session) or set ORY_AGENT_CLIENT_ID + ORY_AGENT_CLIENT_SECRET / ORY_AGENT_REGISTRATION_TOKEN.",
345
865
  warnings,
346
866
  };
347
867
  }
868
+ /**
869
+ * Claim the `(projectUrl, harness, session)` slot for a freshly registered client.
870
+ *
871
+ * Against the real config file this is {@link claimAgentDynamicCredentials},
872
+ * whose read and write share one `mutateConfig` lock — the only way the loser
873
+ * of a same-harness race reliably sees the winner. When the caller injected its
874
+ * own credential store (tests), that lock isn't ours to take, so the same
875
+ * check runs against the injected load/save pair instead.
876
+ */
877
+ function claimRegistration(harness, sessionKey, registered, store) {
878
+ if (store.useSharedStore) {
879
+ return claimAgentDynamicCredentials(harness, sessionKey, registered);
880
+ }
881
+ const existing = store.loadDynamicFn(harness, sessionKey);
882
+ if (isUsablePeer(existing, registered)) {
883
+ return { credentials: existing, peer: true };
884
+ }
885
+ store.saveDynamicFn(harness, sessionKey, registered);
886
+ return { credentials: registered, peer: false };
887
+ }
348
888
  async function resolveClientCredentials(args) {
349
889
  const cacheKey = `${args.kind}:${args.clientId}@${args.projectUrl}`;
350
890
  const nowSec = Math.floor(Date.now() / 1000);
@@ -369,6 +909,7 @@ async function resolveClientCredentials(args) {
369
909
  clientSecret: args.clientSecret,
370
910
  audience: args.audience,
371
911
  scope: args.scope,
912
+ signal: args.signal,
372
913
  });
373
914
  ccCache.set(cacheKey, {
374
915
  token: result.accessToken,
@@ -428,6 +969,7 @@ async function fetchClientCredentialsToken(args) {
428
969
  method: "POST",
429
970
  headers: { "content-type": "application/x-www-form-urlencoded" },
430
971
  body: body.toString(),
972
+ signal: args.signal,
431
973
  });
432
974
  if (!res.ok) {
433
975
  const err = new Error(`HTTP ${res.status}`);
@@ -449,34 +991,48 @@ async function fetchClientCredentialsToken(args) {
449
991
  * Resolve the agent's credentials and attach them to the client.
450
992
  *
451
993
  * Always returns the resolved credentials; never throws and never blocks.
452
- * Emits exactly one `agent.auth` span so the audit trail records whether
994
+ * Emits exactly one `agent.auth` activity event so the audit trail records whether
453
995
  * the agent identity was available for the session.
454
996
  */
455
997
  async function ensureAgentIdentity(client, options = {}) {
456
- // Audit-only kill switch: Ory is disabled entirely — skip credential
457
- // resolution (no DCR, no token grant) and record the no-op, shaped like
458
- // the user gate's audit_only result.
459
- if ((0, config_js_1.resolveConfig)().auditOnly) {
998
+ const sessionId = options.sessionId ?? client.sessionId;
999
+ // Agent Security isn't connected skip credential resolution entirely
1000
+ // (no DCR, no token grant) and record the no-op, shaped like the user
1001
+ // gate's not_connected result.
1002
+ const resolvedConfig = (0, config_js_1.resolveConfig)();
1003
+ const security = resolvedConfig.security;
1004
+ if (!security.connected) {
460
1005
  const creds = {
461
1006
  kind: "none",
462
- reason: "Configured for audit-only mode; agent identity resolution is a no-op",
1007
+ reason: `Ory Agent Security is not connected (${(0, config_js_1.describeSecurityGap)(security)}); agent identity resolution is a no-op`,
463
1008
  warnings: [],
464
1009
  };
465
- client.tracer.record("agent.auth", "skipped", {
466
- attributes: { kind: creds.kind, reason: creds.reason, auditOnly: true },
1010
+ client.logger.activity("agent.auth", "skipped", {
1011
+ attributes: { kind: creds.kind, reason: creds.reason, securityConnected: false },
467
1012
  });
468
1013
  return creds;
469
1014
  }
470
- const resolveFn = options.resolveFn ?? resolveAgentCredentials;
1015
+ const resolveFn = options.resolveFn ?? resolveRuntimeAgentCredentials;
1016
+ const projectUrl = options.projectUrl ?? (0, config_js_1.resolveConfig)().projectUrl;
1017
+ let credentialStore = options.credentialStore;
1018
+ if (!credentialStore) {
1019
+ try {
1020
+ credentialStore = (0, runtime_credential_js_1.createOsTalosCredentialStore)();
1021
+ }
1022
+ catch {
1023
+ // The resolver and enrollment path report a safe warning below.
1024
+ }
1025
+ }
471
1026
  let creds;
472
1027
  try {
473
1028
  creds = await resolveFn({
474
- projectUrl: options.projectUrl,
1029
+ projectUrl,
475
1030
  env: options.env,
476
- // The user gate populates userPrincipal.token on successful login;
477
- // we hand it to the resolver so DCR can use it as the IAT.
478
- userToken: client.userPrincipal.token,
479
- harness: options.harness,
1031
+ signal: options.signal,
1032
+ runtimeCredential: client.runtimeCredential,
1033
+ credentialStore,
1034
+ harness: options.harness ?? client.harness,
1035
+ sessionId,
480
1036
  });
481
1037
  }
482
1038
  catch (err) {
@@ -486,110 +1042,320 @@ async function ensureAgentIdentity(client, options = {}) {
486
1042
  warnings: [],
487
1043
  };
488
1044
  }
1045
+ // Only the session-start gate may issue a credential. Tool/read subprocesses
1046
+ // can load from the OS store but must never turn a read into a write.
1047
+ const userToken = client.userPrincipalForSession(sessionId).token?.trim();
1048
+ if (creds.kind === "none" && options.trigger === undefined && userToken && projectUrl) {
1049
+ try {
1050
+ (0, runtime_credential_js_1.canonicalProjectUrl)(projectUrl);
1051
+ if (!credentialStore) {
1052
+ creds.warnings.push(`Automatic Talos enrollment is unavailable because ${process.platform} has no supported OS credential store`);
1053
+ }
1054
+ else {
1055
+ const enroll = options.enrollFn ?? runtime_credential_js_1.enrollTalosRuntimeCredential;
1056
+ const identity = {
1057
+ projectUrl,
1058
+ harness: options.harness?.trim() || client.harness || config_js_1.UNKNOWN_HARNESS_KEY,
1059
+ sessionId: sessionId?.trim() || config_js_1.SESSIONLESS_KEY,
1060
+ kind: "agent",
1061
+ };
1062
+ const enrollmentOptions = {
1063
+ projectUrl,
1064
+ agentSecurityUrl: resolvedConfig.agentSecurityUrl,
1065
+ userToken,
1066
+ name: (0, branding_js_1.agentClientName)({
1067
+ harness: options.harness ?? client.harness,
1068
+ host: (0, node_os_1.hostname)(),
1069
+ sessionId,
1070
+ }),
1071
+ idempotencyKey: `talos-runtime-${(0, runtime_credential_js_1.credentialAccount)(identity)}`,
1072
+ signal: options.signal,
1073
+ credentialStore,
1074
+ identity,
1075
+ };
1076
+ let issued = false;
1077
+ const enrolled = await withEnrollmentLock(identity, async () => {
1078
+ const peer = await credentialStore.load(identity);
1079
+ if (peer) {
1080
+ return {
1081
+ actorId: peer.actorId,
1082
+ credential: (0, runtime_credential_js_1.talosRuntimeCredential)(peer.credential, { subject: peer.actorId }),
1083
+ warnings: [],
1084
+ };
1085
+ }
1086
+ issued = true;
1087
+ return enroll(enrollmentOptions);
1088
+ });
1089
+ creds = {
1090
+ kind: "talos",
1091
+ runtimeCredential: enrolled.credential,
1092
+ subject: enrolled.actorId,
1093
+ reason: issued
1094
+ ? "Enrolled a Talos runtime credential using the authenticated user"
1095
+ : "Using the Talos runtime credential enrolled by a concurrent session process",
1096
+ warnings: [...creds.warnings, ...enrolled.warnings],
1097
+ };
1098
+ }
1099
+ }
1100
+ catch (error) {
1101
+ const status = typeof error === "object" && error !== null
1102
+ && typeof error.status === "number"
1103
+ ? ` (HTTP ${error.status})`
1104
+ : "";
1105
+ creds.warnings.push(`Could not enroll a Talos runtime credential${status}`);
1106
+ }
1107
+ }
1108
+ if (creds.runtimeCredential && (creds.kind === "talos" || creds.kind === "injected")) {
1109
+ client.setRuntimeCredential(creds.runtimeCredential);
1110
+ if (creds.subject)
1111
+ client.setAgentPrincipal({ subject: creds.subject }, sessionId);
1112
+ }
1113
+ else if (creds.kind !== "none") {
1114
+ creds = {
1115
+ kind: "none",
1116
+ reason: `Legacy ${creds.kind} credentials are not accepted for runtime authentication`,
1117
+ warnings: creds.warnings,
1118
+ };
1119
+ }
489
1120
  for (const warning of creds.warnings) {
490
1121
  client.logger.warn("agent.auth.warning", { message: warning });
491
1122
  }
492
1123
  if (creds.kind === "none") {
493
1124
  client.logger.warn("agent.auth.none", { reason: creds.reason });
494
1125
  }
495
- if (creds.token) {
496
- client.setAgentPrincipal({ token: creds.token, subject: creds.subject });
497
- }
498
- // When no credentials were resolved, leave the existing principal in
499
- // place. The constructor may have seeded one from `config.apiKey` for
500
- // backward compat, and "no resolver match" should not clear it.
501
- client.tracer.record("agent.auth", creds.kind === "none" ? "skipped" : "ok", {
1126
+ // When no credentials were resolved, leave the existing principal in place —
1127
+ // a caller may have set one explicitly via `setAgentPrincipal`, and "no
1128
+ // resolver match" should not clear it. With nothing set, the read path falls
1129
+ // back to the rehydrated user token (see `buildApis`).
1130
+ const sessionKey = sessionId?.trim() || config_js_1.SESSIONLESS_KEY;
1131
+ client.logger.activity("agent.auth", creds.kind === "none" ? "skipped" : "ok", {
502
1132
  attributes: {
503
1133
  kind: creds.kind,
504
1134
  reason: creds.reason,
1135
+ sessionKey,
1136
+ ...(options.trigger ? { trigger: options.trigger } : {}),
505
1137
  ...(creds.subject ? { subject: creds.subject } : {}),
506
1138
  ...(creds.expiresAt ? { expiresAt: creds.expiresAt } : {}),
1139
+ // Warnings were collected all along and never recorded, so a resolver
1140
+ // that half-worked looked identical to one that worked — which is what
1141
+ // made the rejected-DCR-metadata failure invisible in #182, and what
1142
+ // would hide a rejected subject override here (#241).
1143
+ ...(creds.warnings?.length ? { warnings: creds.warnings } : {}),
507
1144
  },
508
1145
  });
509
1146
  return creds;
510
1147
  }
511
1148
  /**
512
- * Resolve a sub-agent identity via OAuth2 Dynamic Client Registration.
1149
+ * Revoke retired registrations and drop the successful ones from the queue.
513
1150
  *
514
- * Reuses persisted credentials when the `(projectUrl, subAgentType)`
515
- * pair matches; otherwise registers a fresh client with
516
- * `client_name = ory-subagent-<type>@<host>` and persists the result.
517
- * Never throws and never blocks — on any failure the identity collapses
518
- * to `kind: "none"` and the plugin continues without it. Emits exactly
519
- * one `agent.auth` span so the audit trail records each sub-agent
520
- * resolution.
1151
+ * Best-effort throughout: a failed revoke leaves the entry queued for the next
1152
+ * run (and an orphaned client that uninstall or an operator can still prune),
1153
+ * never an error on the session-start path.
1154
+ */
1155
+ async function drainRetiredCredentials(client, opts) {
1156
+ let queued = [];
1157
+ try {
1158
+ queued = loadRetiredCredentials();
1159
+ }
1160
+ catch (err) {
1161
+ client.logger.warn("agent.retired.read_failed", {
1162
+ message: err instanceof Error ? err.message : String(err),
1163
+ });
1164
+ return;
1165
+ }
1166
+ if (queued.length === 0)
1167
+ return;
1168
+ const revoke = opts.revokeFn ?? revokeAgentDynamicClient;
1169
+ const revoked = [];
1170
+ for (const creds of queued) {
1171
+ const outcome = await revoke(creds).catch(() => ({ status: "failed" }));
1172
+ // `deleted` (including a 404 — already gone) is done, and so is `skipped`:
1173
+ // that means the record carries no RFC 7592 management credentials, so no
1174
+ // future run could revoke it either and keeping it queued would leak a
1175
+ // growing list into the config file forever. Only a real failure retries.
1176
+ if (outcome.status !== "failed")
1177
+ revoked.push(creds.clientId);
1178
+ client.logger.debug("agent.retired.revoked", {
1179
+ clientId: creds.clientId,
1180
+ revoke: outcome.status,
1181
+ });
1182
+ }
1183
+ try {
1184
+ clearRetiredCredentials(revoked);
1185
+ }
1186
+ catch (err) {
1187
+ client.logger.warn("agent.retired.clear_failed", {
1188
+ message: err instanceof Error ? err.message : String(err),
1189
+ });
1190
+ }
1191
+ client.logger.activity("agent.retired.drain", "ok", {
1192
+ attributes: { queued: queued.length, revoked: revoked.length },
1193
+ });
1194
+ }
1195
+ /**
1196
+ * Resolve a sub-agent against the process runtime credential. The broker's
1197
+ * `type_name` supplies sub-agent attribution; no separate OAuth identity is
1198
+ * registered, loaded, minted, or persisted.
521
1199
  */
522
1200
  async function ensureSubAgentIdentity(client, options) {
523
- // Audit-only kill switch: same no-op as ensureAgentIdentity — no
524
- // sub-agent DCR while Ory is disabled entirely.
525
- if ((0, config_js_1.resolveConfig)().auditOnly) {
1201
+ // Same no-op as ensureAgentIdentity — no sub-agent DCR while Agent
1202
+ // Security is not connected.
1203
+ const resolvedConfig = (0, config_js_1.resolveConfig)();
1204
+ const subAgentSecurity = resolvedConfig.security;
1205
+ if (!subAgentSecurity.connected) {
526
1206
  const identity = {
527
1207
  kind: "none",
528
1208
  subAgentType: options.subAgentType,
529
- reason: "Configured for audit-only mode; sub-agent identity resolution is a no-op",
1209
+ reason: `Ory Agent Security is not connected (${(0, config_js_1.describeSecurityGap)(subAgentSecurity)}); sub-agent identity resolution is a no-op`,
530
1210
  warnings: [],
531
1211
  };
532
- client.tracer.record("agent.auth", "skipped", {
533
- attributes: {
534
- kind: "subagent_none",
535
- subAgentType: identity.subAgentType,
536
- reason: identity.reason,
537
- auditOnly: true,
538
- },
539
- });
1212
+ if (options.emitActivity !== false) {
1213
+ client.logger.activity("agent.auth", "skipped", {
1214
+ attributes: {
1215
+ kind: "subagent_none",
1216
+ subAgentType: identity.subAgentType,
1217
+ reason: identity.reason,
1218
+ securityConnected: false,
1219
+ },
1220
+ });
1221
+ }
540
1222
  return identity;
541
1223
  }
542
- const env = options.env ?? process.env;
543
- const registerFn = options.registerAgentClientFn ?? registerAgentClient;
544
- const loadFn = options.loadFn ?? loadSubAgentDynamicCredentials;
545
- const saveFn = options.saveFn ?? saveSubAgentDynamicCredentials;
546
- const warnings = [];
547
- let identity;
548
- try {
549
- identity = await resolveSubAgentIdentity({
1224
+ const projectUrl = options.projectUrl ?? resolvedConfig.projectUrl;
1225
+ const sessionId = options.sessionId ?? client.sessionId;
1226
+ const perSpawnId = options.perSpawnId?.trim();
1227
+ if (!projectUrl || !perSpawnId) {
1228
+ return {
1229
+ kind: "none",
550
1230
  subAgentType: options.subAgentType,
551
- projectUrl: options.projectUrl,
552
- harness: options.harness,
553
- iat: options.iat
554
- ?? client.agentPrincipal.token
555
- ?? client.userPrincipal.token
556
- ?? env.ORY_AGENT_REGISTRATION_TOKEN,
557
- registerFn,
558
- loadFn,
559
- saveFn,
560
- warnings,
1231
+ reason: "A project and stable spawn identifier are required for a child identity",
1232
+ warnings: [],
1233
+ };
1234
+ }
1235
+ const credentialIdentity = {
1236
+ projectUrl,
1237
+ harness: options.harness?.trim() || client.harness || config_js_1.UNKNOWN_HARNESS_KEY,
1238
+ sessionId: sessionId?.trim() || config_js_1.SESSIONLESS_KEY,
1239
+ kind: "subagent",
1240
+ subAgentType: options.subAgentType,
1241
+ perSpawnId,
1242
+ };
1243
+ let store = options.credentialStore;
1244
+ try {
1245
+ store ??= (0, runtime_credential_js_1.createOsTalosCredentialStore)();
1246
+ const stored = await store.load(credentialIdentity);
1247
+ if (stored) {
1248
+ const runtimeCredential = (0, runtime_credential_js_1.talosRuntimeCredential)(stored.credential, { subject: stored.actorId });
1249
+ return {
1250
+ kind: "runtime",
1251
+ subAgentType: options.subAgentType,
1252
+ subject: stored.actorId,
1253
+ runtimeCredential,
1254
+ reason: "Using the child Talos credential from the OS credential store",
1255
+ warnings: [],
1256
+ };
1257
+ }
1258
+ }
1259
+ catch {
1260
+ // Enrollment below reports a safe outcome if native persistence is unavailable.
1261
+ }
1262
+ let parentCredential = client.runtimeCredential;
1263
+ if (!parentCredential) {
1264
+ const parent = await resolveRuntimeAgentCredentials({
1265
+ projectUrl,
1266
+ harness: credentialIdentity.harness,
1267
+ sessionId: credentialIdentity.sessionId,
1268
+ credentialStore: store,
1269
+ env: options.env,
561
1270
  });
1271
+ parentCredential = parent.runtimeCredential;
562
1272
  }
563
- catch (err) {
1273
+ let identity;
1274
+ if (options.allowEnrollment !== false && parentCredential && store) {
1275
+ try {
1276
+ const enroll = options.enrollFn ?? runtime_credential_js_1.enrollTalosChildRuntimeCredential;
1277
+ const enrollmentOptions = {
1278
+ projectUrl,
1279
+ agentSecurityUrl: resolvedConfig.agentSecurityUrl,
1280
+ name: (0, branding_js_1.agentClientName)({
1281
+ harness: credentialIdentity.harness,
1282
+ host: (0, node_os_1.hostname)(),
1283
+ sessionId: credentialIdentity.sessionId,
1284
+ subAgentType: options.subAgentType,
1285
+ }),
1286
+ subAgentType: options.subAgentType,
1287
+ idempotencyKey: `talos-child-${(0, runtime_credential_js_1.credentialAccount)(credentialIdentity)}`,
1288
+ parentCredential,
1289
+ identity: credentialIdentity,
1290
+ credentialStore: store,
1291
+ signal: options.signal,
1292
+ };
1293
+ let issued = false;
1294
+ const enrolled = await withEnrollmentLock(credentialIdentity, async () => {
1295
+ const peer = await store.load(credentialIdentity);
1296
+ if (peer) {
1297
+ return {
1298
+ actorId: peer.actorId,
1299
+ credential: (0, runtime_credential_js_1.talosRuntimeCredential)(peer.credential, { subject: peer.actorId }),
1300
+ warnings: [],
1301
+ };
1302
+ }
1303
+ issued = true;
1304
+ return enroll(enrollmentOptions);
1305
+ });
1306
+ identity = {
1307
+ kind: "runtime",
1308
+ subAgentType: options.subAgentType,
1309
+ subject: enrolled.actorId,
1310
+ runtimeCredential: enrolled.credential,
1311
+ reason: issued
1312
+ ? "Enrolled a child Talos credential using the parent runtime"
1313
+ : "Using the child Talos credential enrolled by a concurrent process",
1314
+ warnings: enrolled.warnings,
1315
+ };
1316
+ }
1317
+ catch (error) {
1318
+ const status = typeof error === "object" && error !== null
1319
+ && typeof error.status === "number"
1320
+ ? ` (HTTP ${error.status})`
1321
+ : "";
1322
+ identity = {
1323
+ kind: "none",
1324
+ subAgentType: options.subAgentType,
1325
+ reason: `Child Talos enrollment failed${status}`,
1326
+ warnings: [],
1327
+ };
1328
+ }
1329
+ }
1330
+ else {
564
1331
  identity = {
565
1332
  kind: "none",
566
1333
  subAgentType: options.subAgentType,
567
- reason: `Sub-agent identity resolution threw: ${err instanceof Error ? err.message : String(err)}`,
568
- warnings,
1334
+ reason: "No child Talos credential is stored and parent-authenticated enrollment is unavailable",
1335
+ warnings: [],
569
1336
  };
570
1337
  }
571
- for (const warning of identity.warnings) {
572
- client.logger.warn("agent.auth.warning", {
573
- message: warning,
574
- subAgentType: options.subAgentType,
575
- });
576
- }
577
- if (identity.kind === "none") {
578
- client.logger.warn("agent.auth.none", {
579
- reason: identity.reason,
580
- subAgentType: options.subAgentType,
1338
+ if (options.emitActivity !== false) {
1339
+ client.logger.activity("agent.auth", identity.kind === "runtime" ? "ok" : "skipped", {
1340
+ attributes: {
1341
+ kind: identity.kind === "runtime" ? "subagent_runtime" : "subagent_none",
1342
+ subAgentType: identity.subAgentType,
1343
+ reason: identity.reason,
1344
+ ...(identity.subject ? { subject: identity.subject } : {}),
1345
+ },
581
1346
  });
582
1347
  }
583
- client.tracer.record("agent.auth", identity.kind === "none" ? "skipped" : "ok", {
584
- attributes: {
585
- kind: identity.kind === "dynamic" ? "subagent_dynamic" : "subagent_none",
586
- subAgentType: identity.subAgentType,
587
- reason: identity.reason,
588
- ...(identity.subject ? { subject: identity.subject } : {}),
589
- },
590
- });
591
1348
  return identity;
592
1349
  }
1350
+ /** {@link claimRegistration} for sub-agents, against an injected store. */
1351
+ function claimViaInjectedSubAgentStore(harness, args, registered) {
1352
+ const existing = args.loadFn(harness, args.sessionKey, args.subAgentType);
1353
+ if (isUsablePeer(existing, registered)) {
1354
+ return { credentials: existing, peer: true };
1355
+ }
1356
+ args.saveFn(harness, args.sessionKey, args.subAgentType, registered);
1357
+ return { credentials: registered, peer: false };
1358
+ }
593
1359
  async function resolveSubAgentIdentity(args) {
594
1360
  if (!args.projectUrl) {
595
1361
  return {
@@ -599,8 +1365,12 @@ async function resolveSubAgentIdentity(args) {
599
1365
  warnings: args.warnings,
600
1366
  };
601
1367
  }
602
- // 1. Re-use persisted credentials if they match the current project URL.
603
- const persisted = args.loadFn(args.subAgentType);
1368
+ // 1. Re-use persisted credentials matching this `(projectUrl, harness,
1369
+ // subAgentType)` triple. Keying on the type alone would give two
1370
+ // harnesses' same-named sub-agents (both call theirs `Explore`) one
1371
+ // shared identity, and the audit trail could no longer tell them apart.
1372
+ const harnessKey = args.harness?.trim() || config_js_1.UNKNOWN_HARNESS_KEY;
1373
+ const persisted = args.loadFn(harnessKey, args.sessionKey, args.subAgentType);
604
1374
  if (persisted && persisted.projectUrl === args.projectUrl) {
605
1375
  return {
606
1376
  kind: "dynamic",
@@ -623,24 +1393,50 @@ async function resolveSubAgentIdentity(args) {
623
1393
  warnings: args.warnings,
624
1394
  };
625
1395
  }
626
- // 3. Fresh DCR. client_name is namespaced under sub-agent so audit logs
627
- // can distinguish it from the top-level agent registration.
628
- const subHarness = args.harness
629
- ? `${args.harness}-subagent-${args.subAgentType}`
630
- : `subagent-${args.subAgentType}`;
1396
+ // 3. Fresh DCR. The harness and the type go over as separate fields, which is
1397
+ // what makes `agentClientName` emit a sub-agent name
1398
+ // ("… · sub-agent · <harness>/<type> @ <host>"). Folding the type into the
1399
+ // harness instead would register the sub-agent under an *agent* name, and
1400
+ // that name is the only thing tying the client back to the delegation edge
1401
+ // the broker keys on (harness, host, type).
631
1402
  try {
632
1403
  const registered = await args.registerFn({
633
1404
  projectUrl: args.projectUrl,
634
1405
  auth: args.iat,
635
- harness: subHarness,
1406
+ harness: args.harness,
1407
+ subAgentType: args.subAgentType,
1408
+ sessionId: args.sessionKey === config_js_1.SESSIONLESS_KEY ? undefined : args.sessionKey,
1409
+ signal: args.signal,
1410
+ ...(args.delegatorSubject
1411
+ ? {
1412
+ delegation: {
1413
+ delegatedBy: `agent:${args.delegatorSubject}`,
1414
+ delegationType: "agent-to-subagent",
1415
+ subAgentType: args.subAgentType,
1416
+ },
1417
+ }
1418
+ : {}),
636
1419
  });
637
- args.saveFn(args.subAgentType, registered);
1420
+ // Same double-checked claim as the agent path: a concurrent process of
1421
+ // this harness may have registered the sub-agent while our POST was in
1422
+ // flight. Adopt the peer's client and revoke ours rather than orphan one.
1423
+ const claim = args.useSharedStore
1424
+ ? claimSubAgentDynamicCredentials(harnessKey, args.sessionKey, args.subAgentType, registered)
1425
+ : claimViaInjectedSubAgentStore(harnessKey, args, registered);
1426
+ if (claim.peer) {
1427
+ args.warnings.push(`A concurrent process registered sub-agent ${args.subAgentType} (${claim.credentials.clientId}) for this session first; adopting it and revoking ${registered.clientId}.`);
1428
+ await args.revokeFn(registered).catch(() => {
1429
+ /* best-effort cleanup; a failed revoke leaves an orphan, never an error */
1430
+ });
1431
+ }
638
1432
  return {
639
1433
  kind: "dynamic",
640
1434
  subAgentType: args.subAgentType,
641
- subject: registered.clientId,
642
- credentials: registered,
643
- reason: `Registered sub-agent ${args.subAgentType} via DCR (RFC 7591)`,
1435
+ subject: claim.credentials.clientId,
1436
+ credentials: claim.credentials,
1437
+ reason: claim.peer
1438
+ ? `Adopted concurrently registered sub-agent ${args.subAgentType}`
1439
+ : `Registered sub-agent ${args.subAgentType} via DCR (RFC 7591)`,
644
1440
  warnings: args.warnings,
645
1441
  };
646
1442
  }