@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.
- package/README.md +31 -45
- package/assets/commands/temporal-up.md +1 -1
- package/assets/skills/auth-setup/SKILL.md +1 -1
- package/assets/skills/local-dev/SKILL.md +17 -7
- package/assets/skills/ory-build-agent/SKILL.md +43 -97
- package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -18
- package/assets/skills/ory-temporal-worker/SKILL.md +38 -42
- package/assets/skills/permissions-onboarding/SKILL.md +131 -104
- package/dist/adapters.d.ts +93 -30
- package/dist/adapters.js +464 -136
- package/dist/agent-auth.d.ts +258 -68
- package/dist/agent-auth.js +998 -202
- package/dist/auth-store.d.ts +37 -2
- package/dist/auth-store.js +37 -3
- package/dist/auth.d.ts +40 -4
- package/dist/auth.js +247 -19
- package/dist/bash-parser.d.ts +98 -0
- package/dist/bash-parser.js +396 -0
- package/dist/branding.d.ts +128 -0
- package/dist/branding.js +151 -0
- package/dist/build-info.json +4 -4
- package/dist/cli-invocation.d.ts +1 -1
- package/dist/cli-invocation.js +2 -1
- package/dist/cli.d.ts +20 -29
- package/dist/cli.js +271 -278
- package/dist/client.d.ts +175 -138
- package/dist/client.js +672 -391
- package/dist/config.d.ts +249 -57
- package/dist/config.js +486 -62
- package/dist/context.d.ts +10 -0
- package/dist/context.js +21 -0
- package/dist/contract-suite.d.ts +8 -8
- package/dist/contract-suite.js +88 -69
- package/dist/denial.d.ts +36 -3
- package/dist/denial.js +79 -10
- package/dist/event-reporter.d.ts +77 -0
- package/dist/event-reporter.js +776 -0
- package/dist/external-registrations-main.d.ts +10 -0
- package/dist/external-registrations-main.js +38 -0
- package/dist/external-registrations.d.ts +79 -0
- package/dist/external-registrations.js +188 -0
- package/dist/help-cli.d.ts +39 -0
- package/dist/help-cli.js +55 -0
- package/dist/hook-timeout.d.ts +64 -0
- package/dist/hook-timeout.js +88 -0
- package/dist/index.d.ts +31 -19
- package/dist/index.js +182 -31
- package/dist/lifecycle.d.ts +3 -3
- package/dist/lifecycle.js +38 -6
- package/dist/local/cli.js +11 -6
- package/dist/local/configs.d.ts +74 -18
- package/dist/local/configs.js +291 -84
- package/dist/local/health.d.ts +14 -0
- package/dist/local/health.js +50 -4
- package/dist/local/index.d.ts +2 -2
- package/dist/local/index.js +24 -10
- package/dist/local/manager.d.ts +20 -1
- package/dist/local/manager.js +160 -39
- package/dist/local/ports.d.ts +158 -0
- package/dist/local/ports.js +443 -0
- package/dist/local/seed.d.ts +22 -25
- package/dist/local/seed.js +88 -56
- package/dist/logger.d.ts +54 -25
- package/dist/logger.js +329 -63
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +10 -5
- package/dist/mirror-bootstrap.d.ts +48 -0
- package/dist/mirror-bootstrap.js +254 -0
- package/dist/opl.d.ts +289 -0
- package/dist/opl.js +446 -0
- package/dist/permission-mode.d.ts +87 -0
- package/dist/permission-mode.js +307 -0
- package/dist/permissions-cli.d.ts +13 -49
- package/dist/permissions-cli.js +154 -348
- package/dist/permissions.d.ts +148 -38
- package/dist/permissions.js +591 -45
- package/dist/post-install.d.ts +33 -0
- package/dist/post-install.js +127 -0
- package/dist/read-credential.d.ts +65 -0
- package/dist/read-credential.js +86 -0
- package/dist/registry/cli.js +5 -2
- package/dist/registry/config.d.ts +0 -17
- package/dist/registry/config.js +0 -23
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.js +2 -2
- package/dist/registry/manager.d.ts +4 -21
- package/dist/registry/manager.js +83 -55
- package/dist/runtime-credential.d.ts +140 -0
- package/dist/runtime-credential.js +572 -0
- package/dist/runtime.d.ts +408 -0
- package/dist/runtime.js +748 -0
- package/dist/setup.d.ts +23 -28
- package/dist/setup.js +57 -84
- package/dist/status-cli.d.ts +29 -13
- package/dist/status-cli.js +124 -144
- package/dist/status-data.d.ts +195 -0
- package/dist/status-data.js +333 -0
- package/dist/status-system.d.ts +24 -0
- package/dist/status-system.js +56 -0
- package/dist/subject.d.ts +126 -20
- package/dist/subject.js +215 -30
- package/dist/testing.d.ts +74 -38
- package/dist/testing.js +185 -68
- package/dist/tool-catalog.d.ts +53 -11
- package/dist/tool-catalog.js +164 -13
- package/dist/tool-metadata.d.ts +7 -6
- package/dist/tool-metadata.js +6 -5
- package/dist/types.d.ts +11 -1
- package/dist/uninstall.d.ts +74 -19
- package/dist/uninstall.js +224 -49
- package/dist/user-login.d.ts +22 -16
- package/dist/user-login.js +67 -96
- package/dist/watch-cli.d.ts +6 -0
- package/dist/watch-cli.js +217 -0
- package/package.json +3 -11
- package/dist/dev.d.ts +0 -103
- package/dist/dev.js +0 -584
- package/dist/interactive-setup.d.ts +0 -165
- package/dist/interactive-setup.js +0 -1546
- package/dist/local/jaeger-main.d.ts +0 -13
- package/dist/local/jaeger-main.js +0 -85
- package/dist/local/jaeger.d.ts +0 -50
- package/dist/local/jaeger.js +0 -162
- package/dist/otel/exporter.d.ts +0 -17
- package/dist/otel/exporter.js +0 -12
- package/dist/otel/index.d.ts +0 -2
- package/dist/otel/index.js +0 -8
- package/dist/otel/otlp.d.ts +0 -103
- package/dist/otel/otlp.js +0 -385
- package/dist/tracer.d.ts +0 -190
- package/dist/tracer.js +0 -481
- package/dist/watch-sandbox.d.ts +0 -9
- package/dist/watch-sandbox.js +0 -81
package/dist/agent-auth.js
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
/**
|
|
64
|
-
|
|
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[
|
|
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
|
|
74
|
-
function clearSubAgentDynamicCredentials(subAgentType) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
delete
|
|
82
|
-
if (Object.keys(
|
|
83
|
-
delete
|
|
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
|
-
|
|
86
|
-
if (
|
|
87
|
-
delete
|
|
236
|
+
bySession[sessionKey] = byType;
|
|
237
|
+
if (Object.keys(bySession).length === 0)
|
|
238
|
+
delete subAgents[harness];
|
|
88
239
|
else
|
|
89
|
-
|
|
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
|
-
// ───
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
function
|
|
100
|
-
(0, config_js_1.
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
/**
|
|
106
|
-
|
|
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
|
|
109
|
-
if (!
|
|
110
|
-
return
|
|
111
|
-
const agent = { ...
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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 =
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
213
|
-
*
|
|
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
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 (
|
|
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`
|
|
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
|
-
|
|
457
|
-
//
|
|
458
|
-
// the
|
|
459
|
-
|
|
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:
|
|
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.
|
|
466
|
-
attributes: { kind: creds.kind, reason: creds.reason,
|
|
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 ??
|
|
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
|
|
1029
|
+
projectUrl,
|
|
475
1030
|
env: options.env,
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
*
|
|
1149
|
+
* Revoke retired registrations and drop the successful ones from the queue.
|
|
513
1150
|
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
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:
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
543
|
-
const
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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:
|
|
568
|
-
warnings,
|
|
1334
|
+
reason: "No child Talos credential is stored and parent-authenticated enrollment is unavailable",
|
|
1335
|
+
warnings: [],
|
|
569
1336
|
};
|
|
570
1337
|
}
|
|
571
|
-
|
|
572
|
-
client.logger.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|
603
|
-
|
|
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.
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
642
|
-
credentials:
|
|
643
|
-
reason:
|
|
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
|
}
|