@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.
- package/README.md +31 -46
- 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 +39 -91
- package/assets/skills/ory-e2b-sandbox/SKILL.md +18 -17
- package/assets/skills/ory-temporal-worker/SKILL.md +31 -33
- package/assets/skills/permissions-onboarding/SKILL.md +131 -104
- package/dist/adapters.d.ts +88 -32
- package/dist/adapters.js +443 -164
- package/dist/agent-auth.d.ts +226 -67
- package/dist/agent-auth.js +951 -205
- package/dist/auth-store.d.ts +37 -2
- package/dist/auth-store.js +37 -3
- package/dist/auth.d.ts +33 -4
- package/dist/auth.js +163 -20
- package/dist/bash-parser.d.ts +98 -0
- package/dist/bash-parser.js +396 -0
- package/dist/branding.d.ts +77 -16
- package/dist/branding.js +93 -23
- 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 -239
- package/dist/client.d.ts +175 -138
- package/dist/client.js +672 -391
- package/dist/config.d.ts +217 -54
- package/dist/config.js +461 -42
- package/dist/context.d.ts +10 -0
- package/dist/context.js +21 -0
- package/dist/contract-suite.d.ts +3 -5
- package/dist/contract-suite.js +75 -47
- 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 +28 -23
- package/dist/index.js +173 -58
- 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.js +7 -7
- 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 +159 -36
- package/dist/local/ports.d.ts +158 -0
- package/dist/local/ports.js +443 -0
- package/dist/local/seed.d.ts +13 -5
- package/dist/local/seed.js +62 -38
- 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 +21 -37
- package/dist/runtime-credential.d.ts +140 -0
- package/dist/runtime-credential.js +572 -0
- package/dist/runtime.d.ts +413 -0
- package/dist/runtime.js +825 -0
- package/dist/setup.d.ts +23 -47
- package/dist/setup.js +59 -116
- package/dist/skills.js +0 -7
- package/dist/status-cli.d.ts +27 -11
- package/dist/status-cli.js +121 -115
- package/dist/status-data.d.ts +116 -17
- package/dist/status-data.js +121 -38
- 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 +71 -34
- package/dist/uninstall.js +217 -85
- package/dist/user-login.d.ts +9 -10
- package/dist/user-login.js +56 -75
- package/dist/watch-cli.d.ts +6 -0
- package/dist/watch-cli.js +217 -0
- package/package.json +4 -27
- package/assets/commands/dashboard.md +0 -34
- package/dist/dashboard-cli.d.ts +0 -8
- package/dist/dashboard-cli.js +0 -70
- package/dist/dev.d.ts +0 -103
- package/dist/dev.js +0 -583
- package/dist/interactive-setup.d.ts +0 -286
- package/dist/interactive-setup.js +0 -1734
- 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/project-api-key.d.ts +0 -69
- package/dist/project-api-key.js +0 -147
- package/dist/setup-actions.d.ts +0 -232
- package/dist/setup-actions.js +0 -507
- 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/web/api.d.ts +0 -33
- package/dist/web/api.js +0 -294
- package/dist/web/launch.d.ts +0 -11
- package/dist/web/launch.js +0 -96
- package/dist/web/server.d.ts +0 -20
- package/dist/web/server.js +0 -233
- package/dist/web/types.d.ts +0 -65
- package/dist/web/types.js +0 -2
- package/dist/webapp/assets/index-Wucl4SZs.css +0 -1
- package/dist/webapp/assets/index-m-GtEdq0.js +0 -49
- package/dist/webapp/favicon.ico +0 -0
- package/dist/webapp/index.html +0 -15
package/dist/client.js
CHANGED
|
@@ -33,25 +33,56 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.
|
|
36
|
+
exports.DEFAULT_DEBUG_LOG_FILENAME = exports.OryAgentClient = void 0;
|
|
37
37
|
exports.resolveDebugLogPath = resolveDebugLogPath;
|
|
38
|
-
exports.resolveTraceFilePath = resolveTraceFilePath;
|
|
39
38
|
exports.extractOryRequestId = extractOryRequestId;
|
|
40
39
|
const client_1 = require("@ory/client");
|
|
41
40
|
const path = __importStar(require("node:path"));
|
|
42
41
|
const logger_js_1 = require("./logger.js");
|
|
43
|
-
const
|
|
42
|
+
const event_reporter_js_1 = require("./event-reporter.js");
|
|
43
|
+
const agent_auth_js_1 = require("./agent-auth.js");
|
|
44
|
+
const context_js_1 = require("./context.js");
|
|
44
45
|
const config_js_1 = require("./config.js");
|
|
45
46
|
const auth_store_js_1 = require("./auth-store.js");
|
|
46
|
-
const
|
|
47
|
+
const runtime_credential_js_1 = require("./runtime-credential.js");
|
|
48
|
+
const BROKER_PERMISSION_BATCH_SIZE = 10;
|
|
49
|
+
function permissionCheckFromRequest(request) {
|
|
50
|
+
return {
|
|
51
|
+
namespace: request.namespace,
|
|
52
|
+
object: request.object,
|
|
53
|
+
relation: request.relation,
|
|
54
|
+
subjectId: request.subjectId,
|
|
55
|
+
subjectSet: request.subjectSetNamespace && request.subjectSetObject !== undefined
|
|
56
|
+
? {
|
|
57
|
+
namespace: request.subjectSetNamespace,
|
|
58
|
+
object: request.subjectSetObject,
|
|
59
|
+
relation: request.subjectSetRelation ?? "",
|
|
60
|
+
}
|
|
61
|
+
: undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function permissionCheckFromTuple(tuple) {
|
|
65
|
+
return {
|
|
66
|
+
namespace: tuple.namespace,
|
|
67
|
+
object: tuple.object,
|
|
68
|
+
relation: tuple.relation,
|
|
69
|
+
subjectId: tuple.subject_id,
|
|
70
|
+
subjectSet: tuple.subject_set,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function httpError(status, message) {
|
|
74
|
+
return Object.assign(new Error(message), { status });
|
|
75
|
+
}
|
|
47
76
|
class OryAgentClient {
|
|
48
77
|
frontend;
|
|
49
78
|
oauth2;
|
|
50
79
|
permission;
|
|
51
|
-
relationship;
|
|
52
80
|
config;
|
|
53
81
|
logger;
|
|
54
|
-
|
|
82
|
+
eventReporter;
|
|
83
|
+
_runtimeCredential;
|
|
84
|
+
_runtimeCredentialsBySession = new Map();
|
|
85
|
+
_runtimeCredentialsByPrincipalKey = new Map();
|
|
55
86
|
sessionCache = new Map();
|
|
56
87
|
sessionCacheTtlMs;
|
|
57
88
|
/**
|
|
@@ -59,94 +90,383 @@ class OryAgentClient {
|
|
|
59
90
|
* Populated by `ensureUserAuthenticated` on session start.
|
|
60
91
|
*/
|
|
61
92
|
_userPrincipal = {};
|
|
93
|
+
_userPrincipalsBySession = new Map();
|
|
62
94
|
/**
|
|
63
95
|
* The AI agent process — the principal that authenticates outgoing
|
|
64
96
|
* Ory API calls. Populated by `ensureAgentIdentity`.
|
|
65
97
|
*/
|
|
66
98
|
_agentPrincipal = {};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
* used to authenticate the Keto Permission and Relationship APIs.
|
|
70
|
-
*
|
|
71
|
-
* This is deliberately distinct from the agent principal's token. Ory
|
|
72
|
-
* Network's permission APIs authenticate with a project API key and
|
|
73
|
-
* **reject project-issued OAuth2 access tokens** (a DCR client-credentials
|
|
74
|
-
* token is answered with `401 "Access token is not active"`). So when the
|
|
75
|
-
* agent identity resolves to a DCR OAuth2 client — the default path — its
|
|
76
|
-
* token cannot authenticate permission checks. Holding the admin key here
|
|
77
|
-
* lets {@link buildApis} route Keto through it while the agent OAuth2 token
|
|
78
|
-
* still authenticates the OAuth2 / Frontend APIs and carries attribution.
|
|
79
|
-
*
|
|
80
|
-
* When unset, Keto calls fall back to the agent token (legacy
|
|
81
|
-
* single-credential behavior, unchanged for static-key deployments).
|
|
82
|
-
*/
|
|
83
|
-
_adminApiKey;
|
|
99
|
+
_agentPrincipalsBySession = new Map();
|
|
100
|
+
_eventPrincipalsByKey = new Map();
|
|
84
101
|
constructor(config) {
|
|
85
102
|
this.config = config;
|
|
86
103
|
this.sessionCacheTtlMs = config.sessionCacheTtlMs ?? 60_000;
|
|
87
104
|
this.logger = new logger_js_1.DebugLogger(config.harness, {
|
|
88
105
|
logFile: config.logFile,
|
|
89
106
|
});
|
|
90
|
-
this.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
this.logger.setActivityEnricher(() => this.principalActivityAttributes());
|
|
108
|
+
this._runtimeCredential = (0, runtime_credential_js_1.resolveRuntimeCredential)({
|
|
109
|
+
requestAuthenticator: config.runtimeRequestAuthenticator,
|
|
110
|
+
subject: config.runtimeSubject ?? process.env.ORY_AGENT_SUBJECT_ID?.trim(),
|
|
94
111
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
if (this._runtimeCredential?.subject) {
|
|
113
|
+
this._agentPrincipal = { subject: this._runtimeCredential.subject };
|
|
114
|
+
}
|
|
115
|
+
if (config.eventReporter) {
|
|
116
|
+
const reporterOptions = config.eventReporter === true ? {} : config.eventReporter;
|
|
117
|
+
this.eventReporter = new event_reporter_js_1.AgentSecurityEventReporter({
|
|
118
|
+
...reporterOptions,
|
|
119
|
+
projectUrl: config.projectUrl,
|
|
120
|
+
agentSecurityUrl: this.agentSecurityUrl,
|
|
121
|
+
token: (sessionId, principalKey) => this.eventPrincipal(sessionId, principalKey).token,
|
|
122
|
+
request: (url, init, sessionId, principalKey) => {
|
|
123
|
+
const credential = (principalKey ? this._runtimeCredentialsByPrincipalKey.get(principalKey) : undefined) ??
|
|
124
|
+
(sessionId ? this._runtimeCredentialsBySession.get(sessionId) : undefined) ??
|
|
125
|
+
this._runtimeCredential;
|
|
126
|
+
if (!credential)
|
|
127
|
+
return undefined;
|
|
128
|
+
return (0, runtime_credential_js_1.authenticatedRuntimeRequest)(credential, {
|
|
129
|
+
url,
|
|
130
|
+
init,
|
|
131
|
+
authorizationHeader: "authorization",
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
onUnauthorized: (sessionId, principalKey, rejectedToken) => {
|
|
135
|
+
const principal = this.eventPrincipal(sessionId, principalKey);
|
|
136
|
+
if (principal.token === rejectedToken) {
|
|
137
|
+
if (principalKey) {
|
|
138
|
+
this._eventPrincipalsByKey.set(principalKey, { subject: principal.subject });
|
|
139
|
+
if (this.agentPrincipalForSession(sessionId).subject
|
|
140
|
+
&& (0, event_reporter_js_1.eventPrincipalKey)(this.agentPrincipalForSession(sessionId).subject) === principalKey) {
|
|
141
|
+
this.setAgentPrincipal({ subject: principal.subject }, sessionId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
this.setAgentPrincipal({ subject: principal.subject }, sessionId);
|
|
146
|
+
}
|
|
147
|
+
(0, agent_auth_js_1.invalidateAgentAccessToken)(rejectedToken);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
logger: this.logger,
|
|
151
|
+
});
|
|
152
|
+
this.logger.setActivitySink((entry) => {
|
|
153
|
+
const enrichedSubject = entry.attributes?.agentSubject;
|
|
154
|
+
const principal = typeof enrichedSubject === "string"
|
|
155
|
+
? { subject: enrichedSubject }
|
|
156
|
+
: this.agentPrincipalForSession(entry.sessionId);
|
|
157
|
+
this.eventReporter?.enqueue(entry, principal.subject ? (0, event_reporter_js_1.eventPrincipalKey)(principal.subject) : undefined);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// User OAuth APIs remain independent from the runtime credential used by
|
|
161
|
+
// Agent Security broker requests.
|
|
162
|
+
const { frontend, oauth2, permission } = this.buildApis();
|
|
112
163
|
this.frontend = frontend;
|
|
113
164
|
this.oauth2 = oauth2;
|
|
114
165
|
this.permission = permission;
|
|
115
|
-
this.relationship = relationship;
|
|
116
166
|
}
|
|
117
167
|
/** Build a fresh set of Ory API instances using the current agent token. */
|
|
118
168
|
buildApis() {
|
|
119
|
-
const accessToken = this.
|
|
169
|
+
const accessToken = this.agentPrincipal.token;
|
|
170
|
+
const auth = accessToken ? { accessToken } : {};
|
|
120
171
|
const apiConfig = new client_1.Configuration({
|
|
121
172
|
basePath: this.config.projectUrl,
|
|
122
|
-
...
|
|
173
|
+
...auth,
|
|
123
174
|
});
|
|
124
|
-
// Keto (Permission + Relationship) authenticates with the admin project
|
|
125
|
-
// API key when one is set; otherwise it reuses the agent token (legacy
|
|
126
|
-
// single-credential behavior). When the two credentials are identical we
|
|
127
|
-
// reuse the same Configuration object so a single-credential deployment —
|
|
128
|
-
// and tests that stub one Configuration — see exactly one.
|
|
129
|
-
const ketoToken = this._adminApiKey ?? accessToken;
|
|
130
|
-
const ketoConfig = ketoToken === accessToken
|
|
131
|
-
? apiConfig
|
|
132
|
-
: new client_1.Configuration({
|
|
133
|
-
basePath: this.config.projectUrl,
|
|
134
|
-
...(ketoToken ? { accessToken: ketoToken } : {}),
|
|
135
|
-
});
|
|
136
175
|
return {
|
|
137
176
|
frontend: new client_1.FrontendApi(apiConfig),
|
|
138
177
|
oauth2: new client_1.OAuth2Api(apiConfig),
|
|
139
|
-
permission:
|
|
140
|
-
relationship: new client_1.RelationshipApi(ketoConfig),
|
|
178
|
+
permission: this.buildBrokerPermissionTransport(),
|
|
141
179
|
};
|
|
142
180
|
}
|
|
181
|
+
/** Canonical broker origin, with the shipped project URL fallback applied. */
|
|
182
|
+
get agentSecurityUrl() {
|
|
183
|
+
return this.config.agentSecurityUrl ?? this.config.projectUrl;
|
|
184
|
+
}
|
|
185
|
+
buildBrokerPermissionTransport() {
|
|
186
|
+
return {
|
|
187
|
+
checkPermission: async (request) => {
|
|
188
|
+
const response = await this.postPermissionChecks([permissionCheckFromRequest(request)]);
|
|
189
|
+
return { data: response.data.results[0] ?? { allowed: false }, headers: response.headers };
|
|
190
|
+
},
|
|
191
|
+
batchCheckPermission: async ({ batchCheckPermissionBody }) => {
|
|
192
|
+
const checks = batchCheckPermissionBody.tuples.map(permissionCheckFromTuple);
|
|
193
|
+
return this.postPermissionChecks(checks);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async postPermissionChecks(checks) {
|
|
198
|
+
const credential = this.runtimeCredential;
|
|
199
|
+
if (!credential)
|
|
200
|
+
throw httpError(403, "Runtime credential is required for permission checks");
|
|
201
|
+
const results = [];
|
|
202
|
+
let headers;
|
|
203
|
+
for (let start = 0; start < checks.length; start += BROKER_PERMISSION_BATCH_SIZE) {
|
|
204
|
+
const chunk = checks.slice(start, start + BROKER_PERMISSION_BATCH_SIZE);
|
|
205
|
+
const body = {
|
|
206
|
+
project_url: this.config.projectUrl,
|
|
207
|
+
checks: chunk.map((check) => ({
|
|
208
|
+
namespace: check.namespace,
|
|
209
|
+
object: check.object,
|
|
210
|
+
relation: check.relation,
|
|
211
|
+
principal: this.permissionPrincipal(check),
|
|
212
|
+
})),
|
|
213
|
+
};
|
|
214
|
+
const response = await (0, runtime_credential_js_1.authenticatedRuntimeRequest)(credential, {
|
|
215
|
+
url: `${this.agentSecurityUrl.replace(/\/+$/, "")}/agent-security/v1/permissions:check`,
|
|
216
|
+
init: {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "content-type": "application/json" },
|
|
219
|
+
body: JSON.stringify(body),
|
|
220
|
+
signal: AbortSignal.timeout(30_000),
|
|
221
|
+
},
|
|
222
|
+
authorizationHeader: "authorization",
|
|
223
|
+
});
|
|
224
|
+
if (!response.ok)
|
|
225
|
+
throw httpError(response.status, `HTTP ${response.status}`);
|
|
226
|
+
const data = await response.json();
|
|
227
|
+
if (!Array.isArray(data.results)
|
|
228
|
+
|| data.results.length !== chunk.length
|
|
229
|
+
|| data.results.some((result) => typeof result !== "object"
|
|
230
|
+
|| result === null
|
|
231
|
+
|| typeof result.allowed !== "boolean"
|
|
232
|
+
|| (result.error !== undefined && typeof result.error !== "string"))) {
|
|
233
|
+
throw httpError(403, "Agent Security returned an invalid permission result");
|
|
234
|
+
}
|
|
235
|
+
results.push(...data.results);
|
|
236
|
+
headers = response.headers;
|
|
237
|
+
}
|
|
238
|
+
return { data: { results }, headers };
|
|
239
|
+
}
|
|
240
|
+
permissionPrincipal(check) {
|
|
241
|
+
if (check.subjectId) {
|
|
242
|
+
throw httpError(403, "Permission subject IDs are not accepted by Agent Security");
|
|
243
|
+
}
|
|
244
|
+
const subject = check.subjectSet;
|
|
245
|
+
if (!subject)
|
|
246
|
+
throw httpError(403, "Permission check has no representable principal");
|
|
247
|
+
const projectPrincipal = subject.object === "project" &&
|
|
248
|
+
subject.relation === "enforcedSubjects" &&
|
|
249
|
+
subject.namespace === "PermissionMode";
|
|
250
|
+
if (projectPrincipal &&
|
|
251
|
+
(check.relation === "blockedSubjects" || (check.namespace === "PermissionMode" &&
|
|
252
|
+
check.object === "mode" &&
|
|
253
|
+
check.relation === "enforcedSubjects"))) {
|
|
254
|
+
return { kind: "project" };
|
|
255
|
+
}
|
|
256
|
+
if (subject.relation) {
|
|
257
|
+
throw httpError(403, "Permission subject-set expansion is not accepted by Agent Security");
|
|
258
|
+
}
|
|
259
|
+
const ownerNamespace = process.env.ORY_USER_SUBJECT_NAMESPACE?.trim()
|
|
260
|
+
|| this.config.userSubjectNamespace
|
|
261
|
+
|| "User";
|
|
262
|
+
if (subject.namespace === ownerNamespace) {
|
|
263
|
+
if (subject.object === this.userPrincipal.subject) {
|
|
264
|
+
return { kind: "owner", namespace: subject.namespace };
|
|
265
|
+
}
|
|
266
|
+
throw httpError(403, "Permission owner does not match the authenticated user");
|
|
267
|
+
}
|
|
268
|
+
const parts = subject.object.split("|");
|
|
269
|
+
if (parts.some((part) => !part.trim())) {
|
|
270
|
+
throw httpError(403, "Permission principal contains an invalid subject separator");
|
|
271
|
+
}
|
|
272
|
+
if (subject.namespace === "Agent") {
|
|
273
|
+
if (parts[0] !== this.agentPrincipal.subject) {
|
|
274
|
+
throw httpError(403, "Permission agent does not match the authenticated agent");
|
|
275
|
+
}
|
|
276
|
+
if (parts.length === 1)
|
|
277
|
+
return { kind: "agent" };
|
|
278
|
+
if (parts.length === 2)
|
|
279
|
+
return { kind: "agent_session", session_id: parts[1] };
|
|
280
|
+
}
|
|
281
|
+
else if (subject.namespace === "SubAgent") {
|
|
282
|
+
if (parts.length === 1) {
|
|
283
|
+
const typeName = this.subAgentTypeForClient(parts[0]);
|
|
284
|
+
if (typeName)
|
|
285
|
+
return { kind: "subagent", type_name: typeName };
|
|
286
|
+
}
|
|
287
|
+
if ((parts.length === 3 || parts.length === 4) && this.subAgentTypeForClient(parts[0]) === parts[2]) {
|
|
288
|
+
return {
|
|
289
|
+
kind: "subagent_spawn",
|
|
290
|
+
session_id: parts[1],
|
|
291
|
+
type_name: parts[2],
|
|
292
|
+
...(parts[3] ? { per_spawn_id: parts[3] } : {}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
throw httpError(403, "Permission check principal cannot be represented safely");
|
|
297
|
+
}
|
|
298
|
+
subAgentTypeForClient(clientId) {
|
|
299
|
+
const bySession = (0, config_js_1.loadConfig)().agent?.subAgents?.[this.harness];
|
|
300
|
+
for (const byType of Object.values(bySession ?? {})) {
|
|
301
|
+
const match = Object.entries(byType).find(([, credentials]) => credentials.clientId === clientId);
|
|
302
|
+
if (match)
|
|
303
|
+
return match[0];
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
143
307
|
/** Snapshot of the current user principal. */
|
|
144
308
|
get userPrincipal() {
|
|
145
|
-
return
|
|
309
|
+
return this.userPrincipalForSession(this.sessionId);
|
|
310
|
+
}
|
|
311
|
+
/** Snapshot of the user principal bound to one session. */
|
|
312
|
+
userPrincipalForSession(sessionId) {
|
|
313
|
+
const session = sessionId?.trim();
|
|
314
|
+
const principal = session ? this._userPrincipalsBySession.get(session) : undefined;
|
|
315
|
+
return { ...(principal ?? this._userPrincipal) };
|
|
146
316
|
}
|
|
147
317
|
/** Snapshot of the current agent principal. */
|
|
148
318
|
get agentPrincipal() {
|
|
149
|
-
|
|
319
|
+
const runtimeSubject = (0, context_js_1.currentSessionContext)().runtimeCredential?.subject;
|
|
320
|
+
if (runtimeSubject)
|
|
321
|
+
return { subject: runtimeSubject };
|
|
322
|
+
return this.agentPrincipalForSession(this.sessionId);
|
|
323
|
+
}
|
|
324
|
+
/** Active noninteractive broker credential. It is process memory only. */
|
|
325
|
+
get runtimeCredential() {
|
|
326
|
+
const context = (0, context_js_1.currentSessionContext)();
|
|
327
|
+
if (context.runtimeCredential)
|
|
328
|
+
return context.runtimeCredential;
|
|
329
|
+
if (context.sessionId) {
|
|
330
|
+
const credential = this._runtimeCredentialsBySession.get(context.sessionId);
|
|
331
|
+
if (credential)
|
|
332
|
+
return credential;
|
|
333
|
+
}
|
|
334
|
+
if (this._runtimeCredential)
|
|
335
|
+
return this._runtimeCredential;
|
|
336
|
+
const principal = this.agentPrincipal;
|
|
337
|
+
return principal.token
|
|
338
|
+
? (0, runtime_credential_js_1.talosRuntimeCredential)(principal.token, { subject: principal.subject })
|
|
339
|
+
: undefined;
|
|
340
|
+
}
|
|
341
|
+
get hasRuntimeCredential() {
|
|
342
|
+
return this.runtimeCredential !== undefined;
|
|
343
|
+
}
|
|
344
|
+
setRuntimeCredential(credential) {
|
|
345
|
+
const sessionId = this.sessionId?.trim();
|
|
346
|
+
if (sessionId) {
|
|
347
|
+
if (credential)
|
|
348
|
+
this._runtimeCredentialsBySession.set(sessionId, credential);
|
|
349
|
+
else
|
|
350
|
+
this._runtimeCredentialsBySession.delete(sessionId);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
this._runtimeCredential = credential;
|
|
354
|
+
}
|
|
355
|
+
if (credential)
|
|
356
|
+
this.rememberRuntimeCredential(credential);
|
|
357
|
+
if (credential?.subject)
|
|
358
|
+
this.setAgentPrincipal({ subject: credential.subject }, sessionId);
|
|
359
|
+
}
|
|
360
|
+
/** Retain an actor-scoped credential for deferred event delivery without activating it globally. */
|
|
361
|
+
rememberRuntimeCredential(credential) {
|
|
362
|
+
if (credential.subject) {
|
|
363
|
+
this._runtimeCredentialsByPrincipalKey.set((0, event_reporter_js_1.eventPrincipalKey)(credential.subject), credential);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** Run one hook with session/credential state isolated from concurrent hooks. */
|
|
367
|
+
withRuntimeContext(context, work) {
|
|
368
|
+
if (context.runtimeCredential)
|
|
369
|
+
this.rememberRuntimeCredential(context.runtimeCredential);
|
|
370
|
+
return (0, context_js_1.withSessionContext)(context, work);
|
|
371
|
+
}
|
|
372
|
+
/** Snapshot of the agent principal bound to one session. */
|
|
373
|
+
agentPrincipalForSession(sessionId) {
|
|
374
|
+
const session = sessionId?.trim();
|
|
375
|
+
const principal = session ? this._agentPrincipalsBySession.get(session) : undefined;
|
|
376
|
+
return { ...(principal ?? this._agentPrincipal) };
|
|
377
|
+
}
|
|
378
|
+
eventPrincipal(sessionId, principalKey) {
|
|
379
|
+
if (principalKey) {
|
|
380
|
+
const principal = this._eventPrincipalsByKey.get(principalKey);
|
|
381
|
+
if (principal)
|
|
382
|
+
return { ...principal };
|
|
383
|
+
const sessionPrincipal = this.agentPrincipalForSession(sessionId);
|
|
384
|
+
if (sessionPrincipal.subject && (0, event_reporter_js_1.eventPrincipalKey)(sessionPrincipal.subject) === principalKey) {
|
|
385
|
+
return sessionPrincipal;
|
|
386
|
+
}
|
|
387
|
+
return {};
|
|
388
|
+
}
|
|
389
|
+
return this.agentPrincipalForSession(sessionId);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* The harness this client runs in (`"claude-code"`, `"codex"`, …). Exposed
|
|
393
|
+
* because persisted agent / sub-agent credentials and delegation anchors are
|
|
394
|
+
* keyed first by harness and then by session.
|
|
395
|
+
*/
|
|
396
|
+
get harness() {
|
|
397
|
+
return this.config.harness;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The harness session currently in scope, or `undefined` outside one.
|
|
401
|
+
*
|
|
402
|
+
* Read from standalone ambient session context, which every harness sets from its
|
|
403
|
+
* own session identifier at the top of a lifecycle invocation — so identity
|
|
404
|
+
* and delegation code can select the session's credential and node without
|
|
405
|
+
* each plugin threading the id through its own call chain.
|
|
406
|
+
*/
|
|
407
|
+
get sessionId() {
|
|
408
|
+
return (0, context_js_1.currentSessionContext)().sessionId;
|
|
409
|
+
}
|
|
410
|
+
/** Best-effort bounded flush for short-lived hook subprocesses and tests. */
|
|
411
|
+
async flushEvents(timeoutMs = 1_000) {
|
|
412
|
+
if (!this.eventReporter || timeoutMs <= 0)
|
|
413
|
+
return;
|
|
414
|
+
const startedAt = Date.now();
|
|
415
|
+
const remaining = () => Math.max(0, timeoutMs - (Date.now() - startedAt));
|
|
416
|
+
// Deliver sessions that already have a bearer before historical credential
|
|
417
|
+
// recovery can consume the short-lived hook process's entire deadline.
|
|
418
|
+
await this.eventReporter.flush(remaining());
|
|
419
|
+
if (remaining() <= 0)
|
|
420
|
+
return;
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const timer = setTimeout(() => controller.abort(), remaining());
|
|
423
|
+
timer.unref?.();
|
|
424
|
+
try {
|
|
425
|
+
for (const scope of this.eventReporter.pendingScopes) {
|
|
426
|
+
if (controller.signal.aborted)
|
|
427
|
+
break;
|
|
428
|
+
if (this.eventPrincipal(scope.sessionId, scope.principalKey).token)
|
|
429
|
+
continue;
|
|
430
|
+
try {
|
|
431
|
+
const { loadAllSubAgentDynamicCredentials, ensureSubAgentIdentity } = await import("./agent-auth.js");
|
|
432
|
+
const subAgent = scope.principalKey
|
|
433
|
+
? Object.entries(loadAllSubAgentDynamicCredentials(this.harness, scope.sessionId?.trim())).find(([, credentials]) => (0, event_reporter_js_1.eventPrincipalKey)(credentials.clientId) === scope.principalKey)
|
|
434
|
+
: undefined;
|
|
435
|
+
if (subAgent) {
|
|
436
|
+
const [subAgentType] = subAgent;
|
|
437
|
+
const identity = await (0, context_js_1.withSessionContext)({ sessionId: scope.sessionId }, () => ensureSubAgentIdentity(this, {
|
|
438
|
+
subAgentType,
|
|
439
|
+
harness: this.harness,
|
|
440
|
+
sessionId: scope.sessionId,
|
|
441
|
+
emitActivity: false,
|
|
442
|
+
signal: controller.signal,
|
|
443
|
+
}));
|
|
444
|
+
if (identity.subject && identity.token) {
|
|
445
|
+
this._eventPrincipalsByKey.set((0, event_reporter_js_1.eventPrincipalKey)(identity.subject), {
|
|
446
|
+
subject: identity.subject,
|
|
447
|
+
token: identity.token,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
const { ensureReadCredential } = await import("./read-credential.js");
|
|
453
|
+
await (0, context_js_1.withSessionContext)({ sessionId: scope.sessionId }, () => ensureReadCredential(this, {
|
|
454
|
+
signal: controller.signal,
|
|
455
|
+
allowRegistration: false,
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
this.logger.debug("events.credential_resolution_failed", {
|
|
461
|
+
message: error instanceof Error ? error.message : String(error),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
}
|
|
469
|
+
await this.eventReporter.flush(remaining());
|
|
150
470
|
}
|
|
151
471
|
/**
|
|
152
472
|
* Configured user-subject namespace (from config, resolved at construction).
|
|
@@ -159,53 +479,44 @@ class OryAgentClient {
|
|
|
159
479
|
}
|
|
160
480
|
/**
|
|
161
481
|
* Set or update the human user principal. The user is the subject of
|
|
162
|
-
* permission checks
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
/** Whether an admin API key is set for the Keto APIs (never exposes it). */
|
|
169
|
-
get hasAdminApiKey() {
|
|
170
|
-
return !!this._adminApiKey;
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Set or update the admin API key (an Ory Network project API key) used to
|
|
174
|
-
* authenticate the Keto Permission / Relationship APIs. Rebuilds those API
|
|
175
|
-
* instances when the key changes; a no-op update leaves them alone so test
|
|
176
|
-
* stubs survive. See {@link _adminApiKey} for why Keto needs a credential
|
|
177
|
-
* distinct from the agent's OAuth2 token.
|
|
482
|
+
* permission checks. Its token does not authenticate agent-attributed calls
|
|
483
|
+
* (OAuth2 / Frontend / Relationship), but it *is* the fallback credential for
|
|
484
|
+
* Keto permission reads when no agent token is present (see `buildApis`) — so
|
|
485
|
+
* a change to the user token rebuilds the API instances, otherwise the
|
|
486
|
+
* rehydrated user token would never reach the Permission API in a fresh
|
|
487
|
+
* subprocess. Pass `{}` (or fields set to undefined) to clear.
|
|
178
488
|
*/
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
this.oauth2 = oauth2;
|
|
186
|
-
this.permission = permission;
|
|
187
|
-
this.relationship = relationship;
|
|
489
|
+
setUserPrincipal(principal, sessionId = this.sessionId) {
|
|
490
|
+
const session = sessionId?.trim();
|
|
491
|
+
if (session)
|
|
492
|
+
this._userPrincipalsBySession.set(session, { ...principal });
|
|
493
|
+
else
|
|
494
|
+
this._userPrincipal = { ...principal };
|
|
188
495
|
}
|
|
189
496
|
/**
|
|
190
497
|
* Set or update the AI agent principal. The agent's token (when present)
|
|
191
|
-
* authenticates outgoing
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* so subsequent calls pick it up; otherwise leaves the API instances alone
|
|
197
|
-
* (so test stubs survive a no-op update).
|
|
498
|
+
* authenticates *every* outgoing Ory call — OAuth2 / Frontend and Keto
|
|
499
|
+
* (Permission / Relationship) alike — and carries audit attribution ("agent X
|
|
500
|
+
* acting on behalf of user Y"). Rebuilds the underlying Ory API instances when
|
|
501
|
+
* the token actually changes so subsequent calls pick it up; otherwise leaves
|
|
502
|
+
* the API instances alone (so test stubs survive a no-op update).
|
|
198
503
|
*/
|
|
199
|
-
setAgentPrincipal(principal) {
|
|
200
|
-
const tokenChanged = this.
|
|
201
|
-
|
|
504
|
+
setAgentPrincipal(principal, sessionId = this.sessionId) {
|
|
505
|
+
const tokenChanged = this.agentPrincipalForSession(sessionId).token !== principal.token;
|
|
506
|
+
const session = sessionId?.trim();
|
|
507
|
+
if (session)
|
|
508
|
+
this._agentPrincipalsBySession.set(session, { ...principal });
|
|
509
|
+
else
|
|
510
|
+
this._agentPrincipal = { ...principal };
|
|
511
|
+
if (principal.subject) {
|
|
512
|
+
this._eventPrincipalsByKey.set((0, event_reporter_js_1.eventPrincipalKey)(principal.subject), { ...principal });
|
|
513
|
+
}
|
|
202
514
|
if (!tokenChanged)
|
|
203
515
|
return;
|
|
204
|
-
const { frontend, oauth2, permission
|
|
516
|
+
const { frontend, oauth2, permission } = this.buildApis();
|
|
205
517
|
this.frontend = frontend;
|
|
206
518
|
this.oauth2 = oauth2;
|
|
207
519
|
this.permission = permission;
|
|
208
|
-
this.relationship = relationship;
|
|
209
520
|
}
|
|
210
521
|
// ─── Session (Ory Identities / Kratos) ──────────────────────────
|
|
211
522
|
/**
|
|
@@ -221,7 +532,7 @@ class OryAgentClient {
|
|
|
221
532
|
});
|
|
222
533
|
return cached.info;
|
|
223
534
|
}
|
|
224
|
-
const
|
|
535
|
+
const activity = this.logger.startActivity("session.verify");
|
|
225
536
|
try {
|
|
226
537
|
const response = await this.frontend.toSession({
|
|
227
538
|
xSessionToken: sessionToken,
|
|
@@ -254,7 +565,7 @@ class OryAgentClient {
|
|
|
254
565
|
active: info.active,
|
|
255
566
|
aal: info.authenticatorAssuranceLevel,
|
|
256
567
|
});
|
|
257
|
-
|
|
568
|
+
activity.end("ok", {
|
|
258
569
|
sessionId: info.sessionId,
|
|
259
570
|
identityId: info.identityId,
|
|
260
571
|
active: info.active,
|
|
@@ -269,7 +580,7 @@ class OryAgentClient {
|
|
|
269
580
|
status: oryErr.status,
|
|
270
581
|
message: oryErr.message,
|
|
271
582
|
});
|
|
272
|
-
|
|
583
|
+
activity.end("error", {
|
|
273
584
|
error: oryErr.code,
|
|
274
585
|
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
275
586
|
});
|
|
@@ -283,7 +594,7 @@ class OryAgentClient {
|
|
|
283
594
|
*/
|
|
284
595
|
async introspectToken(token, scope) {
|
|
285
596
|
this.logger.debug("oauth2.introspect.start", { hasToken: !!token });
|
|
286
|
-
const
|
|
597
|
+
const activity = this.logger.startActivity("oauth2.introspect");
|
|
287
598
|
try {
|
|
288
599
|
const response = await this.oauth2.introspectOAuth2Token({
|
|
289
600
|
token,
|
|
@@ -307,7 +618,7 @@ class OryAgentClient {
|
|
|
307
618
|
clientId: info.clientId,
|
|
308
619
|
subject: info.subject,
|
|
309
620
|
});
|
|
310
|
-
|
|
621
|
+
activity.end("ok", {
|
|
311
622
|
active: info.active,
|
|
312
623
|
clientId: info.clientId,
|
|
313
624
|
subject: info.subject,
|
|
@@ -322,7 +633,7 @@ class OryAgentClient {
|
|
|
322
633
|
status: oryErr.status,
|
|
323
634
|
message: oryErr.message,
|
|
324
635
|
});
|
|
325
|
-
|
|
636
|
+
activity.end("error", {
|
|
326
637
|
error: oryErr.code,
|
|
327
638
|
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
328
639
|
});
|
|
@@ -333,19 +644,23 @@ class OryAgentClient {
|
|
|
333
644
|
/**
|
|
334
645
|
* Check if a subject has permission to perform an action.
|
|
335
646
|
*
|
|
336
|
-
* `options.
|
|
337
|
-
*
|
|
647
|
+
* `options.activityAttributes` are merged into the `permission.check` activity
|
|
648
|
+
* event so callers can attach harness-side context (toolName, mcpServer,
|
|
338
649
|
* etc.) that the wire-level `PermissionCheck` shape doesn't carry.
|
|
650
|
+
*
|
|
651
|
+
* `options.informational` marks a check whose `false` result is *not* a
|
|
652
|
+
* denial — e.g. the permission-mode probe, where "enforce not granted" simply
|
|
653
|
+
* selects `observe`. Such an event ends `ok` (the read succeeded) rather than
|
|
654
|
+
* `denied`, so a reader watching activity doesn't mistake a routine mode read
|
|
655
|
+
* for a blocked tool. The boolean is still recorded as the `allowed`
|
|
656
|
+
* attribute either way.
|
|
339
657
|
*/
|
|
340
658
|
async checkPermission(check, options) {
|
|
341
659
|
this.logger.debug("permission.check.start", { ...check });
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
...this.principalSpanAttributes(),
|
|
347
|
-
...options?.spanAttributes,
|
|
348
|
-
},
|
|
660
|
+
const activity = this.logger.startActivity("permission.check", {
|
|
661
|
+
object: check.object,
|
|
662
|
+
relation: check.relation,
|
|
663
|
+
...options?.activityAttributes,
|
|
349
664
|
});
|
|
350
665
|
try {
|
|
351
666
|
const response = await this.permission.checkPermission({
|
|
@@ -372,7 +687,7 @@ class OryAgentClient {
|
|
|
372
687
|
subjectId: check.subjectId,
|
|
373
688
|
subjectSet: check.subjectSet,
|
|
374
689
|
});
|
|
375
|
-
|
|
690
|
+
activity.end(options?.informational || result.allowed ? "ok" : "denied", {
|
|
376
691
|
allowed: result.allowed,
|
|
377
692
|
...(oryRequestId ? { oryRequestId } : {}),
|
|
378
693
|
});
|
|
@@ -380,14 +695,13 @@ class OryAgentClient {
|
|
|
380
695
|
}
|
|
381
696
|
catch (err) {
|
|
382
697
|
const oryErr = this.classifyError(err);
|
|
383
|
-
this.warnIfKetoCredentialMismatch(oryErr);
|
|
384
698
|
this.logger.error("permission.check.failed", {
|
|
385
699
|
code: oryErr.code,
|
|
386
700
|
status: oryErr.status,
|
|
387
701
|
message: oryErr.message,
|
|
388
702
|
...check,
|
|
389
703
|
});
|
|
390
|
-
|
|
704
|
+
activity.end("error", {
|
|
391
705
|
error: oryErr.code,
|
|
392
706
|
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
393
707
|
});
|
|
@@ -397,19 +711,16 @@ class OryAgentClient {
|
|
|
397
711
|
/**
|
|
398
712
|
* Check multiple permissions in a single request.
|
|
399
713
|
*
|
|
400
|
-
* `options.
|
|
401
|
-
*
|
|
714
|
+
* `options.activityAttributes` are merged into the `permission.batch_check`
|
|
715
|
+
* activity event, for the same purpose as `checkPermission`'s attributes.
|
|
402
716
|
*/
|
|
403
717
|
async batchCheckPermissions(checks, options) {
|
|
404
718
|
this.logger.debug("permission.batch_check.start", {
|
|
405
719
|
count: checks.length,
|
|
406
720
|
});
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
...this.principalSpanAttributes(),
|
|
411
|
-
...options?.spanAttributes,
|
|
412
|
-
},
|
|
721
|
+
const activity = this.logger.startActivity("permission.batch_check", {
|
|
722
|
+
count: checks.length,
|
|
723
|
+
...options?.activityAttributes,
|
|
413
724
|
});
|
|
414
725
|
try {
|
|
415
726
|
const response = await this.permission.batchCheckPermission({
|
|
@@ -446,7 +757,7 @@ class OryAgentClient {
|
|
|
446
757
|
allowed,
|
|
447
758
|
denied,
|
|
448
759
|
});
|
|
449
|
-
|
|
760
|
+
activity.end("ok", {
|
|
450
761
|
allowed,
|
|
451
762
|
denied,
|
|
452
763
|
...(oryRequestId ? { oryRequestId } : {}),
|
|
@@ -455,261 +766,177 @@ class OryAgentClient {
|
|
|
455
766
|
}
|
|
456
767
|
catch (err) {
|
|
457
768
|
const oryErr = this.classifyError(err);
|
|
458
|
-
this.warnIfKetoCredentialMismatch(oryErr);
|
|
459
769
|
this.logger.error("permission.batch_check.failed", {
|
|
460
770
|
code: oryErr.code,
|
|
461
771
|
status: oryErr.status,
|
|
462
772
|
message: oryErr.message,
|
|
463
773
|
});
|
|
464
|
-
|
|
774
|
+
activity.end("error", {
|
|
465
775
|
error: oryErr.code,
|
|
466
776
|
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
467
777
|
});
|
|
468
778
|
throw oryErr;
|
|
469
779
|
}
|
|
470
780
|
}
|
|
471
|
-
// ───
|
|
781
|
+
// ─── Delegation (agent-security broker) ──────────────────────────
|
|
472
782
|
/**
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* the
|
|
783
|
+
* Record a delegation edge through the Ory Agent Security broker
|
|
784
|
+
* (`POST <agentSecurityUrl>/agent-security/v1/delegations:record`), authenticated
|
|
785
|
+
* with the process runtime credential. The broker constructs and writes the Keto tuple — the plugin
|
|
786
|
+
* sends only the semantic inputs, so the join-key/tuple encoding stays
|
|
787
|
+
* server-side and cannot drift here. Returns the node id + chain the broker
|
|
788
|
+
* assigned. Throws a classified {@link OryError} on any non-2xx; callers wrap
|
|
789
|
+
* in fail-open semantics (delegation is audit-only).
|
|
477
790
|
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
791
|
+
* The broker exists only on hosted Ory; against the local dev stack the call
|
|
792
|
+
* returns 404 and callers treat it as a best-effort no-op.
|
|
480
793
|
*/
|
|
481
|
-
async
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
794
|
+
async recordDelegation(input, options) {
|
|
795
|
+
const kind = input.kind === "agent" ? "PRINCIPAL_KIND_AGENT" : "PRINCIPAL_KIND_SUBAGENT";
|
|
796
|
+
const sessionId = input.sessionId ?? this.sessionId;
|
|
797
|
+
// `session_id` and `per_spawn_id` belong on `principal` — that is where the
|
|
798
|
+
// broker's proto declares them. They were previously sent at the top level
|
|
799
|
+
// of the body, where `RecordDelegationBody` has no such fields; the
|
|
800
|
+
// gateway's protojson runs with `DiscardUnknown: true`, so every value was
|
|
801
|
+
// silently dropped and the server never saw a session at all.
|
|
802
|
+
const body = {
|
|
803
|
+
project_url: this.config.projectUrl,
|
|
804
|
+
...(input.identityId ? { identity_id: input.identityId } : {}),
|
|
805
|
+
harness: this.config.harness,
|
|
806
|
+
principal: {
|
|
807
|
+
kind,
|
|
808
|
+
...(input.host ? { host: input.host } : {}),
|
|
809
|
+
...(input.subAgentType ? { type_name: input.subAgentType } : {}),
|
|
810
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
811
|
+
...(input.perSpawnId ? { per_spawn_id: input.perSpawnId } : {}),
|
|
490
812
|
},
|
|
491
|
-
|
|
813
|
+
...(input.delegatedBy ? { delegated_by: input.delegatedBy } : {}),
|
|
814
|
+
};
|
|
815
|
+
const activity = this.logger.startActivity("delegation.record", { delegationKind: input.kind, ...options?.activityAttributes }, sessionId);
|
|
816
|
+
const url = `${this.agentSecurityUrl.replace(/\/+$/, "")}/agent-security/v1/delegations:record`;
|
|
817
|
+
// The user bearer authenticates who delegated; the agent or sub-agent bearer
|
|
818
|
+
// separately proves the principal being attached to the delegation edge.
|
|
819
|
+
const userToken = this.userPrincipalForSession(sessionId).token;
|
|
820
|
+
const runtimeCredential = options?.runtimeCredential ?? (options?.agentToken
|
|
821
|
+
? (0, runtime_credential_js_1.talosRuntimeCredential)(options.agentToken)
|
|
822
|
+
: this.runtimeCredential);
|
|
492
823
|
try {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
: {}),
|
|
824
|
+
if (!userToken || !runtimeCredential) {
|
|
825
|
+
throw httpError(403, "User token and runtime credential are required");
|
|
826
|
+
}
|
|
827
|
+
const res = await (0, runtime_credential_js_1.authenticatedRuntimeRequest)(runtimeCredential, {
|
|
828
|
+
url,
|
|
829
|
+
init: {
|
|
830
|
+
method: "POST",
|
|
831
|
+
headers: {
|
|
832
|
+
"content-type": "application/json",
|
|
833
|
+
authorization: `Bearer ${userToken}`,
|
|
834
|
+
},
|
|
835
|
+
body: JSON.stringify(body),
|
|
836
|
+
signal: options?.signal ?? AbortSignal.timeout(30_000),
|
|
508
837
|
},
|
|
838
|
+
authorizationHeader: "x-ory-agent-authorization",
|
|
839
|
+
fetch: options?.fetchImpl,
|
|
509
840
|
});
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
relation: check.relation,
|
|
515
|
-
subjectId: check.subjectId,
|
|
516
|
-
});
|
|
517
|
-
span.end("ok", {
|
|
518
|
-
created: true,
|
|
519
|
-
...(oryRequestId ? { oryRequestId } : {}),
|
|
520
|
-
});
|
|
521
|
-
return { created: true, alreadyExisted: false };
|
|
522
|
-
}
|
|
523
|
-
catch (err) {
|
|
524
|
-
const oryErr = this.classifyError(err);
|
|
525
|
-
// Keto returns 409 when the tuple already exists; treat as success
|
|
526
|
-
// so callers can call this on every session start without dedup.
|
|
527
|
-
if (oryErr.status === 409) {
|
|
528
|
-
this.logger.debug("relationship.create.exists", {
|
|
529
|
-
namespace: check.namespace,
|
|
530
|
-
object: check.object,
|
|
531
|
-
relation: check.relation,
|
|
532
|
-
});
|
|
533
|
-
span.end("ok", {
|
|
534
|
-
created: false,
|
|
535
|
-
alreadyExisted: true,
|
|
536
|
-
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
537
|
-
});
|
|
538
|
-
return { created: false, alreadyExisted: true };
|
|
841
|
+
if (!res.ok) {
|
|
842
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
843
|
+
err.status = res.status;
|
|
844
|
+
throw err;
|
|
539
845
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
* True if the *exact* relation tuple already exists in Keto. Queries the
|
|
556
|
-
* relation-tuple listing API filtered by the tuple's own coordinates
|
|
557
|
-
* (namespace/object/relation + subject), so — unlike `checkPermission` —
|
|
558
|
-
* it does not follow subject-set expansion: it answers "is this literal
|
|
559
|
-
* tuple stored?", not "does this permission resolve?". Throws a classified
|
|
560
|
-
* {@link OryError} if the query itself fails.
|
|
561
|
-
*/
|
|
562
|
-
async relationshipExists(check) {
|
|
563
|
-
try {
|
|
564
|
-
const response = await this.relationship.getRelationships({
|
|
565
|
-
namespace: check.namespace,
|
|
566
|
-
object: check.object,
|
|
567
|
-
relation: check.relation,
|
|
568
|
-
...(check.subjectId ? { subjectId: check.subjectId } : {}),
|
|
569
|
-
...(check.subjectSet
|
|
570
|
-
? {
|
|
571
|
-
subjectSetNamespace: check.subjectSet.namespace,
|
|
572
|
-
subjectSetObject: check.subjectSet.object,
|
|
573
|
-
subjectSetRelation: check.subjectSet.relation,
|
|
574
|
-
}
|
|
575
|
-
: {}),
|
|
576
|
-
pageSize: 1,
|
|
577
|
-
});
|
|
578
|
-
const tuples = response.data?.relation_tuples ?? [];
|
|
579
|
-
return tuples.length > 0;
|
|
580
|
-
}
|
|
581
|
-
catch (err) {
|
|
582
|
-
throw this.classifyError(err);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
/**
|
|
586
|
-
* Idempotent relationship write. Keto's create API is **not** idempotent —
|
|
587
|
-
* `PUT /admin/relation-tuples` inserts a fresh row on every call (each with
|
|
588
|
-
* a new primary key) and never returns 409, so calling `createRelationship`
|
|
589
|
-
* on repeated bootstraps / session starts accumulates duplicate tuples.
|
|
590
|
-
* This reads the exact tuple first (via {@link relationshipExists}) and only
|
|
591
|
-
* writes when it is absent — which also self-heals if the tuple was deleted
|
|
592
|
-
* out of band.
|
|
593
|
-
*
|
|
594
|
-
* If the existence probe itself fails, the tuple is left untouched (we do
|
|
595
|
-
* not write blind, to avoid re-introducing duplicates when the read path is
|
|
596
|
-
* misbehaving) and the classified error is returned as `probeError`. Callers
|
|
597
|
-
* that must guarantee the grant exists (e.g. install-time bootstrap) can
|
|
598
|
-
* fall back to {@link createRelationship} when `probeError` is set.
|
|
599
|
-
*/
|
|
600
|
-
async ensureRelationship(check, options) {
|
|
601
|
-
let exists;
|
|
602
|
-
try {
|
|
603
|
-
exists = await this.relationshipExists(check);
|
|
604
|
-
}
|
|
605
|
-
catch (err) {
|
|
606
|
-
return { created: false, alreadyExisted: false, probeError: err };
|
|
607
|
-
}
|
|
608
|
-
if (exists)
|
|
609
|
-
return { created: false, alreadyExisted: true };
|
|
610
|
-
return this.createRelationship(check, options);
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* Delete a relation tuple in Keto. Missing tuples (404) are treated as
|
|
614
|
-
* success so callers can call this idempotently when unwinding state.
|
|
615
|
-
*/
|
|
616
|
-
async deleteRelationship(check, options) {
|
|
617
|
-
this.logger.debug("relationship.delete.start", { ...check });
|
|
618
|
-
const span = this.tracer.startSpan("relationship.delete", {
|
|
619
|
-
attributes: {
|
|
620
|
-
namespace: check.namespace,
|
|
621
|
-
object: check.object,
|
|
622
|
-
relation: check.relation,
|
|
623
|
-
...this.principalSpanAttributes(),
|
|
624
|
-
...options?.spanAttributes,
|
|
625
|
-
},
|
|
626
|
-
});
|
|
627
|
-
try {
|
|
628
|
-
const response = await this.relationship.deleteRelationships({
|
|
629
|
-
namespace: check.namespace,
|
|
630
|
-
object: check.object,
|
|
631
|
-
relation: check.relation,
|
|
632
|
-
subjectId: check.subjectId,
|
|
633
|
-
...(check.subjectSet
|
|
634
|
-
? {
|
|
635
|
-
subjectSetNamespace: check.subjectSet.namespace,
|
|
636
|
-
subjectSetObject: check.subjectSet.object,
|
|
637
|
-
subjectSetRelation: check.subjectSet.relation,
|
|
638
|
-
}
|
|
639
|
-
: {}),
|
|
640
|
-
});
|
|
641
|
-
const oryRequestId = extractOryRequestId(response.headers);
|
|
642
|
-
span.end("ok", {
|
|
643
|
-
deleted: true,
|
|
644
|
-
...(oryRequestId ? { oryRequestId } : {}),
|
|
645
|
-
});
|
|
646
|
-
return { deleted: true, notFound: false };
|
|
846
|
+
const json = (await res.json());
|
|
847
|
+
// Accept both spellings. The broker's OpenAPI document carries the proto
|
|
848
|
+
// field names (`node_id`), but grpc-gateway's default marshaler emits
|
|
849
|
+
// lowerCamelCase — reading only one silently yields "" on the other,
|
|
850
|
+
// which reads as "no node" and stops every sub-agent edge from being
|
|
851
|
+
// recorded. Tolerating both also lets the two repos deploy in any order.
|
|
852
|
+
const raw = json.node_id ?? json.nodeId;
|
|
853
|
+
const nodeId = typeof raw === "string" ? raw : "";
|
|
854
|
+
const chain = json.delegation_chain ?? json.delegationChain;
|
|
855
|
+
const delegationChain = Array.isArray(chain)
|
|
856
|
+
? chain.filter((c) => typeof c === "string")
|
|
857
|
+
: [];
|
|
858
|
+
activity.end("ok", { nodeId });
|
|
859
|
+
this.logger.debug("delegation.record.result", { kind: input.kind, nodeId });
|
|
860
|
+
return { nodeId, delegationChain };
|
|
647
861
|
}
|
|
648
862
|
catch (err) {
|
|
649
863
|
const oryErr = this.classifyError(err);
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
notFound: true,
|
|
654
|
-
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
655
|
-
});
|
|
656
|
-
return { deleted: false, notFound: true };
|
|
657
|
-
}
|
|
658
|
-
this.warnIfKetoCredentialMismatch(oryErr);
|
|
659
|
-
this.logger.error("relationship.delete.failed", {
|
|
864
|
+
activity.end("error", { error: oryErr.code });
|
|
865
|
+
this.logger.debug("delegation.record.failed", {
|
|
866
|
+
kind: input.kind,
|
|
660
867
|
code: oryErr.code,
|
|
661
868
|
status: oryErr.status,
|
|
662
869
|
message: oryErr.message,
|
|
663
|
-
...check,
|
|
664
|
-
});
|
|
665
|
-
span.end("error", {
|
|
666
|
-
error: oryErr.code,
|
|
667
|
-
...(oryErr.requestId ? { oryRequestId: oryErr.requestId } : {}),
|
|
668
870
|
});
|
|
669
871
|
throw oryErr;
|
|
670
872
|
}
|
|
671
873
|
}
|
|
672
874
|
/**
|
|
673
|
-
*
|
|
674
|
-
* client
|
|
875
|
+
* Activity attributes describing which principals are attached to the
|
|
876
|
+
* client. Useful for correlating audit logs
|
|
675
877
|
* across the user/agent split.
|
|
676
878
|
*/
|
|
677
|
-
|
|
879
|
+
principalActivityAttributes() {
|
|
678
880
|
const out = {};
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (
|
|
682
|
-
out.
|
|
881
|
+
const user = this.userPrincipal;
|
|
882
|
+
const agent = this.agentPrincipal;
|
|
883
|
+
if (user.subject)
|
|
884
|
+
out.userSubject = user.subject;
|
|
885
|
+
if (agent.subject)
|
|
886
|
+
out.agentSubject = agent.subject;
|
|
683
887
|
return out;
|
|
684
888
|
}
|
|
685
|
-
/**
|
|
686
|
-
* When a Keto call is auth-rejected while it was authenticated by something
|
|
687
|
-
* other than an Ory Network project API key (`ory_pat_…`) — e.g. a DCR
|
|
688
|
-
* OAuth2 access token — the credential *type* is wrong, not merely expired.
|
|
689
|
-
* Ory Network's Permission / Relationship APIs only accept a project API
|
|
690
|
-
* key. Emit an actionable hint so the failure isn't misread as a stale
|
|
691
|
-
* session (the generic `session_inactive` classification of the raw 401).
|
|
692
|
-
*/
|
|
693
|
-
warnIfKetoCredentialMismatch(oryErr) {
|
|
694
|
-
if (oryErr.code !== "session_inactive" && oryErr.code !== "forbidden")
|
|
695
|
-
return;
|
|
696
|
-
const ketoToken = this._adminApiKey ?? this._agentPrincipal.token;
|
|
697
|
-
// A real project API key is already in use — treat as a genuine auth error.
|
|
698
|
-
if (typeof ketoToken === "string" && ketoToken.startsWith("ory_pat_"))
|
|
699
|
-
return;
|
|
700
|
-
this.logger.warn("permission.credential_mismatch", {
|
|
701
|
-
code: oryErr.code,
|
|
702
|
-
message: "Ory Network's Permission/Relationship APIs require a project API key " +
|
|
703
|
-
"(ory_pat_…); the agent's OAuth2 access token is not accepted. Create a " +
|
|
704
|
-
"key in the Ory Console (Project settings → API keys) and set it with " +
|
|
705
|
-
"`configure --api-key <key>` or the ORY_AGENT_API_KEY env var.",
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
889
|
// ─── Error Classification ────────────────────────────────────────
|
|
709
890
|
/**
|
|
710
891
|
* Classify an error from any Ory API call into a structured OryError.
|
|
711
892
|
*/
|
|
712
893
|
classifyError(err) {
|
|
894
|
+
// ── Transport failures, before anything status-shaped ──────────────
|
|
895
|
+
//
|
|
896
|
+
// Ordering matters and was the whole bug in #218. An axios error carries
|
|
897
|
+
// `isAxiosError` whether or not a response arrived, so the status table below
|
|
898
|
+
// claimed *every* transport failure and — with `status === undefined` — fell
|
|
899
|
+
// through to `unknown`, which fails **open** in both modes. That silently
|
|
900
|
+
// downgraded an `enforce` project to `observe` on any certificate problem, and
|
|
901
|
+
// made breaking TLS sufficient to disable all tool gating.
|
|
902
|
+
//
|
|
903
|
+
// It also made the socket-code list further down dead code for every
|
|
904
|
+
// axios-based call: a plain `ECONNREFUSED` classified as `unknown` too, so
|
|
905
|
+
// `network_error` was effectively unreachable from a permission check.
|
|
906
|
+
//
|
|
907
|
+
// A response-less error is by definition a transport failure, so it is
|
|
908
|
+
// classified here, by cause, and never by HTTP status.
|
|
909
|
+
const tlsCode = findTlsErrorCode(err);
|
|
910
|
+
if (tlsCode) {
|
|
911
|
+
return {
|
|
912
|
+
code: "tls_error",
|
|
913
|
+
message: `TLS/certificate verification failed: ${tlsCode} — ${err.message}. ` +
|
|
914
|
+
"The permission check could not be completed against a verified host. " +
|
|
915
|
+
"For a local stack with a private CA, set NODE_EXTRA_CA_CERTS to its root certificate.",
|
|
916
|
+
cause: err,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
const socketCode = findSocketErrorCode(err);
|
|
920
|
+
if (socketCode) {
|
|
921
|
+
return {
|
|
922
|
+
code: "network_error",
|
|
923
|
+
message: `Network error: ${socketCode} — ${err.message}`,
|
|
924
|
+
cause: err,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
// A plain Error carrying a top-level numeric `status`. The `fetch`-based
|
|
928
|
+
// calls (the agent-security broker) raise this shape rather than an axios
|
|
929
|
+
// error, and without this branch every one of them classified as `unknown`
|
|
930
|
+
// with no status at all — so a broker 403 was indistinguishable in the audit
|
|
931
|
+
// trail from a parse failure. Normalized into the axios shape so there is
|
|
932
|
+
// exactly one status→code table below.
|
|
933
|
+
if (!this.isAxiosError(err) && typeof err?.status === "number") {
|
|
934
|
+
return this.classifyError({
|
|
935
|
+
isAxiosError: true,
|
|
936
|
+
message: err.message,
|
|
937
|
+
response: { status: err.status },
|
|
938
|
+
});
|
|
939
|
+
}
|
|
713
940
|
// Axios errors have a `response` property with status + data
|
|
714
941
|
if (this.isAxiosError(err)) {
|
|
715
942
|
const status = err.response?.status;
|
|
@@ -782,29 +1009,29 @@ class OryAgentClient {
|
|
|
782
1009
|
* Create a client from environment variables, falling back to the
|
|
783
1010
|
* shared config file at ~/.config/ory-agent-plugins/config.json.
|
|
784
1011
|
*
|
|
785
|
-
* Resolution order for projectUrl
|
|
786
|
-
* 1. Environment variables (ORY_PROJECT_URL
|
|
1012
|
+
* Resolution order for projectUrl:
|
|
1013
|
+
* 1. Environment variables (ORY_PROJECT_URL / ORY_SDK_URL)
|
|
787
1014
|
* 2. Config file (~/.config/ory-agent-plugins/config.json)
|
|
788
1015
|
* 3. Placeholder (fail-open pass-through mode)
|
|
789
1016
|
*/
|
|
790
|
-
static fromEnv(harness) {
|
|
1017
|
+
static fromEnv(harness, runtime = {}) {
|
|
791
1018
|
const resolved = (0, config_js_1.resolveConfig)();
|
|
792
1019
|
const projectUrl = resolved.projectUrl;
|
|
793
1020
|
const logFile = resolveDebugLogPath(harness);
|
|
794
|
-
const traceFile = resolveTraceFilePath(harness);
|
|
795
1021
|
if (!projectUrl) {
|
|
796
1022
|
// Use a placeholder URL so the client can be constructed.
|
|
797
1023
|
// API calls will fail with network_error which is handled as fail-open.
|
|
798
1024
|
const client = new OryAgentClient({
|
|
799
1025
|
projectUrl: "http://localhost:0",
|
|
800
|
-
|
|
1026
|
+
agentSecurityUrl: resolved.agentSecurityUrl ?? "http://localhost:0",
|
|
801
1027
|
harness,
|
|
802
1028
|
logFile,
|
|
803
|
-
traceFile,
|
|
804
1029
|
userSubjectNamespace: resolved.userSubjectNamespace,
|
|
805
1030
|
sessionCacheTtlMs: process.env.ORY_SESSION_CACHE_TTL_MS
|
|
806
1031
|
? parseInt(process.env.ORY_SESSION_CACHE_TTL_MS, 10)
|
|
807
1032
|
: undefined,
|
|
1033
|
+
eventReporter: undefined,
|
|
1034
|
+
...runtime,
|
|
808
1035
|
});
|
|
809
1036
|
client.logger.warn("config.missing_project_url", {
|
|
810
1037
|
message: "ORY_PROJECT_URL is not set and no config file found. " +
|
|
@@ -812,26 +1039,29 @@ class OryAgentClient {
|
|
|
812
1039
|
"Set ORY_PROJECT_URL or run the plugin's configure command.",
|
|
813
1040
|
});
|
|
814
1041
|
rehydrateUserPrincipal(client);
|
|
815
|
-
attachOtlpExporterFromEnv(client, harness);
|
|
816
1042
|
return client;
|
|
817
1043
|
}
|
|
818
1044
|
const client = new OryAgentClient({
|
|
819
1045
|
projectUrl,
|
|
820
|
-
|
|
1046
|
+
agentSecurityUrl: resolved.agentSecurityUrl,
|
|
821
1047
|
harness,
|
|
822
1048
|
logFile,
|
|
823
|
-
traceFile,
|
|
824
1049
|
userSubjectNamespace: resolved.userSubjectNamespace,
|
|
825
1050
|
sessionCacheTtlMs: process.env.ORY_SESSION_CACHE_TTL_MS
|
|
826
1051
|
? parseInt(process.env.ORY_SESSION_CACHE_TTL_MS, 10)
|
|
827
1052
|
: undefined,
|
|
1053
|
+
eventReporter: resolved.security.connected
|
|
1054
|
+
? {
|
|
1055
|
+
outboxDir: path.join((0, config_js_1.getHarnessDataDir)(harness), "events-outbox", (0, event_reporter_js_1.eventOutboxProjectScope)(projectUrl)),
|
|
1056
|
+
}
|
|
1057
|
+
: undefined,
|
|
1058
|
+
...runtime,
|
|
828
1059
|
});
|
|
829
1060
|
client.logger.debug("config.resolved", {
|
|
830
1061
|
projectUrlSource: resolved.projectUrlSource,
|
|
831
|
-
|
|
1062
|
+
agentSecurityUrlSource: resolved.agentSecurityUrlSource,
|
|
832
1063
|
});
|
|
833
1064
|
rehydrateUserPrincipal(client);
|
|
834
|
-
attachOtlpExporterFromEnv(client, harness);
|
|
835
1065
|
return client;
|
|
836
1066
|
}
|
|
837
1067
|
}
|
|
@@ -855,13 +1085,39 @@ exports.OryAgentClient = OryAgentClient;
|
|
|
855
1085
|
* with fresh values when it runs at `SessionStart`.
|
|
856
1086
|
*
|
|
857
1087
|
* The subject is attached whenever it is known — even for an expired token,
|
|
858
|
-
* since the subject is what permission checks resolve against
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1088
|
+
* since the subject is what permission checks resolve against. The access
|
|
1089
|
+
* token itself is only carried forward while still valid, so delegation
|
|
1090
|
+
* authentication never uses a stale bearer. Permission reads independently
|
|
1091
|
+
* resolve the required agent credential.
|
|
1092
|
+
*
|
|
1093
|
+
* Two sources feed it, in the same order the login gate uses: a credential
|
|
1094
|
+
* supplied through the environment (`ORY_USER_OAUTH2_TOKEN`) first, then the
|
|
1095
|
+
* persisted tokens. The env branch exists because the gate's `env_token` short-circuit
|
|
1096
|
+
* attaches that credential *in memory only* and runs only at `SessionStart`;
|
|
1097
|
+
* reading it here is what makes a pre-supplied token apply on every lifecycle
|
|
1098
|
+
* event rather than just the first one.
|
|
862
1099
|
*/
|
|
863
1100
|
function rehydrateUserPrincipal(client) {
|
|
1101
|
+
// An env-supplied credential wins, matching the login gate's own ordering
|
|
1102
|
+
// (its `env_token` short-circuit is checked before persisted tokens). That
|
|
1103
|
+
// gate only runs on `SessionStart`, and it attaches the credential in memory
|
|
1104
|
+
// without persisting it — so without this branch `ORY_USER_OAUTH2_TOKEN`
|
|
1105
|
+
// authenticated the session and then silently stopped
|
|
1106
|
+
// applying on every subsequent event. On a subprocess harness that left the
|
|
1107
|
+
// tool-call client with no user credential for delegation or DCR bootstrap.
|
|
1108
|
+
const env = (0, auth_store_js_1.readEnvUserCredential)();
|
|
864
1109
|
const tokens = (0, auth_store_js_1.loadTokens)();
|
|
1110
|
+
if (env.token) {
|
|
1111
|
+
client.setUserPrincipal({
|
|
1112
|
+
// The operator-pinned subject wins; fall back to the persisted one so an
|
|
1113
|
+
// env token supplied alongside a completed login keeps its identity.
|
|
1114
|
+
...(env.subject ?? tokens?.subject
|
|
1115
|
+
? { subject: env.subject ?? tokens?.subject }
|
|
1116
|
+
: {}),
|
|
1117
|
+
token: env.token,
|
|
1118
|
+
});
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
865
1121
|
if (!tokens?.subject)
|
|
866
1122
|
return;
|
|
867
1123
|
client.setUserPrincipal({
|
|
@@ -869,17 +1125,15 @@ function rehydrateUserPrincipal(client) {
|
|
|
869
1125
|
token: (0, auth_store_js_1.isExpired)(tokens) ? undefined : tokens.accessToken,
|
|
870
1126
|
});
|
|
871
1127
|
}
|
|
872
|
-
/** Default debug
|
|
1128
|
+
/** Default unified activity/debug log path under the shared per-harness data dir. */
|
|
873
1129
|
exports.DEFAULT_DEBUG_LOG_FILENAME = "ory-agent-debug.log";
|
|
874
|
-
/** Default trace (NDJSON) path under the shared per-harness data dir. */
|
|
875
|
-
exports.DEFAULT_TRACE_FILENAME = "ory-agent-trace.ndjson";
|
|
876
1130
|
/**
|
|
877
1131
|
* Resolve a file path from an env var with a sensible default under the
|
|
878
1132
|
* per-harness data dir. Semantics:
|
|
879
1133
|
* - unset → the default path (so post-install runs are observable
|
|
880
1134
|
* without hand-setting env vars),
|
|
881
1135
|
* - set to a path → that path (operator / dev-launcher override),
|
|
882
|
-
* - set but empty → `undefined` (explicit opt-out, e.g. `
|
|
1136
|
+
* - set but empty → `undefined` (explicit opt-out, e.g. `ORY_AGENT_LOG_FILE=`).
|
|
883
1137
|
*/
|
|
884
1138
|
function resolveFilePath(envValue, defaultPath) {
|
|
885
1139
|
if (envValue === undefined)
|
|
@@ -888,40 +1142,12 @@ function resolveFilePath(envValue, defaultPath) {
|
|
|
888
1142
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
889
1143
|
}
|
|
890
1144
|
/**
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
* file automatically — important for subprocess-hook harnesses whose stderr
|
|
894
|
-
* the harness may swallow.
|
|
1145
|
+
* Unified activity/debug log path. Activity is always appended; verbose
|
|
1146
|
+
* debug/info/warn/error entries are appended only when `ORY_AGENT_DEBUG=true`.
|
|
895
1147
|
*/
|
|
896
1148
|
function resolveDebugLogPath(harness) {
|
|
897
1149
|
return resolveFilePath(process.env.ORY_AGENT_LOG_FILE, path.join((0, config_js_1.getHarnessDataDir)(harness), exports.DEFAULT_DEBUG_LOG_FILENAME));
|
|
898
1150
|
}
|
|
899
|
-
/** Trace (NDJSON) path. Written on every recorded span when set. */
|
|
900
|
-
function resolveTraceFilePath(harness) {
|
|
901
|
-
return resolveFilePath(process.env.ORY_AGENT_TRACE_FILE, path.join((0, config_js_1.getHarnessDataDir)(harness), exports.DEFAULT_TRACE_FILENAME));
|
|
902
|
-
}
|
|
903
|
-
function attachOtlpExporterFromEnv(client, harness) {
|
|
904
|
-
const meta = client.tracer.processMetadata;
|
|
905
|
-
const exporter = (0, otlp_js_1.otlpExporterFromEnv)({
|
|
906
|
-
harness,
|
|
907
|
-
hostname: meta.hostname,
|
|
908
|
-
user: meta.user,
|
|
909
|
-
pluginVersion: meta.pluginVersion,
|
|
910
|
-
cwd: meta.cwd,
|
|
911
|
-
gitBranch: meta.gitBranch,
|
|
912
|
-
gitCommit: meta.gitCommit,
|
|
913
|
-
onError: (err) => client.logger.warn("otel.export.failed", {
|
|
914
|
-
message: err instanceof Error ? err.message : String(err),
|
|
915
|
-
}),
|
|
916
|
-
});
|
|
917
|
-
if (!exporter)
|
|
918
|
-
return;
|
|
919
|
-
client.tracer.setExporter(exporter);
|
|
920
|
-
client.logger.info("otel.export.enabled", {
|
|
921
|
-
endpoint: exporter.endpoint,
|
|
922
|
-
serviceName: exporter.resource.attributes["service.name"],
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
1151
|
/**
|
|
926
1152
|
* Pull the Ory backend request ID from a response headers bag (axios returns
|
|
927
1153
|
* lowercase header names). Returns undefined when not present.
|
|
@@ -940,3 +1166,58 @@ function extractOryRequestId(headers) {
|
|
|
940
1166
|
return value[0];
|
|
941
1167
|
return undefined;
|
|
942
1168
|
}
|
|
1169
|
+
/**
|
|
1170
|
+
* The TLS/certificate error code on `err`, walking `cause` because HTTP clients
|
|
1171
|
+
* wrap the underlying socket error.
|
|
1172
|
+
*
|
|
1173
|
+
* Matched by *shape*, not an exhaustive list. OpenSSL codes are SCREAMING_SNAKE
|
|
1174
|
+
* and name the artifact that failed (`CERT_HAS_EXPIRED`,
|
|
1175
|
+
* `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `SELF_SIGNED_CERT_IN_CHAIN`,
|
|
1176
|
+
* `DEPTH_ZERO_SELF_SIGNED_CERT`, …), and Node adds `ERR_TLS_*`. A missed code
|
|
1177
|
+
* would fall through to `unknown`, which fails **open** — the bug this exists to
|
|
1178
|
+
* prevent — so the predicate is deliberately broad. `ERR_TLS_CERT_ALTNAME_INVALID`
|
|
1179
|
+
* (hostname mismatch) is included: it is a verification failure, not a transport
|
|
1180
|
+
* one.
|
|
1181
|
+
*/
|
|
1182
|
+
function findTlsErrorCode(err, depth = 0) {
|
|
1183
|
+
if (!err || typeof err !== "object" || depth > 4)
|
|
1184
|
+
return undefined;
|
|
1185
|
+
const code = err.code;
|
|
1186
|
+
if (typeof code === "string") {
|
|
1187
|
+
const isOpenSslCertCode = /^[A-Z0-9_]+$/.test(code) && /(CERT|SSL|_TLS|SIGNATURE)/.test(code);
|
|
1188
|
+
const isNodeTlsCode = code.startsWith("ERR_TLS_") || code === "ERR_SSL_PROTOCOL_ERROR";
|
|
1189
|
+
// `ERR_TLS_*` and OpenSSL cert codes only; plain socket codes (ECONNRESET…)
|
|
1190
|
+
// deliberately do not match — those are transport failures and fail open.
|
|
1191
|
+
if (isNodeTlsCode || (isOpenSslCertCode && !code.startsWith("E")))
|
|
1192
|
+
return code;
|
|
1193
|
+
if (isOpenSslCertCode && /(CERT|SIGNATURE)/.test(code))
|
|
1194
|
+
return code;
|
|
1195
|
+
}
|
|
1196
|
+
return (findTlsErrorCode(err.cause, depth + 1) ??
|
|
1197
|
+
findTlsErrorCode(err.response?.cause, depth + 1));
|
|
1198
|
+
}
|
|
1199
|
+
/** Socket-level codes meaning the request never reached a server. */
|
|
1200
|
+
const SOCKET_ERROR_CODES = new Set([
|
|
1201
|
+
"ECONNREFUSED",
|
|
1202
|
+
"ECONNRESET",
|
|
1203
|
+
"ENOTFOUND",
|
|
1204
|
+
"ETIMEDOUT",
|
|
1205
|
+
"EAI_AGAIN",
|
|
1206
|
+
"EHOSTUNREACH",
|
|
1207
|
+
"ENETUNREACH",
|
|
1208
|
+
"EPIPE",
|
|
1209
|
+
"ECONNABORTED",
|
|
1210
|
+
]);
|
|
1211
|
+
/**
|
|
1212
|
+
* The socket-level error code on `err`, walking `cause` the same way
|
|
1213
|
+
* {@link findTlsErrorCode} does — an HTTP client wraps the underlying socket
|
|
1214
|
+
* error, so the code is rarely on the outermost object.
|
|
1215
|
+
*/
|
|
1216
|
+
function findSocketErrorCode(err, depth = 0) {
|
|
1217
|
+
if (!err || typeof err !== "object" || depth > 4)
|
|
1218
|
+
return undefined;
|
|
1219
|
+
const code = err.code;
|
|
1220
|
+
if (typeof code === "string" && SOCKET_ERROR_CODES.has(code))
|
|
1221
|
+
return code;
|
|
1222
|
+
return findSocketErrorCode(err.cause, depth + 1);
|
|
1223
|
+
}
|