@slashfi/agents-sdk 0.75.0 → 0.77.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.
Files changed (43) hide show
  1. package/dist/adk.js +186 -150
  2. package/dist/adk.js.map +1 -1
  3. package/dist/cjs/config-store.js +642 -38
  4. package/dist/cjs/config-store.js.map +1 -1
  5. package/dist/cjs/define-config.js.map +1 -1
  6. package/dist/cjs/index.js.map +1 -1
  7. package/dist/cjs/mcp-client.js +98 -0
  8. package/dist/cjs/mcp-client.js.map +1 -1
  9. package/dist/cjs/registry-consumer.js +76 -10
  10. package/dist/cjs/registry-consumer.js.map +1 -1
  11. package/dist/cjs/server.js +8 -0
  12. package/dist/cjs/server.js.map +1 -1
  13. package/dist/config-store.d.ts +43 -8
  14. package/dist/config-store.d.ts.map +1 -1
  15. package/dist/config-store.js +643 -39
  16. package/dist/config-store.js.map +1 -1
  17. package/dist/define-config.d.ts +83 -17
  18. package/dist/define-config.d.ts.map +1 -1
  19. package/dist/define-config.js.map +1 -1
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/mcp-client.d.ts +44 -0
  24. package/dist/mcp-client.d.ts.map +1 -1
  25. package/dist/mcp-client.js +95 -0
  26. package/dist/mcp-client.js.map +1 -1
  27. package/dist/registry-consumer.d.ts +10 -0
  28. package/dist/registry-consumer.d.ts.map +1 -1
  29. package/dist/registry-consumer.js +76 -10
  30. package/dist/registry-consumer.js.map +1 -1
  31. package/dist/server.d.ts +11 -0
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +8 -0
  34. package/dist/server.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/adk.ts +107 -65
  37. package/src/config-store.test.ts +381 -1
  38. package/src/config-store.ts +750 -55
  39. package/src/define-config.ts +89 -23
  40. package/src/index.ts +0 -2
  41. package/src/mcp-client.ts +121 -0
  42. package/src/registry-consumer.ts +101 -12
  43. package/src/server.ts +19 -0
@@ -20,7 +20,6 @@
20
20
  import type { FsStore } from "./agent-definitions/config.js";
21
21
  import type {
22
22
  ConsumerConfig,
23
- ProxyEntry,
24
23
  RefEntry,
25
24
  RegistryEntry,
26
25
  ResolvedRef,
@@ -44,7 +43,10 @@ import {
44
43
  dynamicClientRegistration,
45
44
  buildOAuthAuthorizeUrl,
46
45
  exchangeCodeForTokens,
46
+ probeRegistryAuth,
47
+ refreshAccessToken,
47
48
  } from "./mcp-client.js";
49
+ import type { RegistryAuthRequirement } from "./define-config.js";
48
50
 
49
51
  const CONFIG_PATH = "consumer-config.json";
50
52
  const SECRET_PREFIX = "secret:";
@@ -125,7 +127,7 @@ export interface RegistryTestResult {
125
127
  }
126
128
 
127
129
  export interface AdkRegistryApi {
128
- add(entry: RegistryEntry): Promise<void>;
130
+ add(entry: RegistryEntry): Promise<{ authRequirement?: RegistryAuthRequirement }>;
129
131
  remove(nameOrUrl: string): Promise<boolean>;
130
132
  list(): Promise<RegistryEntry[]>;
131
133
  get(name: string): Promise<RegistryEntry | null>;
@@ -133,6 +135,39 @@ export interface AdkRegistryApi {
133
135
  browse(name: string, query?: string): Promise<AgentListEntry[]>;
134
136
  inspect(name: string): Promise<RegistryConfiguration>;
135
137
  test(name?: string): Promise<RegistryTestResult[]>;
138
+ /**
139
+ * Attach a credential to a registry that returned 401 during `add`. Clears
140
+ * `authRequirement` so subsequent ops stop throwing `registry_auth_required`.
141
+ * Accepts a pre-existing token / api-key when the caller already has one.
142
+ */
143
+ auth(
144
+ nameOrUrl: string,
145
+ credential:
146
+ | { token: string; tokenUrl?: string }
147
+ | { apiKey: string; header?: string },
148
+ ): Promise<boolean>;
149
+
150
+ /**
151
+ * Resolve auth for a registry the way `adk registry auth` does — runs the
152
+ * full OAuth flow (dynamic client registration + PKCE authorize + callback
153
+ * + token exchange) when the registry advertised authorization servers,
154
+ * or spins up a local HTTPS form for bearer-token entry otherwise.
155
+ *
156
+ * Returns `{ complete: true }` once the registry has usable credentials
157
+ * persisted. The `onAuthorizeUrl` callback fires with the URL the user
158
+ * should open (browser redirect URL for OAuth, or `http://localhost/auth`
159
+ * for the token-entry form). Pass `force: true` to skip the short-circuit
160
+ * when existing credentials look syntactically valid but may be stale
161
+ * server-side — the common case when the CLI command is invoked explicitly.
162
+ */
163
+ authLocal(
164
+ nameOrUrl: string,
165
+ opts?: {
166
+ onAuthorizeUrl?: (url: string) => void;
167
+ timeoutMs?: number;
168
+ force?: boolean;
169
+ },
170
+ ): Promise<{ complete: boolean }>;
136
171
  }
137
172
 
138
173
  /** Describes a single credential field requirement */
@@ -242,6 +277,12 @@ export interface AdkRefApi {
242
277
  stateContext?: Record<string, unknown>;
243
278
  /** Additional scopes to request (e.g., optional scopes declared by the agent) */
244
279
  scopes?: string[];
280
+ /**
281
+ * Opt out of proxy routing when the ref's source registry has
282
+ * `proxy: { mode: 'optional' }`. Ignored for `mode: 'required'`.
283
+ * Defaults to `false` — if a registry offers a proxy we use it.
284
+ */
285
+ preferLocal?: boolean;
245
286
  }): Promise<AuthStartResult>;
246
287
  /**
247
288
  * Run the full OAuth flow locally: start auth, spin up a callback
@@ -265,14 +306,7 @@ export interface AdkRefApi {
265
306
  refreshToken(name: string): Promise<{ accessToken: string } | null>;
266
307
  }
267
308
 
268
- export interface AdkProxyApi {
269
- add(entry: ProxyEntry): Promise<void>;
270
- remove(name: string): Promise<boolean>;
271
- list(): Promise<ProxyEntry[]>;
272
- }
273
-
274
309
  export interface Adk {
275
- proxy: AdkProxyApi;
276
310
  registry: AdkRegistryApi;
277
311
  ref: AdkRefApi;
278
312
  readConfig(): Promise<ConsumerConfig>;
@@ -724,19 +758,324 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
724
758
  return fallback;
725
759
  }
726
760
 
761
+ // ==========================================
762
+ // Proxy Routing
763
+ // ==========================================
764
+
765
+ /**
766
+ * Find the configured RegistryEntry for a ref, consulting `sourceRegistry`
767
+ * first and falling back to the first registry in config. Returns `null` when
768
+ * the ref is sourced from a raw URL (no registry), in which case proxy routing
769
+ * does not apply.
770
+ */
771
+ async function findRegistryEntryForRef(entry: RefEntry): Promise<RegistryEntry | null> {
772
+ const sourceUrl = entry.sourceRegistry?.url;
773
+ if (!sourceUrl) return null;
774
+ const config = await readConfig();
775
+ const match = (config.registries ?? []).find((r) => {
776
+ if (typeof r === "string") return r === sourceUrl;
777
+ return r.url === sourceUrl;
778
+ });
779
+ if (!match || typeof match === "string") return null;
780
+ return match;
781
+ }
782
+
783
+ /**
784
+ * Returns the proxy settings for a ref when its source registry has
785
+ * `proxy` configured. `null` means "run locally".
786
+ *
787
+ * Callers pass `{ preferLocal: true }` to opt out of `mode: 'optional'`
788
+ * proxying when they already hold credentials locally. `mode: 'required'`
789
+ * cannot be bypassed — the registry owns auth server-side and there is
790
+ * nothing useful the local SDK can do.
791
+ */
792
+ async function resolveProxyForRef(
793
+ entry: RefEntry,
794
+ opts?: { preferLocal?: boolean },
795
+ ): Promise<{ reg: RegistryEntry; agent: string } | null> {
796
+ const reg = await findRegistryEntryForRef(entry);
797
+ if (!reg?.proxy) return null;
798
+ if (reg.proxy.mode === "optional" && opts?.preferLocal) return null;
799
+ return { reg, agent: reg.proxy.agent ?? "@config" };
800
+ }
801
+
802
+ /**
803
+ * Forward an `@config ref` operation to the proxy agent on a remote registry.
804
+ *
805
+ * The remote side speaks the standard adk-tools surface, so the call shape is
806
+ * identical to what the local `ref` API would do — the only difference is
807
+ * that tokens and secrets live server-side. `callRegistry` returns the
808
+ * standard CallAgentResponse envelope: `{ success: true, result }` on
809
+ * success or `{ success: false, error }` on failure. We unwrap once and
810
+ * throw on error so callers get a result that matches the local signature.
811
+ */
812
+ async function forwardRefOpToProxy<T>(
813
+ reg: RegistryEntry,
814
+ agent: string,
815
+ operation: string,
816
+ params: Record<string, unknown>,
817
+ ): Promise<T> {
818
+ const consumer = await buildConsumerForRef({ ref: "", sourceRegistry: { url: reg.url, agentPath: agent } } as RefEntry);
819
+ const resolved = consumer.registries().find((r) => r.url === reg.url);
820
+ if (!resolved) throw new Error(`Registry ${reg.url} not resolvable for proxy forwarding`);
821
+
822
+ const response = await consumer.callRegistry(resolved, {
823
+ action: "execute_tool",
824
+ path: agent,
825
+ tool: "ref",
826
+ params: { operation, ...params },
827
+ });
828
+
829
+ if (!response.success) {
830
+ const errResponse = response as { success: false; error?: string; code?: string };
831
+ const msg = errResponse.error ?? `Proxy ${agent}.ref(${operation}) failed`;
832
+ throw new Error(msg);
833
+ }
834
+ return (response as { success: true; result: unknown }).result as T;
835
+ }
836
+
727
837
  // ==========================================
728
838
  // Registry API
729
839
  // ==========================================
730
840
 
841
+ /**
842
+ * Encrypt with `secret:` prefix when an encryption key is configured, so the
843
+ * value is readable by the existing `decryptConfigSecrets` path on the read
844
+ * side. Plaintext fallback preserves the "no key = dev mode" contract.
845
+ */
846
+ async function protectSecret(value: string): Promise<string> {
847
+ if (!options.encryptionKey) return value;
848
+ return `${SECRET_PREFIX}${await encryptSecret(value, options.encryptionKey)}`;
849
+ }
850
+
851
+ /**
852
+ * Re-probe a registry with the current stored credentials to see whether it
853
+ * advertises `capabilities.registry.proxy` in its MCP `initialize` response,
854
+ * and persist the proxy config when it does. Safe to call after a successful
855
+ * `auth()` / `authLocal()` — on the add path we skip the proxy probe when
856
+ * auth is required, so this is the second chance to back-fill it.
857
+ *
858
+ * Respects explicit user config: if `proxy` is already set, we leave it
859
+ * alone. Any discovery failure is swallowed — proxy is an optimization,
860
+ * not a correctness requirement.
861
+ */
862
+ async function discoverProxyAfterAuth(nameOrUrl: string): Promise<void> {
863
+ const config = await readConfig();
864
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
865
+ if (!target || typeof target === "string") return;
866
+ if (target.proxy) return;
867
+
868
+ try {
869
+ const consumer = await buildConsumer(nameOrUrl);
870
+ const discovered = await consumer.discover(target.url);
871
+ if (!discovered.proxy?.mode) return;
872
+ await updateRegistryEntry(nameOrUrl, (existing) => {
873
+ if (existing.proxy) return;
874
+ existing.proxy = {
875
+ mode: discovered.proxy!.mode,
876
+ ...(discovered.proxy!.agent && { agent: discovered.proxy!.agent }),
877
+ };
878
+ });
879
+ } catch {
880
+ // Proxy probe is best-effort — auth itself already succeeded.
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Atomic read-modify-write on a registry entry by name or URL. Used by
886
+ * `authLocal` to persist both `auth` and `oauth` together, which `auth()`
887
+ * alone can't express. Returns true when the entry was found and written.
888
+ */
889
+ async function updateRegistryEntry(
890
+ nameOrUrl: string,
891
+ mutate: (entry: RegistryEntry) => void,
892
+ ): Promise<boolean> {
893
+ const config = await readConfig();
894
+ if (!config.registries?.length) return false;
895
+ let found = false;
896
+ const registries = config.registries.map((r): string | RegistryEntry => {
897
+ const rName = registryDisplayName(r);
898
+ if (rName !== nameOrUrl && registryUrl(r) !== nameOrUrl) return r;
899
+ found = true;
900
+ const existing: RegistryEntry = typeof r === "string" ? { url: r } : { ...r };
901
+ mutate(existing);
902
+ return existing;
903
+ });
904
+ if (!found) return false;
905
+ await writeConfig({ ...config, registries });
906
+ return true;
907
+ }
908
+
909
+ /**
910
+ * Decrypt a `secret:`-prefixed value if we hold the encryption key. Plaintext
911
+ * values pass through unchanged so dev configs keep working.
912
+ */
913
+ async function revealSecret(value: string | undefined): Promise<string | undefined> {
914
+ if (!value) return value;
915
+ if (!value.startsWith(SECRET_PREFIX)) return value;
916
+ if (!options.encryptionKey) return undefined;
917
+ return decryptSecret(value.slice(SECRET_PREFIX.length), options.encryptionKey);
918
+ }
919
+
920
+ /**
921
+ * Refresh a registry's OAuth access token using the stored refresh token.
922
+ * Persists the new access token (encrypted) and updates `expiresAt`. If the
923
+ * provider rotates the refresh token, that's encrypted and stored too.
924
+ * Returns `true` when the refresh succeeded. Callers should catch and fall
925
+ * back to full re-auth on failure.
926
+ */
927
+ async function refreshRegistryToken(nameOrUrl: string): Promise<boolean> {
928
+ const config = await readConfig();
929
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
930
+ if (!target || typeof target === "string") return false;
931
+ const oauth = target.oauth;
932
+ if (!oauth?.refreshToken || !oauth.tokenEndpoint || !oauth.clientId) return false;
933
+
934
+ const refreshToken = await revealSecret(oauth.refreshToken);
935
+ const clientSecret = await revealSecret(oauth.clientSecret);
936
+ if (!refreshToken) return false;
937
+
938
+ const refreshed = await refreshAccessToken(oauth.tokenEndpoint, {
939
+ refreshToken,
940
+ clientId: oauth.clientId,
941
+ ...(clientSecret && { clientSecret }),
942
+ });
943
+
944
+ const expiresAt = refreshed.expiresIn
945
+ ? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString()
946
+ : undefined;
947
+ const encAccess = await protectSecret(refreshed.accessToken);
948
+ const encRefresh = refreshed.refreshToken
949
+ ? await protectSecret(refreshed.refreshToken)
950
+ : undefined;
951
+
952
+ await updateRegistryEntry(nameOrUrl, (existing) => {
953
+ existing.auth = { type: "bearer", token: encAccess };
954
+ if (!existing.oauth) return;
955
+ if (encRefresh) existing.oauth.refreshToken = encRefresh;
956
+ if (expiresAt) existing.oauth.expiresAt = expiresAt;
957
+ else delete existing.oauth.expiresAt;
958
+ });
959
+ return true;
960
+ }
961
+
962
+ /**
963
+ * Run a registry op once; on 401 (`registry_auth_required`), try to refresh
964
+ * via the stored refresh token and retry exactly once. Any other AdkError
965
+ * propagates as-is.
966
+ */
967
+ async function callWithRefresh<T>(
968
+ nameOrUrl: string,
969
+ fn: () => Promise<T>,
970
+ ): Promise<T> {
971
+ try {
972
+ return await fn();
973
+ } catch (err) {
974
+ if (!(err instanceof AdkError) || err.code !== "registry_auth_required") throw err;
975
+ let refreshed = false;
976
+ try {
977
+ refreshed = await refreshRegistryToken(nameOrUrl);
978
+ } catch {
979
+ // Refresh failed — surface the original 401 below.
980
+ }
981
+ if (!refreshed) throw err;
982
+ return fn();
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Throw a typed error if the registry has a recorded auth challenge and
988
+ * no usable credentials on the entry. Callers should invoke this before
989
+ * running any op that talks to the registry.
990
+ */
991
+ function assertRegistryAuthorized(entry: RegistryEntry): void {
992
+ if (!entry.authRequirement) return;
993
+ const hasUsableAuth =
994
+ entry.auth && entry.auth.type !== "none"
995
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
996
+ (entry.auth.type === "api-key" && !!entry.auth.key)
997
+ : false;
998
+ if (hasUsableAuth) return;
999
+
1000
+ const name = entry.name ?? entry.url;
1001
+ const scope = entry.authRequirement.scopes?.join(" ");
1002
+ throw new AdkError({
1003
+ code: "registry_auth_required",
1004
+ message: `Registry "${name}" requires authentication.`,
1005
+ hint: `Run: adk registry auth ${name} --token <token>${scope ? ` (scopes: ${scope})` : ""}`,
1006
+ details: {
1007
+ url: entry.url,
1008
+ scheme: entry.authRequirement.scheme,
1009
+ realm: entry.authRequirement.realm,
1010
+ authorizationServers: entry.authRequirement.authorizationServers,
1011
+ scopes: entry.authRequirement.scopes,
1012
+ resourceMetadataUrl: entry.authRequirement.resourceMetadataUrl,
1013
+ },
1014
+ });
1015
+ }
1016
+
731
1017
  const registry: AdkRegistryApi = {
732
- async add(entry: RegistryEntry): Promise<void> {
1018
+ async add(entry: RegistryEntry): Promise<{ authRequirement?: RegistryAuthRequirement }> {
733
1019
  const config = await readConfig();
734
1020
  const alias = entry.name ?? entry.url;
735
1021
  const registries = (config.registries ?? []).filter(
736
1022
  (r) => registryDisplayName(r) !== alias,
737
1023
  );
738
- registries.push(entry);
1024
+
1025
+ // Probe the registry before saving. Two things fall out of the probe:
1026
+ // 1. Auth challenge — 401 + WWW-Authenticate points at RFC 9728
1027
+ // resource metadata; we persist it on `authRequirement` so
1028
+ // subsequent ops can refuse early with a friendly message.
1029
+ // 2. Proxy capability — the MCP `initialize` response may advertise
1030
+ // `capabilities.registry.proxy`, which auto-populates `proxy`.
1031
+ // Users who set `proxy` or `auth` explicitly on the entry always win:
1032
+ // discovery only fills in blanks.
1033
+ let final: RegistryEntry = entry;
1034
+ let authRequirement: RegistryAuthRequirement | undefined;
1035
+
1036
+ const hasUsableAuth =
1037
+ entry.auth && entry.auth.type !== "none"
1038
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
1039
+ (entry.auth.type === "api-key" && !!entry.auth.key)
1040
+ : false;
1041
+
1042
+ if (!hasUsableAuth) {
1043
+ const fetchFn = options.fetch ?? globalThis.fetch;
1044
+ const probe = await probeRegistryAuth(entry.url, fetchFn);
1045
+ if (probe.ok === false) {
1046
+ authRequirement = probe.requirement;
1047
+ final = { ...final, authRequirement };
1048
+ }
1049
+ }
1050
+
1051
+ if (!entry.proxy && !authRequirement) {
1052
+ try {
1053
+ const probeConsumer = await createRegistryConsumer(
1054
+ { registries: [entry], refs: [] },
1055
+ { token: options.token, fetch: options.fetch },
1056
+ );
1057
+ const resolved = probeConsumer.registries()[0];
1058
+ if (resolved) {
1059
+ const discovered = await probeConsumer.discover(resolved.url);
1060
+ if (discovered.proxy?.mode) {
1061
+ final = {
1062
+ ...final,
1063
+ proxy: {
1064
+ mode: discovered.proxy.mode,
1065
+ ...(discovered.proxy.agent && { agent: discovered.proxy.agent }),
1066
+ },
1067
+ };
1068
+ }
1069
+ }
1070
+ } catch {
1071
+ // Discovery is best-effort — offline, unreachable, or non-adk
1072
+ // registries simply skip proxy auto-configuration.
1073
+ }
1074
+ }
1075
+
1076
+ registries.push(final);
739
1077
  await writeConfig({ ...config, registries });
1078
+ return authRequirement ? { authRequirement } : {};
740
1079
  },
741
1080
 
742
1081
  async remove(nameOrUrl: string): Promise<boolean> {
@@ -778,6 +1117,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
778
1117
  if (updates.name) existing.name = updates.name;
779
1118
  if (updates.auth) existing.auth = updates.auth;
780
1119
  if (updates.headers) existing.headers = { ...existing.headers, ...updates.headers };
1120
+ if (updates.proxy !== undefined) existing.proxy = updates.proxy;
781
1121
  return existing;
782
1122
  });
783
1123
  if (!found) return false;
@@ -786,19 +1126,25 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
786
1126
  },
787
1127
 
788
1128
  async browse(name: string, query?: string): Promise<AgentListEntry[]> {
789
- const consumer = await buildConsumer(name);
790
1129
  const config = await readConfig();
791
1130
  const target = findRegistry(config.registries ?? [], name);
792
- const url = target ? registryUrl(target) : name;
793
- return consumer.browse(url, query);
1131
+ if (target && typeof target !== "string") assertRegistryAuthorized(target);
1132
+ return callWithRefresh(name, async () => {
1133
+ const consumer = await buildConsumer(name);
1134
+ const url = target ? registryUrl(target) : name;
1135
+ return consumer.browse(url, query);
1136
+ });
794
1137
  },
795
1138
 
796
1139
  async inspect(name: string): Promise<RegistryConfiguration> {
797
- const consumer = await buildConsumer(name);
798
1140
  const config = await readConfig();
799
1141
  const target = findRegistry(config.registries ?? [], name);
800
- const url = target ? registryUrl(target) : name;
801
- return consumer.discover(url);
1142
+ if (target && typeof target !== "string") assertRegistryAuthorized(target);
1143
+ return callWithRefresh(name, async () => {
1144
+ const consumer = await buildConsumer(name);
1145
+ const url = target ? registryUrl(target) : name;
1146
+ return consumer.discover(url);
1147
+ });
802
1148
  },
803
1149
 
804
1150
  async test(name?: string): Promise<RegistryTestResult[]> {
@@ -812,12 +1158,29 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
812
1158
  targets.map(async (r): Promise<RegistryTestResult> => {
813
1159
  const url = registryUrl(r);
814
1160
  const rName = registryDisplayName(r);
1161
+ if (typeof r !== "string" && r.authRequirement) {
1162
+ const hasUsableAuth =
1163
+ r.auth && r.auth.type !== "none"
1164
+ ? (r.auth.type === "bearer" && !!r.auth.token) ||
1165
+ (r.auth.type === "api-key" && !!r.auth.key)
1166
+ : false;
1167
+ if (!hasUsableAuth) {
1168
+ return {
1169
+ name: rName,
1170
+ url,
1171
+ status: "error",
1172
+ error: `auth required — run: adk registry auth ${rName} --token <token>`,
1173
+ };
1174
+ }
1175
+ }
815
1176
  try {
816
- const consumer = await createRegistryConsumer(
817
- { registries: [r] },
818
- { token: options.token, fetch: options.fetch },
819
- );
820
- const disc = await consumer.discover(url);
1177
+ // Route through buildConsumer so encrypted auth/headers get
1178
+ // decrypted, then use callWithRefresh so a 401 triggers the
1179
+ // stored refresh token before giving up.
1180
+ const disc = await callWithRefresh(rName, async () => {
1181
+ const consumer = await buildConsumer(rName);
1182
+ return consumer.discover(url);
1183
+ });
821
1184
  return { name: rName, url, status: "active", issuer: disc.issuer };
822
1185
  } catch (err: unknown) {
823
1186
  const msg = err instanceof Error ? err.message : "unknown";
@@ -832,6 +1195,302 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
832
1195
  : { name: "unknown", url: "unknown", status: "error" as const, error: "unknown" },
833
1196
  );
834
1197
  },
1198
+
1199
+ async auth(
1200
+ nameOrUrl: string,
1201
+ credential:
1202
+ | { token: string; tokenUrl?: string }
1203
+ | { apiKey: string; header?: string },
1204
+ ): Promise<boolean> {
1205
+ // Encrypt the secret value up-front so the write path is uniform;
1206
+ // `buildConsumer` decrypts on the read side via `decryptConfigSecrets`.
1207
+ const protectedValue =
1208
+ "token" in credential
1209
+ ? await protectSecret(credential.token)
1210
+ : await protectSecret(credential.apiKey);
1211
+
1212
+ const updated = await updateRegistryEntry(nameOrUrl, (existing) => {
1213
+ if ("token" in credential) {
1214
+ existing.auth = {
1215
+ type: "bearer",
1216
+ token: protectedValue,
1217
+ ...(credential.tokenUrl && { tokenUrl: credential.tokenUrl }),
1218
+ };
1219
+ } else {
1220
+ existing.auth = {
1221
+ type: "api-key",
1222
+ key: protectedValue,
1223
+ ...(credential.header && { header: credential.header }),
1224
+ };
1225
+ }
1226
+ delete existing.authRequirement;
1227
+ });
1228
+ if (updated) await discoverProxyAfterAuth(nameOrUrl);
1229
+ return updated;
1230
+ },
1231
+
1232
+ async authLocal(
1233
+ nameOrUrl: string,
1234
+ opts?: {
1235
+ onAuthorizeUrl?: (url: string) => void;
1236
+ timeoutMs?: number;
1237
+ force?: boolean;
1238
+ },
1239
+ ): Promise<{ complete: boolean }> {
1240
+ const config = await readConfig();
1241
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
1242
+ if (!target || typeof target === "string") {
1243
+ throw new AdkError({
1244
+ code: "registry_not_found",
1245
+ message: `Registry not found: ${nameOrUrl}`,
1246
+ hint: "Run `adk registry list` to see configured registries.",
1247
+ details: { nameOrUrl },
1248
+ });
1249
+ }
1250
+
1251
+ // When the caller forces re-auth, wipe the existing credentials and
1252
+ // re-probe so we know what scheme the registry wants now. Servers can
1253
+ // rotate auth server metadata between runs.
1254
+ if (opts?.force) {
1255
+ await updateRegistryEntry(nameOrUrl, (existing) => {
1256
+ delete existing.auth;
1257
+ delete existing.oauth;
1258
+ });
1259
+ const fetchFn = options.fetch ?? globalThis.fetch;
1260
+ const probe = await probeRegistryAuth(target.url, fetchFn);
1261
+ if (probe.ok === false) {
1262
+ await updateRegistryEntry(nameOrUrl, (existing) => {
1263
+ existing.authRequirement = probe.requirement;
1264
+ });
1265
+ // Re-read so the flow below sees the fresh requirement.
1266
+ const refreshed = await readConfig();
1267
+ const refreshedTarget = findRegistry(refreshed.registries ?? [], nameOrUrl);
1268
+ if (refreshedTarget && typeof refreshedTarget !== "string") {
1269
+ Object.assign(target, refreshedTarget);
1270
+ }
1271
+ } else if (probe.ok === true) {
1272
+ // Registry no longer requires auth — nothing to do.
1273
+ await updateRegistryEntry(nameOrUrl, (existing) => {
1274
+ delete existing.authRequirement;
1275
+ });
1276
+ return { complete: true };
1277
+ }
1278
+ }
1279
+
1280
+ // Already authenticated — nothing to do (unless forced above).
1281
+ const hasUsableAuth =
1282
+ target.auth && target.auth.type !== "none"
1283
+ ? (target.auth.type === "bearer" && !!target.auth.token) ||
1284
+ (target.auth.type === "api-key" && !!target.auth.key)
1285
+ : false;
1286
+ if (hasUsableAuth && !target.authRequirement) {
1287
+ return { complete: true };
1288
+ }
1289
+
1290
+ const req = target.authRequirement;
1291
+ const port = options.oauthCallbackPort ?? 8919;
1292
+ const timeout = opts?.timeoutMs ?? 300_000;
1293
+ const displayName = target.name ?? target.url;
1294
+ const { createServer } = await import("node:http");
1295
+
1296
+ // OAuth path — the registry advertised authorization servers via
1297
+ // RFC 9728 protected-resource metadata. Walk the full flow:
1298
+ // AS metadata → dynamic client registration → PKCE authorize →
1299
+ // local callback → token exchange → persist access token.
1300
+ if (req?.authorizationServers?.length) {
1301
+ const authServer = req.authorizationServers[0]!;
1302
+ const metadata =
1303
+ (await discoverOAuthMetadata(authServer)) ??
1304
+ (await tryFetchOAuthMetadata(authServer));
1305
+ if (!metadata) {
1306
+ throw new AdkError({
1307
+ code: "registry_oauth_discovery_failed",
1308
+ message: `Could not discover OAuth metadata at ${authServer}.`,
1309
+ hint: "The authorization server must expose /.well-known/oauth-authorization-server.",
1310
+ details: { authServer, registry: displayName },
1311
+ });
1312
+ }
1313
+ if (!metadata.registration_endpoint) {
1314
+ throw new AdkError({
1315
+ code: "registry_oauth_no_registration",
1316
+ message: `Authorization server ${authServer} does not support dynamic client registration.`,
1317
+ hint: `Obtain a bearer token manually, then run: adk registry auth ${displayName} --token <token>`,
1318
+ details: { authServer, registry: displayName },
1319
+ });
1320
+ }
1321
+
1322
+ const redirectUri = `http://localhost:${port}/callback`;
1323
+ const registration = await dynamicClientRegistration(
1324
+ metadata.registration_endpoint,
1325
+ {
1326
+ clientName: options.oauthClientName ?? "adk",
1327
+ redirectUris: [redirectUri],
1328
+ grantTypes: ["authorization_code"],
1329
+ },
1330
+ );
1331
+
1332
+ const state = crypto.randomUUID();
1333
+ const { url: authorizeUrl, codeVerifier } = await buildOAuthAuthorizeUrl({
1334
+ authorizationEndpoint: metadata.authorization_endpoint,
1335
+ clientId: registration.clientId,
1336
+ redirectUri,
1337
+ scopes: req.scopes,
1338
+ state,
1339
+ });
1340
+
1341
+ return new Promise<{ complete: boolean }>((resolve, reject) => {
1342
+ const server = createServer(async (reqIn, resOut) => {
1343
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
1344
+ if (reqUrl.pathname !== "/callback") {
1345
+ resOut.writeHead(404);
1346
+ resOut.end();
1347
+ return;
1348
+ }
1349
+
1350
+ const code = reqUrl.searchParams.get("code");
1351
+ const returnedState = reqUrl.searchParams.get("state");
1352
+ if (!code || returnedState !== state) {
1353
+ const error = reqUrl.searchParams.get("error") ?? "missing code/state";
1354
+ resOut.writeHead(400, { "Content-Type": "text/html" });
1355
+ resOut.end(`<h1>Error</h1><p>${esc(error)}</p>`);
1356
+ server.close();
1357
+ reject(
1358
+ new AdkError({
1359
+ code: "registry_oauth_denied",
1360
+ message: `OAuth callback rejected: ${error}`,
1361
+ hint: "Retry `adk registry auth` and complete the browser consent.",
1362
+ details: { registry: displayName, error },
1363
+ }),
1364
+ );
1365
+ return;
1366
+ }
1367
+
1368
+ try {
1369
+ const tokens = await exchangeCodeForTokens(metadata.token_endpoint, {
1370
+ code,
1371
+ codeVerifier,
1372
+ clientId: registration.clientId,
1373
+ clientSecret: registration.clientSecret,
1374
+ redirectUri,
1375
+ });
1376
+ const expiresAt = tokens.expiresIn
1377
+ ? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
1378
+ : undefined;
1379
+ const encToken = await protectSecret(tokens.accessToken);
1380
+ const encRefresh = tokens.refreshToken
1381
+ ? await protectSecret(tokens.refreshToken)
1382
+ : undefined;
1383
+ const encClientSecret = registration.clientSecret
1384
+ ? await protectSecret(registration.clientSecret)
1385
+ : undefined;
1386
+ await updateRegistryEntry(displayName, (existing) => {
1387
+ existing.auth = { type: "bearer", token: encToken };
1388
+ existing.oauth = {
1389
+ tokenEndpoint: metadata.token_endpoint,
1390
+ clientId: registration.clientId,
1391
+ ...(encClientSecret && { clientSecret: encClientSecret }),
1392
+ ...(encRefresh && { refreshToken: encRefresh }),
1393
+ ...(expiresAt && { expiresAt }),
1394
+ ...(req.scopes?.length && { scopes: req.scopes }),
1395
+ };
1396
+ delete existing.authRequirement;
1397
+ });
1398
+ await discoverProxyAfterAuth(displayName);
1399
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1400
+ resOut.end(renderAuthSuccess(displayName));
1401
+ server.close();
1402
+ resolve({ complete: true });
1403
+ } catch (err) {
1404
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1405
+ resOut.end(
1406
+ `<h1>Error</h1><p>${esc(err instanceof Error ? err.message : String(err))}</p>`,
1407
+ );
1408
+ server.close();
1409
+ reject(err);
1410
+ }
1411
+ });
1412
+
1413
+ server.listen(port, () => {
1414
+ opts?.onAuthorizeUrl?.(authorizeUrl);
1415
+ });
1416
+
1417
+ const timer = setTimeout(() => {
1418
+ server.close();
1419
+ reject(new Error("OAuth callback timed out"));
1420
+ }, timeout);
1421
+ server.on("close", () => clearTimeout(timer));
1422
+ });
1423
+ }
1424
+
1425
+ // No OAuth metadata — serve a local HTTPS form asking for a token.
1426
+ // Used when the registry returned 401 without pointing at an AS, or
1427
+ // when the caller simply wants to paste a pre-issued token.
1428
+ const fields: AuthChallengeField[] = [
1429
+ {
1430
+ name: "token",
1431
+ label: "Bearer token",
1432
+ description: req?.realm
1433
+ ? `Token for realm "${req.realm}"`
1434
+ : "Token sent as `Authorization: Bearer <token>`.",
1435
+ secret: true,
1436
+ },
1437
+ ];
1438
+
1439
+ return new Promise<{ complete: boolean }>((resolve, reject) => {
1440
+ const server = createServer(async (reqIn, resOut) => {
1441
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
1442
+
1443
+ if (reqIn.method === "GET" && reqUrl.pathname === "/auth") {
1444
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1445
+ resOut.end(renderCredentialForm(displayName, fields));
1446
+ return;
1447
+ }
1448
+
1449
+ if (reqIn.method === "POST" && reqUrl.pathname === "/auth") {
1450
+ const chunks: Buffer[] = [];
1451
+ for await (const chunk of reqIn) chunks.push(chunk as Buffer);
1452
+ const body = Buffer.concat(chunks).toString();
1453
+ const params = new URLSearchParams(body);
1454
+ const token = params.get("token");
1455
+ if (!token) {
1456
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1457
+ resOut.end(renderCredentialForm(displayName, fields, "Token is required."));
1458
+ return;
1459
+ }
1460
+ try {
1461
+ await registry.auth(displayName, { token });
1462
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1463
+ resOut.end(renderAuthSuccess(displayName));
1464
+ server.close();
1465
+ resolve({ complete: true });
1466
+ } catch (err) {
1467
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1468
+ resOut.end(
1469
+ renderCredentialForm(
1470
+ displayName,
1471
+ fields,
1472
+ err instanceof Error ? err.message : String(err),
1473
+ ),
1474
+ );
1475
+ }
1476
+ return;
1477
+ }
1478
+
1479
+ resOut.writeHead(404);
1480
+ resOut.end();
1481
+ });
1482
+
1483
+ server.listen(port, () => {
1484
+ opts?.onAuthorizeUrl?.(`http://localhost:${port}/auth`);
1485
+ });
1486
+
1487
+ const timer = setTimeout(() => {
1488
+ server.close();
1489
+ reject(new Error("Auth timed out"));
1490
+ }, timeout);
1491
+ server.on("close", () => clearTimeout(timer));
1492
+ });
1493
+ },
835
1494
  };
836
1495
 
837
1496
  // ==========================================
@@ -1103,6 +1762,13 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1103
1762
  const entry = findRef(config.refs ?? [], name);
1104
1763
  if (!entry) throw new Error(`Ref "${name}" not found`);
1105
1764
 
1765
+ // Registry-proxied refs: ask the remote @config for state (secrets live
1766
+ // server-side so local inspection would always return "missing").
1767
+ const proxy = await resolveProxyForRef(entry);
1768
+ if (proxy) {
1769
+ return forwardRefOpToProxy<RefAuthStatus>(proxy.reg, proxy.agent, "auth-status", { name });
1770
+ }
1771
+
1106
1772
  let security: SecuritySchemeSummary | null = null;
1107
1773
  try {
1108
1774
  const consumer = await buildConsumerForRef(entry);
@@ -1229,11 +1895,30 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1229
1895
  stateContext?: Record<string, unknown>;
1230
1896
  /** Additional scopes to request (e.g., optional scopes declared by the agent) */
1231
1897
  scopes?: string[];
1898
+ /**
1899
+ * Opt out of proxy routing when the ref's source registry has
1900
+ * `proxy: { mode: 'optional' }`. Ignored for `mode: 'required'`.
1901
+ */
1902
+ preferLocal?: boolean;
1232
1903
  }): Promise<AuthStartResult> {
1233
1904
  const config = await readConfig();
1234
1905
  const entry = findRef(config.refs ?? [], name);
1235
1906
  if (!entry) throw new Error(`Ref "${name}" not found`);
1236
1907
 
1908
+ // Registry-proxied auth: forward the start-of-flow to the remote @config
1909
+ // agent. The registry owns the client_id/secret and returns an authorize
1910
+ // URL pointing at the registry's OAuth callback domain, so the user
1911
+ // completes the flow against the registry instead of localhost.
1912
+ const proxy = await resolveProxyForRef(entry, { preferLocal: opts?.preferLocal });
1913
+ if (proxy) {
1914
+ const params: Record<string, unknown> = { name };
1915
+ if (opts?.apiKey !== undefined) params.apiKey = opts.apiKey;
1916
+ if (opts?.credentials) params.credentials = opts.credentials;
1917
+ if (opts?.scopes) params.scopes = opts.scopes;
1918
+ if (opts?.stateContext) params.stateContext = opts.stateContext;
1919
+ return forwardRefOpToProxy<AuthStartResult>(proxy.reg, proxy.agent, "auth", params);
1920
+ }
1921
+
1237
1922
  const status = await ref.authStatus(name);
1238
1923
  const security = status.security;
1239
1924
  const resolve = options.resolveCredentials;
@@ -1488,16 +2173,34 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1488
2173
  onAuthorizeUrl?: (url: string) => void;
1489
2174
  timeoutMs?: number;
1490
2175
  }): Promise<{ complete: boolean }> {
2176
+ // `ref.auth` is already proxy-aware — for proxied refs it returns
2177
+ // the authorizeUrl that the registry minted against its own
2178
+ // callback domain. Everything below is identical for local and
2179
+ // proxied refs except the last step (polling for the callback),
2180
+ // which only makes sense when we own the redirect URI.
1491
2181
  const result = await ref.auth(name);
1492
-
1493
2182
  if (result.complete) return { complete: true };
1494
2183
 
2184
+ const config = await readConfig();
2185
+ const entry = findRef(config.refs ?? [], name);
2186
+ const proxy = entry ? await resolveProxyForRef(entry) : null;
2187
+
1495
2188
  const port = options.oauthCallbackPort ?? 8919;
1496
2189
  const timeout = opts?.timeoutMs ?? 300_000;
1497
2190
  const { createServer } = await import("node:http");
1498
2191
 
1499
- // API key / HTTP auth — serve a local credential form
2192
+ // API key / HTTP auth — local credential form.
2193
+ //
2194
+ // We refuse to serve the form for a proxied ref: the registry
2195
+ // owns the credential store, so the user needs to submit via
2196
+ // whatever UI the registry exposes. Supporting this through the
2197
+ // proxy would need a remote form endpoint — out of scope here.
1500
2198
  if (result.fields && result.fields.length > 0 && result.type !== "oauth2") {
2199
+ if (proxy) {
2200
+ throw new Error(
2201
+ `Ref "${name}" is sourced from a proxied registry; submit credentials through ${proxy.agent} instead of a local form.`,
2202
+ );
2203
+ }
1501
2204
  return new Promise<{ complete: boolean }>((resolve, reject) => {
1502
2205
  const server = createServer(async (req, res) => {
1503
2206
  const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
@@ -1564,15 +2267,21 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1564
2267
  });
1565
2268
  }
1566
2269
 
1567
- // OAuth2 — open authorize URL and wait for callback
2270
+ // OAuth2 — hand the authorize URL to the caller.
1568
2271
  if (result.type !== "oauth2" || !result.authorizeUrl) {
1569
2272
  throw new Error(`authLocal cannot handle auth type: ${result.type}`);
1570
2273
  }
1571
-
1572
2274
  if (opts?.onAuthorizeUrl) {
1573
2275
  opts.onAuthorizeUrl(result.authorizeUrl);
1574
2276
  }
1575
2277
 
2278
+ // Proxied refs: the registry owns the callback endpoint, so there's
2279
+ // nothing to poll here. Callers poll `ref.authStatus` on their own
2280
+ // schedule once the user finishes the remote consent screen.
2281
+ if (proxy) return { complete: false };
2282
+
2283
+ // Local refs: spin up the callback server on oauthCallbackPort and
2284
+ // block until the OAuth provider redirects back.
1576
2285
  return new Promise<{ complete: boolean }>((resolve, reject) => {
1577
2286
  const server = createServer(async (req, res) => {
1578
2287
  const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
@@ -1614,6 +2323,20 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1614
2323
  },
1615
2324
 
1616
2325
  async refreshToken(name: string): Promise<{ accessToken: string } | null> {
2326
+ // Registry-proxied refs: the remote @config holds the refresh_token.
2327
+ const entryForProxy = await ref.get(name);
2328
+ if (entryForProxy) {
2329
+ const proxy = await resolveProxyForRef(entryForProxy);
2330
+ if (proxy) {
2331
+ return forwardRefOpToProxy<{ accessToken: string } | null>(
2332
+ proxy.reg,
2333
+ proxy.agent,
2334
+ "refresh-token",
2335
+ { name },
2336
+ );
2337
+ }
2338
+ }
2339
+
1617
2340
  // Read stored refresh_token
1618
2341
  const refreshToken = await readRefSecret(name, "refresh_token");
1619
2342
  if (!refreshToken) return null;
@@ -1697,33 +2420,5 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1697
2420
  return { refName: pending.refName, complete: true, stateContext };
1698
2421
  }
1699
2422
 
1700
- // ==========================================
1701
- // Proxy API
1702
- // ==========================================
1703
-
1704
- const proxy: AdkProxyApi = {
1705
- async add(entry: ProxyEntry): Promise<void> {
1706
- const config = await readConfig();
1707
- const proxies = (config.proxies ?? []).filter((p) => p.name !== entry.name);
1708
- proxies.push(entry);
1709
- await writeConfig({ ...config, proxies });
1710
- },
1711
-
1712
- async remove(name: string): Promise<boolean> {
1713
- const config = await readConfig();
1714
- if (!config.proxies?.length) return false;
1715
- const before = config.proxies.length;
1716
- const proxies = config.proxies.filter((p) => p.name !== name);
1717
- if (proxies.length === before) return false;
1718
- await writeConfig({ ...config, proxies });
1719
- return true;
1720
- },
1721
-
1722
- async list(): Promise<ProxyEntry[]> {
1723
- const config = await readConfig();
1724
- return config.proxies ?? [];
1725
- },
1726
- };
1727
-
1728
- return { proxy, registry, ref, readConfig, writeConfig, handleCallback };
2423
+ return { registry, ref, readConfig, writeConfig, handleCallback };
1729
2424
  }