diff --git a/docs/aiden-on-the-go-remote-access.md b/docs/aiden-on-the-go-remote-access.md index f96aeb8d..958dd183 100644 --- a/docs/aiden-on-the-go-remote-access.md +++ b/docs/aiden-on-the-go-remote-access.md @@ -22,7 +22,7 @@ Tailscale supplies reachability and network encryption, but Aiden still requires 4. Review the exact command-equivalent route preview, then choose **Connect**. 5. Pair with **Pair over Tailscale** after the stable `https://…ts.net/api/aiden/v1` address appears. -Aiden owns only `/api/aiden/v1`, proxies it to the loopback-only HTTP listener's matching `/api/aiden/v1` base, and verifies the resulting route. The matching target base is required because Tailscale strips the public `--set-path` prefix before proxying. First-time connection works from an empty Serve configuration only after the node's exact Tailscale certificate domain proves HTTPS was already authorized. Aiden never enables Tailscale Funnel, never runs `tailscale serve reset`, never completes Tailscale authorization for you, and never changes unrelated Serve handlers. **Disconnect** removes only the exact route and target recorded by Aiden. A conflict is reported instead of being overwritten. +Aiden owns only `/api/aiden/v1`, proxies it to the loopback-only HTTP listener's matching `/api/aiden/v1` base, and verifies the resulting route. The matching target base is required because Tailscale strips the public `--set-path` prefix before proxying. On macOS, Aiden invokes Tailscale's shared app executable in its documented explicit CLI mode, so Finder and Dock launches do not depend on terminal environment variables. First-time connection works from an empty Serve configuration only after the node's exact Tailscale certificate domain proves HTTPS was already authorized. Aiden never enables Tailscale Funnel, never runs `tailscale serve reset`, never completes Tailscale authorization for you, and never changes unrelated Serve handlers. **Disconnect** removes only the exact route and target recorded by Aiden. A conflict is reported instead of being overwritten. ## Devices, credentials, and revocation @@ -52,6 +52,7 @@ If the selected local speech model is not installed, the mobile settings can ask - **Local device cannot find Aiden:** confirm both devices are on the same network, Local Network mode is selected, and local-network permission is enabled for Aiden On The Go. - **Certificate or pin changed:** do not bypass the warning. Verify the Mac, revoke the old device record, and pair again. - **Tailscale not found or disconnected:** open Tailscale on the Mac and confirm it reports a stable MagicDNS name. +- **Local service ready, Tailscale unavailable:** the listener is running, but Aiden could not verify a safe Serve route. Retry after confirming Tailscale is signed in; Aiden will not mutate a route it cannot inspect. - **Tailnet HTTPS unavailable:** complete Tailscale's HTTPS authorization flow, then retry Connect in Aiden. Aiden will not authorize it on your behalf. - **Serve conflict:** inspect the route shown in the error. Remove or relocate the conflicting handler yourself; Aiden will not take it over. - **A folder is missing:** add it from the Mac. The phone cannot submit an arbitrary path or approve a new browser root. @@ -61,4 +62,4 @@ If the selected local speech model is not installed, the mobile settings can ask - **Speech service is busy:** wait for the active transcription to finish and retry. The Mac permits one active transcription and one waiting request. - **Port already in use:** stop the other local service or repair the saved Remote Access configuration before enabling it again. -Remote Access diagnostics keep only closed route categories, outcome/status classes, bounded latency, and stable Aiden-owned error codes. Successful production traffic is reduced to daily aggregate counts; durable records never contain request IDs, device or instance suffixes, bearer credentials, pairing secrets, provider keys, request/response bodies, URLs, or filesystem paths. +Remote Access diagnostics keep only closed route categories, outcome/status classes, bounded latency, stable Aiden-owned error codes, and categorical Tailscale inspection phase/reason/attempt counts. Successful production traffic is reduced to daily aggregate counts; durable records never contain Tailscale command output, request IDs, device or instance suffixes, bearer credentials, pairing secrets, provider keys, request/response bodies, URLs, or filesystem paths. diff --git a/docs/plans/README.md b/docs/plans/README.md index 0d198b9e..674419a9 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -29,7 +29,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Completion note | | ---------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Aiden Remote Multi-Instance Hardening](completed/aiden-remote-multi-instance-hardening-plan.md) | Complete | Authenticated pairing completion, multi-device/Mac isolation, transactional listeners, exact Tailscale ownership, durable revocation, and physical-iPhone acceptance all pass. | +| [Aiden Remote Multi-Instance Hardening](completed/aiden-remote-multi-instance-hardening-plan.md) | Complete | Authenticated pairing completion, multi-device/Mac isolation, transactional listeners, explicit packaged Tailscale CLI mode, exact route ownership, durable revocation, and physical-iPhone acceptance all pass. | | [Bots Mode](completed/bots-mode-plan.md) | Complete | Reusable Pi-backed bots now have first-class conversations, authoritative personas, soft archive, and exact one-to-one Telegram DM/topic control. | | [Bot Avatar Studio](completed/bot-avatar-studio-plan.md) | Complete | Mouthless pastel faces now have live customization, crash-reconciled rollback storage, cancellable bounded Pi suggestions, and cohesive onboarding art. | | [Companion Vision Models](completed/companion-vision-model-plan.md) | Complete | Vision primaries keep the native fast path; text-only Bots use one explicit exact-bound companion through a current-chat-only tool across Mac and iOS. | diff --git a/docs/plans/completed/aiden-remote-multi-instance-hardening-plan.md b/docs/plans/completed/aiden-remote-multi-instance-hardening-plan.md index cc34ffa0..88766abc 100644 --- a/docs/plans/completed/aiden-remote-multi-instance-hardening-plan.md +++ b/docs/plans/completed/aiden-remote-multi-instance-hardening-plan.md @@ -180,3 +180,10 @@ Every implementation phase uses the same mandatory gate: - Settings now offers an explicit, confirmed **Use another port** recovery. It selects a complete available HTTPS/HTTP listener pair, persists it atomically, retains device credentials, and warns that LAN rediscovery may be required. - Recovery refuses to move while a Tailscale route mutation is pending or this profile still owns a Serve route, preventing an exact-path route from being orphaned. - The Tailscale companion target remains HTTP loopback behind Tailnet HTTPS. Separately, OpenAI-compatible model servers on private Tailnet addresses accept either HTTP or HTTPS and the setup UI now advertises HTTP directly. + +## Post-completion repair — 2026-08-28 + +- Packaged macOS launches force Tailscale's documented `TAILSCALE_BE_CLI=1` mode instead of relying on terminal-only `TERM` or `SHLVL` heuristics. Fixed executable resolution, route ownership, and the HTTPS/HTTP listener-port pair remain unchanged. +- Regression coverage reproduces Finder-style process environments and the previous zero-exit, non-JSON GUI response. Route inspection now classifies that response as unavailable rather than retaining its text. +- Production diagnostics retain only the inspection phase, closed failure category, and bounded attempt count. Tailscale output, device names, tailnet names, and paths are excluded. +- Remote Access distinguishes a ready local listener from verified Tailscale Serve readiness, so a failed inspection no longer presents the entire connection as ready. diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index 04d94627..c463d133 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -327,12 +327,18 @@ async function createRuntime(): Promise { const tailscale = new AidenRemoteTailscaleController( await createSystemTailscaleCommandRunner(), { - onStatusReadFailure: ({ phase, attempt, final }) => { + onStatusReadFailure: ({ phase, attempt, final, category }) => { if (!final) return; - writeRemoteLog({ + writeDiagnosticEvent({ level: "warn", - event: "tailscale_status_read_unavailable", - details: { phase, attempts: attempt }, + area: "remote", + event: "tailscale-status-read-unavailable", + outcome: "unavailable", + fields: { + tailscalePhase: phase, + failureCategory: category, + attempts: attempt, + }, }); }, outcomeStore: { diff --git a/main/services/aiden-remote-tailscale.test.ts b/main/services/aiden-remote-tailscale.test.ts index 581c15b8..cdc27575 100644 --- a/main/services/aiden-remote-tailscale.test.ts +++ b/main/services/aiden-remote-tailscale.test.ts @@ -4,12 +4,55 @@ import { createServer } from "node:net"; import test from "node:test"; import { AidenRemoteTailscaleController, + createSystemTailscaleCommandRunner, withAidenTailscaleRouteLock, type AidenTailscaleCommandRunner, + type AidenTailscaleStatusReadFailureCategory, } from "./aiden-remote-tailscale.js"; const target = "http://127.0.0.1:43177/api/aiden/v1"; +test("system Tailscale runner forces CLI mode for Finder-style production launches", async () => { + const executions: Array<{ + binary: string; + args: readonly string[]; + environment: NodeJS.ProcessEnv | undefined; + }> = []; + const runner = await createSystemTailscaleCommandRunner({ + environment: { + HOME: "/test-home", + TAILSCALE_BE_CLI: "0", + }, + resolveBinary: async () => "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + execute: async (binary, args, options) => { + executions.push({ binary, args: [...args], environment: options.env }); + if (options.env?.TAILSCALE_BE_CLI !== "1") { + return { stdout: "The Tailscale GUI failed to start." }; + } + return { + stdout: args[0] === "status" + ? JSON.stringify({ + Self: { DNSName: "aiden.tailnet.ts.net." }, + CertDomains: ["aiden.tailnet.ts.net"], + }) + : "{}", + }; + }, + }); + assert.ok(runner); + + const inspection = await new AidenRemoteTailscaleController(runner).inspectRoute(target); + assert.equal(inspection.connectionStatus.dnsName, "aiden.tailnet.ts.net"); + assert.equal(inspection.assessment.state, "available"); + assert.equal(executions.length, 2); + for (const execution of executions) { + assert.equal(execution.binary, "/Applications/Tailscale.app/Contents/MacOS/Tailscale"); + assert.equal(execution.environment?.HOME, "/test-home"); + assert.equal(execution.environment?.TERM, undefined); + assert.equal(execution.environment?.TAILSCALE_BE_CLI, "1"); + } +}); + async function availableLoopbackPort(): Promise { const socket = createSocket({ type: "udp4", reuseAddr: false }); await new Promise((resolve, reject) => { @@ -135,7 +178,12 @@ test("combined route inspection retries a transient CLI read and recovers", asyn test("combined route inspection fails closed after bounded CLI retries", async () => { const calls: string[][] = []; - const diagnostics: Array<{ phase: "node" | "serve"; attempt: number; final: boolean }> = []; + const diagnostics: Array<{ + phase: "node" | "serve"; + attempt: number; + final: boolean; + category: AidenTailscaleStatusReadFailureCategory; + }> = []; const controller = new AidenRemoteTailscaleController({ run: async (args) => { calls.push([...args]); @@ -149,12 +197,61 @@ test("combined route inspection fails closed after bounded CLI retries", async ( }); assert.equal(calls.length, 3); assert.deepEqual(diagnostics, [ - { phase: "node", attempt: 1, final: false }, - { phase: "node", attempt: 2, final: false }, - { phase: "node", attempt: 3, final: true }, + { phase: "node", attempt: 1, final: false, category: "command-failed" }, + { phase: "node", attempt: 2, final: false, category: "command-failed" }, + { phase: "node", attempt: 3, final: true, category: "command-failed" }, ]); }); +test("combined route inspection categorizes zero-exit non-JSON CLI output without retaining it", async () => { + const diagnostics: Array<{ + phase: "node" | "serve"; + attempt: number; + final: boolean; + category: AidenTailscaleStatusReadFailureCategory; + }> = []; + const controller = new AidenRemoteTailscaleController({ + run: async () => "The Tailscale GUI failed to start with private details.", + }, { onStatusReadFailure: (input) => diagnostics.push(input) }); + + assert.equal((await controller.inspectRoute(target)).assessment.errorCode, "status_unavailable"); + assert.deepEqual(diagnostics, [ + { phase: "node", attempt: 1, final: false, category: "invalid-response" }, + { phase: "node", attempt: 2, final: false, category: "invalid-response" }, + { phase: "node", attempt: 3, final: true, category: "invalid-response" }, + ]); + assert.doesNotMatch(JSON.stringify(diagnostics), /private details/u); +}); + +test("combined route inspection categorizes both Node CLI timeout shapes", async () => { + const timeoutErrors = [ + Object.assign(new Error("deadline exceeded"), { code: "ETIMEDOUT" }), + Object.assign(new Error("process terminated"), { killed: true }), + ]; + + for (const timeoutError of timeoutErrors) { + const diagnostics: Array<{ + phase: "node" | "serve"; + attempt: number; + final: boolean; + category: AidenTailscaleStatusReadFailureCategory; + }> = []; + const controller = new AidenRemoteTailscaleController({ + run: async () => { + throw timeoutError; + }, + }, { onStatusReadFailure: (input) => diagnostics.push(input) }); + + assert.equal((await controller.inspectRoute(target)).assessment.errorCode, "status_unavailable"); + assert.deepEqual(diagnostics, [ + { phase: "node", attempt: 1, final: false, category: "timed-out" }, + { phase: "node", attempt: 2, final: false, category: "timed-out" }, + { phase: "node", attempt: 3, final: true, category: "timed-out" }, + ]); + assert.doesNotMatch(JSON.stringify(diagnostics), /deadline exceeded|process terminated/u); + } +}); + test("concurrent settings reads share one node and Serve snapshot", async () => { const app = fixture(); const [status, inspection] = await Promise.all([ diff --git a/main/services/aiden-remote-tailscale.ts b/main/services/aiden-remote-tailscale.ts index 314cfa3e..10a31d85 100644 --- a/main/services/aiden-remote-tailscale.ts +++ b/main/services/aiden-remote-tailscale.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { createSocket } from "node:dgram"; import * as fs from "node:fs/promises"; @@ -39,6 +39,23 @@ export interface AidenTailscaleCommandRunner { run(args: readonly string[]): Promise; } +export type AidenTailscaleStatusReadFailureCategory = + | "command-failed" + | "invalid-response" + | "timed-out"; + +export type AidenTailscaleCommandExecutor = ( + binary: string, + args: readonly string[], + options: ExecFileOptionsWithStringEncoding, +) => Promise<{ stdout: string }>; + +export interface AidenTailscaleSystemRunnerOptions { + environment?: NodeJS.ProcessEnv; + execute?: AidenTailscaleCommandExecutor; + resolveBinary?: () => Promise; +} + export interface AidenTailscaleConnectionStatus { installed: boolean; dnsName?: string; @@ -107,6 +124,7 @@ export interface AidenRemoteTailscaleControllerOptions { phase: "node" | "serve"; attempt: number; final: boolean; + category: AidenTailscaleStatusReadFailureCategory; }) => void; } @@ -129,9 +147,13 @@ function record(value: unknown): Record | null { function parseBoundedJson(serialized: string, label: string): unknown { if (Buffer.byteLength(serialized, "utf8") > MAX_STATUS_BYTES) { - throw new Error(`${label}_too_large`); + throw new Error("tailscale_status_invalid_response"); + } + try { + return parseAidenRemoteJson(serialized, label); + } catch { + throw new Error("tailscale_status_invalid_response"); } - return parseAidenRemoteJson(serialized, label); } function normalizeDnsName(value: unknown): string | undefined { @@ -160,7 +182,7 @@ function parseNodeStatus(serialized: string): AidenTailscaleNodeStatus { function parseServeStatus(serialized: string): AidenTailscaleStatus { const value = parseBoundedJson(serialized, "Tailscale Serve status"); - if (!record(value)) throw new Error("tailscale_status_invalid"); + if (!record(value)) throw new Error("tailscale_status_invalid_response"); return value as AidenTailscaleStatus; } @@ -320,13 +342,27 @@ export async function resolveTailscaleBinary(): Promise { return null; } -export async function createSystemTailscaleCommandRunner(): Promise { - const binary = await resolveTailscaleBinary(); +export async function createSystemTailscaleCommandRunner( + options: AidenTailscaleSystemRunnerOptions = {}, +): Promise { + const binary = await (options.resolveBinary ?? resolveTailscaleBinary)(); if (!binary) return null; + const execute = options.execute ?? (async (command, args, execOptions) => { + const { stdout } = await execFileAsync(command, [...args], execOptions); + return { stdout }; + }); + const environment = options.environment ?? process.env; return { run: async (args) => { - const { stdout } = await execFileAsync(binary, [...args], { + const { stdout } = await execute(binary, args, { encoding: "utf8", + env: { + ...environment, + // Tailscale's macOS app and CLI share one executable. Finder-launched + // apps do not inherit TERM/SHLVL, so force the documented CLI mode + // instead of relying on Tailscale's terminal-environment heuristic. + TAILSCALE_BE_CLI: "1", + }, maxBuffer: MAX_STATUS_BYTES, timeout: 15_000, windowsHide: true, @@ -336,6 +372,17 @@ export async function createSystemTailscaleCommandRunner(): Promise number; private readonly monotonicNow: () => number; @@ -399,12 +446,13 @@ export class AidenRemoteTailscaleController { phase = "serve"; const serveStatus = await this.serveStatus(); return this.connectionStatus(nodeStatus, serveStatus); - } catch { + } catch (error) { try { this.onStatusReadFailure?.({ phase, attempt, final: attempt === STATUS_READ_ATTEMPTS, + category: statusReadFailureCategory(error), }); } catch { // Diagnostics must never change the fail-closed inspection result. diff --git a/main/services/diagnostics-contract.test.ts b/main/services/diagnostics-contract.test.ts index e43b4cd9..07ec5b39 100644 --- a/main/services/diagnostics-contract.test.ts +++ b/main/services/diagnostics-contract.test.ts @@ -100,6 +100,31 @@ test("categorical fields reject grammar-valid but unregistered strings", () => { }), { platform: "darwin", arch: "arm64" }); }); +test("Tailscale status diagnostics retain only closed failure categories", () => { + const event = createDiagnosticEvent( + { + level: "warn", + area: "remote", + event: "tailscale-status-read-unavailable", + outcome: "unavailable", + fields: { + tailscalePhase: "serve", + failureCategory: "invalid-response", + attempts: 3, + commandOutput: "The Tailscale GUI failed at /Users/private/tailnet.ts.net", + }, + }, + sessionId, + ); + + assert.deepEqual(event.fields, { + tailscalePhase: "serve", + failureCategory: "invalid-response", + attempts: 3, + }); + assert.doesNotMatch(JSON.stringify(event), /Users|tailnet\.ts\.net/u); +}); + test("the closed field vocabulary keeps hostile oversized inputs below the envelope cap", () => { const fields = Object.fromEntries( Array.from({ length: 16 }, (_, index) => [`field${index}`, "x".repeat(240)]), diff --git a/main/services/diagnostics-contract.ts b/main/services/diagnostics-contract.ts index 19bc8999..d96002a5 100644 --- a/main/services/diagnostics-contract.ts +++ b/main/services/diagnostics-contract.ts @@ -69,6 +69,7 @@ export const DIAGNOSTIC_BASE_EVENT_NAMES = [ "electron-ready", "remote-request-failed", "remote-request-slow", + "tailscale-status-read-unavailable", "process-monitor-installed", "uncaught-exception", "node-warning", @@ -193,6 +194,7 @@ const BOOLEAN_FIELDS = new Set(["isMainFrame", "retryable", "snapshotFailed", "t const EXPORT_OMITTED_FIELDS = new Set(["legacyScope", "message"]); const ENUM_STRING_FIELDS: Readonly>> = { arch: new Set(["arm64", "ia32", "universal", "unknown", "x64"]), + failureCategory: new Set(["command-failed", "invalid-response", "timed-out"]), origin: new Set(["uncaughtException", "unhandledRejection", "unknown"]), platform: new Set(["aix", "android", "darwin", "freebsd", "haiku", "linux", "openbsd", "sunos", "unknown", "win32"]), processType: new Set(["Browser", "GPU", "Pepper Plugin", "Renderer", "Utility", "Zygote", "unknown"]), @@ -243,6 +245,7 @@ const ENUM_STRING_FIELDS: Readonly>> = { signal: new Set(["SIGHUP", "SIGINT", "SIGTERM"]), statusClass: new Set(["2xx", "4xx", "5xx"]), storeClass: new Set(["cache", "chat", "config", "health", "journal", "settings", "subagent", "unknown", "usage"]), + tailscalePhase: new Set(["node", "serve"]), }; function normalizedStringField(key: string, value: string): string | undefined { diff --git a/package-lock.json b/package-lock.json index 52ad015c..85c14da0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aiden-agent", - "version": "0.35.0", + "version": "0.35.76", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aiden-agent", - "version": "0.35.0", + "version": "0.35.76", "hasInstallScript": true, "dependencies": { "@earendil-works/pi-agent-core": "0.80.10", diff --git a/package.json b/package.json index d22ea03f..1a36bdc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aiden-agent", - "version": "0.35.0", + "version": "0.35.76", "private": true, "description": "A macOS AI workspace agent for local and hosted models", "keywords": [ diff --git a/renderer/components/settings/remote-access-settings.test.tsx b/renderer/components/settings/remote-access-settings.test.tsx index e65b53a9..0f50ffad 100644 --- a/renderer/components/settings/remote-access-settings.test.tsx +++ b/renderer/components/settings/remote-access-settings.test.tsx @@ -62,6 +62,9 @@ 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, / { diff --git a/renderer/components/settings/remote-access-settings.tsx b/renderer/components/settings/remote-access-settings.tsx index b244b02c..11d6e730 100644 --- a/renderer/components/settings/remote-access-settings.tsx +++ b/renderer/components/settings/remote-access-settings.tsx @@ -504,6 +504,15 @@ export function RemoteAccessSettings() { error: status.error, activeDeviceCount: groups.active.length, }); + const localServiceOnly = status.running + && transportAllowsTailscale + && !status.tailscaleConnected; + const connectionSummary = localServiceOnly + ? `Local ready · ${tailscalePresentation.badge}` + : status.running ? "Ready" : summary; + const technicalStatus = localServiceOnly + ? "Local service ready" + : status.running ? "Ready" : summary; return ( <> @@ -658,7 +667,7 @@ export function RemoteAccessSettings() { )} - {status.running ? "Ready" : summary} + {technicalStatus} {status.lanEndpoint ? (