@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.
- package/dist/cjs/config-store.js +68 -8
- 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 +68 -8
- 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.test.ts +32 -0
- package/src/config-store.ts +74 -16
- package/src/encrypted-headers.test.ts +150 -0
- package/src/registry-consumer.ts +8 -1
package/src/config-store.test.ts
CHANGED
|
@@ -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 ─────────────────────────────────────
|
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
|
}
|
|
@@ -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:
|
|
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
|
+
});
|
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) {
|