@slashfi/agents-sdk 0.76.0 → 0.77.1

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 (59) hide show
  1. package/dist/adk-tools.d.ts +2 -2
  2. package/dist/adk-tools.d.ts.map +1 -1
  3. package/dist/adk-tools.js +9 -18
  4. package/dist/adk-tools.js.map +1 -1
  5. package/dist/adk.js +190 -120
  6. package/dist/adk.js.map +1 -1
  7. package/dist/agent-definitions/config.d.ts.map +1 -1
  8. package/dist/agent-definitions/config.js +12 -14
  9. package/dist/agent-definitions/config.js.map +1 -1
  10. package/dist/cjs/adk-tools.js +9 -18
  11. package/dist/cjs/adk-tools.js.map +1 -1
  12. package/dist/cjs/agent-definitions/config.js +12 -14
  13. package/dist/cjs/agent-definitions/config.js.map +1 -1
  14. package/dist/cjs/config-store.js +527 -30
  15. package/dist/cjs/config-store.js.map +1 -1
  16. package/dist/cjs/define-config.js +5 -7
  17. package/dist/cjs/define-config.js.map +1 -1
  18. package/dist/cjs/index.js.map +1 -1
  19. package/dist/cjs/materialize.js +1 -1
  20. package/dist/cjs/materialize.js.map +1 -1
  21. package/dist/cjs/mcp-client.js +98 -0
  22. package/dist/cjs/mcp-client.js.map +1 -1
  23. package/dist/cjs/registry-consumer.js +69 -11
  24. package/dist/cjs/registry-consumer.js.map +1 -1
  25. package/dist/config-store.d.ts +39 -4
  26. package/dist/config-store.d.ts.map +1 -1
  27. package/dist/config-store.js +528 -31
  28. package/dist/config-store.js.map +1 -1
  29. package/dist/define-config.d.ts +65 -18
  30. package/dist/define-config.d.ts.map +1 -1
  31. package/dist/define-config.js +5 -7
  32. package/dist/define-config.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/materialize.js +1 -1
  37. package/dist/materialize.js.map +1 -1
  38. package/dist/mcp-client.d.ts +44 -0
  39. package/dist/mcp-client.d.ts.map +1 -1
  40. package/dist/mcp-client.js +95 -0
  41. package/dist/mcp-client.js.map +1 -1
  42. package/dist/registry-consumer.d.ts +1 -1
  43. package/dist/registry-consumer.d.ts.map +1 -1
  44. package/dist/registry-consumer.js +69 -11
  45. package/dist/registry-consumer.js.map +1 -1
  46. package/dist/validate.d.ts +8 -8
  47. package/package.json +1 -1
  48. package/src/adk-tools.ts +11 -18
  49. package/src/adk.ts +78 -11
  50. package/src/agent-definitions/config.ts +15 -16
  51. package/src/config-store.test.ts +212 -0
  52. package/src/config-store.ts +615 -37
  53. package/src/consumer.test.ts +7 -7
  54. package/src/define-config.ts +69 -20
  55. package/src/index.ts +1 -0
  56. package/src/materialize.ts +1 -1
  57. package/src/mcp-client.ts +121 -0
  58. package/src/ref-naming.test.ts +115 -90
  59. package/src/registry-consumer.ts +75 -13
@@ -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
  // ============================================
@@ -37,9 +37,10 @@ function refName(entry) {
37
37
  function findRef(refs, name) {
38
38
  const match = refs.find((r) => refName(r) === name);
39
39
  if (match)
40
- return match;
40
+ return normalizeRef(match);
41
41
  const alt = name.startsWith("@") ? name.slice(1) : `@${name}`;
42
- return refs.find((r) => refName(r) === alt);
42
+ const altMatch = refs.find((r) => refName(r) === alt);
43
+ return altMatch ? normalizeRef(altMatch) : undefined;
43
44
  }
44
45
  /**
45
46
  * Match a ref name with @ normalization (for filter/map operations).
@@ -191,7 +192,8 @@ export function createAdk(fs, options = {}) {
191
192
  const refs = (config.refs ?? []).map((r) => {
192
193
  if (refName(r) !== name)
193
194
  return r;
194
- return { ...r, config: { ...r.config, [key]: stored } };
195
+ const normalized = normalizeRef(r);
196
+ return { ...normalized, config: { ...normalized.config, [key]: stored } };
195
197
  });
196
198
  await writeConfig({ ...config, refs });
197
199
  }
@@ -453,7 +455,11 @@ export function createAdk(fs, options = {}) {
453
455
  * throw on error so callers get a result that matches the local signature.
454
456
  */
455
457
  async function forwardRefOpToProxy(reg, agent, operation, params) {
456
- const consumer = await buildConsumerForRef({ ref: "", sourceRegistry: { url: reg.url, agentPath: agent } });
458
+ const consumer = await buildConsumerForRef({
459
+ ref: "",
460
+ name: "",
461
+ sourceRegistry: { url: reg.url, agentPath: agent },
462
+ });
457
463
  const resolved = consumer.registries().find((r) => r.url === reg.url);
458
464
  if (!resolved)
459
465
  throw new Error(`Registry ${reg.url} not resolvable for proxy forwarding`);
@@ -473,16 +479,215 @@ export function createAdk(fs, options = {}) {
473
479
  // ==========================================
474
480
  // Registry API
475
481
  // ==========================================
482
+ /**
483
+ * Encrypt with `secret:` prefix when an encryption key is configured, so the
484
+ * value is readable by the existing `decryptConfigSecrets` path on the read
485
+ * side. Plaintext fallback preserves the "no key = dev mode" contract.
486
+ */
487
+ async function protectSecret(value) {
488
+ if (!options.encryptionKey)
489
+ return value;
490
+ return `${SECRET_PREFIX}${await encryptSecret(value, options.encryptionKey)}`;
491
+ }
492
+ /**
493
+ * Re-probe a registry with the current stored credentials to see whether it
494
+ * advertises `capabilities.registry.proxy` in its MCP `initialize` response,
495
+ * and persist the proxy config when it does. Safe to call after a successful
496
+ * `auth()` / `authLocal()` — on the add path we skip the proxy probe when
497
+ * auth is required, so this is the second chance to back-fill it.
498
+ *
499
+ * Respects explicit user config: if `proxy` is already set, we leave it
500
+ * alone. Any discovery failure is swallowed — proxy is an optimization,
501
+ * not a correctness requirement.
502
+ */
503
+ async function discoverProxyAfterAuth(nameOrUrl) {
504
+ const config = await readConfig();
505
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
506
+ if (!target || typeof target === "string")
507
+ return;
508
+ if (target.proxy)
509
+ return;
510
+ try {
511
+ const consumer = await buildConsumer(nameOrUrl);
512
+ const discovered = await consumer.discover(target.url);
513
+ if (!discovered.proxy?.mode)
514
+ return;
515
+ await updateRegistryEntry(nameOrUrl, (existing) => {
516
+ if (existing.proxy)
517
+ return;
518
+ existing.proxy = {
519
+ mode: discovered.proxy.mode,
520
+ ...(discovered.proxy.agent && { agent: discovered.proxy.agent }),
521
+ };
522
+ });
523
+ }
524
+ catch {
525
+ // Proxy probe is best-effort — auth itself already succeeded.
526
+ }
527
+ }
528
+ /**
529
+ * Atomic read-modify-write on a registry entry by name or URL. Used by
530
+ * `authLocal` to persist both `auth` and `oauth` together, which `auth()`
531
+ * alone can't express. Returns true when the entry was found and written.
532
+ */
533
+ async function updateRegistryEntry(nameOrUrl, mutate) {
534
+ const config = await readConfig();
535
+ if (!config.registries?.length)
536
+ return false;
537
+ let found = false;
538
+ const registries = config.registries.map((r) => {
539
+ const rName = registryDisplayName(r);
540
+ if (rName !== nameOrUrl && registryUrl(r) !== nameOrUrl)
541
+ return r;
542
+ found = true;
543
+ const existing = typeof r === "string" ? { url: r } : { ...r };
544
+ mutate(existing);
545
+ return existing;
546
+ });
547
+ if (!found)
548
+ return false;
549
+ await writeConfig({ ...config, registries });
550
+ return true;
551
+ }
552
+ /**
553
+ * Decrypt a `secret:`-prefixed value if we hold the encryption key. Plaintext
554
+ * values pass through unchanged so dev configs keep working.
555
+ */
556
+ async function revealSecret(value) {
557
+ if (!value)
558
+ return value;
559
+ if (!value.startsWith(SECRET_PREFIX))
560
+ return value;
561
+ if (!options.encryptionKey)
562
+ return undefined;
563
+ return decryptSecret(value.slice(SECRET_PREFIX.length), options.encryptionKey);
564
+ }
565
+ /**
566
+ * Refresh a registry's OAuth access token using the stored refresh token.
567
+ * Persists the new access token (encrypted) and updates `expiresAt`. If the
568
+ * provider rotates the refresh token, that's encrypted and stored too.
569
+ * Returns `true` when the refresh succeeded. Callers should catch and fall
570
+ * back to full re-auth on failure.
571
+ */
572
+ async function refreshRegistryToken(nameOrUrl) {
573
+ const config = await readConfig();
574
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
575
+ if (!target || typeof target === "string")
576
+ return false;
577
+ const oauth = target.oauth;
578
+ if (!oauth?.refreshToken || !oauth.tokenEndpoint || !oauth.clientId)
579
+ return false;
580
+ const refreshToken = await revealSecret(oauth.refreshToken);
581
+ const clientSecret = await revealSecret(oauth.clientSecret);
582
+ if (!refreshToken)
583
+ return false;
584
+ const refreshed = await refreshAccessToken(oauth.tokenEndpoint, {
585
+ refreshToken,
586
+ clientId: oauth.clientId,
587
+ ...(clientSecret && { clientSecret }),
588
+ });
589
+ const expiresAt = refreshed.expiresIn
590
+ ? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString()
591
+ : undefined;
592
+ const encAccess = await protectSecret(refreshed.accessToken);
593
+ const encRefresh = refreshed.refreshToken
594
+ ? await protectSecret(refreshed.refreshToken)
595
+ : undefined;
596
+ await updateRegistryEntry(nameOrUrl, (existing) => {
597
+ existing.auth = { type: "bearer", token: encAccess };
598
+ if (!existing.oauth)
599
+ return;
600
+ if (encRefresh)
601
+ existing.oauth.refreshToken = encRefresh;
602
+ if (expiresAt)
603
+ existing.oauth.expiresAt = expiresAt;
604
+ else
605
+ delete existing.oauth.expiresAt;
606
+ });
607
+ return true;
608
+ }
609
+ /**
610
+ * Run a registry op once; on 401 (`registry_auth_required`), try to refresh
611
+ * via the stored refresh token and retry exactly once. Any other AdkError
612
+ * propagates as-is.
613
+ */
614
+ async function callWithRefresh(nameOrUrl, fn) {
615
+ try {
616
+ return await fn();
617
+ }
618
+ catch (err) {
619
+ if (!(err instanceof AdkError) || err.code !== "registry_auth_required")
620
+ throw err;
621
+ let refreshed = false;
622
+ try {
623
+ refreshed = await refreshRegistryToken(nameOrUrl);
624
+ }
625
+ catch {
626
+ // Refresh failed — surface the original 401 below.
627
+ }
628
+ if (!refreshed)
629
+ throw err;
630
+ return fn();
631
+ }
632
+ }
633
+ /**
634
+ * Throw a typed error if the registry has a recorded auth challenge and
635
+ * no usable credentials on the entry. Callers should invoke this before
636
+ * running any op that talks to the registry.
637
+ */
638
+ function assertRegistryAuthorized(entry) {
639
+ if (!entry.authRequirement)
640
+ return;
641
+ const hasUsableAuth = entry.auth && entry.auth.type !== "none"
642
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
643
+ (entry.auth.type === "api-key" && !!entry.auth.key)
644
+ : false;
645
+ if (hasUsableAuth)
646
+ return;
647
+ const name = entry.name ?? entry.url;
648
+ const scope = entry.authRequirement.scopes?.join(" ");
649
+ throw new AdkError({
650
+ code: "registry_auth_required",
651
+ message: `Registry "${name}" requires authentication.`,
652
+ hint: `Run: adk registry auth ${name} --token <token>${scope ? ` (scopes: ${scope})` : ""}`,
653
+ details: {
654
+ url: entry.url,
655
+ scheme: entry.authRequirement.scheme,
656
+ realm: entry.authRequirement.realm,
657
+ authorizationServers: entry.authRequirement.authorizationServers,
658
+ scopes: entry.authRequirement.scopes,
659
+ resourceMetadataUrl: entry.authRequirement.resourceMetadataUrl,
660
+ },
661
+ });
662
+ }
476
663
  const registry = {
477
664
  async add(entry) {
478
665
  const config = await readConfig();
479
666
  const alias = entry.name ?? entry.url;
480
667
  const registries = (config.registries ?? []).filter((r) => registryDisplayName(r) !== alias);
481
- // Probe the registry's MCP initialize response so we can auto-populate
482
- // `proxy` when the server advertises it. Users who pass an explicit
483
- // `proxy` on the entry always win — discovery only fills in blanks.
668
+ // Probe the registry before saving. Two things fall out of the probe:
669
+ // 1. Auth challenge 401 + WWW-Authenticate points at RFC 9728
670
+ // resource metadata; we persist it on `authRequirement` so
671
+ // subsequent ops can refuse early with a friendly message.
672
+ // 2. Proxy capability — the MCP `initialize` response may advertise
673
+ // `capabilities.registry.proxy`, which auto-populates `proxy`.
674
+ // Users who set `proxy` or `auth` explicitly on the entry always win:
675
+ // discovery only fills in blanks.
484
676
  let final = entry;
485
- if (!entry.proxy) {
677
+ let authRequirement;
678
+ const hasUsableAuth = entry.auth && entry.auth.type !== "none"
679
+ ? (entry.auth.type === "bearer" && !!entry.auth.token) ||
680
+ (entry.auth.type === "api-key" && !!entry.auth.key)
681
+ : false;
682
+ if (!hasUsableAuth) {
683
+ const fetchFn = options.fetch ?? globalThis.fetch;
684
+ const probe = await probeRegistryAuth(entry.url, fetchFn);
685
+ if (probe.ok === false) {
686
+ authRequirement = probe.requirement;
687
+ final = { ...final, authRequirement };
688
+ }
689
+ }
690
+ if (!entry.proxy && !authRequirement) {
486
691
  try {
487
692
  const probeConsumer = await createRegistryConsumer({ registries: [entry], refs: [] }, { token: options.token, fetch: options.fetch });
488
693
  const resolved = probeConsumer.registries()[0];
@@ -490,7 +695,7 @@ export function createAdk(fs, options = {}) {
490
695
  const discovered = await probeConsumer.discover(resolved.url);
491
696
  if (discovered.proxy?.mode) {
492
697
  final = {
493
- ...entry,
698
+ ...final,
494
699
  proxy: {
495
700
  mode: discovered.proxy.mode,
496
701
  ...(discovered.proxy.agent && { agent: discovered.proxy.agent }),
@@ -506,6 +711,7 @@ export function createAdk(fs, options = {}) {
506
711
  }
507
712
  registries.push(final);
508
713
  await writeConfig({ ...config, registries });
714
+ return authRequirement ? { authRequirement } : {};
509
715
  },
510
716
  async remove(nameOrUrl) {
511
717
  const config = await readConfig();
@@ -558,18 +764,26 @@ export function createAdk(fs, options = {}) {
558
764
  return true;
559
765
  },
560
766
  async browse(name, query) {
561
- const consumer = await buildConsumer(name);
562
767
  const config = await readConfig();
563
768
  const target = findRegistry(config.registries ?? [], name);
564
- const url = target ? registryUrl(target) : name;
565
- return consumer.browse(url, query);
769
+ if (target && typeof target !== "string")
770
+ assertRegistryAuthorized(target);
771
+ return callWithRefresh(name, async () => {
772
+ const consumer = await buildConsumer(name);
773
+ const url = target ? registryUrl(target) : name;
774
+ return consumer.browse(url, query);
775
+ });
566
776
  },
567
777
  async inspect(name) {
568
- const consumer = await buildConsumer(name);
569
778
  const config = await readConfig();
570
779
  const target = findRegistry(config.registries ?? [], name);
571
- const url = target ? registryUrl(target) : name;
572
- return consumer.discover(url);
780
+ if (target && typeof target !== "string")
781
+ assertRegistryAuthorized(target);
782
+ return callWithRefresh(name, async () => {
783
+ const consumer = await buildConsumer(name);
784
+ const url = target ? registryUrl(target) : name;
785
+ return consumer.discover(url);
786
+ });
573
787
  },
574
788
  async test(name) {
575
789
  const config = await readConfig();
@@ -580,9 +794,28 @@ export function createAdk(fs, options = {}) {
580
794
  const results = await Promise.allSettled(targets.map(async (r) => {
581
795
  const url = registryUrl(r);
582
796
  const rName = registryDisplayName(r);
797
+ if (typeof r !== "string" && r.authRequirement) {
798
+ const hasUsableAuth = r.auth && r.auth.type !== "none"
799
+ ? (r.auth.type === "bearer" && !!r.auth.token) ||
800
+ (r.auth.type === "api-key" && !!r.auth.key)
801
+ : false;
802
+ if (!hasUsableAuth) {
803
+ return {
804
+ name: rName,
805
+ url,
806
+ status: "error",
807
+ error: `auth required — run: adk registry auth ${rName} --token <token>`,
808
+ };
809
+ }
810
+ }
583
811
  try {
584
- const consumer = await createRegistryConsumer({ registries: [r] }, { token: options.token, fetch: options.fetch });
585
- const disc = await consumer.discover(url);
812
+ // Route through buildConsumer so encrypted auth/headers get
813
+ // decrypted, then use callWithRefresh so a 401 triggers the
814
+ // stored refresh token before giving up.
815
+ const disc = await callWithRefresh(rName, async () => {
816
+ const consumer = await buildConsumer(rName);
817
+ return consumer.discover(url);
818
+ });
586
819
  return { name: rName, url, status: "active", issuer: disc.issuer };
587
820
  }
588
821
  catch (err) {
@@ -594,15 +827,278 @@ export function createAdk(fs, options = {}) {
594
827
  ? r.value
595
828
  : { name: "unknown", url: "unknown", status: "error", error: "unknown" });
596
829
  },
830
+ async auth(nameOrUrl, credential) {
831
+ // Encrypt the secret value up-front so the write path is uniform;
832
+ // `buildConsumer` decrypts on the read side via `decryptConfigSecrets`.
833
+ const protectedValue = "token" in credential
834
+ ? await protectSecret(credential.token)
835
+ : await protectSecret(credential.apiKey);
836
+ const updated = await updateRegistryEntry(nameOrUrl, (existing) => {
837
+ if ("token" in credential) {
838
+ existing.auth = {
839
+ type: "bearer",
840
+ token: protectedValue,
841
+ ...(credential.tokenUrl && { tokenUrl: credential.tokenUrl }),
842
+ };
843
+ }
844
+ else {
845
+ existing.auth = {
846
+ type: "api-key",
847
+ key: protectedValue,
848
+ ...(credential.header && { header: credential.header }),
849
+ };
850
+ }
851
+ delete existing.authRequirement;
852
+ });
853
+ if (updated)
854
+ await discoverProxyAfterAuth(nameOrUrl);
855
+ return updated;
856
+ },
857
+ async authLocal(nameOrUrl, opts) {
858
+ const config = await readConfig();
859
+ const target = findRegistry(config.registries ?? [], nameOrUrl);
860
+ if (!target || typeof target === "string") {
861
+ throw new AdkError({
862
+ code: "registry_not_found",
863
+ message: `Registry not found: ${nameOrUrl}`,
864
+ hint: "Run `adk registry list` to see configured registries.",
865
+ details: { nameOrUrl },
866
+ });
867
+ }
868
+ // When the caller forces re-auth, wipe the existing credentials and
869
+ // re-probe so we know what scheme the registry wants now. Servers can
870
+ // rotate auth server metadata between runs.
871
+ if (opts?.force) {
872
+ await updateRegistryEntry(nameOrUrl, (existing) => {
873
+ delete existing.auth;
874
+ delete existing.oauth;
875
+ });
876
+ const fetchFn = options.fetch ?? globalThis.fetch;
877
+ const probe = await probeRegistryAuth(target.url, fetchFn);
878
+ if (probe.ok === false) {
879
+ await updateRegistryEntry(nameOrUrl, (existing) => {
880
+ existing.authRequirement = probe.requirement;
881
+ });
882
+ // Re-read so the flow below sees the fresh requirement.
883
+ const refreshed = await readConfig();
884
+ const refreshedTarget = findRegistry(refreshed.registries ?? [], nameOrUrl);
885
+ if (refreshedTarget && typeof refreshedTarget !== "string") {
886
+ Object.assign(target, refreshedTarget);
887
+ }
888
+ }
889
+ else if (probe.ok === true) {
890
+ // Registry no longer requires auth — nothing to do.
891
+ await updateRegistryEntry(nameOrUrl, (existing) => {
892
+ delete existing.authRequirement;
893
+ });
894
+ return { complete: true };
895
+ }
896
+ }
897
+ // Already authenticated — nothing to do (unless forced above).
898
+ const hasUsableAuth = target.auth && target.auth.type !== "none"
899
+ ? (target.auth.type === "bearer" && !!target.auth.token) ||
900
+ (target.auth.type === "api-key" && !!target.auth.key)
901
+ : false;
902
+ if (hasUsableAuth && !target.authRequirement) {
903
+ return { complete: true };
904
+ }
905
+ const req = target.authRequirement;
906
+ const port = options.oauthCallbackPort ?? 8919;
907
+ const timeout = opts?.timeoutMs ?? 300_000;
908
+ const displayName = target.name ?? target.url;
909
+ const { createServer } = await import("node:http");
910
+ // OAuth path — the registry advertised authorization servers via
911
+ // RFC 9728 protected-resource metadata. Walk the full flow:
912
+ // AS metadata → dynamic client registration → PKCE authorize →
913
+ // local callback → token exchange → persist access token.
914
+ if (req?.authorizationServers?.length) {
915
+ const authServer = req.authorizationServers[0];
916
+ const metadata = (await discoverOAuthMetadata(authServer)) ??
917
+ (await tryFetchOAuthMetadata(authServer));
918
+ if (!metadata) {
919
+ throw new AdkError({
920
+ code: "registry_oauth_discovery_failed",
921
+ message: `Could not discover OAuth metadata at ${authServer}.`,
922
+ hint: "The authorization server must expose /.well-known/oauth-authorization-server.",
923
+ details: { authServer, registry: displayName },
924
+ });
925
+ }
926
+ if (!metadata.registration_endpoint) {
927
+ throw new AdkError({
928
+ code: "registry_oauth_no_registration",
929
+ message: `Authorization server ${authServer} does not support dynamic client registration.`,
930
+ hint: `Obtain a bearer token manually, then run: adk registry auth ${displayName} --token <token>`,
931
+ details: { authServer, registry: displayName },
932
+ });
933
+ }
934
+ const redirectUri = `http://localhost:${port}/callback`;
935
+ const registration = await dynamicClientRegistration(metadata.registration_endpoint, {
936
+ clientName: options.oauthClientName ?? "adk",
937
+ redirectUris: [redirectUri],
938
+ grantTypes: ["authorization_code"],
939
+ });
940
+ const state = crypto.randomUUID();
941
+ const { url: authorizeUrl, codeVerifier } = await buildOAuthAuthorizeUrl({
942
+ authorizationEndpoint: metadata.authorization_endpoint,
943
+ clientId: registration.clientId,
944
+ redirectUri,
945
+ scopes: req.scopes,
946
+ state,
947
+ });
948
+ return new Promise((resolve, reject) => {
949
+ const server = createServer(async (reqIn, resOut) => {
950
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
951
+ if (reqUrl.pathname !== "/callback") {
952
+ resOut.writeHead(404);
953
+ resOut.end();
954
+ return;
955
+ }
956
+ const code = reqUrl.searchParams.get("code");
957
+ const returnedState = reqUrl.searchParams.get("state");
958
+ if (!code || returnedState !== state) {
959
+ const error = reqUrl.searchParams.get("error") ?? "missing code/state";
960
+ resOut.writeHead(400, { "Content-Type": "text/html" });
961
+ resOut.end(`<h1>Error</h1><p>${esc(error)}</p>`);
962
+ server.close();
963
+ reject(new AdkError({
964
+ code: "registry_oauth_denied",
965
+ message: `OAuth callback rejected: ${error}`,
966
+ hint: "Retry `adk registry auth` and complete the browser consent.",
967
+ details: { registry: displayName, error },
968
+ }));
969
+ return;
970
+ }
971
+ try {
972
+ const tokens = await exchangeCodeForTokens(metadata.token_endpoint, {
973
+ code,
974
+ codeVerifier,
975
+ clientId: registration.clientId,
976
+ clientSecret: registration.clientSecret,
977
+ redirectUri,
978
+ });
979
+ const expiresAt = tokens.expiresIn
980
+ ? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
981
+ : undefined;
982
+ const encToken = await protectSecret(tokens.accessToken);
983
+ const encRefresh = tokens.refreshToken
984
+ ? await protectSecret(tokens.refreshToken)
985
+ : undefined;
986
+ const encClientSecret = registration.clientSecret
987
+ ? await protectSecret(registration.clientSecret)
988
+ : undefined;
989
+ await updateRegistryEntry(displayName, (existing) => {
990
+ existing.auth = { type: "bearer", token: encToken };
991
+ existing.oauth = {
992
+ tokenEndpoint: metadata.token_endpoint,
993
+ clientId: registration.clientId,
994
+ ...(encClientSecret && { clientSecret: encClientSecret }),
995
+ ...(encRefresh && { refreshToken: encRefresh }),
996
+ ...(expiresAt && { expiresAt }),
997
+ ...(req.scopes?.length && { scopes: req.scopes }),
998
+ };
999
+ delete existing.authRequirement;
1000
+ });
1001
+ await discoverProxyAfterAuth(displayName);
1002
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1003
+ resOut.end(renderAuthSuccess(displayName));
1004
+ server.close();
1005
+ resolve({ complete: true });
1006
+ }
1007
+ catch (err) {
1008
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1009
+ resOut.end(`<h1>Error</h1><p>${esc(err instanceof Error ? err.message : String(err))}</p>`);
1010
+ server.close();
1011
+ reject(err);
1012
+ }
1013
+ });
1014
+ server.listen(port, () => {
1015
+ opts?.onAuthorizeUrl?.(authorizeUrl);
1016
+ });
1017
+ const timer = setTimeout(() => {
1018
+ server.close();
1019
+ reject(new Error("OAuth callback timed out"));
1020
+ }, timeout);
1021
+ server.on("close", () => clearTimeout(timer));
1022
+ });
1023
+ }
1024
+ // No OAuth metadata — serve a local HTTPS form asking for a token.
1025
+ // Used when the registry returned 401 without pointing at an AS, or
1026
+ // when the caller simply wants to paste a pre-issued token.
1027
+ const fields = [
1028
+ {
1029
+ name: "token",
1030
+ label: "Bearer token",
1031
+ description: req?.realm
1032
+ ? `Token for realm "${req.realm}"`
1033
+ : "Token sent as `Authorization: Bearer <token>`.",
1034
+ secret: true,
1035
+ },
1036
+ ];
1037
+ return new Promise((resolve, reject) => {
1038
+ const server = createServer(async (reqIn, resOut) => {
1039
+ const reqUrl = new URL(reqIn.url ?? "/", `http://localhost:${port}`);
1040
+ if (reqIn.method === "GET" && reqUrl.pathname === "/auth") {
1041
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1042
+ resOut.end(renderCredentialForm(displayName, fields));
1043
+ return;
1044
+ }
1045
+ if (reqIn.method === "POST" && reqUrl.pathname === "/auth") {
1046
+ const chunks = [];
1047
+ for await (const chunk of reqIn)
1048
+ chunks.push(chunk);
1049
+ const body = Buffer.concat(chunks).toString();
1050
+ const params = new URLSearchParams(body);
1051
+ const token = params.get("token");
1052
+ if (!token) {
1053
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1054
+ resOut.end(renderCredentialForm(displayName, fields, "Token is required."));
1055
+ return;
1056
+ }
1057
+ try {
1058
+ await registry.auth(displayName, { token });
1059
+ resOut.writeHead(200, { "Content-Type": "text/html" });
1060
+ resOut.end(renderAuthSuccess(displayName));
1061
+ server.close();
1062
+ resolve({ complete: true });
1063
+ }
1064
+ catch (err) {
1065
+ resOut.writeHead(500, { "Content-Type": "text/html" });
1066
+ resOut.end(renderCredentialForm(displayName, fields, err instanceof Error ? err.message : String(err)));
1067
+ }
1068
+ return;
1069
+ }
1070
+ resOut.writeHead(404);
1071
+ resOut.end();
1072
+ });
1073
+ server.listen(port, () => {
1074
+ opts?.onAuthorizeUrl?.(`http://localhost:${port}/auth`);
1075
+ });
1076
+ const timer = setTimeout(() => {
1077
+ server.close();
1078
+ reject(new Error("Auth timed out"));
1079
+ }, timeout);
1080
+ server.on("close", () => clearTimeout(timer));
1081
+ });
1082
+ },
597
1083
  };
598
1084
  // ==========================================
599
1085
  // Ref API
600
1086
  // ==========================================
601
1087
  const ref = {
602
- async add(entry) {
1088
+ async add(entryInput) {
603
1089
  let security = null;
604
1090
  const config = await readConfig();
605
1091
  const hasRegistries = (config.registries ?? []).length > 0;
1092
+ const name = entryInput.name ?? entryInput.ref;
1093
+ let entry = { ...entryInput, name };
1094
+ if ((config.refs ?? []).some((r) => refNameMatches(r, name))) {
1095
+ throw new AdkError({
1096
+ code: "REF_INVALID",
1097
+ message: `Cannot add ref "${entry.ref}" as "${name}": a ref with that name already exists`,
1098
+ hint: "Choose a different name, or remove/update the existing ref first.",
1099
+ details: { ref: entry.ref, name },
1100
+ });
1101
+ }
606
1102
  // Auto-infer scheme from context
607
1103
  if (!entry.scheme) {
608
1104
  if (entry.sourceRegistry?.url) {
@@ -695,9 +1191,7 @@ export function createAdk(fs, options = {}) {
695
1191
  });
696
1192
  }
697
1193
  }
698
- const name = refName(entry);
699
- const refs = (config.refs ?? []).filter((r) => refName(r) !== name);
700
- refs.push(entry);
1194
+ const refs = [...(config.refs ?? []), entry];
701
1195
  await writeConfig({ ...config, refs });
702
1196
  return { security };
703
1197
  },
@@ -732,16 +1226,18 @@ export function createAdk(fs, options = {}) {
732
1226
  const updated = { ...r };
733
1227
  if (updates.url)
734
1228
  updated.url = updates.url;
735
- // Rename: prefer `name`, fall back to legacy `as`. When the
736
- // caller passes `name`, clear the legacy `as` so the stored
737
- // entry has one source of truth.
738
1229
  if (updates.name !== undefined) {
1230
+ const duplicate = config.refs?.some((candidate) => !refNameMatches(candidate, name) &&
1231
+ refNameMatches(candidate, updates.name));
1232
+ if (duplicate) {
1233
+ throw new AdkError({
1234
+ code: "REF_INVALID",
1235
+ message: `Cannot rename ref "${name}" to "${updates.name}": a ref with that name already exists`,
1236
+ hint: "Choose a different name, or remove/update the existing ref first.",
1237
+ details: { name, newName: updates.name },
1238
+ });
1239
+ }
739
1240
  updated.name = updates.name;
740
- if (updated.as !== undefined)
741
- updated.as = undefined;
742
- }
743
- else if (updates.as !== undefined) {
744
- updated.as = updates.as;
745
1241
  }
746
1242
  if (updates.scheme)
747
1243
  updated.scheme = updates.scheme;
@@ -1152,7 +1648,8 @@ export function createAdk(fs, options = {}) {
1152
1648
  // so callers can include extra context (tenant/user IDs).
1153
1649
  const statePayload = {
1154
1650
  ...opts?.stateContext,
1155
- ref: name,
1651
+ ref: entry.ref,
1652
+ name,
1156
1653
  ts: Date.now(),
1157
1654
  };
1158
1655
  const state = btoa(JSON.stringify(statePayload));