Skip to content

Commit 086aea2

Browse files
authored
🤖 feat: VS Code extension prefers oRPC with fallback (#1269)
This updates the VS Code extension to prefer a locally running mux server (localhost oRPC) for listing workspaces. If it can't connect, it prompts the user to either fix connection config or fall back to direct local-file reads. Adds: - `mux: Configure Connection` command - Settings: `mux.connectionMode` and `mux.serverUrl` - Auth token override stored in VS Code SecretStorage Validation: - `bun run --cwd vscode compile` - `make typecheck` - `make static-check` --- <details> <summary>📋 Implementation Plan</summary> # 🤖 Plan: VS Code extension → oRPC-first with safe local-file fallback ## Goal Make the VS Code extension prefer talking to a locally running mux instance over **localhost + oRPC** instead of reading mux’s config/session files directly. If the extension **cannot connect**, it should: 1) show a small prompt explaining why, and 2) let the user either **fix connection config** or **continue with local file access** (with a warning about possible conflicts). Net new product code: **~+250 LoC** (recommended approach). --- ## What exists today (repo facts) - Extension entrypoint: `vscode/src/extension.ts` registers `mux.openWorkspace`. - Workspace listing currently uses direct disk reads via shared node modules: - `vscode/src/muxConfig.ts` → `new Config().getAllWorkspaceMetadata()` (reads `~/.mux/config.json` etc) - `vscode/src/muxConfig.ts` → `readExtensionMetadata()` (reads `~/.mux/extensionMetadata.json`) - mux already exposes a local oRPC server: - HTTP: `POST {baseUrl}/orpc` - WS: `{baseUrl}/orpc/ws` - Health: `GET {baseUrl}/health` - mux server discovery already exists via: - Env: `MUX_SERVER_URL`, `MUX_SERVER_AUTH_TOKEN` - Lockfile: `~/.mux/server.lock` via `src/node/services/serverLockfile.ts` - Needed API calls are already defined: - `workspace.list -> FrontendWorkspaceMetadata[]` - `workspace.activity.list -> Record<workspaceId, WorkspaceActivitySnapshot>` (recency/streaming/lastModel) --- ## Recommended approach (oRPC-first + guided fallback) ### High-level behavior 1. On `mux.openWorkspace`, try to create an oRPC client using server discovery (settings/env/lockfile/default). 2. If connection succeeds, list workspaces via oRPC: - `workspace.list()` for metadata - `workspace.activity.list()` for recency/streaming - merge + sort by recency (same UX as today) 3. If connection fails, show a **single warning prompt per VS Code session**: - **Fix connection config** → launches a small configuration flow (settings + secret) - **Use local file access** → fall back to existing disk-based behavior for the rest of the session - **Cancel** → abort the command 4. Auth failures (server reachable but 401/unauthorized) still offer local-file fallback, but with a **strong warning**. ### Configuration storage (per your answers) - Add VS Code settings: - `mux.connectionMode`: `"auto" | "server-only" | "file-only"` (default: `"auto"`) - `mux.serverUrl`: optional string (overrides discovery) - Store auth token override in **VS Code SecretStorage**: - secret key: `mux.serverAuthToken` ### Discovery precedence Base URL: 1) VS Code setting `mux.serverUrl` (if set) 2) `process.env.MUX_SERVER_URL` 3) `~/.mux/server.lock` (`ServerLockfile.read()`) 4) `http://localhost:3000` Auth token: 1) SecretStorage override `mux.serverAuthToken` 2) `process.env.MUX_SERVER_AUTH_TOKEN` 3) lockfile token **only if** lockfile baseUrl matches selected baseUrl ### Connection test (fast + classified) - `GET {baseUrl}/health` with a short timeout (e.g. 750–1000ms) - If this fails: classify as **not running / unreachable**. - If health passes, call `orpc.general.ping("vscode")` (also timeout) - If this fails with unauthorized/401: classify as **auth misconfigured**. --- ## Implementation steps (files + concrete changes) ### 1) Add connection + client utilities **New:** `vscode/src/orpc/client.ts` - `createVscodeOrpcClient({ baseUrl, authToken }): ORPCClient` - Use `RPCLink` from `@orpc/client/fetch` and inject `Authorization: Bearer <token>`. - Defensive asserts on `baseUrl` shape. **New:** `vscode/src/orpc/discovery.ts` - `discoverServerConfig(): Promise<{ baseUrl: string; authToken?: string; source: ... }>` - Reads settings/env/lockfile, plus SecretStorage token. - Keep this mostly pure; isolate VS Code IO to small helpers. **New:** `vscode/src/orpc/connectionCheck.ts` - `checkServerReachable(baseUrl): Promise<"ok" | "unreachable">` - `checkAuth(client): Promise<"ok" | "unauthorized" | "error">` - Implement timeouts via `AbortController`. ### 2) Wire “oRPC workspace list” into the extension **Edit:** `vscode/src/muxConfig.ts` - Split existing logic into: - `getAllWorkspacesFromFiles()` (keep current disk behavior) - `getAllWorkspacesFromOrpc()`: - `client.workspace.list()` - `client.workspace.activity.list()` - map activity → `ExtensionMetadata` shape - merge onto workspace objects and sort by recency - `getAllWorkspaces()` becomes a mode switch: - `file-only` → always `getAllWorkspacesFromFiles()` - `server-only` → error if cannot connect - `auto` → prefer orpc; on failure show prompt; obey session choice ### 3) Add UX for “fix config vs file access” **Edit:** `vscode/src/extension.ts` - Add session-level state: - `let sessionPreferredMode: "orpc" | "file" | null = null;` - `let didShowFallbackPrompt = false;` - If orpc fails and `auto`: - `showWarningMessage` with actions: - **Fix connection config** - **Use local file access** - **Cancel** - When auth failure: adjust message text to include strong warning about conflicts. ### 4) Add a command to fix config **Edit:** `vscode/src/extension.ts` + `vscode/package.json` - Register new command: `mux.configureConnection` - Implementation: - Prompt for server URL (pre-filled from current setting). - Prompt for token (password input) and store in SecretStorage. - Optionally offer “Clear token” / “Clear URL override” via QuickPick. - From the fallback prompt, invoke this command. - After configuration, retry connection once. ### 5) Add VS Code settings contributions **Edit:** `vscode/package.json` - Add `contributes.configuration` section: - `mux.connectionMode` (enum) - `mux.serverUrl` (string) - Scope settings to **application/machine** (avoid workspace check-in risk). --- ## Validation plan - Build + typecheck: - `bun run -C vscode compile` - `make typecheck` - Manual scenarios: 1) mux running (lockfile present): extension uses oRPC and lists workspaces. 2) mux not running: prompt appears once; choosing file access works. 3) mux running but token wrong (set bad secret): prompt shows auth warning; file fallback still works. 4) `mux.connectionMode=file-only`: never attempts network. 5) `mux.connectionMode=server-only`: fails fast with actionable error. --- <details> <summary>Alternatives considered</summary> ### A) Server-side change: include activity in `workspace.list` - Add `includeActivity` param so VS Code makes a single RPC call. - Pros: fewer roundtrips. - Cons: touches server API + backwards-compat; more surface area than needed for first iteration. ### B) Don’t add VS Code settings - Only env vars + lockfile discovery. - “Fix config” would just open docs / show instructions. - Pros: minimal change. - Cons: doesn’t actually let users *fix* misconfig from within VS Code. </details> </details> --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_ --------- Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 3f74a6d commit 086aea2

File tree

6 files changed

+631
-36
lines changed

6 files changed

+631
-36
lines changed

vscode/package.json

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,43 @@
2222
"development"
2323
],
2424
"activationEvents": [
25-
"onCommand:mux.openWorkspace"
25+
"onCommand:mux.openWorkspace",
26+
"onCommand:mux.configureConnection"
2627
],
2728
"main": "./out/extension.js",
2829
"contributes": {
2930
"commands": [
3031
{
3132
"command": "mux.openWorkspace",
3233
"title": "mux: Open Workspace"
34+
},
35+
{
36+
"command": "mux.configureConnection",
37+
"title": "mux: Configure Connection"
38+
}
39+
],
40+
"configuration": {
41+
"title": "mux",
42+
"properties": {
43+
"mux.connectionMode": {
44+
"type": "string",
45+
"enum": [
46+
"auto",
47+
"server-only",
48+
"file-only"
49+
],
50+
"default": "auto",
51+
"scope": "machine",
52+
"description": "How the mux VS Code extension connects to mux (prefer server, require server, or use local files)."
53+
},
54+
"mux.serverUrl": {
55+
"type": "string",
56+
"default": "",
57+
"scope": "machine",
58+
"description": "Override the mux server URL (leave empty to auto-discover). Example: http://127.0.0.1:3000"
59+
}
3360
}
34-
]
61+
}
3562
},
3663
"scripts": {
3764
"vscode:prepublish": "bun run compile",
@@ -54,3 +81,4 @@
5481
"license": "AGPL-3.0-only",
5582
"packageManager": "[email protected]"
5683
}
84+

vscode/src/api/client.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { RPCLink } from "@orpc/client/fetch";
2+
import type { RouterClient } from "@orpc/server";
3+
import assert from "node:assert";
4+
5+
import { createClient } from "mux/common/orpc/client";
6+
import type { AppRouter } from "mux/node/orpc/router";
7+
8+
export type ApiClient = RouterClient<AppRouter>;
9+
10+
export interface ApiClientConfig {
11+
baseUrl: string;
12+
authToken?: string | undefined;
13+
}
14+
15+
function normalizeBaseUrl(baseUrl: string): string {
16+
assert(baseUrl.length > 0, "baseUrl must be non-empty");
17+
18+
const parsed = new URL(baseUrl);
19+
assert(
20+
parsed.protocol === "http:" || parsed.protocol === "https:",
21+
`Unsupported baseUrl protocol: ${parsed.protocol}`
22+
);
23+
24+
// URL.toString() includes a trailing slash for naked origins.
25+
return parsed.toString().replace(/\/$/, "");
26+
}
27+
28+
export function createApiClient(config: ApiClientConfig): ApiClient {
29+
assert(typeof config.baseUrl === "string", "baseUrl must be a string");
30+
31+
const normalizedBaseUrl = normalizeBaseUrl(config.baseUrl);
32+
33+
const link = new RPCLink({
34+
url: `${normalizedBaseUrl}/orpc`,
35+
async fetch(request, init) {
36+
const headers = new Headers(request.headers);
37+
if (config.authToken) {
38+
headers.set("Authorization", `Bearer ${config.authToken}`);
39+
}
40+
41+
return fetch(request.url, {
42+
body: await request.blob(),
43+
headers,
44+
method: request.method,
45+
signal: request.signal,
46+
...init,
47+
});
48+
},
49+
});
50+
51+
return createClient(link);
52+
}

vscode/src/api/connectionCheck.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import assert from "node:assert";
2+
3+
import type { ApiClient } from "./client";
4+
5+
export type ServerReachabilityResult =
6+
| { status: "ok" }
7+
| {
8+
status: "unreachable";
9+
error: string;
10+
};
11+
12+
export type AuthCheckResult =
13+
| { status: "ok" }
14+
| {
15+
status: "unauthorized";
16+
error: string;
17+
}
18+
| {
19+
status: "error";
20+
error: string;
21+
};
22+
23+
function normalizeBaseUrl(baseUrl: string): string {
24+
assert(baseUrl.length > 0, "baseUrl must be non-empty");
25+
return baseUrl.replace(/\/$/, "");
26+
}
27+
28+
async function promiseWithTimeout<T>(
29+
promise: Promise<T>,
30+
timeoutMs: number,
31+
label: string
32+
): Promise<T> {
33+
assert(timeoutMs > 0, "timeoutMs must be positive");
34+
35+
return new Promise<T>((resolve, reject) => {
36+
const timeout = setTimeout(() => {
37+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
38+
}, timeoutMs);
39+
40+
promise
41+
.then(resolve, reject)
42+
.finally(() => {
43+
clearTimeout(timeout);
44+
});
45+
});
46+
}
47+
48+
function formatError(error: unknown): string {
49+
return error instanceof Error ? error.message : String(error);
50+
}
51+
52+
function isUnauthorizedError(error: unknown): boolean {
53+
const msg = formatError(error).toLowerCase();
54+
return (
55+
msg.includes("unauthorized") ||
56+
msg.includes("401") ||
57+
msg.includes("auth token") ||
58+
msg.includes("authentication")
59+
);
60+
}
61+
62+
export async function checkServerReachable(
63+
baseUrl: string,
64+
options?: { timeoutMs?: number }
65+
): Promise<ServerReachabilityResult> {
66+
const timeoutMs = options?.timeoutMs ?? 1_000;
67+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
68+
69+
const controller = new AbortController();
70+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
71+
72+
try {
73+
const resp = await fetch(`${normalizedBaseUrl}/health`, {
74+
method: "GET",
75+
signal: controller.signal,
76+
});
77+
78+
if (!resp.ok) {
79+
return { status: "unreachable", error: `HTTP ${resp.status} from /health` };
80+
}
81+
82+
const data = (await resp.json()) as { status?: unknown };
83+
if (data.status !== "ok") {
84+
return {
85+
status: "unreachable",
86+
error: "Unexpected /health response",
87+
};
88+
}
89+
90+
return { status: "ok" };
91+
} catch (error) {
92+
return {
93+
status: "unreachable",
94+
error: formatError(error),
95+
};
96+
} finally {
97+
clearTimeout(timeout);
98+
}
99+
}
100+
101+
export async function checkAuth(
102+
client: ApiClient,
103+
options?: { timeoutMs?: number }
104+
): Promise<AuthCheckResult> {
105+
const timeoutMs = options?.timeoutMs ?? 1_000;
106+
107+
try {
108+
// Used both as an auth check and a basic liveness check.
109+
await promiseWithTimeout(client.general.ping("vscode"), timeoutMs, "API ping");
110+
return { status: "ok" };
111+
} catch (error) {
112+
if (isUnauthorizedError(error)) {
113+
return { status: "unauthorized", error: formatError(error) };
114+
}
115+
116+
return { status: "error", error: formatError(error) };
117+
}
118+
}

vscode/src/api/discovery.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as vscode from "vscode";
2+
import assert from "node:assert";
3+
4+
import { getMuxHome } from "mux/common/constants/paths";
5+
import { ServerLockfile } from "mux/node/services/serverLockfile";
6+
7+
export type ConnectionMode = "auto" | "server-only" | "file-only";
8+
9+
export interface DiscoveredServerConfig {
10+
baseUrl: string;
11+
authToken?: string | undefined;
12+
13+
// For debugging / UX messaging.
14+
baseUrlSource: "settings" | "env" | "lockfile" | "default";
15+
authTokenSource: "secret" | "env" | "lockfile" | "none";
16+
}
17+
18+
const SERVER_AUTH_TOKEN_SECRET_KEY = "mux.serverAuthToken";
19+
20+
function normalizeBaseUrl(baseUrl: string): string {
21+
assert(baseUrl.length > 0, "baseUrl must be non-empty");
22+
23+
const parsed = new URL(baseUrl);
24+
assert(
25+
parsed.protocol === "http:" || parsed.protocol === "https:",
26+
`Unsupported baseUrl protocol: ${parsed.protocol}`
27+
);
28+
29+
// URL.toString() includes a trailing slash for naked origins.
30+
return parsed.toString().replace(/\/$/, "");
31+
}
32+
33+
export function getConnectionModeSetting(): ConnectionMode {
34+
const config = vscode.workspace.getConfiguration("mux");
35+
const value = config.get<unknown>("connectionMode");
36+
37+
if (value === "auto" || value === "server-only" || value === "file-only") {
38+
return value;
39+
}
40+
41+
return "auto";
42+
}
43+
44+
export async function discoverServerConfig(
45+
context: vscode.ExtensionContext
46+
): Promise<DiscoveredServerConfig> {
47+
const config = vscode.workspace.getConfiguration("mux");
48+
const serverUrlOverrideRaw = config.get<string>("serverUrl")?.trim();
49+
50+
let lockfileData: { baseUrl: string; token: string } | null = null;
51+
try {
52+
const lockfile = new ServerLockfile(getMuxHome());
53+
const data = await lockfile.read();
54+
if (data) {
55+
lockfileData = { baseUrl: data.baseUrl, token: data.token };
56+
}
57+
} catch {
58+
// Ignore lockfile errors; we'll fall back to defaults.
59+
}
60+
61+
// Base URL precedence: settings -> env -> lockfile -> default.
62+
const envBaseUrl = process.env.MUX_SERVER_URL?.trim();
63+
64+
let baseUrlSource: DiscoveredServerConfig["baseUrlSource"] = "default";
65+
let baseUrlRaw = "http://localhost:3000";
66+
67+
if (serverUrlOverrideRaw) {
68+
baseUrlSource = "settings";
69+
baseUrlRaw = serverUrlOverrideRaw;
70+
} else if (envBaseUrl) {
71+
baseUrlSource = "env";
72+
baseUrlRaw = envBaseUrl;
73+
} else if (lockfileData) {
74+
baseUrlSource = "lockfile";
75+
baseUrlRaw = lockfileData.baseUrl;
76+
}
77+
78+
const baseUrl = normalizeBaseUrl(baseUrlRaw);
79+
80+
// Auth token precedence: secret storage -> env -> lockfile (only if same baseUrl).
81+
const secretToken = (await context.secrets.get(SERVER_AUTH_TOKEN_SECRET_KEY))?.trim();
82+
const envToken = process.env.MUX_SERVER_AUTH_TOKEN?.trim();
83+
84+
let authTokenSource: DiscoveredServerConfig["authTokenSource"] = "none";
85+
let authToken: string | undefined;
86+
87+
if (secretToken) {
88+
authTokenSource = "secret";
89+
authToken = secretToken;
90+
} else if (envToken) {
91+
authTokenSource = "env";
92+
authToken = envToken;
93+
} else if (lockfileData && normalizeBaseUrl(lockfileData.baseUrl) === baseUrl) {
94+
authTokenSource = "lockfile";
95+
authToken = lockfileData.token;
96+
}
97+
98+
return {
99+
baseUrl,
100+
authToken,
101+
baseUrlSource,
102+
authTokenSource,
103+
};
104+
}
105+
106+
export async function storeAuthTokenOverride(
107+
context: vscode.ExtensionContext,
108+
authToken: string
109+
): Promise<void> {
110+
await context.secrets.store(SERVER_AUTH_TOKEN_SECRET_KEY, authToken);
111+
}
112+
113+
export async function clearAuthTokenOverride(context: vscode.ExtensionContext): Promise<void> {
114+
await context.secrets.delete(SERVER_AUTH_TOKEN_SECRET_KEY);
115+
}

0 commit comments

Comments
 (0)