diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ed7e357a..64525f03 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.8.6", + "version": "0.8.7", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index af93911b..9eb21639 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -38,6 +38,7 @@ import type { GatewayApprovalRequest, GatewayApprovalResult } from "../server/ro import { normalizeAndValidateOrigin, normalizeWebAppOrigin } from "./origin-policy.js"; import { LocalSessionStore } from "./local-session-store.js"; import { enrichJobSnapshot } from "../server/operations/symphony-job-snapshot.js"; +import { GatewayRecoveryManager } from "./gateway-recovery.js"; import pkg from "electron-updater"; const { autoUpdater } = pkg; import { BUILD_COMMIT_HASH } from "../shared/build-info.js"; @@ -57,6 +58,7 @@ export class DesktopApplication { private readonly activityLog: ActivityLogStore; private readonly approvalStore: ApprovalStore; private readonly jobStore: JobStore; + private readonly recovery: GatewayRecoveryManager; private readonly gatewayAuthToken: string; private readonly sessionStore: LocalSessionStore; private shuttingDown = false; @@ -108,7 +110,8 @@ export class DesktopApplication { () => this.settingsStore.getApiOrigin(), () => this.settingsStore.getWebAppOrigin(), this.isProdOriginsOnly(), - this.jobStore + this.jobStore, + () => this.recovery.onUnexpectedClose() ); this.commandExecutor = new CloudCommandExecutor({ getGatewayPort: () => this.server.getActivePort(), @@ -118,7 +121,8 @@ export class DesktopApplication { sendCommandEvent: (event) => this.cloudSocket.sendCommandEvent(event), onQueueStatsChange: (stats) => { const presenceState = - this.cloudStatus.state === "online" && !this.cloudCommandsPaused ? "online" : "degraded"; + this.cloudStatus.state === "online" && !this.cloudCommandsPaused && this.recovery.gatewayHealthy + ? "online" : "degraded"; this.cloudSocket.sendPresence({ state: presenceState, ...(this.cloudCommandsPaused ? { error: "cloud commands paused by user" } : {}), @@ -182,6 +186,25 @@ export class DesktopApplication { this.commandExecutor.acknowledge(event); } }); + this.recovery = new GatewayRecoveryManager({ + probe: () => this.probeGatewayAlive(), + restart: () => this.server.restart(), + getCloudStatus: () => this.cloudStatus, + setConnected: (connected) => this.commandExecutor.setConnected(connected), + sendPresence: (state, error) => { + const stats = this.commandExecutor.getStats(); + this.cloudSocket.sendPresence({ + state, + ...(error ? { error } : {}), + activeCommands: stats.activeCommands, + queueDepth: stats.queueDepth + }); + }, + refreshTray: (detail) => this.refreshTrayState(detail), + log: (level, msg) => gatewayLog[level]("gateway-recovery", msg), + isShuttingDown: () => this.shuttingDown, + isPaused: () => this.cloudCommandsPaused, + }); this.registerIpcHandlers(); } @@ -288,6 +311,19 @@ export class DesktopApplication { this.tray.dispose(); } + private async probeGatewayAlive(): Promise { + if (!this.server.isAlive()) return false; + try { + const response = await fetch( + `http://127.0.0.1:${this.server.getActivePort()}/health`, + { signal: AbortSignal.timeout(2000) } + ); + return response.ok; + } catch { + return false; + } + } + private onCloudSocketStatus(status: CloudSocketStatus): void { if (!this.cloudConnectionEnabled) { this.cloudStatus = { state: "degraded", error: "Cloud connection disabled by user" }; @@ -296,29 +332,23 @@ export class DesktopApplication { } this.cloudStatus = status; - const stats = this.commandExecutor.getStats(); - - this.commandExecutor.setConnected(status.state === "online"); if (status.state === "online") { - this.cloudSocket.sendPresence({ - state: this.cloudCommandsPaused ? "degraded" : "online", - ...(this.cloudCommandsPaused ? { error: "cloud commands paused by user" } : {}), - activeCommands: stats.activeCommands, - queueDepth: stats.queueDepth - }); - this.refreshTrayState(`Serving on localhost:${this.server.getActivePort()} | cloud: online (${status.targetId})`); + void this.recovery.onCloudOnline(); return; } + this.commandExecutor.setConnected(false); + if (status.state === "degraded") { this.cloudSocket.sendPresence({ state: "degraded", error: status.error, - activeCommands: stats.activeCommands, - queueDepth: stats.queueDepth + ...this.commandExecutor.getStats() }); - this.refreshTrayState(`Serving on localhost:${this.server.getActivePort()} | cloud degraded: ${status.error}`); + this.refreshTrayState( + `Serving on localhost:${this.server.getActivePort()} | cloud degraded: ${status.error}` + ); return; } @@ -332,8 +362,11 @@ export class DesktopApplication { this.refreshTrayState(paused ? "Gateway paused from tray/menu" : undefined); const stats = this.commandExecutor.getStats(); + const presenceState = + this.cloudStatus.state === "online" && !paused && this.recovery.gatewayHealthy + ? "online" : "degraded"; this.cloudSocket.sendPresence({ - state: this.cloudStatus.state === "online" && !paused ? "online" : "degraded", + state: presenceState, ...(paused ? { error: "cloud commands paused by user" } : {}), activeCommands: stats.activeCommands, queueDepth: stats.queueDepth @@ -480,6 +513,14 @@ export class DesktopApplication { } private refreshTrayState(explicitDetails?: string): void { + if (!this.recovery.gatewayHealthy) { + this.tray.setState( + "error", + explicitDetails ?? `Gateway down on port ${this.server.getActivePort()}` + ); + return; + } + if (this.cloudCommandsPaused) { this.tray.setState( "degraded", @@ -818,7 +859,9 @@ export class DesktopApplication { apiOrigin: this.settingsStore.getApiOrigin(), sandboxBaseDirectory: this.settingsStore.getSandboxBaseDirectory(), commandsPaused: this.cloudCommandsPaused, - connectionEnabled: this.cloudConnectionEnabled + connectionEnabled: this.cloudConnectionEnabled, + serverAlive: this.server.isAlive(), + gatewayHealthy: this.recovery.gatewayHealthy, })); ipcMain.handle("desktop:list-running-jobs", async () => { const jobs = this.jobStore.listRunning(); diff --git a/apps/desktop/src/main/gateway-recovery.ts b/apps/desktop/src/main/gateway-recovery.ts new file mode 100644 index 00000000..3fdae322 --- /dev/null +++ b/apps/desktop/src/main/gateway-recovery.ts @@ -0,0 +1,81 @@ +export interface GatewayRecoveryDeps { + probe: () => Promise; + restart: () => Promise; + getCloudStatus: () => { state: string }; + setConnected: (connected: boolean) => void; + sendPresence: (state: "online" | "degraded", error?: string) => void; + refreshTray: (detail?: string) => void; + log: (level: "info" | "warn" | "error", msg: string) => void; + isShuttingDown: () => boolean; + isPaused: () => boolean; +} + +export class GatewayRecoveryManager { + private epoch = 0; + private recoveryInFlight: Promise | null = null; + gatewayHealthy = true; + + constructor(private readonly deps: GatewayRecoveryDeps) {} + + recoverGateway(reason: string): Promise { + if (this.recoveryInFlight) { + this.deps.log("warn", `Recovery already in flight, deduplicating: ${reason}`); + return this.recoveryInFlight; + } + this.epoch++; + this.gatewayHealthy = false; + this.deps.setConnected(false); + this.deps.sendPresence("degraded", `gateway recovering: ${reason}`); + + this.recoveryInFlight = this.doRecover(reason).finally(() => { + this.recoveryInFlight = null; + }); + return this.recoveryInFlight; + } + + private async doRecover(reason: string): Promise { + this.deps.log("warn", `Gateway recovery started: ${reason}`); + try { + await this.deps.restart(); + this.gatewayHealthy = true; + this.deps.log("info", "Gateway recovered"); + if (this.deps.getCloudStatus().state === "online") { + this.deps.setConnected(true); + this.deps.sendPresence( + this.deps.isPaused() ? "degraded" : "online", + this.deps.isPaused() ? "cloud commands paused by user" : undefined + ); + } + this.deps.refreshTray(); + } catch (error) { + const msg = error instanceof Error ? error.message : "unknown error"; + this.deps.log("error", `Gateway recovery failed: ${msg}`); + this.deps.refreshTray(`Gateway down -- restart failed: ${msg}`); + } + } + + async onCloudOnline(): Promise { + const epoch = ++this.epoch; + const alive = await this.deps.probe(); + + if (this.epoch !== epoch || this.deps.getCloudStatus().state !== "online") return; + + if (!alive) { + await this.recoverGateway("liveness probe failed on cloud reconnect"); + return; + } + + this.gatewayHealthy = true; + this.deps.setConnected(true); + this.deps.sendPresence( + this.deps.isPaused() ? "degraded" : "online", + this.deps.isPaused() ? "cloud commands paused by user" : undefined + ); + this.deps.refreshTray(); + } + + onUnexpectedClose(): void { + if (this.deps.isShuttingDown()) return; + void this.recoverGateway("unexpected server close"); + } +} diff --git a/apps/desktop/src/server/server.ts b/apps/desktop/src/server/server.ts index 1b61ed9f..c08c6dbb 100644 --- a/apps/desktop/src/server/server.ts +++ b/apps/desktop/src/server/server.ts @@ -8,6 +8,7 @@ import { type ComputeTargetCapabilities, type HealthResponse } from "../shared/contracts.js"; +import { gatewayLog } from "../main/gateway-logger.js"; import type { LocalSessionStore } from "../main/local-session-store.js"; import type { JobStore } from "../main/job-store.js"; import { @@ -40,12 +41,14 @@ export interface DesktopGatewayServerOptions { getApiOrigin?: () => string; prodOriginsOnly?: boolean; jobStore?: JobStore; + onUnexpectedClose?: () => void; } export class DesktopGatewayServer { private readonly options: DesktopGatewayServerOptions; private readonly router: GatewayRouter; private server: Server | null = null; + private alive = false; private activePort: number; constructor(options: DesktopGatewayServerOptions) { @@ -93,7 +96,8 @@ export class DesktopGatewayServer { getApiOrigin?: () => string, getWebAppOrigin?: () => string, prodOriginsOnly?: boolean, - jobStore?: JobStore + jobStore?: JobStore, + onUnexpectedClose?: () => void ): DesktopGatewayServer { return new DesktopGatewayServer({ host: "127.0.0.1", @@ -115,6 +119,7 @@ export class DesktopGatewayServer { getApiOrigin, prodOriginsOnly, jobStore, + onUnexpectedClose, }); } @@ -152,7 +157,20 @@ export class DesktopGatewayServer { try { await this.listen(candidateServer, candidate); this.server = candidateServer; - this.activePort = candidate; + this.alive = true; + const addr = candidateServer.address(); + this.activePort = typeof addr === "object" && addr ? addr.port : candidate; + candidateServer.on("error", (err) => { + gatewayLog.error("gateway-server", `Server error: ${err.message}`); + }); + candidateServer.on("close", () => { + if (this.server === candidateServer) { + this.alive = false; + this.server = null; + gatewayLog.warn("gateway-server", "Server closed unexpectedly"); + this.options.onUnexpectedClose?.(); + } + }); await this.writeDiscoveryFile(); return; } catch (error) { @@ -173,6 +191,7 @@ export class DesktopGatewayServer { } async stop(): Promise { + this.alive = false; if (!this.server) { return; } @@ -181,7 +200,7 @@ export class DesktopGatewayServer { this.server = null; await new Promise((resolve, reject) => { runningServer.close((error) => { - if (error) { + if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") { reject(error); return; } @@ -190,6 +209,15 @@ export class DesktopGatewayServer { }); } + isAlive(): boolean { + return this.alive && this.server !== null && this.server.listening; + } + + async restart(): Promise { + await this.stop(); + await this.start(); + } + private async listen(server: Server, port: number): Promise { await new Promise((resolve, reject) => { server.once("error", reject); diff --git a/apps/desktop/test/gateway-auth.test.ts b/apps/desktop/test/gateway-auth.test.ts index b18e5acf..67ace928 100644 --- a/apps/desktop/test/gateway-auth.test.ts +++ b/apps/desktop/test/gateway-auth.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { afterEach, test } from "node:test"; import { DesktopGatewayServer } from "../src/server/server.js"; import { LocalSessionStore } from "../src/main/local-session-store.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; const serversToClose: DesktopGatewayServer[] = []; const fakeApiServersToClose: http.Server[] = []; @@ -42,8 +42,8 @@ function makeServer( ): DesktopGatewayServer { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.test.com", getAllowedDirectories: () => [tmpDir], getGatewayAuthToken: () => "test-gateway-token-hex", @@ -316,8 +316,8 @@ test("exchange handler passes REST API origin (getApiOrigin) to verifyChallenge, const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.test.com", getAllowedDirectories: () => [tmpDir], getGatewayAuthToken: () => "test-gateway-token-hex", diff --git a/apps/desktop/test/gateway-liveness.test.ts b/apps/desktop/test/gateway-liveness.test.ts new file mode 100644 index 00000000..0d72f79a --- /dev/null +++ b/apps/desktop/test/gateway-liveness.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; +import { DesktopGatewayServer } from "../src/server/server.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; + +const serversToClose: DesktopGatewayServer[] = []; +const tempPathsToClean: string[] = []; + +afterEach(async () => { + for (const srv of serversToClose) { + try { await srv.stop(); } catch { /* already stopped */ } + } + serversToClose.length = 0; + for (const p of tempPathsToClean) { + try { await fs.rm(p, { recursive: true }); } catch { /* best effort */ } + } + tempPathsToClean.length = 0; +}); + +function createTestServer(opts?: { + onUnexpectedClose?: () => void; +}): DesktopGatewayServer { + const tmpDir = path.join(os.tmpdir(), `gateway-liveness-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempPathsToClean.push(tmpDir); + const srv = new DesktopGatewayServer({ + host: "127.0.0.1", + preferredPort: 0, + fallbackPorts: [0], + webAppOrigin: "http://localhost:3000", + machineName: "test", + version: "0.0.1", + capabilities: EMPTY_CAPABILITIES, + getAllowedDirectories: () => ["/tmp"], + discoveryFilePath: path.join(tmpDir, "electron-port"), + onUnexpectedClose: opts?.onUnexpectedClose, + }); + serversToClose.push(srv); + return srv; +} + +test("isAlive() lifecycle: false before start, true after start, false after stop", async () => { + const srv = createTestServer(); + assert.equal(srv.isAlive(), false, "should be false before start"); + + await srv.start(); + assert.equal(srv.isAlive(), true, "should be true after start"); + + await srv.stop(); + assert.equal(srv.isAlive(), false, "should be false after stop"); +}); + +test("restart() recovers the server", async () => { + const srv = createTestServer(); + await srv.start(); + + await srv.restart(); + assert.equal(srv.isAlive(), true, "should be alive after restart"); + + const res = await fetch(`http://127.0.0.1:${srv.getActivePort()}/health`); + assert.equal(res.status, 200); +}); + +test("stop() resolves with stale (non-listening) server handle", async () => { + const srv = createTestServer(); + await srv.start(); + + // Grab the internal http.Server handle and close it directly to simulate stale state + const internal = (srv as unknown as { server: http.Server | null }).server; + assert.ok(internal, "internal server should exist"); + + // Close it out-of-band (simulates OS-level close) + await new Promise((resolve) => { + internal!.closeAllConnections(); + internal!.close(() => resolve()); + }); + + // Re-inject the stale handle (the close handler would have nulled it) + (srv as unknown as { server: http.Server | null }).server = internal; + (srv as unknown as { alive: boolean }).alive = true; + + // stop() should resolve without throwing even though the handle is stale + await assert.doesNotReject(() => srv.stop()); +}); + +test("onUnexpectedClose fires on unexpected close", async () => { + let callbackFired = false; + const srv = createTestServer({ + onUnexpectedClose: () => { callbackFired = true; }, + }); + await srv.start(); + assert.equal(srv.isAlive(), true); + + // Grab internal handle and close it to simulate crash + const internal = (srv as unknown as { server: http.Server | null }).server; + assert.ok(internal); + + await new Promise((resolve) => { + internal!.closeAllConnections(); + internal!.close(() => resolve()); + }); + + assert.equal(callbackFired, true, "onUnexpectedClose should have fired"); + assert.equal(srv.isAlive(), false, "should not be alive after unexpected close"); +}); + +test("onUnexpectedClose does NOT fire on intentional stop()", async () => { + let callbackFired = false; + const srv = createTestServer({ + onUnexpectedClose: () => { callbackFired = true; }, + }); + await srv.start(); + await srv.stop(); + + assert.equal(callbackFired, false, "onUnexpectedClose should not fire on intentional stop"); +}); diff --git a/apps/desktop/test/gateway-recovery.test.ts b/apps/desktop/test/gateway-recovery.test.ts new file mode 100644 index 00000000..3ffff8a9 --- /dev/null +++ b/apps/desktop/test/gateway-recovery.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { GatewayRecoveryManager, type GatewayRecoveryDeps } from "../src/main/gateway-recovery.js"; + +function createStubDeps(overrides?: Partial): GatewayRecoveryDeps & { + calls: Record; + cloudState: { state: string }; + paused: boolean; + shuttingDown: boolean; +} { + const calls: Record = {}; + const record = (name: string, ...args: unknown[]) => { + calls[name] ??= []; + calls[name].push(args); + }; + + const cloudState = { state: "online" }; + const state = { + calls, + cloudState, + paused: false, + shuttingDown: false, + probe: async () => { record("probe"); return true; }, + restart: async () => { record("restart"); }, + getCloudStatus: () => cloudState, + setConnected: (connected: boolean) => record("setConnected", connected), + sendPresence: (presState: "online" | "degraded", error?: string) => + record("sendPresence", presState, error), + refreshTray: (detail?: string) => record("refreshTray", detail), + log: (level: string, msg: string) => record("log", level, msg), + isShuttingDown: () => state.shuttingDown, + isPaused: () => state.paused, + ...overrides, + }; + return state; +} + +test("healthy reconnect: probe returns true, setConnected(true) called, restart NOT called", async () => { + const deps = createStubDeps(); + const mgr = new GatewayRecoveryManager(deps); + + await mgr.onCloudOnline(); + + assert.ok(deps.calls.setConnected?.some(([v]) => v === true), "setConnected(true) should be called"); + assert.ok(deps.calls.sendPresence?.some(([s]) => s === "online"), "sendPresence('online') should be called"); + assert.ok(!deps.calls.restart, "restart should NOT be called"); + assert.equal(mgr.gatewayHealthy, true); +}); + +test("failed probe triggers restart", async () => { + const deps = createStubDeps({ + probe: async () => false, + }); + const mgr = new GatewayRecoveryManager(deps); + + await mgr.onCloudOnline(); + + assert.ok(deps.calls.restart, "restart should be called"); + assert.ok(deps.calls.setConnected?.some(([v]) => v === true), "setConnected(true) should be called after recovery"); + assert.equal(mgr.gatewayHealthy, true); +}); + +test("failed probe + failed restart leaves degraded", async () => { + const deps = createStubDeps({ + probe: async () => false, + restart: async () => { throw new Error("bind failed"); }, + }); + const mgr = new GatewayRecoveryManager(deps); + + await mgr.onCloudOnline(); + + assert.equal(mgr.gatewayHealthy, false); + assert.ok(deps.calls.setConnected?.some(([v]) => v === false), "setConnected(false) should be called"); + assert.ok( + deps.calls.sendPresence?.some(([s]) => s === "degraded"), + "sendPresence('degraded') should be called" + ); +}); + +test("concurrent recovery is deduplicated", async () => { + let restartCount = 0; + const deps = createStubDeps({ + restart: async () => { restartCount++; }, + }); + const mgr = new GatewayRecoveryManager(deps); + + const p1 = mgr.recoverGateway("reason 1"); + const p2 = mgr.recoverGateway("reason 2"); + await Promise.all([p1, p2]); + + assert.equal(restartCount, 1, "restart should only be called once"); +}); + +test("stale epoch cancels reconnect", async () => { + let probeResolve: (() => void) | null = null; + const deps = createStubDeps({ + probe: () => new Promise((resolve) => { + probeResolve = () => resolve(true); + }), + }); + const mgr = new GatewayRecoveryManager(deps); + + const promise = mgr.onCloudOnline(); + + // Simulate cloud going offline during probe + deps.cloudState.state = "degraded"; + probeResolve!(); + await promise; + + // setConnected(true) should NOT have been called because epoch/state changed + const setConnectedTrue = deps.calls.setConnected?.filter(([v]) => v === true) ?? []; + assert.equal(setConnectedTrue.length, 0, "setConnected(true) should not be called when cloud went offline during probe"); +}); + +test("unexpected close during in-flight reconnect probe deduplicates restart", async () => { + let restartCount = 0; + let probeResolve: (() => void) | null = null; + const deps = createStubDeps({ + probe: () => new Promise((resolve) => { + probeResolve = () => resolve(false); + }), + restart: async () => { restartCount++; }, + }); + const mgr = new GatewayRecoveryManager(deps); + + const onlinePromise = mgr.onCloudOnline(); + + // Fire unexpected close while probe is in-flight + mgr.onUnexpectedClose(); + + // Resolve the probe (returns false) + probeResolve!(); + await onlinePromise; + + // Wait for the recovery triggered by onUnexpectedClose to also complete + // The onCloudOnline's recoverGateway should join the existing recovery from onUnexpectedClose + // or vice versa (single-flight guarantee) + assert.equal(restartCount, 1, "restart should only be called once despite concurrent triggers"); +}); diff --git a/apps/desktop/test/gateway-server.test.ts b/apps/desktop/test/gateway-server.test.ts index 1b911a71..87f1ff91 100644 --- a/apps/desktop/test/gateway-server.test.ts +++ b/apps/desktop/test/gateway-server.test.ts @@ -9,7 +9,7 @@ import { afterEach, test } from "node:test"; import { promisify } from "node:util"; import { DesktopGatewayServer } from "../src/server/server.js"; import { saveCodexChatSession } from "../src/server/operations/codex.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; import { SymphonyDirNotConfiguredError, tryAssertRepoAllowed, tryAssertPathAllowed } from "../src/server/operations/symphony-utils.js"; const execFileAsync = promisify(execFile); @@ -73,8 +73,8 @@ afterEach(async () => { test("uses closedloop-ai discovery file path by default", () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [os.homedir()], machineName: "discovery-default-machine", @@ -95,8 +95,8 @@ test("returns health contract with active port and CORS headers", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "test-machine", @@ -126,8 +126,8 @@ test("returns 204 for CORS preflight requests", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://staging.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "preflight-machine", @@ -156,8 +156,8 @@ test("returns private-network CORS allow header for terminal-chat preflight", as const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getAllowedDirectories: () => [tmpDir], machineName: "pna-preflight-machine", @@ -190,8 +190,8 @@ test("allows loopback origin variants for CORS preflight", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getAllowedDirectories: () => [tmpDir], machineName: "loopback-origin-machine", @@ -221,8 +221,8 @@ test("normal mode: 127.0.0.2 loopback variant echoed back in CORS preflight", as const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getAllowedDirectories: () => [tmpDir], machineName: "loopback-127-2-machine", @@ -252,8 +252,8 @@ test("normal mode: DNS name like 127.evil.com is NOT treated as loopback", async const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getAllowedDirectories: () => [tmpDir], machineName: "loopback-evil-machine", @@ -284,8 +284,8 @@ test("prodOriginsOnly: preflight from loopback returns configured origin, no PNA const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getAllowedDirectories: () => [tmpDir], machineName: "prod-loopback-machine", @@ -318,8 +318,8 @@ test("prodOriginsOnly: preflight from configured origin returns correct CORS + P const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getAllowedDirectories: () => [tmpDir], machineName: "prod-configured-machine", @@ -352,8 +352,8 @@ test("prodOriginsOnly: preflight from random origin returns configured origin", const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getAllowedDirectories: () => [tmpDir], machineName: "prod-random-machine", @@ -384,8 +384,8 @@ test("prodOriginsOnly: loopback webAppOrigin preflight from that origin echoes i const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getAllowedDirectories: () => [tmpDir], machineName: "prod-loopback-webapp-machine", @@ -417,8 +417,8 @@ test("requires gateway token when configured", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getGatewayAuthToken: () => "test-gateway-token", getAllowedDirectories: () => [tmpDir], @@ -465,8 +465,8 @@ test("rejects trusted browser origin without session token (origin-only bypass r const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getGatewayAuthToken: () => "test-gateway-token", getAllowedDirectories: () => [tmpDir], @@ -500,8 +500,8 @@ test("rejects localhost browser origin without session token", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.closedloop.ai", getGatewayAuthToken: () => "test-gateway-token", getAllowedDirectories: () => [tmpDir], @@ -530,8 +530,8 @@ test("rejects loopback browser request without origin or session token", async ( const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getGatewayAuthToken: () => "test-gateway-token", getAllowedDirectories: () => [tmpDir], @@ -561,8 +561,8 @@ test("keeps non-browser loopback request unauthorized without token", async () = const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getGatewayAuthToken: () => "test-gateway-token", getAllowedDirectories: () => [tmpDir], @@ -584,8 +584,8 @@ test("returns approval-required response when approval evaluator blocks engineer const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "approval-machine", @@ -622,8 +622,8 @@ test("supports async approval evaluation before dispatch", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "approval-async-machine", @@ -659,8 +659,8 @@ test("passes cloud approval headers into approval evaluator context", async () = const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "approval-header-machine", @@ -742,8 +742,8 @@ test("supports symphony sessions CRUD with contract-compatible response envelope const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "session-machine", @@ -794,8 +794,8 @@ test("rejects disallowed directories for symphony sessions writes (AC-049)", asy const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "session-deny-machine", @@ -852,8 +852,8 @@ test("returns symphony status envelope for existing state file", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "status-machine", @@ -896,8 +896,8 @@ test("rejects disallowed repo paths for symphony status (AC-049)", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "status-deny-machine", @@ -938,8 +938,8 @@ test("marks state as stopped when killing by ticket without PID file", async () const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "kill-machine", @@ -981,8 +981,8 @@ test("rejects disallowed repo paths for symphony kill (AC-049)", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "kill-deny-machine", @@ -1030,8 +1030,8 @@ test("returns plan content envelope for symphony plan route", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "plan-machine", @@ -1067,8 +1067,8 @@ test("supports chat history CRUD operations", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "chat-history-machine", @@ -1139,8 +1139,8 @@ test("supports provider-scoped chat history with isolated CRUD", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "provider-scope-machine", @@ -1255,8 +1255,8 @@ test("returns jsonl log format when claude-output.jsonl exists", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "logs-machine", @@ -1297,8 +1297,8 @@ test("returns judges payload when judges.json exists", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "judges-machine", @@ -1336,8 +1336,8 @@ test("serves attachment binary from wildcard route", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "attachments-machine", @@ -1371,8 +1371,8 @@ test("uploads image attachments and returns file metadata", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "upload-machine", @@ -1416,8 +1416,8 @@ test("returns health-check response envelope with required check structure", asy const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "health-check-machine", @@ -1449,8 +1449,8 @@ test("health-check returns 200 with worktree-dir failed when getSymphonyDir thro const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "health-unconfigured-machine", @@ -1481,8 +1481,8 @@ test("repos-config returns 503 when getSymphonyDir throws (not 500)", async () = const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "repos-unconfigured-machine", @@ -1537,8 +1537,8 @@ test("supports repos config CRUD and settings patch", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "repos-machine", @@ -1600,8 +1600,8 @@ test("lists directories and supports file search endpoint", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "filesystem-machine", @@ -1638,8 +1638,8 @@ test("supports terminal chat history GET and DELETE", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "terminal-chat-machine", @@ -1669,8 +1669,8 @@ test("supports ticket chat GET and DELETE with ticketId", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "ticket-chat-machine", @@ -1707,8 +1707,8 @@ test("rejects disallowed repo path for ticket chat POST before spawn (AC-049)", const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "ticket-chat-deny-machine", @@ -1744,8 +1744,8 @@ test("supports run viewer chat history GET and DELETE", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "run-viewer-chat-machine", @@ -1779,8 +1779,8 @@ test("rejects disallowed run directory for run viewer chat POST (AC-049)", async const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "run-viewer-chat-deny-machine", @@ -1816,8 +1816,8 @@ test("lists and cleans up extracted run-viewer directories", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "run-viewer-extract-machine", @@ -1855,8 +1855,8 @@ test("validates run-viewer-extract POST multipart payload", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "run-viewer-extract-post-machine", @@ -1899,8 +1899,8 @@ test("proxies unimplemented engineer routes to fallback origin when configured", const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], fallbackEngineerOrigin: `http://127.0.0.1:${upstreamAddress.port}`, @@ -1934,8 +1934,8 @@ test("supports core git action routes", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "git-action-machine", @@ -1990,8 +1990,8 @@ test("supports git diff route for working tree changes", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "git-diff-machine", @@ -2023,8 +2023,8 @@ test("validates git PR create request payload", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "git-pr-validate-machine", @@ -2054,8 +2054,8 @@ test("rejects disallowed repo for git PR list endpoint (AC-049)", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "git-pr-deny-machine", @@ -2085,8 +2085,8 @@ test("returns empty work-directory result when no session or worktree exists", a const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "work-dir-machine", @@ -2119,8 +2119,8 @@ test("rejects disallowed workDir on aggregate symphony status route (AC-049)", a const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "status-all-deny-machine", @@ -2154,8 +2154,8 @@ test("detects deploy config from repo scripts", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "deploy-detect-machine", @@ -2192,8 +2192,8 @@ test("rejects disallowed repo/worktree for deploy check-existing (AC-049)", asyn const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "deploy-deny-machine", @@ -2226,8 +2226,8 @@ test("validates required fields for symphony extract-learnings route", async () const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "learnings-validate-machine", @@ -2265,8 +2265,8 @@ test("returns skipped status when no learnings are pending", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "learnings-process-machine", @@ -2315,8 +2315,8 @@ test("invokes plugin cache discovery when pending learnings exist", async () => const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "learnings-plugin-machine", @@ -2380,8 +2380,8 @@ test("process-learnings launches self-learning wrapper with .claude/work as arg const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "learnings-wrapper-machine", @@ -2438,8 +2438,8 @@ test("rejects disallowed repo path for record-learning-use (AC-049)", async () = const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "learnings-deny-machine", @@ -2473,8 +2473,8 @@ test("validates required fields for symphony chat route", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "symphony-chat-validate-machine", @@ -2503,8 +2503,8 @@ test("validates required query params for symphony comment-chat GET", async () = const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "comment-chat-validate-machine", @@ -2532,8 +2532,8 @@ test("returns default commit message when worktree does not exist", async () => const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "commit-message-default-machine", @@ -2589,8 +2589,8 @@ test("returns empty description when claude CLI is unavailable", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "commit-claude-unavail-machine", @@ -2652,8 +2652,8 @@ test("uses valid JSON from claude stdout even when exit code is non-zero", async const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "commit-nonzero-machine", @@ -2699,8 +2699,8 @@ test("returns default with empty description when worktree has no diff", async ( const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "commit-nodiff-machine", @@ -2733,8 +2733,8 @@ test("rejects disallowed repo for symphony launch (AC-049)", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "launch-deny-machine", @@ -2772,8 +2772,8 @@ test("symphony launch invokes plugin cache discovery for run-loop script", async const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "launch-plugin-machine", @@ -2840,8 +2840,8 @@ test("symphony launch passes .claude/work path (not ticket ID) as first arg to r const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "launch-args-machine", @@ -2899,8 +2899,8 @@ test("validates required fields for codex chat route", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "codex-chat-validate-machine", @@ -2933,8 +2933,8 @@ test("rejects disallowed repo for codex status route (AC-049)", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [allowedDir], machineName: "codex-status-deny-machine", @@ -3042,8 +3042,8 @@ test("GET codex status returns sessionId when state file has one", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getAllowedDirectories: () => [tmpDir], machineName: "status-sessionid-machine", @@ -3070,8 +3070,8 @@ test("POST review-verdict returns 400 when sessionId is missing", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getAllowedDirectories: () => [tmpDir], machineName: "verdict-400-machine", @@ -3101,8 +3101,8 @@ test("POST review-verdict returns 400 for invalid provider", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getAllowedDirectories: () => [tmpDir], machineName: "verdict-bad-provider-machine", @@ -3132,8 +3132,8 @@ test("POST review-verdict returns 403 for disallowed repo", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "http://localhost:3000", getAllowedDirectories: () => [tmpDir], machineName: "verdict-403-machine", @@ -3163,8 +3163,8 @@ test("getWebAppOrigin getter takes effect on next CORS response without restart" const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://initial.example.com", getWebAppOrigin: () => currentWebAppOrigin, getAllowedDirectories: () => [tmpDir], diff --git a/apps/desktop/test/symphony-loop-cloud-failures.test.ts b/apps/desktop/test/symphony-loop-cloud-failures.test.ts index 891fbf18..8e6c3956 100644 --- a/apps/desktop/test/symphony-loop-cloud-failures.test.ts +++ b/apps/desktop/test/symphony-loop-cloud-failures.test.ts @@ -20,7 +20,7 @@ import path from "node:path"; import { afterEach, test } from "node:test"; import { JobStore } from "../src/main/job-store.js"; import { DesktopGatewayServer } from "../src/server/server.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; import { createFakeRunLoopScript, initGitRepo, @@ -123,8 +123,8 @@ test("EXECUTE: artifact upload failure sets ARTIFACT_UPLOAD_FAILED in completed const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "cloud-fail-upload-machine", @@ -222,8 +222,8 @@ test("EXECUTE: event post failure logged as warning in job store", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "cloud-fail-event-machine", diff --git a/apps/desktop/test/symphony-loop-evaluate-prd.test.ts b/apps/desktop/test/symphony-loop-evaluate-prd.test.ts index f4b90299..8eee62f8 100644 --- a/apps/desktop/test/symphony-loop-evaluate-prd.test.ts +++ b/apps/desktop/test/symphony-loop-evaluate-prd.test.ts @@ -12,7 +12,7 @@ import { writePrdArtifact, } from "../src/server/operations/symphony-prd-artifacts.js"; import { DesktopGatewayServer } from "../src/server/server.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; // --------------------------------------------------------------------------- // Shared cleanup state @@ -86,8 +86,8 @@ function makeGatewayServer(options?: { const tmpDir = options?.tmpDir ?? makeTempDir(); const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getGatewayAuthToken: () => "test-token", // Dummy origin for tests that never POST loop events (Node fetch rejects port 9 as invalid). diff --git a/apps/desktop/test/symphony-loop-execute.test.ts b/apps/desktop/test/symphony-loop-execute.test.ts index 8daaf198..54d50b49 100644 --- a/apps/desktop/test/symphony-loop-execute.test.ts +++ b/apps/desktop/test/symphony-loop-execute.test.ts @@ -23,7 +23,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, test } from "node:test"; import { DesktopGatewayServer } from "../src/server/server.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; import { createFakeRunLoopScript, initGitRepo, @@ -101,8 +101,8 @@ test("EXECUTE: no PR URL in upload when worktree has no changes (git status empt const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "execute-nochange-machine", @@ -232,8 +232,8 @@ test("EXECUTE: handleProcessCompletion reads pre-written execution-result.json a const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "execute-llmresult-machine", @@ -367,8 +367,8 @@ test("EXECUTE: uses existing PR URL from gh pr view without calling gh pr create const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "execute-existingpr-machine", @@ -484,8 +484,8 @@ test("EXECUTE: git status failure sets GIT_PUSH_FAILED in completed event warnin const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "execute-gitstatus-fail-machine", diff --git a/apps/desktop/test/symphony-loop-generate-prd.test.ts b/apps/desktop/test/symphony-loop-generate-prd.test.ts index a1aa8b2a..da238479 100644 --- a/apps/desktop/test/symphony-loop-generate-prd.test.ts +++ b/apps/desktop/test/symphony-loop-generate-prd.test.ts @@ -13,7 +13,7 @@ import path from "node:path"; import { afterEach, test } from "node:test"; import { promisify } from "node:util"; import { DesktopGatewayServer } from "../src/server/server.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; const execFileAsync = promisify(execFile); @@ -161,8 +161,8 @@ test("GENERATE_PRD: rejects with 400 when no repo configured", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-norepo-machine", @@ -226,8 +226,8 @@ test("GENERATE_PRD: accepts valid command and responds 200", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-accept-machine", @@ -274,8 +274,8 @@ test("GENERATE_PRD: rejects with 400 when prompt is missing", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-noprompt-machine", @@ -382,8 +382,8 @@ test("GENERATE_PRD: spawns with worktree cwd, writes context pack, no --add-dir" const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-layout-machine", @@ -495,8 +495,8 @@ test("GENERATE_PRD: uploads { prd: { content } } when prd.md is written", async const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-upload-machine", @@ -567,8 +567,8 @@ test("GENERATE_PRD: uploads empty artifacts when prd.md is not written", async ( const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-noout-machine", @@ -638,8 +638,8 @@ test("GENERATE_PRD: cleans up worktree on failure (exit code 1)", async () => { const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getAllowedDirectories: () => [tmpDir], machineName: "genprd-cleanup-machine", diff --git a/apps/desktop/test/symphony-loop-output-events.test.ts b/apps/desktop/test/symphony-loop-output-events.test.ts index 72be355e..83a9f82c 100644 --- a/apps/desktop/test/symphony-loop-output-events.test.ts +++ b/apps/desktop/test/symphony-loop-output-events.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { afterEach, describe, test } from "node:test"; import { summarizeJsonlRecord, startOutputTailer } from "../src/server/operations/output-tailer.js"; import { DesktopGatewayServer } from "../src/server/server.js"; -import { EMPTY_CAPABILITIES, PORT_PROBE_ORDER } from "../src/shared/contracts.js"; +import { EMPTY_CAPABILITIES } from "../src/shared/contracts.js"; // --------------------------------------------------------------------------- // Shared cleanup state @@ -83,8 +83,8 @@ function makeGatewayServer(options?: { const tmpDir = options?.tmpDir ?? makeTempDir(); const server = new DesktopGatewayServer({ host: "127.0.0.1", - preferredPort: PORT_PROBE_ORDER[0], - fallbackPorts: PORT_PROBE_ORDER.slice(1), + preferredPort: 0, + fallbackPorts: [0], webAppOrigin: "https://app.symphony.com", getGatewayAuthToken: () => "test-token", getApiOrigin: options?.getApiOrigin ?? (() => "http://127.0.0.1:49152"),