From 538aa7d66097f8d62e47cbf9313b31ba5da08d5a Mon Sep 17 00:00:00 2001 From: bulgadev Date: Thu, 30 Jul 2026 18:46:57 -0300 Subject: [PATCH 1/2] feat(vscode): pair with pairing URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "T3 Code: Pair with Server…" command that accepts a full pairing URL (http://host:port/pair#token=…) or a bare pairing token, exchanges it for a bearer access token via the OAuth token-exchange endpoint, and stores it in SecretStorage so ensureConnected picks it up as the bearer tier. Removes the need to hand-exchange tokens with curl before using the Set Server Bearer Token command. Co-Authored-By: Claude Ported to fork/dev from the external pull request patroza/t3code#237, which was opened against the now-frozen fork/vscode overlay branch from a fork this repository cannot push to. Authorship is preserved on the commit; the trailer below keeps the credit attached through a squash merge, which is the only merge method fork/dev allows. Co-authored-by: bulgadev Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- apps/vscode/package.json | 6 ++ apps/vscode/src/extension.ts | 38 +++++++++++++ apps/vscode/src/pairing.test.ts | 97 +++++++++++++++++++++++++++++++++ apps/vscode/src/pairing.ts | 35 ++++++++++++ apps/vscode/src/t3Client.ts | 22 ++++++++ 5 files changed, 198 insertions(+) create mode 100644 apps/vscode/src/pairing.test.ts create mode 100644 apps/vscode/src/pairing.ts 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..6397df48a9b 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,6 +16,7 @@ import { readDesktopBootstrapCredential, readDesktopServerUrl, } from "./desktopFavorites.ts"; +import { classifyPairingInput, describeTokenExpiry } from "./pairing.ts"; import { serverCandidates } from "./serverResolution.ts"; import { T3ChatViewProvider } from "./chatViewProvider.ts"; import { T3Client } from "./t3Client.ts"; @@ -512,6 +514,42 @@ export function activate(context: vscode.ExtensionContext): void { ); } }), + 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, + ); + await context.secrets.store(BEARER_TOKEN_SECRET, accessToken); + await ensureConnected(); + 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); void vscode.window.showInformationMessage("T3 Code bearer token cleared."); 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/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 []; From 8f3401881fed1084aa9600edcaedb592ed11ad57 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:35:08 +0000 Subject: [PATCH 2/2] fix(vscode): pin the paired bearer token to its issuing server Review of #351 raised two problems with the pairing flow. The token is issued by the server named in the pairing URL, but the flow then called ensureConnected(), which walks the desktop and configured candidates and offers the bearer to each in turn. Pairing with server B therefore disclosed B's token to server A, whichever A happened to be first. Bearer tokens are now pinned to the endpoint that issued them. bearerTokenAppliesTo() gates every use, and the paired endpoint becomes the first connection candidate so pairing actually connects where the user pointed it. Pinning alone would have left the extension connecting to A without the token, which is a quieter kind of wrong. Tokens with no recorded endpoint stay unpinned: they predate this change or were entered by hand through Set Server Bearer Token, where the user chose the destination. Set and Clear both maintain the endpoint alongside the token, so a hand-entered token cannot inherit a previous pairing's scope. The token was also written to SecretStorage before the connection was proven, so a failed pairing destroyed a working credential. Exchange, connect to the issuing endpoint and wait for the shell first; store only once that succeeds. Nothing is written on failure, so there is no previous value to restore. Co-authored-by: bulgadev Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- apps/vscode/src/extension.ts | 25 +++++++++++-- apps/vscode/src/serverResolution.test.ts | 47 +++++++++++++++++++++++- apps/vscode/src/serverResolution.ts | 33 +++++++++++++++-- 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 6397df48a9b..b33d5e582ff 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -17,13 +17,16 @@ import { readDesktopServerUrl, } from "./desktopFavorites.ts"; import { classifyPairingInput, describeTokenExpiry } from "./pairing.ts"; -import { serverCandidates } from "./serverResolution.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; @@ -227,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; @@ -244,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) { @@ -509,6 +517,7 @@ 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.", ); @@ -539,8 +548,15 @@ export function activate(context: vscode.ExtensionContext): void { 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 ensureConnected(); + await context.secrets.store(BEARER_TOKEN_ENDPOINT_SECRET, httpBaseUrl); + await ensureIdentityClaim(); void vscode.window.showInformationMessage( `T3 Code paired with ${httpBaseUrl}, valid ${describeTokenExpiry(expiresInSeconds)}.`, ); @@ -552,6 +568,7 @@ export function activate(context: vscode.ExtensionContext): void { }), 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/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); +}