@slashfi/agents-sdk 0.90.1 → 0.90.4

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.
@@ -86,9 +86,36 @@ export interface RegistryCacheToolSummary {
86
86
  * client registration). Doesn't need to be `present`
87
87
  * in the user's config to count as satisfied.
88
88
  */
89
+ export type CompositeCredentialFormat = "basic";
90
+
91
+ export interface RegistryCacheAuthFieldPart {
92
+ /** Field key callers should collect (for example "username") */
93
+ name: string;
94
+ /** Human-readable label for UI rendering */
95
+ label: string;
96
+ /** Whether this part is secret and should be masked */
97
+ secret: boolean;
98
+ /** Whether this part may be intentionally blank */
99
+ optional?: boolean;
100
+ /** Optional description / help text */
101
+ description?: string;
102
+ }
103
+
89
104
  export interface RegistryCacheAuthField {
90
105
  required: boolean;
91
106
  automated: boolean;
107
+ /** How `parts` compose into this canonical stored credential. */
108
+ format?: CompositeCredentialFormat;
109
+ /**
110
+ * Optional structured inputs that compose this canonical stored credential.
111
+ * For example HTTP Basic stores one `token` but asks UI for username/password.
112
+ */
113
+ parts?: RegistryCacheAuthFieldPart[];
114
+ /**
115
+ * When `false`, connect/refresh bookkeeping only — not forwarded on
116
+ * `ref.call`. Omitted or `true` for bearer, header, and call-time creds.
117
+ */
118
+ outbound?: boolean;
92
119
  }
93
120
 
94
121
  /**
@@ -329,6 +356,7 @@ export interface AdkRegistryApi {
329
356
  nameOrUrl: string,
330
357
  credential:
331
358
  | { token: string; tokenUrl?: string }
359
+ | { username: string; password?: string }
332
360
  | { apiKey: string; header?: string },
333
361
  ): Promise<boolean>;
334
362
 
@@ -364,6 +392,12 @@ export interface CredentialField {
364
392
  present: boolean;
365
393
  /** Available via resolveCredentials callback */
366
394
  resolvable: boolean;
395
+ /** How `parts` compose into this canonical stored credential */
396
+ format?: CompositeCredentialFormat;
397
+ /** Structured inputs that compose this canonical stored credential */
398
+ parts?: AuthChallengeField[];
399
+ /** Connect/refresh only — not forwarded on ref.call. */
400
+ outbound?: boolean;
367
401
  }
368
402
 
369
403
  /** Describes what auth a ref needs and what's already provided */
@@ -391,6 +425,8 @@ export interface AuthChallengeField {
391
425
  label: string;
392
426
  /** Whether this is a secret value (should be masked in UI) */
393
427
  secret: boolean;
428
+ /** Whether this field may be intentionally blank */
429
+ optional?: boolean;
394
430
  /** Optional description / help text */
395
431
  description?: string;
396
432
  }
@@ -667,7 +703,7 @@ function renderCredentialForm(
667
703
  <div class="field">
668
704
  <label for="${esc(f.name)}">${esc(f.label)}</label>
669
705
  ${f.description ? `<p class="desc">${esc(f.description)}</p>` : ""}
670
- <input id="${esc(f.name)}" name="${esc(f.name)}" type="${f.secret ? "password" : "text"}" required autocomplete="off" spellcheck="false" />
706
+ <input id="${esc(f.name)}" name="${esc(f.name)}" type="${f.secret ? "password" : "text"}" ${f.optional ? "" : "required"} autocomplete="off" spellcheck="false" />
671
707
  </div>`,
672
708
  )
673
709
  .join("");
@@ -962,6 +998,203 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
962
998
  };
963
999
  }
964
1000
 
1001
+ /**
1002
+ * Call-time credential lookup: stored ref config first, then the host
1003
+ * `resolveCredentials` callback. Does not persist resolved values.
1004
+ */
1005
+ async function resolveCallCredential(
1006
+ ctx: CredentialResolverContext,
1007
+ field: string,
1008
+ ): Promise<string | null> {
1009
+ const stored = await readRefSecret(ctx.name, field);
1010
+ if (stored) return stored;
1011
+ return makeTryResolve(ctx)(field);
1012
+ }
1013
+
1014
+ const CALL_BEARER_FIELDS = ["access_token", "api_key", "token"] as const;
1015
+
1016
+ function isBearerAuthField(field: string): boolean {
1017
+ return (CALL_BEARER_FIELDS as readonly string[]).includes(field);
1018
+ }
1019
+
1020
+ /** Legacy cache entries may omit `outbound`; these are never call-time creds. */
1021
+ const LEGACY_CONNECT_ONLY_FIELDS = new Set([
1022
+ "client_id",
1023
+ "client_secret",
1024
+ "refresh_token",
1025
+ ]);
1026
+
1027
+ function isCallOutboundAuthField(
1028
+ field: string,
1029
+ info: RegistryCacheAuthField,
1030
+ ): boolean {
1031
+ if (info.outbound === false) return false;
1032
+ if (info.outbound === true) return true;
1033
+ return !LEGACY_CONNECT_ONLY_FIELDS.has(field);
1034
+ }
1035
+
1036
+ function readRegistryDeclaredAuthFields(
1037
+ security: SecuritySchemeSummary | null,
1038
+ ): Record<string, RegistryCacheAuthField> | undefined {
1039
+ if (!security || typeof security !== "object") return undefined;
1040
+ const raw = (security as { authFields?: unknown }).authFields;
1041
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
1042
+ const out: Record<string, RegistryCacheAuthField> = {};
1043
+ for (const [field, meta] of Object.entries(raw as Record<string, unknown>)) {
1044
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) continue;
1045
+ const m = meta as Record<string, unknown>;
1046
+ if (typeof m.required !== "boolean" || typeof m.automated !== "boolean") {
1047
+ continue;
1048
+ }
1049
+ out[field] = { required: m.required, automated: m.automated };
1050
+ if (typeof m.outbound === "boolean") {
1051
+ out[field].outbound = m.outbound;
1052
+ }
1053
+ }
1054
+ return Object.keys(out).length > 0 ? out : undefined;
1055
+ }
1056
+
1057
+ async function mergeRegistryDeclaredAuthFields(
1058
+ fields: Record<string, CredentialField>,
1059
+ declared: Record<string, RegistryCacheAuthField> | undefined,
1060
+ canResolve: (field: string) => Promise<boolean>,
1061
+ configKeys: string[],
1062
+ refConfig: Record<string, unknown>,
1063
+ ): Promise<Record<string, CredentialField>> {
1064
+ if (!declared) return fields;
1065
+ const next: Record<string, CredentialField> = {};
1066
+ for (const [field, meta] of Object.entries(declared)) {
1067
+ next[field] = {
1068
+ required: meta.required,
1069
+ automated: meta.automated,
1070
+ present:
1071
+ configKeys.includes(field) || hasCredentialField(refConfig, field),
1072
+ resolvable: await canResolve(field),
1073
+ ...(meta.format && { format: meta.format }),
1074
+ ...(meta.parts && { parts: meta.parts }),
1075
+ ...(meta.outbound === false && { outbound: false }),
1076
+ };
1077
+ }
1078
+ return next;
1079
+ }
1080
+
1081
+ function bearerFieldSatisfied(
1082
+ accessToken: string | null,
1083
+ refConfig: Record<string, unknown>,
1084
+ field: string,
1085
+ ): boolean {
1086
+ if (accessToken) return true;
1087
+ return hasCredentialField(refConfig, field);
1088
+ }
1089
+
1090
+ function fallbackCallAuthFields(): Record<string, RegistryCacheAuthField> {
1091
+ return {
1092
+ access_token: { required: true, automated: true },
1093
+ api_key: { required: false, automated: true },
1094
+ token: { required: false, automated: true },
1095
+ };
1096
+ }
1097
+
1098
+ function headerFieldSatisfied(
1099
+ headers: Record<string, string>,
1100
+ field: string,
1101
+ ): boolean {
1102
+ const wanted = normalizeCredentialKey(field);
1103
+ return Object.keys(headers).some(
1104
+ (key) => normalizeCredentialKey(key) === wanted,
1105
+ );
1106
+ }
1107
+
1108
+ function resolveHeaderNameForField(
1109
+ field: string,
1110
+ refConfig: Record<string, unknown>,
1111
+ ): string {
1112
+ const wanted = normalizeCredentialKey(field);
1113
+ const configHeaders = refConfig.headers;
1114
+ if (
1115
+ configHeaders &&
1116
+ typeof configHeaders === "object" &&
1117
+ !Array.isArray(configHeaders)
1118
+ ) {
1119
+ for (const key of Object.keys(configHeaders as Record<string, unknown>)) {
1120
+ if (normalizeCredentialKey(key) === wanted) return key;
1121
+ }
1122
+ }
1123
+ // x_api_key → X-API-KEY (registry codegen declares the canonical name;
1124
+ // env-resolved keys use the normalized storage field name).
1125
+ return field
1126
+ .split("_")
1127
+ .filter(Boolean)
1128
+ .map((part) => part.toUpperCase())
1129
+ .join("-");
1130
+ }
1131
+
1132
+ /**
1133
+ * Supplement call-time credentials from `resolveCredentials` when they
1134
+ * are not already present in consumer-config. Stored values and config
1135
+ * headers win — this only fills gaps. Walks cached `authFields` as the
1136
+ * source of truth (registry-declared when auth-status has run).
1137
+ */
1138
+ async function resolveAllCallCredentials(opts: {
1139
+ ctx: CredentialResolverContext;
1140
+ refConfig: Record<string, unknown>;
1141
+ accessToken: string | null;
1142
+ resolvedHeaders: Record<string, string> | undefined;
1143
+ }): Promise<{
1144
+ accessToken: string | null;
1145
+ resolvedHeaders: Record<string, string> | undefined;
1146
+ }> {
1147
+ if (!options.resolveCredentials) {
1148
+ return {
1149
+ accessToken: opts.accessToken,
1150
+ resolvedHeaders: opts.resolvedHeaders,
1151
+ };
1152
+ }
1153
+
1154
+ let accessToken = opts.accessToken;
1155
+ let resolvedHeaders = opts.resolvedHeaders;
1156
+ const { ctx, refConfig } = opts;
1157
+
1158
+ const cache = await readRegistryCache();
1159
+ const authFields =
1160
+ cache.refs[ctx.name]?.authFields ?? fallbackCallAuthFields();
1161
+
1162
+ for (const [field, info] of Object.entries(authFields)) {
1163
+ if (!isCallOutboundAuthField(field, info)) continue;
1164
+ if (!info.required && !info.automated) continue;
1165
+
1166
+ if (isBearerAuthField(field)) {
1167
+ if (bearerFieldSatisfied(accessToken, refConfig, field)) continue;
1168
+ const value = await resolveCallCredential(ctx, field);
1169
+ if (value) accessToken = accessToken ?? value;
1170
+ continue;
1171
+ }
1172
+
1173
+ if (hasCredentialField(refConfig, field)) continue;
1174
+ if (resolvedHeaders && headerFieldSatisfied(resolvedHeaders, field)) {
1175
+ continue;
1176
+ }
1177
+
1178
+ const value = await resolveCallCredential(ctx, field);
1179
+ if (!value) continue;
1180
+
1181
+ resolvedHeaders = resolvedHeaders ?? {};
1182
+ if (!headerFieldSatisfied(resolvedHeaders, field)) {
1183
+ resolvedHeaders[resolveHeaderNameForField(field, refConfig)] = value;
1184
+ }
1185
+ }
1186
+
1187
+ if (!accessToken) {
1188
+ const username = await resolveCallCredential(ctx, "username");
1189
+ const password = await resolveCallCredential(ctx, "password");
1190
+ if (username && password) {
1191
+ accessToken = btoa(`${username}:${password}`);
1192
+ }
1193
+ }
1194
+
1195
+ return { accessToken, resolvedHeaders };
1196
+ }
1197
+
965
1198
  /**
966
1199
  * Resolve OAuth client credentials (client_id + client_secret) for a
967
1200
  * ref. Walks: `resolveCredentials` callback → per-ref VCS storage.
@@ -1320,7 +1553,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1320
1553
  */
1321
1554
  async function updateRegistryEntry(
1322
1555
  nameOrUrl: string,
1323
- mutate: (entry: RegistryEntry) => void,
1556
+ mutate: (entry: RegistryEntry) => void | Promise<void>,
1324
1557
  ): Promise<boolean> {
1325
1558
  const config = await readConfig();
1326
1559
  if (!config.registries?.length) return false;
@@ -1331,10 +1564,14 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1331
1564
  found = true;
1332
1565
  const existing: RegistryEntry =
1333
1566
  typeof r === "string" ? { url: r } : { ...r };
1334
- mutate(existing);
1335
1567
  return existing;
1336
1568
  });
1337
1569
  if (!found) return false;
1570
+ for (const r of registries) {
1571
+ if (typeof r !== "string" && (registryDisplayName(r) === nameOrUrl || registryUrl(r) === nameOrUrl)) {
1572
+ await mutate(r);
1573
+ }
1574
+ }
1338
1575
  await writeConfig({ ...config, registries });
1339
1576
  return true;
1340
1577
  }
@@ -1433,6 +1670,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1433
1670
  const hasUsableAuth =
1434
1671
  entry.auth && entry.auth.type !== "none"
1435
1672
  ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
1673
+ (entry.auth.type === "basic" && !!entry.auth.username) ||
1436
1674
  (entry.auth.type === "api-key" && !!entry.auth.key)
1437
1675
  : false;
1438
1676
  if (hasUsableAuth) return;
@@ -1474,6 +1712,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1474
1712
  const hasUsableAuth =
1475
1713
  entry.auth && entry.auth.type !== "none"
1476
1714
  ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
1715
+ (entry.auth.type === "basic" && !!entry.auth.username) ||
1477
1716
  (entry.auth.type === "api-key" && !!entry.auth.key)
1478
1717
  : false;
1479
1718
 
@@ -1584,6 +1823,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1584
1823
  const hasUsableAuth =
1585
1824
  r.auth && r.auth.type !== "none"
1586
1825
  ? (r.auth.type === "bearer" && !!r.auth.token) ||
1826
+ (r.auth.type === "basic" && !!r.auth.username) ||
1587
1827
  (r.auth.type === "api-key" && !!r.auth.key)
1588
1828
  : false;
1589
1829
  if (!hasUsableAuth) {
@@ -1627,26 +1867,30 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1627
1867
  nameOrUrl: string,
1628
1868
  credential:
1629
1869
  | { token: string; tokenUrl?: string }
1870
+ | { username: string; password?: string }
1630
1871
  | { apiKey: string; header?: string },
1631
1872
  ): Promise<boolean> {
1632
- // Encrypt the secret value up-front so the write path is uniform;
1633
- // `buildConsumer` decrypts on the read side via `decryptConfigSecrets`.
1634
- const protectedValue =
1635
- "token" in credential
1636
- ? await protectSecret(credential.token)
1637
- : await protectSecret(credential.apiKey);
1638
-
1639
- const updated = await updateRegistryEntry(nameOrUrl, (existing) => {
1873
+ // Encrypt secret values before writing. `buildConsumer` decrypts on the
1874
+ // read side via `decryptConfigSecrets`.
1875
+ const updated = await updateRegistryEntry(nameOrUrl, async (existing) => {
1640
1876
  if ("token" in credential) {
1641
1877
  existing.auth = {
1642
1878
  type: "bearer",
1643
- token: protectedValue,
1879
+ token: await protectSecret(credential.token),
1644
1880
  ...(credential.tokenUrl && { tokenUrl: credential.tokenUrl }),
1645
1881
  };
1882
+ } else if ("username" in credential) {
1883
+ existing.auth = {
1884
+ type: "basic",
1885
+ username: await protectSecret(credential.username),
1886
+ ...(credential.password && {
1887
+ password: await protectSecret(credential.password),
1888
+ }),
1889
+ };
1646
1890
  } else {
1647
1891
  existing.auth = {
1648
1892
  type: "api-key",
1649
- key: protectedValue,
1893
+ key: await protectSecret(credential.apiKey),
1650
1894
  ...(credential.header && { header: credential.header }),
1651
1895
  };
1652
1896
  }
@@ -1710,6 +1954,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1710
1954
  const hasUsableAuth =
1711
1955
  target.auth && target.auth.type !== "none"
1712
1956
  ? (target.auth.type === "bearer" && !!target.auth.token) ||
1957
+ (target.auth.type === "basic" && !!target.auth.username) ||
1713
1958
  (target.auth.type === "api-key" && !!target.auth.key)
1714
1959
  : false;
1715
1960
  if (hasUsableAuth && !target.authRequirement) {
@@ -2213,7 +2458,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2213
2458
  const entry = findRef(config.refs ?? [], name);
2214
2459
  if (!entry) throw new Error(`Ref "${name}" not found`);
2215
2460
 
2216
- const accessToken =
2461
+ let accessToken =
2217
2462
  (await readRefSecret(name, "access_token")) ??
2218
2463
  (await readRefSecret(name, "api_key")) ??
2219
2464
  (await readRefSecret(name, "token"));
@@ -2265,6 +2510,17 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2265
2510
  }
2266
2511
  }
2267
2512
 
2513
+ if (options.resolveCredentials) {
2514
+ const supplemented = await resolveAllCallCredentials({
2515
+ ctx: { name, entry, security: null },
2516
+ refConfig,
2517
+ accessToken,
2518
+ resolvedHeaders,
2519
+ });
2520
+ accessToken = supplemented.accessToken;
2521
+ resolvedHeaders = supplemented.resolvedHeaders;
2522
+ }
2523
+
2268
2524
  const doCall = async (token: string | null) => {
2269
2525
  // Direct MCP only for redirect/proxy agents with an MCP upstream.
2270
2526
  // API-mode agents must go through the registry (it does REST translation).
@@ -2419,7 +2675,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2419
2675
  return (await tryResolveField(field, oauthMetadata)) !== null;
2420
2676
  }
2421
2677
 
2422
- const fields: Record<string, CredentialField> = {};
2678
+ let fields: Record<string, CredentialField> = {};
2423
2679
 
2424
2680
  if (security.type === "oauth2") {
2425
2681
  const securityExt = security as {
@@ -2465,6 +2721,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2465
2721
  automated: hasRegistration,
2466
2722
  present: configKeys.includes("client_id"),
2467
2723
  resolvable: await canResolve("client_id", oauthMetadata),
2724
+ outbound: false,
2468
2725
  };
2469
2726
  if (needsSecret) {
2470
2727
  fields.client_secret = {
@@ -2472,13 +2729,14 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2472
2729
  automated: hasRegistration,
2473
2730
  present: configKeys.includes("client_secret"),
2474
2731
  resolvable: await canResolve("client_secret", oauthMetadata),
2732
+ outbound: false,
2475
2733
  };
2476
2734
  }
2477
2735
  fields.access_token = {
2478
2736
  required: true,
2479
2737
  automated: accessTokenAutomated,
2480
2738
  present: configKeys.includes("access_token"),
2481
- resolvable: false,
2739
+ resolvable: await canResolve("access_token"),
2482
2740
  };
2483
2741
  } else if (security.type === "apiKey") {
2484
2742
  const apiKeySec = security as {
@@ -2531,11 +2789,23 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2531
2789
  };
2532
2790
  }
2533
2791
  } else if (security.type === "http") {
2792
+ const httpSec = security as { scheme?: string };
2793
+ const isBasic = httpSec.scheme === "basic";
2534
2794
  fields.token = {
2535
2795
  required: true,
2536
2796
  automated: false,
2537
2797
  present: configKeys.includes("token"),
2538
- resolvable: await canResolve("token"),
2798
+ resolvable: isBasic
2799
+ ? (await canResolve("username")) &&
2800
+ (await tryResolveField("password")) !== null
2801
+ : await canResolve("token"),
2802
+ ...(isBasic && {
2803
+ format: "basic" as const,
2804
+ parts: [
2805
+ { name: "username", label: "Username", secret: false },
2806
+ { name: "password", label: "Password", secret: true, optional: true },
2807
+ ],
2808
+ }),
2539
2809
  };
2540
2810
  } else if (security.type === "form") {
2541
2811
  // Form-based refs collect structured user input at connect time
@@ -2554,8 +2824,16 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2554
2824
  };
2555
2825
  }
2556
2826
 
2827
+ fields = await mergeRegistryDeclaredAuthFields(
2828
+ fields,
2829
+ readRegistryDeclaredAuthFields(security),
2830
+ canResolve,
2831
+ configKeys,
2832
+ (entry.config ?? {}) as Record<string, unknown>,
2833
+ );
2834
+
2557
2835
  const complete = Object.values(fields).every(
2558
- (f) => !f.required || f.present || f.resolvable,
2836
+ (f) => !f.required || f.automated || f.present || f.resolvable,
2559
2837
  );
2560
2838
 
2561
2839
  // Persist the slim {required, automated} per-field shape into the
@@ -2569,6 +2847,9 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2569
2847
  authFields[field] = {
2570
2848
  required: info.required,
2571
2849
  automated: info.automated,
2850
+ ...(info.format && { format: info.format }),
2851
+ ...(info.parts && { parts: info.parts }),
2852
+ ...(info.outbound === false && { outbound: false }),
2572
2853
  };
2573
2854
  }
2574
2855
  await upsertRegistryCacheAuthFields(name, entry.ref, authFields);
@@ -2706,24 +2987,23 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2706
2987
  const username =
2707
2988
  opts?.credentials?.["username"] ?? (await tryResolve("username"));
2708
2989
  const password =
2709
- opts?.credentials?.["password"] ?? (await tryResolve("password"));
2710
- if (!username || !password) {
2711
- const missingFields: AuthChallengeField[] = [];
2712
- if (!username)
2713
- missingFields.push({
2714
- name: "username",
2715
- label: "Username",
2716
- secret: false,
2717
- });
2718
- if (!password)
2719
- missingFields.push({
2720
- name: "password",
2721
- label: "Password",
2722
- secret: true,
2723
- });
2724
- return { type: "http", complete: false, fields: missingFields };
2990
+ opts?.credentials?.["password"] ?? (await tryResolve("password")) ?? "";
2991
+ const hasUsername = username !== undefined && username !== null && username !== "";
2992
+ if (!hasUsername) {
2993
+ return {
2994
+ type: "http",
2995
+ complete: false,
2996
+ fields: [
2997
+ {
2998
+ name: "username",
2999
+ label: "Username",
3000
+ secret: false,
3001
+ },
3002
+ ],
3003
+ };
2725
3004
  }
2726
- // Store as base64 encoded basic auth token
3005
+ // Store as base64 encoded basic auth token. Password may be blank
3006
+ // for APIs that use the Basic username slot as an API key.
2727
3007
  const token = btoa(`${username}:${password}`);
2728
3008
  await storeRefSecret(name, "token", token);
2729
3009
  return { type: "http", complete: true };
@@ -2951,7 +3231,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2951
3231
  const credentials: Record<string, string> = {};
2952
3232
  for (const field of result.fields!) {
2953
3233
  const val = params.get(field.name);
2954
- if (val) credentials[field.name] = val;
3234
+ if (val !== null) credentials[field.name] = val;
2955
3235
  }
2956
3236
 
2957
3237
  try {
@@ -538,6 +538,60 @@ describe("Secret URI resolution", () => {
538
538
  });
539
539
  });
540
540
 
541
+ // ─── Basic Auth Tests ────────────────────────────────────────────
542
+
543
+ describe("Registry Consumer — Basic Auth", () => {
544
+ let server: AgentServer;
545
+ const PORT = 19895;
546
+ const USERNAME = "ashby-api-key";
547
+ const PASSWORD = "";
548
+
549
+ beforeAll(async () => {
550
+ const registry = createAgentRegistry();
551
+ registry.register(mathAgent);
552
+ registry.register(echoAgent);
553
+
554
+ server = createAgentServer(registry, {
555
+ port: PORT,
556
+ resolveAuth: async (req) => {
557
+ const auth = req.headers.get("authorization");
558
+ const expected = `Basic ${Buffer.from(`${USERNAME}:${PASSWORD}`, "utf8").toString("base64")}`;
559
+ if (auth === expected) {
560
+ return {
561
+ callerId: "basic-auth-user",
562
+ callerType: "system" as const,
563
+ scopes: ["*"],
564
+ };
565
+ }
566
+ return null;
567
+ },
568
+ });
569
+ await server.start();
570
+ });
571
+
572
+ afterAll(async () => {
573
+ await server.stop();
574
+ });
575
+
576
+ test("consumer with basic auth type can list agents", async () => {
577
+ const consumer = await createRegistryConsumer({
578
+ registries: [
579
+ {
580
+ url: `http://localhost:${PORT}`,
581
+ auth: { type: "basic", username: USERNAME, password: PASSWORD },
582
+ },
583
+ ],
584
+ refs: [{ ref: "@math" }],
585
+ });
586
+
587
+ const agents = await consumer.list();
588
+ expect(agents.length).toBeGreaterThanOrEqual(2);
589
+ const paths = agents.map((a) => a.path);
590
+ expect(paths).toContain("@math");
591
+ expect(paths).toContain("@echo");
592
+ });
593
+ });
594
+
541
595
  // ─── API Key Auth Tests ──────────────────────────────────────────
542
596
 
543
597
  describe("Registry Consumer — API Key Auth", () => {
@@ -31,6 +31,7 @@
31
31
  export type RegistryAuth =
32
32
  | { type: "none" }
33
33
  | { type: "bearer"; token?: string; tokenUrl?: string }
34
+ | { type: "basic"; username?: string; password?: string }
34
35
  | { type: "api-key"; key?: string; header?: string }
35
36
  | { type: "jwt"; issuer?: string };
36
37
 
@@ -144,7 +144,7 @@ function expandEnvVars(value: string): string {
144
144
 
145
145
  /**
146
146
  * Build auth headers for a registry based on its auth config and custom headers.
147
- * Merges typed auth (bearer, api-key) with arbitrary custom headers.
147
+ * Merges typed auth (bearer, basic, api-key) with arbitrary custom headers.
148
148
  * Environment variable references ($VAR or ${VAR}) in header values are expanded.
149
149
  */
150
150
  function buildRegistryAuthHeaders(
@@ -162,6 +162,15 @@ function buildRegistryAuthHeaders(
162
162
  }
163
163
  break;
164
164
  }
165
+ case "basic": {
166
+ if ("username" in registry.auth && registry.auth.username) {
167
+ const password = ("password" in registry.auth ? registry.auth.password : undefined) ?? "";
168
+ const credentials = `${registry.auth.username}:${password}`;
169
+ const encoded = Buffer.from(credentials, "utf8").toString("base64");
170
+ headers.Authorization = `Basic ${encoded}`;
171
+ }
172
+ break;
173
+ }
165
174
  case "api-key": {
166
175
  if ("key" in registry.auth && registry.auth.key) {
167
176
  const headerName = ("header" in registry.auth ? registry.auth.header : undefined) ?? "x-api-key";