@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,7 @@ import { normalizeRef } from "./define-config.js";
20
20
  import { createRegistryConsumer } from "./registry-consumer.js";
21
21
  import { decryptSecret, encryptSecret } from "./crypto.js";
22
22
  import { AdkError } from "./adk-error.js";
23
- import { discoverOAuthMetadata, dynamicClientRegistration, buildOAuthAuthorizeUrl, exchangeCodeForTokens, } from "./mcp-client.js";
23
+ import { discoverOAuthMetadata, dynamicClientRegistration, buildOAuthAuthorizeUrl, exchangeCodeForTokens, probeRegistryAuth, refreshAccessToken, } from "./mcp-client.js";
24
24
  const CONFIG_PATH = "consumer-config.json";
25
25
  const SECRET_PREFIX = "secret:";
26
26
  // ============================================
@@ -403,15 +403,309 @@ export function createAdk(fs, options = {}) {
403
403
  return fallback;
404
404
  }
405
405
  // ==========================================
406
+ // Proxy Routing
407
+ // ==========================================
408
+ /**
409
+ * Find the configured RegistryEntry for a ref, consulting `sourceRegistry`
410
+ * first and falling back to the first registry in config. Returns `null` when
411
+ * the ref is sourced from a raw URL (no registry), in which case proxy routing
412
+ * does not apply.
413
+ */
414
+ async function findRegistryEntryForRef(entry) {
415
+ const sourceUrl = entry.sourceRegistry?.url;
416
+ if (!sourceUrl)
417
+ return null;
418
+ const config = await readConfig();
419
+ const match = (config.registries ?? []).find((r) => {
420
+ if (typeof r === "string")
421
+ return r === sourceUrl;
422
+ return r.url === sourceUrl;
423
+ });
424
+ if (!match || typeof match === "string")
425
+ return null;
426
+ return match;
427
+ }
428
+ /**
429
+ * Returns the proxy settings for a ref when its source registry has
430
+ * `proxy` configured. `null` means "run locally".
431
+ *
432
+ * Callers pass `{ preferLocal: true }` to opt out of `mode: 'optional'`
433
+ * proxying when they already hold credentials locally. `mode: 'required'`
434
+ * cannot be bypassed — the registry owns auth server-side and there is
435
+ * nothing useful the local SDK can do.
436
+ */
437
+ async function resolveProxyForRef(entry, opts) {
438
+ const reg = await findRegistryEntryForRef(entry);
439
+ if (!reg?.proxy)
440
+ return null;
441
+ if (reg.proxy.mode === "optional" && opts?.preferLocal)
442
+ return null;
443
+ return { reg, agent: reg.proxy.agent ?? "@config" };
444
+ }
445
+ /**
446
+ * Forward an `@config ref` operation to the proxy agent on a remote registry.
447
+ *
448
+ * The remote side speaks the standard adk-tools surface, so the call shape is
449
+ * identical to what the local `ref` API would do — the only difference is
450
+ * that tokens and secrets live server-side. `callRegistry` returns the
451
+ * standard CallAgentResponse envelope: `{ success: true, result }` on
452
+ * success or `{ success: false, error }` on failure. We unwrap once and
453
+ * throw on error so callers get a result that matches the local signature.
454
+ */
455
+ async function forwardRefOpToProxy(reg, agent, operation, params) {
456
+ const consumer = await buildConsumerForRef({ ref: "", sourceRegistry: { url: reg.url, agentPath: agent } });
457
+ const resolved = consumer.registries().find((r) => r.url === reg.url);
458
+ if (!resolved)
459
+ throw new Error(`Registry ${reg.url} not resolvable for proxy forwarding`);
460
+ const response = await consumer.callRegistry(resolved, {
461
+ action: "execute_tool",
462
+ path: agent,
463
+ tool: "ref",
464
+ params: { operation, ...params },
465
+ });
466
+ if (!response.success) {
467
+ const errResponse = response;
468
+ const msg = errResponse.error ?? `Proxy ${agent}.ref(${operation}) failed`;
469
+ throw new Error(msg);
470
+ }
471
+ return response.result;
472
+ }
473
+ // ==========================================
406
474
  // Registry API
407
475
  // ==========================================
476
+ /**
477
+ * Encrypt with `secret:` prefix when an encryption key is configured, so the
478
+ * value is readable by the existing `decryptConfigSecrets` path on the read
479
+ * side. Plaintext fallback preserves the "no key = dev mode" contract.
480
+ */
481
+ async function protectSecret(value) {
482
+ if (!options.encryptionKey)
483
+ return value;
484
+ return `${SECRET_PREFIX}${await encryptSecret(value, options.encryptionKey)}`;
485
+ }
486
+ /**
487
+ * Re-probe a registry with the current stored credentials to see whether it
488
+ * advertises `capabilities.registry.proxy` in its MCP `initialize` response,
489
+ * and persist the proxy config when it does. Safe to call after a successful
490
+ * `auth()` / `authLocal()` — on the add path we skip the proxy probe when
491
+ * auth is required, so this is the second chance to back-fill it.
492
+ *
493
+ * Respects explicit user config: if `proxy` is already set, we leave it
494
+ * alone. Any discovery failure is swallowed — proxy is an optimization,
495
+ * not a correctness requirement.
496
+ */
497
+ async function discoverProxyAfterAuth(nameOrUrl) {
498
+ const config = await readConfig();
499
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
500
+ if (!target || typeof target === "string")
501
+ return;
502
+ if (target.proxy)
503
+ return;
504
+ try {
505
+ const consumer = await buildConsumer(nameOrUrl);
506
+ const discovered = await consumer.discover(target.url);
507
+ if (!discovered.proxy?.mode)
508
+ return;
509
+ await updateRegistryEntry(nameOrUrl, (existing) => {
510
+ if (existing.proxy)
511
+ return;
512
+ existing.proxy = {
513
+ mode: discovered.proxy.mode,
514
+ ...(discovered.proxy.agent && { agent: discovered.proxy.agent }),
515
+ };
516
+ });
517
+ }
518
+ catch {
519
+ // Proxy probe is best-effort — auth itself already succeeded.
520
+ }
521
+ }
522
+ /**
523
+ * Atomic read-modify-write on a registry entry by name or URL. Used by
524
+ * `authLocal` to persist both `auth` and `oauth` together, which `auth()`
525
+ * alone can't express. Returns true when the entry was found and written.
526
+ */
527
+ async function updateRegistryEntry(nameOrUrl, mutate) {
528
+ const config = await readConfig();
529
+ if (!config.registries?.length)
530
+ return false;
531
+ let found = false;
532
+ const registries = config.registries.map((r) => {
533
+ const rName = registryDisplayName(r);
534
+ if (rName !== nameOrUrl && registryUrl(r) !== nameOrUrl)
535
+ return r;
536
+ found = true;
537
+ const existing = typeof r === "string" ? { url: r } : { ...r };
538
+ mutate(existing);
539
+ return existing;
540
+ });
541
+ if (!found)
542
+ return false;
543
+ await writeConfig({ ...config, registries });
544
+ return true;
545
+ }
546
+ /**
547
+ * Decrypt a `secret:`-prefixed value if we hold the encryption key. Plaintext
548
+ * values pass through unchanged so dev configs keep working.
549
+ */
550
+ async function revealSecret(value) {
551
+ if (!value)
552
+ return value;
553
+ if (!value.startsWith(SECRET_PREFIX))
554
+ return value;
555
+ if (!options.encryptionKey)
556
+ return undefined;
557
+ return decryptSecret(value.slice(SECRET_PREFIX.length), options.encryptionKey);
558
+ }
559
+ /**
560
+ * Refresh a registry's OAuth access token using the stored refresh token.
561
+ * Persists the new access token (encrypted) and updates `expiresAt`. If the
562
+ * provider rotates the refresh token, that's encrypted and stored too.
563
+ * Returns `true` when the refresh succeeded. Callers should catch and fall
564
+ * back to full re-auth on failure.
565
+ */
566
+ async function refreshRegistryToken(nameOrUrl) {
567
+ const config = await readConfig();
568
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
569
+ if (!target || typeof target === "string")
570
+ return false;
571
+ const oauth = target.oauth;
572
+ if (!oauth?.refreshToken || !oauth.tokenEndpoint || !oauth.clientId)
573
+ return false;
574
+ const refreshToken = await revealSecret(oauth.refreshToken);
575
+ const clientSecret = await revealSecret(oauth.clientSecret);
576
+ if (!refreshToken)
577
+ return false;
578
+ const refreshed = await refreshAccessToken(oauth.tokenEndpoint, {
579
+ refreshToken,
580
+ clientId: oauth.clientId,
581
+ ...(clientSecret && { clientSecret }),
582
+ });
583
+ const expiresAt = refreshed.expiresIn
584
+ ? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString()
585
+ : undefined;
586
+ const encAccess = await protectSecret(refreshed.accessToken);
587
+ const encRefresh = refreshed.refreshToken
588
+ ? await protectSecret(refreshed.refreshToken)
589
+ : undefined;
590
+ await updateRegistryEntry(nameOrUrl, (existing) => {
591
+ existing.auth = { type: "bearer", token: encAccess };
592
+ if (!existing.oauth)
593
+ return;
594
+ if (encRefresh)
595
+ existing.oauth.refreshToken = encRefresh;
596
+ if (expiresAt)
597
+ existing.oauth.expiresAt = expiresAt;
598
+ else
599
+ delete existing.oauth.expiresAt;
600
+ });
601
+ return true;
602
+ }
603
+ /**
604
+ * Run a registry op once; on 401 (`registry_auth_required`), try to refresh
605
+ * via the stored refresh token and retry exactly once. Any other AdkError
606
+ * propagates as-is.
607
+ */
608
+ async function callWithRefresh(nameOrUrl, fn) {
609
+ try {
610
+ return await fn();
611
+ }
612
+ catch (err) {
613
+ if (!(err instanceof AdkError) || err.code !== "registry_auth_required")
614
+ throw err;
615
+ let refreshed = false;
616
+ try {
617
+ refreshed = await refreshRegistryToken(nameOrUrl);
618
+ }
619
+ catch {
620
+ // Refresh failed — surface the original 401 below.
621
+ }
622
+ if (!refreshed)
623
+ throw err;
624
+ return fn();
625
+ }
626
+ }
627
+ /**
628
+ * Throw a typed error if the registry has a recorded auth challenge and
629
+ * no usable credentials on the entry. Callers should invoke this before
630
+ * running any op that talks to the registry.
631
+ */
632
+ function assertRegistryAuthorized(entry) {
633
+ if (!entry.authRequirement)
634
+ return;
635
+ const hasUsableAuth = entry.auth && entry.auth.type !== "none"
636
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
637
+ (entry.auth.type === "api-key" && !!entry.auth.key)
638
+ : false;
639
+ if (hasUsableAuth)
640
+ return;
641
+ const name = entry.name ?? entry.url;
642
+ const scope = entry.authRequirement.scopes?.join(" ");
643
+ throw new AdkError({
644
+ code: "registry_auth_required",
645
+ message: `Registry "${name}" requires authentication.`,
646
+ hint: `Run: adk registry auth ${name} --token <token>${scope ? ` (scopes: ${scope})` : ""}`,
647
+ details: {
648
+ url: entry.url,
649
+ scheme: entry.authRequirement.scheme,
650
+ realm: entry.authRequirement.realm,
651
+ authorizationServers: entry.authRequirement.authorizationServers,
652
+ scopes: entry.authRequirement.scopes,
653
+ resourceMetadataUrl: entry.authRequirement.resourceMetadataUrl,
654
+ },
655
+ });
656
+ }
408
657
  const registry = {
409
658
  async add(entry) {
410
659
  const config = await readConfig();
411
660
  const alias = entry.name ?? entry.url;
412
661
  const registries = (config.registries ?? []).filter((r) => registryDisplayName(r) !== alias);
413
- registries.push(entry);
662
+ // Probe the registry before saving. Two things fall out of the probe:
663
+ // 1. Auth challenge — 401 + WWW-Authenticate points at RFC 9728
664
+ // resource metadata; we persist it on `authRequirement` so
665
+ // subsequent ops can refuse early with a friendly message.
666
+ // 2. Proxy capability — the MCP `initialize` response may advertise
667
+ // `capabilities.registry.proxy`, which auto-populates `proxy`.
668
+ // Users who set `proxy` or `auth` explicitly on the entry always win:
669
+ // discovery only fills in blanks.
670
+ let final = entry;
671
+ let authRequirement;
672
+ const hasUsableAuth = entry.auth && entry.auth.type !== "none"
673
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
674
+ (entry.auth.type === "api-key" && !!entry.auth.key)
675
+ : false;
676
+ if (!hasUsableAuth) {
677
+ const fetchFn = options.fetch ?? globalThis.fetch;
678
+ const probe = await probeRegistryAuth(entry.url, fetchFn);
679
+ if (probe.ok === false) {
680
+ authRequirement = probe.requirement;
681
+ final = { ...final, authRequirement };
682
+ }
683
+ }
684
+ if (!entry.proxy && !authRequirement) {
685
+ try {
686
+ const probeConsumer = await createRegistryConsumer({ registries: [entry], refs: [] }, { token: options.token, fetch: options.fetch });
687
+ const resolved = probeConsumer.registries()[0];
688
+ if (resolved) {
689
+ const discovered = await probeConsumer.discover(resolved.url);
690
+ if (discovered.proxy?.mode) {
691
+ final = {
692
+ ...final,
693
+ proxy: {
694
+ mode: discovered.proxy.mode,
695
+ ...(discovered.proxy.agent && { agent: discovered.proxy.agent }),
696
+ },
697
+ };
698
+ }
699
+ }
700
+ }
701
+ catch {
702
+ // Discovery is best-effort — offline, unreachable, or non-adk
703
+ // registries simply skip proxy auto-configuration.
704
+ }
705
+ }
706
+ registries.push(final);
414
707
  await writeConfig({ ...config, registries });
708
+ return authRequirement ? { authRequirement } : {};
415
709
  },
416
710
  async remove(nameOrUrl) {
417
711
  const config = await readConfig();
@@ -454,6 +748,8 @@ export function createAdk(fs, options = {}) {
454
748
  existing.auth = updates.auth;
455
749
  if (updates.headers)
456
750
  existing.headers = { ...existing.headers, ...updates.headers };
751
+ if (updates.proxy !== undefined)
752
+ existing.proxy = updates.proxy;
457
753
  return existing;
458
754
  });
459
755
  if (!found)
@@ -462,18 +758,26 @@ export function createAdk(fs, options = {}) {
462
758
  return true;
463
759
  },
464
760
  async browse(name, query) {
465
- const consumer = await buildConsumer(name);
466
761
  const config = await readConfig();
467
762
  const target = findRegistry(config.registries ?? [], name);
468
- const url = target ? registryUrl(target) : name;
469
- return consumer.browse(url, query);
763
+ if (target && typeof target !== "string")
764
+ assertRegistryAuthorized(target);
765
+ return callWithRefresh(name, async () => {
766
+ const consumer = await buildConsumer(name);
767
+ const url = target ? registryUrl(target) : name;
768
+ return consumer.browse(url, query);
769
+ });
470
770
  },
471
771
  async inspect(name) {
472
- const consumer = await buildConsumer(name);
473
772
  const config = await readConfig();
474
773
  const target = findRegistry(config.registries ?? [], name);
475
- const url = target ? registryUrl(target) : name;
476
- return consumer.discover(url);
774
+ if (target && typeof target !== "string")
775
+ assertRegistryAuthorized(target);
776
+ return callWithRefresh(name, async () => {
777
+ const consumer = await buildConsumer(name);
778
+ const url = target ? registryUrl(target) : name;
779
+ return consumer.discover(url);
780
+ });
477
781
  },
478
782
  async test(name) {
479
783
  const config = await readConfig();
@@ -484,9 +788,28 @@ export function createAdk(fs, options = {}) {
484
788
  const results = await Promise.allSettled(targets.map(async (r) => {
485
789
  const url = registryUrl(r);
486
790
  const rName = registryDisplayName(r);
791
+ if (typeof r !== "string" && r.authRequirement) {
792
+ const hasUsableAuth = r.auth && r.auth.type !== "none"
793
+ ? (r.auth.type === "bearer" && !!r.auth.token) ||
794
+ (r.auth.type === "api-key" && !!r.auth.key)
795
+ : false;
796
+ if (!hasUsableAuth) {
797
+ return {
798
+ name: rName,
799
+ url,
800
+ status: "error",
801
+ error: `auth required — run: adk registry auth ${rName} --token <token>`,
802
+ };
803
+ }
804
+ }
487
805
  try {
488
- const consumer = await createRegistryConsumer({ registries: [r] }, { token: options.token, fetch: options.fetch });
489
- const disc = await consumer.discover(url);
806
+ // Route through buildConsumer so encrypted auth/headers get
807
+ // decrypted, then use callWithRefresh so a 401 triggers the
808
+ // stored refresh token before giving up.
809
+ const disc = await callWithRefresh(rName, async () => {
810
+ const consumer = await buildConsumer(rName);
811
+ return consumer.discover(url);
812
+ });
490
813
  return { name: rName, url, status: "active", issuer: disc.issuer };
491
814
  }
492
815
  catch (err) {
@@ -498,6 +821,259 @@ export function createAdk(fs, options = {}) {
498
821
  ? r.value
499
822
  : { name: "unknown", url: "unknown", status: "error", error: "unknown" });
500
823
  },
824
+ async auth(nameOrUrl, credential) {
825
+ // Encrypt the secret value up-front so the write path is uniform;
826
+ // `buildConsumer` decrypts on the read side via `decryptConfigSecrets`.
827
+ const protectedValue = "token" in credential
828
+ ? await protectSecret(credential.token)
829
+ : await protectSecret(credential.apiKey);
830
+ const updated = await updateRegistryEntry(nameOrUrl, (existing) => {
831
+ if ("token" in credential) {
832
+ existing.auth = {
833
+ type: "bearer",
834
+ token: protectedValue,
835
+ ...(credential.tokenUrl && { tokenUrl: credential.tokenUrl }),
836
+ };
837
+ }
838
+ else {
839
+ existing.auth = {
840
+ type: "api-key",
841
+ key: protectedValue,
842
+ ...(credential.header && { header: credential.header }),
843
+ };
844
+ }
845
+ delete existing.authRequirement;
846
+ });
847
+ if (updated)
848
+ await discoverProxyAfterAuth(nameOrUrl);
849
+ return updated;
850
+ },
851
+ async authLocal(nameOrUrl, opts) {
852
+ const config = await readConfig();
853
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
854
+ if (!target || typeof target === "string") {
855
+ throw new AdkError({
856
+ code: "registry_not_found",
857
+ message: `Registry not found: ${nameOrUrl}`,
858
+ hint: "Run `adk registry list` to see configured registries.",
859
+ details: { nameOrUrl },
860
+ });
861
+ }
862
+ // When the caller forces re-auth, wipe the existing credentials and
863
+ // re-probe so we know what scheme the registry wants now. Servers can
864
+ // rotate auth server metadata between runs.
865
+ if (opts?.force) {
866
+ await updateRegistryEntry(nameOrUrl, (existing) => {
867
+ delete existing.auth;
868
+ delete existing.oauth;
869
+ });
870
+ const fetchFn = options.fetch ?? globalThis.fetch;
871
+ const probe = await probeRegistryAuth(target.url, fetchFn);
872
+ if (probe.ok === false) {
873
+ await updateRegistryEntry(nameOrUrl, (existing) => {
874
+ existing.authRequirement = probe.requirement;
875
+ });
876
+ // Re-read so the flow below sees the fresh requirement.
877
+ const refreshed = await readConfig();
878
+ const refreshedTarget = findRegistry(refreshed.registries ?? [], nameOrUrl);
879
+ if (refreshedTarget && typeof refreshedTarget !== "string") {
880
+ Object.assign(target, refreshedTarget);
881
+ }
882
+ }
883
+ else if (probe.ok === true) {
884
+ // Registry no longer requires auth — nothing to do.
885
+ await updateRegistryEntry(nameOrUrl, (existing) => {
886
+ delete existing.authRequirement;
887
+ });
888
+ return { complete: true };
889
+ }
890
+ }
891
+ // Already authenticated — nothing to do (unless forced above).
892
+ const hasUsableAuth = target.auth && target.auth.type !== "none"
893
+ ? (target.auth.type === "bearer" && !!target.auth.token) ||
894
+ (target.auth.type === "api-key" && !!target.auth.key)
895
+ : false;
896
+ if (hasUsableAuth && !target.authRequirement) {
897
+ return { complete: true };
898
+ }
899
+ const req = target.authRequirement;
900
+ const port = options.oauthCallbackPort ?? 8919;
901
+ const timeout = opts?.timeoutMs ?? 300_000;
902
+ const displayName = target.name ?? target.url;
903
+ const { createServer } = await import("node:http");
904
+ // OAuth path — the registry advertised authorization servers via
905
+ // RFC 9728 protected-resource metadata. Walk the full flow:
906
+ // AS metadata → dynamic client registration → PKCE authorize →
907
+ // local callback → token exchange → persist access token.
908
+ if (req?.authorizationServers?.length) {
909
+ const authServer = req.authorizationServers[0];
910
+ const metadata = (await discoverOAuthMetadata(authServer)) ??
911
+ (await tryFetchOAuthMetadata(authServer));
912
+ if (!metadata) {
913
+ throw new AdkError({
914
+ code: "registry_oauth_discovery_failed",
915
+ message: `Could not discover OAuth metadata at ${authServer}.`,
916
+ hint: "The authorization server must expose /.well-known/oauth-authorization-server.",
917
+ details: { authServer, registry: displayName },
918
+ });
919
+ }
920
+ if (!metadata.registration_endpoint) {
921
+ throw new AdkError({
922
+ code: "registry_oauth_no_registration",
923
+ message: `Authorization server ${authServer} does not support dynamic client registration.`,
924
+ hint: `Obtain a bearer token manually, then run: adk registry auth ${displayName} --token <token>`,
925
+ details: { authServer, registry: displayName },
926
+ });
927
+ }
928
+ const redirectUri = `http://localhost:${port}/callback`;
929
+ const registration = await dynamicClientRegistration(metadata.registration_endpoint, {
930
+ clientName: options.oauthClientName ?? "adk",
931
+ redirectUris: [redirectUri],
932
+ grantTypes: ["authorization_code"],
933
+ });
934
+ const state = crypto.randomUUID();
935
+ const { url: authorizeUrl, codeVerifier } = await buildOAuthAuthorizeUrl({
936
+ authorizationEndpoint: metadata.authorization_endpoint,
937
+ clientId: registration.clientId,
938
+ redirectUri,
939
+ scopes: req.scopes,
940
+ state,
941
+ });
942
+ return new Promise((resolve, reject) => {
943
+ const server = createServer(async (reqIn, resOut) => {
944
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
945
+ if (reqUrl.pathname !== "/callback") {
946
+ resOut.writeHead(404);
947
+ resOut.end();
948
+ return;
949
+ }
950
+ const code = reqUrl.searchParams.get("code");
951
+ const returnedState = reqUrl.searchParams.get("state");
952
+ if (!code || returnedState !== state) {
953
+ const error = reqUrl.searchParams.get("error") ?? "missing code/state";
954
+ resOut.writeHead(400, { "Content-Type": "text/html" });
955
+ resOut.end(`<h1>Error</h1><p>${esc(error)}</p>`);
956
+ server.close();
957
+ reject(new AdkError({
958
+ code: "registry_oauth_denied",
959
+ message: `OAuth callback rejected: ${error}`,
960
+ hint: "Retry `adk registry auth` and complete the browser consent.",
961
+ details: { registry: displayName, error },
962
+ }));
963
+ return;
964
+ }
965
+ try {
966
+ const tokens = await exchangeCodeForTokens(metadata.token_endpoint, {
967
+ code,
968
+ codeVerifier,
969
+ clientId: registration.clientId,
970
+ clientSecret: registration.clientSecret,
971
+ redirectUri,
972
+ });
973
+ const expiresAt = tokens.expiresIn
974
+ ? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
975
+ : undefined;
976
+ const encToken = await protectSecret(tokens.accessToken);
977
+ const encRefresh = tokens.refreshToken
978
+ ? await protectSecret(tokens.refreshToken)
979
+ : undefined;
980
+ const encClientSecret = registration.clientSecret
981
+ ? await protectSecret(registration.clientSecret)
982
+ : undefined;
983
+ await updateRegistryEntry(displayName, (existing) => {
984
+ existing.auth = { type: "bearer", token: encToken };
985
+ existing.oauth = {
986
+ tokenEndpoint: metadata.token_endpoint,
987
+ clientId: registration.clientId,
988
+ ...(encClientSecret && { clientSecret: encClientSecret }),
989
+ ...(encRefresh && { refreshToken: encRefresh }),
990
+ ...(expiresAt && { expiresAt }),
991
+ ...(req.scopes?.length && { scopes: req.scopes }),
992
+ };
993
+ delete existing.authRequirement;
994
+ });
995
+ await discoverProxyAfterAuth(displayName);
996
+ resOut.writeHead(200, { "Content-Type": "text/html" });
997
+ resOut.end(renderAuthSuccess(displayName));
998
+ server.close();
999
+ resolve({ complete: true });
1000
+ }
1001
+ catch (err) {
1002
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1003
+ resOut.end(`<h1>Error</h1><p>${esc(err instanceof Error ? err.message : String(err))}</p>`);
1004
+ server.close();
1005
+ reject(err);
1006
+ }
1007
+ });
1008
+ server.listen(port, () => {
1009
+ opts?.onAuthorizeUrl?.(authorizeUrl);
1010
+ });
1011
+ const timer = setTimeout(() => {
1012
+ server.close();
1013
+ reject(new Error("OAuth callback timed out"));
1014
+ }, timeout);
1015
+ server.on("close", () => clearTimeout(timer));
1016
+ });
1017
+ }
1018
+ // No OAuth metadata — serve a local HTTPS form asking for a token.
1019
+ // Used when the registry returned 401 without pointing at an AS, or
1020
+ // when the caller simply wants to paste a pre-issued token.
1021
+ const fields = [
1022
+ {
1023
+ name: "token",
1024
+ label: "Bearer token",
1025
+ description: req?.realm
1026
+ ? `Token for realm "${req.realm}"`
1027
+ : "Token sent as `Authorization: Bearer <token>`.",
1028
+ secret: true,
1029
+ },
1030
+ ];
1031
+ return new Promise((resolve, reject) => {
1032
+ const server = createServer(async (reqIn, resOut) => {
1033
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
1034
+ if (reqIn.method === "GET" && reqUrl.pathname === "/auth") {
1035
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1036
+ resOut.end(renderCredentialForm(displayName, fields));
1037
+ return;
1038
+ }
1039
+ if (reqIn.method === "POST" && reqUrl.pathname === "/auth") {
1040
+ const chunks = [];
1041
+ for await (const chunk of reqIn)
1042
+ chunks.push(chunk);
1043
+ const body = Buffer.concat(chunks).toString();
1044
+ const params = new URLSearchParams(body);
1045
+ const token = params.get("token");
1046
+ if (!token) {
1047
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1048
+ resOut.end(renderCredentialForm(displayName, fields, "Token is required."));
1049
+ return;
1050
+ }
1051
+ try {
1052
+ await registry.auth(displayName, { token });
1053
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1054
+ resOut.end(renderAuthSuccess(displayName));
1055
+ server.close();
1056
+ resolve({ complete: true });
1057
+ }
1058
+ catch (err) {
1059
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1060
+ resOut.end(renderCredentialForm(displayName, fields, err instanceof Error ? err.message : String(err)));
1061
+ }
1062
+ return;
1063
+ }
1064
+ resOut.writeHead(404);
1065
+ resOut.end();
1066
+ });
1067
+ server.listen(port, () => {
1068
+ opts?.onAuthorizeUrl?.(`http://localhost:${port}/auth`);
1069
+ });
1070
+ const timer = setTimeout(() => {
1071
+ server.close();
1072
+ reject(new Error("Auth timed out"));
1073
+ }, timeout);
1074
+ server.on("close", () => clearTimeout(timer));
1075
+ });
1076
+ },
501
1077
  };
502
1078
  // ==========================================
503
1079
  // Ref API
@@ -751,6 +1327,12 @@ export function createAdk(fs, options = {}) {
751
1327
  const entry = findRef(config.refs ?? [], name);
752
1328
  if (!entry)
753
1329
  throw new Error(`Ref "${name}" not found`);
1330
+ // Registry-proxied refs: ask the remote @config for state (secrets live
1331
+ // server-side so local inspection would always return "missing").
1332
+ const proxy = await resolveProxyForRef(entry);
1333
+ if (proxy) {
1334
+ return forwardRefOpToProxy(proxy.reg, proxy.agent, "auth-status", { name });
1335
+ }
754
1336
  let security = null;
755
1337
  try {
756
1338
  const consumer = await buildConsumerForRef(entry);
@@ -856,6 +1438,23 @@ export function createAdk(fs, options = {}) {
856
1438
  const entry = findRef(config.refs ?? [], name);
857
1439
  if (!entry)
858
1440
  throw new Error(`Ref "${name}" not found`);
1441
+ // Registry-proxied auth: forward the start-of-flow to the remote @config
1442
+ // agent. The registry owns the client_id/secret and returns an authorize
1443
+ // URL pointing at the registry's OAuth callback domain, so the user
1444
+ // completes the flow against the registry instead of localhost.
1445
+ const proxy = await resolveProxyForRef(entry, { preferLocal: opts?.preferLocal });
1446
+ if (proxy) {
1447
+ const params = { name };
1448
+ if (opts?.apiKey !== undefined)
1449
+ params.apiKey = opts.apiKey;
1450
+ if (opts?.credentials)
1451
+ params.credentials = opts.credentials;
1452
+ if (opts?.scopes)
1453
+ params.scopes = opts.scopes;
1454
+ if (opts?.stateContext)
1455
+ params.stateContext = opts.stateContext;
1456
+ return forwardRefOpToProxy(proxy.reg, proxy.agent, "auth", params);
1457
+ }
859
1458
  const status = await ref.authStatus(name);
860
1459
  const security = status.security;
861
1460
  const resolve = options.resolveCredentials;
@@ -1076,14 +1675,30 @@ export function createAdk(fs, options = {}) {
1076
1675
  return { type: security.type, complete: false };
1077
1676
  },
1078
1677
  async authLocal(name, opts) {
1678
+ // `ref.auth` is already proxy-aware — for proxied refs it returns
1679
+ // the authorizeUrl that the registry minted against its own
1680
+ // callback domain. Everything below is identical for local and
1681
+ // proxied refs except the last step (polling for the callback),
1682
+ // which only makes sense when we own the redirect URI.
1079
1683
  const result = await ref.auth(name);
1080
1684
  if (result.complete)
1081
1685
  return { complete: true };
1686
+ const config = await readConfig();
1687
+ const entry = findRef(config.refs ?? [], name);
1688
+ const proxy = entry ? await resolveProxyForRef(entry) : null;
1082
1689
  const port = options.oauthCallbackPort ?? 8919;
1083
1690
  const timeout = opts?.timeoutMs ?? 300_000;
1084
1691
  const { createServer } = await import("node:http");
1085
- // API key / HTTP auth — serve a local credential form
1692
+ // API key / HTTP auth — local credential form.
1693
+ //
1694
+ // We refuse to serve the form for a proxied ref: the registry
1695
+ // owns the credential store, so the user needs to submit via
1696
+ // whatever UI the registry exposes. Supporting this through the
1697
+ // proxy would need a remote form endpoint — out of scope here.
1086
1698
  if (result.fields && result.fields.length > 0 && result.type !== "oauth2") {
1699
+ if (proxy) {
1700
+ throw new Error(`Ref "${name}" is sourced from a proxied registry; submit credentials through ${proxy.agent} instead of a local form.`);
1701
+ }
1087
1702
  return new Promise((resolve, reject) => {
1088
1703
  const server = createServer(async (req, res) => {
1089
1704
  const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
@@ -1138,13 +1753,20 @@ export function createAdk(fs, options = {}) {
1138
1753
  server.on("close", () => clearTimeout(timer));
1139
1754
  });
1140
1755
  }
1141
- // OAuth2 — open authorize URL and wait for callback
1756
+ // OAuth2 — hand the authorize URL to the caller.
1142
1757
  if (result.type !== "oauth2" || !result.authorizeUrl) {
1143
1758
  throw new Error(`authLocal cannot handle auth type: ${result.type}`);
1144
1759
  }
1145
1760
  if (opts?.onAuthorizeUrl) {
1146
1761
  opts.onAuthorizeUrl(result.authorizeUrl);
1147
1762
  }
1763
+ // Proxied refs: the registry owns the callback endpoint, so there's
1764
+ // nothing to poll here. Callers poll `ref.authStatus` on their own
1765
+ // schedule once the user finishes the remote consent screen.
1766
+ if (proxy)
1767
+ return { complete: false };
1768
+ // Local refs: spin up the callback server on oauthCallbackPort and
1769
+ // block until the OAuth provider redirects back.
1148
1770
  return new Promise((resolve, reject) => {
1149
1771
  const server = createServer(async (req, res) => {
1150
1772
  const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
@@ -1183,6 +1805,14 @@ export function createAdk(fs, options = {}) {
1183
1805
  });
1184
1806
  },
1185
1807
  async refreshToken(name) {
1808
+ // Registry-proxied refs: the remote @config holds the refresh_token.
1809
+ const entryForProxy = await ref.get(name);
1810
+ if (entryForProxy) {
1811
+ const proxy = await resolveProxyForRef(entryForProxy);
1812
+ if (proxy) {
1813
+ return forwardRefOpToProxy(proxy.reg, proxy.agent, "refresh-token", { name });
1814
+ }
1815
+ }
1186
1816
  // Read stored refresh_token
1187
1817
  const refreshToken = await readRefSecret(name, "refresh_token");
1188
1818
  if (!refreshToken)
@@ -1257,32 +1887,6 @@ export function createAdk(fs, options = {}) {
1257
1887
  catch { /* state wasn't base64 JSON — legacy format */ }
1258
1888
  return { refName: pending.refName, complete: true, stateContext };
1259
1889
  }
1260
- // ==========================================
1261
- // Proxy API
1262
- // ==========================================
1263
- const proxy = {
1264
- async add(entry) {
1265
- const config = await readConfig();
1266
- const proxies = (config.proxies ?? []).filter((p) => p.name !== entry.name);
1267
- proxies.push(entry);
1268
- await writeConfig({ ...config, proxies });
1269
- },
1270
- async remove(name) {
1271
- const config = await readConfig();
1272
- if (!config.proxies?.length)
1273
- return false;
1274
- const before = config.proxies.length;
1275
- const proxies = config.proxies.filter((p) => p.name !== name);
1276
- if (proxies.length === before)
1277
- return false;
1278
- await writeConfig({ ...config, proxies });
1279
- return true;
1280
- },
1281
- async list() {
1282
- const config = await readConfig();
1283
- return config.proxies ?? [];
1284
- },
1285
- };
1286
- return { proxy, registry, ref, readConfig, writeConfig, handleCallback };
1890
+ return { registry, ref, readConfig, writeConfig, handleCallback };
1287
1891
  }
1288
1892
  //# sourceMappingURL=config-store.js.map