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; +}