@ory/argus 0.13.8 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +2 -1
  2. package/assets/commands/dashboard.md +34 -0
  3. package/assets/skills/ory-build-agent/SKILL.md +11 -13
  4. package/assets/skills/ory-e2b-sandbox/SKILL.md +0 -1
  5. package/assets/skills/ory-temporal-worker/SKILL.md +8 -10
  6. package/assets/skills/permissions-onboarding/SKILL.md +3 -3
  7. package/dist/adapters.d.ts +27 -4
  8. package/dist/adapters.js +132 -32
  9. package/dist/agent-auth.d.ts +34 -3
  10. package/dist/agent-auth.js +58 -8
  11. package/dist/auth.d.ts +7 -0
  12. package/dist/auth.js +90 -5
  13. package/dist/branding.d.ts +67 -0
  14. package/dist/branding.js +81 -0
  15. package/dist/build-info.json +4 -4
  16. package/dist/cli.d.ts +3 -3
  17. package/dist/cli.js +13 -52
  18. package/dist/client.d.ts +31 -0
  19. package/dist/client.js +58 -0
  20. package/dist/config.d.ts +48 -19
  21. package/dist/config.js +31 -26
  22. package/dist/contract-suite.d.ts +5 -3
  23. package/dist/contract-suite.js +13 -22
  24. package/dist/dashboard-cli.d.ts +8 -0
  25. package/dist/dashboard-cli.js +70 -0
  26. package/dist/dev.js +3 -4
  27. package/dist/index.d.ts +12 -5
  28. package/dist/index.js +41 -3
  29. package/dist/interactive-setup.d.ts +144 -23
  30. package/dist/interactive-setup.js +412 -224
  31. package/dist/local/health.d.ts +14 -0
  32. package/dist/local/health.js +46 -0
  33. package/dist/local/manager.js +1 -3
  34. package/dist/local/seed.d.ts +9 -20
  35. package/dist/local/seed.js +26 -18
  36. package/dist/permissions-cli.js +12 -4
  37. package/dist/project-api-key.d.ts +69 -0
  38. package/dist/project-api-key.js +147 -0
  39. package/dist/registry/manager.js +62 -18
  40. package/dist/setup-actions.d.ts +232 -0
  41. package/dist/setup-actions.js +507 -0
  42. package/dist/setup.d.ts +19 -0
  43. package/dist/setup.js +44 -14
  44. package/dist/skills.js +7 -0
  45. package/dist/status-cli.d.ts +2 -2
  46. package/dist/status-cli.js +4 -30
  47. package/dist/status-data.d.ts +96 -0
  48. package/dist/status-data.js +250 -0
  49. package/dist/status-system.d.ts +24 -0
  50. package/dist/status-system.js +56 -0
  51. package/dist/testing.d.ts +8 -0
  52. package/dist/testing.js +20 -1
  53. package/dist/uninstall.d.ts +33 -15
  54. package/dist/uninstall.js +65 -22
  55. package/dist/user-login.d.ts +16 -9
  56. package/dist/user-login.js +18 -28
  57. package/dist/web/api.d.ts +33 -0
  58. package/dist/web/api.js +294 -0
  59. package/dist/web/launch.d.ts +11 -0
  60. package/dist/web/launch.js +96 -0
  61. package/dist/web/server.d.ts +20 -0
  62. package/dist/web/server.js +233 -0
  63. package/dist/web/types.d.ts +65 -0
  64. package/dist/web/types.js +2 -0
  65. package/dist/webapp/assets/index-Wucl4SZs.css +1 -0
  66. package/dist/webapp/assets/index-m-GtEdq0.js +49 -0
  67. package/dist/webapp/favicon.ico +0 -0
  68. package/dist/webapp/index.html +15 -0
  69. package/package.json +17 -2
package/README.md CHANGED
@@ -42,10 +42,11 @@ const client = OryAgentClient.fromEnv("my-harness");
42
42
 
43
43
  // 1. Session start: authenticate the human and the agent process.
44
44
  async function onSessionStart() {
45
+ // Runs every session and never blocks — it establishes the user identity
46
+ // for attribution; enforcement happens at the tool gate via permissionMode.
45
47
  await ensureUserAuthenticated(client, {
46
48
  binName: "my-harness",
47
49
  harness: "my-harness",
48
- allowBlock: true,
49
50
  });
50
51
  await ensureAgentIdentity(client, {
51
52
  projectUrl: process.env.ORY_PROJECT_URL,
@@ -0,0 +1,34 @@
1
+ # Open the Ory Plugin Dashboard
2
+
3
+ Open a live, browser-based control panel for the Ory agent plugin. It shows
4
+ the health of the core Ory services the plugin uses (Kratos, Hydra, Keto) and
5
+ the plugin's current configuration (project URL, permission mode, audit mode,
6
+ user-login flag, namespaces, and the resolved user/agent identities),
7
+ refreshing the health status live.
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ {{NPX}} dashboard
13
+ ```
14
+
15
+ This will:
16
+
17
+ 1. Start a small local web server bound to `127.0.0.1`
18
+ 2. Open your default browser at the dashboard
19
+ 3. Refresh the service health live
20
+
21
+ The dashboard is the read-only status view of the Ory Agent web app — the same
22
+ local React UI, loopback server, and styleguide that power the guided web
23
+ installer. It never changes configuration. Stop it with `Ctrl-C` in the
24
+ terminal (or the "Close" button in the page).
25
+
26
+ When no browser is available (CI, SSH, headless), or with `--json`, it prints a
27
+ machine-readable status report instead:
28
+
29
+ ```bash
30
+ {{NPX}} dashboard --json
31
+ ```
32
+
33
+ If the core services show as unreachable, start the local stack with
34
+ {{REF_LOCAL_UP}} (or check state with `{{NPX}} local status`).
@@ -86,16 +86,13 @@ import {
86
86
  const client = OryAgentClient.fromEnv("my-agent");
87
87
  const { projectUrl } = resolveConfig();
88
88
 
89
- // 1. User gate — interactive PKCE when ORY_USER_LOGIN=true, no-op otherwise.
90
- const userDecision = await ensureUserAuthenticated(client, {
89
+ // 1. User gate — interactive PKCE; runs every session and never blocks.
90
+ // Establishes the user identity for attribution; a declined/skipped login
91
+ // is audited and the flow proceeds (enforcement is at the tool gate).
92
+ await ensureUserAuthenticated(client, {
91
93
  binName: "my-agent",
92
94
  harness: "my-agent",
93
- allowBlock: true, // flip to false if your agent can't refuse to start
94
95
  });
95
- if (userDecision.proceed === false) {
96
- console.error(`Ory user login: ${userDecision.reason}`);
97
- process.exit(2);
98
- }
99
96
 
100
97
  // 2. Agent gate — never blocks; resolves machine credentials (DCR by default).
101
98
  await ensureAgentIdentity(client, { projectUrl, harness: "my-agent" });
@@ -113,10 +110,11 @@ if (client.userPrincipal.subject && client.agentPrincipal.subject) {
113
110
  }
114
111
  ```
115
112
 
116
- Set `allowBlock: false` when the agent runs in-process inside a parent
117
- application and can't refuse to start. The gate still runs in advisory mode
118
- it refreshes tokens, prompts on TTY, emits the `user.auth` span but
119
- always returns `proceed: true`.
113
+ The user gate runs on every session and is always non-blocking: it refreshes
114
+ tokens, prompts on TTY, and emits the `user.auth` span, but always returns
115
+ `proceed: true`. A missing or declined login never stops the agentthe
116
+ consequence surfaces at the tool gate, where `permissionMode: enforce` denies
117
+ and `observe` audits.
120
118
 
121
119
  ## Step 4 — The shared gate body
122
120
 
@@ -409,8 +407,8 @@ PKCE flow, permission tuples, and trace spans are all visible:
409
407
  1. {{REF_LOCAL_UP}} — brings up Kratos / Keto / Hydra on `localhost:4000`
410
408
  and seeds a demo user. The banner prints the email + password.
411
409
  2. Export the env vars the launcher writes (`ORY_PROJECT_URL`,
412
- `ORY_USER_LOGIN=true`, `ORY_OAUTH2_CLIENT_ID`, optional
413
- `ORY_AGENT_TRACE_FILE` for an NDJSON span log).
410
+ `ORY_OAUTH2_CLIENT_ID`, optional `ORY_AGENT_TRACE_FILE` for an NDJSON
411
+ span log). The user login runs every session.
414
412
  3. Start your agent. Confirm the browser opens for PKCE login.
415
413
  4. Invoke a gated tool and `tail -f $ORY_AGENT_TRACE_FILE | jq .` — you
416
414
  should see `user.auth` → `agent.auth` → `permission.check` →
@@ -66,7 +66,6 @@ export const template = Template()
66
66
  // Sandbox runtime defaults. Per-tenant secrets (project URL, tokens, client
67
67
  // IDs) MUST be passed at Sandbox.create() time, never baked into the image.
68
68
  .setEnvs({
69
- ORY_USER_LOGIN: "true",
70
69
  ORY_PERMISSION_MODE: "observe",
71
70
  ORY_PERMISSION_NAMESPACE: "AgentTools",
72
71
  ORY_AGENT_DEBUG: "true",
@@ -83,12 +83,10 @@ function bootstrap(): Promise<void> {
83
83
  await ensureUserAuthenticated(ory, {
84
84
  binName: "temporal-worker",
85
85
  harness: "temporal",
86
- // Temporal's Activity entry point has no channel to carry a
87
- // session-start block, so the user gate runs in advisory mode:
88
- // it still refreshes tokens and emits the audit span, but the
89
- // worker proceeds even if the user is unauthenticated. Hard
90
- // enforcement happens at the per-Activity permission check.
91
- allowBlock: false,
86
+ // The user gate runs every invocation and never blocks: it refreshes
87
+ // tokens and emits the audit span, but the worker proceeds even if the
88
+ // user is unauthenticated. Enforcement happens at the per-Activity
89
+ // permission check (permissionMode).
92
90
  });
93
91
  await ensureAgentIdentity(ory, {
94
92
  projectUrl: process.env.ORY_PROJECT_URL,
@@ -145,10 +143,10 @@ Key choices:
145
143
 
146
144
  - **Activities call Ory, Workflows don't.** Anything that needs a live decision
147
145
  goes in an Activity. Workflows only orchestrate.
148
- - **`allowBlock: false`.** The Activity boundary can't carry a session-start
149
- block, so the user gate runs in advisory mode. Enforcement is at the
150
- permission check, which throws on deny — Temporal will mark the Activity as
151
- failed and surface the error via the Workflow result or retry policy.
146
+ - **The user gate never blocks.** It runs every invocation to refresh the user
147
+ identity, but always proceeds. Enforcement is at the permission check, which
148
+ throws on deny — Temporal will mark the Activity as failed and surface the
149
+ error via the Workflow result or retry policy.
152
150
  - **`harness: "temporal"`.** Distinguishes worker-originated spans in the trace
153
151
  file from CLI plugin spans.
154
152
  - **Span attributes carry the Workflow + Activity IDs.** This is how operators
@@ -44,7 +44,7 @@ If any of those are wrong, fix them before continuing:
44
44
  {{NPX}} configure --project-url <URL> --oauth2-client-id <CLIENT_ID> [--api-key <KEY>]
45
45
  ```
46
46
 
47
- `--oauth2-client-id` is the public OAuth2 client registered in your Ory project (required when `ORY_USER_LOGIN=true`; see the *Register the user OAuth2 client* section of the plugin README).
47
+ `--oauth2-client-id` is the public OAuth2 client registered in your Ory project (needed for the per-session user login to complete its PKCE browser flow; see the *Register the user OAuth2 client* section of the plugin README).
48
48
 
49
49
  ## Step 2: Look at the current permission posture
50
50
 
@@ -94,9 +94,9 @@ out-of-band as they come into scope.
94
94
  Two common failure modes:
95
95
 
96
96
  1. **No user identity cached.** Bootstrap needs to know which subject
97
- to grant tuples to. If user login has never run (no PKCE login,
97
+ to grant tuples to. If the user login has never completed (no PKCE login,
98
98
  no `ORY_USER_SUBJECT_ID`), the command refuses. Run the harness once
99
- with `ORY_USER_LOGIN=true` to cache a user token, or set
99
+ (the user login runs every session) to cache a user token, or set
100
100
  `ORY_USER_SUBJECT_ID=<id>` to target a known subject.
101
101
  2. **Credentials lack write scope on the permission namespace.** The
102
102
  command prints the full tuple list so you can apply them manually
@@ -30,7 +30,6 @@ export interface SessionStartResult {
30
30
  }
31
31
  export interface SessionStartOptions {
32
32
  harness: string;
33
- allowBlock?: boolean;
34
33
  projectUrl?: string;
35
34
  binName?: string;
36
35
  /** Injectable gates (tests). */
@@ -38,11 +37,27 @@ export interface SessionStartOptions {
38
37
  agentGate?: typeof ensureAgentIdentity;
39
38
  }
40
39
  /**
41
- * Run both auth gates and write the user→agent delegation tuple. `allowBlock` is advisory
42
- * for in-process SDK integrations (the agent can't be prevented from constructing), but the
43
- * user gate still runs, refreshes tokens, and records the `user.auth` span.
40
+ * Run both auth gates and write the user→agent delegation tuple. The user gate
41
+ * runs every session to establish/refresh the user identity, records the
42
+ * `user.auth` span, and never blocks enforcement is governed by
43
+ * `permissionMode` at tool-call time. `SessionStartResult.proceed` is therefore
44
+ * always `true`; it is retained only for a stable adapter shape.
44
45
  */
45
46
  export declare function sessionStart(client: OryAgentClient, opts: SessionStartOptions): Promise<SessionStartResult>;
47
+ /**
48
+ * Write the `user → agent` delegation tuple, at most once per install
49
+ * (see {@link writeDelegationOnce}). Exported so harness plugins that run
50
+ * their own session-start sequence (rather than {@link sessionStart}) share
51
+ * the same write-once semantics instead of re-issuing the write every
52
+ * session.
53
+ *
54
+ * The tuple is a *derived projection* of the delegation anchor stamped into
55
+ * the agent's OAuth2 client metadata at registration: the edge is resolved
56
+ * from the live principals when populated, otherwise rebuilt from the persisted
57
+ * anchor — so a tuple lost out of band can be self-healed from the identity
58
+ * layer. No-op only when neither source yields both ends.
59
+ */
60
+ export declare function writeUserDelegatesAgent(client: OryAgentClient): Promise<void>;
46
61
  export interface RegisterSubagentOptions {
47
62
  harness: string;
48
63
  subAgentType: string;
@@ -50,6 +65,14 @@ export interface RegisterSubagentOptions {
50
65
  subAgentGate?: typeof ensureSubAgentIdentity;
51
66
  }
52
67
  export declare function registerSubagent(client: OryAgentClient, opts: RegisterSubagentOptions): Promise<void>;
68
+ /**
69
+ * Write the `agent → subagent` delegation tuple, at most once per install
70
+ * (see {@link writeDelegationOnce}). Exported so harness plugins that
71
+ * resolve the sub-agent identity through their own event plumbing (rather
72
+ * than {@link registerSubagent}) still share the write-once semantics.
73
+ * No-op until the agent principal is populated.
74
+ */
75
+ export declare function writeAgentDelegatesSubagent(client: OryAgentClient, subAgentSubject: string, subAgentType: string): Promise<void>;
53
76
  export interface GateResult {
54
77
  /** False only when the tool was hard-denied in enforce mode. */
55
78
  proceed: boolean;
package/dist/adapters.js CHANGED
@@ -21,7 +21,9 @@
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.resolveNamespace = resolveNamespace;
23
23
  exports.sessionStart = sessionStart;
24
+ exports.writeUserDelegatesAgent = writeUserDelegatesAgent;
24
25
  exports.registerSubagent = registerSubagent;
26
+ exports.writeAgentDelegatesSubagent = writeAgentDelegatesSubagent;
25
27
  exports.gate = gate;
26
28
  exports.complete = complete;
27
29
  exports.wrapTool = wrapTool;
@@ -35,9 +37,11 @@ function resolveNamespace() {
35
37
  return process.env.ORY_PERMISSION_NAMESPACE ?? "AgentTools";
36
38
  }
37
39
  /**
38
- * Run both auth gates and write the user→agent delegation tuple. `allowBlock` is advisory
39
- * for in-process SDK integrations (the agent can't be prevented from constructing), but the
40
- * user gate still runs, refreshes tokens, and records the `user.auth` span.
40
+ * Run both auth gates and write the user→agent delegation tuple. The user gate
41
+ * runs every session to establish/refresh the user identity, records the
42
+ * `user.auth` span, and never blocks enforcement is governed by
43
+ * `permissionMode` at tool-call time. `SessionStartResult.proceed` is therefore
44
+ * always `true`; it is retained only for a stable adapter shape.
41
45
  */
42
46
  async function sessionStart(client, opts) {
43
47
  const projectUrl = opts.projectUrl ?? (0, config_js_1.resolveConfig)().projectUrl;
@@ -46,13 +50,12 @@ async function sessionStart(client, opts) {
46
50
  const decision = await userLogin(client, {
47
51
  binName: opts.binName ?? `ory-${opts.harness}`,
48
52
  harness: opts.harness,
49
- allowBlock: opts.allowBlock ?? false,
50
53
  });
51
54
  const creds = await agentGate(client, {
52
55
  projectUrl,
53
56
  harness: opts.harness,
54
57
  });
55
- await recordUserDelegatesAgent(client);
58
+ await writeUserDelegatesAgent(client);
56
59
  return {
57
60
  proceed: decision.proceed,
58
61
  userMode: decision.mode,
@@ -60,25 +63,104 @@ async function sessionStart(client, opts) {
60
63
  agentKind: creds.kind,
61
64
  };
62
65
  }
63
- async function recordUserDelegatesAgent(client) {
64
- const user = client.userPrincipal.subject;
65
- const agent = client.agentPrincipal.subject;
66
- if (!user || !agent)
67
- return;
66
+ /**
67
+ * Write a delegation tuple at most once. The tuple is stable for a given
68
+ * (namespace, object, subject), and Keto's create API is not idempotent, so
69
+ * this goes through {@link OryAgentClient.ensureRelationship}: it reads the
70
+ * exact tuple first and only writes when absent — no duplicate on repeated
71
+ * session starts, and self-healing if the tuple was deleted. If the existence
72
+ * probe fails we skip the write rather than risk a duplicate (delegation
73
+ * tuples are audit-only, so a missed write is harmless and retried next
74
+ * session). Best-effort: any failure is logged and swallowed.
75
+ */
76
+ async function writeDelegationOnce(client, check, options) {
68
77
  try {
69
- await client.createRelationship({
70
- namespace: resolveNamespace(),
71
- object: `agent:${agent}`,
72
- relation: "delegate",
73
- subjectId: `user:${user}`,
74
- }, { spanAttributes: { delegation: "user-to-agent" } });
78
+ const res = await client.ensureRelationship(check, {
79
+ spanAttributes: options.spanAttributes,
80
+ });
81
+ if (res.probeError) {
82
+ client.logger.debug("delegation.probe_failed", {
83
+ ...options.failedContext,
84
+ message: res.probeError.message,
85
+ });
86
+ }
75
87
  }
76
88
  catch (err) {
77
- client.logger.warn("delegation.user_to_agent.failed", {
89
+ client.logger.warn(options.failedEvent, {
90
+ ...options.failedContext,
78
91
  message: err instanceof Error ? err.message : String(err),
79
92
  });
80
93
  }
81
94
  }
95
+ /** Strip a `<ns>:` prefix from a namespaced subject, if present. */
96
+ function unqualify(subject, ns) {
97
+ const prefix = `${ns}:`;
98
+ return subject.startsWith(prefix) ? subject.slice(prefix.length) : undefined;
99
+ }
100
+ /**
101
+ * Resolve one end of a delegation edge, preferring the live principal but
102
+ * falling back to the persisted delegation anchor (`delegation.delegatedBy`
103
+ * on the DCR credentials in local config). This is what lets the tuple be
104
+ * rebuilt from an authoritative source rather than from nothing when the
105
+ * principal isn't populated for the session. A mismatch between the two is
106
+ * logged for reconciliation visibility; the live principal wins.
107
+ */
108
+ function reconcileEdgeEnd(client, principal, anchor, role) {
109
+ if (principal && anchor && principal !== anchor) {
110
+ client.logger.debug("delegation.anchor_mismatch", { role, principal, anchor });
111
+ }
112
+ return principal ?? anchor;
113
+ }
114
+ /**
115
+ * Write the `user → agent` delegation tuple, at most once per install
116
+ * (see {@link writeDelegationOnce}). Exported so harness plugins that run
117
+ * their own session-start sequence (rather than {@link sessionStart}) share
118
+ * the same write-once semantics instead of re-issuing the write every
119
+ * session.
120
+ *
121
+ * The tuple is a *derived projection* of the delegation anchor stamped into
122
+ * the agent's OAuth2 client metadata at registration: the edge is resolved
123
+ * from the live principals when populated, otherwise rebuilt from the persisted
124
+ * anchor — so a tuple lost out of band can be self-healed from the identity
125
+ * layer. No-op only when neither source yields both ends.
126
+ */
127
+ async function writeUserDelegatesAgent(client) {
128
+ // The anchor is the delegation *record* stamped into the agent client's
129
+ // metadata — not the mere existence of a persisted client. Only when a
130
+ // user-to-agent record is present do we know the delegator, so both ends of
131
+ // the edge are taken from the same record's client (agent = its clientId,
132
+ // user = its delegatedBy). Absent a record, fall back to live principals only.
133
+ const anchorCreds = (0, agent_auth_js_1.loadAgentDynamicCredentials)();
134
+ const anchor = anchorCreds?.delegation;
135
+ const hasUserAgentAnchor = anchor?.delegationType === "user-to-agent";
136
+ const anchorUser = hasUserAgentAnchor ? unqualify(anchor.delegatedBy, "user") : undefined;
137
+ // The agent principal's subject is `ORY_AGENT_SUBJECT_ID ?? clientId` (see
138
+ // resolveClientCredentials). The anchor rebuild must apply the *same*
139
+ // precedence, or a no-principal session would write `agent:<clientId>` while
140
+ // a live session wrote `agent:<override>` — two divergent tuples for one edge.
141
+ const anchorAgent = hasUserAgentAnchor
142
+ ? (process.env.ORY_AGENT_SUBJECT_ID || anchorCreds.clientId)
143
+ : undefined;
144
+ const user = reconcileEdgeEnd(client, client.userPrincipal.subject, anchorUser, "user");
145
+ const agent = reconcileEdgeEnd(client, client.agentPrincipal.subject, anchorAgent, "agent");
146
+ if (!user || !agent) {
147
+ client.logger.debug("delegation.skip", {
148
+ reason: "missing delegation edge (no principal and no anchor)",
149
+ hasUser: !!user,
150
+ hasAgent: !!agent,
151
+ });
152
+ return;
153
+ }
154
+ await writeDelegationOnce(client, {
155
+ namespace: resolveNamespace(),
156
+ object: `agent:${agent}`,
157
+ relation: "delegate",
158
+ subjectId: `user:${user}`,
159
+ }, {
160
+ spanAttributes: { delegation: "user-to-agent" },
161
+ failedEvent: "delegation.user_to_agent.failed",
162
+ });
163
+ }
82
164
  async function registerSubagent(client, opts) {
83
165
  const projectUrl = opts.projectUrl ?? (0, config_js_1.resolveConfig)().projectUrl;
84
166
  const subAgentGate = opts.subAgentGate ?? agent_auth_js_1.ensureSubAgentIdentity;
@@ -99,23 +181,41 @@ async function registerSubagent(client, opts) {
99
181
  }
100
182
  if (identity.kind !== "dynamic" || !identity.subject)
101
183
  return;
102
- const agent = client.agentPrincipal.subject;
103
- if (!agent)
104
- return;
105
- try {
106
- await client.createRelationship({
107
- namespace: resolveNamespace(),
108
- object: `subagent:${identity.subject}`,
109
- relation: "delegate",
110
- subjectId: `agent:${agent}`,
111
- }, { spanAttributes: { delegation: "agent-to-subagent", subAgentType: opts.subAgentType } });
112
- }
113
- catch (err) {
114
- client.logger.warn("delegation.agent_to_subagent.failed", {
115
- subAgentType: opts.subAgentType,
116
- message: err instanceof Error ? err.message : String(err),
184
+ await writeAgentDelegatesSubagent(client, identity.subject, opts.subAgentType);
185
+ }
186
+ /**
187
+ * Write the `agent → subagent` delegation tuple, at most once per install
188
+ * (see {@link writeDelegationOnce}). Exported so harness plugins that
189
+ * resolve the sub-agent identity through their own event plumbing (rather
190
+ * than {@link registerSubagent}) still share the write-once semantics.
191
+ * No-op until the agent principal is populated.
192
+ */
193
+ async function writeAgentDelegatesSubagent(client, subAgentSubject, subAgentType) {
194
+ // The delegator (agent) is resolved from the live principal, falling back to
195
+ // the sub-agent client's persisted delegation anchor (`delegation.delegatedBy`
196
+ // = `agent:<agent>`) so the tuple can be rebuilt when no principal is present.
197
+ const anchor = (0, agent_auth_js_1.loadSubAgentDynamicCredentials)(subAgentType)?.delegation;
198
+ const anchorAgent = anchor?.delegationType === "agent-to-subagent"
199
+ ? unqualify(anchor.delegatedBy, "agent")
200
+ : undefined;
201
+ const agent = reconcileEdgeEnd(client, client.agentPrincipal.subject, anchorAgent, "agent");
202
+ if (!agent) {
203
+ client.logger.debug("delegation.skip", {
204
+ reason: "no agent delegation edge (no principal and no anchor)",
205
+ subAgentType,
117
206
  });
207
+ return;
118
208
  }
209
+ await writeDelegationOnce(client, {
210
+ namespace: resolveNamespace(),
211
+ object: `subagent:${subAgentSubject}`,
212
+ relation: "delegate",
213
+ subjectId: `agent:${agent}`,
214
+ }, {
215
+ spanAttributes: { delegation: "agent-to-subagent", subAgentType },
216
+ failedEvent: "delegation.agent_to_subagent.failed",
217
+ failedContext: { subAgentType },
218
+ });
119
219
  }
120
220
  /**
121
221
  * Authorize a tool call, record the spans, and return a normalized {@link GateResult}.
@@ -26,8 +26,19 @@
26
26
  * the user with an `act` claim showing the agent — is intentionally
27
27
  * deferred.
28
28
  */
29
- import { type OryAgentDynamicCredentials } from "./config.js";
29
+ import { type OryAgentDynamicCredentials, type OryDelegationRecord } from "./config.js";
30
30
  import { OryAgentClient } from "./client.js";
31
+ /**
32
+ * Delegation context handed to {@link registerAgentClient} so the freshly
33
+ * minted OAuth2 client is stamped with its delegator at the identity layer.
34
+ * `delegatedAt` is stamped by the registrar, not the caller.
35
+ */
36
+ export interface DelegationInput {
37
+ /** The delegator, namespaced: `user:<sub>` or `agent:<sub>`. */
38
+ delegatedBy: string;
39
+ delegationType: OryDelegationRecord["delegationType"];
40
+ subAgentType?: string;
41
+ }
31
42
  export type AgentCredentialKind = "api_key" | "client_credentials" | "dynamic" | "none";
32
43
  export interface AgentCredentials {
33
44
  kind: AgentCredentialKind;
@@ -64,6 +75,13 @@ export interface ResolveAgentCredentialsOptions {
64
75
  env?: NodeJS.ProcessEnv;
65
76
  /** User's bearer token; preferred IAT for dynamic registration. */
66
77
  userToken?: string;
78
+ /**
79
+ * User's subject. When present, a fresh DCR stamps `metadata.delegatedBy`
80
+ * = `user:<userSubject>` on the agent client so the user→agent delegation
81
+ * has an identity-layer anchor. Absent ⇒ the client registers without a
82
+ * delegation anchor (no delegator known).
83
+ */
84
+ userSubject?: string;
67
85
  /** Harness identifier — baked into the registered client_name. */
68
86
  harness?: string;
69
87
  /** Inject the client_credentials token-grant function for tests. */
@@ -125,8 +143,21 @@ export interface RegisterAgentClientArgs {
125
143
  auth: string;
126
144
  /** Harness identifier baked into the client_name for audit. */
127
145
  harness?: string;
146
+ /** When set, names the client as a sub-agent of the given type. */
147
+ subAgentType?: string;
148
+ /**
149
+ * Delegation edge recorded for this client. When present, the issued
150
+ * credentials carry a durable record of who delegated to the agent — the
151
+ * integrity anchor from which the Keto delegation tuple can be rebuilt.
152
+ * Persisted in local config only; Ory's public DCR endpoint rejects a
153
+ * `metadata` field, so it is not stored server-side. Omitted when the
154
+ * delegator isn't known at registration time.
155
+ */
156
+ delegation?: DelegationInput;
128
157
  /** Override hostname for testing. */
129
158
  hostnameFn?: () => string;
159
+ /** Inject the wall clock for deterministic `delegatedAt` in tests. */
160
+ nowFn?: () => Date;
130
161
  }
131
162
  /**
132
163
  * Register a new OAuth2 client via RFC 7591. Authorization is carried
@@ -224,8 +255,8 @@ export interface EnsureSubAgentIdentityOptions {
224
255
  * Resolve a sub-agent identity via OAuth2 Dynamic Client Registration.
225
256
  *
226
257
  * Reuses persisted credentials when the `(projectUrl, subAgentType)`
227
- * pair matches; otherwise registers a fresh client with
228
- * `client_name = ory-subagent-<type>@<host>` and persists the result.
258
+ * pair matches; otherwise registers a fresh client named
259
+ * `Ory Agent Security · sub-agent · <harness>/<type> @ <host>` and persists the result.
229
260
  * Never throws and never blocks — on any failure the identity collapses
230
261
  * to `kind: "none"` and the plugin continues without it. Emits exactly
231
262
  * one `agent.auth` span so the audit trail records each sub-agent
@@ -45,6 +45,7 @@ exports.ensureSubAgentIdentity = ensureSubAgentIdentity;
45
45
  const node_os_1 = require("node:os");
46
46
  const client_1 = require("@ory/client");
47
47
  const config_js_1 = require("./config.js");
48
+ const branding_js_1 = require("./branding.js");
48
49
  /**
49
50
  * Skew window before nominal expiry at which a cached client_credentials
50
51
  * token is considered stale. Matches the user-token skew in auth-store.
@@ -162,9 +163,26 @@ async function revokeAgentDynamicClient(creds) {
162
163
  */
163
164
  async function registerAgentClient(args) {
164
165
  const host = (args.hostnameFn ?? node_os_1.hostname)() || "unknown-host";
165
- const clientName = args.harness
166
- ? `ory-agent-${args.harness}@${host}`
167
- : `ory-agent@${host}`;
166
+ const clientName = (0, branding_js_1.agentClientName)({
167
+ harness: args.harness,
168
+ host,
169
+ subAgentType: args.subAgentType,
170
+ });
171
+ // Build the delegation anchor (if a delegator is known) and mirror it into the
172
+ // persisted credentials. This is the integrity anchor the Keto tuple is derived
173
+ // from. It is NOT sent to Ory: the public DCR endpoint rejects a `metadata`
174
+ // field (see the request body below), so the anchor lives only in local config.
175
+ const delegation = args.delegation
176
+ ? {
177
+ delegatedBy: args.delegation.delegatedBy,
178
+ delegationType: args.delegation.delegationType,
179
+ ...(args.harness ? { harness: args.harness } : {}),
180
+ ...(args.delegation.subAgentType
181
+ ? { subAgentType: args.delegation.subAgentType }
182
+ : {}),
183
+ delegatedAt: (args.nowFn ?? (() => new Date()))().toISOString(),
184
+ }
185
+ : undefined;
168
186
  const oidc = new client_1.OidcApi(new client_1.Configuration({
169
187
  basePath: args.projectUrl,
170
188
  accessToken: args.auth,
@@ -178,6 +196,14 @@ async function registerAgentClient(args) {
178
196
  response_types: ["token"],
179
197
  scope: "openid offline_access",
180
198
  token_endpoint_auth_method: "client_secret_post",
199
+ // NB: the delegation record is deliberately NOT sent as `metadata`.
200
+ // Ory Network / Hydra reject a `metadata` field on the public DCR
201
+ // endpoints — both RFC 7591 create and RFC 7592 update return
202
+ // `400 invalid_client_metadata: 'metadata' cannot be set for dynamic
203
+ // client registration` (metadata is admin-API-only). Sending it made
204
+ // every fresh registration fail, so the agent never got credentials.
205
+ // The delegation anchor is persisted locally instead (see the return
206
+ // value) and is never read back from the server side.
181
207
  },
182
208
  },
183
209
  // The generated SDK declares no security scheme for the public DCR create
@@ -206,6 +232,7 @@ async function registerAgentClient(args) {
206
232
  registeredAt: Math.floor(Date.now() / 1000),
207
233
  projectUrl: args.projectUrl,
208
234
  harness: args.harness,
235
+ ...(delegation ? { delegation } : {}),
209
236
  };
210
237
  }
211
238
  /**
@@ -300,6 +327,14 @@ async function resolveAgentCredentials(options = {}) {
300
327
  projectUrl: options.projectUrl,
301
328
  auth: iat,
302
329
  harness: options.harness,
330
+ ...(options.userSubject
331
+ ? {
332
+ delegation: {
333
+ delegatedBy: `user:${options.userSubject}`,
334
+ delegationType: "user-to-agent",
335
+ },
336
+ }
337
+ : {}),
303
338
  });
304
339
  if (!registered.clientSecret) {
305
340
  warnings.push("Dynamic registration returned no client_secret; cannot run client_credentials grant.");
@@ -341,7 +376,7 @@ async function resolveAgentCredentials(options = {}) {
341
376
  }
342
377
  return {
343
378
  kind: "none",
344
- reason: "No agent credentials configured. Run with a user session (ORY_USER_LOGIN=true) or set ORY_AGENT_API_KEY / ORY_AGENT_CLIENT_ID + ORY_AGENT_CLIENT_SECRET / ORY_AGENT_REGISTRATION_TOKEN.",
379
+ reason: "No agent credentials configured. Run with a user session (the interactive user login runs every session) or set ORY_AGENT_API_KEY / ORY_AGENT_CLIENT_ID + ORY_AGENT_CLIENT_SECRET / ORY_AGENT_REGISTRATION_TOKEN.",
345
380
  warnings,
346
381
  };
347
382
  }
@@ -473,9 +508,11 @@ async function ensureAgentIdentity(client, options = {}) {
473
508
  creds = await resolveFn({
474
509
  projectUrl: options.projectUrl,
475
510
  env: options.env,
476
- // The user gate populates userPrincipal.token on successful login;
477
- // we hand it to the resolver so DCR can use it as the IAT.
511
+ // The user gate populates userPrincipal on successful login; we hand the
512
+ // token to the resolver so DCR can use it as the IAT, and the subject so
513
+ // the fresh client is stamped with its delegator (metadata.delegatedBy).
478
514
  userToken: client.userPrincipal.token,
515
+ userSubject: client.userPrincipal.subject,
479
516
  harness: options.harness,
480
517
  });
481
518
  }
@@ -512,8 +549,8 @@ async function ensureAgentIdentity(client, options = {}) {
512
549
  * Resolve a sub-agent identity via OAuth2 Dynamic Client Registration.
513
550
  *
514
551
  * Reuses persisted credentials when the `(projectUrl, subAgentType)`
515
- * pair matches; otherwise registers a fresh client with
516
- * `client_name = ory-subagent-<type>@<host>` and persists the result.
552
+ * pair matches; otherwise registers a fresh client named
553
+ * `Ory Agent Security · sub-agent · <harness>/<type> @ <host>` and persists the result.
517
554
  * Never throws and never blocks — on any failure the identity collapses
518
555
  * to `kind: "none"` and the plugin continues without it. Emits exactly
519
556
  * one `agent.auth` span so the audit trail records each sub-agent
@@ -550,6 +587,10 @@ async function ensureSubAgentIdentity(client, options) {
550
587
  subAgentType: options.subAgentType,
551
588
  projectUrl: options.projectUrl,
552
589
  harness: options.harness,
590
+ // The delegator for a sub-agent is the parent agent. When its subject is
591
+ // populated, stamp `metadata.delegatedBy` = `agent:<agent>` on the
592
+ // sub-agent client so the agent→subagent edge is anchored to the identity.
593
+ delegatorSubject: client.agentPrincipal.subject,
553
594
  iat: options.iat
554
595
  ?? client.agentPrincipal.token
555
596
  ?? client.userPrincipal.token
@@ -633,6 +674,15 @@ async function resolveSubAgentIdentity(args) {
633
674
  projectUrl: args.projectUrl,
634
675
  auth: args.iat,
635
676
  harness: subHarness,
677
+ ...(args.delegatorSubject
678
+ ? {
679
+ delegation: {
680
+ delegatedBy: `agent:${args.delegatorSubject}`,
681
+ delegationType: "agent-to-subagent",
682
+ subAgentType: args.subAgentType,
683
+ },
684
+ }
685
+ : {}),
636
686
  });
637
687
  args.saveFn(args.subAgentType, registered);
638
688
  return {
package/dist/auth.d.ts CHANGED
@@ -91,3 +91,10 @@ export declare function refreshAccessToken(args: {
91
91
  clientId: string;
92
92
  refreshToken: string;
93
93
  }): Promise<OryOAuth2Tokens>;
94
+ /**
95
+ * Best-effort human-readable name for the signed-in user, pulled from the
96
+ * id_token's identity claims — `email`, then `name`, then `preferred_username`.
97
+ * Returns undefined when the token carries none of them (e.g. an `openid`-only
98
+ * token), so callers fall back to the opaque subject id.
99
+ */
100
+ export declare function displayNameFromIdToken(idToken: string | undefined): string | undefined;