@slashfi/agents-sdk 0.85.0 → 0.86.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.
@@ -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
  }
@@ -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) {