@slashfi/agents-sdk 0.85.0 → 0.87.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.
@@ -532,6 +532,38 @@ describe("ADK ref.call() full auto-refresh flow", () => {
532
532
  expect((result as any)?.result?.message).toBe("success");
533
533
  expect((result as any)?.result?.token).toBe("refreshed-token");
534
534
  });
535
+
536
+ test("ref.authStatus reports access_token.automated=false for authorizationCode (user must consent)", async () => {
537
+ // Regression: previously `access_token.automated` was hardcoded to
538
+ // `true` for every oauth2 scheme. That made cached-authFields
539
+ // callers think the ref was "connected" the moment `ref.add` ran,
540
+ // even when the user had never completed OAuth — because the
541
+ // `automated:true` flag tells `isRefAuthComplete` to skip the
542
+ // presence check. For authorizationCode (which requires user
543
+ // consent), `automated` must be `false`.
544
+ const fs = createMemoryFs();
545
+ const adk = createAdk(fs, {
546
+ encryptionKey: "test-key-32-chars-long-enough!!",
547
+ });
548
+
549
+ await adk.registry.add({
550
+ name: "oauth-reg",
551
+ url: `http://localhost:${REG_PORT}`,
552
+ });
553
+ await adk.ref.add({
554
+ ref: "oauth-api",
555
+ name: "oauth-api-unauthed",
556
+ sourceRegistry: {
557
+ url: `http://localhost:${REG_PORT}`,
558
+ agentPath: "oauth-api",
559
+ },
560
+ });
561
+
562
+ const status = await adk.ref.authStatus("oauth-api-unauthed");
563
+ expect(status.complete).toBe(false);
564
+ expect(status.fields?.access_token?.automated).toBe(false);
565
+ expect(status.fields?.access_token?.present).toBe(false);
566
+ });
535
567
  });
536
568
 
537
569
  // ─── Registry auth lifecycle ─────────────────────────────────────
@@ -811,11 +811,31 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
811
811
  const entry = findRef(config.refs ?? [], name);
812
812
  const value = entry?.config?.[key];
813
813
  if (typeof value !== "string") return null;
814
- if (value.startsWith(SECRET_PREFIX) && options.encryptionKey) {
815
- return decryptSecret(
816
- value.slice(SECRET_PREFIX.length),
817
- options.encryptionKey,
818
- );
814
+ if (value.startsWith(SECRET_PREFIX)) {
815
+ // Encrypted credential. Refuse to forward the ciphertext verbatim;
816
+ // upstreams will silently reject `secret:...` as a bearer token
817
+ // and the cause becomes invisible.
818
+ if (!options.encryptionKey) {
819
+ throw new AdkError({
820
+ code: "encryption_key_missing",
821
+ message: `ref.call(${name}): credential "${key}" is encrypted (secret:...) but this Adk instance was constructed without an encryptionKey.`,
822
+ hint: "Pass `encryptionKey` when constructing the Adk (createAdk/createAdkForUser/createAdkForTenant).",
823
+ details: { ref: name, field: key },
824
+ });
825
+ }
826
+ try {
827
+ return await decryptSecret(
828
+ value.slice(SECRET_PREFIX.length),
829
+ options.encryptionKey,
830
+ );
831
+ } catch (err) {
832
+ throw new AdkError({
833
+ code: "encryption_key_mismatch",
834
+ message: `ref.call(${name}): failed to decrypt credential "${key}". The configured encryptionKey does not match the key used to encrypt this value.`,
835
+ hint: "Re-encrypt the ref's credentials with the current encryptionKey, or restore the previous key.",
836
+ details: { ref: name, field: key, cause: (err as Error)?.message },
837
+ });
838
+ }
819
839
  }
820
840
  return value;
821
841
  }
@@ -2098,16 +2118,35 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2098
2118
  if (rawHeaders && typeof rawHeaders === "object") {
2099
2119
  resolvedHeaders = {};
2100
2120
  for (const [k, v] of Object.entries(rawHeaders)) {
2101
- if (
2102
- typeof v === "string" &&
2103
- v.startsWith(SECRET_PREFIX) &&
2104
- options.encryptionKey
2105
- ) {
2106
- resolvedHeaders[k] = await decryptSecret(
2107
- v.slice(SECRET_PREFIX.length),
2108
- options.encryptionKey,
2109
- );
2110
- } else if (typeof v === "string") {
2121
+ if (typeof v !== "string") continue;
2122
+ if (v.startsWith(SECRET_PREFIX)) {
2123
+ // Encrypted header value. Refuse to forward the ciphertext
2124
+ // verbatim — that historically leaked literal `secret:...`
2125
+ // strings as outbound HTTP headers, which upstreams rejected
2126
+ // with opaque 401s. Hard-fail with a clear message so the
2127
+ // misconfiguration surfaces instead of silently breaking auth.
2128
+ if (!options.encryptionKey) {
2129
+ throw new AdkError({
2130
+ code: "encryption_key_missing",
2131
+ message: `ref.call(${name}): header "${k}" is encrypted (secret:...) but this Adk instance was constructed without an encryptionKey, so the ciphertext cannot be resolved.`,
2132
+ hint: "Pass `encryptionKey` when constructing the Adk (createAdk/createAdkForUser/createAdkForTenant), or strip the encrypted header from the ref config.",
2133
+ details: { ref: name, header: k },
2134
+ });
2135
+ }
2136
+ try {
2137
+ resolvedHeaders[k] = await decryptSecret(
2138
+ v.slice(SECRET_PREFIX.length),
2139
+ options.encryptionKey,
2140
+ );
2141
+ } catch (err) {
2142
+ throw new AdkError({
2143
+ code: "encryption_key_mismatch",
2144
+ message: `ref.call(${name}): failed to decrypt header "${k}". The configured encryptionKey does not match the key used to encrypt this value.`,
2145
+ hint: "Re-encrypt the ref's headers with the current encryptionKey, or restore the previous key. Decrypting an unrelated value would have leaked ciphertext as a header before this fix.",
2146
+ details: { ref: name, header: k, cause: (err as Error)?.message },
2147
+ });
2148
+ }
2149
+ } else {
2111
2150
  resolvedHeaders[k] = v;
2112
2151
  }
2113
2152
  }
@@ -2226,9 +2265,28 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2226
2265
  const securityExt = security as {
2227
2266
  dynamicRegistration?: boolean;
2228
2267
  discoveryUrl?: string;
2268
+ flows?: Record<string, unknown>;
2229
2269
  };
2230
2270
  const hasRegistration = !!securityExt.dynamicRegistration;
2231
2271
 
2272
+ // `access_token.automated` decides whether `isRefAuthComplete`
2273
+ // requires the token to be present in `entry.config`. It should
2274
+ // be `true` ONLY when the SDK can mint the token with no user
2275
+ // action — i.e. a pure machine-to-machine flow like
2276
+ // `clientCredentials`. For `authorizationCode` / `implicit` /
2277
+ // `password` (and the "no flows declared" fallback), the user
2278
+ // must complete the OAuth consent step before the token lands
2279
+ // in config, so treat it as a normal user-supplied required
2280
+ // field. Without this, cached-authFields callers think the ref
2281
+ // is "connected" the moment `ref.add` runs, even though the
2282
+ // user never consented.
2283
+ const declaredFlows = securityExt.flows
2284
+ ? Object.keys(securityExt.flows)
2285
+ : [];
2286
+ const accessTokenAutomated =
2287
+ declaredFlows.length > 0 &&
2288
+ declaredFlows.every((f) => f === "clientCredentials");
2289
+
2232
2290
  let oauthMetadata:
2233
2291
  | import("./mcp-client.js").OAuthServerMetadata
2234
2292
  | null = null;
@@ -2258,7 +2316,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2258
2316
  }
2259
2317
  fields.access_token = {
2260
2318
  required: true,
2261
- automated: true,
2319
+ automated: accessTokenAutomated,
2262
2320
  present: configKeys.includes("access_token"),
2263
2321
  resolvable: false,
2264
2322
  };
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { FsStore } from "./agent-definitions/config";
3
+ import { createAdk } from "./index";
4
+ import { encryptSecret } from "./crypto";
5
+
6
+ function memFs(initial: Record<string, unknown>): FsStore {
7
+ const files = new Map<string, string>([
8
+ ["consumer-config.json", JSON.stringify(initial)],
9
+ ]);
10
+ return {
11
+ async readFile(p) {
12
+ return files.get(p) ?? null;
13
+ },
14
+ async writeFile(p, c) {
15
+ files.set(p, c);
16
+ },
17
+ };
18
+ }
19
+
20
+ describe("ref.call: encrypted config.headers", () => {
21
+ const KEY = "test-encryption-key-1234567890";
22
+
23
+ test("forwards plaintext _headers when encryptionKey decrypts", async () => {
24
+ const encApi = `secret:${await encryptSecret("DD_API_KEY_REAL", KEY)}`;
25
+ const encApp = `secret:${await encryptSecret("DD_APP_KEY_REAL", KEY)}`;
26
+
27
+ const captured: Array<{ body: string }> = [];
28
+ const fetchSpy: typeof fetch = async (_url, init) => {
29
+ captured.push({ body: String((init as RequestInit | undefined)?.body) });
30
+ return new Response(
31
+ JSON.stringify({
32
+ jsonrpc: "2.0",
33
+ id: "1",
34
+ result: { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] },
35
+ }),
36
+ { status: 200, headers: { "content-type": "application/json" } },
37
+ );
38
+ };
39
+
40
+ const adk = createAdk(
41
+ memFs({
42
+ registries: [{ name: "test", url: "https://example.com" }],
43
+ refs: [{
44
+ ref: "datadog",
45
+ scheme: "registry",
46
+ mode: "api",
47
+ sourceRegistry: { agentPath: "datadog", url: "https://example.com" },
48
+ config: { headers: { "DD-API-KEY": encApi, "DD-APPLICATION-KEY": encApp } },
49
+ }],
50
+ }),
51
+ { fetch: fetchSpy, encryptionKey: KEY },
52
+ );
53
+
54
+ await adk.ref.call("datadog", "some_tool", { foo: "bar" });
55
+
56
+ expect(captured.length).toBe(1);
57
+ const body = JSON.parse(captured[0].body);
58
+ const params = body.params.arguments.request.params;
59
+ expect(params._headers).toEqual({
60
+ "DD-API-KEY": "DD_API_KEY_REAL",
61
+ "DD-APPLICATION-KEY": "DD_APP_KEY_REAL",
62
+ });
63
+ });
64
+
65
+ test("hard-fails when secret: header present but encryptionKey is missing", async () => {
66
+ const encApi = `secret:${await encryptSecret("x", KEY)}`;
67
+
68
+ const fetchSpy: typeof fetch = async () =>
69
+ new Response("", { status: 200 });
70
+
71
+ const adk = createAdk(
72
+ memFs({
73
+ registries: [{ name: "test", url: "https://example.com" }],
74
+ refs: [{
75
+ ref: "datadog",
76
+ scheme: "registry",
77
+ mode: "api",
78
+ sourceRegistry: { agentPath: "datadog", url: "https://example.com" },
79
+ config: { headers: { "DD-API-KEY": encApi } },
80
+ }],
81
+ }),
82
+ { fetch: fetchSpy /* no encryptionKey */ },
83
+ );
84
+
85
+ await expect(
86
+ adk.ref.call("datadog", "some_tool", {}),
87
+ ).rejects.toThrow(/encryption_key_missing|encryptionKey/);
88
+ });
89
+
90
+ test("hard-fails when encryptionKey is present but cannot decrypt the value", async () => {
91
+ const encApi = `secret:${await encryptSecret("x", KEY)}`;
92
+
93
+ const fetchSpy: typeof fetch = async () =>
94
+ new Response("", { status: 200 });
95
+
96
+ const adk = createAdk(
97
+ memFs({
98
+ registries: [{ name: "test", url: "https://example.com" }],
99
+ refs: [{
100
+ ref: "datadog",
101
+ scheme: "registry",
102
+ mode: "api",
103
+ sourceRegistry: { agentPath: "datadog", url: "https://example.com" },
104
+ config: { headers: { "DD-API-KEY": encApi } },
105
+ }],
106
+ }),
107
+ { fetch: fetchSpy, encryptionKey: "a-different-wrong-key" },
108
+ );
109
+
110
+ await expect(
111
+ adk.ref.call("datadog", "some_tool", {}),
112
+ ).rejects.toThrow(/encryption_key_mismatch|decrypt/);
113
+ });
114
+
115
+ test("plaintext header values are still forwarded as-is", async () => {
116
+ const captured: Array<{ body: string }> = [];
117
+ const fetchSpy: typeof fetch = async (_url, init) => {
118
+ captured.push({ body: String((init as RequestInit | undefined)?.body) });
119
+ return new Response(
120
+ JSON.stringify({
121
+ jsonrpc: "2.0",
122
+ id: "1",
123
+ result: { content: [{ type: "text", text: JSON.stringify({ ok: true }) }] },
124
+ }),
125
+ { status: 200, headers: { "content-type": "application/json" } },
126
+ );
127
+ };
128
+
129
+ const adk = createAdk(
130
+ memFs({
131
+ registries: [{ name: "test", url: "https://example.com" }],
132
+ refs: [{
133
+ ref: "weather",
134
+ scheme: "registry",
135
+ mode: "api",
136
+ sourceRegistry: { agentPath: "weather", url: "https://example.com" },
137
+ config: { headers: { "X-API-Key": "plain-value" } },
138
+ }],
139
+ }),
140
+ { fetch: fetchSpy /* no encryptionKey needed for plaintext */ },
141
+ );
142
+
143
+ await adk.ref.call("weather", "some_tool", {});
144
+
145
+ expect(captured.length).toBe(1);
146
+ const body = JSON.parse(captured[0].body);
147
+ const params = body.params.arguments.request.params;
148
+ expect(params._headers).toEqual({ "X-API-Key": "plain-value" });
149
+ });
150
+ });
@@ -1002,7 +1002,14 @@ export async function createRegistryConsumer(
1002
1002
  );
1003
1003
  }
1004
1004
 
1005
- return callTool(registry, ref.ref, tool, params);
1005
+ // Forward resolved per-ref headers as `_headers` so the registry
1006
+ // injects them on the outbound REST call. Without this the
1007
+ // `auth.headers` declared by the ref are silently dropped for
1008
+ // registry-routed (`mode: "api"`) refs.
1009
+ const paramsWithHeaders = resolvedHeaders
1010
+ ? { ...params, _headers: resolvedHeaders }
1011
+ : params;
1012
+ return callTool(registry, ref.ref, tool, paramsWithHeaders);
1006
1013
  },
1007
1014
 
1008
1015
  discover(registryUrl: string) {