diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 0b2f7a40a88..b4362a42fa7 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -129,6 +129,11 @@ "title": "T3 Code: Show Diagnostics", "category": "T3 Code" }, + { + "command": "t3Code.pair", + "title": "Pair with Server…", + "category": "T3 Code" + }, { "command": "t3Code.setBearerToken", "title": "T3 Code: Set Server Bearer Token", @@ -194,6 +199,7 @@ "onCommand:t3Code.newThread", "onCommand:t3Code.openChat", "onCommand:t3Code.openInT3", + "onCommand:t3Code.pair", "onCommand:t3Code.selectThread", "onCommand:t3Code.setBearerToken", "onCommand:t3Code.showDiagnostics", diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 4f88f5ed2e4..b33d5e582ff 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -7,6 +7,7 @@ import type { RuntimeMode, ThreadId, } from "@t3tools/contracts"; +import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; import * as vscode from "vscode"; import { composePrompt, type TextContext } from "./editorContext.ts"; @@ -15,13 +16,17 @@ import { readDesktopBootstrapCredential, readDesktopServerUrl, } from "./desktopFavorites.ts"; -import { serverCandidates } from "./serverResolution.ts"; +import { classifyPairingInput, describeTokenExpiry } from "./pairing.ts"; +import { bearerTokenAppliesTo, serverCandidates } from "./serverResolution.ts"; import { T3ChatViewProvider } from "./chatViewProvider.ts"; import { T3Client } from "./t3Client.ts"; import { filterIdentityPeople, type IdentityPerson } from "./identity.ts"; const ACTIVE_THREAD_KEY_PREFIX = "t3Code.activeThread"; const BEARER_TOKEN_SECRET = "t3Code.serverBearerToken"; +// The endpoint a paired bearer token was issued by. Stored beside the token so +// the two are cleared together; see bearerTokenAppliesTo. +const BEARER_TOKEN_ENDPOINT_SECRET = "t3Code.serverBearerTokenEndpoint"; function workspaceFolder(): vscode.WorkspaceFolder | undefined { const activeUri = vscode.window.activeTextEditor?.document.uri; @@ -225,9 +230,14 @@ export function activate(context: vscode.ExtensionContext): void { const ensureConnected = async (): Promise => { const config = configuration(); const bearerToken = await context.secrets.get(BEARER_TOKEN_SECRET); + const bearerEndpoint = (await context.secrets.get(BEARER_TOKEN_ENDPOINT_SECRET)) ?? null; const bootstrapCredential = await readDesktopBootstrapCredential(); const connect = async (serverUrl: string): Promise => { - if (bearerToken !== undefined && bearerToken !== "") { + if ( + bearerToken !== undefined && + bearerToken !== "" && + bearerTokenAppliesTo(bearerEndpoint, serverUrl) + ) { try { await client.connect(serverUrl, bearerToken); return; @@ -242,7 +252,7 @@ export function activate(context: vscode.ExtensionContext): void { } }; const desktopServerUrl = await readDesktopServerUrl(); - const candidates = serverCandidates(desktopServerUrl, config.serverUrl); + const candidates = serverCandidates(desktopServerUrl, config.serverUrl, bearerEndpoint); let lastCause: unknown = new Error("No T3 Code server endpoint is available."); let connected = false; for (const candidate of candidates) { @@ -507,13 +517,58 @@ export function activate(context: vscode.ExtensionContext): void { }); if (token !== undefined && token.trim() !== "") { await context.secrets.store(BEARER_TOKEN_SECRET, token.trim()); + await context.secrets.delete(BEARER_TOKEN_ENDPOINT_SECRET); void vscode.window.showInformationMessage( "T3 Code bearer token stored in VS Code secret storage.", ); } }), + vscode.commands.registerCommand("t3Code.pair", async () => { + const input = await vscode.window.showInputBox({ + password: true, + ignoreFocusOut: true, + prompt: "Paste the T3 Code pairing URL or pairing token", + placeHolder: "http://127.0.0.1:3773/pair#token=… or the bare token", + }); + if (input === undefined || input.trim() === "") return; + try { + const desktopServerUrl = await readDesktopServerUrl(); + const fallbackUrl = + serverCandidates(desktopServerUrl, configuration().serverUrl)[0]?.url ?? + configuration().serverUrl; + const classified = classifyPairingInput(input, fallbackUrl); + const { credential, httpBaseUrl } = + classified.kind === "url" + ? resolveRemotePairingTarget({ pairingUrl: classified.pairingUrl }) + : resolveRemotePairingTarget({ + host: classified.host, + pairingCode: classified.pairingCode, + }); + const { accessToken, expiresInSeconds } = await client.exchangePairingCredential( + httpBaseUrl, + credential, + ); + // Validate against the issuing endpoint before touching SecretStorage. + // Storing first would destroy a working credential whenever pairing + // failed, and the token is only ever valid for the server that issued + // it, so this must not fall back to the desktop or configured candidate. + await client.connect(httpBaseUrl, accessToken); + await client.waitForShell(); + await context.secrets.store(BEARER_TOKEN_SECRET, accessToken); + await context.secrets.store(BEARER_TOKEN_ENDPOINT_SECRET, httpBaseUrl); + await ensureIdentityClaim(); + void vscode.window.showInformationMessage( + `T3 Code paired with ${httpBaseUrl}, valid ${describeTokenExpiry(expiresInSeconds)}.`, + ); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + log(`pairing failed error=${message}`); + void vscode.window.showErrorMessage(`T3 Code pairing failed: ${message}`); + } + }), vscode.commands.registerCommand("t3Code.clearBearerToken", async () => { await context.secrets.delete(BEARER_TOKEN_SECRET); + await context.secrets.delete(BEARER_TOKEN_ENDPOINT_SECRET); void vscode.window.showInformationMessage("T3 Code bearer token cleared."); }), { dispose: () => void client.dispose() }, diff --git a/apps/vscode/src/pairing.test.ts b/apps/vscode/src/pairing.test.ts new file mode 100644 index 00000000000..437122ea2af --- /dev/null +++ b/apps/vscode/src/pairing.test.ts @@ -0,0 +1,97 @@ +import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; +import { describe, expect, it } from "vite-plus/test"; + +import { classifyPairingInput, describeTokenExpiry } from "./pairing.ts"; + +const FALLBACK_SERVER_URL = "http://127.0.0.1:3773"; + +describe("classifyPairingInput", () => { + it("treats input containing a scheme as a pairing URL", () => { + expect( + classifyPairingInput("http://127.0.0.1:3773/pair#token=abc123", FALLBACK_SERVER_URL), + ).toEqual({ kind: "url", pairingUrl: "http://127.0.0.1:3773/pair#token=abc123" }); + }); + + it("trims surrounding whitespace from a pairing URL", () => { + expect( + classifyPairingInput(" https://t3.example/pair#token=xyz ", FALLBACK_SERVER_URL), + ).toEqual({ kind: "url", pairingUrl: "https://t3.example/pair#token=xyz" }); + }); + + it("treats a bare token as a pairing code against the fallback host", () => { + expect(classifyPairingInput("pair-token-123", FALLBACK_SERVER_URL)).toEqual({ + kind: "code", + host: FALLBACK_SERVER_URL, + pairingCode: "pair-token-123", + }); + }); + + it("throws on empty or whitespace-only input", () => { + expect(() => classifyPairingInput("", FALLBACK_SERVER_URL)).toThrow( + "Enter a pairing URL or pairing token.", + ); + expect(() => classifyPairingInput(" \n\t ", FALLBACK_SERVER_URL)).toThrow( + "Enter a pairing URL or pairing token.", + ); + }); +}); + +describe("describeTokenExpiry", () => { + it("describes multi-day expiries in days", () => { + expect(describeTokenExpiry(2_592_000)).toBe("~30 days"); + expect(describeTokenExpiry(86_400)).toBe("~1 days"); + }); + + it("describes sub-day expiries in hours", () => { + expect(describeTokenExpiry(18_000)).toBe("~5 hours"); + expect(describeTokenExpiry(3_600)).toBe("~1 hours"); + }); + + it("describes sub-hour expiries in minutes", () => { + expect(describeTokenExpiry(300)).toBe("~5 minutes"); + }); + + it("clamps very short or invalid expiries", () => { + expect(describeTokenExpiry(30)).toBe("~1 minute"); + expect(describeTokenExpiry(0)).toBe("unknown duration"); + expect(describeTokenExpiry(-5)).toBe("unknown duration"); + expect(describeTokenExpiry(Number.NaN)).toBe("unknown duration"); + }); +}); + +describe("classifyPairingInput composed with resolveRemotePairingTarget", () => { + it("resolves a pairing URL to a credential and base URLs", () => { + const classified = classifyPairingInput( + "http://127.0.0.1:3773/pair#token=abc123", + FALLBACK_SERVER_URL, + ); + const resolved = + classified.kind === "url" + ? resolveRemotePairingTarget({ pairingUrl: classified.pairingUrl }) + : resolveRemotePairingTarget({ + host: classified.host, + pairingCode: classified.pairingCode, + }); + expect(resolved).toEqual({ + credential: "abc123", + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + }); + }); + + it("resolves a bare token against the fallback host", () => { + const classified = classifyPairingInput("abc123", FALLBACK_SERVER_URL); + const resolved = + classified.kind === "url" + ? resolveRemotePairingTarget({ pairingUrl: classified.pairingUrl }) + : resolveRemotePairingTarget({ + host: classified.host, + pairingCode: classified.pairingCode, + }); + expect(resolved).toEqual({ + credential: "abc123", + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + }); + }); +}); diff --git a/apps/vscode/src/pairing.ts b/apps/vscode/src/pairing.ts new file mode 100644 index 00000000000..0c8a1ce8f0d --- /dev/null +++ b/apps/vscode/src/pairing.ts @@ -0,0 +1,35 @@ +export type PairingInput = + | { readonly kind: "url"; readonly pairingUrl: string } + | { readonly kind: "code"; readonly host: string; readonly pairingCode: string }; + +/** + * Classify raw user input as either a full pairing URL (anything containing + * "://") or a bare pairing token to be resolved against a fallback server. + * The real parsing is left to `resolveRemotePairingTarget` from + * `@t3tools/shared/remote`, whose typed errors are fine to let bubble. + */ +export function classifyPairingInput(raw: string, fallbackServerUrl: string): PairingInput { + const trimmed = raw.trim(); + if (trimmed === "") throw new Error("Enter a pairing URL or pairing token."); + if (trimmed.includes("://")) return { kind: "url", pairingUrl: trimmed }; + return { kind: "code", host: fallbackServerUrl, pairingCode: trimmed }; +} + +const DAY_IN_SECONDS = 86_400; +const HOUR_IN_SECONDS = 3_600; +const MINUTE_IN_SECONDS = 60; + +/** Rough human-readable lifetime for the success message, e.g. "~30 days". */ +export function describeTokenExpiry(expiresInSeconds: number): string { + if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) return "unknown duration"; + if (expiresInSeconds >= DAY_IN_SECONDS) { + return `~${Math.round(expiresInSeconds / DAY_IN_SECONDS)} days`; + } + if (expiresInSeconds >= HOUR_IN_SECONDS) { + return `~${Math.round(expiresInSeconds / HOUR_IN_SECONDS)} hours`; + } + if (expiresInSeconds >= MINUTE_IN_SECONDS) { + return `~${Math.round(expiresInSeconds / MINUTE_IN_SECONDS)} minutes`; + } + return "~1 minute"; +} diff --git a/apps/vscode/src/serverResolution.test.ts b/apps/vscode/src/serverResolution.test.ts index 010aa425eaf..cbb2f700c45 100644 --- a/apps/vscode/src/serverResolution.test.ts +++ b/apps/vscode/src/serverResolution.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { serverCandidates } from "./serverResolution.ts"; +import { bearerTokenAppliesTo, serverCandidates } from "./serverResolution.ts"; describe("serverCandidates", () => { it("prefers the backend advertised by the local desktop runtime", () => { @@ -22,3 +22,48 @@ describe("serverCandidates", () => { ]); }); }); + +describe("paired endpoint candidates", () => { + it("tries the paired endpoint before desktop and configured ones", () => { + expect( + serverCandidates("http://127.0.0.1:3773", "http://127.0.0.1:8080", "http://paired.example"), + ).toEqual([ + { source: "paired", url: "http://paired.example/" }, + { source: "desktop", url: "http://127.0.0.1:3773/" }, + { source: "configured", url: "http://127.0.0.1:8080/" }, + ]); + }); + + it("does not list the paired endpoint twice when it is also the desktop one", () => { + expect( + serverCandidates("http://127.0.0.1:3773", "http://127.0.0.1:8080", "http://127.0.0.1:3773/"), + ).toEqual([ + { source: "paired", url: "http://127.0.0.1:3773/" }, + { source: "configured", url: "http://127.0.0.1:8080/" }, + ]); + }); + + it("is unchanged when nothing is paired", () => { + expect(serverCandidates("http://127.0.0.1:3773", "http://127.0.0.1:8080", null)).toEqual( + serverCandidates("http://127.0.0.1:3773", "http://127.0.0.1:8080"), + ); + }); +}); + +describe("bearerTokenAppliesTo", () => { + it("keeps a paired token away from every server but its issuer", () => { + // Pairing with B must not disclose B's token to the desktop or configured + // server A, which is what an unscoped token would do on the first candidate. + expect(bearerTokenAppliesTo("http://server-b.example", "http://server-a.example")).toBe(false); + expect(bearerTokenAppliesTo("http://server-b.example", "http://server-b.example")).toBe(true); + }); + + it("compares endpoints after normalisation rather than as raw strings", () => { + expect(bearerTokenAppliesTo("http://server-b.example", "http://server-b.example/")).toBe(true); + }); + + it("leaves hand-entered and pre-pinning tokens unscoped", () => { + expect(bearerTokenAppliesTo(null, "http://anything.example")).toBe(true); + expect(bearerTokenAppliesTo("", "http://anything.example")).toBe(true); + }); +}); diff --git a/apps/vscode/src/serverResolution.ts b/apps/vscode/src/serverResolution.ts index 8ada338919e..d9d409e2a4c 100644 --- a/apps/vscode/src/serverResolution.ts +++ b/apps/vscode/src/serverResolution.ts @@ -1,9 +1,9 @@ export interface ServerCandidate { - readonly source: "desktop" | "configured"; + readonly source: "paired" | "desktop" | "configured"; readonly url: string; } -function normalizeServerUrl(value: string | null): string | null { +export function normalizeServerUrl(value: string | null): string | null { if (value === null || value.trim() === "") return null; return new URL(value).toString(); } @@ -16,13 +16,38 @@ function normalizeServerUrl(value: string | null): string | null { export function serverCandidates( desktopServerUrl: string | null, configuredServerUrl: string, + pairedServerUrl: string | null = null, ): ReadonlyArray { + const paired = normalizeServerUrl(pairedServerUrl); const desktop = normalizeServerUrl(desktopServerUrl); const configured = normalizeServerUrl(configuredServerUrl); const candidates: Array = []; - if (desktop !== null) candidates.push({ source: "desktop", url: desktop }); - if (configured !== null && configured !== desktop) { + // An explicit pairing is the strongest signal of intent, and it is the only + // endpoint the paired bearer token may be sent to, so try it first. + if (paired !== null) candidates.push({ source: "paired", url: paired }); + if (desktop !== null && desktop !== paired) candidates.push({ source: "desktop", url: desktop }); + if (configured !== null && configured !== desktop && configured !== paired) { candidates.push({ source: "configured", url: configured }); } return candidates; } + +/** + * Whether a stored bearer token may be sent to `targetServerUrl`. + * + * A token obtained by pairing is issued by, and only valid for, the server that + * issued it. Offering it to the desktop or configured candidate would disclose + * one server's credential to another, so a scoped token is pinned to its issuer. + * + * A null scope means the token predates endpoint pinning or was entered by hand + * through "Set Server Bearer Token", where the user chose the destination + * themselves. Those stay unpinned so existing setups keep working. + */ +export function bearerTokenAppliesTo( + tokenEndpoint: string | null, + targetServerUrl: string, +): boolean { + const scope = normalizeServerUrl(tokenEndpoint); + if (scope === null) return true; + return scope === normalizeServerUrl(targetServerUrl); +} diff --git a/apps/vscode/src/t3Client.ts b/apps/vscode/src/t3Client.ts index 0c0173e7c0c..e730044efc7 100644 --- a/apps/vscode/src/t3Client.ts +++ b/apps/vscode/src/t3Client.ts @@ -342,6 +342,28 @@ export class T3Client { await this.connect(httpBaseUrl, session.access_token); } + /** + * Exchanges a pairing credential for a bearer access token without + * connecting, so callers can persist the token before establishing a + * session. + */ + async exchangePairingCredential( + httpBaseUrl: string, + credential: string, + ): Promise<{ accessToken: string; expiresInSeconds: number }> { + const startedAt = Date.now(); + this.#log(`pairing exchange start endpoint=${httpBaseUrl}`); + const session = await this.#runtime.runPromise( + bootstrapRemoteBearerSession({ + httpBaseUrl, + credential, + clientMetadata: { label: "T3 Code for VS Code", deviceType: "desktop" }, + }), + ); + this.#log(`pairing exchange complete in ${Date.now() - startedAt}ms endpoint=${httpBaseUrl}`); + return { accessToken: session.access_token, expiresInSeconds: session.expires_in }; + } + projectsForWorktree(worktreePath: string): ReadonlyArray { const shell = this.#shell; if (shell === null) return [];