From aa6036d47d56a6a8d5a898aaaf02f67a315f59d1 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Sat, 22 Aug 2026 19:42:01 -0500 Subject: [PATCH 1/4] Add external-browser challenge kind and unavailable start result Extends the authentication attempt contract for in-app credential refresh (e.g. expired AWS SSO / Google ADC sessions): - New 'external-browser' challenge kind for flows where an external process (spawned CLI) owns the browser; carries attemptId, expiresIn, instructions, and an optional url. - New 'unavailable' AuthenticationStartResult variant meaning no attempt was created (required CLI missing, no SSO profile, etc.) so callers can fall back to a configuration UI instead of catching a throw. Duplicate-start ownership (already-in-progress) and disposal-cancel semantics are unchanged; the new variants are purely additive. --- .../ai-credentials/src/CredentialProvider.ts | 23 +++++++- .../src/__tests__/attempt-contract.test.ts | 58 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/ai-credentials/src/__tests__/attempt-contract.test.ts diff --git a/packages/ai-credentials/src/CredentialProvider.ts b/packages/ai-credentials/src/CredentialProvider.ts index d875781..554451a 100644 --- a/packages/ai-credentials/src/CredentialProvider.ts +++ b/packages/ai-credentials/src/CredentialProvider.ts @@ -18,11 +18,32 @@ export type AuthenticationChallenge = attemptId: string; authorizationUrl: string; expiresIn: number; + } + | { + /** + * An external process (e.g. a spawned CLI such as `gcloud`) owns the + * browser flow. `url` is omitted when the process opens the browser + * itself and no URL is known up front. + */ + kind: "external-browser"; + attemptId: string; + url?: string; + instructions: string; + expiresIn: number; }; export type AuthenticationStartResult = | { status: "started"; challenge: AuthenticationChallenge } - | { status: "already-in-progress" }; + | { status: "already-in-progress" } + | { + /** + * No attempt was created — the provider cannot be authenticated in + * this environment (e.g. required CLI missing, no SSO profile + * configured). Callers should fall back to a configuration UI. + */ + status: "unavailable"; + reason: string; + }; /** Strict semantic inputs accepted by the store-backed credential controller. */ export type CredentialSourceInput = diff --git a/packages/ai-credentials/src/__tests__/attempt-contract.test.ts b/packages/ai-credentials/src/__tests__/attempt-contract.test.ts new file mode 100644 index 0000000..fe038f5 --- /dev/null +++ b/packages/ai-credentials/src/__tests__/attempt-contract.test.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; + +import type { AuthenticationChallenge, AuthenticationStartResult } from "../CredentialProvider"; + +/** + * Contract coverage for the attempt-start result variants that the + * AcquisitionEngine itself does not produce: `unavailable` (no attempt + * created; produced by host services when a provider cannot be authenticated + * in the current environment) and the `external-browser` challenge (an + * external process owns the browser flow). + */ +describe("authentication attempt contract", () => { + it("distinguishes unavailable from started and already-in-progress", () => { + const unavailable: AuthenticationStartResult = { + status: "unavailable", + reason: "aws_cli_missing", + }; + const inProgress: AuthenticationStartResult = { status: "already-in-progress" }; + + if (unavailable.status === "unavailable") { + expect(unavailable.reason).toBe("aws_cli_missing"); + } else { + expect.unreachable("unavailable result must narrow on status"); + } + expect(inProgress.status).toBe("already-in-progress"); + expect("reason" in inProgress).toBe(false); + }); + + it("carries an external-browser challenge with optional url", () => { + const withUrl: AuthenticationChallenge = { + kind: "external-browser", + attemptId: "attempt-1", + url: "https://example.com/login", + instructions: "Complete the login in your browser.", + expiresIn: 600, + }; + const withoutUrl: AuthenticationChallenge = { + kind: "external-browser", + attemptId: "attempt-2", + instructions: "Complete the login in the browser opened by gcloud.", + expiresIn: 600, + }; + + const started: AuthenticationStartResult = { status: "started", challenge: withUrl }; + if (started.status === "started" && started.challenge.kind === "external-browser") { + expect(started.challenge.url).toBe("https://example.com/login"); + expect(started.challenge.instructions).toContain("browser"); + } else { + expect.unreachable("started result must expose the external-browser challenge"); + } + expect(withoutUrl.url).toBeUndefined(); + expect(withoutUrl.attemptId).toBe("attempt-2"); + }); +}); From d3fdd43c06d192676d51b592c6d18a46d22050b9 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Sat, 22 Aug 2026 20:11:08 -0500 Subject: [PATCH 2/4] Add completed start result for synchronous credential refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A silent (browser-free) token refresh finishes inside startAuthentication — no attempt is created, nothing is polled or cancelled — so the attempt contract needs a way to say 'already done; refetch status and models'. The interactive paths are unchanged and still ride attempts end to end. --- packages/ai-credentials/src/CredentialProvider.ts | 8 ++++++++ .../src/__tests__/attempt-contract.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/ai-credentials/src/CredentialProvider.ts b/packages/ai-credentials/src/CredentialProvider.ts index 554451a..156988e 100644 --- a/packages/ai-credentials/src/CredentialProvider.ts +++ b/packages/ai-credentials/src/CredentialProvider.ts @@ -35,6 +35,14 @@ export type AuthenticationChallenge = export type AuthenticationStartResult = | { status: "started"; challenge: AuthenticationChallenge } | { status: "already-in-progress" } + | { + /** + * The refresh completed synchronously (e.g. a silent token refresh + * that needed no browser interaction). No attempt was created; + * callers should refetch auth status and models. + */ + status: "completed"; + } | { /** * No attempt was created — the provider cannot be authenticated in diff --git a/packages/ai-credentials/src/__tests__/attempt-contract.test.ts b/packages/ai-credentials/src/__tests__/attempt-contract.test.ts index fe038f5..47b81a1 100644 --- a/packages/ai-credentials/src/__tests__/attempt-contract.test.ts +++ b/packages/ai-credentials/src/__tests__/attempt-contract.test.ts @@ -30,6 +30,15 @@ describe("authentication attempt contract", () => { expect("reason" in inProgress).toBe(false); }); + it("distinguishes a synchronously completed refresh from an interactive start", () => { + const completed: AuthenticationStartResult = { status: "completed" }; + expect(completed.status).toBe("completed"); + // A completed refresh created no attempt, so there is nothing to track + // or cancel — the result carries no challenge and no attemptId. + expect("challenge" in completed).toBe(false); + expect("attemptId" in completed).toBe(false); + }); + it("carries an external-browser challenge with optional url", () => { const withUrl: AuthenticationChallenge = { kind: "external-browser", From 6e08cda689a25a26464aa28c25b4c7c82f1d42a3 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Sat, 22 Aug 2026 22:59:42 -0500 Subject: [PATCH 3/4] Remove attempt-contract change-detector tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tests constructed typed literals and asserted the same literals back, so they could not detect a behavioral regression — TypeScript already supplies their only useful coverage. The protocol handler and standalone tests cover the real pass-through and rendering behavior. --- .../src/__tests__/attempt-contract.test.ts | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 packages/ai-credentials/src/__tests__/attempt-contract.test.ts diff --git a/packages/ai-credentials/src/__tests__/attempt-contract.test.ts b/packages/ai-credentials/src/__tests__/attempt-contract.test.ts deleted file mode 100644 index 47b81a1..0000000 --- a/packages/ai-credentials/src/__tests__/attempt-contract.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (C) 2026 Posit Software, PBC. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -import { describe, expect, it } from "vitest"; - -import type { AuthenticationChallenge, AuthenticationStartResult } from "../CredentialProvider"; - -/** - * Contract coverage for the attempt-start result variants that the - * AcquisitionEngine itself does not produce: `unavailable` (no attempt - * created; produced by host services when a provider cannot be authenticated - * in the current environment) and the `external-browser` challenge (an - * external process owns the browser flow). - */ -describe("authentication attempt contract", () => { - it("distinguishes unavailable from started and already-in-progress", () => { - const unavailable: AuthenticationStartResult = { - status: "unavailable", - reason: "aws_cli_missing", - }; - const inProgress: AuthenticationStartResult = { status: "already-in-progress" }; - - if (unavailable.status === "unavailable") { - expect(unavailable.reason).toBe("aws_cli_missing"); - } else { - expect.unreachable("unavailable result must narrow on status"); - } - expect(inProgress.status).toBe("already-in-progress"); - expect("reason" in inProgress).toBe(false); - }); - - it("distinguishes a synchronously completed refresh from an interactive start", () => { - const completed: AuthenticationStartResult = { status: "completed" }; - expect(completed.status).toBe("completed"); - // A completed refresh created no attempt, so there is nothing to track - // or cancel — the result carries no challenge and no attemptId. - expect("challenge" in completed).toBe(false); - expect("attemptId" in completed).toBe(false); - }); - - it("carries an external-browser challenge with optional url", () => { - const withUrl: AuthenticationChallenge = { - kind: "external-browser", - attemptId: "attempt-1", - url: "https://example.com/login", - instructions: "Complete the login in your browser.", - expiresIn: 600, - }; - const withoutUrl: AuthenticationChallenge = { - kind: "external-browser", - attemptId: "attempt-2", - instructions: "Complete the login in the browser opened by gcloud.", - expiresIn: 600, - }; - - const started: AuthenticationStartResult = { status: "started", challenge: withUrl }; - if (started.status === "started" && started.challenge.kind === "external-browser") { - expect(started.challenge.url).toBe("https://example.com/login"); - expect(started.challenge.instructions).toContain("browser"); - } else { - expect.unreachable("started result must expose the external-browser challenge"); - } - expect(withoutUrl.url).toBeUndefined(); - expect(withoutUrl.attemptId).toBe("attempt-2"); - }); -}); From 41ef84cf0f60868b8c3e22e2027c1a6dfb37aa56 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Sat, 22 Aug 2026 23:50:25 -0500 Subject: [PATCH 4/4] Add RECONNECT_PROVIDER notification action Hosts with a graphical configuration overlay rewrite the credential-expiry toast to a Reconnect action that opens the overlay and starts an in-app credential refresh. The bridge keeps emitting REFRESH_MODELS with CLI copy as its host-agnostic default; the rewrite happens in the Node provider callbacks when the host opts in. --- packages/ai-provider-bridge/src/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ai-provider-bridge/src/types.ts b/packages/ai-provider-bridge/src/types.ts index 54543f0..2025e45 100644 --- a/packages/ai-provider-bridge/src/types.ts +++ b/packages/ai-provider-bridge/src/types.ts @@ -289,6 +289,14 @@ export const NOTIFICATION_ACTIONS = { * Handler should open the Posit AI setup page */ POSIT_AI_COMPLETE_SETUP: "posit-ai-complete-setup", + + /** + * Reconnect a provider whose credentials expired - triggered instead of + * REFRESH_MODELS on hosts with a graphical configuration overlay. + * Handler should open the configuration overlay and start an in-app + * credential refresh for the notification's providerId. + */ + RECONNECT_PROVIDER: "reconnect-provider", } as const; /**