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:
@@ -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();
|
||||
|
||||
55
packages/engine/src/__tests__/provider-adapters.test.ts
Normal file
55
packages/engine/src/__tests__/provider-adapters.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
186
packages/engine/src/__tests__/tunnel-process-manager.test.ts
Normal file
186
packages/engine/src/__tests__/tunnel-process-manager.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
27
packages/engine/src/remote-access/index.ts
Normal file
27
packages/engine/src/remote-access/index.ts
Normal 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";
|
||||
187
packages/engine/src/remote-access/provider-adapters.ts
Normal file
187
packages/engine/src/remote-access/provider-adapters.ts
Normal 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);
|
||||
}
|
||||
474
packages/engine/src/remote-access/tunnel-process-manager.ts
Normal file
474
packages/engine/src/remote-access/tunnel-process-manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
133
packages/engine/src/remote-access/types.ts
Normal file
133
packages/engine/src/remote-access/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user