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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 11 additions & 0 deletions main/handlers/aiden-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
24 changes: 16 additions & 8 deletions main/handlers/aiden-remote.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
12 changes: 12 additions & 0 deletions main/linux-graphics-flags.ts
Original file line number Diff line number Diff line change
@@ -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");
}
28 changes: 28 additions & 0 deletions main/linux-wayland-vulkan-core.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
14 changes: 14 additions & 0 deletions main/linux-wayland-vulkan-core.ts
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 10 additions & 1 deletion main/runtime-profile-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
72 changes: 70 additions & 2 deletions main/services/aiden-remote-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -138,6 +138,8 @@ interface FixtureOptions {
transport: "lan" | "tailscale";
port: number;
}) => Promise<void>;
connectFailsWith?: string;
resolveTlsEndpointPin?: (hostname: string, port?: number) => Promise<string>;
}

async function fixture(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<void>,
) => {
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}`;
Expand Down
40 changes: 30 additions & 10 deletions main/services/aiden-remote-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
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,
Expand Down Expand Up @@ -471,6 +471,7 @@
private activeState: AidenRemoteStateDocument | null = null;
private lastError: string | undefined;
private lastErrorCode: "remote_port_in_use" | undefined;
private tailscalePermissionDenied = false;
private operationTail: Promise<void> = Promise.resolve();
private settleRemoteApi: (() => Promise<void>) | undefined;
private readonly now: () => number;
Expand Down Expand Up @@ -808,6 +809,7 @@
await this.options.state.setEnabled(false);
this.lastError = undefined;
this.lastErrorCode = undefined;
this.tailscalePermissionDenied = false;
if (disconnectError) throw disconnectError;
});
}
Expand Down Expand Up @@ -851,6 +853,7 @@
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 });
Expand Down Expand Up @@ -933,11 +936,20 @@
);
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;
}
});
}

Expand Down Expand Up @@ -1065,9 +1077,13 @@
}
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 {
Expand Down Expand Up @@ -1162,7 +1178,7 @@
} else if (tailscaleStatus.installed) {
tailscaleRouteState = "available";
}
return {

Check failure on line 1181 in main/services/aiden-remote-service.ts

View workflow job for this annotation

GitHub Actions / Linux arm64

Type '{ error?: string | undefined; errorCode?: "remote_port_in_use" | undefined; pairedDeviceCount: number; approvedRootCount: number; tailscaleErrorCode: "permission_denied"; tailscaleConnected: boolean; ... 8 more ...; lanPort: number; } | { ...; } | { ...; }' is not assignable to type 'AidenRemoteServiceStatus'.

Check failure on line 1181 in main/services/aiden-remote-service.ts

View workflow job for this annotation

GitHub Actions / verify

Type '{ error?: string | undefined; errorCode?: "remote_port_in_use" | undefined; pairedDeviceCount: number; approvedRootCount: number; tailscaleErrorCode: "permission_denied"; tailscaleConnected: boolean; ... 8 more ...; lanPort: number; } | { ...; } | { ...; }' is not assignable to type 'AidenRemoteServiceStatus'.

Check failure on line 1181 in main/services/aiden-remote-service.ts

View workflow job for this annotation

GitHub Actions / Linux x64

Type '{ error?: string | undefined; errorCode?: "remote_port_in_use" | undefined; pairedDeviceCount: number; approvedRootCount: number; tailscaleErrorCode: "permission_denied"; tailscaleConnected: boolean; ... 8 more ...; lanPort: number; } | { ...; } | { ...; }' is not assignable to type 'AidenRemoteServiceStatus'.
enabled: state.enabled,
running: this.lanServer !== null || this.tailscaleServer !== null,
connectionMode: state.connectionMode,
Expand All @@ -1175,7 +1191,11 @@
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 } : {}),
Expand Down
Loading
Loading