feat(FN-2519): add remote tunnel manager for settings sync

- Add remote-access contracts, provider adapters, and a tunnel process manager with lifecycle handling
- Wire tunnel manager into ProjectEngine startup/shutdown flow and export new remote-access modules
- Update settings modal UX for remote auth URLs, including wrapping and related UI test coverage
- Document tunnel manager behavior and remote settings sync details in architecture, CLI, and settings docs
This commit is contained in:
Fusion
2026-04-26 02:20:28 -07:00
committed by gsxdsm
parent 7527a7ddaf
commit cb1da0c659
16 changed files with 1234 additions and 28 deletions

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
import { aiMergeTask } from "../merger.js";
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
@@ -234,6 +235,55 @@ describe("ProjectEngine auto-summarize wiring", () => {
});
});
describe("ProjectEngine remote tunnel manager wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
const mockStore = createMockStore(baseSettings);
mocks.currentStore = mockStore.store;
});
it("is unavailable before start and available after start", async () => {
const engine = createEngine();
expect(engine.getRemoteTunnelManager()).toBeUndefined();
await engine.start();
expect(engine.getRemoteTunnelManager()).toBeInstanceOf(TunnelProcessManager);
await engine.stop();
expect(engine.getRemoteTunnelManager()).toBeUndefined();
});
it("calls tunnel manager stop once during shutdown", async () => {
const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockResolvedValueOnce(undefined);
const engine = createEngine();
await engine.start();
await engine.stop();
expect(stopSpy).toHaveBeenCalledTimes(1);
stopSpy.mockRestore();
});
it("warns when tunnel manager shutdown fails and clears manager reference", async () => {
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockRejectedValueOnce(new Error("tunnel stop failed"));
const engine = createEngine();
await engine.start();
await engine.stop();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Tunnel process manager stop failed"),
);
expect(engine.getRemoteTunnelManager()).toBeUndefined();
stopSpy.mockRestore();
warnSpy.mockRestore();
});
});
describe("ProjectEngine shutdown merge handling", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -0,0 +1,55 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { getTunnelProviderAdapter, redactTunnelText } from "../remote-access/provider-adapters.js";
describe("remote-access provider adapters", () => {
it("builds redacted command previews and masks sensitive values", () => {
const adapter = getTunnelProviderAdapter("cloudflare");
const command = adapter.buildCommand({
provider: "cloudflare",
executablePath: "cloudflared",
args: ["tunnel", "--token", "very-secret-token"],
tokenEnvVar: "CLOUDFLARED_TOKEN",
env: {
CLOUDFLARED_TOKEN: "very-secret-token",
},
});
expect(command.redactedPreview).toContain("[REDACTED]");
expect(command.redactedPreview).not.toContain("very-secret-token");
expect(redactTunnelText("token=very-secret-token", command.sensitiveValues)).toBe("token=[REDACTED]");
});
it("fails config validation when token env var reference is missing", () => {
const adapter = getTunnelProviderAdapter("tailscale");
expect(() =>
adapter.buildCommand({
provider: "tailscale",
executablePath: "tailscale",
args: ["serve", "status"],
tokenEnvVar: "TS_AUTHKEY",
}),
).toThrow(/invalid_config:missing credential in env var TS_AUTHKEY/);
});
it("validates cloudflare credentialsPath when path is provided", () => {
const tempDir = mkdtempSync(join(tmpdir(), "fn-remote-access-"));
const credentialsPath = join(tempDir, "credentials.json");
writeFileSync(credentialsPath, "{}", "utf8");
try {
const adapter = getTunnelProviderAdapter("cloudflare");
const command = adapter.buildCommand({
provider: "cloudflare",
executablePath: "cloudflared",
args: ["tunnel", "run"],
credentialsPath,
});
expect(command.command).toBe("cloudflared");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,186 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
import type { TunnelProviderConfig } from "../remote-access/types.js";
class FakeChildProcess extends EventEmitter {
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
readonly stdin = null;
readonly stdio = [null, this.stdout, this.stderr] as const;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
constructor(public readonly pid: number) {
super();
}
emitStdout(line: string): void {
this.stdout.write(`${line}\n`);
}
emitStderr(line: string): void {
this.stderr.write(`${line}\n`);
}
close(code: number | null = 0, signal: NodeJS.Signals | null = null): void {
this.exitCode = code;
this.signalCode = signal;
this.stdout.end();
this.stderr.end();
this.emit("close", code, signal);
}
}
function cloudflareConfig(overrides: Partial<TunnelProviderConfig> = {}): TunnelProviderConfig {
return {
provider: "cloudflare",
executablePath: "cloudflared",
args: ["tunnel", "--token", "secret-token"],
tokenEnvVar: "CLOUDFLARED_TOKEN",
env: { CLOUDFLARED_TOKEN: "secret-token" },
...overrides,
} as TunnelProviderConfig;
}
describe("TunnelProcessManager", () => {
let pid = 1000;
let children = new Map<number, FakeChildProcess>();
let processKillSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
pid = 1000;
children = new Map();
processKillSpy = vi.spyOn(process, "kill") as unknown as ReturnType<typeof vi.spyOn>;
processKillSpy.mockImplementation((...args: unknown[]) => {
const targetPid = Number(args[0]);
const signal = args[1] as NodeJS.Signals | number | undefined;
const child = children.get(Math.abs(targetPid));
if (!child) {
return true;
}
if (signal === "SIGKILL") {
child.close(0, "SIGKILL");
} else if (signal === "SIGTERM") {
child.close(0, "SIGTERM");
}
return true;
});
});
afterEach(() => {
processKillSpy.mockRestore();
vi.useRealTimers();
});
it("starts, emits readiness transitions, and redacts token-bearing logs", async () => {
const manager = new TunnelProcessManager({
spawnImpl: () => {
const child = new FakeChildProcess(++pid);
children.set(child.pid, child);
return child as never;
},
});
const states: Array<string> = [];
const logs: string[] = [];
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
manager.subscribeLogs((entry) => logs.push(entry.message));
await manager.start("cloudflare", cloudflareConfig());
const child = [...children.values()][0];
child.emitStdout("Connected at https://demo.trycloudflare.com with secret-token");
await vi.waitFor(() => {
expect(manager.getStatus().state).toBe("running");
});
const status = manager.getStatus();
expect(status.url).toBe("https://demo.trycloudflare.com");
expect(states).toContain("starting");
expect(states).toContain("running");
const allLogs = logs.join("\n");
expect(allLogs).toContain("[REDACTED]");
expect(allLogs).not.toContain("secret-token");
});
it("falls back to SIGKILL when graceful stop times out", async () => {
vi.useFakeTimers();
processKillSpy.mockImplementation((...args: unknown[]) => {
const targetPid = Number(args[0]);
const signal = args[1] as NodeJS.Signals | number | undefined;
const child = children.get(Math.abs(targetPid));
if (!child) {
return true;
}
if (signal === "SIGKILL") {
child.close(0, "SIGKILL");
}
return true;
});
const manager = new TunnelProcessManager({
stopTimeoutMs: 10,
spawnImpl: () => {
const child = new FakeChildProcess(++pid);
children.set(child.pid, child);
return child as never;
},
});
await manager.start("cloudflare", cloudflareConfig({ stopTimeoutMs: 10 }));
const child = [...children.values()][0];
child.emitStdout("Tunnel ready https://demo.trycloudflare.com");
await vi.waitFor(() => expect(manager.getStatus().state).toBe("running"));
const stopPromise = manager.stop();
await vi.advanceTimersByTimeAsync(20);
await stopPromise;
expect(processKillSpy).toHaveBeenCalledWith(expect.any(Number), "SIGTERM");
expect(processKillSpy).toHaveBeenCalledWith(expect.any(Number), "SIGKILL");
expect(manager.getStatus().state).toBe("stopped");
});
it("switchProvider stops active provider before emitting switch_failed on target start failure", async () => {
const manager = new TunnelProcessManager({
spawnImpl: vi
.fn()
.mockImplementationOnce(() => {
const child = new FakeChildProcess(++pid);
children.set(child.pid, child);
return child as never;
})
.mockImplementationOnce(() => {
throw new Error("cloudflare launcher boom");
}),
});
const states: string[] = [];
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
await manager.start("tailscale", {
provider: "tailscale",
executablePath: "tailscale",
args: ["serve", "status"],
});
const child = [...children.values()][0];
child.emitStdout("Serve started https://machine.ts.net");
await vi.waitFor(() => expect(manager.getStatus().state).toBe("running"));
await expect(
manager.switchProvider("cloudflare", cloudflareConfig()),
).rejects.toThrow("cloudflare launcher boom");
const finalStatus = manager.getStatus();
expect(finalStatus.state).toBe("failed");
expect(finalStatus.lastError?.code).toBe("switch_failed");
expect(finalStatus.provider).toBe("cloudflare");
expect(states).toContain("stopping");
expect(states).toContain("stopped");
});
});

View File

@@ -81,6 +81,30 @@ export { ProjectEngine, type ProjectEngineOptions } from "./project-engine.js";
export { ProjectEngineManager, type EngineManagerOptions } from "./project-engine-manager.js";
export { NodeHealthMonitor } from "./node-health-monitor.js";
export { PeerExchangeService, type PeerExchangeServiceOptions, type SyncResult } from "./peer-exchange-service.js";
export {
TunnelProcessManager,
getTunnelProviderAdapter,
redactTunnelText,
type TunnelProcessManagerOptions,
type CloudflareProviderConfig,
type ManagedTunnelProcess,
type PreparedTunnelCommand,
type TailscaleProviderConfig,
type TunnelError,
type TunnelErrorCode,
type TunnelLifecycleState,
type TunnelLogEntry,
type TunnelLogLevel,
type TunnelLogListener,
type TunnelManager,
type TunnelOutputStream,
type TunnelProvider,
type TunnelProviderAdapter,
type TunnelProviderConfig,
type TunnelReadinessEvent,
type TunnelStatusListener,
type TunnelStatusSnapshot,
} from "./remote-access/index.js";
export { RemoteNodeClient } from "./runtimes/remote-node-client.js";
export { RemoteNodeRuntime, type RemoteNodeRuntimeConfig } from "./runtimes/remote-node-runtime.js";
export { StepSessionExecutor } from "./step-session-executor.js";

View File

@@ -104,6 +104,9 @@ export const heartbeatLog = createLogger("heartbeat");
/** Logger for remote node runtime/client subsystems. */
export const remoteNodeLog = createLogger("remote-node");
/** Logger for remote tunnel process orchestration subsystem. */
export const remoteTunnelLog = createLogger("remote-tunnel");
/** Logger for periodic node health monitor subsystem. */
export const nodeHealthMonitorLog = createLogger("node-health-monitor");

View File

@@ -20,6 +20,7 @@ import { aiMergeTask } from "./merger.js";
import { PRIORITY_MERGE } from "./concurrency.js";
import { runtimeLog } from "./logger.js";
import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
import { TunnelProcessManager } from "./remote-access/tunnel-process-manager.js";
/**
* Callback for processing pull-request merge strategy.
@@ -90,6 +91,7 @@ export class ProjectEngine {
private notifier?: NtfyNotifier;
private cronRunner?: CronRunner;
private automationStore?: AutomationStoreType;
private remoteTunnelManager?: TunnelProcessManager;
// ── Auto-merge state ──
private mergeQueue: string[] = [];
@@ -140,6 +142,8 @@ export class ProjectEngine {
const store = this.runtime.getTaskStore();
const cwd = this.config.workingDirectory;
this.remoteTunnelManager = new TunnelProcessManager();
// 2. Initialize PrMonitor + PrCommentHandler
this.prMonitor = new PrMonitor();
this.prCommentHandler = new PrCommentHandler(store);
@@ -281,6 +285,17 @@ export class ProjectEngine {
this.notifier?.stop();
this.cronRunner?.stop();
const tunnelManager = this.remoteTunnelManager;
this.remoteTunnelManager = undefined;
if (tunnelManager) {
try {
await tunnelManager.stop();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeLog.warn(`Tunnel process manager stop failed (continuing shutdown): ${message}`);
}
}
// Stop the core runtime (Triage, Scheduler, Executor, etc.)
await this.runtime.stop();
@@ -339,6 +354,11 @@ export class ProjectEngine {
return this.runtime.getRoutineStore();
}
/** Get the remote tunnel manager (available after start()). */
getRemoteTunnelManager(): TunnelProcessManager | undefined {
return this.remoteTunnelManager;
}
/** Get the RoutineRunner (if initialized). */
getRoutineRunner(): RoutineRunner | undefined {
return this.runtime.getRoutineRunner();

View File

@@ -0,0 +1,27 @@
export {
getTunnelProviderAdapter,
redactTunnelText,
} from "./provider-adapters.js";
export { TunnelProcessManager, type TunnelProcessManagerOptions } from "./tunnel-process-manager.js";
export type {
CloudflareProviderConfig,
ManagedTunnelProcess,
PreparedTunnelCommand,
TailscaleProviderConfig,
TunnelError,
TunnelErrorCode,
TunnelLifecycleState,
TunnelLogEntry,
TunnelLogLevel,
TunnelLogListener,
TunnelManager,
TunnelOutputStream,
TunnelProvider,
TunnelProviderAdapter,
TunnelProviderConfig,
TunnelReadinessEvent,
TunnelStatusListener,
TunnelStatusSnapshot,
} from "./types.js";

View File

@@ -0,0 +1,187 @@
import { accessSync, constants as fsConstants } from "node:fs";
import type {
PreparedTunnelCommand,
TunnelOutputStream,
TunnelProvider,
TunnelProviderAdapter,
TunnelProviderConfig,
} from "./types.js";
const DEFAULT_READINESS_TIMEOUT_MS = 20_000;
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
const URL_PATTERN = /(https?:\/\/[^\s]+)/i;
function isAbsoluteOrPathLike(input: string): boolean {
return input.startsWith("/") || input.startsWith("./") || input.startsWith("../");
}
function assertNonEmpty(value: unknown, label: string): asserts value is string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`invalid_config:${label} must be a non-empty string`);
}
}
function ensureNumberInRange(value: number | undefined, label: string): number | undefined {
if (value === undefined) {
return undefined;
}
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`invalid_config:${label} must be a positive number`);
}
return Math.floor(value);
}
function collectSensitiveValues(config: TunnelProviderConfig): string[] {
const values = new Set<string>();
const env = config.env ?? {};
const tokenEnvVar = config.tokenEnvVar;
if (typeof tokenEnvVar === "string" && tokenEnvVar.trim().length > 0) {
const tokenValue = env[tokenEnvVar] ?? process.env[tokenEnvVar];
if (!tokenValue) {
throw new Error(`invalid_config:missing credential in env var ${tokenEnvVar}`);
}
values.add(tokenValue);
}
for (const envName of config.sensitiveEnvVars ?? []) {
const envValue = env[envName] ?? process.env[envName];
if (envValue) {
values.add(envValue);
}
}
return [...values].sort((a, b) => b.length - a.length);
}
function redactValue(input: string, sensitiveValues: string[]): string {
let redacted = input;
for (const value of sensitiveValues) {
redacted = redacted.split(value).join("[REDACTED]");
}
return redacted;
}
function redactArgs(args: string[], sensitiveValues: string[]): string[] {
return args.map((arg) => redactValue(arg, sensitiveValues));
}
function buildCommand(config: TunnelProviderConfig): PreparedTunnelCommand {
const command = config.executablePath.trim();
const args = [...config.args];
const env: NodeJS.ProcessEnv = {
...process.env,
...(config.env ?? {}),
};
const sensitiveValues = collectSensitiveValues(config);
const redactedPreview = [command, ...redactArgs(args, sensitiveValues)].join(" ").trim();
return {
provider: config.provider,
command,
args,
cwd: config.cwd,
env,
redactedPreview,
sensitiveValues,
readinessTimeoutMs: config.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS,
stopTimeoutMs: config.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS,
};
}
function parseCommonReadiness(line: string): { ready: boolean; url?: string } | null {
const normalized = line.trim();
if (!normalized) {
return null;
}
const urlMatch = normalized.match(URL_PATTERN);
const url = urlMatch?.[1];
if (/\b(connected|ready|available|started|serving)\b/i.test(normalized)) {
return { ready: true, url };
}
if (url && /\b(trycloudflare|tailscale|ts\.net|serve|funnel)\b/i.test(normalized)) {
return { ready: true, url };
}
return null;
}
function validateBaseConfig(config: TunnelProviderConfig, provider: TunnelProvider): void {
if (config.provider !== provider) {
throw new Error(`invalid_config:config provider ${config.provider} does not match ${provider}`);
}
assertNonEmpty(config.executablePath, "executablePath");
if (!Array.isArray(config.args)) {
throw new Error("invalid_config:args must be an array");
}
for (const [index, arg] of config.args.entries()) {
assertNonEmpty(arg, `args[${index}]`);
}
if (config.cwd !== undefined) {
assertNonEmpty(config.cwd, "cwd");
}
if (config.tokenEnvVar !== undefined) {
assertNonEmpty(config.tokenEnvVar, "tokenEnvVar");
}
ensureNumberInRange(config.readinessTimeoutMs, "readinessTimeoutMs");
ensureNumberInRange(config.stopTimeoutMs, "stopTimeoutMs");
}
const tailscaleAdapter: TunnelProviderAdapter = {
provider: "tailscale",
validateConfig(config) {
validateBaseConfig(config, "tailscale");
},
buildCommand(config) {
this.validateConfig(config);
return buildCommand(config);
},
parseReadiness(line: string, _stream: TunnelOutputStream) {
return parseCommonReadiness(line);
},
};
const cloudflareAdapter: TunnelProviderAdapter = {
provider: "cloudflare",
validateConfig(config) {
validateBaseConfig(config, "cloudflare");
if ("credentialsPath" in config && config.credentialsPath !== undefined) {
assertNonEmpty(config.credentialsPath, "credentialsPath");
if (isAbsoluteOrPathLike(config.credentialsPath)) {
accessSync(config.credentialsPath, fsConstants.R_OK);
}
}
},
buildCommand(config) {
this.validateConfig(config);
return buildCommand(config);
},
parseReadiness(line: string, _stream: TunnelOutputStream) {
return parseCommonReadiness(line);
},
};
const ADAPTERS: Record<TunnelProvider, TunnelProviderAdapter> = {
tailscale: tailscaleAdapter,
cloudflare: cloudflareAdapter,
};
export function getTunnelProviderAdapter(provider: TunnelProvider): TunnelProviderAdapter {
return ADAPTERS[provider];
}
export function redactTunnelText(input: string, sensitiveValues: string[]): string {
return redactValue(input, sensitiveValues);
}

View File

@@ -0,0 +1,474 @@
import { EventEmitter } from "node:events";
import { spawn, type ChildProcess } from "node:child_process";
import type { Readable } from "node:stream";
import { remoteTunnelLog } from "../logger.js";
import {
getTunnelProviderAdapter,
redactTunnelText,
} from "./provider-adapters.js";
import type {
ManagedTunnelProcess,
TunnelErrorCode,
TunnelLogEntry,
TunnelLogLevel,
TunnelLogListener,
TunnelManager,
TunnelOutputStream,
TunnelProvider,
TunnelProviderConfig,
TunnelStatusListener,
TunnelStatusSnapshot,
} from "./types.js";
export interface TunnelProcessManagerOptions {
maxLogEntries?: number;
stopTimeoutMs?: number;
spawnImpl?: typeof spawn;
}
const DEFAULT_MAX_LOG_ENTRIES = 400;
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
class LineBuffer {
private pending = "";
push(chunk: string): string[] {
this.pending += chunk;
const lines = this.pending.split(/\r?\n/);
this.pending = lines.pop() ?? "";
return lines.map((line) => line.trim()).filter(Boolean);
}
flush(): string[] {
const tail = this.pending.trim();
this.pending = "";
return tail ? [tail] : [];
}
}
function nowIso(): string {
return new Date().toISOString();
}
function normalizeError(input: unknown): Error {
if (input instanceof Error) {
return input;
}
return new Error(String(input));
}
function maskSensitive(message: string, processHandle: ManagedTunnelProcess | null): string {
if (!processHandle) {
return message;
}
return redactTunnelText(message, processHandle.command.sensitiveValues);
}
function killManagedProcess(child: ChildProcess, signal: NodeJS.Signals): void {
if (typeof child.pid !== "number") {
return;
}
if (process.platform !== "win32") {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to direct pid.
}
}
try {
process.kill(child.pid, signal);
} catch {
// Process may already be gone.
}
}
function toStateError(code: TunnelErrorCode, err: unknown): { code: TunnelErrorCode; message: string; at: string } {
const normalized = normalizeError(err);
return {
code,
message: normalized.message,
at: nowIso(),
};
}
export class TunnelProcessManager extends EventEmitter implements TunnelManager {
private readonly maxLogEntries: number;
private readonly defaultStopTimeoutMs: number;
private readonly spawnImpl: typeof spawn;
private status: TunnelStatusSnapshot = {
provider: null,
state: "stopped",
pid: null,
startedAt: null,
stoppedAt: null,
url: null,
lastError: null,
};
private logs: TunnelLogEntry[] = [];
private readonly statusListeners = new Set<TunnelStatusListener>();
private readonly logListeners = new Set<TunnelLogListener>();
private processHandle: ManagedTunnelProcess | null = null;
private readinessTimer: NodeJS.Timeout | null = null;
private stopTimer: NodeJS.Timeout | null = null;
private operationChain: Promise<void> = Promise.resolve();
private expectedStop = false;
private activeStopPromise: Promise<void> | null = null;
constructor(options: TunnelProcessManagerOptions = {}) {
super();
this.maxLogEntries = options.maxLogEntries ?? DEFAULT_MAX_LOG_ENTRIES;
this.defaultStopTimeoutMs = options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
this.spawnImpl = options.spawnImpl ?? spawn;
}
getStatus(): TunnelStatusSnapshot {
return { ...this.status, lastError: this.status.lastError ? { ...this.status.lastError } : null };
}
subscribeStatus(listener: TunnelStatusListener): () => void {
this.statusListeners.add(listener);
listener(this.getStatus());
return () => {
this.statusListeners.delete(listener);
};
}
subscribeLogs(listener: TunnelLogListener): () => void {
this.logListeners.add(listener);
return () => {
this.logListeners.delete(listener);
};
}
async start(provider: TunnelProvider, config: TunnelProviderConfig): Promise<void> {
return this.runExclusive(async () => {
if (this.processHandle || this.status.state === "starting" || this.status.state === "running") {
throw new Error("already_running:tunnel process is already active");
}
await this.startInternal(provider, config);
});
}
async stop(): Promise<void> {
return this.runExclusive(async () => {
await this.stopInternal();
});
}
async switchProvider(target: TunnelProvider, config: TunnelProviderConfig): Promise<void> {
return this.runExclusive(async () => {
const previousProvider = this.status.provider;
if (this.processHandle) {
await this.stopInternal();
}
try {
await this.startInternal(target, config);
} catch (error) {
const stateError = toStateError("switch_failed", error);
this.updateStatus({
provider: target,
state: "failed",
pid: null,
startedAt: null,
stoppedAt: nowIso(),
url: null,
lastError: stateError,
});
this.emitLog("error", "manager", `Provider switch failed (${previousProvider ?? "none"} -> ${target}): ${stateError.message}`);
throw error;
}
});
}
private async runExclusive(operation: () => Promise<void>): Promise<void> {
const next = this.operationChain.then(operation);
this.operationChain = next.catch(() => undefined);
return next;
}
private async startInternal(provider: TunnelProvider, config: TunnelProviderConfig): Promise<void> {
const adapter = getTunnelProviderAdapter(provider);
if (config.provider !== provider) {
throw new Error(`invalid_config:provider mismatch (${config.provider} vs ${provider})`);
}
try {
adapter.validateConfig(config);
} catch (error) {
const stateError = toStateError("invalid_config", error);
this.updateStatus({
provider,
state: "failed",
pid: null,
startedAt: null,
stoppedAt: nowIso(),
url: null,
lastError: stateError,
});
this.emitLog("error", "manager", `Configuration validation failed for ${provider}: ${stateError.message}`);
throw error;
}
const command = adapter.buildCommand(config);
this.updateStatus({
provider,
state: "starting",
pid: null,
startedAt: nowIso(),
stoppedAt: null,
url: null,
lastError: null,
});
this.emitLog("info", "manager", `Starting ${provider} tunnel: ${command.redactedPreview}`);
const child = this.spawnImpl(command.command, command.args, {
cwd: command.cwd,
env: command.env,
detached: process.platform !== "win32",
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
this.processHandle = {
provider,
child,
command,
};
this.expectedStop = false;
this.updateStatus({ pid: child.pid ?? null });
const stdoutBuffer = new LineBuffer();
const stderrBuffer = new LineBuffer();
const attachStream = (stream: Readable | null, source: TunnelOutputStream, buffer: LineBuffer) => {
stream?.on("data", (chunk: Buffer | string) => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
for (const line of buffer.push(text)) {
this.handleOutputLine(source, line);
}
});
};
attachStream(child.stdout, "stdout", stdoutBuffer);
attachStream(child.stderr, "stderr", stderrBuffer);
child.once("error", (error) => {
const maskedMessage = maskSensitive(normalizeError(error).message, this.processHandle);
this.emitLog("error", "manager", `Spawn failure for ${provider}: ${maskedMessage}`);
this.handleUnexpectedExit("start_failed", `Spawn failure: ${maskedMessage}`);
});
child.once("close", (code, signal) => {
for (const line of stdoutBuffer.flush()) {
this.handleOutputLine("stdout", line);
}
for (const line of stderrBuffer.flush()) {
this.handleOutputLine("stderr", line);
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? 0}`;
if (this.expectedStop) {
this.emitLog("info", "manager", `Tunnel process stopped (${reason})`);
this.finalizeStoppedState();
return;
}
this.emitLog("error", "manager", `Tunnel process exited unexpectedly (${reason})`);
this.handleUnexpectedExit("process_exit", `Process exited unexpectedly (${reason})`);
});
this.readinessTimer = setTimeout(() => {
if (this.status.state === "starting" && this.processHandle?.provider === provider) {
this.emitLog("error", "manager", `Readiness timed out after ${command.readinessTimeoutMs}ms`);
this.handleUnexpectedExit("readiness_timeout", `Tunnel readiness timeout after ${command.readinessTimeoutMs}ms`);
}
}, command.readinessTimeoutMs);
this.readinessTimer.unref?.();
}
private async stopInternal(): Promise<void> {
if (!this.processHandle) {
this.updateStatus({
provider: null,
state: "stopped",
pid: null,
stoppedAt: nowIso(),
url: null,
});
return;
}
if (this.activeStopPromise) {
await this.activeStopPromise;
return;
}
const currentHandle = this.processHandle;
const stopTimeoutMs = currentHandle.command.stopTimeoutMs || this.defaultStopTimeoutMs;
this.expectedStop = true;
this.updateStatus({
state: "stopping",
provider: currentHandle.provider,
pid: currentHandle.child.pid ?? null,
lastError: null,
});
this.emitLog("info", "manager", `Stopping ${currentHandle.provider} tunnel (pid=${currentHandle.child.pid ?? "n/a"})`);
this.activeStopPromise = new Promise<void>((resolve) => {
const onClose = () => {
currentHandle.child.removeListener("close", onClose);
resolve();
};
currentHandle.child.once("close", onClose);
killManagedProcess(currentHandle.child, "SIGTERM");
this.stopTimer = setTimeout(() => {
if (this.processHandle === currentHandle) {
this.emitLog("warn", "manager", `Graceful stop timed out after ${stopTimeoutMs}ms, sending SIGKILL`);
killManagedProcess(currentHandle.child, "SIGKILL");
}
}, stopTimeoutMs);
this.stopTimer.unref?.();
}).finally(() => {
this.activeStopPromise = null;
if (this.stopTimer) {
clearTimeout(this.stopTimer);
this.stopTimer = null;
}
});
await this.activeStopPromise;
}
private handleOutputLine(source: TunnelOutputStream, rawLine: string): void {
const processHandle = this.processHandle;
const maskedLine = maskSensitive(rawLine, processHandle);
this.emitLog("info", source, maskedLine);
if (!processHandle || this.status.state !== "starting") {
return;
}
const adapter = getTunnelProviderAdapter(processHandle.provider);
const readiness = adapter.parseReadiness(maskedLine, source);
if (!readiness?.ready) {
return;
}
this.clearReadinessTimer();
this.updateStatus({
state: "running",
provider: processHandle.provider,
pid: processHandle.child.pid ?? null,
url: readiness.url ?? this.status.url,
startedAt: this.status.startedAt ?? nowIso(),
lastError: null,
});
this.emitLog("info", "manager", `${processHandle.provider} tunnel is running`);
}
private handleUnexpectedExit(code: TunnelErrorCode, message: string): void {
this.clearReadinessTimer();
if (this.stopTimer) {
clearTimeout(this.stopTimer);
this.stopTimer = null;
}
this.expectedStop = false;
const provider = this.processHandle?.provider ?? this.status.provider;
this.processHandle = null;
this.updateStatus({
provider,
state: "failed",
pid: null,
stoppedAt: nowIso(),
url: null,
lastError: {
code,
message,
at: nowIso(),
},
});
}
private finalizeStoppedState(): void {
this.clearReadinessTimer();
if (this.stopTimer) {
clearTimeout(this.stopTimer);
this.stopTimer = null;
}
this.expectedStop = false;
this.processHandle = null;
this.updateStatus({
provider: null,
state: "stopped",
pid: null,
stoppedAt: nowIso(),
url: null,
lastError: null,
});
}
private clearReadinessTimer(): void {
if (this.readinessTimer) {
clearTimeout(this.readinessTimer);
this.readinessTimer = null;
}
}
private updateStatus(patch: Partial<TunnelStatusSnapshot>): void {
this.status = {
...this.status,
...patch,
lastError: patch.lastError === undefined ? this.status.lastError : patch.lastError,
};
const snapshot = this.getStatus();
for (const listener of this.statusListeners) {
listener(snapshot);
}
this.emit("status", snapshot);
}
private emitLog(level: TunnelLogLevel, source: TunnelLogEntry["source"], message: string): void {
const safeMessage = maskSensitive(message, this.processHandle);
const entry: TunnelLogEntry = {
timestamp: nowIso(),
provider: this.status.provider,
level,
source,
message: safeMessage,
};
this.logs.push(entry);
if (this.logs.length > this.maxLogEntries) {
this.logs.splice(0, this.logs.length - this.maxLogEntries);
}
const logMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
remoteTunnelLog[logMethod](safeMessage);
for (const listener of this.logListeners) {
listener(entry);
}
this.emit("log", entry);
}
}

View File

@@ -0,0 +1,133 @@
import type { ChildProcess } from "node:child_process";
export type TunnelProvider = "tailscale" | "cloudflare";
export type TunnelLifecycleState =
| "stopped"
| "starting"
| "running"
| "stopping"
| "failed";
export type TunnelErrorCode =
| "invalid_config"
| "already_running"
| "already_stopped"
| "start_failed"
| "stop_failed"
| "switch_failed"
| "credential_missing"
| "process_exit"
| "readiness_timeout"
| "signal_failed";
export interface TunnelError {
code: TunnelErrorCode;
message: string;
at: string;
}
export interface TunnelStatusSnapshot {
provider: TunnelProvider | null;
state: TunnelLifecycleState;
pid: number | null;
startedAt: string | null;
stoppedAt: string | null;
url: string | null;
lastError: TunnelError | null;
}
export type TunnelLogLevel = "info" | "warn" | "error";
export interface TunnelLogEntry {
timestamp: string;
provider: TunnelProvider | null;
level: TunnelLogLevel;
source: "manager" | "stdout" | "stderr";
message: string;
}
export type TunnelStatusListener = (snapshot: TunnelStatusSnapshot) => void;
export type TunnelLogListener = (entry: TunnelLogEntry) => void;
export interface TunnelManager {
getStatus(): TunnelStatusSnapshot;
start(provider: TunnelProvider, config: TunnelProviderConfig): Promise<void>;
stop(): Promise<void>;
switchProvider(target: TunnelProvider, config: TunnelProviderConfig): Promise<void>;
subscribeStatus(listener: TunnelStatusListener): () => void;
subscribeLogs(listener: TunnelLogListener): () => void;
}
interface TunnelProviderConfigBase {
provider: TunnelProvider;
executablePath: string;
args: string[];
cwd?: string;
env?: Record<string, string | undefined>;
readinessTimeoutMs?: number;
stopTimeoutMs?: number;
/**
* Names of env vars that should always be masked in status/log output.
* Values are sourced by the caller and MUST NOT be logged verbatim.
*/
sensitiveEnvVars?: string[];
}
export interface TailscaleProviderConfig extends TunnelProviderConfigBase {
provider: "tailscale";
/**
* Optional environment variable name holding an auth key/token reference.
* The manager validates that it exists when provided, but never logs its value.
*/
tokenEnvVar?: string;
}
export interface CloudflareProviderConfig extends TunnelProviderConfigBase {
provider: "cloudflare";
/**
* Optional environment variable name holding a Cloudflare token reference.
* The manager validates that it exists when provided, but never logs its value.
*/
tokenEnvVar?: string;
/**
* Optional path to Cloudflare credentials JSON used by cloudflared.
*/
credentialsPath?: string;
}
export type TunnelProviderConfig = TailscaleProviderConfig | CloudflareProviderConfig;
export interface PreparedTunnelCommand {
provider: TunnelProvider;
command: string;
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
redactedPreview: string;
sensitiveValues: string[];
readinessTimeoutMs: number;
stopTimeoutMs: number;
}
export interface TunnelReadinessEvent {
ready: boolean;
url?: string;
reason?: string;
}
export type TunnelOutputStream = "stdout" | "stderr";
export interface TunnelProviderAdapter {
provider: TunnelProvider;
validateConfig(config: TunnelProviderConfig): void;
buildCommand(config: TunnelProviderConfig): PreparedTunnelCommand;
parseReadiness(line: string, stream: TunnelOutputStream): TunnelReadinessEvent | null;
}
export interface ManagedTunnelProcess {
provider: TunnelProvider;
child: ChildProcess;
command: PreparedTunnelCommand;
}