Skip to content
Merged
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
5 changes: 3 additions & 2 deletions docs/aiden-on-the-go-remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
2 changes: 1 addition & 1 deletion docs/plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 10 additions & 4 deletions main/services/aiden-remote-service-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,12 +327,18 @@ async function createRuntime(): Promise<AidenRemoteRuntime> {
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: {
Expand Down
105 changes: 101 additions & 4 deletions main/services/aiden-remote-tailscale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
const socket = createSocket({ type: "udp4", reuseAddr: false });
await new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -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]);
Expand All @@ -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([
Expand Down
64 changes: 56 additions & 8 deletions main/services/aiden-remote-tailscale.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,6 +39,23 @@ export interface AidenTailscaleCommandRunner {
run(args: readonly string[]): Promise<string>;
}

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<string | null>;
}

export interface AidenTailscaleConnectionStatus {
installed: boolean;
dnsName?: string;
Expand Down Expand Up @@ -107,6 +124,7 @@ export interface AidenRemoteTailscaleControllerOptions {
phase: "node" | "serve";
attempt: number;
final: boolean;
category: AidenTailscaleStatusReadFailureCategory;
}) => void;
}

Expand All @@ -129,9 +147,13 @@ function record(value: unknown): Record<string, unknown> | 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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -320,13 +342,27 @@ export async function resolveTailscaleBinary(): Promise<string | null> {
return null;
}

export async function createSystemTailscaleCommandRunner(): Promise<AidenTailscaleCommandRunner | null> {
const binary = await resolveTailscaleBinary();
export async function createSystemTailscaleCommandRunner(
options: AidenTailscaleSystemRunnerOptions = {},
): Promise<AidenTailscaleCommandRunner | null> {
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,
Expand All @@ -336,6 +372,17 @@ export async function createSystemTailscaleCommandRunner(): Promise<AidenTailsca
};
}

function statusReadFailureCategory(error: unknown): AidenTailscaleStatusReadFailureCategory {
if (error instanceof Error && error.message === "tailscale_status_invalid_response") {
return "invalid-response";
}
const details = error !== null && typeof error === "object"
? error as { code?: unknown; killed?: unknown }
: undefined;
if (details?.code === "ETIMEDOUT" || details?.killed === true) return "timed-out";
Comment thread
sambitcreate marked this conversation as resolved.
return "command-failed";
}

export class AidenRemoteTailscaleController {
private readonly now: () => number;
private readonly monotonicNow: () => number;
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions main/services/diagnostics-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]),
Expand Down
Loading