@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.
- package/dist/cjs/config-store.js +51 -7
- package/dist/cjs/config-store.js.map +1 -1
- package/dist/cjs/registry-consumer.js +8 -1
- package/dist/cjs/registry-consumer.js.map +1 -1
- package/dist/config-store.d.ts.map +1 -1
- package/dist/config-store.js +51 -7
- package/dist/config-store.js.map +1 -1
- package/dist/registry-consumer.d.ts.map +1 -1
- package/dist/registry-consumer.js +8 -1
- package/dist/registry-consumer.js.map +1 -1
- package/package.json +1 -1
- package/src/config-store.ts +54 -15
- package/src/encrypted-headers.test.ts +150 -0
- package/src/registry-consumer.ts +8 -1
package/src/config-store.ts
CHANGED
|
@@ -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)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
+
});
|
package/src/registry-consumer.ts
CHANGED
|
@@ -1002,7 +1002,14 @@ export async function createRegistryConsumer(
|
|
|
1002
1002
|
);
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
1005
|
-
|
|
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) {
|