Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 58 additions & 3 deletions apps/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -225,9 +230,14 @@ export function activate(context: vscode.ExtensionContext): void {
const ensureConnected = async (): Promise<void> => {
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<void> => {
if (bearerToken !== undefined && bearerToken !== "") {
if (
bearerToken !== undefined &&
bearerToken !== "" &&
bearerTokenAppliesTo(bearerEndpoint, serverUrl)
) {
try {
await client.connect(serverUrl, bearerToken);
return;
Expand All @@ -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) {
Expand Down Expand Up @@ -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() },
Expand Down
97 changes: 97 additions & 0 deletions apps/vscode/src/pairing.test.ts
Original file line number Diff line number Diff line change
@@ -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/",
});
});
});
35 changes: 35 additions & 0 deletions apps/vscode/src/pairing.ts
Original file line number Diff line number Diff line change
@@ -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";
}
47 changes: 46 additions & 1 deletion apps/vscode/src/serverResolution.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
});
33 changes: 29 additions & 4 deletions apps/vscode/src/serverResolution.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Expand All @@ -16,13 +16,38 @@ function normalizeServerUrl(value: string | null): string | null {
export function serverCandidates(
desktopServerUrl: string | null,
configuredServerUrl: string,
pairedServerUrl: string | null = null,
): ReadonlyArray<ServerCandidate> {
const paired = normalizeServerUrl(pairedServerUrl);
const desktop = normalizeServerUrl(desktopServerUrl);
const configured = normalizeServerUrl(configuredServerUrl);
const candidates: Array<ServerCandidate> = [];
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);
}
Loading
Loading