From e0f4836667316d9921ab91d02985ac30f0b6a102 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 03:05:28 +0000 Subject: [PATCH] fix(linux,remote): handle Wayland Vulkan, TLS pairing timeouts, and Tailscale operator denial Disable Chromium Vulkan on Linux Wayland to suppress the Ozone incompatibility warning. Classify Tailscale TLS probe failures and resolve them from remote:beginPairing instead of rejecting IPC. Latch tailscale_permission_denied on connect so Settings can show the operator grant instead of an unhandled handler error. Co-authored-by: Sambit Biswas --- docs/linux.md | 2 + main/bootstrap.ts | 2 + main/handlers/aiden-remote.test.ts | 11 +++ main/handlers/aiden-remote.ts | 24 +++-- main/linux-graphics-flags.ts | 12 +++ main/linux-wayland-vulkan-core.test.ts | 28 ++++++ main/linux-wayland-vulkan-core.ts | 14 +++ main/runtime-profile-bootstrap.test.ts | 11 ++- main/services/aiden-remote-service.test.ts | 72 +++++++++++++- main/services/aiden-remote-service.ts | 40 ++++++-- main/services/aiden-remote-tailscale.test.ts | 43 ++++++++ main/services/aiden-remote-tailscale.ts | 18 ++-- .../aiden-remote-tls-identity.test.ts | 41 +++++++- main/services/aiden-remote-tls-identity.ts | 97 +++++++++++++++++-- package.json | 4 +- .../settings/remote-access-settings.test.tsx | 4 + .../settings/remote-access-settings.tsx | 21 +++- renderer/lib/ipc.ts | 4 +- renderer/shared/aiden-remote.ts | 29 +++++- 19 files changed, 434 insertions(+), 43 deletions(-) create mode 100644 main/linux-graphics-flags.ts create mode 100644 main/linux-wayland-vulkan-core.test.ts create mode 100644 main/linux-wayland-vulkan-core.ts diff --git a/docs/linux.md b/docs/linux.md index a0f67c2e..9578df26 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -81,6 +81,8 @@ native subagents use the same contracts as macOS. Linux-specific integrations in - native distro window chrome and conventional File/Edit/View/Window/Help menus; - Ctrl-based app and global shortcuts, including the Wayland Global Shortcuts portal on desktops that implement it; +- Vulkan disabled on Wayland sessions to avoid Chromium's Ozone incompatibility + warning (pass `--ozone-platform=x11` to keep the default feature set); - editor discovery through `PATH`, Snap command locations, JetBrains Toolbox scripts, and common Flatpak application IDs; - opening folders with the default desktop file manager; diff --git a/main/bootstrap.ts b/main/bootstrap.ts index 38bc76fc..8570e2d2 100644 --- a/main/bootstrap.ts +++ b/main/bootstrap.ts @@ -5,12 +5,14 @@ import { initDiagnosticHealth } from "./services/diagnostic-health.js"; import { projectDiagnosticError } from "./services/diagnostics-contract.js"; import { pruneExpiredDiagnosticCrashDumps } from "./services/diagnostic-support.js"; import { installProcessDiagnostics } from "./services/process-diagnostics.js"; +import { applyLinuxGraphicsFlags } from "./linux-graphics-flags.js"; import { configureRuntimeProfile } from "./runtime-profile.js"; import { initSubagentRuntimeDiagnostics, SUBAGENT_RUNTIME_LOG_FILENAME, } from "./services/subagents/subagent-runtime-diagnostics.js"; +applyLinuxGraphicsFlags(); const runtimeProfile = configureRuntimeProfile(); const productionDiagnosticsDisabled = runtimeProfile.id === "production" && process.env.AIDEN_DISABLE_PRODUCTION_DIAGNOSTICS === "1"; diff --git a/main/handlers/aiden-remote.test.ts b/main/handlers/aiden-remote.test.ts index dea69e4a..0e04b5ea 100644 --- a/main/handlers/aiden-remote.test.ts +++ b/main/handlers/aiden-remote.test.ts @@ -52,3 +52,14 @@ test("saved endpoint repair is an explicit IPC action", async () => { assert.match(source, /ipcMain\.handle\("remote:moveToAvailablePort"/u); assert.match(source, /service\.moveToAvailablePort\(\)/u); }); + +test("pairing TLS probe failures resolve as a structured IPC outcome", async () => { + const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8"); + const handler = source.slice( + source.indexOf('ipcMain.handle("remote:beginPairing"'), + source.indexOf('ipcMain.handle("remote:closePairing"'), + ); + assert.match(handler, /AidenRemoteTlsEndpointError/u); + assert.match(handler, /ok: false as const/u); + assert.match(handler, /code: error\.code/u); +}); diff --git a/main/handlers/aiden-remote.ts b/main/handlers/aiden-remote.ts index 938a46f3..c1bef0da 100644 --- a/main/handlers/aiden-remote.ts +++ b/main/handlers/aiden-remote.ts @@ -1,6 +1,7 @@ import { BrowserWindow, dialog, ipcMain } from "../platform.js"; import { getAidenRemoteRuntime } from "../services/aiden-remote-service-main.js"; import type { AidenRemoteSettingsSnapshot } from "../../renderer/shared/aiden-remote.js"; +import { AidenRemoteTlsEndpointError } from "../services/aiden-remote-tls-identity.js"; import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; import { parseAidenRemoteConnectionMode, @@ -132,14 +133,21 @@ export function registerAidenRemoteHandlers(): void { ipcMain.handle("remote:beginPairing", async (_event, transport: unknown) => { const selectedTransport = parseAidenRemoteTransport(transport); const service = (await getAidenRemoteRuntime()).service; - const pairing = await service.beginPairing(selectedTransport); - return { - ...pairing.bootstrap, - pairingSessionId: pairing.sessionId, - qrPayload: pairing.qrPayload - ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport), - manualCode: pairing.manualCode, - }; + try { + const pairing = await service.beginPairing(selectedTransport); + return { + ...pairing.bootstrap, + pairingSessionId: pairing.sessionId, + qrPayload: pairing.qrPayload + ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport), + manualCode: pairing.manualCode, + }; + } catch (error) { + if (error instanceof AidenRemoteTlsEndpointError) { + return { ok: false as const, code: error.code, message: error.message }; + } + throw error; + } }); ipcMain.handle("remote:closePairing", async (_event, sessionId: unknown) => { diff --git a/main/linux-graphics-flags.ts b/main/linux-graphics-flags.ts new file mode 100644 index 00000000..04f84e10 --- /dev/null +++ b/main/linux-graphics-flags.ts @@ -0,0 +1,12 @@ +import { app } from "electron"; +import { shouldSuppressOzoneWaylandVulkan } from "./linux-wayland-vulkan-core.js"; + +export function applyLinuxGraphicsFlags(): void { + const ozonePlatformOverride = app.commandLine.hasSwitch("ozone-platform") + ? app.commandLine.getSwitchValue("ozone-platform") + : undefined; + if (!shouldSuppressOzoneWaylandVulkan(process.platform, process.env, ozonePlatformOverride)) { + return; + } + app.commandLine.appendSwitch("disable-features", "Vulkan"); +} diff --git a/main/linux-wayland-vulkan-core.test.ts b/main/linux-wayland-vulkan-core.test.ts new file mode 100644 index 00000000..5775e0bd --- /dev/null +++ b/main/linux-wayland-vulkan-core.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isWaylandSession, + shouldSuppressOzoneWaylandVulkan, +} from "./linux-wayland-vulkan-core.js"; + +test("Wayland session detection mirrors Chromium ozone auto-selection", () => { + assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "wayland" }), true); + assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "Wayland" }), true); + assert.equal(isWaylandSession({ WAYLAND_DISPLAY: "wayland-0" }), true); + assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "x11" }), false); + assert.equal(isWaylandSession({ XDG_SESSION_TYPE: "tty" }), false); + assert.equal(isWaylandSession({ WAYLAND_DISPLAY: " " }), false); + assert.equal(isWaylandSession({}), false); +}); + +test("Vulkan suppression is Linux Wayland only and respects an explicit X11 ozone override", () => { + const wayland = { XDG_SESSION_TYPE: "wayland" }; + assert.equal(shouldSuppressOzoneWaylandVulkan("linux", wayland), true); + assert.equal(shouldSuppressOzoneWaylandVulkan("linux", { WAYLAND_DISPLAY: "wayland-1" }), true); + assert.equal(shouldSuppressOzoneWaylandVulkan("linux", wayland, "x11"), false); + assert.equal(shouldSuppressOzoneWaylandVulkan("linux", wayland, "X11"), false); + assert.equal(shouldSuppressOzoneWaylandVulkan("linux", { XDG_SESSION_TYPE: "x11" }), false); + assert.equal(shouldSuppressOzoneWaylandVulkan("darwin", wayland), false); + assert.equal(shouldSuppressOzoneWaylandVulkan("win32", wayland), false); + assert.equal(shouldSuppressOzoneWaylandVulkan("linux", {}), false); +}); diff --git a/main/linux-wayland-vulkan-core.ts b/main/linux-wayland-vulkan-core.ts new file mode 100644 index 00000000..5b8d1d2c --- /dev/null +++ b/main/linux-wayland-vulkan-core.ts @@ -0,0 +1,14 @@ +export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.XDG_SESSION_TYPE?.trim().toLowerCase() === "wayland") return true; + return (env.WAYLAND_DISPLAY ?? "").trim().length > 0; +} + +export function shouldSuppressOzoneWaylandVulkan( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + ozonePlatformOverride?: string, +): boolean { + if (platform !== "linux") return false; + if (ozonePlatformOverride?.trim().toLowerCase() === "x11") return false; + return isWaylandSession(env); +} diff --git a/main/runtime-profile-bootstrap.test.ts b/main/runtime-profile-bootstrap.test.ts index 2a6fae9d..ce4f52d7 100644 --- a/main/runtime-profile-bootstrap.test.ts +++ b/main/runtime-profile-bootstrap.test.ts @@ -5,14 +5,23 @@ import test from "node:test"; test("runtime identity is configured before the main module can take its lock", () => { const bootstrap = readFileSync(new URL("./bootstrap.ts", import.meta.url), "utf8"); const main = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); + const graphicsFlags = bootstrap.indexOf("applyLinuxGraphicsFlags()"); const configure = bootstrap.indexOf("configureRuntimeProfile()"); const loadMain = bootstrap.indexOf('await import("./index.js")'); - assert.ok(configure >= 0 && loadMain > configure); + assert.ok(graphicsFlags >= 0 && configure > graphicsFlags && loadMain > configure); assert.match(main, /app\.requestSingleInstanceLock\(\)/u); assert.doesNotMatch(main, /app\.setName\(/u); }); +test("Linux Wayland launches disable Chromium Vulkan before the main module loads", () => { + const bootstrap = readFileSync(new URL("./bootstrap.ts", import.meta.url), "utf8"); + const flags = readFileSync(new URL("./linux-graphics-flags.ts", import.meta.url), "utf8"); + assert.match(bootstrap, /applyLinuxGraphicsFlags\(\)/u); + assert.match(flags, /appendSwitch\("disable-features", "Vulkan"\)/u); + assert.doesNotMatch(flags, /disableHardwareAcceleration/u); +}); + test("the Electron build enters through the profile bootstrap", () => { const buildScript = readFileSync( new URL("../scripts/build-electron.mjs", import.meta.url), diff --git a/main/services/aiden-remote-service.test.ts b/main/services/aiden-remote-service.test.ts index f3854f8b..3d333aaf 100644 --- a/main/services/aiden-remote-service.test.ts +++ b/main/services/aiden-remote-service.test.ts @@ -20,7 +20,7 @@ import { createDefaultAidenRemoteState, type AidenRemoteStateDocument, } from "./aiden-remote-state.js"; -import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; +import { loadOrCreateAidenRemoteTlsIdentity, AidenRemoteTlsEndpointError } from "./aiden-remote-tls-identity.js"; import type { AidenTailscaleStatus } from "./aiden-remote-tailscale-route.js"; import { revokeAidenRemoteRuntimeDevice } from "./aiden-remote-revocation.js"; @@ -138,6 +138,8 @@ interface FixtureOptions { transport: "lan" | "tailscale"; port: number; }) => Promise; + connectFailsWith?: string; + resolveTlsEndpointPin?: (hostname: string, port?: number) => Promise; } async function fixture( @@ -216,6 +218,7 @@ async function fixture( ) => { tailscale.connects += 1; tailscale.targets.push(target); + if (options.connectFailsWith) throw new Error(options.connectFailsWith); const ownership = { path: "/api/aiden/v1" as const, target }; await persistOwnership?.(ownership); return ownership; @@ -279,7 +282,8 @@ async function fixture( hostname: "Aiden-Test", bonjour, tailscale, - resolveTlsEndpointPin: async () => `sha256/${Buffer.alloc(32, 9).toString("base64")}`, + resolveTlsEndpointPin: options.resolveTlsEndpointPin + ?? (async () => `sha256/${Buffer.alloc(32, 9).toString("base64")}`), loadTlsIdentity: async () => { identityLoads += 1; return loadOrCreateAidenRemoteTlsIdentity({ @@ -1169,6 +1173,70 @@ test("Tailscale connect ownership persists only after connect and explicit disab } }); +test("Tailscale operator denial resolves as a settings latch instead of rejecting connect", async () => { + const app = await fixture({ mode: "both", connectFailsWith: "tailscale_permission_denied" }); + try { + await app.service.setEnabled(true); + await app.service.connectTailscale(); + const status = await app.service.status(); + assert.equal(status.tailscaleErrorCode, "permission_denied"); + assert.equal(status.tailscaleConnected, false); + assert.equal(app.persisted().tailscaleOwnership, undefined); + assert.equal(app.tailscale.connects, 1); + } finally { + await app.cleanup(); + } +}); + +test("Tailscale operator denial latch clears after a successful connect", async () => { + const app = await fixture({ mode: "both", connectFailsWith: "tailscale_permission_denied" }); + try { + await app.service.setEnabled(true); + await app.service.connectTailscale(); + assert.equal((await app.service.status()).tailscaleErrorCode, "permission_denied"); + app.tailscale.connect = async ( + target: string, + _ownership?: { path: "/api/aiden/v1"; target: string }, + persistOwnership?: (ownership: { path: "/api/aiden/v1"; target: string }) => Promise, + ) => { + const ownership = { path: "/api/aiden/v1" as const, target }; + await persistOwnership?.(ownership); + return ownership; + }; + await app.service.connectTailscale(); + assert.equal((await app.service.status()).tailscaleErrorCode, undefined); + assert.equal(app.persisted().tailscaleOwnership?.path, "/api/aiden/v1"); + } finally { + await app.cleanup(); + } +}); + +test("Tailscale pairing TLS probe failures stay classified and create no pairing session", async () => { + const app = await fixture({ + mode: "both", + tailscaleAssessment: { state: "owned" }, + resolveTlsEndpointPin: async () => { + throw new Error("Aiden Remote TLS endpoint timed out."); + }, + initial: (state) => { + state.tailscaleOwnership = { + path: "/api/aiden/v1", + target: `http://127.0.0.1:${state.lanPort + 1}/api/aiden/v1`, + }; + }, + }); + try { + await app.service.setEnabled(true); + await assert.rejects( + app.service.beginPairing("tailscale"), + (error: unknown) => error instanceof AidenRemoteTlsEndpointError && error.code === "timed_out", + ); + assert.equal(app.service.pairingStatus(), undefined); + } finally { + await app.cleanup(); + } +}); + test("Tailscale connect removes only a persisted origin-only route before canonical migration", async () => { const app = await fixture("both"); const legacyTarget = `http://127.0.0.1:${app.persisted().lanPort + 1}`; diff --git a/main/services/aiden-remote-service.ts b/main/services/aiden-remote-service.ts index 0ef93185..3657a9be 100644 --- a/main/services/aiden-remote-service.ts +++ b/main/services/aiden-remote-service.ts @@ -23,7 +23,7 @@ import type { AidenRemoteStateRegistry, } from "./aiden-remote-state.js"; import type { AidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; -import { fetchTlsServerSpkiSha256 } from "./aiden-remote-tls-identity.js"; +import { fetchTlsServerSpkiSha256, classifyAidenRemoteTlsEndpointFailure } from "./aiden-remote-tls-identity.js"; import type { AidenRemoteTailscaleController, AidenTailscaleConnectionStatus, @@ -471,6 +471,7 @@ export class AidenRemoteService { private activeState: AidenRemoteStateDocument | null = null; private lastError: string | undefined; private lastErrorCode: "remote_port_in_use" | undefined; + private tailscalePermissionDenied = false; private operationTail: Promise = Promise.resolve(); private settleRemoteApi: (() => Promise) | undefined; private readonly now: () => number; @@ -808,6 +809,7 @@ export class AidenRemoteService { await this.options.state.setEnabled(false); this.lastError = undefined; this.lastErrorCode = undefined; + this.tailscalePermissionDenied = false; if (disconnectError) throw disconnectError; }); } @@ -851,6 +853,7 @@ export class AidenRemoteService { await this.disconnectTailscaleInternal(current); } await this.options.state.setConnectionMode(connectionMode); + if (connectionMode === "lan") this.tailscalePermissionDenied = false; if (current.enabled) { if (!this.activeState || !this.lanServer || !this.tailscaleServer) { await this.startConfigured({ ...current, connectionMode }); @@ -933,11 +936,20 @@ export class AidenRemoteService { ); ownership = undefined; } - await this.options.tailscale.connect( - target, - ownership, - (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership), - ); + try { + await this.options.tailscale.connect( + target, + ownership, + (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership), + ); + this.tailscalePermissionDenied = false; + } catch (error) { + if (error instanceof Error && error.message === "tailscale_permission_denied") { + this.tailscalePermissionDenied = true; + return; + } + throw error; + } }); } @@ -1065,9 +1077,13 @@ export class AidenRemoteService { } if (!status.dnsName) throw new Error("Tailscale does not report a stable DNS name."); endpoint = `https://${status.dnsName}${AIDEN_REMOTE_BASE_PATH}`; - serverSpkiSha256 = await ( - this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256 - )(status.dnsName, 443); + try { + serverSpkiSha256 = await ( + this.options.resolveTlsEndpointPin ?? fetchTlsServerSpkiSha256 + )(status.dnsName, 443); + } catch (error) { + throw classifyAidenRemoteTlsEndpointFailure(error); + } } const pairing = this.pairing.begin(endpoint, serverSpkiSha256); try { @@ -1175,7 +1191,11 @@ export class AidenRemoteService { tailscaleConnected, tailscaleInstalled: tailscaleStatus.installed, tailscaleRouteState, - ...(tailscaleErrorCode ? { tailscaleErrorCode } : {}), + ...(this.tailscalePermissionDenied && !tailscaleConnected + ? { tailscaleErrorCode: "permission_denied" as const } + : tailscaleErrorCode + ? { tailscaleErrorCode } + : {}), pairedDeviceCount: state.devices.length, approvedRootCount: state.approvedRoots.length, ...(this.lastErrorCode ? { errorCode: this.lastErrorCode } : {}), diff --git a/main/services/aiden-remote-tailscale.test.ts b/main/services/aiden-remote-tailscale.test.ts index 2d07c91e..c7cfb85c 100644 --- a/main/services/aiden-remote-tailscale.test.ts +++ b/main/services/aiden-remote-tailscale.test.ts @@ -95,6 +95,16 @@ test("Linux operator denial maps to an actionable stable code", () => { }), "tailscale_permission_denied", ); + assert.equal( + tailscaleCommandErrorCode(new Error("tailscale_permission_denied")), + "tailscale_permission_denied", + ); + assert.equal( + tailscaleCommandErrorCode( + new Error("Access denied: serve config denied; run tailscale set --operator=$USER"), + ), + "tailscale_permission_denied", + ); assert.equal(tailscaleCommandErrorCode({ stderr: "permission denied" }), undefined); }); @@ -176,6 +186,39 @@ test("Tailscale controller connects and verifies only Aiden's route", async () = ]); }); +test("Linux operator denial leaves the route untouched with a typed permission code", async () => { + const calls: string[][] = []; + const runner: AidenTailscaleCommandRunner = { + run: async (args) => { + calls.push([...args]); + if (args[0] === "status") { + return JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "aiden.tailnet.ts.net.", Online: true }, + CertDomains: ["aiden.tailnet.ts.net"], + }); + } + if (args[0] === "serve" && args[1] === "status") return "{}"; + throw new Error("Access denied: serve config denied; run tailscale set --operator=$USER"); + }, + }; + const outcomes: unknown[] = []; + const controller = new AidenRemoteTailscaleController(runner, { + outcomeStore: { + begin: async (outcome) => { outcomes.push(outcome); }, + snapshot: async () => undefined, + commit: async () => undefined, + clear: async () => { outcomes.length = 0; }, + }, + }); + await assert.rejects( + controller.connect(target), + (error: unknown) => error instanceof Error && error.message === "tailscale_permission_denied", + ); + assert.equal(outcomes.length, 0); + assert.equal(calls.some((args) => args.includes("--set-path=/api/aiden/v1") && !args.includes("off")), true); +}); + test("Tailscale controller reports stable URL identity without mutating configuration", async () => { const app = fixture(); assert.deepEqual(await app.controller.status(), { diff --git a/main/services/aiden-remote-tailscale.ts b/main/services/aiden-remote-tailscale.ts index 35f0ab29..6453b426 100644 --- a/main/services/aiden-remote-tailscale.ts +++ b/main/services/aiden-remote-tailscale.ts @@ -157,10 +157,17 @@ export function tailscaleBinaryCandidates( export function tailscaleCommandErrorCode( error: unknown, ): "tailscale_permission_denied" | undefined { + if (error instanceof Error && error.message === "tailscale_permission_denied") { + return "tailscale_permission_denied"; + } const value = record(error); const stderr = typeof value?.stderr === "string" ? value.stderr : ""; - return stderr.includes("Access denied: serve config denied") && - stderr.includes("tailscale set --operator=") + const message = error instanceof Error + ? error.message + : typeof value?.message === "string" ? value.message : ""; + const haystack = `${stderr}\n${message}`; + return haystack.includes("Access denied: serve config denied") && + haystack.includes("tailscale set --operator=") ? "tailscale_permission_denied" : undefined; } @@ -714,12 +721,7 @@ export class AidenRemoteTailscaleController { else await this.clearExactRoute(); } catch (error) { commandFailed = true; - if ( - error instanceof Error && - error.message === "tailscale_permission_denied" - ) { - commandFailureCode = error.message; - } + commandFailureCode = tailscaleCommandErrorCode(error); } const observed = await this.serveStatusAfterMutation("tailscale_route_outcome_unknown"); const observedSnapshot = aidenTailscaleCanonicalRouteSnapshot(observed); diff --git a/main/services/aiden-remote-tls-identity.test.ts b/main/services/aiden-remote-tls-identity.test.ts index f18607d7..e9a6a5bf 100644 --- a/main/services/aiden-remote-tls-identity.test.ts +++ b/main/services/aiden-remote-tls-identity.test.ts @@ -4,7 +4,7 @@ import * as fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; +import { loadOrCreateAidenRemoteTlsIdentity, classifyAidenRemoteTlsEndpointFailure, AidenRemoteTlsEndpointError, fetchTlsServerSpkiSha256 } from "./aiden-remote-tls-identity.js"; async function temporaryDirectory(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), "aiden-remote-tls-")); @@ -67,3 +67,42 @@ test("TLS identity fails closed instead of silently rotating an incomplete ident await fs.rm(directory, { force: true, recursive: true }); } }); + +test("TLS endpoint probe failures classify into stable pairing codes", () => { + const timedOut = classifyAidenRemoteTlsEndpointFailure( + new Error("Aiden Remote TLS endpoint timed out."), + ); + assert.equal(timedOut.code, "timed_out"); + assert.match(timedOut.message, /did not respond/u); + + const refused = classifyAidenRemoteTlsEndpointFailure( + Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), { code: "ECONNREFUSED" }), + ); + assert.equal(refused.code, "unreachable"); + + const untrusted = classifyAidenRemoteTlsEndpointFailure( + Object.assign(new Error("unable to verify the first certificate"), { + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }), + ); + assert.equal(untrusted.code, "untrusted"); + + const invalid = classifyAidenRemoteTlsEndpointFailure( + new Error("Aiden Remote TLS endpoint is invalid."), + ); + assert.equal(invalid.code, "invalid_endpoint"); + + const already = new AidenRemoteTlsEndpointError("timed_out", "kept"); + assert.equal(classifyAidenRemoteTlsEndpointFailure(already), already); +}); + +test("invalid TLS endpoints fail closed without opening a socket", async () => { + await assert.rejects( + fetchTlsServerSpkiSha256("not a host"), + (error: unknown) => error instanceof AidenRemoteTlsEndpointError && error.code === "invalid_endpoint", + ); + await assert.rejects( + fetchTlsServerSpkiSha256("aiden.tailnet.ts.net", 0), + (error: unknown) => error instanceof AidenRemoteTlsEndpointError && error.code === "invalid_endpoint", + ); +}); diff --git a/main/services/aiden-remote-tls-identity.ts b/main/services/aiden-remote-tls-identity.ts index 06bcd687..2f0e3714 100644 --- a/main/services/aiden-remote-tls-identity.ts +++ b/main/services/aiden-remote-tls-identity.ts @@ -9,6 +9,7 @@ import * as fs from "node:fs/promises"; import path from "node:path"; import tls from "node:tls"; import { promisify } from "node:util"; +import type { AidenRemoteTlsEndpointErrorCode } from "../../renderer/shared/aiden-remote.js"; const execFileAsync = promisify(execFile); const DEFAULT_OPENSSL_PATH = "/usr/bin/openssl"; @@ -82,6 +83,76 @@ function spkiDigest(value: string | Buffer): string { return `sha256/${createHash("sha256").update(spki).digest("base64")}`; } +const TLS_PROBE_TIMEOUT_MS = 5_000; +const UNREACHABLE_SYSTEM_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "EPIPE", + "ETIMEDOUT", +]); + +export class AidenRemoteTlsEndpointError extends Error { + readonly code: AidenRemoteTlsEndpointErrorCode; + + constructor(code: AidenRemoteTlsEndpointErrorCode, message: string) { + super(message); + this.name = "AidenRemoteTlsEndpointError"; + this.code = code; + } +} + +function errorCode(error: unknown): string { + if (typeof error !== "object" || error === null || !("code" in error)) return ""; + return typeof error.code === "string" ? error.code : ""; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function classifyAidenRemoteTlsEndpointFailure(error: unknown): AidenRemoteTlsEndpointError { + if (error instanceof AidenRemoteTlsEndpointError) return error; + const code = errorCode(error); + const message = errorMessage(error); + if (code === "ERR_INVALID_ARG" || /TLS endpoint is invalid/u.test(message)) { + return new AidenRemoteTlsEndpointError( + "invalid_endpoint", + "The Tailscale DNS name is invalid.", + ); + } + if (/timed out/iu.test(message) || code === "ETIMEDOUT") { + return new AidenRemoteTlsEndpointError( + "timed_out", + "The Tailscale HTTPS endpoint did not respond. Confirm Serve still points at this Aiden profile, then try pairing again.", + ); + } + if ( + UNREACHABLE_SYSTEM_CODES.has(code) + || /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ECONNRESET/u.test(message) + ) { + return new AidenRemoteTlsEndpointError( + "unreachable", + "Aiden couldn't reach the Tailscale HTTPS endpoint. Confirm Tailscale is connected and the Serve route is current.", + ); + } + if ( + /certificate|UNABLE_TO_VERIFY|CERT_|ERR_TLS|altname|self[- ]signed/iu.test(`${code} ${message}`) + || /has no certificate/u.test(message) + ) { + return new AidenRemoteTlsEndpointError( + "untrusted", + "The Tailscale HTTPS certificate could not be verified. Check HTTPS on this Tailscale name, then try again.", + ); + } + return new AidenRemoteTlsEndpointError( + "unreachable", + "Aiden couldn't reach the Tailscale HTTPS endpoint. Confirm Tailscale is connected and the Serve route is current.", + ); +} + export async function fetchTlsServerSpkiSha256( hostname: string, port = 443, @@ -92,9 +163,15 @@ export async function fetchTlsServerSpkiSha256( port < 1 || port > 65_535 ) { - throw new Error("Aiden Remote TLS endpoint is invalid."); + throw new AidenRemoteTlsEndpointError( + "invalid_endpoint", + "The Tailscale DNS name is invalid.", + ); } return new Promise((resolve, reject) => { + const fail = (error: unknown) => { + reject(classifyAidenRemoteTlsEndpointFailure(error)); + }; const socket = tls.connect({ host: hostname, port, @@ -102,15 +179,23 @@ export async function fetchTlsServerSpkiSha256( rejectUnauthorized: true, }); const timeout = setTimeout(() => { - socket.destroy(new Error("Aiden Remote TLS endpoint timed out.")); - }, 5_000); + socket.destroy(new AidenRemoteTlsEndpointError( + "timed_out", + "The Tailscale HTTPS endpoint did not respond. Confirm Serve still points at this Aiden profile, then try pairing again.", + )); + }, TLS_PROBE_TIMEOUT_MS); socket.once("secureConnect", () => { try { const certificate = socket.getPeerCertificate(true); - if (!certificate.raw?.length) throw new Error("Aiden Remote TLS endpoint has no certificate."); + if (!certificate.raw?.length) { + throw new AidenRemoteTlsEndpointError( + "untrusted", + "The Tailscale HTTPS certificate could not be verified. Check HTTPS on this Tailscale name, then try again.", + ); + } resolve(spkiDigest(certificate.raw)); } catch (error) { - reject(error); + fail(error); } finally { clearTimeout(timeout); socket.end(); @@ -118,7 +203,7 @@ export async function fetchTlsServerSpkiSha256( }); socket.once("error", (error) => { clearTimeout(timeout); - reject(error); + fail(error); }); }); } diff --git a/package.json b/package.json index f79353f8..ac46d931 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "ios:asc-monitor": "node scripts/ios-asc-monitor.mjs", "ios:activitykit-process-proof": "node scripts/ios-live-activity-process-proof.mjs", "test:ios-release": "ruby ios/ci/select_testflight_build_number_test.rb && node --test scripts/check-ios-testflight-policy.test.mjs scripts/check-ios-app-store-metadata.test.mjs scripts/check-ios-shipping-target.test.mjs scripts/ios-asc-monitor.test.mjs scripts/ios-live-activity-process-proof.test.mjs", - "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs scripts/check-ci-policy.test.mjs scripts/patch-pi-oauth-branding.test.mjs scripts/publish-github-release.test.mjs", + "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/linux-wayland-vulkan-core.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs scripts/check-ci-policy.test.mjs scripts/patch-pi-oauth-branding.test.mjs scripts/publish-github-release.test.mjs", "test:scheduled": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-notification.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-script.test.ts main/services/schedule-tool.test.ts renderer/lib/scheduled-task-view.test.ts", "test:artificial-analysis": "tsx --test main/services/artificial-analysis-cache.test.ts main/services/artificial-analysis-runtime-core.test.ts main/services/artificial-analysis-catalog-core.test.ts main/services/provider-model-info-core.test.ts renderer/lib/settings-section.test.ts", "test:model-insights": "tsx --test main/services/openrouter-benchmark.test.ts main/services/models.test.ts main/services/provider-model-info-core.test.ts main/handlers/ipc-contract.test.ts", @@ -131,7 +131,7 @@ "dist": "node scripts/run-macos-distribution.mjs", "dist:linux": "npm run build && electron-builder --linux AppImage deb rpm --config.forceCodeSigning=false --config.directories.output=release/linux-distribution --publish never", "test:linux-native": "node scripts/build-worktree-remover.mjs && node scripts/build-worktree-remover.mjs --test && node --test scripts/worktree-remover.test.mjs && node scripts/build-bot-inbox-writer.mjs && node scripts/build-bot-inbox-writer.mjs --test && node --test scripts/bot-inbox-writer.test.mjs && node scripts/build-subagent-run-store.mjs && node scripts/build-subagent-run-store.mjs --test && node --test scripts/subagent-run-store.test.mjs && tsx --test main/services/subagents/subagent-run-store-io.test.ts && node scripts/build-subagent-file-mutator.mjs && node scripts/build-subagent-file-mutator.mjs --test && node --test scripts/subagent-file-mutator.test.mjs && node scripts/build-subagent-shell-runner.mjs && node scripts/build-subagent-shell-runner.mjs --test && tsx --test main/services/subagents/subagent-shell-runner-io.test.ts", - "test:linux-contracts": "tsx --test main/application-lifecycle-core.test.ts main/desktop-cli-core.test.ts main/services/application-menu-core.test.ts main/services/computer-use/platform.test.ts main/services/external-editors.test.ts main/services/profile-share-files.test.ts main/services/provider-key-policy.test.ts main/services/secure-storage-core.test.ts main/windows/main-window-options.test.ts main/windows/pill-window-platform.test.ts renderer/components/environment-subagents-contract.test.ts renderer/lib/command-system-core.test.ts renderer/shared/keybindings.test.ts && node --test scripts/native-c-build-core.test.mjs scripts/configure-electron-fuses.test.mjs scripts/verify-linux-package.test.mjs", + "test:linux-contracts": "tsx --test main/application-lifecycle-core.test.ts main/desktop-cli-core.test.ts main/linux-wayland-vulkan-core.test.ts main/services/application-menu-core.test.ts main/services/computer-use/platform.test.ts main/services/external-editors.test.ts main/services/profile-share-files.test.ts main/services/provider-key-policy.test.ts main/services/secure-storage-core.test.ts main/windows/main-window-options.test.ts main/windows/pill-window-platform.test.ts renderer/components/environment-subagents-contract.test.ts renderer/lib/command-system-core.test.ts renderer/shared/keybindings.test.ts && node --test scripts/native-c-build-core.test.mjs scripts/configure-electron-fuses.test.mjs scripts/verify-linux-package.test.mjs", "test:tailscale-live": "tsx scripts/aiden-remote-tailscale-live-acceptance.ts", "test:portable-config": "tsx --test main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/config-store-core.test.ts main/services/secret-map-core.test.ts" }, diff --git a/renderer/components/settings/remote-access-settings.test.tsx b/renderer/components/settings/remote-access-settings.test.tsx index 42b76cf3..26412d1f 100644 --- a/renderer/components/settings/remote-access-settings.test.tsx +++ b/renderer/components/settings/remote-access-settings.test.tsx @@ -62,6 +62,10 @@ test("Tailscale setup failures retain typed actionable remediation", () => { assert.match(source, /Open Tailscale and sign in/u); assert.match(source, /Enable HTTPS for this Tailscale device name/u); assert.match(source, /tailscale_permission_denied[\s\S]*?sudo tailscale set --operator=\$USER/u); + assert.match(source, /status\.tailscaleErrorCode === "permission_denied"/u); + assert.match(source, /Permission needed/u); + assert.match(source, /isAidenRemoteTlsEndpointFailure\(nextPairing\)/u); + assert.match(source, /toast\.error\(nextPairing\.message\)/u); assert.match(source, / undefined); return; diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 12b75a6a..8bccd0da 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -139,7 +139,7 @@ import { parseSkillCatalog, type SkillCatalogEntry } from "../shared/slash-comma import { rememberAppendReconciliationFailure } from "./append-reconciliation"; import type { AidenRemoteConnectionMode, - AidenRemotePairingBootstrapView, + AidenRemoteBeginPairingResult, AidenRemoteSettingsSnapshot, } from "../shared/aiden-remote"; import { @@ -499,7 +499,7 @@ export const aidenRemoteApi = { takeOverTailscale: (token: string) => invoke("remote:tailscaleTakeOver", token), beginPairing: (transport: "lan" | "tailscale") => - invoke("remote:beginPairing", transport), + invoke("remote:beginPairing", transport), closePairing: (pairingSessionId: string) => invoke<{ closed: boolean }>("remote:closePairing", pairingSessionId), revokeDevice: (deviceId: string) => diff --git a/renderer/shared/aiden-remote.ts b/renderer/shared/aiden-remote.ts index 907d3966..bc782a1c 100644 --- a/renderer/shared/aiden-remote.ts +++ b/renderer/shared/aiden-remote.ts @@ -19,7 +19,12 @@ export interface AidenRemoteStatusView { | "funnel_conflict" | "reconciliation_required" | "unavailable"; - tailscaleErrorCode?: "not_installed" | "not_connected" | "https_unavailable" | "status_unavailable"; + tailscaleErrorCode?: + | "not_installed" + | "not_connected" + | "https_unavailable" + | "status_unavailable" + | "permission_denied"; pairedDeviceCount: number; approvedRootCount: number; errorCode?: "remote_port_in_use"; @@ -77,3 +82,25 @@ export interface AidenRemotePairingBootstrapView { /** IPC-only 100-bit setup code. It is never exposed through remote status. */ manualCode: string; } + +export type AidenRemoteTlsEndpointErrorCode = + | "timed_out" + | "unreachable" + | "untrusted" + | "invalid_endpoint"; + +export interface AidenRemoteTlsEndpointFailure { + ok: false; + code: AidenRemoteTlsEndpointErrorCode; + message: string; +} + +export type AidenRemoteBeginPairingResult = + | AidenRemotePairingBootstrapView + | AidenRemoteTlsEndpointFailure; + +export function isAidenRemoteTlsEndpointFailure( + value: AidenRemoteBeginPairingResult, +): value is AidenRemoteTlsEndpointFailure { + return "ok" in value && value.ok === false; +}