@ory/argus 0.14.0 → 1.0.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.
Files changed (150) hide show
  1. package/README.md +31 -46
  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 +39 -91
  6. package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -17
  7. package/assets/skills/ory-temporal-worker/SKILL.md +31 -33
  8. package/assets/skills/permissions-onboarding/SKILL.md +131 -104
  9. package/dist/adapters.d.ts +88 -32
  10. package/dist/adapters.js +443 -164
  11. package/dist/agent-auth.d.ts +226 -67
  12. package/dist/agent-auth.js +951 -205
  13. package/dist/auth-store.d.ts +37 -2
  14. package/dist/auth-store.js +37 -3
  15. package/dist/auth.d.ts +33 -4
  16. package/dist/auth.js +163 -20
  17. package/dist/bash-parser.d.ts +98 -0
  18. package/dist/bash-parser.js +396 -0
  19. package/dist/branding.d.ts +77 -16
  20. package/dist/branding.js +93 -23
  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 -239
  26. package/dist/client.d.ts +175 -138
  27. package/dist/client.js +672 -391
  28. package/dist/config.d.ts +217 -54
  29. package/dist/config.js +461 -42
  30. package/dist/context.d.ts +10 -0
  31. package/dist/context.js +21 -0
  32. package/dist/contract-suite.d.ts +3 -5
  33. package/dist/contract-suite.js +75 -47
  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 +28 -23
  47. package/dist/index.js +173 -58
  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.js +7 -7
  54. package/dist/local/index.d.ts +2 -2
  55. package/dist/local/index.js +24 -10
  56. package/dist/local/manager.d.ts +20 -1
  57. package/dist/local/manager.js +159 -36
  58. package/dist/local/ports.d.ts +158 -0
  59. package/dist/local/ports.js +443 -0
  60. package/dist/local/seed.d.ts +13 -5
  61. package/dist/local/seed.js +62 -38
  62. package/dist/logger.d.ts +54 -25
  63. package/dist/logger.js +329 -63
  64. package/dist/mcp.d.ts +2 -2
  65. package/dist/mcp.js +10 -5
  66. package/dist/mirror-bootstrap.d.ts +48 -0
  67. package/dist/mirror-bootstrap.js +254 -0
  68. package/dist/opl.d.ts +289 -0
  69. package/dist/opl.js +446 -0
  70. package/dist/permission-mode.d.ts +87 -0
  71. package/dist/permission-mode.js +307 -0
  72. package/dist/permissions-cli.d.ts +13 -49
  73. package/dist/permissions-cli.js +154 -348
  74. package/dist/permissions.d.ts +148 -38
  75. package/dist/permissions.js +591 -45
  76. package/dist/post-install.d.ts +33 -0
  77. package/dist/post-install.js +127 -0
  78. package/dist/read-credential.d.ts +65 -0
  79. package/dist/read-credential.js +86 -0
  80. package/dist/registry/cli.js +5 -2
  81. package/dist/registry/config.d.ts +0 -17
  82. package/dist/registry/config.js +0 -23
  83. package/dist/registry/index.d.ts +1 -1
  84. package/dist/registry/index.js +2 -2
  85. package/dist/registry/manager.d.ts +4 -21
  86. package/dist/registry/manager.js +21 -37
  87. package/dist/runtime-credential.d.ts +140 -0
  88. package/dist/runtime-credential.js +572 -0
  89. package/dist/runtime.d.ts +413 -0
  90. package/dist/runtime.js +825 -0
  91. package/dist/setup.d.ts +23 -47
  92. package/dist/setup.js +59 -116
  93. package/dist/skills.js +0 -7
  94. package/dist/status-cli.d.ts +27 -11
  95. package/dist/status-cli.js +121 -115
  96. package/dist/status-data.d.ts +116 -17
  97. package/dist/status-data.js +121 -38
  98. package/dist/subject.d.ts +126 -20
  99. package/dist/subject.js +215 -30
  100. package/dist/testing.d.ts +74 -38
  101. package/dist/testing.js +185 -68
  102. package/dist/tool-catalog.d.ts +53 -11
  103. package/dist/tool-catalog.js +164 -13
  104. package/dist/tool-metadata.d.ts +7 -6
  105. package/dist/tool-metadata.js +6 -5
  106. package/dist/types.d.ts +11 -1
  107. package/dist/uninstall.d.ts +71 -34
  108. package/dist/uninstall.js +217 -85
  109. package/dist/user-login.d.ts +9 -10
  110. package/dist/user-login.js +56 -75
  111. package/dist/watch-cli.d.ts +6 -0
  112. package/dist/watch-cli.js +217 -0
  113. package/package.json +4 -27
  114. package/assets/commands/dashboard.md +0 -34
  115. package/dist/dashboard-cli.d.ts +0 -8
  116. package/dist/dashboard-cli.js +0 -70
  117. package/dist/dev.d.ts +0 -103
  118. package/dist/dev.js +0 -583
  119. package/dist/interactive-setup.d.ts +0 -286
  120. package/dist/interactive-setup.js +0 -1734
  121. package/dist/local/jaeger-main.d.ts +0 -13
  122. package/dist/local/jaeger-main.js +0 -85
  123. package/dist/local/jaeger.d.ts +0 -50
  124. package/dist/local/jaeger.js +0 -162
  125. package/dist/otel/exporter.d.ts +0 -17
  126. package/dist/otel/exporter.js +0 -12
  127. package/dist/otel/index.d.ts +0 -2
  128. package/dist/otel/index.js +0 -8
  129. package/dist/otel/otlp.d.ts +0 -103
  130. package/dist/otel/otlp.js +0 -385
  131. package/dist/project-api-key.d.ts +0 -69
  132. package/dist/project-api-key.js +0 -147
  133. package/dist/setup-actions.d.ts +0 -232
  134. package/dist/setup-actions.js +0 -507
  135. package/dist/tracer.d.ts +0 -190
  136. package/dist/tracer.js +0 -481
  137. package/dist/watch-sandbox.d.ts +0 -9
  138. package/dist/watch-sandbox.js +0 -81
  139. package/dist/web/api.d.ts +0 -33
  140. package/dist/web/api.js +0 -294
  141. package/dist/web/launch.d.ts +0 -11
  142. package/dist/web/launch.js +0 -96
  143. package/dist/web/server.d.ts +0 -20
  144. package/dist/web/server.js +0 -233
  145. package/dist/web/types.d.ts +0 -65
  146. package/dist/web/types.js +0 -2
  147. package/dist/webapp/assets/index-Wucl4SZs.css +0 -1
  148. package/dist/webapp/assets/index-m-GtEdq0.js +0 -49
  149. package/dist/webapp/favicon.ico +0 -0
  150. package/dist/webapp/index.html +0 -15
@@ -4,48 +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");
48
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
+ }
49
162
  /**
50
163
  * Skew window before nominal expiry at which a cached client_credentials
51
164
  * token is considered stale. Matches the user-token skew in auth-store.
@@ -56,67 +169,409 @@ const ccCache = new Map();
56
169
  function _resetAgentCredentialsCache() {
57
170
  ccCache.clear();
58
171
  }
59
- // ─── Sub-agent persistence ───────────────────────────────────────────
60
- /** Load persisted sub-agent DCR credentials keyed by sub-agent type. */
61
- function loadSubAgentDynamicCredentials(subAgentType) {
62
- 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];
63
190
  }
64
- /** Persist sub-agent DCR credentials under the given type key. */
65
- 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;
66
214
  (0, config_js_1.mutateConfig)((current) => {
67
215
  const agent = { ...(current.agent ?? {}) };
68
216
  const subAgents = { ...(agent.subAgents ?? {}) };
69
- subAgents[subAgentType] = creds;
217
+ const bySession = { ...(subAgents[harness] ?? {}) };
218
+ bySession[sessionKey] = { ...(bySession[sessionKey] ?? {}), [subAgentType]: creds };
219
+ subAgents[harness] = bySession;
70
220
  agent.subAgents = subAgents;
71
221
  return { ...current, agent };
72
222
  });
73
223
  }
74
- /** Remove persisted sub-agent DCR credentials for the given type. */
75
- function clearSubAgentDynamicCredentials(subAgentType) {
76
- (0, config_js_1.mutateConfig)((current) => {
77
- const next = { ...current };
78
- if (!next.agent?.subAgents)
79
- return next;
80
- const agent = { ...next.agent };
81
- const subAgents = { ...agent.subAgents };
82
- delete subAgents[subAgentType];
83
- if (Object.keys(subAgents).length === 0)
84
- 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];
85
235
  else
86
- agent.subAgents = subAgents;
87
- if (!agent.dynamic && !agent.subAgents)
88
- delete next.agent;
236
+ bySession[sessionKey] = byType;
237
+ if (Object.keys(bySession).length === 0)
238
+ delete subAgents[harness];
89
239
  else
90
- 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)
91
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
+ };
92
382
  });
93
383
  }
94
- // ─── Dynamic registration persistence ────────────────────────────────
95
- /** Load persisted DCR credentials from the shared config file. */
96
- function loadAgentDynamicCredentials() {
97
- return (0, config_js_1.loadConfig)().agent?.dynamic;
98
- }
99
- /** Persist DCR credentials. Existing credentials are replaced atomically. */
100
- function saveAgentDynamicCredentials(creds) {
101
- (0, config_js_1.mutateConfig)((current) => ({
102
- ...current,
103
- 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));
104
404
  }));
105
405
  }
106
- /** Remove persisted DCR credentials (e.g. after revocation or unregister). */
107
- 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;
108
473
  (0, config_js_1.mutateConfig)((current) => {
109
- const next = { ...current };
110
- if (!next.agent)
111
- return next;
112
- const agent = { ...next.agent };
113
- delete agent.dynamic;
114
- if (Object.keys(agent).length === 0)
115
- 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];
116
494
  else
117
- next.agent = agent;
118
- 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 };
119
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 };
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;
120
575
  }
121
576
  /**
122
577
  * Best-effort RFC 7592 `DELETE` of a dynamically-registered OAuth2 client.
@@ -157,7 +612,7 @@ async function revokeAgentDynamicClient(creds) {
157
612
  * either by the user's interactive token (the natural bootstrap) or by
158
613
  * a pre-issued initial access token (`ORY_AGENT_REGISTRATION_TOKEN`).
159
614
  * Throws on any non-2xx response so callers can decide whether to
160
- * retry, fall back, or surface the error in the audit span. The
615
+ * retry, fall back, or surface the error in audit activity. The
161
616
  * `HTTP <status>` error message shape is preserved so callers and tests
162
617
  * can pattern-match on it regardless of the underlying transport.
163
618
  */
@@ -167,6 +622,7 @@ async function registerAgentClient(args) {
167
622
  harness: args.harness,
168
623
  host,
169
624
  subAgentType: args.subAgentType,
625
+ sessionId: args.sessionId,
170
626
  });
171
627
  // Build the delegation anchor (if a delegator is known) and mirror it into the
172
628
  // persisted credentials. This is the integrity anchor the Keto tuple is derived
@@ -211,7 +667,10 @@ async function registerAgentClient(args) {
211
667
  // Configuration's `accessToken`. Ory projects that gate DCR behind an
212
668
  // initial access token then reject the unauthenticated request and the
213
669
  // agent never gets credentials. Send the bearer explicitly.
214
- args.auth ? { headers: { Authorization: `Bearer ${args.auth}` } } : undefined);
670
+ {
671
+ ...(args.auth ? { headers: { Authorization: `Bearer ${args.auth}` } } : {}),
672
+ ...(args.signal ? { signal: args.signal } : {}),
673
+ });
215
674
  }
216
675
  catch (err) {
217
676
  const status = err?.response?.status;
@@ -232,35 +691,38 @@ async function registerAgentClient(args) {
232
691
  registeredAt: Math.floor(Date.now() / 1000),
233
692
  projectUrl: args.projectUrl,
234
693
  harness: args.harness,
694
+ clientName: client.client_name ?? clientName,
235
695
  ...(delegation ? { delegation } : {}),
236
696
  };
237
697
  }
238
698
  /**
239
- * Resolve the agent's credentials from environment configuration and
240
- * persisted state. Never throws — failures collapse to `kind: "none"`
241
- * so the caller can fail open.
699
+ * Legacy OAuth/DCR resolver retained for source compatibility. Normal runtime
700
+ * gates use {@link resolveRuntimeAgentCredentials} instead.
242
701
  */
243
702
  async function resolveAgentCredentials(options = {}) {
244
703
  const env = options.env ?? process.env;
245
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);
246
708
  const fetchCcFn = options.fetchClientCredentialsTokenFn ?? fetchClientCredentialsToken;
247
709
  const registerFn = options.registerAgentClientFn ?? registerAgentClient;
248
710
  const loadDynamicFn = options.loadDynamicFn ?? loadAgentDynamicCredentials;
249
711
  const saveDynamicFn = options.saveDynamicFn ?? saveAgentDynamicCredentials;
250
712
  const clearDynamicFn = options.clearDynamicFn ?? clearAgentDynamicCredentials;
713
+ const revokeFn = options.revokeFn ?? revokeAgentDynamicClient;
251
714
  const skipCache = options.skipCache ?? false;
252
- // 1. Explicit static API key operator override.
253
- const apiKey = env.ORY_AGENT_API_KEY;
254
- if (apiKey && apiKey.length > 0) {
255
- return {
256
- kind: "api_key",
257
- token: apiKey,
258
- subject: env.ORY_AGENT_SUBJECT_ID,
259
- reason: "Resolved agent identity from ORY_AGENT_API_KEY",
260
- warnings,
261
- };
262
- }
263
- // 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.
264
726
  const clientId = env.ORY_AGENT_CLIENT_ID;
265
727
  const clientSecret = env.ORY_AGENT_CLIENT_SECRET;
266
728
  if (clientId && clientSecret) {
@@ -272,21 +734,37 @@ async function resolveAgentCredentials(options = {}) {
272
734
  };
273
735
  }
274
736
  return await resolveClientCredentials({
275
- projectUrl: options.projectUrl,
737
+ projectUrl: options.projectUrl ?? (0, config_js_1.resolveConfig)().projectUrl,
276
738
  clientId,
277
739
  clientSecret,
278
740
  audience: env.ORY_AGENT_AUDIENCE,
279
741
  scope: env.ORY_AGENT_SCOPE,
280
- subjectOverride: env.ORY_AGENT_SUBJECT_ID,
742
+ subjectOverride,
281
743
  kind: "client_credentials",
282
744
  fetchFn: fetchCcFn,
283
745
  skipCache,
284
746
  warnings,
747
+ signal: options.signal,
285
748
  });
286
749
  }
287
- // 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.
288
752
  if (options.projectUrl) {
289
- 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
+ }
290
768
  if (persisted && persisted.projectUrl === options.projectUrl && persisted.clientSecret) {
291
769
  const result = await resolveClientCredentials({
292
770
  projectUrl: options.projectUrl,
@@ -294,11 +772,12 @@ async function resolveAgentCredentials(options = {}) {
294
772
  clientSecret: persisted.clientSecret,
295
773
  audience: env.ORY_AGENT_AUDIENCE,
296
774
  scope: env.ORY_AGENT_SCOPE,
297
- subjectOverride: env.ORY_AGENT_SUBJECT_ID,
775
+ subjectOverride,
298
776
  kind: "dynamic",
299
777
  fetchFn: fetchCcFn,
300
778
  skipCache,
301
779
  warnings,
780
+ signal: options.signal,
302
781
  });
303
782
  if (result.kind !== "none")
304
783
  return result;
@@ -310,7 +789,7 @@ async function resolveAgentCredentials(options = {}) {
310
789
  // blip. Keep the persisted creds and report the transient failure instead.
311
790
  if (result.authRejected) {
312
791
  warnings.push(`Persisted dynamic agent credentials rejected (${result.reason}); re-registering.`);
313
- clearDynamicFn();
792
+ clearDynamicFn(harnessKey, sessionKey);
314
793
  }
315
794
  else {
316
795
  warnings.push(`Persisted dynamic agent credentials temporarily unavailable (${result.reason}); keeping them for the next session.`);
@@ -318,15 +797,17 @@ async function resolveAgentCredentials(options = {}) {
318
797
  }
319
798
  }
320
799
  }
321
- // 4. Fresh dynamic registration — bootstrap using the user's bearer
800
+ // 3. Fresh dynamic registration — bootstrap using the user's bearer
322
801
  // or an out-of-band initial access token.
323
802
  const iat = options.userToken ?? env.ORY_AGENT_REGISTRATION_TOKEN;
324
- if (options.projectUrl && iat) {
803
+ if (options.allowRegistration !== false && options.projectUrl && iat) {
325
804
  try {
326
805
  const registered = await registerFn({
327
806
  projectUrl: options.projectUrl,
328
807
  auth: iat,
329
808
  harness: options.harness,
809
+ sessionId: sessionKey === config_js_1.SESSIONLESS_KEY ? undefined : sessionKey,
810
+ signal: options.signal,
330
811
  ...(options.userSubject
331
812
  ? {
332
813
  delegation: {
@@ -340,18 +821,34 @@ async function resolveAgentCredentials(options = {}) {
340
821
  warnings.push("Dynamic registration returned no client_secret; cannot run client_credentials grant.");
341
822
  }
342
823
  else {
343
- 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;
344
840
  const result = await resolveClientCredentials({
345
841
  projectUrl: options.projectUrl,
346
- clientId: registered.clientId,
347
- clientSecret: registered.clientSecret,
842
+ clientId: adopted.clientId,
843
+ clientSecret: adopted.clientSecret ?? registered.clientSecret,
348
844
  audience: env.ORY_AGENT_AUDIENCE,
349
845
  scope: env.ORY_AGENT_SCOPE,
350
- subjectOverride: env.ORY_AGENT_SUBJECT_ID,
846
+ subjectOverride,
351
847
  kind: "dynamic",
352
848
  fetchFn: fetchCcFn,
353
849
  skipCache: true, // freshly registered, no stale cache
354
850
  warnings,
851
+ signal: options.signal,
355
852
  });
356
853
  if (result.kind !== "none")
357
854
  return result;
@@ -362,24 +859,32 @@ async function resolveAgentCredentials(options = {}) {
362
859
  warnings.push(`Dynamic client registration failed: ${err instanceof Error ? err.message : String(err)}`);
363
860
  }
364
861
  }
365
- // 5. Deprecated ORY_API_KEY alias.
366
- const legacyApiKey = env.ORY_API_KEY;
367
- if (legacyApiKey && legacyApiKey.length > 0) {
368
- warnings.push("ORY_API_KEY is deprecated for agent authentication — set ORY_AGENT_API_KEY instead");
369
- return {
370
- kind: "api_key",
371
- token: legacyApiKey,
372
- subject: env.ORY_AGENT_SUBJECT_ID,
373
- reason: "Resolved agent identity from ORY_API_KEY (deprecated alias)",
374
- warnings,
375
- };
376
- }
377
862
  return {
378
863
  kind: "none",
379
- reason: "No agent credentials configured. Run with a user session (the interactive user login runs every session) 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.",
380
865
  warnings,
381
866
  };
382
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
+ }
383
888
  async function resolveClientCredentials(args) {
384
889
  const cacheKey = `${args.kind}:${args.clientId}@${args.projectUrl}`;
385
890
  const nowSec = Math.floor(Date.now() / 1000);
@@ -404,6 +909,7 @@ async function resolveClientCredentials(args) {
404
909
  clientSecret: args.clientSecret,
405
910
  audience: args.audience,
406
911
  scope: args.scope,
912
+ signal: args.signal,
407
913
  });
408
914
  ccCache.set(cacheKey, {
409
915
  token: result.accessToken,
@@ -463,6 +969,7 @@ async function fetchClientCredentialsToken(args) {
463
969
  method: "POST",
464
970
  headers: { "content-type": "application/x-www-form-urlencoded" },
465
971
  body: body.toString(),
972
+ signal: args.signal,
466
973
  });
467
974
  if (!res.ok) {
468
975
  const err = new Error(`HTTP ${res.status}`);
@@ -484,36 +991,48 @@ async function fetchClientCredentialsToken(args) {
484
991
  * Resolve the agent's credentials and attach them to the client.
485
992
  *
486
993
  * Always returns the resolved credentials; never throws and never blocks.
487
- * 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
488
995
  * the agent identity was available for the session.
489
996
  */
490
997
  async function ensureAgentIdentity(client, options = {}) {
491
- // Audit-only kill switch: Ory is disabled entirely — skip credential
492
- // resolution (no DCR, no token grant) and record the no-op, shaped like
493
- // the user gate's audit_only result.
494
- 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) {
495
1005
  const creds = {
496
1006
  kind: "none",
497
- 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`,
498
1008
  warnings: [],
499
1009
  };
500
- client.tracer.record("agent.auth", "skipped", {
501
- 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 },
502
1012
  });
503
1013
  return creds;
504
1014
  }
505
- 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
+ }
506
1026
  let creds;
507
1027
  try {
508
1028
  creds = await resolveFn({
509
- projectUrl: options.projectUrl,
1029
+ projectUrl,
510
1030
  env: options.env,
511
- // The user gate populates userPrincipal on successful login; we hand the
512
- // token to the resolver so DCR can use it as the IAT, and the subject so
513
- // the fresh client is stamped with its delegator (metadata.delegatedBy).
514
- userToken: client.userPrincipal.token,
515
- userSubject: client.userPrincipal.subject,
516
- harness: options.harness,
1031
+ signal: options.signal,
1032
+ runtimeCredential: client.runtimeCredential,
1033
+ credentialStore,
1034
+ harness: options.harness ?? client.harness,
1035
+ sessionId,
517
1036
  });
518
1037
  }
519
1038
  catch (err) {
@@ -523,114 +1042,320 @@ async function ensureAgentIdentity(client, options = {}) {
523
1042
  warnings: [],
524
1043
  };
525
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
+ }
526
1120
  for (const warning of creds.warnings) {
527
1121
  client.logger.warn("agent.auth.warning", { message: warning });
528
1122
  }
529
1123
  if (creds.kind === "none") {
530
1124
  client.logger.warn("agent.auth.none", { reason: creds.reason });
531
1125
  }
532
- if (creds.token) {
533
- client.setAgentPrincipal({ token: creds.token, subject: creds.subject });
534
- }
535
- // When no credentials were resolved, leave the existing principal in
536
- // place. The constructor may have seeded one from `config.apiKey` for
537
- // backward compat, and "no resolver match" should not clear it.
538
- 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", {
539
1132
  attributes: {
540
1133
  kind: creds.kind,
541
1134
  reason: creds.reason,
1135
+ sessionKey,
1136
+ ...(options.trigger ? { trigger: options.trigger } : {}),
542
1137
  ...(creds.subject ? { subject: creds.subject } : {}),
543
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 } : {}),
544
1144
  },
545
1145
  });
546
1146
  return creds;
547
1147
  }
548
1148
  /**
549
- * Resolve a sub-agent identity via OAuth2 Dynamic Client Registration.
1149
+ * Revoke retired registrations and drop the successful ones from the queue.
550
1150
  *
551
- * Reuses persisted credentials when the `(projectUrl, subAgentType)`
552
- * pair matches; otherwise registers a fresh client named
553
- * `Ory Agent Security · sub-agent · <harness>/<type> @ <host>` and persists the result.
554
- * Never throws and never blocks — on any failure the identity collapses
555
- * to `kind: "none"` and the plugin continues without it. Emits exactly
556
- * one `agent.auth` span so the audit trail records each sub-agent
557
- * 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.
558
1199
  */
559
1200
  async function ensureSubAgentIdentity(client, options) {
560
- // Audit-only kill switch: same no-op as ensureAgentIdentity — no
561
- // sub-agent DCR while Ory is disabled entirely.
562
- 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) {
563
1206
  const identity = {
564
1207
  kind: "none",
565
1208
  subAgentType: options.subAgentType,
566
- 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`,
567
1210
  warnings: [],
568
1211
  };
569
- client.tracer.record("agent.auth", "skipped", {
570
- attributes: {
571
- kind: "subagent_none",
572
- subAgentType: identity.subAgentType,
573
- reason: identity.reason,
574
- auditOnly: true,
575
- },
576
- });
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
+ }
577
1222
  return identity;
578
1223
  }
579
- const env = options.env ?? process.env;
580
- const registerFn = options.registerAgentClientFn ?? registerAgentClient;
581
- const loadFn = options.loadFn ?? loadSubAgentDynamicCredentials;
582
- const saveFn = options.saveFn ?? saveSubAgentDynamicCredentials;
583
- const warnings = [];
584
- let identity;
585
- try {
586
- 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",
587
1230
  subAgentType: options.subAgentType,
588
- projectUrl: options.projectUrl,
589
- harness: options.harness,
590
- // The delegator for a sub-agent is the parent agent. When its subject is
591
- // populated, stamp `metadata.delegatedBy` = `agent:<agent>` on the
592
- // sub-agent client so the agent→subagent edge is anchored to the identity.
593
- delegatorSubject: client.agentPrincipal.subject,
594
- iat: options.iat
595
- ?? client.agentPrincipal.token
596
- ?? client.userPrincipal.token
597
- ?? env.ORY_AGENT_REGISTRATION_TOKEN,
598
- registerFn,
599
- loadFn,
600
- saveFn,
601
- 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,
602
1270
  });
1271
+ parentCredential = parent.runtimeCredential;
603
1272
  }
604
- 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 {
605
1331
  identity = {
606
1332
  kind: "none",
607
1333
  subAgentType: options.subAgentType,
608
- reason: `Sub-agent identity resolution threw: ${err instanceof Error ? err.message : String(err)}`,
609
- warnings,
1334
+ reason: "No child Talos credential is stored and parent-authenticated enrollment is unavailable",
1335
+ warnings: [],
610
1336
  };
611
1337
  }
612
- for (const warning of identity.warnings) {
613
- client.logger.warn("agent.auth.warning", {
614
- message: warning,
615
- subAgentType: options.subAgentType,
616
- });
617
- }
618
- if (identity.kind === "none") {
619
- client.logger.warn("agent.auth.none", {
620
- reason: identity.reason,
621
- 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
+ },
622
1346
  });
623
1347
  }
624
- client.tracer.record("agent.auth", identity.kind === "none" ? "skipped" : "ok", {
625
- attributes: {
626
- kind: identity.kind === "dynamic" ? "subagent_dynamic" : "subagent_none",
627
- subAgentType: identity.subAgentType,
628
- reason: identity.reason,
629
- ...(identity.subject ? { subject: identity.subject } : {}),
630
- },
631
- });
632
1348
  return identity;
633
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
+ }
634
1359
  async function resolveSubAgentIdentity(args) {
635
1360
  if (!args.projectUrl) {
636
1361
  return {
@@ -640,8 +1365,12 @@ async function resolveSubAgentIdentity(args) {
640
1365
  warnings: args.warnings,
641
1366
  };
642
1367
  }
643
- // 1. Re-use persisted credentials if they match the current project URL.
644
- 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);
645
1374
  if (persisted && persisted.projectUrl === args.projectUrl) {
646
1375
  return {
647
1376
  kind: "dynamic",
@@ -664,16 +1393,20 @@ async function resolveSubAgentIdentity(args) {
664
1393
  warnings: args.warnings,
665
1394
  };
666
1395
  }
667
- // 3. Fresh DCR. client_name is namespaced under sub-agent so audit logs
668
- // can distinguish it from the top-level agent registration.
669
- const subHarness = args.harness
670
- ? `${args.harness}-subagent-${args.subAgentType}`
671
- : `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).
672
1402
  try {
673
1403
  const registered = await args.registerFn({
674
1404
  projectUrl: args.projectUrl,
675
1405
  auth: args.iat,
676
- 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,
677
1410
  ...(args.delegatorSubject
678
1411
  ? {
679
1412
  delegation: {
@@ -684,13 +1417,26 @@ async function resolveSubAgentIdentity(args) {
684
1417
  }
685
1418
  : {}),
686
1419
  });
687
- 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
+ }
688
1432
  return {
689
1433
  kind: "dynamic",
690
1434
  subAgentType: args.subAgentType,
691
- subject: registered.clientId,
692
- credentials: registered,
693
- 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)`,
694
1440
  warnings: args.warnings,
695
1441
  };
696
1442
  }