From 760a12e6776e53db6f8d4ce8cf11273fa8cc44d4 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Sat, 1 Aug 2026 19:09:59 -0400 Subject: [PATCH 01/42] feat(windows): port runtime foundation from #999 Based-on: nsxdavid/ADE#999 --- AGENTS.md | 1 + apps/ade-cli/src/multiProjectRpcServer.ts | 1 + .../ade-cli/src/serviceManager/common.test.ts | 571 +++++++++++++++-- apps/ade-cli/src/serviceManager/common.ts | 66 +- apps/ade-cli/src/serviceManager/index.ts | 9 +- .../src/serviceManager/installWindows.ts | 597 ++++++++++++++++-- .../services/projects/machineLayout.test.ts | 139 +++- .../src/services/projects/machineLayout.ts | 96 ++- .../src/services/projects/projectRegistry.ts | 1 + .../runtime/brainLoopWatchdog.test.ts | 7 + .../src/services/runtime/brainLoopWatchdog.ts | 14 +- .../services/runtime/localIpcListenOptions.ts | 24 + .../src/services/runtime/socketSpawnLock.ts | 35 +- .../src/services/sync/syncHostService.test.ts | 2 +- .../src/services/sync/syncHostService.ts | 22 +- .../services/sync/syncHostSingleton.test.ts | 47 +- .../src/services/sync/syncHostSingleton.ts | 152 ++++- apps/ade-cli/src/services/sync/syncService.ts | 5 +- apps/desktop/scripts/dev.cjs | 97 ++- apps/desktop/src/main/main.ts | 51 +- .../localRuntimeConnectionPool.test.ts | 43 ++ .../localRuntimeConnectionPool.ts | 71 ++- .../runtime/projectRecoveryService.ts | 10 +- .../desktop/src/main/services/shared/utils.ts | 6 +- .../src/main/services/storage/diskPressure.ts | 8 +- .../desktop/src/main/windowAppearance.test.ts | 32 + apps/desktop/src/main/windowAppearance.ts | 32 + .../desktop/src/renderer/lib/platform.test.ts | 27 + apps/desktop/src/renderer/lib/platform.ts | 20 +- apps/desktop/src/renderer/main.tsx | 3 + apps/desktop/src/shared/machineIdentity.ts | 4 +- apps/desktop/src/shared/types/core.ts | 9 +- apps/desktop/src/shared/types/sessions.ts | 16 + apps/desktop/src/shared/types/sync.ts | 2 + apps/desktop/tsup.config.ts | 17 + scripts/dev-desktop.mjs | 49 +- scripts/dev-runtime-stop.mjs | 111 +--- scripts/dev-shared.mjs | 171 ++++- scripts/dev-shared.test.mjs | 164 +++++ scripts/run-desktop-test-shards.mjs | 19 +- 40 files changed, 2370 insertions(+), 381 deletions(-) create mode 100644 apps/ade-cli/src/services/runtime/localIpcListenOptions.ts create mode 100644 apps/desktop/src/main/windowAppearance.test.ts create mode 100644 apps/desktop/src/main/windowAppearance.ts create mode 100644 apps/desktop/src/renderer/lib/platform.test.ts create mode 100644 scripts/dev-shared.test.mjs diff --git a/AGENTS.md b/AGENTS.md index efa0e0507..3e72b5220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Utilities (run when relevant, not part of the core loop): **/audit** (targeted b ## Playbooks - `docs/playbooks/ship-lane.md` — autonomous PR-to-merge driver (poll → fix → rebase → merge). Baseline `/quality` and `/test` run before it; mutation-specific commit-bound quality revalidation runs inside it. Any agent CLI can follow it directly; Claude Code invokes it via the `/ship` skill. +- `docs/playbooks/windows-signed-release.md` — maintainer handoff for taking the gated Windows x64 build through signing, clean-host and installed-update proof, draft verification, publication, and website enablement without changing the macOS or iOS release paths. ## Working norms diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 51c79b082..58f156978 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -413,6 +413,7 @@ function resolveRemoteProjectIconInWorker( killSignal: "SIGKILL", maxBuffer: REMOTE_ICON_MAX_DATA_URL_BYTES + 16 * 1024, encoding: "utf8", + windowsHide: true, }, (error, stdout) => { if (error) { diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index 1255d4b9e..c91d7ebe0 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { createHash } from "node:crypto"; +import { spawnSync as spawnChildSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +10,7 @@ import { isStaleChannelServeCommandLine, renderCommand, renderWindowsCommand, + renderWindowsServiceLauncher, resolveAdeServeCommand, type AdeServiceCommand, type ServiceManagerProcessResult, @@ -26,14 +28,21 @@ import { installSystemdService, renderSystemdEnvironment, renderSystemdUnit, ser import { buildWindowsCreateTaskArgs, buildWindowsDeleteTaskArgs, + buildWindowsEndTaskArgs, buildWindowsQueryTaskArgs, + buildWindowsRunKeyAddArgs, + buildWindowsRunKeyDeleteArgs, + buildWindowsRunKeyQueryArgs, buildWindowsRunTaskArgs, + buildWindowsStartLauncherArgs, + getWindowsServiceStatus, installWindowsService, - isSchtasksOutputRunning, - parseSchtasksListStatus, + isWindowsTaskStateRunning, + resolveWindowsServiceLauncherPath, + resolveWindowsTaskName, resolveWindowsTaskUser, - TASK_NAME, uninstallWindowsService, + WINDOWS_POWERSHELL_COMMAND, } from "./installWindows"; const originalArgv = [...process.argv]; @@ -326,14 +335,10 @@ describe("service manager status parsers", () => { expect(parseLaunchdPrintPid("state = waiting\n")).toBeNull(); }); - it("detects running Windows scheduled tasks from schtasks output", () => { - expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Running\r\n")).toBe(true); - expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe(false); - }); - - it("parses Windows scheduled task status from schtasks LIST output", () => { - expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe("Ready"); - expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\n")).toBeNull(); + it("detects invariant Task Scheduler state values without parsing localized field labels", () => { + expect(isWindowsTaskStateRunning("Running\r\n")).toBe(true); + expect(isWindowsTaskStateRunning("Ready\r\n")).toBe(false); + expect(isWindowsTaskStateRunning("Status: Running\r\n")).toBe(false); }); }); @@ -363,8 +368,12 @@ describe("launchd service rendering", () => { expect(plist).toContain("/opt/ADE & deps"); expect(plist).toContain("ADE_HOME"); expect(plist).toContain("/Users/example/'ade'"); - expect(plist).toContain("/Users/example/'ade'/runtime/launchd.out.log"); - expect(plist).toContain("/Users/example/'ade'/runtime/launchd.err.log"); + expect(plist).toContain( + `${path.join("/Users/example/'ade'", "runtime", "launchd.out.log").replace(/'/g, "'")}`, + ); + expect(plist).toContain( + `${path.join("/Users/example/'ade'", "runtime", "launchd.err.log").replace(/'/g, "'")}`, + ); }); }); @@ -598,7 +607,11 @@ describe("launchd service install", () => { path: servicePath, }); expect(result.message).toContain("Another ADE brain is already hosting mobile sync on port 8801."); - expect(result.message).toContain("brain stop --text"); + expect(result.message).toContain( + process.platform === "win32" + ? `Stop-Process -Id ${existingPid}` + : "brain stop --text", + ); expect(killed).toEqual([]); expect(calls).toEqual([ { command: "launchctl", args: ["print", currentLaunchdDomain()] }, @@ -980,22 +993,42 @@ describe("systemd service install", () => { }); }); -describe("Windows scheduled task helpers", () => { +describe("Windows background service helpers", () => { const serviceCommand: AdeServiceCommand = { command: "C:\\Program Files\\ADE\\ade.exe", - args: ["serve"], + args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], + env: { + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "C:\\Program Files\\ADE\\resources\\ade-cli\\node_modules", + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + }, }; const taskUser = "ADEBOX\\arul"; + const serviceName = "com.ade.runtime.beta"; + const taskName = resolveWindowsTaskName({ serviceName, userName: taskUser }); it("builds schtasks create, run, query, and delete arguments without invoking schtasks", () => { - const renderedCommand = renderWindowsCommand(serviceCommand); + const renderedCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + "C:\\Users\\arul\\.ade-beta\\runtime\\brain-service.ps1", + ], + }); - expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser)).toEqual([ + expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser, taskName)).toEqual([ "/Create", "/SC", "ONLOGON", "/TN", - TASK_NAME, + taskName, "/TR", renderedCommand, "/RU", @@ -1003,15 +1036,48 @@ describe("Windows scheduled task helpers", () => { "/IT", "/F", ]); - expect(buildWindowsRunTaskArgs()).toEqual(["/Run", "/TN", TASK_NAME]); - expect(buildWindowsQueryTaskArgs()).toEqual(["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]); - expect(buildWindowsDeleteTaskArgs()).toEqual(["/Delete", "/TN", TASK_NAME, "/F"]); + expect(buildWindowsRunTaskArgs(taskName)).toEqual(["/Run", "/TN", taskName]); + expect(buildWindowsEndTaskArgs(taskName)).toEqual(["/End", "/TN", taskName]); + expect(buildWindowsQueryTaskArgs(taskName)).toEqual([ + "-NoProfile", + "-NonInteractive", + "-Command", + expect.stringContaining(`$_.TaskName -eq '${taskName}'`), + ]); + expect(buildWindowsDeleteTaskArgs(taskName)).toEqual(["/Delete", "/TN", taskName, "/F"]); }); it("resolves the Windows scheduled task user from domain and username environment values", () => { expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "arul" })).toBe("ADEBOX\\arul"); expect(resolveWindowsTaskUser({ USERNAME: "LOCALUSER" })).toBe("LOCALUSER"); expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "ADEBOX\\arul" })).toBe("ADEBOX\\arul"); + expect(resolveWindowsTaskUser({ + USERDOMAIN: "MicrosoftAccount", + USERNAME: "owner@example.com", + })).toBe("MicrosoftAccount\\owner@example.com"); + }); + + it("isolates scheduled task names by release channel and Windows principal", () => { + const stableArul = resolveWindowsTaskName({ + serviceName: "com.ade.runtime", + userName: "ADEBOX\\arul", + }); + const betaArul = resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "ADEBOX\\arul", + }); + const betaOtherUser = resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "ADEBOX\\other", + }); + + expect(stableArul).toMatch(/^ADE Runtime \(stable-[a-f0-9]{12}\)$/); + expect(betaArul).toMatch(/^ADE Runtime \(beta-[a-f0-9]{12}\)$/); + expect(new Set([stableArul, betaArul, betaOtherUser])).toHaveLength(3); + expect(resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "adebox\\ARUL", + })).toBe(betaArul); }); it("renders Windows scheduled task commands with double-quoted argv tokens", () => { @@ -1019,102 +1085,499 @@ describe("Windows scheduled task helpers", () => { command: "C:\\Program Files\\ADE\\ade.exe", args: ["serve", "--root", "C:\\path with space\\"], })).toBe("\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--root\" \"C:\\path with space\\\\\""); - expect(renderCommand(serviceCommand)).toBe("'C:\\Program Files\\ADE\\ade.exe' 'serve'"); + expect(renderCommand(serviceCommand)).toBe( + "'C:\\Program Files\\ADE\\ade.exe' 'C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs' 'serve'", + ); }); - it("rejects embedded double quotes in Windows scheduled task command tokens", () => { - expect(() => renderWindowsCommand({ + it("escapes embedded double quotes in Windows scheduled task command tokens", () => { + expect(renderWindowsCommand({ command: "C:\\Program Files\\ADE\\ade.exe", args: ["serve", "--name", "quoted \"value\""], - })).toThrow("Windows service command arguments cannot contain double quotes."); + })).toBe( + "\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--name\" \"quoted \\\"value\\\"\"", + ); }); - it("starts the scheduled task immediately after a successful create", () => { + it("renders a PowerShell launcher that preserves the service environment and quotes data literally", () => { + const script = renderWindowsServiceLauncher({ + command: "C:\\Program Files\\ADE\\ADE.exe", + args: ["C:\\Program Files\\ADE\\cli.cjs", "serve", "quoted \"value\"", "O'Brien"], + env: { + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "C:\\ADE deps\\100% & O'Brien", + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + }, + }); + + expect(script).toContain( + "[System.Environment]::SetEnvironmentVariable('ELECTRON_RUN_AS_NODE', '1', 'Process')", + ); + expect(script).toContain( + "[System.Environment]::SetEnvironmentVariable('NODE_PATH', 'C:\\ADE deps\\100% & O''Brien', 'Process')", + ); + expect(script).toContain("$startInfo.FileName = 'C:\\Program Files\\ADE\\ADE.exe'"); + expect(script).toContain( + "$startInfo.Arguments = '\"C:\\Program Files\\ADE\\cli.cjs\" \"serve\" \"quoted \\\"value\\\"\" \"O''Brien\"'", + ); + expect(script).toContain("$startInfo.CreateNoWindow = $true"); + expect(script).toContain( + "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + ); + expect(script).toContain("$process = [System.Diagnostics.Process]::Start($startInfo)"); + }); + + (process.platform === "win32" ? it : it.skip)( + "executes the generated PowerShell launcher with literal environment and argv values", + () => { + const launcherPath = path.join( + makeTempHome("ade-windows-service-exec-"), + "brain-service.ps1", + ); + const outputPath = path.join(path.dirname(launcherPath), "result.json"); + fs.writeFileSync( + launcherPath, + `\uFEFF${renderWindowsServiceLauncher({ + command: process.execPath, + args: [ + "-e", + "require('node:fs').writeFileSync(process.env.ADE_TEST_OUTPUT, JSON.stringify({ value: process.env.ADE_TEST_VALUE, args: process.argv.slice(1) }), 'utf8')", + "quoted \"value\"", + "O'Brien", + "100% & $HOME", + "naïve-東京-🚀", + ], + env: { + ADE_TEST_VALUE: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", + ADE_TEST_OUTPUT: outputPath, + }, + })}`, + "utf8", + ); + + const result = spawnChildSync( + WINDOWS_POWERSHELL_COMMAND, + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launcherPath], + { encoding: "utf8", windowsHide: true }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({ + value: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", + args: ["quoted \"value\"", "O'Brien", "100% & $HOME", "naïve-東京-🚀"], + }); + }, + ); + + it("registers and starts the per-user background service without Task Scheduler", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "SUCCESS: created", stderr: "" }, - { status: 0, stdout: "SUCCESS: attempted to run", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + { status: 0, stdout: "The operation completed successfully.", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-"), "brain-service.ps1"); + const pidPath = `${launcherPath}.pid.json`; - const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result).toMatchObject({ ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: "ADE service scheduled task installed and started.", + path: taskName, + message: "ADE per-user startup entry installed and background service started.", + }); + expect(fs.readFileSync(launcherPath, "utf8")).toBe( + `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, + ); + const scheduledCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, + ]); + }); + + it("ends and replaces a running channel task before starting the repaired runtime", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-repair-"), "brain-service.ps1"); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.slice(0, 4)).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + ]); + expect(calls.at(-2)?.args).toEqual(expect.arrayContaining(["ADD", "/V", taskName])); + expect(calls.at(-1)?.args).toEqual(buildWindowsStartLauncherArgs(launcherPath)); + }); + + it("ends and deletes only the exact legacy task before installing the channel task", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-"), "brain-service.ps1"); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.slice(0, 3)).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + ]); + expect(calls.flatMap((call) => call.args)).not.toContain("ADE Runtime "); + }); + + it("does not register or start a channel task when the running legacy task cannot be ended", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-fail-"), "brain-service.ps1"); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("legacy ADE Runtime scheduled task"); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser) }, - { command: "schtasks.exe", args: buildWindowsRunTaskArgs() }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, ]); }); - it("surfaces a clear install failure when create succeeds but immediate start fails", () => { + it("removes the per-user startup entry when immediate start fails", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, { status: 0, stdout: "SUCCESS: created", stderr: "" }, { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-start-fail-"), "brain-service.ps1"); - const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result.ok).toBe(false); - expect(result.message).toBe("ADE service scheduled task installed, but failed to start: ERROR: access is denied"); + expect(result.message).toBe("ADE per-user startup entry was installed, but the background service failed to start: ERROR: access is denied"); + const scheduledCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); expect(calls.map((call) => call.args)).toEqual([ - buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser), - buildWindowsRunTaskArgs(), + buildWindowsQueryTaskArgs("ADE Runtime"), + buildWindowsQueryTaskArgs(taskName), + buildWindowsRunKeyQueryArgs(taskName), + buildWindowsRunKeyAddArgs(taskName, scheduledCommand), + buildWindowsStartLauncherArgs(launcherPath), + buildWindowsRunKeyDeleteArgs(taskName), ]); }); - it("does not try to run the task when create fails", () => { + it("does not start the service when per-user registration fails", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ - { status: 1, stdout: "", stderr: "ERROR: create failed" }, + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: registration failed" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-create-fail-"), "brain-service.ps1"); - const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result.ok).toBe(false); - expect(result.message).toBe("ERROR: create failed"); - expect(calls).toHaveLength(1); + expect(result.message).toBe("ERROR: registration failed"); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + expect.objectContaining({ command: "reg.exe", args: expect.arrayContaining(["ADD"]) }), + ]); }); - it("reports successful scheduled task removal", () => { + it("removes legacy tasks and the per-user startup entry", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Ready", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 0, stdout: "startup value", stderr: "" }, { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-remove-"), "brain-service.ps1"); + fs.writeFileSync(launcherPath, "old launcher", "utf8"); - const result = uninstallWindowsService({ spawnSync }); + const result = uninstallWindowsService({ + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result).toMatchObject({ ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "uninstall", - path: TASK_NAME, - message: "ADE service scheduled task removed.", + path: taskName, + message: "ADE background service startup entry removed.", }); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs() }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyDeleteArgs(taskName) }, ]); + expect(fs.existsSync(launcherPath)).toBe(false); }); it("surfaces scheduled task removal failures", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Ready", stderr: "" }, { status: 1, stdout: "", stderr: "ERROR: The system cannot find the file specified." }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, ]); - const result = uninstallWindowsService({ spawnSync }); + const result = uninstallWindowsService({ serviceName, spawnSync, userName: taskUser }); expect(result.ok).toBe(false); - expect(result.message).toBe("ERROR: The system cannot find the file specified."); + expect(result.message).toContain("ERROR: The system cannot find the file specified."); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs() }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, ]); }); + + it("fails uninstall when the scheduled task launcher cannot be removed", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + ]); + const launcherPath = makeTempHome("ade-windows-service-launcher-dir-"); + + const result = uninstallWindowsService({ + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ + ok: false, + serviceName, + action: "uninstall", + path: launcherPath, + }); + expect(result.message).toContain("launcher could not be deleted"); + }); + + it("queries Task Scheduler state through PowerShell instead of localized schtasks labels", () => { + const calls: Array<{ + command: string; + args: string[]; + options: import("node:child_process").SpawnSyncOptions | undefined; + }> = []; + const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: "Running", stderr: "" }; + }; + + expect(getWindowsServiceStatus({ serviceName, spawnSync, userName: taskUser })).toMatchObject({ + ok: true, + installed: true, + running: true, + path: taskName, + }); + expect(calls).toEqual([ + { + command: WINDOWS_POWERSHELL_COMMAND, + args: buildWindowsQueryTaskArgs(taskName), + options: expect.objectContaining({ windowsHide: true }), + }, + ]); + }); + + it("hides every Windows scheduled-task lifecycle subprocess", () => { + const calls: Array<{ + command: string; + args: string[]; + options: import("node:child_process").SpawnSyncOptions | undefined; + }> = []; + const results: ServiceManagerProcessResult[] = [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]; + const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { + calls.push({ command, args, options }); + return results.shift() ?? { status: 0, stdout: "", stderr: "" }; + }; + const launcherPath = path.join( + makeTempHome("ade-windows-service-hidden-"), + "brain-service.ps1", + ); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.options?.windowsHide === true)).toBe(true); + }); + + it("distinguishes an absent task from a failed locale-independent status query", () => { + const absentCalls: Array<{ command: string; args: string[] }> = []; + const absent = getWindowsServiceStatus({ + serviceName, + spawnSync: spawnSequence(absentCalls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + ]), + userName: taskUser, + }); + const failedCalls: Array<{ command: string; args: string[] }> = []; + const failed = getWindowsServiceStatus({ + serviceName, + spawnSync: spawnSequence(failedCalls, [ + { status: 1, stdout: "", stderr: "PowerShell unavailable" }, + { status: 1, stdout: "", stderr: "" }, + ]), + userName: taskUser, + }); + + expect(absent).toMatchObject({ ok: true, installed: false, running: false }); + expect(failed).toMatchObject({ ok: false, installed: null, running: null }); + }); + + ( + process.platform === "win32" + && !os.userInfo().username.toLowerCase().startsWith("codexsandbox") + ? it + : it.skip + )( + "returns the dedicated not-found exit code from a real locale-independent task query", + () => { + const missingTaskName = `ADE Runtime Test ${process.pid} ${Date.now()}`; + const result = spawnChildSync( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskArgs(missingTaskName), + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(3); + expect(result.stdout).toBe(""); + }, + ); + + it("derives the launcher path from the channel-local ADE home", () => { + expect(resolveWindowsServiceLauncherPath({ + env: { ADE_HOME: "C:\\Users\\arul\\.ade-beta" }, + serviceName, + })).toMatch(/^C:\\Users\\arul\\\.ade-beta\\runtime\\brain-service-[a-f0-9]{12}\.ps1$/i); + }); }); function spawnSequence( diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index e0b0a5ed1..76caf5f1c 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -103,6 +103,7 @@ const RUNTIME_ENV_PASSTHROUGH = [ "ADE_RUNTIME_ROOT", "ADE_RUNTIME_NODE_MODULES", "ADE_DEFAULT_ROLE", + "ADE_WINDOWS_USER_SID", ] as const; function runtimeEnvironment(): Record | undefined { @@ -342,9 +343,6 @@ export function shellQuote(value: string): string { } export function cmdQuote(value: string): string { - if (value.includes("\"")) { - throw new Error("Windows service command arguments cannot contain double quotes."); - } let quoted = "\""; let backslashes = 0; for (const char of value) { @@ -352,6 +350,12 @@ export function cmdQuote(value: string): string { backslashes += 1; continue; } + if (char === "\"") { + quoted += "\\".repeat((backslashes * 2) + 1); + quoted += "\""; + backslashes = 0; + continue; + } quoted += "\\".repeat(backslashes); quoted += char; backslashes = 0; @@ -395,6 +399,62 @@ export function renderWindowsCommand(command: AdeServiceCommand): string { return [command.command, ...command.args].map(cmdQuote).join(" "); } +function powerShellSingleQuotedLiteral(value: string): string { + if (value.includes("\0")) { + throw new Error("Windows service command values cannot contain NUL bytes."); + } + return `'${value.replace(/'/g, "''")}'`; +} + +export function renderWindowsServiceLauncher( + command: AdeServiceCommand, + options: { pidPath?: string } = {}, +): string { + const environment = Object.entries(command.env ?? {}).sort(([left], [right]) => + left.localeCompare(right), + ); + const environmentLines = environment.map(([key, value]) => { + if (!key || key.includes("=") || key.includes("\0")) { + throw new Error(`Invalid Windows service environment variable name: ${JSON.stringify(key)}.`); + } + return `[System.Environment]::SetEnvironmentVariable(${powerShellSingleQuotedLiteral(key)}, ${powerShellSingleQuotedLiteral(value)}, 'Process')`; + }); + const commandLine = command.args.map(cmdQuote).join(" "); + const processLines = options.pidPath + ? [ + "$process = [System.Diagnostics.Process]::Start($startInfo)", + "if ($null -eq $process) { throw 'Windows failed to start the ADE brain process.' }", + "$pidRecord = '{\"supervisorPid\":' + $PID.ToString([Globalization.CultureInfo]::InvariantCulture) + ',\"runtimePid\":' + $process.Id.ToString([Globalization.CultureInfo]::InvariantCulture) + '}'", + `[IO.File]::WriteAllText(${powerShellSingleQuotedLiteral(options.pidPath)}, $pidRecord, [Text.Encoding]::ASCII)`, + "try {", + " $process.WaitForExit()", + " $exitCode = $process.ExitCode", + "} finally {", + ` Remove-Item -LiteralPath ${powerShellSingleQuotedLiteral(options.pidPath)} -Force -ErrorAction SilentlyContinue`, + "}", + "exit $exitCode", + ] + : [ + "$process = [System.Diagnostics.Process]::Start($startInfo)", + "if ($null -eq $process) { throw 'Windows failed to start the ADE brain process.' }", + "$process.WaitForExit()", + "exit $process.ExitCode", + ]; + + return [ + "$ErrorActionPreference = 'Stop'", + ...environmentLines, + "$startInfo = New-Object System.Diagnostics.ProcessStartInfo", + `$startInfo.FileName = ${powerShellSingleQuotedLiteral(command.command)}`, + `$startInfo.Arguments = ${powerShellSingleQuotedLiteral(commandLine)}`, + "$startInfo.UseShellExecute = $false", + "$startInfo.CreateNoWindow = $true", + "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + ...processLines, + "", + ].join("\r\n"); +} + function streamToText(value: string | Buffer | null | undefined): string { if (typeof value === "string") return value.trim(); if (Buffer.isBuffer(value)) return value.toString("utf8").trim(); diff --git a/apps/ade-cli/src/serviceManager/index.ts b/apps/ade-cli/src/serviceManager/index.ts index 5642b3de5..91d40f304 100644 --- a/apps/ade-cli/src/serviceManager/index.ts +++ b/apps/ade-cli/src/serviceManager/index.ts @@ -3,7 +3,12 @@ import type { ServiceManagerResult, ServiceManagerStatusResult } from "./common" import { ADE_RUNTIME_SERVICE_NAME } from "./common"; import { getLaunchdServiceMainPid, getLaunchdServiceStatus, installLaunchdService, uninstallLaunchdService } from "./installLaunchd"; import { getSystemdServiceStatus, installSystemdService, uninstallSystemdService } from "./installSystemd"; -import { getWindowsServiceStatus, installWindowsService, uninstallWindowsService } from "./installWindows"; +import { + getWindowsServiceStatus, + installWindowsService, + readWindowsServicePidRecord, + uninstallWindowsService, +} from "./installWindows"; export type { ServiceManagerResult, ServiceManagerStatusResult } from "./common"; @@ -25,6 +30,8 @@ export function getRuntimeServiceMainPid(): number | null { const pid = Number(String(result.stdout ?? "").trim()); return Number.isFinite(pid) && pid > 0 ? Math.floor(pid) : null; } + case "win32": + return readWindowsServicePidRecord()?.runtimePid ?? null; default: return null; } diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 9606b1d8a..bb588abc9 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -1,20 +1,38 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import { ADE_RUNTIME_SERVICE_NAME, type AdeServiceCommand, renderWindowsCommand, + renderWindowsServiceLauncher, resolveAdeServeCommand, serviceManagerResultText, type ServiceManagerResult, type ServiceManagerSpawnSync, type ServiceManagerStatusResult, } from "./common"; +import { resolveMachineAdeDir } from "../services/projects/machineLayout"; export const TASK_NAME = "ADE Runtime"; +export const WINDOWS_POWERSHELL_COMMAND = "powershell.exe"; +export const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +const TASK_NOT_FOUND_EXIT_CODE = 3; +const REGISTRY_VALUE_NOT_FOUND_EXIT_CODE = 1; + +export type WindowsServicePidRecord = { + supervisorPid: number; + runtimePid: number; +}; type WindowsServiceManagerDeps = { command?: AdeServiceCommand; + env?: NodeJS.ProcessEnv; + launcherPath?: string; + pidPath?: string; + serviceName?: string; spawnSync?: ServiceManagerSpawnSync; userName?: string; }; @@ -22,7 +40,7 @@ type WindowsServiceManagerDeps = { export function resolveWindowsTaskUser(env: NodeJS.ProcessEnv = process.env): string { const username = env.USERNAME?.trim() || os.userInfo().username.trim(); if (!username) { - throw new Error("Unable to resolve current Windows user for scheduled task registration."); + throw new Error("Unable to resolve the current Windows user for background-service registration."); } const domain = env.USERDOMAIN?.trim(); if (domain && !username.includes("\\")) { @@ -31,13 +49,78 @@ export function resolveWindowsTaskUser(env: NodeJS.ProcessEnv = process.env): st return username; } -export function buildWindowsCreateTaskArgs(command: string, userName = resolveWindowsTaskUser()): string[] { +function serviceChannelLabel(serviceName: string): string { + const normalized = serviceName.trim().toLowerCase(); + if (normalized === "com.ade.runtime") return "stable"; + if (normalized.endsWith(".alpha")) return "alpha"; + if (normalized.endsWith(".beta")) return "beta"; + return "custom"; +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +export function resolveWindowsTaskName(args: { + serviceName?: string; + userName?: string; +} = {}): string { + const serviceName = args.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const userName = args.userName ?? resolveWindowsTaskUser(); + const identity = `${serviceName.trim().toLowerCase()}\0${userName.trim().toLowerCase()}`; + return `${TASK_NAME} (${serviceChannelLabel(serviceName)}-${shortHash(identity)})`; +} + +export function resolveWindowsServiceLauncherPath(args: { + env?: NodeJS.ProcessEnv; + serviceName?: string; +} = {}): string { + const env = args.env ?? process.env; + const serviceName = args.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const adeDir = path.win32.resolve(env.ADE_HOME?.trim() || resolveMachineAdeDir(env)); + return path.win32.join( + adeDir, + "runtime", + `brain-service-${shortHash(serviceName.trim().toLowerCase())}.ps1`, + ); +} + +export function resolveWindowsServicePidPath(args: { + env?: NodeJS.ProcessEnv; + serviceName?: string; +} = {}): string { + return `${resolveWindowsServiceLauncherPath(args)}.pid.json`; +} + +export function readWindowsServicePidRecord(args: { + env?: NodeJS.ProcessEnv; + serviceName?: string; + pidPath?: string; +} = {}): WindowsServicePidRecord | null { + const pidPath = args.pidPath ?? resolveWindowsServicePidPath(args); + try { + const parsed = JSON.parse(fs.readFileSync(pidPath, "utf8")) as Partial; + const supervisorPid = Number(parsed.supervisorPid); + const runtimePid = Number(parsed.runtimePid); + if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) return null; + if (!Number.isInteger(runtimePid) || runtimePid <= 0) return null; + return { supervisorPid, runtimePid }; + } catch { + return null; + } +} + +export function buildWindowsCreateTaskArgs( + command: string, + userName = resolveWindowsTaskUser(), + taskName = resolveWindowsTaskName({ userName }), +): string[] { return [ "/Create", "/SC", "ONLOGON", "/TN", - TASK_NAME, + taskName, "/TR", command, "/RU", @@ -47,115 +130,519 @@ export function buildWindowsCreateTaskArgs(command: string, userName = resolveWi ]; } -export function buildWindowsRunTaskArgs(): string[] { - return ["/Run", "/TN", TASK_NAME]; +export function buildWindowsRunTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + return ["/Run", "/TN", taskName]; +} + +export function buildWindowsEndTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + return ["/End", "/TN", taskName]; +} + +function powerShellSingleQuotedLiteral(value: string): string { + if (value.includes("\0")) { + throw new Error("Windows scheduled task names cannot contain NUL bytes."); + } + return `'${value.replace(/'/g, "''")}'`; +} + +export function buildWindowsQueryTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + const taskNameLiteral = powerShellSingleQuotedLiteral(taskName); + const query = [ + "$ErrorActionPreference = 'Stop'", + `try { $task = Get-ScheduledTask -TaskPath '\\' -ErrorAction Stop | Where-Object { $_.TaskName -eq ${taskNameLiteral} } | Select-Object -First 1 } catch { [Console]::Error.Write($_.Exception.Message); exit 4 }`, + `if ($null -eq $task) { exit ${TASK_NOT_FOUND_EXIT_CODE} }`, + "[Console]::Out.Write($task.State.ToString())", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; +} + +export function buildWindowsDeleteTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + return ["/Delete", "/TN", taskName, "/F"]; +} + +export function buildWindowsRunKeyQueryArgs(valueName: string): string[] { + return ["QUERY", WINDOWS_RUN_KEY, "/V", valueName]; +} + +export function buildWindowsRunKeyAddArgs(valueName: string, command: string): string[] { + return ["ADD", WINDOWS_RUN_KEY, "/V", valueName, "/T", "REG_SZ", "/D", command, "/F"]; } -export function buildWindowsQueryTaskArgs(): string[] { - return ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]; +export function buildWindowsRunKeyDeleteArgs(valueName: string): string[] { + return ["DELETE", WINDOWS_RUN_KEY, "/V", valueName, "/F"]; } -export function buildWindowsDeleteTaskArgs(): string[] { - return ["/Delete", "/TN", TASK_NAME, "/F"]; +export function buildWindowsStartLauncherArgs(launcherPath: string): string[] { + const childArgs = [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ]; + const startCommand = [ + `$process = Start-Process -FilePath ${powerShellSingleQuotedLiteral(WINDOWS_POWERSHELL_COMMAND)}`, + `-ArgumentList @(${childArgs.map(powerShellSingleQuotedLiteral).join(", ")})`, + "-WindowStyle Hidden -PassThru", + ].join(" "); + const command = `${startCommand}; [Console]::Out.Write($process.Id)`; + return ["-NoProfile", "-NonInteractive", "-Command", command]; } -export function parseSchtasksListStatus(output: string): string | null { - const match = /^\s*Status:\s*(.*?)\s*$/im.exec(output); - return match?.[1] ?? null; +export function buildWindowsSupervisorQueryArgs(pid: number, launcherPath: string): string[] { + const launcherLiteral = powerShellSingleQuotedLiteral(launcherPath); + const query = [ + "$ErrorActionPreference = 'Stop'", + `$process = Get-CimInstance Win32_Process -Filter ${powerShellSingleQuotedLiteral(`ProcessId = ${pid}`)} -ErrorAction SilentlyContinue`, + "if ($null -eq $process) { exit 3 }", + "$commandLine = [string]$process.CommandLine", + `$matchesLauncher = $commandLine.IndexOf(${launcherLiteral}, [StringComparison]::OrdinalIgnoreCase) -ge 0`, + "if (-not $matchesLauncher -or $process.Name -notmatch '^powershell(?:\\.exe)?$') { exit 4 }", + "[Console]::Out.Write($process.ProcessId)", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; } -export function isSchtasksOutputRunning(output: string): boolean { - return parseSchtasksListStatus(output)?.toLowerCase() === "running"; +export function isWindowsTaskStateRunning(output: string | Buffer | null | undefined): boolean { + const state = Buffer.isBuffer(output) ? output.toString("utf8") : output ?? ""; + return state.trim().toLowerCase() === "running"; +} + +type WindowsTaskRemovalResult = + | { ok: true; removed: boolean } + | { ok: false; message: string }; + +function removeWindowsTaskIfPresent( + run: ServiceManagerSpawnSync, + taskName: string, + description: string, +): WindowsTaskRemovalResult { + const query = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskArgs(taskName), + { encoding: "utf8", windowsHide: true }, + ); + if (query.status === TASK_NOT_FOUND_EXIT_CODE) { + return { ok: true, removed: false }; + } + if (query.status !== 0) { + return { + ok: false, + message: `Unable to query the ${description}: ${serviceManagerResultText(query) || "PowerShell task query failed."}`, + }; + } + if (isWindowsTaskStateRunning(query.stdout)) { + const end = run("schtasks.exe", buildWindowsEndTaskArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); + if (end.status !== 0) { + return { + ok: false, + message: `Unable to end the ${description}: ${serviceManagerResultText(end) || "schtasks end failed."}`, + }; + } + } + const remove = run("schtasks.exe", buildWindowsDeleteTaskArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); + if (remove.status !== 0) { + return { + ok: false, + message: `Unable to delete the ${description}: ${serviceManagerResultText(remove) || "schtasks delete failed."}`, + }; + } + return { ok: true, removed: true }; +} + +function windowsLauncherCommand(launcherPath: string): string { + return renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); +} + +function queryWindowsSupervisor( + run: ServiceManagerSpawnSync, + launcherPath: string, + pidPath: string, +): { running: boolean; pid: number | null; error: string | null } { + const record = readWindowsServicePidRecord({ pidPath }); + if (!record) return { running: false, pid: null, error: null }; + const result = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsSupervisorQueryArgs(record.supervisorPid, launcherPath), + { encoding: "utf8", windowsHide: true }, + ); + if (result.status === 0) { + return { running: true, pid: record.supervisorPid, error: null }; + } + if (result.status === 3 || result.status === 4) { + try { fs.rmSync(pidPath, { force: true }); } catch { /* advisory record */ } + return { running: false, pid: null, error: null }; + } + return { + running: false, + pid: null, + error: serviceManagerResultText(result) || "Unable to inspect the ADE startup process.", + }; +} + +function removeWindowsRunEntryIfPresent( + run: ServiceManagerSpawnSync, + valueName: string, + launcherPath: string, + pidPath: string, +): WindowsTaskRemovalResult { + const query = run("reg.exe", buildWindowsRunKeyQueryArgs(valueName), { + encoding: "utf8", + windowsHide: true, + }); + const installed = query.status === 0; + if (!installed && query.status !== REGISTRY_VALUE_NOT_FOUND_EXIT_CODE) { + return { + ok: false, + message: `Unable to query the ADE per-user startup entry: ${serviceManagerResultText(query) || "reg query failed."}`, + }; + } + + const supervisor = queryWindowsSupervisor(run, launcherPath, pidPath); + if (supervisor.error) return { ok: false, message: supervisor.error }; + if (supervisor.running && supervisor.pid) { + const stop = run("taskkill.exe", ["/PID", String(supervisor.pid), "/T", "/F"], { + encoding: "utf8", + windowsHide: true, + }); + if (stop.status !== 0) { + const recheck = queryWindowsSupervisor(run, launcherPath, pidPath); + if (recheck.running || recheck.error) { + return { + ok: false, + message: `Unable to stop the ADE startup process: ${serviceManagerResultText(stop) || recheck.error || "taskkill failed."}`, + }; + } + } + } + + if (installed) { + const remove = run("reg.exe", buildWindowsRunKeyDeleteArgs(valueName), { + encoding: "utf8", + windowsHide: true, + }); + if (remove.status !== 0) { + return { + ok: false, + message: `Unable to delete the ADE per-user startup entry: ${serviceManagerResultText(remove) || "reg delete failed."}`, + }; + } + } + try { fs.rmSync(pidPath, { force: true }); } catch { /* advisory record */ } + return { ok: true, removed: installed || supervisor.running }; } export function installWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; - const command = renderWindowsCommand(deps.command ?? resolveAdeServeCommand()); + const env = deps.env ?? process.env; + const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const serviceCommand = deps.command ?? resolveAdeServeCommand(); let userName: string; try { - userName = deps.userName ?? resolveWindowsTaskUser(); + userName = deps.userName ?? resolveWindowsTaskUser(env); } catch (error) { return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, + path: null, message: error instanceof Error ? error.message : "Unable to resolve current Windows user.", }; } - const result = run("schtasks.exe", buildWindowsCreateTaskArgs(command, userName), { encoding: "utf8" }); - if (result.status !== 0) { + const taskName = resolveWindowsTaskName({ serviceName, userName }); + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + try { + fs.mkdirSync(path.dirname(launcherPath), { recursive: true }); + fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, { + encoding: "utf8", + mode: 0o600, + }); + } catch (error) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: error instanceof Error + ? `Unable to write the Windows brain launcher: ${error.message}` + : "Unable to write the Windows brain launcher.", + }; + } + const legacyRemoval = removeWindowsTaskIfPresent( + run, + TASK_NAME, + "legacy ADE Runtime scheduled task", + ); + if (!legacyRemoval.ok) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: legacyRemoval.message, + }; + } + const currentRemoval = removeWindowsTaskIfPresent( + run, + taskName, + "existing ADE service scheduled task", + ); + if (!currentRemoval.ok) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: currentRemoval.message, + }; + } + const startupRemoval = removeWindowsRunEntryIfPresent( + run, + taskName, + launcherPath, + pidPath, + ); + if (!startupRemoval.ok) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: startupRemoval.message, + }; + } + const command = windowsLauncherCommand(launcherPath); + const registration = run( + "reg.exe", + buildWindowsRunKeyAddArgs(taskName, command), + { encoding: "utf8", windowsHide: true }, + ); + if (registration.status !== 0) { return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: serviceManagerResultText(result) || "schtasks create failed.", + path: taskName, + message: serviceManagerResultText(registration) || "Unable to create the ADE per-user startup entry.", }; } - const start = run("schtasks.exe", buildWindowsRunTaskArgs(), { encoding: "utf8" }); + const start = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsStartLauncherArgs(launcherPath), { + encoding: "utf8", + windowsHide: true, + }); if (start.status !== 0) { + run("reg.exe", buildWindowsRunKeyDeleteArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: `ADE service scheduled task installed, but failed to start: ${serviceManagerResultText(start) || "schtasks run failed."}`, + path: taskName, + message: `ADE per-user startup entry was installed, but the background service failed to start: ${serviceManagerResultText(start) || "PowerShell launch failed."}`, }; } return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: "ADE service scheduled task installed and started.", + path: taskName, + message: "ADE per-user startup entry installed and background service started.", }; } -export function uninstallWindowsService(deps: Pick = {}): ServiceManagerResult { +export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; - const result = run("schtasks.exe", buildWindowsDeleteTaskArgs(), { encoding: "utf8" }); - if (result.status !== 0) { + const env = deps.env ?? process.env; + const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + let userName: string; + try { + userName = deps.userName ?? resolveWindowsTaskUser(env); + } catch (error) { + return { + ok: false, + serviceName, + action: "uninstall", + path: null, + message: error instanceof Error ? error.message : "Unable to resolve current Windows user.", + }; + } + const taskName = resolveWindowsTaskName({ serviceName, userName }); + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + const currentRemoval = removeWindowsTaskIfPresent( + run, + taskName, + "ADE service scheduled task", + ); + const legacyRemoval = taskName === TASK_NAME + ? { ok: true as const, removed: false } + : removeWindowsTaskIfPresent( + run, + TASK_NAME, + "legacy ADE Runtime scheduled task", + ); + const startupRemoval = removeWindowsRunEntryIfPresent( + run, + taskName, + launcherPath, + pidPath, + ); + const removalErrors = [currentRemoval, legacyRemoval, startupRemoval] + .filter((result): result is Extract => !result.ok) + .map((result) => result.message); + if (removalErrors.length > 0) { return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "uninstall", - path: TASK_NAME, - message: serviceManagerResultText(result) || "schtasks delete failed.", + path: taskName, + message: removalErrors.join(" "), + }; + } + try { + fs.rmSync(launcherPath, { force: true }); + } catch (error) { + return { + ok: false, + serviceName, + action: "uninstall", + path: launcherPath, + message: `ADE startup entry was removed, but its launcher could not be deleted: ${ + error instanceof Error ? error.message : String(error) + }`, }; } return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "uninstall", - path: TASK_NAME, - message: "ADE service scheduled task removed.", + path: taskName, + message: "ADE background service startup entry removed.", }; } -export function getWindowsServiceStatus(): ServiceManagerStatusResult { - const result = spawnSync("schtasks.exe", buildWindowsQueryTaskArgs(), { encoding: "utf8" }); - if (result.status !== 0) { +export function getWindowsServiceStatus( + deps: Pick = {}, +): ServiceManagerStatusResult { + const run = deps.spawnSync ?? spawnSync; + const env = deps.env ?? process.env; + const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + let userName: string; + try { + userName = deps.userName ?? resolveWindowsTaskUser(env); + } catch (error) { + return { + ok: false, + serviceName, + action: "status", + installed: null, + running: null, + path: null, + message: error instanceof Error ? error.message : "Unable to resolve current Windows user.", + }; + } + const taskName = resolveWindowsTaskName({ serviceName, userName }); + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + const taskResult = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskArgs(taskName), + { encoding: "utf8", windowsHide: true }, + ); + if (taskResult.status === 0) { + const running = isWindowsTaskStateRunning(taskResult.stdout); return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, + action: "status", + installed: true, + running, + path: taskName, + message: running + ? "ADE service scheduled task is running." + : "ADE service scheduled task is installed.", + }; + } + const startupResult = run("reg.exe", buildWindowsRunKeyQueryArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); + if (startupResult.status === 0) { + const supervisor = queryWindowsSupervisor(run, launcherPath, pidPath); + return { + ok: supervisor.error == null, + serviceName, + action: "status", + installed: true, + running: supervisor.error ? null : supervisor.running, + path: taskName, + message: supervisor.error + ?? (supervisor.running + ? "ADE per-user background service is running." + : "ADE per-user startup entry is installed, but the background service is not running."), + }; + } + if (taskResult.status !== TASK_NOT_FOUND_EXIT_CODE) { + return { + ok: false, + serviceName, + action: "status", + installed: null, + running: null, + path: taskName, + message: serviceManagerResultText(taskResult) || "Unable to query the legacy ADE scheduled task.", + }; + } + if (startupResult.status !== REGISTRY_VALUE_NOT_FOUND_EXIT_CODE) { + return { + ok: false, + serviceName, action: "status", - installed: false, - running: false, - path: TASK_NAME, - message: serviceManagerResultText(result) || "ADE service scheduled task is not installed.", + installed: null, + running: null, + path: taskName, + message: serviceManagerResultText(startupResult) || "Unable to query the ADE per-user startup entry.", }; } - const running = isSchtasksOutputRunning(result.stdout); return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "status", - installed: true, - running, - path: TASK_NAME, - message: running - ? "ADE service scheduled task is running." - : "ADE service scheduled task is installed.", + installed: false, + running: false, + path: taskName, + message: "ADE background service startup entry is not installed.", }; } diff --git a/apps/ade-cli/src/services/projects/machineLayout.test.ts b/apps/ade-cli/src/services/projects/machineLayout.test.ts index 5e033a95e..63c25e3ea 100644 --- a/apps/ade-cli/src/services/projects/machineLayout.test.ts +++ b/apps/ade-cli/src/services/projects/machineLayout.test.ts @@ -1,31 +1,131 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveMachineAdeLayout } from "./machineLayout"; describe("resolveMachineAdeLayout", () => { - it("keeps the stable Windows runtime pipe name for the default ADE home", () => { - const layout = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade" }, + const arulEnv = { + USERDOMAIN: "ADEBOX", + USERNAME: "arul", + USERPROFILE: "C:\\Users\\arul", + }; + + it("derives stable Windows pipe names from the canonical ADE home and user identity", () => { + const first = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: "C:\\Users\\arul\\.ade" }, + "win32", + ); + const equivalent = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: "C:/Users/arul/.ade" }, "win32", ); - expect(layout.socketPath).toBe("\\\\.\\pipe\\ade-runtime"); + expect(first.socketPath).toMatch(/^\\\\\.\\pipe\\ade-runtime-stable-[a-f0-9]{16}$/); + expect(equivalent.socketPath).toBe(first.socketPath); + expect(first.desktopBridgeSocketPath).toMatch( + /^\\\\\.\\pipe\\ade-desktop-bridge-stable-[a-f0-9]{16}$/, + ); + expect(equivalent.desktopBridgeSocketPath).toBe(first.desktopBridgeSocketPath); }); - it("uses distinct Windows runtime pipes for channel ADE homes", () => { + it("uses distinct Windows runtime pipes for release channels", () => { const alpha = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade-alpha" }, + { + ...arulEnv, + ADE_HOME: "C:\\Users\\arul\\.ade-alpha", + ADE_PACKAGE_CHANNEL: "alpha", + }, "win32", ); const beta = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade-beta" }, + { + ...arulEnv, + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + }, + "win32", + ); + + expect(alpha.socketPath).not.toBe(beta.socketPath); + expect(alpha.socketPath).toContain("ade-runtime-alpha-"); + expect(beta.socketPath).toContain("ade-runtime-beta-"); + }); + + it("isolates Windows runtime and desktop-bridge pipes for different users", () => { + const arul = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: "D:\\Shared\\ADE" }, + "win32", + ); + const other = resolveMachineAdeLayout( + { + USERDOMAIN: "ADEBOX", + USERNAME: "other", + USERPROFILE: "C:\\Users\\other", + ADE_HOME: "D:\\Shared\\ADE", + }, "win32", ); - expect(alpha.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-alpha"); - expect(beta.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-beta"); + expect(arul.socketPath).not.toBe(other.socketPath); + expect(arul.desktopBridgeSocketPath).not.toBe(other.desktopBridgeSocketPath); }); + it("prefers a provided Windows SID over mutable account labels", () => { + const first = resolveMachineAdeLayout( + { + ...arulEnv, + ADE_WINDOWS_USER_SID: "S-1-5-21-1000", + ADE_HOME: "D:\\Shared\\ADE", + }, + "win32", + ); + const renamed = resolveMachineAdeLayout( + { + USERDOMAIN: "NEWDOMAIN", + USERNAME: "renamed", + USERPROFILE: "C:\\Users\\renamed", + ADE_WINDOWS_USER_SID: "S-1-5-21-1000", + ADE_HOME: "D:\\Shared\\ADE", + }, + "win32", + ); + + expect(renamed.socketPath).toBe(first.socketPath); + expect(renamed.desktopBridgeSocketPath).toBe(first.desktopBridgeSocketPath); + }); + + (process.platform === "win32" ? it : it.skip)( + "canonicalizes existing Windows ancestor casing before ADE_HOME is created", + () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-layout-case-")); + const mixedCaseParent = path.join(tempRoot, "MiXeD-Parent"); + fs.mkdirSync(mixedCaseParent); + const canonicalHome = path.join(mixedCaseParent, "Missing-ADE-Home"); + const alternateHome = path.join( + tempRoot, + "mixed-parent", + "Missing-ADE-Home", + ); + try { + const canonical = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: canonicalHome }, + "win32", + ); + const alternate = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: alternateHome }, + "win32", + ); + + expect(alternate.socketPath).toBe(canonical.socketPath); + expect(alternate.desktopBridgeSocketPath).toBe(canonical.desktopBridgeSocketPath); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, + ); + it("derives the desktop-bridge socket from the ADE home", () => { const stable = resolveMachineAdeLayout( { ADE_HOME: "/Users/arul/.ade" }, @@ -35,20 +135,29 @@ describe("resolveMachineAdeLayout", () => { { ADE_HOME: "/Users/arul/.ade-beta" }, "darwin", ); - expect(stable.desktopBridgeSocketPath).toBe("/Users/arul/.ade/sock/desktop-bridge.sock"); - expect(beta.desktopBridgeSocketPath).toBe("/Users/arul/.ade-beta/sock/desktop-bridge.sock"); + expect(stable.desktopBridgeSocketPath).toBe( + path.join(path.resolve("/Users/arul/.ade"), "sock", "desktop-bridge.sock"), + ); + expect(beta.desktopBridgeSocketPath).toBe( + path.join(path.resolve("/Users/arul/.ade-beta"), "sock", "desktop-bridge.sock"), + ); }); it("uses distinct Windows desktop-bridge pipes for channel ADE homes", () => { const stable = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade" }, + { ...arulEnv, ADE_HOME: "C:\\Users\\arul\\.ade" }, "win32", ); const beta = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade-beta" }, + { + ...arulEnv, + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + }, "win32", ); - expect(stable.desktopBridgeSocketPath).toBe("\\\\.\\pipe\\ade-desktop-bridge"); - expect(beta.desktopBridgeSocketPath).toBe("\\\\.\\pipe\\ade-desktop-bridge-ade-beta"); + expect(stable.desktopBridgeSocketPath).not.toBe(beta.desktopBridgeSocketPath); + expect(stable.desktopBridgeSocketPath).toContain("ade-desktop-bridge-stable-"); + expect(beta.desktopBridgeSocketPath).toContain("ade-desktop-bridge-beta-"); }); }); diff --git a/apps/ade-cli/src/services/projects/machineLayout.ts b/apps/ade-cli/src/services/projects/machineLayout.ts index 071b57495..c02a9658a 100644 --- a/apps/ade-cli/src/services/projects/machineLayout.ts +++ b/apps/ade-cli/src/services/projects/machineLayout.ts @@ -1,4 +1,6 @@ import os from "node:os"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; export type MachineAdeLayout = { @@ -28,16 +30,89 @@ export function resolveMachineAdeDir(env: NodeJS.ProcessEnv = process.env): stri return path.join(os.homedir(), ".ade"); } -function windowsPipePathForAdeDir(adeDir: string): string { - const homeName = path.basename(adeDir).replace(/[^a-zA-Z0-9_-]+/g, "-"); - if (!homeName || homeName === "-ade") return "\\\\.\\pipe\\ade-runtime"; - return `\\\\.\\pipe\\ade-runtime-${homeName.replace(/^-+/, "")}`; +function windowsUserIdentity(env: NodeJS.ProcessEnv): string { + const sid = env.ADE_WINDOWS_USER_SID?.trim() || env.USER_SID?.trim(); + if (sid) return `sid:${sid.toLowerCase()}`; + const username = env.USERNAME?.trim(); + const domain = env.USERDOMAIN?.trim(); + if (username) { + return `account:${domain ? `${domain}\\` : ""}${username}`.toLowerCase(); + } + const profile = env.USERPROFILE?.trim(); + if (profile) { + return `profile:${path.win32.resolve(profile).toLowerCase()}`; + } + const userInfo = os.userInfo(); + return `fallback:${userInfo.username}\0${userInfo.homedir}`.toLowerCase(); } -function windowsDesktopBridgePipePathForAdeDir(adeDir: string): string { - const homeName = path.basename(adeDir).replace(/[^a-zA-Z0-9_-]+/g, "-"); - if (!homeName || homeName === "-ade") return "\\\\.\\pipe\\ade-desktop-bridge"; - return `\\\\.\\pipe\\ade-desktop-bridge-${homeName.replace(/^-+/, "")}`; +function windowsChannelIdentity(adeDir: string, env: NodeJS.ProcessEnv): { + identity: string; + label: string; +} { + const serviceName = env.ADE_RUNTIME_SERVICE_NAME?.trim().toLowerCase(); + const explicitChannel = env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); + const homeName = path.win32.basename(adeDir).toLowerCase(); + const inferred = homeName === ".ade-alpha" + ? "alpha" + : homeName === ".ade-beta" + ? "beta" + : homeName === ".ade" + ? "stable" + : "custom"; + const label = explicitChannel === "alpha" || explicitChannel === "beta" + ? explicitChannel + : explicitChannel === "stable" + ? "stable" + : serviceName?.endsWith(".alpha") + ? "alpha" + : serviceName?.endsWith(".beta") + ? "beta" + : serviceName === "com.ade.runtime" + ? "stable" + : inferred; + return { + identity: serviceName || explicitChannel || inferred, + label, + }; +} + +function canonicalWindowsPath(value: string): string { + const original = path.win32.resolve(value).replace(/\//g, "\\"); + const missingParts: string[] = []; + let cursor = original; + for (;;) { + try { + return path.win32.join(fs.realpathSync.native(cursor), ...missingParts); + } catch { + const parent = path.win32.dirname(cursor); + if (parent === cursor) return original; + missingParts.unshift(path.win32.basename(cursor)); + cursor = parent; + } + } +} + +function windowsPipeIdentity( + adeDir: string, + env: NodeJS.ProcessEnv, +): { channelLabel: string; hash: string } { + const canonicalAdeDir = canonicalWindowsPath(adeDir); + const channel = windowsChannelIdentity(canonicalAdeDir, env); + const hash = createHash("sha256") + .update(`${canonicalAdeDir}\0${channel.identity}\0${windowsUserIdentity(env)}`) + .digest("hex") + .slice(0, 16); + return { channelLabel: channel.label, hash }; +} + +function windowsPipePath( + prefix: "ade-runtime" | "ade-desktop-bridge", + adeDir: string, + env: NodeJS.ProcessEnv, +): string { + const identity = windowsPipeIdentity(adeDir, env); + return `\\\\.\\pipe\\${prefix}-${identity.channelLabel}-${identity.hash}`; } export function resolveMachineAdeLayout( @@ -45,13 +120,14 @@ export function resolveMachineAdeLayout( platform: NodeJS.Platform = process.platform, ): MachineAdeLayout { const adeDir = resolveMachineAdeDir(env); + const pipeAdeDir = env.ADE_HOME?.trim() || adeDir; const secretsDir = path.join(adeDir, "secrets"); const sockDir = path.join(adeDir, "sock"); const socketPath = platform === "win32" - ? windowsPipePathForAdeDir(adeDir) + ? windowsPipePath("ade-runtime", pipeAdeDir, env) : path.join(sockDir, "ade.sock"); const desktopBridgeSocketPath = platform === "win32" - ? windowsDesktopBridgePipePathForAdeDir(adeDir) + ? windowsPipePath("ade-desktop-bridge", pipeAdeDir, env) : path.join(sockDir, "desktop-bridge.sock"); return { adeDir, diff --git a/apps/ade-cli/src/services/projects/projectRegistry.ts b/apps/ade-cli/src/services/projects/projectRegistry.ts index edc1b0f7e..db9bd9db4 100644 --- a/apps/ade-cli/src/services/projects/projectRegistry.ts +++ b/apps/ade-cli/src/services/projects/projectRegistry.ts @@ -83,6 +83,7 @@ function readGitOriginUrl(rootPath: string): string | null { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, + windowsHide: true, }); if (result.status !== 0) return null; const value = result.stdout.trim(); diff --git a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts index 988285072..8201be35c 100644 --- a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts +++ b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts @@ -12,6 +12,7 @@ import { evaluateBrainLoopWatchdog, readBrainLoopWatchdogLastWedge, recoverBrainLoopWatchdogBreadcrumb, + resolveBrainLoopWatchdogThresholdMs, startBrainLoopWatchdog, } from "./brainLoopWatchdog"; @@ -75,6 +76,12 @@ describe("brainLoopWatchdog", () => { }); }); + it("allows Windows background work more time before declaring the brain wedged", () => { + expect(resolveBrainLoopWatchdogThresholdMs(undefined, "win32")).toBe(60_000); + expect(resolveBrainLoopWatchdogThresholdMs(undefined, "darwin")).toBe(30_000); + expect(resolveBrainLoopWatchdogThresholdMs("45000", "win32")).toBe(45_000); + }); + it("renames a crash breadcrumb to last-wedge and emits the recovery warning", () => { const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-loop-watchdog-")); const breadcrumb = { diff --git a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts index 656b72338..389e6a3a7 100644 --- a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts +++ b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts @@ -4,6 +4,7 @@ import { monitorEventLoopDelay } from "node:perf_hooks"; import { Worker } from "node:worker_threads"; export const DEFAULT_BRAIN_LOOP_WATCHDOG_MS = 30_000; +export const DEFAULT_WINDOWS_BRAIN_LOOP_WATCHDOG_MS = 60_000; export const BRAIN_LOOP_WATCHDOG_HEARTBEAT_MS = 1_000; export const BRAIN_LOOP_WATCHDOG_NEAR_MISS_MS = 2_000; export const BRAIN_LOOP_WATCHDOG_BREADCRUMB_FILE = "event-loop-wedge.json"; @@ -90,9 +91,16 @@ export function evaluateBrainLoopWatchdog(args: { }; } -function parseWatchdogThresholdMs(raw: string | undefined): number { +export function resolveBrainLoopWatchdogThresholdMs( + raw: string | undefined, + platform = process.platform, +): number { const parsed = Number.parseInt(raw?.trim() ?? "", 10); - if (!Number.isFinite(parsed)) return DEFAULT_BRAIN_LOOP_WATCHDOG_MS; + if (!Number.isFinite(parsed)) { + return platform === "win32" + ? DEFAULT_WINDOWS_BRAIN_LOOP_WATCHDOG_MS + : DEFAULT_BRAIN_LOOP_WATCHDOG_MS; + } return Math.max(BRAIN_LOOP_WATCHDOG_HEARTBEAT_MS, parsed); } @@ -329,7 +337,7 @@ export function startBrainLoopWatchdog(args: { if (env.ADE_DISABLE_LOOP_WATCHDOG === "1") return () => {}; if (!args.forceInTests && (env.VITEST || env.NODE_ENV === "test")) return () => {}; - const thresholdMs = parseWatchdogThresholdMs(env.ADE_LOOP_WATCHDOG_MS); + const thresholdMs = resolveBrainLoopWatchdogThresholdMs(env.ADE_LOOP_WATCHDOG_MS); const reportPath = path.join(args.runtimeDir, BRAIN_LOOP_WATCHDOG_REPORT_FILE); const reportSignal = "SIGUSR2"; let reportEnabled = false; diff --git a/apps/ade-cli/src/services/runtime/localIpcListenOptions.ts b/apps/ade-cli/src/services/runtime/localIpcListenOptions.ts new file mode 100644 index 000000000..cbf91abf6 --- /dev/null +++ b/apps/ade-cli/src/services/runtime/localIpcListenOptions.ts @@ -0,0 +1,24 @@ +import type { ListenOptions } from "node:net"; + +function isWindowsNamedPipePath(socketPath: string): boolean { + const normalized = socketPath.trim().replace(/\//g, "\\").toLowerCase(); + return normalized.startsWith("\\\\.\\pipe\\"); +} + +/** + * Make the intended-user-only Windows named-pipe boundary explicit. + * + * Node defaults both flags to false, but spelling them out prevents a future + * listener refactor from accidentally opting into a pipe readable or writable + * by every local Windows user. + */ +export function localIpcListenOptions( + socketPath: string, +): string | ListenOptions { + if (!isWindowsNamedPipePath(socketPath)) return socketPath; + return { + path: socketPath, + readableAll: false, + writableAll: false, + }; +} diff --git a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts index 443285087..4db2ab6dc 100644 --- a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts +++ b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts @@ -1,5 +1,7 @@ +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { resolveMachineAdeLayout } from "../projects/machineLayout"; /** * Cross-process mutual exclusion for "spawn a brain for this socket". @@ -88,9 +90,40 @@ function unlinkSocketSpawnLockIfOwner(lockPath: string, ownerId: string | null): } } +function isWindowsNamedPipePath(socketPath: string): boolean { + return socketPath + .trim() + .replace(/\//g, "\\") + .toLowerCase() + .startsWith("\\\\.\\pipe\\"); +} + +export function socketSpawnLockPath(socketPath: string): string { + if (isWindowsNamedPipePath(socketPath)) { + // A named pipe is a kernel namespace, not a filesystem directory. Trying + // to create `\\.\pipe\.spawn.lock` fails with ENOENT before a cold + // Windows runtime can be spawned. Keep the advisory lock in ADE's + // per-user runtime directory and hash the case-insensitive pipe identity. + const normalizedPipe = socketPath.trim().replace(/\//g, "\\").toLowerCase(); + const key = createHash("sha256") + .update(normalizedPipe) + .digest("hex") + .slice(0, 32); + return path.join( + resolveMachineAdeLayout().runtimeDir, + "spawn-locks", + `${key}.lock`, + ); + } + return path.join( + path.dirname(socketPath), + `${path.basename(socketPath)}.spawn.lock`, + ); +} + export async function withSocketSpawnLock(socketPath: string, task: () => Promise): Promise { if (socketPath.startsWith("tcp://")) return await task(); - const lockPath = path.join(path.dirname(socketPath), `${path.basename(socketPath)}.spawn.lock`); + const lockPath = socketSpawnLockPath(socketPath); fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 }); const deadline = Date.now() + 10_000; const owner = createSocketSpawnLockOwner(); diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 64a883d44..61d1e5509 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -4086,7 +4086,7 @@ describe("sync host account authentication", () => { ); expect(signedOutRejected.payload).toMatchObject({ code: "auth_failed", - message: expect.stringMatching(/not signed in.*Sign in on the Mac/i), + message: expect.stringMatching(/not signed in.*Sign in on this computer/i), }); const pinClient = await openAccountClient(port); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 16a777515..0c8bdf036 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3923,7 +3923,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { `--tcp=${port}`, target, ]; - void execFileAsync(cli, cliArgs, { timeout: 10_000 }) + void execFileAsync(cli, cliArgs, { timeout: 10_000, windowsHide: true }) .then(({ stdout, stderr }) => { if (tailnetServeActivePublishToken !== publishToken) return; tailnetServeLastFailureSignature = null; @@ -4018,7 +4018,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const cli = resolveTailscaleCliPath(); let stale: number[]; try { - const { stdout } = await execFileAsync(cli, ["serve", "status", "--json"], { timeout: 10_000 }); + const { stdout } = await execFileAsync( + cli, + ["serve", "status", "--json"], + { timeout: 10_000, windowsHide: true }, + ); stale = staleAdeTailnetServePorts(stdout, currentPort); } catch { // No Tailscale, no permission, unparseable output: publishing the current @@ -4045,7 +4049,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // between the snapshot and this `off` is closed too. if (await isLocalPortServing(port)) continue; try { - await execFileAsync(cli, ["serve", `--tcp=${port}`, "off"], { timeout: 10_000 }); + await execFileAsync( + cli, + ["serve", `--tcp=${port}`, "off"], + { timeout: 10_000, windowsHide: true }, + ); reclaimed += 1; } catch { // A single stubborn entry must not stop the rest. @@ -4085,7 +4093,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { await execFileAsync( cli, ["serve", `--tcp=${servePort}`, "off"], - { timeout: 10_000 }, + { timeout: 10_000, windowsHide: true }, ); updateTailnetDiscoveryStatus({ state: "disabled", @@ -7248,13 +7256,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { deviceId: accountAuth.deviceId, }); return authFail( - "This machine is not signed in to an ADE account. Sign in on the Mac, then try again.", + "This machine is not signed in to an ADE account. Sign in on this computer, then try again.", ); } const config = args.getAccountAttestationConfig?.(); if (!config) { return authFail( - "This machine cannot verify ADE accounts. Update ADE on the Mac, then try again.", + "This machine cannot verify ADE accounts. Update ADE on this computer, then try again.", ); } const attestation = await verifyAccountAttestation({ @@ -7304,7 +7312,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); return authFail( "This device's saved pairing predates device-key security." - + " Remove it on the Mac and pair it again.", + + " Remove it on this computer and pair it again.", ); } const dpopFailure = evaluatePairedHelloDpop({ diff --git a/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts b/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts index 5e8401d49..0b1af000f 100644 --- a/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts @@ -52,6 +52,7 @@ function owner(overrides: Partial = {}): SyncHostSinglet socketPath: path.join(os.homedir(), ".ade", "sock", "ade.sock"), projectRoot: "/Users/admin/Projects/ADE", commandLine: "/Applications/ADE.app/Contents/MacOS/ADE /Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs serve", + processStartedAt: "2026-06-09T00:00:00.000Z", quitCommand: `ADE_HOME='${path.join(os.homedir(), ".ade")}' '/Applications/ADE.app/Contents/Resources/ade-cli/bin/ade' brain stop --text`, createdAt: now, updatedAt: now, @@ -78,6 +79,7 @@ describe("sync host singleton", () => { lockPath, pidAlive: (pid) => pid === lockOwner.pid, scanListeners: () => [], + platform: "darwin", }); expect(conflict).toMatchObject({ @@ -255,6 +257,44 @@ describe("isSameChannelSyncHostOwner", () => { }); describe("buildQuitCommand (launch-gate stop command)", () => { + it("uses a PowerShell-native process stop on Windows", () => { + const command = buildQuitCommand({ + pid: 4242, + commandLine: "C:\\Program Files\\ADE\\ADE.exe cli.cjs serve", + appName: "ADE", + packageChannel: null, + adeHome: "C:\\Users\\example\\.ade", + platform: "win32", + }); + expect(command).toBe( + "Stop-Process -Id 4242 -Force -ErrorAction SilentlyContinue", + ); + expect(command).not.toContain("launchctl"); + expect(command).not.toContain("/bin/kill"); + }); + + it("clears a Windows lock when the PID was reused by another process", () => { + const lockPath = tempLockPath(); + const lockOwner = owner({ + pid: 21_556, + socketPath: "\\\\.\\pipe\\ade-runtime-dev-test", + commandLine: + "C:\\Program Files\\nodejs\\node.exe C:\\dev\\ADE\\apps\\ade-cli\\dist\\cli.cjs serve --socket \\\\.\\pipe\\ade-runtime-dev-test", + }); + writeLock(lockPath, lockOwner); + + const conflict = detectSyncHostSingletonConflict({ + lockPath, + pidAlive: () => true, + processMatchesOwner: () => false, + scanListeners: () => [], + platform: "win32", + }); + + expect(conflict).toBeNull(); + expect(fs.existsSync(lockPath)).toBe(false); + }); + it("stops a launchd-managed brain via launchctl bootout, not a hardcoded app path", () => { const command = buildQuitCommand({ pid: 4242, @@ -262,6 +302,7 @@ describe("buildQuitCommand (launch-gate stop command)", () => { appName: "ADE", packageChannel: null, adeHome: "/Users/example/.ade", + platform: "darwin", }); expect(command).toContain("launchctl bootout gui/$(id -u)/com.ade.runtime"); expect(command).toContain("/bin/kill 4242"); @@ -273,10 +314,10 @@ describe("buildQuitCommand (launch-gate stop command)", () => { it("derives the per-channel launchd label", () => { expect( - buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Beta", packageChannel: "beta", adeHome: null }), + buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Beta", packageChannel: "beta", adeHome: null, platform: "darwin" }), ).toContain("com.ade.runtime.beta"); expect( - buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Alpha", packageChannel: "alpha", adeHome: null }), + buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Alpha", packageChannel: "alpha", adeHome: null, platform: "darwin" }), ).toContain("com.ade.runtime.alpha"); }); @@ -288,6 +329,7 @@ describe("buildQuitCommand (launch-gate stop command)", () => { packageChannel: null, adeHome: null, serviceName: "com.ade.runtime.custom", + platform: "darwin", }); expect(command).toContain("launchctl bootout gui/$(id -u)/com.ade.runtime.custom"); }); @@ -300,6 +342,7 @@ describe("buildQuitCommand (launch-gate stop command)", () => { appName: "ADE Alpha", packageChannel: null, adeHome: null, + platform: "darwin", }); expect(command).toContain("launchctl bootout gui/$(id -u)/com.ade.runtime.alpha"); expect(command).not.toContain("/Applications/"); diff --git a/apps/ade-cli/src/services/sync/syncHostSingleton.ts b/apps/ade-cli/src/services/sync/syncHostSingleton.ts index e0d282beb..91a0638ba 100644 --- a/apps/ade-cli/src/services/sync/syncHostSingleton.ts +++ b/apps/ade-cli/src/services/sync/syncHostSingleton.ts @@ -17,6 +17,8 @@ export type SyncHostSingletonOwner = { socketPath: string | null; projectRoot: string | null; commandLine: string | null; + /** Stable-enough birth identity used with pid/executable to reject PID reuse. */ + processStartedAt?: string | null; quitCommand: string; createdAt: string; updatedAt: string; @@ -41,7 +43,9 @@ export type SyncHostSingletonLease = { export type SyncHostSingletonDeps = { lockPath?: string; pidAlive?: (pid: number) => boolean; + processMatchesOwner?: (owner: SyncHostSingletonOwner) => boolean | null; scanListeners?: () => SyncHostSingletonOwner[]; + platform?: NodeJS.Platform; }; // Which leases THIS process currently holds. The lock file answers "who owns @@ -95,9 +99,23 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } -function withPidKillFallback(command: string, pid: number): string { +function windowsPidStopCommand(pid: number): string { + return `Stop-Process -Id ${Math.floor(pid)} -Force -ErrorAction SilentlyContinue`; +} + +function withPidKillFallback( + command: string, + pid: number, + platform: NodeJS.Platform = process.platform, +): string { if (!Number.isFinite(pid) || pid <= 0) return command; const normalizedPid = Math.floor(pid); + if (platform === "win32") { + if (command.includes(`Stop-Process -Id ${normalizedPid}`)) return command; + // Windows releases before the native port could persist POSIX recovery + // commands. Replace those instead of copying another unusable command. + return windowsPidStopCommand(normalizedPid); + } if (command.includes(`/bin/kill ${normalizedPid}`)) return command; return `${command}; /bin/kill ${normalizedPid} 2>/dev/null || true`; } @@ -130,7 +148,78 @@ function defaultPidAlive(pid: number): boolean { } } -function safeReadLock(lockPath: string): SyncHostSingletonLockFile | null { +function executableFromCommandLine(commandLine: string | null): string | null { + const match = commandLine?.trim().match(/^(?:"([^"]+)"|(.+?\.exe))(?=\s|$)/i); + const executable = match?.[1] ?? match?.[2] ?? null; + return executable ? path.win32.basename(executable).toLowerCase() : null; +} + +function defaultProcessMatchesOwner( + owner: SyncHostSingletonOwner, + platform: NodeJS.Platform = process.platform, +): boolean | null { + if (platform !== "win32") return null; + const script = [ + `$target = Get-Process -Id ${Math.floor(owner.pid)} -ErrorAction SilentlyContinue`, + "if ($null -eq $target) { exit 3 }", + "$executablePath = $null", + "$startedAt = $null", + "try { $executablePath = $target.Path } catch {}", + "try { $startedAt = $target.StartTime.ToUniversalTime().ToString('o') } catch {}", + "[Console]::Out.Write((@{ executablePath = $executablePath; startedAt = $startedAt } | ConvertTo-Json -Compress))", + ].join("; "); + let raw = ""; + try { + raw = execFileSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", script], + { + encoding: "utf8", + timeout: 2_000, + maxBuffer: 64 * 1024, + windowsHide: true, + }, + ); + } catch { + // If process inspection is unavailable, remain conservative and preserve + // the lock rather than risking two live sync hosts. + return null; + } + try { + const parsed = JSON.parse(raw) as { + executablePath?: unknown; + startedAt?: unknown; + }; + const expectedExecutable = executableFromCommandLine(owner.commandLine); + const actualExecutable = typeof parsed.executablePath === "string" && parsed.executablePath.trim() + ? path.win32.basename(parsed.executablePath.trim()).toLowerCase() + : null; + if (expectedExecutable && actualExecutable && expectedExecutable !== actualExecutable) { + return false; + } + const expectedStartedAtMs = owner.processStartedAt + ? Date.parse(owner.processStartedAt) + : Number.NaN; + const actualStartedAtMs = typeof parsed.startedAt === "string" + ? Date.parse(parsed.startedAt) + : Number.NaN; + if ( + Number.isFinite(expectedStartedAtMs) + && Number.isFinite(actualStartedAtMs) + && Math.abs(expectedStartedAtMs - actualStartedAtMs) > 2_000 + ) { + return false; + } + return true; + } catch { + return null; + } +} + +function safeReadLock( + lockPath: string, + platform: NodeJS.Platform = process.platform, +): SyncHostSingletonLockFile | null { try { const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; @@ -158,7 +247,11 @@ function safeReadLock(lockPath: string): SyncHostSingletonLockFile | null { socketPath: typeof row.socketPath === "string" && row.socketPath.trim() ? row.socketPath : null, projectRoot: typeof row.projectRoot === "string" && row.projectRoot.trim() ? row.projectRoot : null, commandLine: typeof row.commandLine === "string" && row.commandLine.trim() ? row.commandLine : null, - quitCommand: withPidKillFallback(rawQuitCommand, pid), + processStartedAt: + typeof row.processStartedAt === "string" && Number.isFinite(Date.parse(row.processStartedAt)) + ? row.processStartedAt + : null, + quitCommand: withPidKillFallback(rawQuitCommand, pid, platform), createdAt: typeof row.createdAt === "string" && row.createdAt.trim() ? row.createdAt : new Date().toISOString(), updatedAt: typeof row.updatedAt === "string" && row.updatedAt.trim() ? row.updatedAt : new Date().toISOString(), }, @@ -214,7 +307,13 @@ export function buildQuitCommand(args: { packageChannel: string | null; adeHome: string | null; serviceName?: string | null; + platform?: NodeJS.Platform; }): string { + if ((args.platform ?? process.platform) === "win32") { + return Number.isFinite(args.pid) && args.pid > 0 + ? windowsPidStopCommand(args.pid) + : ""; + } const commandLine = args.commandLine ?? ""; const channel = normalizedChannel(args.packageChannel) ?? (/ADE Beta\.app|ade-beta|\bADE Beta\b/i.test(commandLine) ? "beta" : null) @@ -245,6 +344,9 @@ function currentOwner(args: { const appName = process.env.ADE_DESKTOP_APP_NAME?.trim() || defaultAppName(channel); const commandLine = commandLineText(); const serviceName = process.env.ADE_RUNTIME_SERVICE_NAME?.trim() || null; + const processStartedAt = new Date( + Date.now() - Math.max(0, process.uptime() * 1_000), + ).toISOString(); return { id: randomUUID(), pid: process.pid, @@ -256,6 +358,7 @@ function currentOwner(args: { socketPath: process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || null, projectRoot: args.projectRoot ? path.resolve(args.projectRoot) : null, commandLine, + processStartedAt, quitCommand: buildQuitCommand({ pid: process.pid, commandLine, @@ -295,6 +398,7 @@ function psCommandLines(pids: number[]): Map { const output = execFileSync("ps", ["-p", unique.join(","), "-o", "pid=,command="], { encoding: "utf8", timeout: 2_000, + windowsHide: true, }); const commands = new Map(); for (const line of output.split(/\r?\n/)) { @@ -347,6 +451,7 @@ function legacyOwner(pid: number, port: number, commandLine: string | null): Syn socketPath: null, projectRoot: null, commandLine, + processStartedAt: null, quitCommand: buildQuitCommand({ pid, commandLine, appName, packageChannel: channel, adeHome }), createdAt: now, updatedAt: now, @@ -365,6 +470,7 @@ function scanNativeSyncHostListeners(): SyncHostSingletonOwner[] { ], { encoding: "utf8", timeout: 2_000, + windowsHide: true, }); } catch (error) { output = typeof (error as { stdout?: unknown }).stdout === "string" @@ -389,21 +495,35 @@ function scanNativeSyncHostListeners(): SyncHostSingletonOwner[] { function activeLockConflict( lockPath: string, pidAlive: (pid: number) => boolean, + processMatchesOwner: (owner: SyncHostSingletonOwner) => boolean | null, + platform: NodeJS.Platform = process.platform, ): SyncHostSingletonConflict | null { - const lock = safeReadLock(lockPath); + const lock = safeReadLock(lockPath, platform); if (!lock) return null; if (lock.owner.pid === process.pid) return null; if (!pidAlive(lock.owner.pid)) { unlinkLock(lockPath); return null; } + if (processMatchesOwner(lock.owner) === false) { + // Windows can reuse a dead brain's PID after a reboot or crash. A live PID + // is not proof that it is still the process recorded in this lock. + unlinkLock(lockPath); + return null; + } return { reason: "lock", owner: lock.owner }; } export function detectSyncHostSingletonConflict( deps: SyncHostSingletonDeps = {}, ): SyncHostSingletonConflict | null { - const hasExplicitDeps = Boolean(deps.lockPath || deps.pidAlive || deps.scanListeners); + const hasExplicitDeps = Boolean( + deps.lockPath + || deps.pidAlive + || deps.processMatchesOwner + || deps.scanListeners + || deps.platform, + ); if ( isTestProcess() && process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE !== "1" && @@ -413,7 +533,14 @@ export function detectSyncHostSingletonConflict( } const lockPath = deps.lockPath ?? syncHostSingletonLockPath(); const pidAlive = deps.pidAlive ?? defaultPidAlive; - const lockConflict = activeLockConflict(lockPath, pidAlive); + const processMatchesOwner = deps.processMatchesOwner + ?? ((owner) => defaultProcessMatchesOwner(owner, deps.platform)); + const lockConflict = activeLockConflict( + lockPath, + pidAlive, + processMatchesOwner, + deps.platform, + ); if (lockConflict) return lockConflict; const listener = (deps.scanListeners ?? scanNativeSyncHostListeners)() .find((owner) => owner.pid !== process.pid && pidAlive(owner.pid)); @@ -459,13 +586,20 @@ export function acquireSyncHostSingleton( assertNoSyncHostSingletonConflict(deps); const lockPath = deps.lockPath ?? syncHostSingletonLockPath(); const owner = currentOwner(args); + const processMatchesOwner = deps.processMatchesOwner + ?? ((candidate) => defaultProcessMatchesOwner(candidate, deps.platform)); for (let attempt = 0; attempt < 2; attempt += 1) { try { writeLock(lockPath, owner, "wx"); break; } catch (error) { if ((error as NodeJS.ErrnoException | null | undefined)?.code !== "EEXIST") throw error; - const conflict = activeLockConflict(lockPath, deps.pidAlive ?? defaultPidAlive); + const conflict = activeLockConflict( + lockPath, + deps.pidAlive ?? defaultPidAlive, + processMatchesOwner, + deps.platform, + ); if (conflict) throw new SyncHostSingletonConflictError(conflict); unlinkLock(lockPath); if (attempt === 1) writeLock(lockPath, owner, "wx"); @@ -483,13 +617,13 @@ export function acquireSyncHostSingleton( updatedAt: new Date().toISOString(), }; Object.assign(owner, next); - const lock = safeReadLock(lockPath); + const lock = safeReadLock(lockPath, deps.platform); if (lock?.owner.id === owner.id && lock.owner.pid === process.pid) { writeLock(lockPath, owner, "w"); } }, dispose() { - const lock = safeReadLock(lockPath); + const lock = safeReadLock(lockPath, deps.platform); if (lock?.owner.id === owner.id && lock.owner.pid === process.pid) { unlinkLock(lockPath); } diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 5174e1101..8565747d9 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -1460,6 +1460,7 @@ export function createSyncService(args: SyncServiceArgs) { transferReadiness: options?.includeTransferReadiness === false ? (transferReadinessCache?.value ?? buildSkippedTransferReadiness()) : await getTransferReadiness({ force: options?.forceTransferReadiness === true }), + crdtSyncAvailable, survivableStateText: crdtSyncAvailable ? "Paused and idle state will remain available on the new host." @@ -1467,7 +1468,9 @@ export function createSyncService(args: SyncServiceArgs) { blockingStateText: crdtSyncAvailable ? "Live chats or terminals must stop first." - : "Install Windows cr-sqlite support before pairing or syncing devices.", + : process.platform === "win32" + ? "Phone sync is unavailable because crsqlite.dll could not be loaded. Reinstall ADE, then restart it before pairing a device." + : "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", }; }, diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index 94e0431a6..28a89c706 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -7,7 +7,14 @@ const path = require("node:path"); const projectRoot = path.resolve(__dirname, ".."); const distMainFile = path.join(projectRoot, "dist", "main", "main.cjs"); -const npxCommand = "npx"; +const mainReadyMarker = path.join( + projectRoot, + "dist", + `.ade-dev-main-ready-${process.pid}`, +); +const viteCliPath = path.join(projectRoot, "node_modules", "vite", "bin", "vite.js"); +const tsupCliPath = path.join(projectRoot, "node_modules", "tsup", "dist", "cli-default.js"); +const electronCommand = require("electron"); // ADE chat shells export ELECTRON_RUN_AS_NODE=1 so the `ade` shim can run // cli.cjs through the bundled Electron binary as Node. If that leaks into @@ -104,35 +111,7 @@ async function waitForFile(filePath, timeoutMs) { // eslint-disable-next-line no-await-in-loop await sleep(150); } -} - -async function waitForStableFile(filePath, timeoutMs, stableWindowMs = 300) { - const startedAt = Date.now(); - let lastSignature = ""; - let stableSince = 0; - - while (true) { - try { - const stat = fs.statSync(filePath); - const signature = `${stat.size}:${stat.mtimeMs}`; - if (signature !== lastSignature) { - lastSignature = signature; - stableSince = Date.now(); - } else if (Date.now() - stableSince >= stableWindowMs) { - return stat; - } - } catch { - lastSignature = ""; - stableSince = 0; - } - - if (Date.now() - startedAt > timeoutMs) { - throw new Error(`Timed out waiting for stable file: ${filePath}`); - } - - // eslint-disable-next-line no-await-in-loop - await sleep(150); - } + return fs.statSync(filePath); } function quoteWindowsCmdArg(value) { @@ -260,11 +239,13 @@ async function main() { let shuttingDown = false; let electron = null; let electronRestartPending = false; + fs.rmSync(mainReadyMarker, { force: true }); const teardown = (signal = "SIGTERM") => { if (shuttingDown) return; shuttingDown = true; - fs.unwatchFile(distMainFile); + fs.unwatchFile(mainReadyMarker); + fs.rmSync(mainReadyMarker, { force: true }); for (const child of children) { terminateChild(child, signal); } @@ -274,10 +255,15 @@ async function main() { process.on("SIGTERM", () => teardown("SIGTERM")); process.on("exit", () => teardown("SIGTERM")); - const viteArgs = ["vite", "--port", String(devPort), "--strictPort"]; + const viteArgs = [viteCliPath, "--port", String(devPort), "--strictPort"]; if (forceViteOptimize) viteArgs.push("--force"); - const vite = spawnProcess("renderer", npxCommand, viteArgs); - const main = spawnProcess("main", npxCommand, ["tsup", "--watch"]); + const vite = spawnProcess("renderer", process.execPath, viteArgs); + const main = spawnProcess( + "main", + process.execPath, + [tsupCliPath, "--watch"], + { ADE_DEV_MAIN_READY_MARKER: mainReadyMarker }, + ); children.add(vite); children.add(main); @@ -293,23 +279,29 @@ async function main() { vite.on("exit", onUnexpectedExit(vite)); main.on("exit", onUnexpectedExit(main)); - const [, initialMainBundleStat] = await Promise.all([ + const [, initialReadyMarkerStat] = await Promise.all([ waitForPort(devPort, 30_000), - waitForStableFile(distMainFile, 30_000), + waitForFile(mainReadyMarker, 30_000), ]); + if (!fs.existsSync(distMainFile)) { + throw new Error(`Main build completed without producing ${distMainFile}`); + } const electronEnv = { VITE_DEV_SERVER_URL: devServerUrl, }; const launchElectron = () => { - const electronArgs = ["electron", `--remote-debugging-port=${remoteDebugPort}`]; + const electronArgs = [`--remote-debugging-port=${remoteDebugPort}`]; + if (process.env.ADE_DISABLE_HARDWARE_ACCEL === "1") { + electronArgs.push("--disable-gpu"); + } // Electron treats the first non-switch argument as the app path. Use the // absolute app root so macOS launches do not fall back to default_app.asar. electronArgs.push(projectRoot); if (process.platform === "darwin") { electronArgs.push("-ApplePersistenceIgnoreState", "YES"); } - const child = spawnProcess("electron", npxCommand, electronArgs, electronEnv); + const child = spawnProcess("electron", electronCommand, electronArgs, electronEnv); electron = child; children.add(child); child.on("exit", (code, signal) => { @@ -319,19 +311,8 @@ async function main() { electron = null; if (electronRestartPending) { electronRestartPending = false; - waitForStableFile(distMainFile, 30_000) - .then((stat) => { - lastMainBundleMtimeMs = stat.mtimeMs; - process.stdout.write("[ade] electron restarted with updated main bundle\n"); - launchElectron(); - }) - .catch((error) => { - process.stderr.write( - `[ade] failed to restart electron after main bundle update: ${error instanceof Error ? error.message : String(error)}\n` - ); - teardown("SIGTERM"); - process.exit(1); - }); + process.stdout.write("[ade] electron restarted after successful main build\n"); + launchElectron(); return; } process.stdout.write( @@ -350,16 +331,16 @@ async function main() { terminateChild(electron, "SIGTERM"); }; - launchElectron(); - - let lastMainBundleMtimeMs = initialMainBundleStat.mtimeMs; - fs.watchFile(distMainFile, { interval: 250 }, (curr) => { + let lastReadyMarkerMtimeMs = initialReadyMarkerStat.mtimeMs; + fs.watchFile(mainReadyMarker, { interval: 250 }, (curr) => { if (shuttingDown) return; if (!curr || curr.nlink === 0) return; - if (!curr || curr.mtimeMs <= lastMainBundleMtimeMs) return; - lastMainBundleMtimeMs = curr.mtimeMs; - requestElectronRestart("main bundle updated"); + if (curr.mtimeMs <= lastReadyMarkerMtimeMs) return; + lastReadyMarkerMtimeMs = curr.mtimeMs; + requestElectronRestart("main build completed"); }); + + launchElectron(); } main().catch((error) => { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d9eefadd1..25b5ca562 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -50,6 +50,10 @@ import { captureAgentTurnSettledAnalytics } from "./services/analytics/agentTurn import { initPerfRunFromEnv } from "./services/perf/perfLog"; import { startMetricsSampler } from "./services/perf/metricsSampler"; import { registerPerfIpcHandlers } from "./services/perf/perfIpc"; +import { + ADE_WINDOWS_APP_USER_MODEL_ID, + windowChromeOptions, +} from "./windowAppearance"; import { openKvDb } from "./services/state/kvDb"; import { createRegisteredSyncPeerGate } from "./services/state/syncPeerCompactionGate"; import { ensureAdeDirs } from "./services/state/projectState"; @@ -190,6 +194,7 @@ import { type JsonRpcTransport, } from "../../../ade-cli/src/jsonrpc"; import { resolveMachineAdeLayout } from "../../../ade-cli/src/services/projects/machineLayout"; +import { localIpcListenOptions } from "../../../ade-cli/src/services/runtime/localIpcListenOptions"; import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots"; import { getSignedInAccountAccessToken } from "../../../ade-cli/src/services/account/accountAuthService"; import { createPushRelayClient } from "../../../ade-cli/src/services/push/pushRelayClient"; @@ -261,6 +266,7 @@ import { LocalRuntimeConnectionPool } from "./services/localRuntime/localRuntime import { createSyncService } from "./services/sync/syncService"; import { blockPackagedLaunchForCrossChannelSyncConflict } from "./services/sync/packagedSyncHostLaunchGate"; import { createAutoUpdateService } from "./services/updates/autoUpdateService"; +import { DEFAULT_RELEASE_REPOSITORY } from "./services/updates/autoUpdateVersions"; import { cleanupStaleTempArtifacts } from "./services/runtime/tempCleanupService"; import type { Logger } from "./services/logging/logger"; import { resolveDesktopUserDataPath, resolveElectronAppDataPath } from "./desktopUserDataPath"; @@ -271,6 +277,31 @@ const AUTO_UPDATER_CACHE_DIR_NAME = "ade-desktop-updater"; type AdePackageChannel = "alpha" | "beta"; +function normalizeAdeReleaseRepository(value: unknown): string | null { + const normalized = typeof value === "string" + ? value.trim().replace(/^\/+|\/+$/g, "") + : ""; + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized) ? normalized : null; +} + +function readBundledAdeReleaseRepository(): string { + try { + const packageJsonPath = path.join(app.getAppPath(), "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + adeReleaseRepository?: unknown; + }; + const bundledRepository = normalizeAdeReleaseRepository(packageJson.adeReleaseRepository); + if (bundledRepository) return bundledRepository; + } catch { + // Older packages use the upstream repository default. + } + if (!app.isPackaged) { + return normalizeAdeReleaseRepository(process.env.ADE_RELEASE_REPOSITORY) + ?? DEFAULT_RELEASE_REPOSITORY; + } + return DEFAULT_RELEASE_REPOSITORY; +} + function normalizeAdePackageChannel(value: unknown): AdePackageChannel | null { const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; return normalized === "alpha" || normalized === "beta" ? normalized : null; @@ -311,6 +342,7 @@ function applyPackagedChannelDefaults(): void { } applyPackagedChannelDefaults(); +const packagedReleaseRepository = readBundledAdeReleaseRepository(); function configureDesktopUserDataPath(): void { const appDataPath = (() => { @@ -620,9 +652,7 @@ async function createWindow(args: { const win = new BrowserWindow({ ...defaultWindowBounds, icon, - // Hide the native title bar but keep macOS traffic lights. - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 12, y: 12 }, + ...windowChromeOptions(process.platform), // Match renderer dark theme to avoid a flash on load. backgroundColor: "#0F0D14", webPreferences: { @@ -910,6 +940,10 @@ protocol.registerSchemesAsPrivileged([ const deeplinkChannel = normalizeAdePackageChannel(process.env.ADE_PACKAGE_CHANNEL); const deeplinkClaimAsDefault = app.isPackaged && deeplinkChannel === null; +if (process.platform === "win32") { + app.setAppUserModelId(ADE_WINDOWS_APP_USER_MODEL_ID); +} + const pendingAppNavigationRequests: AppNavigationRequest[] = []; let dispatchAppNavigationRequest: ((request: AppNavigationRequest) => void) | null = null; let dispatchAppNavigationForProjectRoot: @@ -2233,6 +2267,7 @@ app.whenReady().then(async () => { rollbackQuitAndInstall: rollbackAutoUpdateInstall, getRuntimeActivitySummary: () => localRuntimePool.activitySummary(), productAnalyticsService, + releaseRepository: packagedReleaseRepository, forceQuit: () => { for (const win of BrowserWindow.getAllWindows()) { try { @@ -4285,10 +4320,11 @@ app.whenReady().then(async () => { ? envSocketOverride : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}` : adePaths.socketPath; + const activeRpcSocketPath = rpcSocketPath; - if (!isAdeRuntimeNamedPipePath(rpcSocketPath)) { + if (!isAdeRuntimeNamedPipePath(activeRpcSocketPath)) { try { - fs.unlinkSync(rpcSocketPath); + fs.unlinkSync(activeRpcSocketPath); } catch {} } @@ -4355,11 +4391,11 @@ app.whenReady().then(async () => { }; server.once("listening", handleListening); server.once("error", handleError); - server.listen(rpcSocketPath); + server.listen(localIpcListenOptions(activeRpcSocketPath)); }), ); logger.warn("rpc.socket_server_started", { - socketPath: rpcSocketPath, + socketPath: activeRpcSocketPath, mode: "legacy_desktop", }); } else { @@ -7058,6 +7094,7 @@ app.whenReady().then(async () => { closeCurrentProject, closeProjectByPath, globalStatePath, + releaseRepository: packagedReleaseRepository, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot: (snapshot: AttentionSnapshot) => { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index acab4c40e..8e7f79421 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -922,6 +922,49 @@ describe("local runtime connection pool", () => { } }); + it("keeps a spawned runtime alive while a stale socket owner drains", async () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", logger as never, { disableSync: true }); + const client = { close: vi.fn() }; + const connectClient = vi.fn() + .mockRejectedValueOnce(new Error("ADE service socket is still owned by runtime PID 111.")) + .mockResolvedValueOnce(client); + const internals = pool as unknown as { + connectClient: typeof connectClient; + connectSpawnedRuntime: ( + socketPath: string, + child: ChildProcess, + ) => Promise; + }; + internals.connectClient = connectClient; + const child = { + pid: 222, + exitCode: null, + signalCode: null, + } as unknown as ChildProcess; + + try { + await expect(internals.connectSpawnedRuntime("\\\\.\\pipe\\ade-runtime-test", child)) + .resolves.toBe(client); + expect(connectClient).toHaveBeenCalledTimes(2); + expect(connectClient).toHaveBeenLastCalledWith( + "\\\\.\\pipe\\ade-runtime-test", + expect.objectContaining({ + expectedPid: 222, + connectTimeoutMs: expect.any(Number), + initializeTimeoutMs: expect.any(Number), + }), + ); + } finally { + pool.dispose(); + } + }); + it("does not let a stale dropped connection clear an in-flight reconnect", () => { const pool = new LocalRuntimeConnectionPool("1.2.3", { debug: vi.fn(), diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 7b7186da1..4c4e43998 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -56,6 +56,9 @@ import { const SLOW_ACTION_THRESHOLD_MS = 500; const RUNTIME_HEALTH_WINDOW_MS = 24 * 60 * 60_000; +const LOCAL_RUNTIME_STARTUP_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 10_000; +const LOCAL_RUNTIME_STARTUP_PROBE_TIMEOUT_MS = 2_000; +const LOCAL_RUNTIME_STARTUP_RETRY_MS = 250; // Ring-cap the in-memory slow-action window so a sustained slow-call storm can // never grow this array without bound (the very failure mode we are surfacing). const RUNTIME_HEALTH_MAX_SAMPLES = 5_000; @@ -999,6 +1002,7 @@ export class LocalRuntimeConnectionPool { env: buildLocalRuntimeNodeEnv(this.appVersion), stdio: ["ignore", "pipe", "pipe"], detached: false, + windowsHide: true, }); let stdout = ""; let stderr = ""; @@ -1139,6 +1143,7 @@ export class LocalRuntimeConnectionPool { }, stdio: ["ignore", "pipe", "pipe"], detached: false, + windowsHide: true, }); let stdout = ""; let stderr = ""; @@ -1924,8 +1929,7 @@ export class LocalRuntimeConnectionPool { const child = this.spawnRuntime(socketPath); try { - await waitForSocket(socketPath); - const client = await this.connectClient(socketPath); + const client = await this.connectSpawnedRuntime(socketPath, child); return { client, child, socketPath }; } catch (error) { disposeOwnedRuntimeChild(child, socketPath, { unlinkSocket: true }); @@ -2083,8 +2087,9 @@ export class LocalRuntimeConnectionPool { await unlinkSocketIfNotListening(socketPath); const child = this.spawnRuntime(socketPath, { ...this.options, disableSync: true }); try { - await waitForSocket(socketPath); - const client = await this.connectClient(socketPath, { preserveVersionSkew: true }); + const client = await this.connectSpawnedRuntime(socketPath, child, { + preserveVersionSkew: true, + }); this.scheduleIsolatedRuntimeRecovery(primarySocketPath); return { client, child, socketPath }; } catch (error) { @@ -2208,20 +2213,33 @@ export class LocalRuntimeConnectionPool { private async connectClient( socketPath: string, - options: { preserveVersionSkew?: boolean } = {}, + options: { + preserveVersionSkew?: boolean; + expectedPid?: number | null; + connectTimeoutMs?: number; + initializeTimeoutMs?: number; + } = {}, ): Promise { - const transport = await openSocketTransport(socketPath); + const transport = await openSocketTransport(socketPath, options.connectTimeoutMs); const client = new RuntimeRpcClient(transport); let initializeResult: unknown; try { initializeResult = await client.initialize("ade-desktop-local", this.appVersion, { desktopBridgeAuthToken: this.options.desktopBridgeAuthToken, + timeoutMs: options.initializeTimeoutMs, }); } catch (error) { closeRuntimeClient(client); throw error; } const runtimeInfo = readLocalRuntimeInfo(initializeResult); + if (options.expectedPid != null && runtimeInfo.pid !== options.expectedPid) { + closeRuntimeClient(client); + throw new Error( + `ADE service socket is still owned by runtime PID ${runtimeInfo.pid ?? "unknown"}; ` + + `waiting for spawned runtime PID ${options.expectedPid}.`, + ); + } const compatibilityError = this.runtimeCompatibilityError(socketPath, runtimeInfo); if (compatibilityError) { closeRuntimeClient(client); @@ -2255,6 +2273,46 @@ export class LocalRuntimeConnectionPool { return client; } + private async connectSpawnedRuntime( + socketPath: string, + child: ChildProcess, + options: { preserveVersionSkew?: boolean } = {}, + ): Promise { + const deadline = Date.now() + LOCAL_RUNTIME_STARTUP_TIMEOUT_MS; + let lastError: Error | null = null; + while (Date.now() < deadline) { + if (child.exitCode != null || child.signalCode != null) { + throw new Error( + `Spawned ADE runtime PID ${child.pid ?? "unknown"} exited before becoming ready.` + + (lastError ? ` Last connection error: ${lastError.message}` : ""), + ); + } + const remainingMs = Math.max(1, deadline - Date.now()); + const probeTimeoutMs = Math.min(LOCAL_RUNTIME_STARTUP_PROBE_TIMEOUT_MS, remainingMs); + try { + return await this.connectClient(socketPath, { + ...options, + expectedPid: child.pid ?? null, + connectTimeoutMs: probeTimeoutMs, + initializeTimeoutMs: probeTimeoutMs, + }); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + const retryDelayMs = Math.min( + LOCAL_RUNTIME_STARTUP_RETRY_MS, + Math.max(0, deadline - Date.now()), + ); + if (retryDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + throw new Error( + `Timed out waiting for spawned ADE runtime PID ${child.pid ?? "unknown"} at ${socketPath}.` + + (lastError ? ` Last connection error: ${lastError.message}` : ""), + ); + } + private spawnRuntime( socketPath: string, options: { disableSync?: boolean } = this.options, @@ -2270,6 +2328,7 @@ export class LocalRuntimeConnectionPool { env, stdio: ["ignore", "pipe", "pipe"], detached: false, + windowsHide: true, }); this.ownedRuntimeChild = child; const outputBase = { diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts index 91122d8b0..b85dedf8a 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts @@ -131,7 +131,7 @@ function diagnosisCopy(state: ProjectRecoveryDiagnosis["state"]): Pick< case "disk_full": case "insufficient_headroom": return { - headline: "ADE stopped because your Mac ran out of storage while project data was being saved.", + headline: "ADE stopped because your computer ran out of storage while project data was being saved.", body: "ADE found your project data, but it needs to finish a repair before this project can open.", canAutoRepair: true, }; @@ -149,7 +149,7 @@ function diagnosisCopy(state: ProjectRecoveryDiagnosis["state"]): Pick< }; case "brain_not_installed": return { - headline: "ADE's background service isn't set up on this Mac.", + headline: "ADE's background service isn't set up on this computer.", body: "ADE can set it up now.", canAutoRepair: true, }; @@ -161,7 +161,7 @@ function diagnosisCopy(state: ProjectRecoveryDiagnosis["state"]): Pick< }; case "socket_owned_by_other": return { - headline: "Another copy of ADE is already managing projects on this Mac.", + headline: "Another copy of ADE is already managing projects on this computer.", body: "Close other copies of ADE, then try again.", canAutoRepair: false, }; @@ -490,7 +490,7 @@ export class ProjectRecoveryService { return fail( "check_space", storage.free < GIB ? "disk_full" : "insufficient_headroom", - `Free up about ${humanGb(requiredSpace - storage.free)} on this Mac, then run repair again.`, + `Free up about ${humanGb(requiredSpace - storage.free)} on this computer, then run repair again.`, `Available ${storage.free} bytes; repair requires ${requiredSpace} bytes.`, ); } @@ -565,7 +565,7 @@ export class ProjectRecoveryService { const nextAction = classified === "migration_unknown_state" ? "ADE found data it doesn't recognize from an interrupted save. Contact support — nothing has been deleted." : classified === "disk_full" || classified === "insufficient_headroom" - ? "Free up more storage on this Mac, then run repair again." + ? "Free up more storage on this computer, then run repair again." : "Contact support with the technical details — nothing has been deleted."; return fail("resolve_migrations", failureCode, nextAction, errorMessage(error)); } diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index 490729e81..12e2442a3 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -113,7 +113,10 @@ export function signalChildProcessTree(child: KillableChildProcess, signal: Node taskkillArgs.push("/F"); } try { - const result = spawnSync("taskkill", taskkillArgs, { stdio: "ignore" }); + const result = spawnSync("taskkill", taskkillArgs, { + stdio: "ignore", + windowsHide: true, + }); if (result.status === 0) return true; } catch { // fall through to direct child signaling @@ -180,6 +183,7 @@ export function spawnAsync( stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stdout = ""; let stderr = ""; diff --git a/apps/desktop/src/main/services/storage/diskPressure.ts b/apps/desktop/src/main/services/storage/diskPressure.ts index 1c7fef2f0..44109db1c 100644 --- a/apps/desktop/src/main/services/storage/diskPressure.ts +++ b/apps/desktop/src/main/services/storage/diskPressure.ts @@ -35,10 +35,10 @@ export const DEFAULT_DISK_PRESSURE_THRESHOLDS: DiskPressureThresholds = { }; export const DISK_PRESSURE_REFUSAL_MESSAGES = { - chat_turn: "Your Mac is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", - cli_launch: "Your Mac is almost out of storage. ADE can't safely start a new CLI session until you free up space.", - high_write_job: "Your Mac is almost out of storage. ADE didn't start this task to avoid writing more data. Free up space, then try again.", - compression: "Your Mac is almost out of storage. ADE didn't start this task to avoid writing more data. Free up space, then try again.", + chat_turn: "Your computer is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", + cli_launch: "Your computer is almost out of storage. ADE can't safely start a new CLI session until you free up space.", + high_write_job: "Your computer is almost out of storage. ADE didn't start this task to avoid writing more data. Free up space, then try again.", + compression: "Your computer is almost out of storage. ADE didn't start this task to avoid writing more data. Free up space, then try again.", } satisfies Record; const STATE_SEVERITY: Record = { diff --git a/apps/desktop/src/main/windowAppearance.test.ts b/apps/desktop/src/main/windowAppearance.test.ts new file mode 100644 index 000000000..7544e45e1 --- /dev/null +++ b/apps/desktop/src/main/windowAppearance.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + ADE_WINDOWS_APP_USER_MODEL_ID, + windowChromeOptions, +} from "./windowAppearance"; + +describe("windowAppearance", () => { + it("keeps inset traffic lights on macOS", () => { + expect(windowChromeOptions("darwin")).toEqual({ + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, + }); + }); + + it("uses a Windows title-bar overlay with native caption controls", () => { + expect(windowChromeOptions("win32")).toEqual({ + titleBarStyle: "hidden", + titleBarOverlay: { + color: "#0F0D14", + symbolColor: "#F8F8F2", + height: 32, + }, + }); + expect(ADE_WINDOWS_APP_USER_MODEL_ID).toBe("com.ade.desktop"); + }); + + it("uses the native title bar on other platforms", () => { + expect(windowChromeOptions("linux")).toEqual({ + titleBarStyle: "default", + }); + }); +}); diff --git a/apps/desktop/src/main/windowAppearance.ts b/apps/desktop/src/main/windowAppearance.ts new file mode 100644 index 000000000..8ba618128 --- /dev/null +++ b/apps/desktop/src/main/windowAppearance.ts @@ -0,0 +1,32 @@ +import type { BrowserWindowConstructorOptions } from "electron"; + +export const ADE_WINDOWS_APP_USER_MODEL_ID = "com.ade.desktop"; + +type WindowChromeOptions = Pick< + BrowserWindowConstructorOptions, + "titleBarStyle" | "trafficLightPosition" | "titleBarOverlay" +>; + +export function windowChromeOptions( + platform: NodeJS.Platform, +): WindowChromeOptions { + if (platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, + }; + } + if (platform === "win32") { + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: "#0F0D14", + symbolColor: "#F8F8F2", + height: 32, + }, + }; + } + return { + titleBarStyle: "default", + }; +} diff --git a/apps/desktop/src/renderer/lib/platform.test.ts b/apps/desktop/src/renderer/lib/platform.test.ts new file mode 100644 index 000000000..7d4cb7ce9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/platform.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + isMacPlatform, + rendererPlatformAttribute, + supportsNativeNotchPlatform, +} from "./platform"; + +describe("renderer platform helpers", () => { + it("recognizes macOS platform spellings", () => { + expect(isMacPlatform("MacIntel")).toBe(true); + expect(isMacPlatform("darwin")).toBe(true); + expect(isMacPlatform("Win32")).toBe(false); + }); + + it("maps renderer platform values to stable CSS attributes", () => { + expect(rendererPlatformAttribute("MacIntel")).toBe("darwin"); + expect(rendererPlatformAttribute("Win32")).toBe("win32"); + expect(rendererPlatformAttribute("Linux x86_64")).toBe("linux"); + expect(rendererPlatformAttribute("browser")).toBe("unknown"); + }); + + it("only enables the native Notch surface on macOS", () => { + expect(supportsNativeNotchPlatform("MacIntel")).toBe(true); + expect(supportsNativeNotchPlatform("Win32")).toBe(false); + expect(supportsNativeNotchPlatform("Linux x86_64")).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/lib/platform.ts b/apps/desktop/src/renderer/lib/platform.ts index f536031a9..8e6878f91 100644 --- a/apps/desktop/src/renderer/lib/platform.ts +++ b/apps/desktop/src/renderer/lib/platform.ts @@ -8,6 +8,24 @@ function getPlatformValue(): string { return ""; } -export const isMac = /mac|darwin/i.test(getPlatformValue()); +export function isMacPlatform(platformValue = getPlatformValue()): boolean { + return /mac|darwin/i.test(platformValue); +} + +export function rendererPlatformAttribute( + platformValue = getPlatformValue(), +): "darwin" | "win32" | "linux" | "unknown" { + if (isMacPlatform(platformValue)) return "darwin"; + if (/win/i.test(platformValue)) return "win32"; + if (/linux/i.test(platformValue)) return "linux"; + return "unknown"; +} + +export function supportsNativeNotchPlatform(platformValue = getPlatformValue()): boolean { + return isMacPlatform(platformValue); +} + +export const isMac = isMacPlatform(); +export const supportsNativeNotch = supportsNativeNotchPlatform(); export const revealLabel = isMac ? "Reveal in Finder" : "Reveal in File Explorer"; export const modifierKeyLabel = isMac ? "Cmd" : "Ctrl"; diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index ffb1fb7c9..5786bbdd0 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -10,6 +10,9 @@ import { RendererErrorBoundary } from "./components/app/RendererErrorBoundary"; import { useAppStore } from "./state/appStore"; import { logRendererDebugEvent } from "./lib/debugLog"; import { initPerfRuntime } from "./perf/harness"; +import { rendererPlatformAttribute } from "./lib/platform"; + +document.documentElement.dataset.adePlatform = rendererPlatformAttribute(); (function injectFontFaces() { const style = document.createElement("style"); diff --git a/apps/desktop/src/shared/machineIdentity.ts b/apps/desktop/src/shared/machineIdentity.ts index 50a70cbb7..1b83822ac 100644 --- a/apps/desktop/src/shared/machineIdentity.ts +++ b/apps/desktop/src/shared/machineIdentity.ts @@ -10,7 +10,7 @@ * branch is on "another" machine by comparing these ids * (`laneDivergence.detectPushDivergence`). The moment one producer supplies * `"local"` and the consumer expects `"this-mac"`, the self-filter stops - * matching and ADE warns you that This Mac has diverged from itself. + * matching and ADE warns you that This computer has diverged from itself. * * Machines are named absolutely. The word "remote" is never a machine name: * once the machine a tab is bound to can change, "remote" has no fixed @@ -22,7 +22,7 @@ export const THIS_MACHINE_ID = "this-mac"; /** Absolute display name for the machine ADE itself is running on. */ -export const THIS_MACHINE_NAME = "This Mac"; +export const THIS_MACHINE_NAME = "This computer"; /** True when an id refers to the machine ADE is running on. */ export function isThisMachineId(machineId: string | null | undefined): boolean { diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index e53eaa246..bad61a5e6 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -134,7 +134,14 @@ export type AppResourceRoleUsage = { export type AppResourceProcessSampleInfo = { /** "skipped" means nothing was active so no process sample was taken. */ status: "ok" | "unavailable" | "skipped"; - reason: "timeout" | "spawn-error" | "exit-code" | "oversized-output" | "idle" | null; + reason: + | "timeout" + | "spawn-error" + | "exit-code" + | "oversized-output" + | "unsupported-platform" + | "idle" + | null; sampledAt: string | null; durationMs: number | null; }; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 4a26df6a8..8277dfaff 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -8,6 +8,7 @@ import type { AgentChatCodexApprovalPolicy, AgentChatCodexConfigSource, AgentChatCodexSandbox, + AgentChatCliLaunchProvider, AgentChatSpawnKind, } from "./chat"; import type { LaneLinearIssue } from "./lanes"; @@ -318,6 +319,21 @@ export type PtyCreateArgs = { command?: string; args?: string[]; env?: Record; + /** + * Fresh provider-launch intent that must be materialized by the runtime + * which owns the lane. This keeps shell choice, skill roots, home paths, and + * path-list delimiters native to a pinned remote host. + */ + runtimeCliLaunch?: { + provider: AgentChatCliLaunchProvider; + permissionMode: AgentChatPermissionMode; + orchestrationRole?: OrchestrationRole | null; + sessionId?: string; + model?: string | null; + reasoningEffort?: string | null; + fastMode?: boolean | null; + initialPrompt?: string | null; + }; /** Optional provider continuation metadata to persist for externally imported sessions. */ resumeMetadata?: TerminalResumeMetadata | null; /** diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index a2bef0aca..a4a902d2c 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -466,6 +466,8 @@ export type SyncRoleSnapshot = { routeHealth: SyncRouteHealth; client: SyncClientStatus; transferReadiness: SyncTransferReadiness; + /** Absent on older runtimes; false means phone/CRDT sync must not be offered. */ + crdtSyncAvailable?: boolean; survivableStateText: string; blockingStateText: string; }; diff --git a/apps/desktop/tsup.config.ts b/apps/desktop/tsup.config.ts index 5b8c4ebbe..1cf297fb0 100644 --- a/apps/desktop/tsup.config.ts +++ b/apps/desktop/tsup.config.ts @@ -1,7 +1,10 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { defineConfig } from "tsup"; import { assertPublicPostHogToken } from "../../scripts/posthog/publicPostHogToken"; const posthogProjectToken = process.env.ADE_POSTHOG_PROJECT_TOKEN?.trim() ?? ""; +const devMainReadyMarker = process.env.ADE_DEV_MAIN_READY_MARKER?.trim() ?? ""; assertPublicPostHogToken(posthogProjectToken, "ADE_POSTHOG_PROJECT_TOKEN"); export default defineConfig({ @@ -28,6 +31,20 @@ export default defineConfig({ sourcemap: process.env.ADE_BUILD_SOURCEMAPS === "1", // Preserve Vite's dist/renderer output during main/preload watch rebuilds in dev. clean: ["dist/main", "dist/preload"], + // The dev launcher must not infer build completion from main.cjs appearing: + // tsup creates that file before every entry has finished. Signal only after + // the complete build succeeds so Electron never starts against a partial + // bundle, and use the same signal for safe watch-mode restarts. + onSuccess: devMainReadyMarker + ? async () => { + await fs.mkdir(path.dirname(devMainReadyMarker), { recursive: true }); + await fs.writeFile( + devMainReadyMarker, + `${process.pid}:${Date.now()}:${Math.random()}\n`, + "utf8", + ); + } + : undefined, // Inline build-time env variables so they're available in the packaged app. define: { "process.env.ADE_LINEAR_CLIENT_ID": JSON.stringify(process.env.ADE_LINEAR_CLIENT_ID ?? ""), diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs index 6c8b6f994..3bdb22d57 100644 --- a/scripts/dev-desktop.mjs +++ b/scripts/dev-desktop.mjs @@ -7,10 +7,10 @@ import { canConnectToSocket, devRuntimeEnv, ensureRuntime, - npmCommand, resolveDevSocketPath, resolveProjectRoot, - run, + runNpm, + shutdownRuntime, } from "./dev-shared.mjs"; function usage() { @@ -100,17 +100,42 @@ async function main() { throw new Error(`No dev runtime is listening at ${options.socketPath}. Start it with npm run dev:runtime.`); } await buildRuntimeCliForDevClient(options.skipRuntimeBuild, options.socketPath); - if (options.mode === "attach") { - await assertRuntimeFresh(options.socketPath, options.projectRoot); - } else { - await ensureRuntime(options.socketPath, options.projectRoot); + let runtimeStartedByLauncher = false; + let runtimeStopPromise = null; + const stopOwnedRuntime = () => { + if (!runtimeStartedByLauncher) return Promise.resolve(); + if (runtimeStopPromise) return runtimeStopPromise; + runtimeStopPromise = shutdownRuntime(options.socketPath) + .catch((error) => { + process.stderr.write( + `[ade] failed to stop owned dev runtime: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }); + return runtimeStopPromise; + }; + const handleSignal = () => { + // Ctrl+C is also delivered to the npm/Electron child. Keep this launcher + // alive just long enough to stop the detached runtime it created. + void stopOwnedRuntime(); + }; + process.once("SIGINT", handleSignal); + process.once("SIGTERM", handleSignal); + try { + if (options.mode === "attach") { + await assertRuntimeFresh(options.socketPath, options.projectRoot); + } else { + runtimeStartedByLauncher = await ensureRuntime(options.socketPath, options.projectRoot); + } + const desktopScript = options.clean ? "dev:clean" : "dev"; + await runNpm( + ["--prefix", "apps/desktop", "run", desktopScript], + devRuntimeEnv(options.socketPath, options.projectRoot), + ); + } finally { + process.off("SIGINT", handleSignal); + process.off("SIGTERM", handleSignal); + await stopOwnedRuntime(); } - const desktopScript = options.clean ? "dev:clean" : "dev"; - await run( - npmCommand, - ["--prefix", "apps/desktop", "run", desktopScript], - devRuntimeEnv(options.socketPath, options.projectRoot), - ); } main().catch((error) => { diff --git a/scripts/dev-runtime-stop.mjs b/scripts/dev-runtime-stop.mjs index 5d54a20be..3be01f4c0 100644 --- a/scripts/dev-runtime-stop.mjs +++ b/scripts/dev-runtime-stop.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; +import { + canConnectToSocket, + resolveDevSocketPath, + shutdownRuntime, +} from "./dev-shared.mjs"; function usage() { return [ @@ -18,13 +20,10 @@ function usage() { } function parseArgs(argv) { - const defaultSocketPath = process.platform === "win32" - ? path.join(os.tmpdir(), "ade-runtime-dev.sock") - : "/tmp/ade-runtime-dev.sock"; let socketPath = process.env.ADE_DEV_RUNTIME_SOCKET_PATH?.trim() || process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() - || defaultSocketPath; + || null; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; @@ -49,104 +48,14 @@ function parseArgs(argv) { throw new Error(`Unknown option: ${arg}`); } - return socketPath.startsWith("tcp://") ? socketPath : path.resolve(socketPath); -} - -function connectSocket(socketPath) { - if (socketPath.startsWith("tcp://")) { - const parsed = new URL(socketPath); - return net.createConnection({ host: parsed.hostname, port: Number(parsed.port) }); - } - return net.createConnection(socketPath); -} - -async function stopRuntime(socketPath) { - await new Promise((resolve, reject) => { - const socket = connectSocket(socketPath); - let buffer = ""; - let nextId = 1; - const pending = new Map(); - let settled = false; - const timer = setTimeout(() => { - finish(new Error(`Timed out waiting for ADE dev runtime at ${socketPath} to exit.`)); - }, 5000); - - const finish = (error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - socket.destroy(); - if (error) reject(error); - else resolve(); - }; - - const request = (method, params) => { - const id = nextId; - nextId += 1; - socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) })}\n`, "utf8"); - return new Promise((requestResolve, requestReject) => { - pending.set(id, { resolve: requestResolve, reject: requestReject }); - }); - }; - - socket.once("connect", () => { - void (async () => { - await request("ade/initialize", { - protocolVersion: "2025-06-18", - clientInfo: { name: "ade-dev-runtime-stop", version: "dev" }, - identity: { - callerId: `ade-dev-runtime-stop:${process.pid}`, - role: "external", - computerUsePolicy: { - mode: "auto", - allowLocalFallback: false, - retainArtifacts: true, - }, - }, - }); - await request("exit"); - finish(); - })().catch(finish); - }); - socket.on("data", (chunk) => { - buffer += chunk.toString("utf8"); - while (true) { - const lineEnd = buffer.indexOf("\n"); - if (lineEnd === -1) return; - const line = buffer.slice(0, lineEnd).trim(); - buffer = buffer.slice(lineEnd + 1); - if (!line) continue; - try { - const response = JSON.parse(line); - if (!response || typeof response !== "object" || !("id" in response)) continue; - const entry = pending.get(response.id); - if (!entry) continue; - pending.delete(response.id); - if (response.error) { - entry.reject(new Error(response.error.message || "ADE dev runtime rejected request.")); - } else { - entry.resolve(response.result); - } - } catch { - finish(new Error(`ADE dev runtime returned invalid JSON-RPC response: ${line}`)); - return; - } - } - }); - socket.once("close", () => finish()); - socket.once("error", (error) => { - if (error && (error.code === "ENOENT" || error.code === "ECONNREFUSED")) { - finish(); - return; - } - finish(error); - }); - }); + return resolveDevSocketPath(socketPath); } async function main() { const socketPath = parseArgs(process.argv.slice(2)); - await stopRuntime(socketPath); + if (await canConnectToSocket(socketPath)) { + await shutdownRuntime(socketPath); + } if (!socketPath.startsWith("tcp://")) { try { fs.unlinkSync(socketPath); } catch {} } diff --git a/scripts/dev-shared.mjs b/scripts/dev-shared.mjs index 3f945301e..0358e4193 100644 --- a/scripts/dev-shared.mjs +++ b/scripts/dev-shared.mjs @@ -9,12 +9,35 @@ import { fileURLToPath } from "node:url"; const sharedPath = fileURLToPath(import.meta.url); export const repoRoot = path.resolve(path.dirname(sharedPath), ".."); -export const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; -export const defaultDevSocketPath = process.platform === "win32" - ? path.join(os.tmpdir(), "ade-runtime-dev.sock") - : "/tmp/ade-runtime-dev.sock"; + +export function resolveDefaultDevSocketPath( + platform = process.platform, + env = process.env, +) { + if (platform !== "win32") return "/tmp/ade-runtime-dev.sock"; + const userIdentity = [ + env.USERDOMAIN?.trim(), + env.USERNAME?.trim() || os.userInfo().username.trim(), + ].filter(Boolean).join("\\").toLowerCase(); + const userHash = createHash("sha256") + .update(userIdentity || "unknown-user") + .digest("hex") + .slice(0, 12); + return `\\\\.\\pipe\\ade-runtime-dev-${userHash}`; +} + +export const defaultDevSocketPath = resolveDefaultDevSocketPath(); const validDefaultRoles = new Set(["cto", "orchestrator", "agent", "external", "evaluator"]); +export function resolveDevRuntimeStartupTimeoutMs( + platform = process.platform, +) { + // A freshly rebuilt CLI can spend more than ten seconds in Windows Defender + // inspection before Node reaches server.listen(). Do not kill a healthy + // runtime during that first-start window. + return platform === "win32" ? 30_000 : 10_000; +} + function normalizeDefaultRole(value, fallback = null) { const candidate = typeof value === "string" ? value.trim() : ""; return validDefaultRoles.has(candidate) ? candidate : fallback; @@ -24,7 +47,10 @@ export function resolveDevSocketPath(rawSocketPath = null) { const candidate = rawSocketPath?.trim() || process.env.ADE_DEV_RUNTIME_SOCKET_PATH?.trim() || defaultDevSocketPath; - return candidate.startsWith("tcp://") ? candidate : path.resolve(candidate); + const isWindowsNamedPipe = /^\\\\[.?]\\pipe\\/i.test(candidate); + return candidate.startsWith("tcp://") || isWindowsNamedPipe + ? candidate + : path.resolve(candidate); } export function resolvePrimaryProjectRoot(candidateRoot = repoRoot) { @@ -148,12 +174,61 @@ function runtimeBuildEnv() { return buildHash ? { ADE_RUNTIME_BUILD_HASH: buildHash } : {}; } +function quoteWindowsCmdArg(value) { + let quoted = "\""; + let backslashes = 0; + for (const char of String(value).replace(/%/g, "%%")) { + if (char === "\\") { + backslashes += 1; + continue; + } + if (char === "\"") { + quoted += "\\".repeat(backslashes * 2); + quoted += "\"\""; + } else { + quoted += "\\".repeat(backslashes); + quoted += char; + } + backslashes = 0; + } + quoted += "\\".repeat(backslashes * 2); + quoted += "\""; + return quoted; +} + +export function resolveDevSpawnInvocation( + command, + args, + env = process.env, + platform = process.platform, +) { + const extension = platform === "win32" + ? path.win32.extname(command).toLowerCase() + : ""; + if (platform !== "win32" || (extension !== ".cmd" && extension !== ".bat")) { + return { command, args, windowsVerbatimArguments: false }; + } + return { + command: env.ComSpec?.trim() || "cmd.exe", + args: [ + "/d", + "/s", + "/c", + `"${[command, ...args].map(quoteWindowsCmdArg).join(" ")}"`, + ], + windowsVerbatimArguments: true, + }; +} + export function run(command, args, extraEnv = {}) { return new Promise((resolve, reject) => { - const child = spawn(command, args, { + const env = { ...process.env, ...extraEnv }; + const invocation = resolveDevSpawnInvocation(command, args, env); + const child = spawn(invocation.command, invocation.args, { cwd: repoRoot, - env: { ...process.env, ...extraEnv }, + env, stdio: "inherit", + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); child.once("error", reject); child.once("exit", (code, signal) => { @@ -170,10 +245,46 @@ export function run(command, args, extraEnv = {}) { }); } +export function resolveNpmInvocation( + args, + options = {}, +) { + const platform = options.platform ?? process.platform; + const execPath = options.execPath ?? process.execPath; + const env = options.env ?? process.env; + const pathExists = options.pathExists ?? fs.existsSync; + if (platform !== "win32") { + return { command: "npm", args }; + } + + const candidates = [ + env.npm_execpath?.trim(), + path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js"), + path.join(path.dirname(execPath), "node_modules", "corepack", "dist", "npm.js"), + ].filter(Boolean); + const npmCliPath = candidates.find((candidate) => pathExists(candidate)); + if (!npmCliPath) { + throw new Error( + `Unable to resolve npm's JavaScript entry point beside ${execPath}. Reinstall Node.js/npm or set npm_execpath.`, + ); + } + return { + command: execPath, + args: [npmCliPath, ...args], + }; +} + +export function runNpm(args, extraEnv = {}) { + const invocation = resolveNpmInvocation(args, { + env: { ...process.env, ...extraEnv }, + }); + return run(invocation.command, invocation.args, extraEnv); +} + export async function buildRuntimeCli(skipRuntimeBuild = false) { if (skipRuntimeBuild) return; process.stdout.write("[ade] building runtime CLI\n"); - await run(npmCommand, ["--prefix", "apps/ade-cli", "run", "build"], { + await runNpm(["--prefix", "apps/ade-cli", "run", "build"], { ADE_CLI_VERSION: resolveDevAppVersion(), }); } @@ -425,7 +536,7 @@ async function waitForSocketToClose(socketPath, timeoutMs = 5000) { throw new Error(`Timed out waiting for stale ADE dev runtime at ${socketPath} to stop.`); } -async function shutdownRuntime(socketPath) { +export async function shutdownRuntime(socketPath) { await jsonRpcRequestSequence( socketPath, [ @@ -464,17 +575,29 @@ export async function ensureRuntime(socketPath, projectRoot = null) { process.stdout.write(`[ade] starting dev runtime at ${socketPath}\n`); const child = spawn(process.execPath, [cliPath(), "serve", "--socket", socketPath], { cwd: repoRoot, - env: { - ...process.env, - ...devRuntimeEnv(socketPath, projectRoot), - }, + env: detachedDevRuntimeEnv(socketPath, projectRoot), detached: true, stdio: "ignore", + windowsHide: process.platform === "win32", + }); + const runtimeExitedBeforeReady = new Promise((_, reject) => { + child.once("error", (error) => { + reject(new Error( + `ADE dev runtime failed to start at ${socketPath}: ${error instanceof Error ? error.message : String(error)}`, + )); + }); + child.once("exit", (code, signal) => { + reject(new Error( + `ADE dev runtime exited before opening ${socketPath} (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + )); + }); }); - child.once("error", () => {}); child.unref(); try { - await waitForSocket(socketPath); + await Promise.race([ + waitForSocket(socketPath, resolveDevRuntimeStartupTimeoutMs()), + runtimeExitedBeforeReady, + ]); } catch (error) { // The child is detached and unref'd, so a launcher that gives up here used // to walk away and leave an immortal brain behind — one per failed dev @@ -526,3 +649,21 @@ export function devRuntimeEnv(socketPath, projectRoot) { ...runtimeBuildEnv(), }; } + +export function detachedDevRuntimeEnv( + socketPath, + projectRoot, + parentEnv = process.env, +) { + const env = { + ...parentEnv, + ...devRuntimeEnv(socketPath, projectRoot), + }; + // A shared dev runtime outlives the terminal or Electron process that + // launched it. ADE-hosted shells can carry these lifecycle controls from a + // different runtime; inheriting them makes this detached server disappear + // as soon as that unrelated parent exits or its idle timer fires. + delete env.ADE_RUNTIME_PARENT_PID; + delete env.ADE_RUNTIME_IDLE_EXIT_MS; + return env; +} diff --git a/scripts/dev-shared.test.mjs b/scripts/dev-shared.test.mjs new file mode 100644 index 000000000..797229a0a --- /dev/null +++ b/scripts/dev-shared.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import net from "node:net"; +import test from "node:test"; + +import { + detachedDevRuntimeEnv, + resolveDefaultDevSocketPath, + resolveDevRuntimeStartupTimeoutMs, + resolveNpmInvocation, + resolveDevSocketPath, + resolveDevSpawnInvocation, + shutdownRuntime, +} from "./dev-shared.mjs"; + +test("uses a per-user Windows named pipe for the default dev runtime", () => { + const alice = resolveDefaultDevSocketPath("win32", { + USERDOMAIN: "ACME", + USERNAME: "alice", + }); + const bob = resolveDefaultDevSocketPath("win32", { + USERDOMAIN: "ACME", + USERNAME: "bob", + }); + + assert.match(alice, /^\\\\\.\\pipe\\ade-runtime-dev-[a-f0-9]{12}$/); + assert.notEqual(alice, bob); + assert.equal(resolveDevSocketPath(alice), alice); +}); + +test("keeps the Unix dev runtime socket unchanged", () => { + assert.equal(resolveDefaultDevSocketPath("linux", {}), "/tmp/ade-runtime-dev.sock"); +}); + +test("allows additional startup time for a freshly rebuilt Windows runtime", () => { + assert.equal(resolveDevRuntimeStartupTimeoutMs("win32"), 30_000); + assert.equal(resolveDevRuntimeStartupTimeoutMs("linux"), 10_000); +}); + +test("runs Windows command shims through cmd.exe without using a shell string", () => { + assert.deepEqual( + resolveDevSpawnInvocation( + "npm.cmd", + ["--prefix", "apps/desktop", "run", "dev"], + { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + "win32", + ), + { + command: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + "\"\"npm.cmd\" \"--prefix\" \"apps/desktop\" \"run\" \"dev\"\"", + ], + windowsVerbatimArguments: true, + }, + ); +}); + +test("runs native executables directly", () => { + assert.deepEqual( + resolveDevSpawnInvocation("node.exe", ["script.mjs"], {}, "win32"), + { + command: "node.exe", + args: ["script.mjs"], + windowsVerbatimArguments: false, + }, + ); +}); + +test("runs npm through its JavaScript entry point on Windows", () => { + const npmCliPath = "C:\\Program Files\\nodejs\\node_modules\\corepack\\dist\\npm.js"; + assert.deepEqual( + resolveNpmInvocation( + ["--prefix", "apps/desktop", "run", "dev"], + { + platform: "win32", + execPath: "C:\\Program Files\\nodejs\\node.exe", + env: {}, + pathExists: (candidate) => candidate === npmCliPath, + }, + ), + { + command: "C:\\Program Files\\nodejs\\node.exe", + args: [ + npmCliPath, + "--prefix", + "apps/desktop", + "run", + "dev", + ], + }, + ); +}); + +test("detached dev runtime does not inherit another runtime's shutdown controls", () => { + const env = detachedDevRuntimeEnv( + "\\\\.\\pipe\\ade-runtime-dev-test", + "C:\\dev\\ADE", + { + ADE_RUNTIME_PARENT_PID: "1234", + ADE_RUNTIME_IDLE_EXIT_MS: "5000", + KEEP_ME: "yes", + }, + ); + + assert.equal(env.ADE_RUNTIME_PARENT_PID, undefined); + assert.equal(env.ADE_RUNTIME_IDLE_EXIT_MS, undefined); + assert.equal(env.KEEP_ME, "yes"); + assert.equal(env.ADE_RUNTIME_SOCKET_PATH, "\\\\.\\pipe\\ade-runtime-dev-test"); +}); + +test("graceful dev runtime cleanup sends shutdown instead of hard exit", async () => { + const methods = []; + const server = net.createServer((socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + while (true) { + const lineEnd = buffer.indexOf("\n"); + if (lineEnd === -1) return; + const line = buffer.slice(0, lineEnd).trim(); + buffer = buffer.slice(lineEnd + 1); + if (!line) continue; + const request = JSON.parse(line); + methods.push(request.method); + socket.write(`${JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + result: request.method === "ade/initialize" + ? { runtimeInfo: {} } + : {}, + })}\n`); + if (request.method === "shutdown") { + socket.end(); + server.close(); + } + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const closed = new Promise((resolve) => server.once("close", resolve)); + + try { + await shutdownRuntime(`tcp://127.0.0.1:${address.port}`); + await closed; + } finally { + if (server.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + } + + assert.deepEqual(methods, ["ade/initialize", "shutdown"]); +}); diff --git a/scripts/run-desktop-test-shards.mjs b/scripts/run-desktop-test-shards.mjs index b889d47e3..a33328dbb 100644 --- a/scripts/run-desktop-test-shards.mjs +++ b/scripts/run-desktop-test-shards.mjs @@ -1,18 +1,25 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; +import { resolveNpmInvocation } from "./dev-shared.mjs"; -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const shardCount = Number.parseInt(process.env.ADE_DESKTOP_TEST_SHARDS ?? "8", 10); const totalShards = Number.isFinite(shardCount) && shardCount > 0 ? shardCount : 8; for (let shard = 1; shard <= totalShards; shard += 1) { process.stdout.write(`[ade] desktop test shard ${shard}/${totalShards}\n`); - const result = spawnSync( - npmCommand, - ["--prefix", "apps/desktop", "run", "test:unit", "--", `--shard=${shard}/${totalShards}`], - { stdio: "inherit" }, - ); + const invocation = resolveNpmInvocation([ + "--prefix", + "apps/desktop", + "run", + "test:unit", + "--", + `--shard=${shard}/${totalShards}`, + ]); + const result = spawnSync(invocation.command, invocation.args, { + stdio: "inherit", + windowsHide: process.platform === "win32", + }); if (result.error) { process.stderr.write(`[ade] desktop test shard ${shard}/${totalShards} failed to start: ${result.error.message}\n`); process.exit(1); From 484ee6b935cab8960c40f26e529d9fec36ef81c8 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 19:25:55 -0400 Subject: [PATCH 02/42] refactor(windows): keep foundation layer independently buildable Move the shared runtime initialize timeout contract into the foundation layer while deferring release-repository wiring to packaging. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- apps/desktop/src/main/main.ts | 29 ------------------- .../remoteRuntime/runtimeRpcClient.ts | 7 ++++- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 25b5ca562..3445ba1ab 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -266,7 +266,6 @@ import { LocalRuntimeConnectionPool } from "./services/localRuntime/localRuntime import { createSyncService } from "./services/sync/syncService"; import { blockPackagedLaunchForCrossChannelSyncConflict } from "./services/sync/packagedSyncHostLaunchGate"; import { createAutoUpdateService } from "./services/updates/autoUpdateService"; -import { DEFAULT_RELEASE_REPOSITORY } from "./services/updates/autoUpdateVersions"; import { cleanupStaleTempArtifacts } from "./services/runtime/tempCleanupService"; import type { Logger } from "./services/logging/logger"; import { resolveDesktopUserDataPath, resolveElectronAppDataPath } from "./desktopUserDataPath"; @@ -277,31 +276,6 @@ const AUTO_UPDATER_CACHE_DIR_NAME = "ade-desktop-updater"; type AdePackageChannel = "alpha" | "beta"; -function normalizeAdeReleaseRepository(value: unknown): string | null { - const normalized = typeof value === "string" - ? value.trim().replace(/^\/+|\/+$/g, "") - : ""; - return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized) ? normalized : null; -} - -function readBundledAdeReleaseRepository(): string { - try { - const packageJsonPath = path.join(app.getAppPath(), "package.json"); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - adeReleaseRepository?: unknown; - }; - const bundledRepository = normalizeAdeReleaseRepository(packageJson.adeReleaseRepository); - if (bundledRepository) return bundledRepository; - } catch { - // Older packages use the upstream repository default. - } - if (!app.isPackaged) { - return normalizeAdeReleaseRepository(process.env.ADE_RELEASE_REPOSITORY) - ?? DEFAULT_RELEASE_REPOSITORY; - } - return DEFAULT_RELEASE_REPOSITORY; -} - function normalizeAdePackageChannel(value: unknown): AdePackageChannel | null { const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; return normalized === "alpha" || normalized === "beta" ? normalized : null; @@ -342,7 +316,6 @@ function applyPackagedChannelDefaults(): void { } applyPackagedChannelDefaults(); -const packagedReleaseRepository = readBundledAdeReleaseRepository(); function configureDesktopUserDataPath(): void { const appDataPath = (() => { @@ -2267,7 +2240,6 @@ app.whenReady().then(async () => { rollbackQuitAndInstall: rollbackAutoUpdateInstall, getRuntimeActivitySummary: () => localRuntimePool.activitySummary(), productAnalyticsService, - releaseRepository: packagedReleaseRepository, forceQuit: () => { for (const win of BrowserWindow.getAllWindows()) { try { @@ -7094,7 +7066,6 @@ app.whenReady().then(async () => { closeCurrentProject, closeProjectByPath, globalStatePath, - releaseRepository: packagedReleaseRepository, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot: (snapshot: AttentionSnapshot) => { diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts index cd0c6a827..d77f62e03 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeRpcClient.ts @@ -84,7 +84,10 @@ export class RuntimeRpcClient { async initialize( clientName: string, version: string, - options: { desktopBridgeAuthToken?: string | null } = {}, + options: { + desktopBridgeAuthToken?: string | null; + timeoutMs?: number; + } = {}, ): Promise { return await this.call("ade/initialize", { protocolVersion: "2025-06-18", @@ -96,6 +99,8 @@ export class RuntimeRpcClient { ...(options.desktopBridgeAuthToken?.trim() ? { desktopBridgeAuthToken: options.desktopBridgeAuthToken.trim() } : {}), + }, { + ...(options.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}), }); } From ff879aa20f97782c4e7d343e58fe13c7f6e26074 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 20:27:36 -0400 Subject: [PATCH 03/42] docs: define stack-aware ship readiness Based-on: nsxdavid/ADE#999 Co-authored-by: David Whatley --- .agents/skills/quality/SKILL.md | 94 ++++++++- .../quality/references/ade-review-rules.md | 22 ++ .../references/correctness-security-review.md | 7 +- .../references/thermo-nuclear-review.md | 5 +- .agents/skills/ship/SKILL.md | 71 ++++++- .agents/skills/test/SKILL.md | 110 ++++++++-- docs/playbooks/ship-lane.md | 189 ++++++++++++++++-- 7 files changed, 447 insertions(+), 51 deletions(-) diff --git a/.agents/skills/quality/SKILL.md b/.agents/skills/quality/SKILL.md index 32792b7cb..a63f609a5 100644 --- a/.agents/skills/quality/SKILL.md +++ b/.agents/skills/quality/SKILL.md @@ -39,26 +39,108 @@ synthesis step owns all edits so dedupe and severity-gating happen in one place. teammate per track, lead runs synthesis. Per the global git-worktrees policy, do **not** pass worktree isolation. Never *require* a team to run this skill. -Each reviewer receives the same scoped context: `git diff main` plus the full +Each reviewer receives the same scoped context: `git diff "$QUALITY_REVIEW_BASE"` +plus the full contents of the changed files — **including new untracked files**, which -`git diff main` omits — so it evaluates without guessing. +the tracked diff omits — so it evaluates without guessing. --- ## Setup +**Invocation:** `/quality [feature] [--base ]`. `--base` is the explicit +direct-parent binding for a stacked layer. Resolve the base in this order: + +1. a validated `--base ` argument; +2. an existing PR's `baseRefName`; +3. the current entry's parent from non-interactive `gh stack view --json`; +4. `ADE_REVIEW_BASE_REF` from a trusted ship state file; +5. `main` for the unchanged ordinary workflow. + +Do not stop discovery after reading an existing PR. Still inspect `gh stack +view --json`: a current branch present in that stack makes its PR base exact, +including a bottom layer based on `main`. A PR with a non-default base is also +an exact direct-parent binding. Only an ordinary unstacked PR targeting the +repository default branch keeps `QUALITY_EXACT_BASE=false` and the historical +merge-base behavior. When both PR and stack metadata exist, their parent names +and SHAs must agree. + +Normalize `refs/heads/`, `refs/remotes/origin/`, `origin/`, and +plain `` to one plain branch name. Reject another remote, symbolic refs, +revision syntax (`..`, `~`, `^`, `:`), an empty value, or a name that fails +`git check-ref-format --branch`; never concatenate an unvalidated ref into a +command. Fetch the normalized name into its exact remote-tracking ref: + ```bash -git diff main --name-only # tracked changes vs main +# QUALITY_BASE_REF is the validated, normalized plain branch name selected +# above. QUALITY_EXACT_BASE is true for --base, stack metadata, or trusted +# stack ship state, a non-default PR base, or a PR confirmed in gh-stack; +# ordinary unstacked /quality against the default branch keeps it false. +git check-ref-format --branch "$QUALITY_BASE_REF" +git fetch origin "refs/heads/$QUALITY_BASE_REF:refs/remotes/origin/$QUALITY_BASE_REF" +QUALITY_BASE_SHA=$(git rev-parse "origin/$QUALITY_BASE_REF") +if [ "$QUALITY_EXACT_BASE" = true ]; then + git merge-base --is-ancestor "$QUALITY_BASE_SHA" HEAD || { + echo "stack-coordinator-sync-required: direct parent is not an ancestor of HEAD" + exit 1 + } + QUALITY_REVIEW_BASE="$QUALITY_BASE_SHA" +else + QUALITY_REVIEW_BASE=$(git merge-base HEAD "$QUALITY_BASE_SHA") +fi +git diff "$QUALITY_REVIEW_BASE" --name-only git status --short # NEW (untracked) files — git diff omits these -git diff main --stat | tail -20 -git log main..HEAD --oneline +git diff "$QUALITY_REVIEW_BASE" --stat | tail -20 +git log "$QUALITY_REVIEW_BASE"..HEAD --oneline ``` +For stack metadata, also require its reported parent SHA to equal +`QUALITY_BASE_SHA`; a name match alone is insufficient. The base must be the +**direct parent** of the current stack entry, not `main` +and not the root of the stack. Record the normalized parent branch, fetched +parent SHA, merge-base, reviewed head SHA, and content-tree SHA. If the parent +cannot be fetched or sources disagree, stop; silently widening or narrowing a +stacked review is not valid evidence. A parent-head or branch change invalidates +this result and every result above it in the stack. + +Run quality once per layer against its direct parent. For the fifth/top layer, +also run both review tracks cumulatively against `origin/main`; the layer passes +only when both the incremental and cumulative gates are empty. Record both +bindings. A lower-parent change cascades invalidation through all higher-layer +bindings, so the coordinator must sync/rebase the stack and rerun them in order. + A new service or module added but not yet committed will not appear in -`git diff main`. Fold the untracked files from `git status` into the review set +the tracked diff. Fold the untracked files from `git status` into the review set and read their full contents — an unreviewed new file is the easiest place for a Blocker to hide. +### Windows parity rules + +When the scoped diff touches filesystem paths, process launch, executable +resolution, IPC, SQLite/native modules, startup services, or Computer Use: + +- Treat Windows as a first-class runtime. Verify drive letters, native and mixed + separators, UNC paths, quoting, `PATHEXT` and executable discovery. Audit + PowerShell, `cmd.exe`, and Git Bash invocation separately for argument loss, + shell injection, and environment drift. Require process-tree termination, + per-user/per-channel named-pipe ACL isolation, Stable/Beta identity isolation, + semantic runtime readiness (not merely a live supervisor PID), stale-PID + cleanup, bounded supervisor restart/backoff, and packaged native dependencies. +- Trace installer, updater, signing, Windows Firewall, Relay, and capability-gate + effects. Verify IPC/preload/shared contracts, CLI/RPC, SQLite/CRR, mobile, + hosted web, and release-manifest compatibility rather than treating a native + host fix as isolated. +- Require platform gates to state the capability, not infer the whole product + is unsupported. Native screenshot/video/OS GUI automation may be blocked on + Windows while App Control and proof-file ingestion remain available. +- Trace the same change through macOS and Linux owners and tests. A Windows fix + that regresses launchd, Unix sockets, POSIX executable lookup, or graceful + Linux capability degradation is a correctness finding. +- Separate code-backed evidence from external proof. Native Windows tests and + CI can prove contracts; installed Stable/Beta isolation, second-account pipe + denial, clean-host restart, and GUI evidence remain explicit blockers until + captured on the corresponding hosts. + --- ## Phase 1: Thermo Dual-Review → Synthesize + Fix diff --git a/.agents/skills/quality/references/ade-review-rules.md b/.agents/skills/quality/references/ade-review-rules.md index 70cd9e44e..e8777e6d4 100644 --- a/.agents/skills/quality/references/ade-review-rules.md +++ b/.agents/skills/quality/references/ade-review-rules.md @@ -125,6 +125,28 @@ rejected for non-linear history; the fallback is a local admin-bypass push. This isn't a code finding but flag any automation that assumes a plain merge will succeed. +## 10. Windows foundation parity + +**Check:** When a lane touches path construction, process launch, executable +lookup, local IPC, SQLite/native artifacts, service lifecycle, or Computer Use: + +- Windows service health means the channel runtime answers on the expected + per-user pipe with the recorded PID; a live supervisor alone is not health. + Verify bounded restart backoff, stale/reused PID diagnostics, and Stable/Beta + identity after packaged channel defaults are applied. +- Named pipes must be scoped by canonical ADE home, channel/service, and current + user identity and retain intended-user listen restrictions. `.exe` resolution + and structured argv must not be replaced with shell-string parsing. +- Native Windows CI must load the actual CR-SQLite DLL and exercise a CRR + mutation when packaging/native paths change. Preserve the macOS dylib, Linux + graceful-degrade, launchd/systemd, and Unix socket contracts. +- Gate exact capabilities: Windows can block native screenshot/video/OS GUI + control while App Control and proof ingestion remain available. Do not widen + a native Computer Use limitation into a product-wide platform block. +- Treat clean-host Stable/Beta coexistence, second-account pipe denial, + reboot/restart, installed updates, and GUI artifacts as external proof. Code + or mocked tests cannot close those gates. + --- ## Output diff --git a/.agents/skills/quality/references/correctness-security-review.md b/.agents/skills/quality/references/correctness-security-review.md index 1923ff519..9e314c31e 100644 --- a/.agents/skills/quality/references/correctness-security-review.md +++ b/.agents/skills/quality/references/correctness-security-review.md @@ -14,14 +14,15 @@ and security/safety issues. Be rigorous — nothing should slip through. ## Scope - ONLY report issues in code being **added or modified** on this lane. Focus on - the diff against `main`. + the diff against the resolved `QUALITY_REVIEW_BASE` (`main` ordinarily; the + direct parent for a stacked PR). - Do NOT report pre-existing issues in untouched code. - Trace cross-module side effects of the changed code even into unchanged files, but the *finding* must trace back to something this lane changed. ```bash -git diff main -git diff main --name-only +git diff "$QUALITY_REVIEW_BASE" +git diff "$QUALITY_REVIEW_BASE" --name-only ``` --- diff --git a/.agents/skills/quality/references/thermo-nuclear-review.md b/.agents/skills/quality/references/thermo-nuclear-review.md index 20ee0ab89..98d3fed02 100644 --- a/.agents/skills/quality/references/thermo-nuclear-review.md +++ b/.agents/skills/quality/references/thermo-nuclear-review.md @@ -4,8 +4,9 @@ Seven structural standards. The primary question for every finding: does a "code judo" move exist — a smaller change that makes the code fundamentally simpler, not just cleaner? -Tone: direct and demanding, not rude. Scope to the diff against `main` — do not -restructure untouched code. +Tone: direct and demanding, not rude. Scope to the diff against the resolved +`QUALITY_REVIEW_BASE` (`main` ordinarily; the direct parent for a stacked PR) — +do not restructure untouched code. --- diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 05b2be90e..1c0595f6a 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -25,7 +25,41 @@ Print a compact status line each iteration (no banner): ship · iter 2/5 · PR #184 · POLL → DECIDE → FIX → MERGE · FIXING CI (test-desktop 3) + 2 comments ``` -**Invocation:** `/ship` (auto-detect state) or `/ship `. +**Invocation:** `/ship` (auto-detect state), `/ship `, or the opt-in +`/ship --stack-ready [] --base `. + +### Stack-ready mode (opt-in only) + +`--stack-ready` prepares one dependent PR for its coordinator; it does not land +the stack. Resolve the direct parent from `--base`, an existing PR's +`baseRefName`, then non-interactive `gh stack view --json`; normalize it with +the `/quality` rules. Persist `mode: "stack"` plus the complete stack binding: +stack number, position, expected parent branch, validated head SHA, base SHA, +content-tree SHA, test-evidence SHA, proof links, and quality/test status. + +After the exact head is green, review-terminal, quality-clean, and test-clean, +write `status: "ready-stacked"` and return the binding to the coordinator. A +branch change, commit/rebase, or any lower-parent movement invalidates this +entry and every entry above it. Missing or ambiguous metadata is `blocked`, not +a fallback to `main`. + +**Coordinator ownership is absolute in stack mode.** Before any cap, +force-finalize, rebase, push, merge, or branch-deletion decision, branch on +`mode == "stack"`. The per-PR loop must never independently rebase a canonical +stack branch, mutate descendants, or push/submit it. It reports +`stack-coordinator-sync-required`; the coordinator alone runs the +non-interactive `gh stack sync --remote origin`, `gh stack rebase --upstack +--remote origin`, `gh stack push --remote origin`, or `gh stack submit --auto +--remote origin` workflow. Stack mode can never enter force-finalize or any +bypass-review logic. It also requires an existing coordinator-created PR and a +clean, already-tested head: it never commits, pushes, creates/updates a PR, or +fixes red CI/review on the canonical branch. Those cases return +`stack-coordinator-pr-required`, `stack-coordinator-sync-required`, or +`stack-coordinator-fix-required` with exact evidence. + +Without `--stack-ready`, every existing `/ship` default and merge behavior is +unchanged: the base is `main`, green work proceeds through Phase 3c, and the +terminal success state is `done-clean` only after merge confirmation. --- @@ -35,7 +69,8 @@ ship · iter 2/5 · PR #184 · POLL → DECIDE → FIX → MERGE · FIXING CI (t commands, decision rules, and bot-ping rules live there. This skill is the runtime-neutral entrypoint and the ADE-specific deltas below. If re-invoked by a scheduled wake, read the state file first; if `status == running`, skip Phase 0 -and go to Phase 1. +and go to Phase 1. If `status == ready-stacked`, print the persisted coordinator +handoff and exit without scheduling or mutating anything. The playbook's Phase 0 is **checkpoint → commit-bound quality revalidation → push → open PR**. Baseline test generation and the local-CI gate are NOT part @@ -60,7 +95,9 @@ change this branch was not asked to make. Both need the author. - If `/quality` was never run on this lane, or its final gate result is not available in the lane handoff, stop with `blocked`; unknown is not empty. - Any base movement, rebase, conflict resolution, Phase 3b edit, or - force-finalize edit clears all three quality binding fields. Run the + force-finalize edit clears all three quality binding fields. In stack mode, + any such movement clears the complete stack binding and returns control to + the coordinator without rebasing or pushing. Run the playbook's single canonical **Commit-bound quality revalidation** procedure before pushing that mutation. - Never enter Phase 3c with a missing or mismatched binding. Revalidate first; @@ -98,10 +135,21 @@ only user-visible output is the per-iteration status line and the final summary. `gh pr checks` / the `ade-pr-workflows` skill — do not hardcode. - **PR creation:** prefer the `ade` CLI (registers the PR in ADE's tracking — lane ↔ PR link, check/comment inventory). `gh pr create --base main --head - --fill` is the fallback, not the default. See the playbook's discovery protocol. + --fill` is the ordinary fallback; stack mode substitutes the persisted direct + parent for `main`. See the playbook's discovery protocol. - **State file:** `.ade/shipLane/.json`. `status`: - `running` | `done-clean` | `done-max` | `blocked`. Rebase rebates the iteration - counter by 2 (floor 0). + `running` | `ready-stacked` | `done-clean` | `done-max` | `blocked`; it also + records `mode` and the complete stack binding. Rebase rebates the iteration counter by 2 + (floor 0). + +**Windows proof gate.** For a Windows-relevant stack entry, require the native +Windows foundation check to be terminal-green on the bound head. Require the +packaged Windows check when packaging or native bundle contents changed. +Computer Use evidence is capability-specific: native OS capture/control may be +explicitly blocked while App Control and proof ingestion remain supported and +tested. Clean-host Stable/Beta coexistence, second-account pipe denial, +restart/reboot, installed-update, and GUI artifacts remain named external proof +blockers until captured; never mark them proven from simulated tests. --- @@ -135,11 +183,13 @@ terminal-neutral, and continue. Record it under `inactiveReviewBots`, never If branch protection requires an absent check, Phase 3c will surface that as a merge-policy block. -**Rebase only on real conflicts or a stale quality base.** `behindMain` alone +**Rebase only on real conflicts or a stale quality base.** `behindBase` alone does not normally trigger a rebase. The one safety exception is base movement after quality validation: the final tree is no longer the reviewed head tree, -so rebase and rerun the canonical quality procedure even when GitHub reports a -clean merge. Otherwise, skip needless rebases. +so ordinary merge mode rebases and reruns the canonical quality procedure even +when GitHub reports a clean merge. Stack mode instead invalidates the current +and upstack bindings and returns `stack-coordinator-sync-required`; it never +rebases or pushes. Otherwise, skip needless rebases. **Bot pings by iteration.** Never ping GitHub Copilot and never treat Copilot as an expected review signal; quota exhaustion otherwise leaves the loop waiting @@ -228,10 +278,11 @@ self-resume signal. Either: | Status | Meaning | |--------|---------| +| `ready-stacked` | Opt-in stacked PR has a complete head/base/tree/test/proof binding; coordinator owns all stack mutation and landing | | `done-clean` | PR merged on main | | `done-max` | 5 normal + 1 force-finalize exhausted, merge genuinely blocked | | `blocked` | Unrecoverable conflict, gate failure, API error, force-finalize CI failed, or a non-empty `/quality` gate awaiting an author decision | Always print the final summary (PR, branch, iterations, status, reason, per-iteration log, unaddressed items) on exit. Do NOT schedule a wake when -`status` is `done-clean` / `done-max` / `blocked`. +`status` is `ready-stacked` / `done-clean` / `done-max` / `blocked`. diff --git a/.agents/skills/test/SKILL.md b/.agents/skills/test/SKILL.md index 1c805b341..5660e1fa6 100644 --- a/.agents/skills/test/SKILL.md +++ b/.agents/skills/test/SKILL.md @@ -66,7 +66,65 @@ Run end-to-end without user interaction. Do not ask, pause, or request clarifica **Do all the work yourself in the main loop.** Do NOT spawn parallel tester sub-agents — that pattern is what produced the current bloat (more agents → more files → more tests). One agent, one judgment. -**Argument:** `$ARGUMENTS` — optional feature hint (e.g. `/test prs` or `/test orchestrator, focus on merge queue`). If empty, infer the feature from `git diff main --name-only` **plus `git status --short`** (the latter catches new untracked test/source files that `git diff main` omits). +**Arguments:** `$ARGUMENTS` — optional feature hint plus optional +`--base ` (for example `/test prs --base codex/stack-parent`). If the +feature hint is empty, infer it from `git diff "$TEST_REVIEW_BASE" --name-only` +**plus `git status --short`** (the latter catches new untracked test/source files +that the tracked diff omits). + +### Review scope (ordinary and stacked PRs) + +Resolve the review base once before Pass 1. Use the same precedence and +normalization as `/quality`: explicit `--base`, existing PR `baseRefName`, the +current parent from `gh stack view --json`, trusted `ADE_REVIEW_BASE_REF`, then +`main`. Normalize `refs/heads/`, `refs/remotes/origin/`, `origin/`, and plain +branch spellings to a validated plain name. Reject other remotes, symbolic refs, +revision syntax, or anything failing `git check-ref-format --branch`. + +Do not stop after an existing PR supplies `baseRefName`; still query `gh stack +view --json`. Set `TEST_EXACT_BASE=true` when the current branch is in that +stack (including a bottom layer targeting `main`) or when the PR targets a +non-default branch. Only an ordinary unstacked PR targeting the repository +default branch retains merge-base behavior. PR and stack parent names/SHAs must +agree when both exist. + +```bash +# TEST_BASE_REF is the validated, normalized plain branch name selected above. +# TEST_EXACT_BASE is true for --base, stack metadata/trusted state, a +# non-default PR base, or a PR confirmed in gh-stack. +git check-ref-format --branch "$TEST_BASE_REF" +git fetch origin "refs/heads/$TEST_BASE_REF:refs/remotes/origin/$TEST_BASE_REF" +TEST_BASE_SHA=$(git rev-parse "origin/$TEST_BASE_REF") +if [ "$TEST_EXACT_BASE" = true ]; then + git merge-base --is-ancestor "$TEST_BASE_SHA" HEAD || { + echo "stack-coordinator-sync-required: direct parent is not an ancestor of HEAD" + exit 1 + } + TEST_REVIEW_BASE="$TEST_BASE_SHA" +else + TEST_REVIEW_BASE=$(git merge-base HEAD "$TEST_BASE_SHA") +fi +``` + +Every pass below uses `TEST_REVIEW_BASE`. It must be the current stack entry's +direct parent. When stack metadata is available, require its parent SHA to +equal `TEST_BASE_SHA`; a branch-name match is insufficient. Record parent +branch/SHA, merge-base, exact tested head SHA and +tree SHA, test-evidence SHA, status, and proof links in the summary. Any commit, +rebase, branch change, or lower-parent movement invalidates that evidence and +all evidence above it. A missing or unfetchable parent is a blocker, not +permission to fall back to `main`. + +### Host parity and evidence binding + +Classify affected behavior across **Windows**, **macOS**, **Linux/headless**, +**iOS**, and **hosted web**. Mark each host applicable, capability-blocked, or +not applicable with a concrete reason; do not use one desktop run as proof for +the matrix. GUI proof requires both (1) direct UI observation and (2) an +independent corroborating log, database, process, IPC, or network signal. Bind +every artifact/link to the exact tested commit SHA and content-tree SHA. A new +commit or rebase makes prior GUI and Computer Use evidence stale even when the +visible diff looks unrelated. --- @@ -238,13 +296,13 @@ Spawn a general-purpose agent with this prompt: ``` You are the documentation updater for the ADE project. -Analyze all changes on the current branch vs main and update relevant internal +Analyze all changes on the current branch vs the resolved review base and update relevant internal docs under `docs/`. The public Mintlify site (docs.json + root-level .mdx files) is out of scope — do NOT touch it. Step 1: Get changed files - git diff main --name-only - git diff main --stat | tail -30 + git diff "$TEST_REVIEW_BASE" --name-only + git diff "$TEST_REVIEW_BASE" --stat | tail -30 Step 2: Map changed source to internal docs @@ -311,14 +369,14 @@ Spawn a general-purpose agent with this prompt: ``` You are the mobile parity reviewer for the ADE project. -Analyze all work on the current branch vs main, including changes that are +Analyze all work on the current branch vs the resolved direct review base, including changes that are already under review and any simplifications made during `/finalize`. Determine whether the iOS companion app under `apps/ios/` needs matching updates. Step 1: Get branch context - git diff main --name-only - git diff main --stat | tail -30 - git log main..HEAD --oneline + git diff "$TEST_REVIEW_BASE" --name-only + git diff "$TEST_REVIEW_BASE" --stat | tail -30 + git log "$TEST_REVIEW_BASE"..HEAD --oneline Step 2: Identify cross-platform changes - Shared contracts: apps/desktop/src/shared/**, preload IPC types, sync payloads, @@ -386,9 +444,9 @@ must change with it. Your job is to detect drift on this branch and patch apps/ade-cli/ so the CLI stays in lockstep with desktop. Step 1: Get branch context - git diff main --name-only - git diff main --stat | tail -30 - git log main..HEAD --oneline + git diff "$TEST_REVIEW_BASE" --name-only + git diff "$TEST_REVIEW_BASE" --stat | tail -30 + git log "$TEST_REVIEW_BASE"..HEAD --oneline Step 2: Identify CLI-relevant desktop changes Treat anything under these paths as a candidate for new / changed / removed @@ -467,8 +525,8 @@ commonly because a new git/lane/PR action becomes available, a slash command is renamed, or a lane summary field is added. Step 1: Get branch context - git diff main --name-only - git diff main --stat | tail -30 + git diff "$TEST_REVIEW_BASE" --name-only + git diff "$TEST_REVIEW_BASE" --stat | tail -30 Step 2: Identify TUI-relevant changes. Treat as candidates: - apps/desktop/src/shared/types/lanes.ts, /chat, /sync — TUI imports these directly. @@ -510,6 +568,32 @@ Report: Wait for all four parity agents to complete before moving to Verification. +### Windows parity and Computer Use evidence + +If the review scope touches paths, processes, executables, local IPC, native +SQLite, startup services, or Computer Use, the test summary must include a +Windows evidence ledger: + +- Run the narrow contract tests locally with injectable `win32`, `darwin`, and + `linux` cases. Native Windows CI must repeat the Windows-sensitive files on a + `windows-latest` runner; a Linux simulation alone is insufficient. +- Prove Stable/Beta service identity, per-user/channel pipe naming and listen + restrictions, `.exe` and argument-array launch resolution, supervisor + restart/backoff, runtime readiness and stale-PID diagnostics, and packaged + SQLite/CRR loading whenever those owners changed. +- For Computer Use, list evidence by capability. Windows may explicitly report + native screenshot/video/OS GUI control as unavailable; do not treat that as + evidence that App Control or proof-file ingestion is unavailable. Test those + platform-neutral paths independently. +- Record external evidence honestly. Installed Stable/Beta coexistence, + second-account named-pipe denial, reboot/restart recovery, signed/installed + upgrades, and real GUI captures require the corresponding Windows hosts. + Attach artifact paths when available and list the missing proof as a blocker + when it is not. Never replace host proof with a mocked assertion. +- Preserve macOS/Linux parity with parameterized contract tests and the + existing CI shards. A Windows-specific pass does not waive regression + coverage for launchd, Unix sockets, or Linux capability degradation. + --- ## Verification diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md index df75e1aae..decdc1c9e 100644 --- a/docs/playbooks/ship-lane.md +++ b/docs/playbooks/ship-lane.md @@ -12,10 +12,36 @@ Run this playbook once per lane, when the code on the branch is done (or nearly - Rebasing when teammates merge into `main` ahead of you - Repeating until the PR is clean, capped, or a human is required +### Optional stacked-PR mode + +`/ship --stack-ready --base ` is an opt-in preparation +mode for a coordinator-owned stack. Persist `mode: "stack"` and a complete +`stackBinding`. Resolve the base from the explicit flag first, an existing PR's +`baseRefName` second, and non-interactive `gh stack view --json` last. It must +be the current entry's direct parent; an ambiguous or unfetchable parent blocks +the run. Never silently substitute `main`. + +In this mode, quality and test scope use the direct parent. When the exact head +is terminal-green and the complete binding is current, set `status: +"ready-stacked"`, print the evidence, and return control to the coordinator. +Do not merge, enable auto-merge, delete branches, mutate any stack branch, +rebase, push, submit, or touch release publication flags. The coordinator alone +uses non-interactive `gh stack ... --remote origin` commands to mutate or +publish the canonical stack. + +Invoking ordinary `/ship` without `--stack-ready` keeps every existing behavior +in this playbook: `main` is the base and a green lane proceeds through Phase 3c +until it is merged or genuinely blocked. + ## Execution contract - **Autonomous.** Do not pause for user confirmation mid-loop. -- **Bounded with a force-finalize escape hatch.** Soft cap: 5 normal iterations of fix-and-poll. Exit earlier if clean or blocked. **At the cap, the loop must land the lane**: if the PR is not merged after iteration 5, run **one** additional force-finalize iteration (Phase 3d) that ignores all open review comments, fixes only CI failures so every required check goes green, then routes through Phase 3c. Only if iteration 6 cannot make CI green, or Phase 3c is genuinely blocked by base-branch policy with no authorized direct/admin path, do you stop and leave a handoff comment for a human. The playbook's exit contract is "PR merged into `main`, or merge genuinely impossible" — never "PR green and parked". +- **Bounded with a force-finalize escape hatch in ordinary mode only.** Before + evaluating a cap or any force path, hard-branch on `mode == "stack"` and + return `ready-stacked` or `blocked` to the coordinator. Stack mode must never + enter force-finalize or bypass review. In ordinary merge mode, the existing + contract is unchanged: after 5 normal iterations, run the one Phase 3d + force-finalize pass and land the PR or report a genuine policy/CI block. - **Rebase budget rebate.** A rebase, merge-from-main, or conflict-resolution pass moves the current iteration count down by 2 before the next cap check, with a floor of 0. Example: if the lane is on iteration 4 and must rebase because `main` moved, record the rebase and continue as iteration 2. - **Scoped checks.** Never run the full test suite between iterations. For CI, fix and rerun only the failing test file(s) or failing check target. For review-only changes, rerun only directly affected existing tests, plus the narrow package typecheck/lint when the touched surface needs it. - **One push per iteration. Wait for BOTH signals before fixing anything.** Never push a CI-only fix while review bots are still running, and never push a review-only fix while CI is still running. Both signals must be **terminal** before the iteration commits — that is, every required check has a final conclusion AND every review bot with current-head start evidence has posted or settled. This is not just an efficiency rule: **review-comment fixes routinely introduce new CI failures**, so applying them on a partial signal means the next push fails and you've thrown away the prior CI cycle. Wait for both, then dispatch ci-fix-agent and review-fix-agent in parallel with full knowledge of both, and combine their edits into one commit. If only one signal has landed when you wake, do not iterate — reschedule and sleep. @@ -102,6 +128,8 @@ Path: `.ade/shipLane/.json` (sanitize by replacing `/` with `_ ```json { "branch": "ade/chat-title-summaries-xyz", + "mode": "merge", + "baseBranch": "main", "prNumber": 1234, "iteration": 2, "lastPushSha": "abc123...", @@ -116,7 +144,58 @@ Path: `.ade/shipLane/.json` (sanitize by replacing `/` with `_ } ``` -`status` values: `running`, `done-clean`, `done-max`, `blocked`. +`status` values are `running`, `ready-stacked`, `done-clean`, `done-max`, and +`blocked`. `mode` is `merge` for ordinary `/ship` and `stack` only when +`--stack-ready` was explicitly supplied. A resumed run must reject mode/base +changes rather than accidentally switching lifecycle semantics. + +Stack mode additionally requires this machine-checkable binding (full 40-hex +SHAs are abbreviated here only for readability): + +```json +{ + "branch": "codex/windows-foundation", + "mode": "stack", + "prNumber": 1006, + "stackBinding": { + "stackNumber": 12, + "position": 1, + "expectedParentBranch": "main", + "validatedHeadSha": "1111111111111111111111111111111111111111", + "baseSha": "2222222222222222222222222222222222222222", + "contentTreeSha": "3333333333333333333333333333333333333333", + "testEvidenceSha": "4444444444444444444444444444444444444444", + "proofLinks": ["https://github.com/example/ADE/actions/runs/123"], + "qualityStatus": "passed", + "testStatus": "passed" + }, + "status": "ready-stacked" +} +``` + +Validate the shape before accepting the terminal state, for example: + +```bash +jq -e ' + .mode == "stack" and .status == "ready-stacked" and + (.stackBinding.stackNumber | type == "number" and . > 0) and + (.stackBinding.position | type == "number" and . > 0) and + ([.stackBinding.validatedHeadSha, .stackBinding.baseSha, + .stackBinding.contentTreeSha, .stackBinding.testEvidenceSha] + | all(test("^[0-9a-f]{40}$"))) and + (.stackBinding.expectedParentBranch | length > 0) and + (.stackBinding.proofLinks | type == "array" and length > 0) and + .stackBinding.testEvidenceSha == .stackBinding.validatedHeadSha and + .stackBinding.qualityStatus == "passed" and + .stackBinding.testStatus == "passed" +' "$STATE_FILE" +``` + +On every resume, compare the current branch and `gh stack view --json` stack +number, position, direct-parent branch/SHA, head SHA, and content tree with the +binding. Also require `testEvidenceSha == validatedHeadSha`. Any commit, rebase, +branch change, or lower-parent movement invalidates this entry and every entry +above it. Clear their bindings and return `stack-coordinator-sync-required`. The `iteration` value is the active turn budget counter, not a raw count of pushes. Normal fix iterations increment it by 1. Rebase/merge/conflict recovery decrements it by 2 first, then the current pass records its result. Never let it go below 0. @@ -131,8 +210,9 @@ base, narrow test targets, and push command; they do not restate this algorithm. `QUALITY_VALIDATED_BASE_SHA` to that fetched commit, and require it to be an ancestor of the candidate head. If it is not, preserve the work, route through Phase 3a, and restart this procedure after the rebase; do not bind a - behind-base tree. This works before PR creation; Phase 0 uses `main` as the - intended base. + behind-base tree. This works before PR creation; Phase 0 uses `main` in + ordinary mode and the persisted direct parent in stack mode. If stack mode + needs a rebase or push, stop this procedure and return coordinator action. 2. Run both `/quality` tracks on the final combined diff, fix every accepted finding, and repeat both tracks until the same pass is clean. 3. Build the validation scope from the union of committed, unstaged, staged, @@ -198,6 +278,9 @@ Only then may state become `done-clean`. A head mismatch is ## Phase 0 — Setup (first invocation only) Skip this phase if `.ade/shipLane/.json` exists with `status: running`. +If it exists with `status: ready-stacked`, first revalidate the complete binding, +then print the persisted coordinator +handoff and exit without a poll, wake, push, rebase, merge, or branch deletion. ### 0.1 Detect current state @@ -208,7 +291,24 @@ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) gh pr view --json number,state,headRefOid,baseRefName 2>/dev/null ``` -If a PR exists for the current branch, skip to 0.4 (bot pings) with `prNumber` captured. +Set `SHIP_MODE=merge` and `SHIP_BASE_BRANCH=main` for ordinary `/ship`. With +`--stack-ready`, set `SHIP_MODE=stack`, resolve and validate the direct +parent as described above, and set `SHIP_BASE_BRANCH` to it. If an existing PR +is present, its `baseRefName` must equal the resolved base. Export +`ADE_REVIEW_BASE_REF="$SHIP_BASE_BRANCH"` so baseline `/quality` and `/test` +review only the incremental stack entry. + +In ordinary mode, if a PR exists for the current branch, skip to 0.4 (bot +pings) with `prNumber` captured. Stack mode follows the hard branch below. + +**Hard stack-mode branch:** stack mode is read-only with respect to the +canonical branch and PR. It requires an existing coordinator-created PR whose +head/base match the supplied binding and a clean working tree already bound to +completed `/quality` and `/test` evidence. If no PR exists, exit `blocked` with +`stack-coordinator-pr-required`. If the head/base or working tree differs, exit +with `stack-coordinator-sync-required`. Write the initial stack state and go +directly to Phase 1; do not execute 0.2–0.4, commit, push, create/update a PR, +or ping bots. The coordinator owns those mutations through `gh stack`. ### 0.2 Pre-push expectation (no existing PR) @@ -244,14 +344,14 @@ git add -A git diff --cached --quiet || git commit -m "ship: prepare lane for review" ``` -Fetch `origin/main`, bind the intended base, and set `QUALITY_DIFF_BASE` to the +Fetch the selected origin base, bind it, and set `QUALITY_DIFF_BASE` to the feature merge-base before running the canonical Commit-bound quality revalidation procedure. This includes every committed feature file rather than only edits made after the checkpoint: ```bash -git fetch origin main -QUALITY_VALIDATED_BASE_SHA=$(git rev-parse origin/main) +git fetch origin "$SHIP_BASE_BRANCH" +QUALITY_VALIDATED_BASE_SHA=$(git rev-parse "origin/$SHIP_BASE_BRANCH") QUALITY_DIFF_BASE=$(git merge-base HEAD "$QUALITY_VALIDATED_BASE_SHA") QUALITY_COMMIT_MESSAGE="ship: apply initial quality revalidation" # Push command for canonical step 5: @@ -288,7 +388,7 @@ The `ade` surface evolves. Don't assume flag names or output shapes from this pl Only after steps 1–6 have been genuinely attempted should the fallback run: ```bash -gh pr create --base main --head "$CURRENT_BRANCH" --fill +gh pr create --base "$SHIP_BASE_BRANCH" --head "$CURRENT_BRANCH" --fill PR_NUMBER=$(gh pr view --json number -q .number) ``` @@ -308,6 +408,8 @@ actual PR head and base. ```json { "branch": "", + "mode": "", + "baseBranch": "
", "prNumber": , "iteration": 0, "lastPushSha": "", @@ -393,7 +495,9 @@ Filter out any comment whose `id` is in `addressedCommentIds`. ```json { "merged": false, - "behindMain": true, + "behindBase": true, + "baseBranch": "", + "baseSha": "", "isDraft": false, "ciRunning": false, "reviewBotsRunning": false, @@ -422,7 +526,12 @@ evidence. Populate `inactiveReviewBots` when the 12-minute grace window has elapsed and every available surface has zero evidence for that bot. Do not infer that a bot is running merely because it is usually expected in this repository. -`behindMain` is derived from `mergeStateStatus` being `BEHIND` or `DIRTY`, or from `git merge-base --is-ancestor origin/main HEAD` returning non-zero. +`behindBase` is the canonical poll field. It is derived from `mergeStateStatus` +being `BEHIND` or `DIRTY`, or +from `git merge-base --is-ancestor "origin/$SHIP_BASE_BRANCH" HEAD` returning +non-zero. When resuming an older ordinary-mode state file only, map its legacy +`behindMain` value to `behindBase`; never emit both fields, and never use the +legacy field for stack state. --- @@ -432,8 +541,9 @@ Pure logic on the poll summary: | Condition | Action | | --- | --- | +| `mode == "stack"` (evaluate before every ordinary row) | If externally merged, report `stack-coordinator-merged` without running merge confirmation/deletion; if any binding changed, clear this/upstack bindings and exit `stack-coordinator-sync-required`; if CI/review is pending, schedule a read-only poll; if terminal-red/actionable, exit `stack-coordinator-fix-required`; if terminal-green, go only to 3c.0. Never enter 3a, 3b, 3c.1–3c.5, or 3d. | | `merged == true` | Run **Confirm the validated merge result**; exit `done-clean` only when it succeeds. | -| `behindMain == true` | Go to Phase 3a (rebase), apply the rebase budget rebate, then schedule/poll according to Phase 5. | +| `behindBase == true` | In ordinary mode, go to Phase 3a (rebase), apply the rebase budget rebate, then schedule/poll according to Phase 5. | | `ciRunning == true` OR `reviewBotsRunning == true` | Do NOT iterate on a partial signal. Go to Phase 5 (schedule next wake). This applies even if the other signal already shows failures/comments — pushing a fix now means the next CI+review cycle races the fix and you likely re-push for the other half. | | `ciFailed` empty, `newComments` empty, `ciRunning == false`, `reviewBotsRunning == false` | Go to **Phase 3c**. Done-clean does not mean "stop and leave for human" — it means everything is green, and the lane should land on `main`. | | Otherwise (both signals terminal, fix work exists) | Go to Phase 3b (fix). Fix CI failures and review comments **in the same iteration / same push**. | @@ -442,23 +552,31 @@ Pure logic on the poll summary: ## Phase 3a — Rebase / merge +**Hard stack-mode branch:** if `SHIP_MODE=stack`, do not execute any command in +this phase. Clear the current and upstack bindings, set `status: "blocked"` and +`exitReason: "stack-coordinator-sync-required"`, and tell the coordinator to +inspect `gh stack view --json` then use the appropriate non-interactive +`gh stack sync --remote origin` or `gh stack rebase --upstack --remote origin`, +followed by `gh stack submit --auto --remote origin`. The per-PR loop never +rebases or pushes a canonical stack branch and never mutates descendants. + ```bash git fetch origin -git rebase origin/main +git rebase "origin/$SHIP_BASE_BRANCH" ``` **On conflict:** the lead resolves using full repo context. The agent has the codebase; it reads both sides of each conflict and produces a merged result. If the conflict spans many files or touches shared contracts (IPC types, DB schema, sync payloads), the lead spawns a **conflict-resolver sub-agent** with: - The conflicted file list - The two divergent diffs per file (`git diff :1: :2:` base→ours, `git diff :1: :3:` base→theirs) -- The branch's feature context (`git log main..HEAD --oneline`) +- The branch's feature context (`git log "origin/$SHIP_BASE_BRANCH"..HEAD --oneline`) - Explicit instruction to preserve both sides' intent rather than picking one If rebase becomes unrecoverable (agent's own judgment): ```bash git rebase --abort -git merge origin/main +git merge "origin/$SHIP_BASE_BRANCH" ``` Resolve merge conflicts the same way. If the merge is **still** unrecoverable, exit `blocked` with `exitReason: "conflict-unrecoverable"` and post a PR comment flagging a human, listing the files involved. @@ -511,6 +629,12 @@ Post bot pings (Phase 4), update state (Phase 5), and schedule the next wake. Do ## Phase 3b — Fix +**Hard stack-mode branch:** if `SHIP_MODE=stack`, do not execute this phase. +Set `status: "blocked"` and `exitReason: "stack-coordinator-fix-required"`, +return the failing checks/actionable comments, and leave all branch/PR mutation +to the coordinator. Stack mode never dispatches fix agents, commits, pushes, or +mutates descendants. + ### 3b.1 Parse failed CI For each failed check: @@ -603,6 +727,27 @@ before merging. If the gate is non-empty or unavailable, exit `blocked` with `quality-gate-nonempty` or `quality-result-missing`; never merge and disclose deferred findings afterwards. +For a Windows-relevant diff, the bound head must have a terminal-green native +`windows-foundation` check. Also require the packaged Windows job when the diff +changes packaging, native bundle contents, or release-contract inputs. Record +Computer Use evidence by capability: native screenshot/video/OS GUI control +may be explicitly unavailable on Windows while App Control and proof-file +ingestion remain supported and independently tested. Clean-host Stable/Beta +coexistence, second-account pipe denial, reboot/restart recovery, +signed/installed update proof, and real GUI captures are external host evidence; +list missing artifacts as blockers rather than claiming them from mocks. + +### 3c.0 Finish stack-ready mode + +If `SHIP_MODE=stack`, validate the full `stackBinding`: stack number, position, +expected parent branch, parent SHA, PR head SHA, content-tree SHA, +test-evidence SHA, proof links, and passed quality/test status. Required +CI/review evidence must be terminal. Then set `status: "ready-stacked"`, retain +the state file, print the full binding and external proof blockers, and return +to the stack coordinator. Do not execute 3c.1–3c.5 or Phase 3d. A moved branch, +parent, head, or lower layer invalidates this and all higher bindings and exits +`stack-coordinator-sync-required`; the per-PR loop does not rebase or push. + ### 3c.1 Resolve repo merge style ```bash @@ -659,6 +804,11 @@ Do NOT schedule another wake-up. Runs at most once per lane, only when iteration 5 has just completed and the PR is still not merged. The point of this phase is to **land** the lane — review feedback is intentionally bypassed; CI must end green. +**Hard precondition before every other check:** `SHIP_MODE` must equal `merge`. +If it equals `stack`, return to Phase 3c.0 or exit +`stack-coordinator-sync-required`. Stack mode must never set `forceFinalize`, +ignore review feedback, or enter any bypass-review path. + ### 3d.1 Preconditions - State file shows `iteration >= 5` AND `forceFinalize` is unset/false. @@ -725,7 +875,7 @@ Schedule the next wake at the normal post-push cadence (270s if CI hasn't starte When the next wake polls: - `merged == true` → run **Confirm the validated merge result**; exit `done-clean` only when it succeeds. -- `behindMain == true` → run Phase 3a (rebase) once, push, schedule next wake. Do NOT count it as a new iteration; force-finalize already ran. +- `behindBase == true` → run Phase 3a (rebase) once, push, schedule next wake. Do NOT count it as a new iteration; force-finalize already ran. - CI terminal AND green → route **immediately** through Phase 3c. Do NOT wait on review bots; review is intentionally bypassed in this phase. - CI terminal AND any required check still failing → exit `blocked`, `exitReason: "force-finalize-ci-failed"`, post a PR comment listing the failing job names + links. Do not start a seventh iteration. - CI still running → sleep on the normal cadence; do not act on a partial signal. @@ -780,6 +930,9 @@ These are separate comments (not a single body) so each bot handler parses its o ### 5.2 Decide exit vs next wake +- `mode == "stack"` → before evaluating iteration caps or `forceFinalize`, run + Phase 3c.0. Return `ready-stacked` when the complete binding is current; + otherwise exit `stack-coordinator-sync-required`. Never enter Phase 3d. - `merged == true` → run **Confirm the validated merge result**; set `done-clean` only when it succeeds. - `iteration >= 5` AND `forceFinalize` unset/false AND not merged → run Phase 3d (force-finalize) on the next wake's fix turn. Do not exit; the cap is not a stop sign, it's a "land it now" trigger that switches the loop into review-ignoring CI-only mode. - `forceFinalize == true` AND CI green AND not merged → route immediately through Phase 3c. Only if Phase 3c has no authorized direct/admin path do you set `status: done-max` and leave a handoff comment. @@ -812,6 +965,7 @@ The cadence is a hint, not a live polling budget. Prefer longer sleeps over freq | status | meaning | next action | | --- | --- | --- | +| `ready-stacked` | Opt-in stacked PR has a complete current head/base/tree/test/proof binding; no mutation or merge was attempted | retain state and return the binding to the stack coordinator | | `done-clean` | PR merged on `main` (Phase 3c succeeded, possibly after Phase 3d force-finalize) | clear state file; print summary | | `done-max` | 5 normal iterations + 1 force-finalize iteration exhausted AND Phase 3c has no authorized direct/admin merge path | leave state file; post PR handoff comment to human | | `blocked` | Unrecoverable conflict, missing/non-empty quality gate, API error, or `force-finalize-ci-failed` (iteration 6 could not turn CI green) | leave state file; post PR comment with reason | @@ -823,8 +977,9 @@ The cadence is a hint, not a live polling budget. Prefer longer sleeps over freq - PR: # - Branch: <branch> +- Mode/base: <merge | stack> / <base branch @ sha> - Iterations: <0..5> -- Status: <done-clean | done-max | blocked> +- Status: <ready-stacked | done-clean | done-max | blocked> - Reason: <one line> ### Per-iteration log From 49d88ce65968d4672f12ce6bb33d1f41ddff63cd Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 20:27:47 -0400 Subject: [PATCH 04/42] feat: harden Windows runtime foundation Based-on: nsxdavid/ADE#999 Co-authored-by: David Whatley <nsxdavid@gmail.com> --- .../ade-cli/src/serviceManager/common.test.ts | 608 +-------------- apps/ade-cli/src/serviceManager/common.ts | 57 +- .../src/serviceManager/installWindows.test.ts | 710 ++++++++++++++++++ .../src/serviceManager/installWindows.ts | 239 +++--- .../serviceManager/windowsSupervisor.test.ts | 162 ++++ .../src/serviceManager/windowsSupervisor.ts | 395 ++++++++++ .../computerUse/localComputerUse.test.ts | 37 +- .../services/computerUse/localComputerUse.ts | 32 +- .../desktop/src/renderer/lib/platform.test.ts | 7 + apps/desktop/src/renderer/lib/platform.ts | 11 +- docs/ARCHITECTURE.md | 5 +- 11 files changed, 1493 insertions(+), 770 deletions(-) create mode 100644 apps/ade-cli/src/serviceManager/installWindows.test.ts create mode 100644 apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts create mode 100644 apps/ade-cli/src/serviceManager/windowsSupervisor.ts diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index c91d7ebe0..8c973bd7c 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -9,8 +9,6 @@ import { isCurrentProcessDescendantOfPid, isStaleChannelServeCommandLine, renderCommand, - renderWindowsCommand, - renderWindowsServiceLauncher, resolveAdeServeCommand, type AdeServiceCommand, type ServiceManagerProcessResult, @@ -25,25 +23,7 @@ import { uninstallLaunchdService, } from "./installLaunchd"; import { installSystemdService, renderSystemdEnvironment, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd"; -import { - buildWindowsCreateTaskArgs, - buildWindowsDeleteTaskArgs, - buildWindowsEndTaskArgs, - buildWindowsQueryTaskArgs, - buildWindowsRunKeyAddArgs, - buildWindowsRunKeyDeleteArgs, - buildWindowsRunKeyQueryArgs, - buildWindowsRunTaskArgs, - buildWindowsStartLauncherArgs, - getWindowsServiceStatus, - installWindowsService, - isWindowsTaskStateRunning, - resolveWindowsServiceLauncherPath, - resolveWindowsTaskName, - resolveWindowsTaskUser, - uninstallWindowsService, - WINDOWS_POWERSHELL_COMMAND, -} from "./installWindows"; +import { isWindowsTaskStateRunning } from "./installWindows"; const originalArgv = [...process.argv]; const originalNodePath = process.env.NODE_PATH; @@ -993,592 +973,6 @@ describe("systemd service install", () => { }); }); -describe("Windows background service helpers", () => { - const serviceCommand: AdeServiceCommand = { - command: "C:\\Program Files\\ADE\\ade.exe", - args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], - env: { - ELECTRON_RUN_AS_NODE: "1", - NODE_PATH: "C:\\Program Files\\ADE\\resources\\ade-cli\\node_modules", - ADE_HOME: "C:\\Users\\arul\\.ade-beta", - ADE_PACKAGE_CHANNEL: "beta", - }, - }; - const taskUser = "ADEBOX\\arul"; - const serviceName = "com.ade.runtime.beta"; - const taskName = resolveWindowsTaskName({ serviceName, userName: taskUser }); - - it("builds schtasks create, run, query, and delete arguments without invoking schtasks", () => { - const renderedCommand = renderWindowsCommand({ - command: WINDOWS_POWERSHELL_COMMAND, - args: [ - "-NoProfile", - "-NonInteractive", - "-WindowStyle", - "Hidden", - "-ExecutionPolicy", - "Bypass", - "-File", - "C:\\Users\\arul\\.ade-beta\\runtime\\brain-service.ps1", - ], - }); - - expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser, taskName)).toEqual([ - "/Create", - "/SC", - "ONLOGON", - "/TN", - taskName, - "/TR", - renderedCommand, - "/RU", - taskUser, - "/IT", - "/F", - ]); - expect(buildWindowsRunTaskArgs(taskName)).toEqual(["/Run", "/TN", taskName]); - expect(buildWindowsEndTaskArgs(taskName)).toEqual(["/End", "/TN", taskName]); - expect(buildWindowsQueryTaskArgs(taskName)).toEqual([ - "-NoProfile", - "-NonInteractive", - "-Command", - expect.stringContaining(`$_.TaskName -eq '${taskName}'`), - ]); - expect(buildWindowsDeleteTaskArgs(taskName)).toEqual(["/Delete", "/TN", taskName, "/F"]); - }); - - it("resolves the Windows scheduled task user from domain and username environment values", () => { - expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "arul" })).toBe("ADEBOX\\arul"); - expect(resolveWindowsTaskUser({ USERNAME: "LOCALUSER" })).toBe("LOCALUSER"); - expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "ADEBOX\\arul" })).toBe("ADEBOX\\arul"); - expect(resolveWindowsTaskUser({ - USERDOMAIN: "MicrosoftAccount", - USERNAME: "owner@example.com", - })).toBe("MicrosoftAccount\\owner@example.com"); - }); - - it("isolates scheduled task names by release channel and Windows principal", () => { - const stableArul = resolveWindowsTaskName({ - serviceName: "com.ade.runtime", - userName: "ADEBOX\\arul", - }); - const betaArul = resolveWindowsTaskName({ - serviceName: "com.ade.runtime.beta", - userName: "ADEBOX\\arul", - }); - const betaOtherUser = resolveWindowsTaskName({ - serviceName: "com.ade.runtime.beta", - userName: "ADEBOX\\other", - }); - - expect(stableArul).toMatch(/^ADE Runtime \(stable-[a-f0-9]{12}\)$/); - expect(betaArul).toMatch(/^ADE Runtime \(beta-[a-f0-9]{12}\)$/); - expect(new Set([stableArul, betaArul, betaOtherUser])).toHaveLength(3); - expect(resolveWindowsTaskName({ - serviceName: "com.ade.runtime.beta", - userName: "adebox\\ARUL", - })).toBe(betaArul); - }); - - it("renders Windows scheduled task commands with double-quoted argv tokens", () => { - expect(renderWindowsCommand({ - command: "C:\\Program Files\\ADE\\ade.exe", - args: ["serve", "--root", "C:\\path with space\\"], - })).toBe("\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--root\" \"C:\\path with space\\\\\""); - expect(renderCommand(serviceCommand)).toBe( - "'C:\\Program Files\\ADE\\ade.exe' 'C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs' 'serve'", - ); - }); - - it("escapes embedded double quotes in Windows scheduled task command tokens", () => { - expect(renderWindowsCommand({ - command: "C:\\Program Files\\ADE\\ade.exe", - args: ["serve", "--name", "quoted \"value\""], - })).toBe( - "\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--name\" \"quoted \\\"value\\\"\"", - ); - }); - - it("renders a PowerShell launcher that preserves the service environment and quotes data literally", () => { - const script = renderWindowsServiceLauncher({ - command: "C:\\Program Files\\ADE\\ADE.exe", - args: ["C:\\Program Files\\ADE\\cli.cjs", "serve", "quoted \"value\"", "O'Brien"], - env: { - ELECTRON_RUN_AS_NODE: "1", - NODE_PATH: "C:\\ADE deps\\100% & O'Brien", - ADE_HOME: "C:\\Users\\arul\\.ade-beta", - }, - }); - - expect(script).toContain( - "[System.Environment]::SetEnvironmentVariable('ELECTRON_RUN_AS_NODE', '1', 'Process')", - ); - expect(script).toContain( - "[System.Environment]::SetEnvironmentVariable('NODE_PATH', 'C:\\ADE deps\\100% & O''Brien', 'Process')", - ); - expect(script).toContain("$startInfo.FileName = 'C:\\Program Files\\ADE\\ADE.exe'"); - expect(script).toContain( - "$startInfo.Arguments = '\"C:\\Program Files\\ADE\\cli.cjs\" \"serve\" \"quoted \\\"value\\\"\" \"O''Brien\"'", - ); - expect(script).toContain("$startInfo.CreateNoWindow = $true"); - expect(script).toContain( - "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", - ); - expect(script).toContain("$process = [System.Diagnostics.Process]::Start($startInfo)"); - }); - - (process.platform === "win32" ? it : it.skip)( - "executes the generated PowerShell launcher with literal environment and argv values", - () => { - const launcherPath = path.join( - makeTempHome("ade-windows-service-exec-"), - "brain-service.ps1", - ); - const outputPath = path.join(path.dirname(launcherPath), "result.json"); - fs.writeFileSync( - launcherPath, - `\uFEFF${renderWindowsServiceLauncher({ - command: process.execPath, - args: [ - "-e", - "require('node:fs').writeFileSync(process.env.ADE_TEST_OUTPUT, JSON.stringify({ value: process.env.ADE_TEST_VALUE, args: process.argv.slice(1) }), 'utf8')", - "quoted \"value\"", - "O'Brien", - "100% & $HOME", - "naïve-東京-🚀", - ], - env: { - ADE_TEST_VALUE: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", - ADE_TEST_OUTPUT: outputPath, - }, - })}`, - "utf8", - ); - - const result = spawnChildSync( - WINDOWS_POWERSHELL_COMMAND, - ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launcherPath], - { encoding: "utf8", windowsHide: true }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({ - value: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", - args: ["quoted \"value\"", "O'Brien", "100% & $HOME", "naïve-東京-🚀"], - }); - }, - ); - - it("registers and starts the per-user background service without Task Scheduler", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 3, stdout: "", stderr: "" }, - { status: 3, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "ERROR: value not found" }, - { status: 0, stdout: "The operation completed successfully.", stderr: "" }, - { status: 0, stdout: "1234", stderr: "" }, - ]); - const launcherPath = path.join(makeTempHome("ade-windows-service-"), "brain-service.ps1"); - const pidPath = `${launcherPath}.pid.json`; - - const result = installWindowsService({ - command: serviceCommand, - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result).toMatchObject({ - ok: true, - serviceName, - action: "install", - path: taskName, - message: "ADE per-user startup entry installed and background service started.", - }); - expect(fs.readFileSync(launcherPath, "utf8")).toBe( - `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, - ); - const scheduledCommand = renderWindowsCommand({ - command: WINDOWS_POWERSHELL_COMMAND, - args: [ - "-NoProfile", - "-NonInteractive", - "-WindowStyle", - "Hidden", - "-ExecutionPolicy", - "Bypass", - "-File", - launcherPath, - ], - }); - expect(calls).toEqual([ - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, - ]); - }); - - it("ends and replaces a running channel task before starting the repaired runtime", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 3, stdout: "", stderr: "" }, - { status: 0, stdout: "Running", stderr: "" }, - { status: 0, stdout: "SUCCESS: ended", stderr: "" }, - { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, - { status: 1, stdout: "", stderr: "" }, - { status: 0, stdout: "SUCCESS: created", stderr: "" }, - { status: 0, stdout: "1234", stderr: "" }, - ]); - const launcherPath = path.join(makeTempHome("ade-windows-service-repair-"), "brain-service.ps1"); - - const result = installWindowsService({ - command: serviceCommand, - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result.ok).toBe(true); - expect(calls.slice(0, 4)).toEqual([ - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, - ]); - expect(calls.at(-2)?.args).toEqual(expect.arrayContaining(["ADD", "/V", taskName])); - expect(calls.at(-1)?.args).toEqual(buildWindowsStartLauncherArgs(launcherPath)); - }); - - it("ends and deletes only the exact legacy task before installing the channel task", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "Running", stderr: "" }, - { status: 0, stdout: "SUCCESS: ended", stderr: "" }, - { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, - { status: 3, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "" }, - { status: 0, stdout: "SUCCESS: created", stderr: "" }, - { status: 0, stdout: "1234", stderr: "" }, - ]); - const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-"), "brain-service.ps1"); - - const result = installWindowsService({ - command: serviceCommand, - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result.ok).toBe(true); - expect(calls.slice(0, 3)).toEqual([ - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, - ]); - expect(calls.flatMap((call) => call.args)).not.toContain("ADE Runtime "); - }); - - it("does not register or start a channel task when the running legacy task cannot be ended", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "Running", stderr: "" }, - { status: 1, stdout: "", stderr: "ERROR: access is denied" }, - ]); - const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-fail-"), "brain-service.ps1"); - - const result = installWindowsService({ - command: serviceCommand, - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result.ok).toBe(false); - expect(result.message).toContain("legacy ADE Runtime scheduled task"); - expect(calls).toEqual([ - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, - ]); - }); - - it("removes the per-user startup entry when immediate start fails", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 3, stdout: "", stderr: "" }, - { status: 3, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "" }, - { status: 0, stdout: "SUCCESS: created", stderr: "" }, - { status: 1, stdout: "", stderr: "ERROR: access is denied" }, - { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, - ]); - const launcherPath = path.join(makeTempHome("ade-windows-service-start-fail-"), "brain-service.ps1"); - - const result = installWindowsService({ - command: serviceCommand, - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result.ok).toBe(false); - expect(result.message).toBe("ADE per-user startup entry was installed, but the background service failed to start: ERROR: access is denied"); - const scheduledCommand = renderWindowsCommand({ - command: WINDOWS_POWERSHELL_COMMAND, - args: [ - "-NoProfile", - "-NonInteractive", - "-WindowStyle", - "Hidden", - "-ExecutionPolicy", - "Bypass", - "-File", - launcherPath, - ], - }); - expect(calls.map((call) => call.args)).toEqual([ - buildWindowsQueryTaskArgs("ADE Runtime"), - buildWindowsQueryTaskArgs(taskName), - buildWindowsRunKeyQueryArgs(taskName), - buildWindowsRunKeyAddArgs(taskName, scheduledCommand), - buildWindowsStartLauncherArgs(launcherPath), - buildWindowsRunKeyDeleteArgs(taskName), - ]); - }); - - it("does not start the service when per-user registration fails", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 3, stdout: "", stderr: "" }, - { status: 3, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "ERROR: registration failed" }, - ]); - const launcherPath = path.join(makeTempHome("ade-windows-service-create-fail-"), "brain-service.ps1"); - - const result = installWindowsService({ - command: serviceCommand, - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result.ok).toBe(false); - expect(result.message).toBe("ERROR: registration failed"); - expect(calls).toEqual([ - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, - expect.objectContaining({ command: "reg.exe", args: expect.arrayContaining(["ADD"]) }), - ]); - }); - - it("removes legacy tasks and the per-user startup entry", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "Ready", stderr: "" }, - { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, - { status: 0, stdout: "Running", stderr: "" }, - { status: 0, stdout: "SUCCESS: ended", stderr: "" }, - { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, - { status: 0, stdout: "startup value", stderr: "" }, - { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, - ]); - const launcherPath = path.join(makeTempHome("ade-windows-service-remove-"), "brain-service.ps1"); - fs.writeFileSync(launcherPath, "old launcher", "utf8"); - - const result = uninstallWindowsService({ - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result).toMatchObject({ - ok: true, - serviceName, - action: "uninstall", - path: taskName, - message: "ADE background service startup entry removed.", - }); - expect(calls).toEqual([ - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyDeleteArgs(taskName) }, - ]); - expect(fs.existsSync(launcherPath)).toBe(false); - }); - - it("surfaces scheduled task removal failures", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "Ready", stderr: "" }, - { status: 1, stdout: "", stderr: "ERROR: The system cannot find the file specified." }, - { status: 3, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "" }, - ]); - - const result = uninstallWindowsService({ serviceName, spawnSync, userName: taskUser }); - - expect(result.ok).toBe(false); - expect(result.message).toContain("ERROR: The system cannot find the file specified."); - expect(calls).toEqual([ - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, - ]); - }); - - it("fails uninstall when the scheduled task launcher cannot be removed", () => { - const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 3, stdout: "", stderr: "" }, - { status: 3, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "" }, - ]); - const launcherPath = makeTempHome("ade-windows-service-launcher-dir-"); - - const result = uninstallWindowsService({ - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result).toMatchObject({ - ok: false, - serviceName, - action: "uninstall", - path: launcherPath, - }); - expect(result.message).toContain("launcher could not be deleted"); - }); - - it("queries Task Scheduler state through PowerShell instead of localized schtasks labels", () => { - const calls: Array<{ - command: string; - args: string[]; - options: import("node:child_process").SpawnSyncOptions | undefined; - }> = []; - const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { - calls.push({ command, args, options }); - return { status: 0, stdout: "Running", stderr: "" }; - }; - - expect(getWindowsServiceStatus({ serviceName, spawnSync, userName: taskUser })).toMatchObject({ - ok: true, - installed: true, - running: true, - path: taskName, - }); - expect(calls).toEqual([ - { - command: WINDOWS_POWERSHELL_COMMAND, - args: buildWindowsQueryTaskArgs(taskName), - options: expect.objectContaining({ windowsHide: true }), - }, - ]); - }); - - it("hides every Windows scheduled-task lifecycle subprocess", () => { - const calls: Array<{ - command: string; - args: string[]; - options: import("node:child_process").SpawnSyncOptions | undefined; - }> = []; - const results: ServiceManagerProcessResult[] = [ - { status: 0, stdout: "Running", stderr: "" }, - { status: 0, stdout: "", stderr: "" }, - { status: 0, stdout: "", stderr: "" }, - { status: 3, stdout: "", stderr: "" }, - { status: 0, stdout: "", stderr: "" }, - { status: 0, stdout: "", stderr: "" }, - ]; - const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { - calls.push({ command, args, options }); - return results.shift() ?? { status: 0, stdout: "", stderr: "" }; - }; - const launcherPath = path.join( - makeTempHome("ade-windows-service-hidden-"), - "brain-service.ps1", - ); - - const result = installWindowsService({ - command: serviceCommand, - launcherPath, - serviceName, - spawnSync, - userName: taskUser, - }); - - expect(result.ok).toBe(true); - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((call) => call.options?.windowsHide === true)).toBe(true); - }); - - it("distinguishes an absent task from a failed locale-independent status query", () => { - const absentCalls: Array<{ command: string; args: string[] }> = []; - const absent = getWindowsServiceStatus({ - serviceName, - spawnSync: spawnSequence(absentCalls, [ - { status: 3, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "" }, - ]), - userName: taskUser, - }); - const failedCalls: Array<{ command: string; args: string[] }> = []; - const failed = getWindowsServiceStatus({ - serviceName, - spawnSync: spawnSequence(failedCalls, [ - { status: 1, stdout: "", stderr: "PowerShell unavailable" }, - { status: 1, stdout: "", stderr: "" }, - ]), - userName: taskUser, - }); - - expect(absent).toMatchObject({ ok: true, installed: false, running: false }); - expect(failed).toMatchObject({ ok: false, installed: null, running: null }); - }); - - ( - process.platform === "win32" - && !os.userInfo().username.toLowerCase().startsWith("codexsandbox") - ? it - : it.skip - )( - "returns the dedicated not-found exit code from a real locale-independent task query", - () => { - const missingTaskName = `ADE Runtime Test ${process.pid} ${Date.now()}`; - const result = spawnChildSync( - WINDOWS_POWERSHELL_COMMAND, - buildWindowsQueryTaskArgs(missingTaskName), - { encoding: "utf8" }, - ); - - expect(result.status, result.stderr).toBe(3); - expect(result.stdout).toBe(""); - }, - ); - - it("derives the launcher path from the channel-local ADE home", () => { - expect(resolveWindowsServiceLauncherPath({ - env: { ADE_HOME: "C:\\Users\\arul\\.ade-beta" }, - serviceName, - })).toMatch(/^C:\\Users\\arul\\\.ade-beta\\runtime\\brain-service-[a-f0-9]{12}\.ps1$/i); - }); -}); function spawnSequence( calls: Array<{ command: string; args: string[] }>, diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index 76caf5f1c..cce2690cf 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -33,7 +33,7 @@ export type AdeServiceCommand = { env?: Record<string, string>; }; -function resolveRuntimeServiceName(env: NodeJS.ProcessEnv = process.env): string { +export function resolveRuntimeServiceName(env: NodeJS.ProcessEnv = process.env): string { const explicit = env.ADE_RUNTIME_SERVICE_NAME?.trim(); if (explicit) return explicit; const channel = env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); @@ -399,61 +399,6 @@ export function renderWindowsCommand(command: AdeServiceCommand): string { return [command.command, ...command.args].map(cmdQuote).join(" "); } -function powerShellSingleQuotedLiteral(value: string): string { - if (value.includes("\0")) { - throw new Error("Windows service command values cannot contain NUL bytes."); - } - return `'${value.replace(/'/g, "''")}'`; -} - -export function renderWindowsServiceLauncher( - command: AdeServiceCommand, - options: { pidPath?: string } = {}, -): string { - const environment = Object.entries(command.env ?? {}).sort(([left], [right]) => - left.localeCompare(right), - ); - const environmentLines = environment.map(([key, value]) => { - if (!key || key.includes("=") || key.includes("\0")) { - throw new Error(`Invalid Windows service environment variable name: ${JSON.stringify(key)}.`); - } - return `[System.Environment]::SetEnvironmentVariable(${powerShellSingleQuotedLiteral(key)}, ${powerShellSingleQuotedLiteral(value)}, 'Process')`; - }); - const commandLine = command.args.map(cmdQuote).join(" "); - const processLines = options.pidPath - ? [ - "$process = [System.Diagnostics.Process]::Start($startInfo)", - "if ($null -eq $process) { throw 'Windows failed to start the ADE brain process.' }", - "$pidRecord = '{\"supervisorPid\":' + $PID.ToString([Globalization.CultureInfo]::InvariantCulture) + ',\"runtimePid\":' + $process.Id.ToString([Globalization.CultureInfo]::InvariantCulture) + '}'", - `[IO.File]::WriteAllText(${powerShellSingleQuotedLiteral(options.pidPath)}, $pidRecord, [Text.Encoding]::ASCII)`, - "try {", - " $process.WaitForExit()", - " $exitCode = $process.ExitCode", - "} finally {", - ` Remove-Item -LiteralPath ${powerShellSingleQuotedLiteral(options.pidPath)} -Force -ErrorAction SilentlyContinue`, - "}", - "exit $exitCode", - ] - : [ - "$process = [System.Diagnostics.Process]::Start($startInfo)", - "if ($null -eq $process) { throw 'Windows failed to start the ADE brain process.' }", - "$process.WaitForExit()", - "exit $process.ExitCode", - ]; - - return [ - "$ErrorActionPreference = 'Stop'", - ...environmentLines, - "$startInfo = New-Object System.Diagnostics.ProcessStartInfo", - `$startInfo.FileName = ${powerShellSingleQuotedLiteral(command.command)}`, - `$startInfo.Arguments = ${powerShellSingleQuotedLiteral(commandLine)}`, - "$startInfo.UseShellExecute = $false", - "$startInfo.CreateNoWindow = $true", - "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", - ...processLines, - "", - ].join("\r\n"); -} function streamToText(value: string | Buffer | null | undefined): string { if (typeof value === "string") return value.trim(); diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts new file mode 100644 index 000000000..96fb7d22e --- /dev/null +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -0,0 +1,710 @@ +import fs from "node:fs"; +import { spawnSync as spawnChildSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + renderCommand, + renderWindowsCommand, + resolveRuntimeServiceName, + type AdeServiceCommand, + type ServiceManagerProcessResult, + type ServiceManagerSpawnSync, +} from "./common"; +import { + buildWindowsCreateTaskArgs, + buildWindowsDeleteTaskArgs, + buildWindowsEndTaskArgs, + buildWindowsQueryTaskArgs, + buildWindowsRunKeyAddArgs, + buildWindowsRunKeyDeleteArgs, + buildWindowsRunKeyQueryArgs, + buildWindowsRunTaskArgs, + buildWindowsStartLauncherArgs, + getWindowsServiceStatus, + installWindowsService, + readWindowsServicePidRecord, + resolveWindowsServiceLauncherPath, + resolveWindowsTaskName, + resolveWindowsTaskUser, + uninstallWindowsService, + WINDOWS_POWERSHELL_COMMAND, + renderWindowsServiceLauncher, +} from "./installWindows"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempHome(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function spawnSequence( + calls: Array<{ command: string; args: string[] }>, + results: ServiceManagerProcessResult[], +): ServiceManagerSpawnSync { + return (command, args) => { + calls.push({ command, args }); + return results.shift() ?? { status: 0, stdout: "", stderr: "" }; + }; +} + +describe("Windows background service helpers", () => { + const serviceCommand: AdeServiceCommand = { + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], + env: { + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "C:\\Program Files\\ADE\\resources\\ade-cli\\node_modules", + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + }, + }; + const taskUser = "ADEBOX\\arul"; + const serviceName = "com.ade.runtime.beta"; + const taskName = resolveWindowsTaskName({ serviceName, userName: taskUser }); + const readyPidRecord = { + supervisorPid: 1234, + runtimePid: 5678, + runtimeStartedAtMs: Date.now(), + restartCount: 0, + lastExitCode: null, + lastExitAt: null, + nextRestartAt: null, + lastLaunchError: null, + }; + const immediateReadiness = { + readPidRecord: () => readyPidRecord, + readinessProbe: () => ({ ready: true, diagnostic: "ready" }), + }; + + it("builds schtasks create, run, query, and delete arguments without invoking schtasks", () => { + const renderedCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + "C:\\Users\\arul\\.ade-beta\\runtime\\brain-service.ps1", + ], + }); + + expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser, taskName)).toEqual([ + "/Create", + "/SC", + "ONLOGON", + "/TN", + taskName, + "/TR", + renderedCommand, + "/RU", + taskUser, + "/IT", + "/F", + ]); + expect(buildWindowsRunTaskArgs(taskName)).toEqual(["/Run", "/TN", taskName]); + expect(buildWindowsEndTaskArgs(taskName)).toEqual(["/End", "/TN", taskName]); + expect(buildWindowsQueryTaskArgs(taskName)).toEqual([ + "-NoProfile", + "-NonInteractive", + "-Command", + expect.stringContaining(`$_.TaskName -eq '${taskName}'`), + ]); + expect(buildWindowsDeleteTaskArgs(taskName)).toEqual(["/Delete", "/TN", taskName, "/F"]); + }); + + it("resolves the Windows scheduled task user from domain and username environment values", () => { + expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "arul" })).toBe("ADEBOX\\arul"); + expect(resolveWindowsTaskUser({ USERNAME: "LOCALUSER" })).toBe("LOCALUSER"); + expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "ADEBOX\\arul" })).toBe("ADEBOX\\arul"); + expect(resolveWindowsTaskUser({ + USERDOMAIN: "MicrosoftAccount", + USERNAME: "owner@example.com", + })).toBe("MicrosoftAccount\\owner@example.com"); + }); + + it("isolates scheduled task names by release channel and Windows principal", () => { + const stableArul = resolveWindowsTaskName({ + serviceName: "com.ade.runtime", + userName: "ADEBOX\\arul", + }); + const betaArul = resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "ADEBOX\\arul", + }); + const betaOtherUser = resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "ADEBOX\\other", + }); + + expect(stableArul).toMatch(/^ADE Runtime \(stable-[a-f0-9]{12}\)$/); + expect(betaArul).toMatch(/^ADE Runtime \(beta-[a-f0-9]{12}\)$/); + expect(new Set([stableArul, betaArul, betaOtherUser])).toHaveLength(3); + expect(resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "adebox\\ARUL", + })).toBe(betaArul); + }); + + it("resolves service identity from the command channel at operation time", async () => { + expect(resolveRuntimeServiceName({})).toBe("com.ade.runtime"); + expect(resolveRuntimeServiceName({ ADE_PACKAGE_CHANNEL: "beta" })).toBe( + "com.ade.runtime.beta", + ); + const calls: Array<{ command: string; args: string[] }> = []; + const result = await installWindowsService({ + ...immediateReadiness, + command: serviceCommand, + env: { USERDOMAIN: "ADEBOX", USERNAME: "arul" }, + launcherPath: path.join(makeTempHome("ade-windows-service-channel-"), "brain-service.ps1"), + spawnSync: spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]), + userName: taskUser, + }); + + expect(result.serviceName).toBe("com.ade.runtime.beta"); + expect(result.path).toBe(taskName); + }); + + it("renders Windows scheduled task commands with double-quoted argv tokens", () => { + expect(renderWindowsCommand({ + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["serve", "--root", "C:\\path with space\\"], + })).toBe("\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--root\" \"C:\\path with space\\\\\""); + expect(renderCommand(serviceCommand)).toBe( + "'C:\\Program Files\\ADE\\ade.exe' 'C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs' 'serve'", + ); + }); + + it("escapes embedded double quotes in Windows scheduled task command tokens", () => { + expect(renderWindowsCommand({ + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["serve", "--name", "quoted \"value\""], + })).toBe( + "\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--name\" \"quoted \\\"value\\\"\"", + ); + }); + + it("renders a PowerShell launcher that preserves the service environment and quotes data literally", () => { + const script = renderWindowsServiceLauncher({ + command: "C:\\Program Files\\ADE\\ADE.exe", + args: ["C:\\Program Files\\ADE\\cli.cjs", "serve", "quoted \"value\"", "O'Brien"], + env: { + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "C:\\ADE deps\\100% & O'Brien", + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + }, + }, { pidPath: "C:\\Users\\arul\\.ade-beta\\runtime\\brain.pid.json" }); + + expect(script).toContain( + "[System.Environment]::SetEnvironmentVariable('ELECTRON_RUN_AS_NODE', '1', 'Process')", + ); + expect(script).toContain( + "[System.Environment]::SetEnvironmentVariable('NODE_PATH', 'C:\\ADE deps\\100% & O''Brien', 'Process')", + ); + expect(script).toContain("$startInfo.FileName = 'C:\\Program Files\\ADE\\ADE.exe'"); + expect(script).toContain( + "$startInfo.Arguments = '\"C:\\Program Files\\ADE\\cli.cjs\" \"serve\" \"quoted \\\"value\\\"\" \"O''Brien\"'", + ); + expect(script).toContain("$startInfo.CreateNoWindow = $true"); + expect(script).toContain( + "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + ); + expect(script).toContain("$process = [System.Diagnostics.Process]::Start($startInfo)"); + }); + + (process.platform === "win32" ? it : it.skip)( + "bootstraps a spaced-path PowerShell supervisor with literal environment and argv values", + async () => { + const launcherPath = path.join( + makeTempHome("ade windows service exec-"), + "brain-service.ps1", + ); + const outputPath = path.join(path.dirname(launcherPath), "result.json"); + fs.writeFileSync( + launcherPath, + `\uFEFF${renderWindowsServiceLauncher({ + command: process.execPath, + args: [ + "-e", + "require('node:fs').writeFileSync(process.env.ADE_TEST_OUTPUT, JSON.stringify({ value: process.env.ADE_TEST_VALUE, args: process.argv.slice(1) }), 'utf8')", + "quoted \"value\"", + "O'Brien", + "100% & $HOME", + "naïve-東京-🚀", + ], + env: { + ADE_TEST_VALUE: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", + ADE_TEST_OUTPUT: outputPath, + }, + }, { pidPath: `${launcherPath}.pid.json`, initialRestartDelayMs: 100 })}`, + "utf8", + ); + + const bootstrap = spawnChildSync( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsStartLauncherArgs(launcherPath), + { encoding: "utf8", windowsHide: true }, + ); + try { + expect(bootstrap.status).toBe(0); + const deadline = Date.now() + 5_000; + while (!fs.existsSync(outputPath) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({ + value: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", + args: ["quoted \"value\"", "O'Brien", "100% & $HOME", "naïve-東京-🚀"], + }); + } finally { + const record = readWindowsServicePidRecord({ pidPath: `${launcherPath}.pid.json` }); + if (record?.supervisorPid) { + spawnChildSync("taskkill.exe", ["/PID", String(record.supervisorPid), "/T", "/F"], { + encoding: "utf8", + windowsHide: true, + }); + } + } + }, + 10_000, + ); + + it("registers and starts the per-user background service without Task Scheduler", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + { status: 0, stdout: "The operation completed successfully.", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-"), "brain-service.ps1"); + const pidPath = `${launcherPath}.pid.json`; + const readinessProbe = vi.fn(immediateReadiness.readinessProbe); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + readPidRecord: immediateReadiness.readPidRecord, + readinessProbe, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ + ok: true, + serviceName, + action: "install", + path: taskName, + message: "ADE per-user startup entry installed and channel brain is ready.", + }); + expect(fs.readFileSync(launcherPath, "utf8")).toBe( + `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, + ); + expect(readinessProbe).toHaveBeenCalledWith(expect.objectContaining({ + command: serviceCommand, + launcherPath, + pidRecord: readyPidRecord, + socketPath: expect.stringMatching(/^\\\\\.\\pipe\\ade-runtime-beta-/), + })); + const scheduledCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, + ]); + }); + + it("ends and replaces a running channel task before starting the repaired runtime", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-repair-"), "brain-service.ps1"); + + const result = await installWindowsService({ + ...immediateReadiness, + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.slice(0, 4)).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + ]); + expect(calls.at(-2)?.args).toEqual(expect.arrayContaining(["ADD", "/V", taskName])); + expect(calls.at(-1)?.args).toEqual(buildWindowsStartLauncherArgs(launcherPath)); + }); + + it("ends and deletes only the exact legacy task before installing the channel task", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-"), "brain-service.ps1"); + + const result = await installWindowsService({ + ...immediateReadiness, + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.slice(0, 3)).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + ]); + expect(calls.flatMap((call) => call.args)).not.toContain("ADE Runtime "); + }); + + it("does not register or start a channel task when the running legacy task cannot be ended", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-fail-"), "brain-service.ps1"); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("legacy ADE Runtime scheduled task"); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + ]); + }); + + it("removes the per-user startup entry when immediate start fails", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-start-fail-"), "brain-service.ps1"); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ADE per-user startup entry was installed, but the background service failed to start: ERROR: access is denied"); + const scheduledCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); + expect(calls.map((call) => call.args)).toEqual([ + buildWindowsQueryTaskArgs("ADE Runtime"), + buildWindowsQueryTaskArgs(taskName), + buildWindowsRunKeyQueryArgs(taskName), + buildWindowsRunKeyAddArgs(taskName, scheduledCommand), + buildWindowsStartLauncherArgs(launcherPath), + buildWindowsRunKeyDeleteArgs(taskName), + ]); + }); + + it("does not start the service when per-user registration fails", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: registration failed" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-create-fail-"), "brain-service.ps1"); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(false); + expect(result.message).toBe("ERROR: registration failed"); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + expect.objectContaining({ command: "reg.exe", args: expect.arrayContaining(["ADD"]) }), + ]); + }); + + it("removes legacy tasks and the per-user startup entry", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Ready", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 0, stdout: "startup value", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-remove-"), "brain-service.ps1"); + fs.writeFileSync(launcherPath, "old launcher", "utf8"); + + const result = uninstallWindowsService({ + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ + ok: true, + serviceName, + action: "uninstall", + path: taskName, + message: "ADE background service startup entry removed.", + }); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyDeleteArgs(taskName) }, + ]); + expect(fs.existsSync(launcherPath)).toBe(false); + }); + + it("surfaces scheduled task removal failures", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Ready", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: The system cannot find the file specified." }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + ]); + + const result = uninstallWindowsService({ serviceName, spawnSync, userName: taskUser }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("ERROR: The system cannot find the file specified."); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + ]); + }); + + it("fails uninstall when the scheduled task launcher cannot be removed", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + ]); + const launcherPath = makeTempHome("ade-windows-service-launcher-dir-"); + + const result = uninstallWindowsService({ + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ + ok: false, + serviceName, + action: "uninstall", + path: launcherPath, + }); + expect(result.message).toContain("launcher could not be deleted"); + }); + + it("reports a running legacy Scheduled Task as installed but not readiness-verified", () => { + const calls: Array<{ + command: string; + args: string[]; + options: import("node:child_process").SpawnSyncOptions | undefined; + }> = []; + const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: "Running", stderr: "" }; + }; + + expect(getWindowsServiceStatus({ serviceName, spawnSync, userName: taskUser })).toMatchObject({ + ok: true, + installed: true, + running: false, + path: taskName, + }); + expect(calls).toEqual([ + { + command: WINDOWS_POWERSHELL_COMMAND, + args: buildWindowsQueryTaskArgs(taskName), + options: expect.objectContaining({ windowsHide: true }), + }, + ]); + }); + + it("hides every Windows scheduled-task lifecycle subprocess", async () => { + const calls: Array<{ + command: string; + args: string[]; + options: import("node:child_process").SpawnSyncOptions | undefined; + }> = []; + const results: ServiceManagerProcessResult[] = [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]; + const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { + calls.push({ command, args, options }); + return results.shift() ?? { status: 0, stdout: "", stderr: "" }; + }; + const launcherPath = path.join( + makeTempHome("ade-windows-service-hidden-"), + "brain-service.ps1", + ); + + const result = await installWindowsService({ + ...immediateReadiness, + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.options?.windowsHide === true)).toBe(true); + }); + + it("distinguishes an absent task from a failed locale-independent status query", () => { + const absentCalls: Array<{ command: string; args: string[] }> = []; + const absent = getWindowsServiceStatus({ + serviceName, + spawnSync: spawnSequence(absentCalls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + ]), + userName: taskUser, + }); + const failedCalls: Array<{ command: string; args: string[] }> = []; + const failed = getWindowsServiceStatus({ + serviceName, + spawnSync: spawnSequence(failedCalls, [ + { status: 1, stdout: "", stderr: "PowerShell unavailable" }, + { status: 1, stdout: "", stderr: "" }, + ]), + userName: taskUser, + }); + + expect(absent).toMatchObject({ ok: true, installed: false, running: false }); + expect(failed).toMatchObject({ ok: false, installed: null, running: null }); + }); + + ( + process.platform === "win32" + && !os.userInfo().username.toLowerCase().startsWith("codexsandbox") + ? it + : it.skip + )( + "returns the dedicated not-found exit code from a real locale-independent task query", + () => { + const missingTaskName = `ADE Runtime Test ${process.pid} ${Date.now()}`; + const result = spawnChildSync( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskArgs(missingTaskName), + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(3); + expect(result.stdout).toBe(""); + }, + ); + + it("derives the launcher path from the channel-local ADE home", () => { + expect(resolveWindowsServiceLauncherPath({ + env: { ADE_HOME: "C:\\Users\\arul\\.ade-beta" }, + serviceName, + })).toMatch(/^C:\\Users\\arul\\\.ade-beta\\runtime\\brain-service-[a-f0-9]{12}\.ps1$/i); + }); +}); diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index bb588abc9..bad3f3001 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -4,29 +4,41 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { - ADE_RUNTIME_SERVICE_NAME, type AdeServiceCommand, + cmdQuote, renderWindowsCommand, - renderWindowsServiceLauncher, resolveAdeServeCommand, + resolveRuntimeServiceName, serviceManagerResultText, type ServiceManagerResult, type ServiceManagerSpawnSync, type ServiceManagerStatusResult, } from "./common"; -import { resolveMachineAdeDir } from "../services/projects/machineLayout"; +import { resolveMachineAdeDir, resolveMachineAdeLayout } from "../services/projects/machineLayout"; +import { + defaultWindowsRuntimeReadiness, + queryWindowsSupervisor, + readWindowsServicePidRecord as readWindowsSupervisorPidRecord, + renderWindowsServiceLauncher, + waitForWindowsRuntimeReadiness, + WINDOWS_POWERSHELL_COMMAND, + type WindowsRuntimeReadinessProbe, + type WindowsServicePidRecord, +} from "./windowsSupervisor"; + +export { + buildWindowsRuntimeQueryArgs, + buildWindowsSupervisorQueryArgs, + renderWindowsServiceLauncher, + WINDOWS_POWERSHELL_COMMAND, + type WindowsServicePidRecord, +} from "./windowsSupervisor"; export const TASK_NAME = "ADE Runtime"; -export const WINDOWS_POWERSHELL_COMMAND = "powershell.exe"; export const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; const TASK_NOT_FOUND_EXIT_CODE = 3; const REGISTRY_VALUE_NOT_FOUND_EXIT_CODE = 1; -export type WindowsServicePidRecord = { - supervisorPid: number; - runtimePid: number; -}; - type WindowsServiceManagerDeps = { command?: AdeServiceCommand; env?: NodeJS.ProcessEnv; @@ -35,8 +47,24 @@ type WindowsServiceManagerDeps = { serviceName?: string; spawnSync?: ServiceManagerSpawnSync; userName?: string; + readPidRecord?: (pidPath: string) => WindowsServicePidRecord | null; + readinessProbe?: WindowsRuntimeReadinessProbe; + handoverTimeoutMs?: number; + handoverPollMs?: number; + sleep?: (ms: number) => Promise<void>; }; +function resolvedServiceName( + deps: Pick<WindowsServiceManagerDeps, "env" | "serviceName">, + command?: AdeServiceCommand, +): string { + if (deps.serviceName?.trim()) return deps.serviceName.trim(); + return resolveRuntimeServiceName({ + ...(deps.env ?? process.env), + ...(command?.env ?? {}), + }); +} + export function resolveWindowsTaskUser(env: NodeJS.ProcessEnv = process.env): string { const username = env.USERNAME?.trim() || os.userInfo().username.trim(); if (!username) { @@ -65,7 +93,7 @@ export function resolveWindowsTaskName(args: { serviceName?: string; userName?: string; } = {}): string { - const serviceName = args.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const serviceName = args.serviceName ?? resolveRuntimeServiceName(); const userName = args.userName ?? resolveWindowsTaskUser(); const identity = `${serviceName.trim().toLowerCase()}\0${userName.trim().toLowerCase()}`; return `${TASK_NAME} (${serviceChannelLabel(serviceName)}-${shortHash(identity)})`; @@ -76,7 +104,7 @@ export function resolveWindowsServiceLauncherPath(args: { serviceName?: string; } = {}): string { const env = args.env ?? process.env; - const serviceName = args.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const serviceName = args.serviceName ?? resolveRuntimeServiceName(env); const adeDir = path.win32.resolve(env.ADE_HOME?.trim() || resolveMachineAdeDir(env)); return path.win32.join( adeDir, @@ -98,16 +126,7 @@ export function readWindowsServicePidRecord(args: { pidPath?: string; } = {}): WindowsServicePidRecord | null { const pidPath = args.pidPath ?? resolveWindowsServicePidPath(args); - try { - const parsed = JSON.parse(fs.readFileSync(pidPath, "utf8")) as Partial<WindowsServicePidRecord>; - const supervisorPid = Number(parsed.supervisorPid); - const runtimePid = Number(parsed.runtimePid); - if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) return null; - if (!Number.isInteger(runtimePid) || runtimePid <= 0) return null; - return { supervisorPid, runtimePid }; - } catch { - return null; - } + return readWindowsSupervisorPidRecord(pidPath); } export function buildWindowsCreateTaskArgs( @@ -191,27 +210,18 @@ export function buildWindowsStartLauncherArgs(launcherPath: string): string[] { "-File", launcherPath, ]; - const startCommand = [ - `$process = Start-Process -FilePath ${powerShellSingleQuotedLiteral(WINDOWS_POWERSHELL_COMMAND)}`, - `-ArgumentList @(${childArgs.map(powerShellSingleQuotedLiteral).join(", ")})`, - "-WindowStyle Hidden -PassThru", - ].join(" "); - const command = `${startCommand}; [Console]::Out.Write($process.Id)`; - return ["-NoProfile", "-NonInteractive", "-Command", command]; -} - -export function buildWindowsSupervisorQueryArgs(pid: number, launcherPath: string): string[] { - const launcherLiteral = powerShellSingleQuotedLiteral(launcherPath); - const query = [ - "$ErrorActionPreference = 'Stop'", - `$process = Get-CimInstance Win32_Process -Filter ${powerShellSingleQuotedLiteral(`ProcessId = ${pid}`)} -ErrorAction SilentlyContinue`, - "if ($null -eq $process) { exit 3 }", - "$commandLine = [string]$process.CommandLine", - `$matchesLauncher = $commandLine.IndexOf(${launcherLiteral}, [StringComparison]::OrdinalIgnoreCase) -ge 0`, - "if (-not $matchesLauncher -or $process.Name -notmatch '^powershell(?:\\.exe)?$') { exit 4 }", - "[Console]::Out.Write($process.ProcessId)", + const childCommandLine = childArgs.map(cmdQuote).join(" "); + const command = [ + "$startInfo = New-Object System.Diagnostics.ProcessStartInfo", + `$startInfo.FileName = ${powerShellSingleQuotedLiteral(WINDOWS_POWERSHELL_COMMAND)}`, + `$startInfo.Arguments = ${powerShellSingleQuotedLiteral(childCommandLine)}`, + "$startInfo.UseShellExecute = $true", + "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + "$process = [System.Diagnostics.Process]::Start($startInfo)", + "if ($null -eq $process) { throw 'Windows failed to start the ADE brain supervisor.' }", + "[Console]::Out.Write($process.Id)", ].join("; "); - return ["-NoProfile", "-NonInteractive", "-Command", query]; + return ["-NoProfile", "-NonInteractive", "-Command", command]; } export function isWindowsTaskStateRunning(output: string | Buffer | null | undefined): boolean { @@ -283,32 +293,6 @@ function windowsLauncherCommand(launcherPath: string): string { }); } -function queryWindowsSupervisor( - run: ServiceManagerSpawnSync, - launcherPath: string, - pidPath: string, -): { running: boolean; pid: number | null; error: string | null } { - const record = readWindowsServicePidRecord({ pidPath }); - if (!record) return { running: false, pid: null, error: null }; - const result = run( - WINDOWS_POWERSHELL_COMMAND, - buildWindowsSupervisorQueryArgs(record.supervisorPid, launcherPath), - { encoding: "utf8", windowsHide: true }, - ); - if (result.status === 0) { - return { running: true, pid: record.supervisorPid, error: null }; - } - if (result.status === 3 || result.status === 4) { - try { fs.rmSync(pidPath, { force: true }); } catch { /* advisory record */ } - return { running: false, pid: null, error: null }; - } - return { - running: false, - pid: null, - error: serviceManagerResultText(result) || "Unable to inspect the ADE startup process.", - }; -} - function removeWindowsRunEntryIfPresent( run: ServiceManagerSpawnSync, valueName: string, @@ -327,7 +311,7 @@ function removeWindowsRunEntryIfPresent( }; } - const supervisor = queryWindowsSupervisor(run, launcherPath, pidPath); + const supervisor = queryWindowsSupervisor({ spawnSync: run, launcherPath, pidPath }); if (supervisor.error) return { ok: false, message: supervisor.error }; if (supervisor.running && supervisor.pid) { const stop = run("taskkill.exe", ["/PID", String(supervisor.pid), "/T", "/F"], { @@ -335,7 +319,7 @@ function removeWindowsRunEntryIfPresent( windowsHide: true, }); if (stop.status !== 0) { - const recheck = queryWindowsSupervisor(run, launcherPath, pidPath); + const recheck = queryWindowsSupervisor({ spawnSync: run, launcherPath, pidPath }); if (recheck.running || recheck.error) { return { ok: false, @@ -361,11 +345,13 @@ function removeWindowsRunEntryIfPresent( return { ok: true, removed: installed || supervisor.running }; } -export function installWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { +export async function installWindowsService( + deps: WindowsServiceManagerDeps = {}, +): Promise<ServiceManagerResult> { const run = deps.spawnSync ?? spawnSync; const env = deps.env ?? process.env; - const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; const serviceCommand = deps.command ?? resolveAdeServeCommand(); + const serviceName = resolvedServiceName(deps, serviceCommand); let userName: string; try { userName = deps.userName ?? resolveWindowsTaskUser(env); @@ -379,8 +365,10 @@ export function installWindowsService(deps: WindowsServiceManagerDeps = {}): Ser }; } const taskName = resolveWindowsTaskName({ serviceName, userName }); - const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const runtimeEnv = { ...env, ...(serviceCommand.env ?? {}) }; + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env: runtimeEnv, serviceName }); const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + const socketPath = resolveMachineAdeLayout(runtimeEnv, "win32").socketPath; try { fs.mkdirSync(path.dirname(launcherPath), { recursive: true }); fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, { @@ -473,19 +461,44 @@ export function installWindowsService(deps: WindowsServiceManagerDeps = {}): Ser message: `ADE per-user startup entry was installed, but the background service failed to start: ${serviceManagerResultText(start) || "PowerShell launch failed."}`, }; } + const readiness = await waitForWindowsRuntimeReadiness({ + command: serviceCommand, + launcherPath, + pidPath, + socketPath, + spawnSync: run, + readPidRecord: deps.readPidRecord + ?? ((target) => readWindowsServicePidRecord({ pidPath: target })), + readinessProbe: deps.readinessProbe ?? defaultWindowsRuntimeReadiness, + timeoutMs: deps.handoverTimeoutMs ?? 15_000, + pollMs: deps.handoverPollMs ?? 100, + sleep: deps.sleep, + }); + if (!readiness.ready) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + failureStep: "replacement_responsive", + message: + `ADE per-user startup entry was installed, but the channel brain did not become ready on ${socketPath}: ` + + readiness.diagnostic, + }; + } return { ok: true, serviceName, action: "install", path: taskName, - message: "ADE per-user startup entry installed and background service started.", + message: "ADE per-user startup entry installed and channel brain is ready.", }; } export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; const env = deps.env ?? process.env; - const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const serviceName = resolvedServiceName(deps); let userName: string; try { userName = deps.userName ?? resolveWindowsTaskUser(env); @@ -554,11 +567,23 @@ export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): S } export function getWindowsServiceStatus( - deps: Pick<WindowsServiceManagerDeps, "env" | "launcherPath" | "pidPath" | "serviceName" | "spawnSync" | "userName"> = {}, + deps: Pick< + WindowsServiceManagerDeps, + | "command" + | "env" + | "launcherPath" + | "pidPath" + | "readinessProbe" + | "readPidRecord" + | "serviceName" + | "spawnSync" + | "userName" + > = {}, ): ServiceManagerStatusResult { const run = deps.spawnSync ?? spawnSync; const env = deps.env ?? process.env; - const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const command = deps.command ?? resolveAdeServeCommand(); + const serviceName = resolvedServiceName(deps, command); let userName: string; try { userName = deps.userName ?? resolveWindowsTaskUser(env); @@ -574,7 +599,8 @@ export function getWindowsServiceStatus( }; } const taskName = resolveWindowsTaskName({ serviceName, userName }); - const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const runtimeEnv = { ...env, ...(command.env ?? {}) }; + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env: runtimeEnv, serviceName }); const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; const taskResult = run( WINDOWS_POWERSHELL_COMMAND, @@ -582,17 +608,15 @@ export function getWindowsServiceStatus( { encoding: "utf8", windowsHide: true }, ); if (taskResult.status === 0) { - const running = isWindowsTaskStateRunning(taskResult.stdout); return { ok: true, serviceName, action: "status", installed: true, - running, + running: false, path: taskName, - message: running - ? "ADE service scheduled task is running." - : "ADE service scheduled task is installed.", + message: + "A legacy ADE Scheduled Task is installed, but runtime readiness cannot be verified. Run `ade brain start` to migrate it to the per-user startup supervisor.", }; } const startupResult = run("reg.exe", buildWindowsRunKeyQueryArgs(taskName), { @@ -600,18 +624,55 @@ export function getWindowsServiceStatus( windowsHide: true, }); if (startupResult.status === 0) { - const supervisor = queryWindowsSupervisor(run, launcherPath, pidPath); + const readPidRecord = deps.readPidRecord + ?? ((target: string) => readWindowsServicePidRecord({ pidPath: target })); + const supervisor = queryWindowsSupervisor({ + spawnSync: run, + launcherPath, + pidPath, + readPidRecord, + }); + if (supervisor.error) { + return { + ok: false, + serviceName, + action: "status", + installed: true, + running: null, + path: taskName, + message: supervisor.error, + }; + } + if (!supervisor.running || !supervisor.record) { + return { + ok: true, + serviceName, + action: "status", + installed: true, + running: false, + path: taskName, + message: supervisor.diagnostic + ?? "ADE per-user startup entry is installed, but the supervisor is not running.", + }; + } + const socketPath = resolveMachineAdeLayout(runtimeEnv, "win32").socketPath; + const readiness = (deps.readinessProbe ?? defaultWindowsRuntimeReadiness)({ + command, + launcherPath, + pidRecord: supervisor.record, + socketPath, + spawnSync: run, + }); return { - ok: supervisor.error == null, + ok: true, serviceName, action: "status", installed: true, - running: supervisor.error ? null : supervisor.running, + running: readiness.ready, path: taskName, - message: supervisor.error - ?? (supervisor.running - ? "ADE per-user background service is running." - : "ADE per-user startup entry is installed, but the background service is not running."), + message: readiness.ready + ? `ADE per-user channel brain is ready on ${socketPath}.` + : readiness.diagnostic, }; } if (taskResult.status !== TASK_NOT_FOUND_EXIT_CODE) { diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts new file mode 100644 index 000000000..f3428a1eb --- /dev/null +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts @@ -0,0 +1,162 @@ +import fs from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildWindowsRuntimeQueryArgs, + readWindowsServicePidRecord, + WINDOWS_POWERSHELL_COMMAND, +} from "./installWindows"; +import { + renderWindowsServiceLauncher, + waitForWindowsRuntimeReadiness, +} from "./windowsSupervisor"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } +}); + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-supervisor-")); + tempDirs.push(dir); + return dir; +} + +describe("Windows runtime supervisor", () => { + it("renders bounded restart state for both child exits and launch failures", () => { + const script = renderWindowsServiceLauncher({ + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], + }, { + pidPath: "C:\\Users\\arul\\.ade-beta\\runtime\\brain.pid.json", + initialRestartDelayMs: 250, + maxRestartDelayMs: 5_000, + healthyRuntimeMs: 30_000, + }); + + expect(script).toContain("while ($true)"); + expect(script).toContain("$initialRestartDelayMs = 250"); + expect(script).toContain("$maxRestartDelayMs = 5000"); + expect(script).toContain("lastLaunchError = $lastLaunchError"); + expect(script).toContain("} catch {"); + expect(script).toContain("Start-Sleep -Milliseconds ([int]$restartDelayMs)"); + }); + + it("reads legacy and current PID records with bounded diagnostics", () => { + const pidPath = path.join(tempDir(), "brain.pid.json"); + fs.writeFileSync(pidPath, JSON.stringify({ supervisorPid: 101, runtimePid: 202 }), "utf8"); + expect(readWindowsServicePidRecord({ pidPath })).toEqual({ + supervisorPid: 101, + runtimePid: 202, + runtimeStartedAtMs: null, + restartCount: 0, + lastExitCode: null, + lastExitAt: null, + nextRestartAt: null, + lastLaunchError: null, + }); + + fs.writeFileSync(pidPath, JSON.stringify({ + supervisorPid: 101, + runtimePid: null, + restartCount: 3, + lastLaunchError: "x".repeat(800), + }), "utf8"); + expect(readWindowsServicePidRecord({ pidPath })).toMatchObject({ + runtimePid: null, + restartCount: 3, + lastLaunchError: "x".repeat(512), + }); + }); + + it("binds runtime PID inspection to the executable, entrypoint, and serve command", () => { + const args = buildWindowsRuntimeQueryArgs(202, { + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], + }); + const query = args.at(-1) ?? ""; + expect(query).toContain("ProcessId = 202"); + expect(query).toContain("C:\\Program Files\\ADE\\ade.exe"); + expect(query).toContain("C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs"); + expect(query).toContain("matchesServe"); + }); + + it("waits asynchronously for semantic readiness without blocking the caller", async () => { + const sleepStarted: number[] = []; + const wait = waitForWindowsRuntimeReadiness({ + command: { command: "C:\\ADE\\ade.exe", args: ["serve"] }, + launcherPath: "C:\\ADE\\brain-service.ps1", + pidPath: "C:\\ADE\\brain.pid.json", + socketPath: "\\\\.\\pipe\\ade-test", + spawnSync, + readPidRecord: () => null, + timeoutMs: 12, + pollMs: 10, + sleep: async (ms) => { + sleepStarted.push(ms); + await new Promise<void>((resolve) => setTimeout(resolve, ms)); + }, + }); + + expect(wait).toBeInstanceOf(Promise); + expect(sleepStarted).toEqual([10]); + await expect(wait).resolves.toMatchObject({ + ready: false, + diagnostic: expect.stringContaining("did not publish a PID record"), + }); + }); + + (process.platform === "win32" ? it : it.skip)( + "keeps supervising a missing executable and publishes launch-error backoff diagnostics", + async () => { + const dir = tempDir(); + const launcherPath = path.join(dir, "brain-service.ps1"); + const pidPath = `${launcherPath}.pid.json`; + fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher({ + command: path.join(dir, "missing-ade.exe"), + args: ["serve"], + }, { + pidPath, + initialRestartDelayMs: 100, + maxRestartDelayMs: 200, + })}`, "utf8"); + const supervisor = spawn(WINDOWS_POWERSHELL_COMMAND, [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], { stdio: "ignore", windowsHide: true }); + try { + const deadline = Date.now() + 5_000; + let record = readWindowsServicePidRecord({ pidPath }); + while ((!record?.lastLaunchError || record.restartCount < 2) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + record = readWindowsServicePidRecord({ pidPath }); + } + expect(record).toMatchObject({ + supervisorPid: supervisor.pid, + runtimePid: null, + restartCount: expect.any(Number), + lastLaunchError: expect.any(String), + nextRestartAt: expect.any(String), + }); + expect(record?.restartCount).toBeGreaterThanOrEqual(2); + } finally { + if (supervisor.pid) { + spawnSync("taskkill.exe", ["/PID", String(supervisor.pid), "/T", "/F"], { + encoding: "utf8", + windowsHide: true, + }); + } + } + }, + 10_000, + ); +}); diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts new file mode 100644 index 000000000..e05c47814 --- /dev/null +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts @@ -0,0 +1,395 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + type AdeServiceCommand, + cmdQuote, + serviceManagerResultText, + type ServiceManagerSpawnSync, +} from "./common"; + +export const WINDOWS_POWERSHELL_COMMAND = "powershell.exe"; + +export type WindowsServicePidRecord = { + supervisorPid: number; + runtimePid: number | null; + runtimeStartedAtMs: number | null; + restartCount: number; + lastExitCode: number | null; + lastExitAt: string | null; + nextRestartAt: string | null; + lastLaunchError: string | null; +}; + +export type WindowsRuntimeReadiness = { + ready: boolean; + diagnostic: string; +}; + +export type WindowsRuntimeReadinessProbe = (args: { + command: AdeServiceCommand; + launcherPath: string; + pidRecord: WindowsServicePidRecord; + socketPath: string; + spawnSync: ServiceManagerSpawnSync; +}) => WindowsRuntimeReadiness; + +export type WindowsSupervisorState = + | { + state: "running"; + running: true; + pid: number; + record: WindowsServicePidRecord; + error: null; + diagnostic: null; + } + | { + state: "stopped"; + running: false; + pid: null; + record: WindowsServicePidRecord | null; + error: null; + diagnostic: string; + } + | { + state: "error"; + running: false; + pid: null; + record: WindowsServicePidRecord; + error: string; + diagnostic: null; + }; + +function powerShellSingleQuotedLiteral(value: string): string { + if (value.includes("\0")) throw new Error("PowerShell values cannot contain NUL bytes."); + return `'${value.replace(/'/g, "''")}'`; +} + +export function renderWindowsServiceLauncher( + command: AdeServiceCommand, + options: { + pidPath: string; + initialRestartDelayMs?: number; + maxRestartDelayMs?: number; + healthyRuntimeMs?: number; + }, +): string { + const environment = Object.entries(command.env ?? {}).sort(([left], [right]) => + left.localeCompare(right), + ); + const environmentLines = environment.map(([key, value]) => { + if (!key || key.includes("=") || key.includes("\0")) { + throw new Error(`Invalid Windows service environment variable name: ${JSON.stringify(key)}.`); + } + return `[System.Environment]::SetEnvironmentVariable(${powerShellSingleQuotedLiteral(key)}, ${powerShellSingleQuotedLiteral(value)}, 'Process')`; + }); + const commandLine = command.args.map(cmdQuote).join(" "); + const processLines = [ + `$pidPath = ${powerShellSingleQuotedLiteral(options.pidPath)}`, + `$initialRestartDelayMs = ${Math.max(100, Math.floor(options.initialRestartDelayMs ?? 1_000))}`, + `$maxRestartDelayMs = ${Math.max(100, Math.floor(options.maxRestartDelayMs ?? 30_000))}`, + `$healthyRuntimeMs = ${Math.max(1_000, Math.floor(options.healthyRuntimeMs ?? 60_000))}`, + "$restartCount = 0", + "$lastExitCode = $null", + "$lastExitAt = $null", + "$nextRestartAt = $null", + "$lastLaunchError = $null", + "function Write-PidRecord([Nullable[int]]$runtimePid, [Nullable[long]]$runtimeStartedAtMs) {", + " $record = [ordered]@{", + " supervisorPid = $PID", + " runtimePid = $runtimePid", + " runtimeStartedAtMs = $runtimeStartedAtMs", + " restartCount = $restartCount", + " lastExitCode = $lastExitCode", + " lastExitAt = $lastExitAt", + " nextRestartAt = $nextRestartAt", + " lastLaunchError = $lastLaunchError", + " }", + " [IO.File]::WriteAllText($pidPath, ($record | ConvertTo-Json -Compress), [Text.Encoding]::ASCII)", + "}", + "try {", + " while ($true) {", + " $runtimeStartedAt = [DateTimeOffset]::UtcNow", + " try {", + " $process = [System.Diagnostics.Process]::Start($startInfo)", + " if ($null -eq $process) { throw 'Windows failed to start the ADE brain process.' }", + " $lastLaunchError = $null", + " $nextRestartAt = $null", + " Write-PidRecord -runtimePid $process.Id -runtimeStartedAtMs $runtimeStartedAt.ToUnixTimeMilliseconds()", + " $process.WaitForExit()", + " $lastExitCode = $process.ExitCode", + " $lastExitAt = [DateTimeOffset]::UtcNow.ToString('o')", + " $runtimeLifetimeMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $runtimeStartedAt.ToUnixTimeMilliseconds()", + " if ($runtimeLifetimeMs -ge $healthyRuntimeMs) { $restartCount = 0 } else { $restartCount += 1 }", + " } catch {", + " $lastExitCode = $null", + " $lastExitAt = [DateTimeOffset]::UtcNow.ToString('o')", + " $lastLaunchError = [string]$_.Exception.Message", + " if ($lastLaunchError.Length -gt 512) { $lastLaunchError = $lastLaunchError.Substring(0, 512) }", + " $restartCount += 1", + " }", + " $exponent = [Math]::Min([Math]::Max($restartCount - 1, 0), 20)", + " $restartDelayMs = [Math]::Min($maxRestartDelayMs, $initialRestartDelayMs * [Math]::Pow(2, $exponent))", + " $nextRestartAt = [DateTimeOffset]::UtcNow.AddMilliseconds($restartDelayMs).ToString('o')", + " Write-PidRecord -runtimePid $null -runtimeStartedAtMs $null", + " Start-Sleep -Milliseconds ([int]$restartDelayMs)", + " }", + "} finally {", + " Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue", + "}", + ]; + + return [ + "$ErrorActionPreference = 'Stop'", + ...environmentLines, + "$startInfo = New-Object System.Diagnostics.ProcessStartInfo", + `$startInfo.FileName = ${powerShellSingleQuotedLiteral(command.command)}`, + `$startInfo.Arguments = ${powerShellSingleQuotedLiteral(commandLine)}`, + "$startInfo.UseShellExecute = $false", + "$startInfo.CreateNoWindow = $true", + "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + ...processLines, + "", + ].join("\r\n"); +} + +export function readWindowsServicePidRecord(pidPath: string): WindowsServicePidRecord | null { + try { + const parsed = JSON.parse(fs.readFileSync(pidPath, "utf8")) as Partial<WindowsServicePidRecord>; + const supervisorPid = Number(parsed.supervisorPid); + const runtimePid = parsed.runtimePid == null ? null : Number(parsed.runtimePid); + if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) return null; + if (runtimePid != null && (!Number.isInteger(runtimePid) || runtimePid <= 0)) return null; + const runtimeStartedAtMs = parsed.runtimeStartedAtMs == null + ? null + : Number(parsed.runtimeStartedAtMs); + if (runtimeStartedAtMs != null && (!Number.isFinite(runtimeStartedAtMs) || runtimeStartedAtMs <= 0)) { + return null; + } + const restartCount = Number(parsed.restartCount ?? 0); + if (!Number.isInteger(restartCount) || restartCount < 0) return null; + const lastExitCode = parsed.lastExitCode == null ? null : Number(parsed.lastExitCode); + if (lastExitCode != null && !Number.isInteger(lastExitCode)) return null; + const boundedText = (value: unknown): string | null => + typeof value === "string" && value.trim() ? value.trim().slice(0, 512) : null; + return { + supervisorPid, + runtimePid, + runtimeStartedAtMs, + restartCount, + lastExitCode, + lastExitAt: boundedText(parsed.lastExitAt), + nextRestartAt: boundedText(parsed.nextRestartAt), + lastLaunchError: boundedText(parsed.lastLaunchError), + }; + } catch { + return null; + } +} + +export function buildWindowsSupervisorQueryArgs(pid: number, launcherPath: string): string[] { + const launcherLiteral = powerShellSingleQuotedLiteral(launcherPath); + const query = [ + "$ErrorActionPreference = 'Stop'", + `$process = Get-CimInstance Win32_Process -Filter ${powerShellSingleQuotedLiteral(`ProcessId = ${pid}`)} -ErrorAction SilentlyContinue`, + "if ($null -eq $process) { exit 3 }", + "$commandLine = [string]$process.CommandLine", + `$matchesLauncher = $commandLine.IndexOf(${launcherLiteral}, [StringComparison]::OrdinalIgnoreCase) -ge 0`, + "if (-not $matchesLauncher -or $process.Name -notmatch '^powershell(?:\\.exe)?$') { exit 4 }", + "[Console]::Out.Write($process.ProcessId)", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; +} + +export function buildWindowsRuntimeQueryArgs(pid: number, command: AdeServiceCommand): string[] { + const expectedExecutable = powerShellSingleQuotedLiteral(path.win32.resolve(command.command)); + const expectedEntry = command.args[0] + ? powerShellSingleQuotedLiteral(command.args[0]) + : "$null"; + const query = [ + "$ErrorActionPreference = 'Stop'", + `$process = Get-CimInstance Win32_Process -Filter ${powerShellSingleQuotedLiteral(`ProcessId = ${pid}`)} -ErrorAction SilentlyContinue`, + "if ($null -eq $process) { exit 3 }", + "$commandLine = [string]$process.CommandLine", + `$matchesExecutable = [string]::Equals([string]$process.ExecutablePath, ${expectedExecutable}, [StringComparison]::OrdinalIgnoreCase)`, + expectedEntry === "$null" + ? "$matchesEntry = $true" + : `$matchesEntry = $commandLine.IndexOf(${expectedEntry}, [StringComparison]::OrdinalIgnoreCase) -ge 0`, + "$matchesServe = $commandLine -match '(?:^|\\s)serve(?:\\s|$)'", + "if (-not $matchesExecutable -or -not $matchesEntry -or -not $matchesServe) { exit 4 }", + "[Console]::Out.Write($process.ProcessId)", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; +} + +function runtimeStatusArgs(command: AdeServiceCommand, socketPath: string): string[] { + const args = [...command.args]; + const serveIndex = args.lastIndexOf("serve"); + if (serveIndex >= 0) args.splice(serveIndex, 1, "runtime", "status"); + else args.push("runtime", "status"); + args.push("--socket", socketPath, "--timeout", "1500", "--json"); + return args; +} + +export const defaultWindowsRuntimeReadiness: WindowsRuntimeReadinessProbe = (args) => { + const { pidRecord, spawnSync: run } = args; + const supervisor = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsSupervisorQueryArgs(pidRecord.supervisorPid, args.launcherPath), + { encoding: "utf8", windowsHide: true }, + ); + if (supervisor.status !== 0) { + return { + ready: false, + diagnostic: supervisor.status === 3 || supervisor.status === 4 + ? `Supervisor PID ${pidRecord.supervisorPid} is stale or belongs to another process.` + : serviceManagerResultText(supervisor) || `Unable to inspect supervisor PID ${pidRecord.supervisorPid}.`, + }; + } + if (pidRecord.runtimePid == null) { + const restart = pidRecord.nextRestartAt ? `; restart scheduled for ${pidRecord.nextRestartAt}` : ""; + const launchError = pidRecord.lastLaunchError ? ` Last launch error: ${pidRecord.lastLaunchError}.` : ""; + return { + ready: false, + diagnostic: `Supervisor PID ${pidRecord.supervisorPid} is running, but the ADE brain is between restart attempts${restart}.${launchError}`, + }; + } + const runtime = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsRuntimeQueryArgs(pidRecord.runtimePid, args.command), + { encoding: "utf8", windowsHide: true }, + ); + if (runtime.status !== 0) { + return { + ready: false, + diagnostic: runtime.status === 3 || runtime.status === 4 + ? `Runtime PID ${pidRecord.runtimePid} is stale or does not match this channel executable.` + : serviceManagerResultText(runtime) || `Unable to inspect runtime PID ${pidRecord.runtimePid}.`, + }; + } + const status = run(args.command.command, runtimeStatusArgs(args.command, args.socketPath), { + encoding: "utf8", + env: { + ...process.env, + ...(args.command.env ?? {}), + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + }, + timeout: 2_000, + windowsHide: true, + }); + if (status.status !== 0) { + return { + ready: false, + diagnostic: serviceManagerResultText(status) + || `Runtime PID ${pidRecord.runtimePid} has not initialized on ${args.socketPath}.`, + }; + } + try { + const payload = JSON.parse(String(status.stdout ?? "")) as { + ok?: unknown; + running?: unknown; + pid?: unknown; + }; + if (payload.ok === true && payload.running === true && Number(payload.pid) === pidRecord.runtimePid) { + return { ready: true, diagnostic: `Runtime PID ${pidRecord.runtimePid} is ready.` }; + } + return { + ready: false, + diagnostic: `Runtime endpoint responded with PID ${String(payload.pid ?? "unknown")}; expected ${pidRecord.runtimePid}.`, + }; + } catch { + return { + ready: false, + diagnostic: `Runtime PID ${pidRecord.runtimePid} returned an invalid readiness payload.`, + }; + } +}; + +export function queryWindowsSupervisor(args: { + spawnSync: ServiceManagerSpawnSync; + launcherPath: string; + pidPath: string; + readPidRecord?: (pidPath: string) => WindowsServicePidRecord | null; +}): WindowsSupervisorState { + const record = (args.readPidRecord ?? readWindowsServicePidRecord)(args.pidPath); + if (!record) { + return { + state: "stopped", + running: false, + pid: null, + record: null, + error: null, + diagnostic: "The startup entry has no valid PID record yet.", + }; + } + const result = args.spawnSync( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsSupervisorQueryArgs(record.supervisorPid, args.launcherPath), + { encoding: "utf8", windowsHide: true }, + ); + if (result.status === 0) { + return { + state: "running", + running: true, + pid: record.supervisorPid, + record, + error: null, + diagnostic: null, + }; + } + if (result.status === 3 || result.status === 4) { + try { fs.rmSync(args.pidPath, { force: true }); } catch { /* advisory record */ } + return { + state: "stopped", + running: false, + pid: null, + record, + error: null, + diagnostic: `Cleared stale supervisor PID record ${record.supervisorPid}.`, + }; + } + return { + state: "error", + running: false, + pid: null, + record, + error: serviceManagerResultText(result) || "Unable to inspect the ADE startup process.", + diagnostic: null, + }; +} + +export async function waitForWindowsRuntimeReadiness(args: { + command: AdeServiceCommand; + launcherPath: string; + pidPath: string; + socketPath: string; + spawnSync: ServiceManagerSpawnSync; + readPidRecord?: (pidPath: string) => WindowsServicePidRecord | null; + readinessProbe?: WindowsRuntimeReadinessProbe; + timeoutMs: number; + pollMs: number; + sleep?: (ms: number) => Promise<void>; +}): Promise<WindowsRuntimeReadiness> { + const deadline = Date.now() + Math.max(0, args.timeoutMs); + const readPidRecord = args.readPidRecord ?? readWindowsServicePidRecord; + const readinessProbe = args.readinessProbe ?? defaultWindowsRuntimeReadiness; + const sleep = args.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + let diagnostic = "The Windows brain supervisor did not publish a PID record."; + do { + const pidRecord = readPidRecord(args.pidPath); + if (pidRecord) { + const result = readinessProbe({ + command: args.command, + launcherPath: args.launcherPath, + pidRecord, + socketPath: args.socketPath, + spawnSync: args.spawnSync, + }); + if (result.ready) return result; + diagnostic = result.diagnostic; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await sleep(Math.min(Math.max(10, args.pollMs), remaining)); + } while (Date.now() <= deadline); + return { ready: false, diagnostic }; +} diff --git a/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts b/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts index ee64ca993..3924a31f4 100644 --- a/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts +++ b/apps/desktop/src/main/services/computerUse/localComputerUse.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createComputerUseArtifactPath } from "./localComputerUse"; +import { createComputerUseArtifactPath, getLocalComputerUseCapabilities } from "./localComputerUse"; describe("createComputerUseArtifactPath", () => { let projectRoot: string; @@ -23,3 +23,38 @@ describe("createComputerUseArtifactPath", () => { expect(paths.size).toBe(500); }); }); + +describe("getLocalComputerUseCapabilities", () => { + it.each(["win32", "linux"] as const)( + "blocks native OS control on %s without disabling App Control or proof ingestion", + (platform) => { + const capabilities = getLocalComputerUseCapabilities(platform, () => { + throw new Error("non-macOS capability checks must not probe macOS executables"); + }); + + expect(capabilities).toMatchObject({ + platform, + overallState: "blocked_by_capability", + screenshot: { state: "blocked_by_capability", available: false, command: null }, + videoRecording: { state: "blocked_by_capability", available: false, command: null }, + appLaunch: { state: "blocked_by_capability", available: false, command: null }, + guiInteraction: { state: "blocked_by_capability", available: false, command: null }, + }); + expect(capabilities.screenshot.detail).toContain("App Control and proof-file ingestion remain available"); + }, + ); + + it("preserves native macOS capability detection", () => { + const capabilities = getLocalComputerUseCapabilities("darwin", () => true); + + expect(capabilities.platform).toBe("darwin"); + expect(capabilities.overallState).toBe("present"); + expect(capabilities.screenshot).toMatchObject({ + state: "present", + available: true, + command: "screencapture", + }); + expect(capabilities.guiInteraction.available).toBe(true); + expect(capabilities.proofRequirements.console_logs.available).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/services/computerUse/localComputerUse.ts b/apps/desktop/src/main/services/computerUse/localComputerUse.ts index 7a4be39ca..2991dfe4c 100644 --- a/apps/desktop/src/main/services/computerUse/localComputerUse.ts +++ b/apps/desktop/src/main/services/computerUse/localComputerUse.ts @@ -28,7 +28,8 @@ export type LocalComputerUseCapabilities = { >; }; -const DARWIN_BLOCKED_DETAIL = "Local computer-use runtime is currently implemented for macOS only."; +const NATIVE_COMPUTER_USE_BLOCKED_DETAIL = + "Native screenshot, video, and OS GUI control are currently implemented for macOS only. App Control and proof-file ingestion remain available on supported desktop platforms."; function present(command: string, detail: string): LocalComputerUseCapability { return { state: "present", available: true, command, detail }; @@ -42,11 +43,14 @@ function blocked(detail: string): LocalComputerUseCapability { return { state: "blocked_by_capability", available: false, command: null, detail }; } -export function getLocalComputerUseCapabilities(): LocalComputerUseCapabilities { - if (process.platform !== "darwin") { - const blockedCapability = blocked(DARWIN_BLOCKED_DETAIL); +export function getLocalComputerUseCapabilities( + platform: NodeJS.Platform = process.platform, + commandAvailable: (command: string) => boolean = commandExists, +): LocalComputerUseCapabilities { + if (platform !== "darwin") { + const blockedCapability = blocked(NATIVE_COMPUTER_USE_BLOCKED_DETAIL); return { - platform: process.platform, + platform, overallState: "blocked_by_capability", screenshot: blockedCapability, videoRecording: blockedCapability, @@ -63,21 +67,21 @@ export function getLocalComputerUseCapabilities(): LocalComputerUseCapabilities }; } - const screenshot = commandExists("screencapture") + const screenshot = commandAvailable("screencapture") ? present("screencapture", "macOS screencapture is available for screenshots.") : missing("screencapture", "macOS screencapture is required for screenshots."); - const videoRecording = commandExists("screencapture") + const videoRecording = commandAvailable("screencapture") ? present("screencapture", "macOS screencapture can record screen video with the -v flag.") : missing("screencapture", "macOS screencapture is required for local video capture."); - const appLaunch = commandExists("open") + const appLaunch = commandAvailable("open") ? present("open", "macOS open is available for launching and focusing apps.") : missing("open", "macOS open is required for launching apps."); - const guiInteraction = commandExists("swift") + const guiInteraction = commandAvailable("swift") ? present("swift", "Swift CLI is available for native click automation; osascript can handle key input.") - : commandExists("osascript") + : commandAvailable("osascript") ? present("osascript", "AppleScript is available for text entry and keypress automation.") : missing("swift", "Either Swift CLI or osascript is required for GUI interaction."); - const environmentInfo = commandExists("osascript") + const environmentInfo = commandAvailable("osascript") ? present("osascript", "AppleScript is available for frontmost-app environment inspection.") : missing("osascript", "AppleScript is required for local environment inspection."); @@ -89,7 +93,7 @@ export function getLocalComputerUseCapabilities(): LocalComputerUseCapabilities : "missing"; return { - platform: process.platform, + platform, overallState, screenshot, videoRecording, @@ -101,12 +105,12 @@ export function getLocalComputerUseCapabilities(): LocalComputerUseCapabilities browser_verification: screenshot.available && guiInteraction.available ? present(screenshot.command ?? guiInteraction.command ?? "screencapture", "Browser verification can use screenshots plus local GUI interaction.") : guiInteraction.state === "blocked_by_capability" || screenshot.state === "blocked_by_capability" - ? blocked(DARWIN_BLOCKED_DETAIL) + ? blocked(NATIVE_COMPUTER_USE_BLOCKED_DETAIL) : missing(guiInteraction.command ?? screenshot.command ?? "screencapture", "Browser verification needs screenshot capture and local GUI interaction."), browser_trace: screenshot.available ? present(screenshot.command ?? "screencapture", "Browser trace collection can attach local screenshot-backed evidence or trace files.") : screenshot.state === "blocked_by_capability" - ? blocked(DARWIN_BLOCKED_DETAIL) + ? blocked(NATIVE_COMPUTER_USE_BLOCKED_DETAIL) : missing(screenshot.command ?? "screencapture", "Browser trace evidence requires local capture support."), video_recording: videoRecording, console_logs: environmentInfo, diff --git a/apps/desktop/src/renderer/lib/platform.test.ts b/apps/desktop/src/renderer/lib/platform.test.ts index 7d4cb7ce9..ed5c9191c 100644 --- a/apps/desktop/src/renderer/lib/platform.test.ts +++ b/apps/desktop/src/renderer/lib/platform.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { isMacPlatform, rendererPlatformAttribute, + supportsIosSimulatorPlatform, supportsNativeNotchPlatform, } from "./platform"; @@ -24,4 +25,10 @@ describe("renderer platform helpers", () => { expect(supportsNativeNotchPlatform("Win32")).toBe(false); expect(supportsNativeNotchPlatform("Linux x86_64")).toBe(false); }); + + it("limits the iOS simulator capability to macOS", () => { + expect(supportsIosSimulatorPlatform("MacIntel")).toBe(true); + expect(supportsIosSimulatorPlatform("Win32")).toBe(false); + expect(supportsIosSimulatorPlatform("Linux x86_64")).toBe(false); + }); }); diff --git a/apps/desktop/src/renderer/lib/platform.ts b/apps/desktop/src/renderer/lib/platform.ts index 8e6878f91..aaa84d192 100644 --- a/apps/desktop/src/renderer/lib/platform.ts +++ b/apps/desktop/src/renderer/lib/platform.ts @@ -25,7 +25,16 @@ export function supportsNativeNotchPlatform(platformValue = getPlatformValue()): return isMacPlatform(platformValue); } +export function supportsIosSimulatorPlatform(platformValue = getPlatformValue()): boolean { + return isMacPlatform(platformValue); +} + export const isMac = isMacPlatform(); export const supportsNativeNotch = supportsNativeNotchPlatform(); -export const revealLabel = isMac ? "Reveal in Finder" : "Reveal in File Explorer"; +const rendererPlatform = rendererPlatformAttribute(); +export const revealLabel = isMac + ? "Reveal in Finder" + : rendererPlatform === "win32" + ? "Reveal in File Explorer" + : "Reveal in file manager"; export const modifierKeyLabel = isMac ? "Cmd" : "Ctrl"; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f127747de..214890b63 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -135,7 +135,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This | `credentials/` | Per-machine credential store. | | `agentRegistry.ts` | Per-machine agent registry. | -**Service managers.** `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), `installWindows.ts` (Windows) register the brain as a login-time service. `index.ts` is the platform router; `common.ts` carries shared types (`ServiceManagerResult`, `ServiceManagerStatusResult`). +**Service managers.** `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), and `installWindows.ts` (Windows) register the brain as a login-time service. `index.ts` is the platform router; `common.ts` carries shared types (`ServiceManagerResult`, `ServiceManagerStatusResult`). Windows uses a per-user, per-channel entry under `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`, so installation does not require administrator access. The entry starts a UTF-8-with-BOM PowerShell supervisor under the channel's ADE home. It restores the complete resolved brain environment (including `ELECTRON_RUN_AS_NODE`, `NODE_PATH`, ADE paths/channel, and role), starts the executable with CRT-compatible argv quoting, and records supervisor/runtime PIDs plus restart count, last exit, and next-restart diagnostics. Unexpected exits restart with capped exponential backoff; a healthy runtime interval resets the counter. Install and status require runtime RPC readiness on the expected per-user/channel pipe and verify the recorded process's executable, entrypoint, and `serve` arguments, so a stale/reused PID or live-but-unready supervisor is never reported as a healthy brain. Service identity is resolved after packaged channel defaults are applied, keeping Stable and Beta Run entries and homes distinct. Install and uninstall also remove exact Scheduled Task registrations left by earlier Windows preview builds. On macOS, an unchanged loaded launch agent is retained only after a bounded runtime initialize probe succeeds. A failed probe takes the full unload, predecessor termination, stale-process reap, and load path; the install result @@ -1463,7 +1463,8 @@ Stages: - `test-webhook-relay`, `test-push-relay`, `test-tunnel-relay`, `test-account-directory` — the four Cloudflare Workers. - `build` — desktop, ade-cli, and web built sequentially after install. - `validate-docs` — `node scripts/validate-docs.mjs`. -3. **Gate** (`ci-pass`) — all required jobs must pass (`if: always()` with failure/cancelled detection). +3. **Windows foundation proof** (`windows-foundation`) — a native `windows-latest` runner typechecks desktop and CLI code and exercises the per-user/channel service, filesystem layout, process/executable, named-pipe, PTY, packaged CR-SQLite, and platform-capability contracts. +4. **Gate** (`ci-pass`) — all required jobs, including `windows-foundation`, must pass (`if: always()` with failure/cancelled detection). Sharding is required because the desktop suite is large enough to be slow in a single process. From 2095f5b2527fbd365f38f6f31fcd0712ef27609f Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 20:34:18 -0400 Subject: [PATCH 05/42] test: gate Windows-native foundation contracts Based-on: nsxdavid/ADE#999 Co-authored-by: David Whatley <nsxdavid@gmail.com> --- .github/workflows/ci.yml | 51 +++++++++++++++++++ apps/ade-cli/src/bootstrap.test.ts | 9 ++-- .../src/headlessLinearServices.test.ts | 12 +++-- apps/ade-cli/src/headlessLinearServices.ts | 21 +++++--- .../src/services/agentRegistry.test.ts | 4 +- .../desktopBridgeClient.test.ts | 21 +++++--- .../src/services/modelPickerStore.test.ts | 45 +++++++++++++--- .../projects/projectIconResolver.test.ts | 7 +-- .../services/projects/projectRegistry.test.ts | 7 +-- .../sync/machineIdentitySigningStore.test.ts | 8 ++- .../sync/syncLoopbackCollision.test.ts | 20 ++++++-- .../services/sync/syncPairingStore.test.ts | 6 ++- .../src/services/sync/syncService.test.ts | 14 ++++- apps/ade-cli/src/test/crrModelPickerWorker.ts | 40 +++++++++++++++ apps/ade-cli/src/test/filesystem.ts | 29 +++++++++++ .../attentionAccountCoordinator.test.ts | 2 +- 16 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 apps/ade-cli/src/test/crrModelPickerWorker.ts create mode 100644 apps/ade-cli/src/test/filesystem.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d41d0f75b..517deca02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -405,6 +405,56 @@ jobs: if-no-files-found: error compression-level: 0 + # Native Windows source gate for the filesystem, process, IPC, SQLite, and + # capability contracts that Linux-hosted unit jobs cannot exercise. + windows-foundation: + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install ADE CLI dependencies + run: npm --prefix apps/ade-cli ci + + - name: Install desktop dependencies + run: npm --prefix apps/desktop ci + + - name: Typecheck Windows runtime foundations + run: | + npm --prefix apps/ade-cli run typecheck + npm --prefix apps/desktop run typecheck + + - name: Test Windows service, layout, and IPC contracts + run: >- + cd apps/ade-cli && npx vitest run + src/bootstrap.test.ts + src/serviceManager/common.test.ts + src/serviceManager/installWindows.test.ts + src/serviceManager/windowsSupervisor.test.ts + src/services/builtInBrowser/desktopBridgeClient.test.ts + src/services/modelPickerStore.test.ts + src/services/projects/machineLayout.test.ts + src/services/projects/projectIconResolver.test.ts + src/services/projects/projectRegistry.test.ts + src/services/sync/syncHostSingleton.test.ts + src/services/sync/syncLoopbackCollision.test.ts + src/services/sync/syncService.test.ts + + - name: Test intended-user named-pipe listener contracts + run: >- + cd apps/ade-cli && npx vitest run src/cli.test.ts + -t "declares intended-user-only access|hosts headless RPC on a Windows named pipe" + + - name: Test Windows desktop, SQLite, and capability contracts + run: cd apps/desktop && npx vitest run src/main/packagedRuntimeSmoke.test.ts src/main/services/computerUse/localComputerUse.test.ts src/renderer/lib/platform.test.ts + validate-docs: needs: install runs-on: ubuntu-latest @@ -449,6 +499,7 @@ jobs: - test-account-directory - build - build-runtime-binaries + - windows-foundation - validate-docs runs-on: ubuntu-latest steps: diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index d03216d2e..15048e16f 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -16,6 +16,7 @@ import { ADE_BUNDLED_AGENT_SKILLS_DIR_ENV, splitAdeAgentSkillRoots, } from "../../desktop/src/shared/agentSkillRoots"; +import { createTestDirectoryLink, removeTestTree } from "./test/filesystem"; const tempRoots: string[] = []; @@ -37,10 +38,10 @@ function writeSkillsManifest(skillsRoot: string): void { ); } -afterEach(() => { +afterEach(async () => { vi.restoreAllMocks(); for (const root of tempRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); + await removeTestTree(root); } }); @@ -112,7 +113,7 @@ describe("headless ADE CLI agent skill roots", () => { const externalSkills = path.join(packagedRoot, "external-skills"); fs.mkdirSync(resourcesPath, { recursive: true }); writeSkillsManifest(externalSkills); - fs.symlinkSync(externalSkills, path.join(resourcesPath, "agent-skills"), "dir"); + createTestDirectoryLink(externalSkills, path.join(resourcesPath, "agent-skills")); const sourceRoot = makeTempRoot(); const sourceExternalRoot = makeTempRoot(); @@ -122,7 +123,7 @@ describe("headless ADE CLI agent skill roots", () => { writeFile(sourceCli, "module.exports = {};\n"); writeSkillsManifest(sourceExternalSkills); fs.mkdirSync(path.dirname(sourceSkills), { recursive: true }); - fs.symlinkSync(sourceExternalSkills, sourceSkills, "dir"); + createTestDirectoryLink(sourceExternalSkills, sourceSkills); expect(inferAgentSkillsRootForCliEntry(null, { cwd: path.join(packagedRoot, "elsewhere"), diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 83ccd233e..2bc68ad2b 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -312,17 +312,16 @@ describe("headlessLinearServices", () => { it("coalesces concurrent forced GitHub status lookups", async () => { const previousAdeHome = process.env.ADE_HOME; - const previousFetch = globalThis.fetch; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-status-coalesce-")); let resolveResponse: ((response: Response) => void) | undefined; const response = new Promise<Response>((resolve) => { resolveResponse = resolve; }); const fetchImpl = vi.fn(async () => await response) as unknown as typeof fetch; - globalThis.fetch = fetchImpl; const githubService = createHeadlessGitHubService( "/tmp/ade-project", { debug() {}, info() {}, warn() {}, error() {} } as any, + { fetchImpl }, ); try { githubService.setToken("ghp_test_token"); @@ -331,6 +330,10 @@ describe("headlessLinearServices", () => { () => githubService.getStatus({ forceRefresh: true }), ); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)); + // The callers independently resolve the repository and credential inventory + // before joining the shared HTTP probe. Keep that probe pending long enough + // for every already-started caller to reach the coalescing boundary. + await new Promise((resolve) => setTimeout(resolve, 1_000)); resolveResponse?.(new Response(JSON.stringify({ login: "octocat" }), { status: 200, headers: { @@ -341,10 +344,11 @@ describe("headlessLinearServices", () => { const statuses = await Promise.all(lookups); expect(statuses).toHaveLength(16); - expect(statuses.every((status) => status.userLogin === "octocat")).toBe(true); + expect(statuses.map((status) => status.userLogin)).toEqual( + Array.from({ length: 16 }, () => "octocat"), + ); expect(fetchImpl).toHaveBeenCalledTimes(1); } finally { - globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; else process.env.ADE_HOME = previousAdeHome; } diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index ba6c0a695..f3e74df48 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -614,7 +614,11 @@ function parseNextGitHubLink(linkHeader: string | null): string | null { const GITHUB_API_TIMEOUT_MS = 20_000; -async function fetchGitHub(input: string | URL, init: RequestInit): Promise<Response> { +async function fetchGitHub( + input: string | URL, + init: RequestInit, + fetchImpl: typeof fetch = fetch, +): Promise<Response> { const controller = new AbortController(); const upstreamSignal = init.signal; const abortFromUpstream = (): void => controller.abort(upstreamSignal?.reason); @@ -622,7 +626,7 @@ async function fetchGitHub(input: string | URL, init: RequestInit): Promise<Resp else upstreamSignal?.addEventListener("abort", abortFromUpstream, { once: true }); const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); try { - return await fetch(input, { ...init, signal: controller.signal }); + return await fetchImpl(input, { ...init, signal: controller.signal }); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new Error( @@ -647,13 +651,16 @@ export function createHeadlessGitHubService( HeadlessGitHubTokenLookup, "token" | "ghCliPath" | "ghAuthError" >) | null; + fetchImpl?: typeof fetch; } = {}, ): HeadlessGitHubService { + const requestGitHub = (input: string | URL, init: RequestInit): Promise<Response> => + fetchGitHub(input, init, options.fetchImpl); const credentialStore = new EncryptedFileCredentialStore(); const appUserAuth = createGitHubAppUserAuthService({ credentialStore, logger, - fetchImpl: (input, init) => fetchGitHub(input, init ?? {}), + fetchImpl: (input, init) => requestGitHub(input, init ?? {}), userAgent: "ade-cli", }); const tokenKey = "github.token.v1"; @@ -877,7 +884,7 @@ export function createHeadlessGitHubService( tokenType: NonNullable<HeadlessGitHubStatus["tokenType"]>; rateLimit: GitHubRateLimitState | null; }> => { - const response = await fetchGitHub("https://api.github.com/user", { + const response = await requestGitHub("https://api.github.com/user", { method: "GET", headers: { accept: "application/vnd.github+json", @@ -969,7 +976,7 @@ export function createHeadlessGitHubService( }; } try { - const response = await fetchGitHub( + const response = await requestGitHub( `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, { method: "GET", @@ -1220,7 +1227,7 @@ export function createHeadlessGitHubService( } let response: Response; try { - response = await fetchGitHub(url, { + response = await requestGitHub(url, { method: args.method, headers, body: args.body == null ? undefined : JSON.stringify(args.body), @@ -1236,7 +1243,7 @@ export function createHeadlessGitHubService( return { data: cached.data as T, response, linkHeader: cached.linkHeader }; } delete headers["if-none-match"]; - response = await fetchGitHub(url, { + response = await requestGitHub(url, { method: args.method, headers, body: args.body == null ? undefined : JSON.stringify(args.body), diff --git a/apps/ade-cli/src/services/agentRegistry.test.ts b/apps/ade-cli/src/services/agentRegistry.test.ts index 9af25c18c..0d7e8d9dd 100644 --- a/apps/ade-cli/src/services/agentRegistry.test.ts +++ b/apps/ade-cli/src/services/agentRegistry.test.ts @@ -7,7 +7,9 @@ describe("classifyAgentCliError", () => { agent: "codex", displayName: "Codex CLI", category: "missing", - installCommand: 'mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g @openai/codex', + installCommand: process.platform === "win32" + ? "npm install -g @openai/codex" + : 'mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g @openai/codex', authCommand: "codex login", }); }); diff --git a/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts index 23b9cadfc..e4d1e2600 100644 --- a/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts +++ b/apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; @@ -30,12 +31,19 @@ type ServerHandle = { close: () => Promise<void>; }; +function createBridgeSocketPath(prefix = "ade-bridge-test"): string { + if (process.platform === "win32") { + return `\\\\.\\pipe\\${prefix}-${process.pid}-${randomUUID()}`; + } + return path.join( + fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)), + "bridge.sock", + ); +} + async function startBridgeServer( handler: (request: JsonRpcRequest) => Promise<unknown>, - socketPath = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), "ade-bridge-test-")), - "bridge.sock", - ), + socketPath = createBridgeSocketPath(), ): Promise<ServerHandle> { const stopHandles = new Set<() => void>(); const sockets = new Set<net.Socket>(); @@ -302,10 +310,7 @@ describe("createBuiltInBrowserDesktopBridgeClient", () => { }); it("forgets a cached bridge connection when the socket closes", async () => { - const socketPath = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), "ade-bridge-test-restart-")), - "bridge.sock", - ); + const socketPath = createBridgeSocketPath("ade-bridge-test-restart"); let generation = 1; server = await startBridgeServer(async () => ({ generation }), socketPath); const client = createBuiltInBrowserDesktopBridgeClient({ diff --git a/apps/ade-cli/src/services/modelPickerStore.test.ts b/apps/ade-cli/src/services/modelPickerStore.test.ts index 2e2565969..adde321b3 100644 --- a/apps/ade-cli/src/services/modelPickerStore.test.ts +++ b/apps/ade-cli/src/services/modelPickerStore.test.ts @@ -1,9 +1,20 @@ import fs from "node:fs"; +import { spawnSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb, type AdeDb } from "../../../desktop/src/main/services/state/kvDb"; import { createModelPickerStore, MODEL_PICKER_MAX_RECENTS } from "./modelPickerStore"; +import { removeTestTree } from "../test/filesystem"; + +vi.mock("../../../desktop/src/main/services/state/crsqliteExtension", async (importOriginal) => { + const original = await importOriginal< + typeof import("../../../desktop/src/main/services/state/crsqliteExtension") + >(); + return process.platform === "win32" + ? { ...original, resolveCrsqliteExtensionPath: () => null } + : original; +}); function createLogger() { return { @@ -18,17 +29,13 @@ describe("modelPickerStore (db-backed)", () => { const cleanupRoots: string[] = []; const openDbs: AdeDb[] = []; - afterEach(() => { + afterEach(async () => { vi.useRealTimers(); for (const db of openDbs.splice(0)) { - try { - db.close(); - } catch { - // best-effort teardown - } + db.close(); } for (const root of cleanupRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); + await removeTestTree(root); } }); @@ -46,6 +53,30 @@ describe("modelPickerStore (db-backed)", () => { return path.join(root, "no-such-legacy.json"); } + it.skipIf(process.platform !== "win32")( + "runs model-picker CRR mutations in an isolated Windows brain-lifetime process", + async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-model-picker-crr-worker-")); + cleanupRoots.push(root); + const workerPath = path.resolve(process.cwd(), "src", "test", "crrModelPickerWorker.ts"); + const tsxPath = path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"); + const result = spawnSync(process.execPath, [tsxPath, workerPath, path.join(root, "ade.db")], { + encoding: "utf8", + env: process.env, + timeout: 15_000, + windowsHide: true, + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + crsqliteAvailable: true, + favorites: ["gpt-5"], + recents: ["claude-sonnet-5"], + }); + }, + 20_000, + ); + it("starts empty on a fresh db", async () => { const { db, root } = await makeDb(); const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts index d4741bd22..9430872e6 100644 --- a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts +++ b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts @@ -7,6 +7,7 @@ import { REMOTE_ICON_MAX_DATA_URL_BYTES, resolveRemoteProjectIcon, } from "./projectIconResolver"; +import { createTestDirectoryLink, removeTestTree } from "../../test/filesystem"; const tempRoots = new Set<string>(); @@ -29,9 +30,9 @@ function decodeDataUrl(dataUrl: string): { mime: string; bytes: Buffer } { return { mime: match[1], bytes: Buffer.from(match[2], "base64") }; } -afterEach(() => { +afterEach(async () => { for (const root of tempRoots) { - fs.rmSync(root, { recursive: true, force: true }); + await removeTestTree(root); } tempRoots.clear(); }); @@ -142,7 +143,7 @@ describe("resolveRemoteProjectIcon", () => { fs.writeFileSync(path.join(outsideDir, "favicon.png"), Buffer.from([0xca, 0xfe])); // `public` is a symlink escaping the root; the public/favicon.png candidate // must not be read. - fs.symlinkSync(outsideDir, path.join(root, "public")); + createTestDirectoryLink(outsideDir, path.join(root, "public")); const icon = resolveRemoteProjectIcon(root); diff --git a/apps/ade-cli/src/services/projects/projectRegistry.test.ts b/apps/ade-cli/src/services/projects/projectRegistry.test.ts index 780fe095a..9bcd922b4 100644 --- a/apps/ade-cli/src/services/projects/projectRegistry.test.ts +++ b/apps/ade-cli/src/services/projects/projectRegistry.test.ts @@ -7,6 +7,7 @@ import { deriveProjectId, isDisallowedProjectRoot, } from "./projectRegistry"; +import { createTestDirectoryLink, removeTestTree } from "../../test/filesystem"; const spawnSyncMock = vi.hoisted(() => vi.fn()); @@ -22,10 +23,10 @@ function makeTempRoot(prefix = "ade-project-registry-"): string { return root; } -afterEach(() => { +afterEach(async () => { vi.restoreAllMocks(); for (const root of tempRoots) { - fs.rmSync(root, { recursive: true, force: true }); + await removeTestTree(root); } tempRoots.clear(); }); @@ -283,7 +284,7 @@ describe("ProjectRegistry", () => { const aliasRoot = path.join(homeDir, "ADE-link"); fs.mkdirSync(rawProjectRoot, { recursive: true }); const projectRoot = fs.realpathSync.native(rawProjectRoot); - fs.symlinkSync(projectRoot, aliasRoot, "dir"); + createTestDirectoryLink(projectRoot, aliasRoot); expect(deriveProjectId(aliasRoot)).toBe(deriveProjectId(projectRoot)); }); diff --git a/apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts b/apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts index 8881ffb39..6ec262d6c 100644 --- a/apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts +++ b/apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { createMachineIdentitySigningStore } from "./machineIdentitySigningStore"; describe("machineIdentitySigningStore", () => { - it("creates once with mode 0600 and reloads the same key", () => { + it("creates once with private POSIX permissions and reloads the same key", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-signing-")); const filePath = path.join(dir, "machine-identity-signing.json"); const first = createMachineIdentitySigningStore({ filePath }).getOrCreate(); @@ -14,7 +14,11 @@ describe("machineIdentitySigningStore", () => { expect(first.publicKeyRawBase64).toBe(second.publicKeyRawBase64); expect(first.privateKeyPkcs8Base64).toBe(second.privateKeyPkcs8Base64); expect(Buffer.from(first.publicKeyRawBase64, "base64")).toHaveLength(32); - expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + const stat = fs.statSync(filePath); + expect(stat.isFile()).toBe(true); + if (process.platform !== "win32") { + expect(stat.mode & 0o777).toBe(0o600); + } }); it("shares one cached identity across consumers of the same file", () => { diff --git a/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts b/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts index 0fbd01aaa..b8365c03a 100644 --- a/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts +++ b/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts @@ -12,6 +12,16 @@ import { createSyncService, type SyncService } from "./syncService"; import { probeAdeLoopbackListener, type SyncLoopbackProbeResult } from "./syncLoopbackProbe"; import type { SyncTunnelClientStatus } from "./syncTunnelClientService"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; +import { removeTestTree } from "../../test/filesystem"; + +vi.mock("../../../../desktop/src/main/services/state/crsqliteExtension", async (importOriginal) => { + const original = await importOriginal< + typeof import("../../../../desktop/src/main/services/state/crsqliteExtension") + >(); + return process.platform === "win32" + ? { ...original, resolveCrsqliteExtensionPath: () => null } + : original; +}); const ORIGINAL_BIND_HOST = vi.hoisted(() => process.env.ADE_SYNC_BIND_HOST); vi.hoisted(() => { @@ -213,7 +223,7 @@ describe("sync loopback collision recovery", () => { if (!holder.killed && holder.exitCode == null) holder.kill("SIGKILL"); if (previousAdeHome === undefined) delete process.env.ADE_HOME; else process.env.ADE_HOME = previousAdeHome; - fs.rmSync(adeHome, { recursive: true, force: true }); + await removeTestTree(adeHome); } }); @@ -282,7 +292,7 @@ describe("sync loopback collision recovery", () => { await listener.close(); db.close(); await close(foreign.server); - fs.rmSync(projectRoot, { recursive: true, force: true }); + await removeTestTree(projectRoot); if (previousLockPath === undefined) delete process.env.ADE_SYNC_HOST_LOCK_PATH; else process.env.ADE_SYNC_HOST_LOCK_PATH = previousLockPath; } @@ -345,7 +355,7 @@ describe("sync loopback collision recovery", () => { await service.dispose(); await listener.close(); db.close(); - fs.rmSync(projectRoot, { recursive: true, force: true }); + await removeTestTree(projectRoot); if (previousLockPath === undefined) delete process.env.ADE_SYNC_HOST_LOCK_PATH; else process.env.ADE_SYNC_HOST_LOCK_PATH = previousLockPath; } @@ -415,7 +425,7 @@ describe("sync loopback collision recovery", () => { await service.dispose(); await listener.close(); db.close(); - fs.rmSync(projectRoot, { recursive: true, force: true }); + await removeTestTree(projectRoot); if (previousLockPath === undefined) delete process.env.ADE_SYNC_HOST_LOCK_PATH; else process.env.ADE_SYNC_HOST_LOCK_PATH = previousLockPath; } @@ -492,7 +502,7 @@ describe("sync loopback collision recovery", () => { await service.dispose(); await listener.close(); db.close(); - fs.rmSync(projectRoot, { recursive: true, force: true }); + await removeTestTree(projectRoot); if (previousLockPath === undefined) delete process.env.ADE_SYNC_HOST_LOCK_PATH; else process.env.ADE_SYNC_HOST_LOCK_PATH = previousLockPath; } diff --git a/apps/ade-cli/src/services/sync/syncPairingStore.test.ts b/apps/ade-cli/src/services/sync/syncPairingStore.test.ts index 2fccf7a89..03516ca44 100644 --- a/apps/ade-cli/src/services/sync/syncPairingStore.test.ts +++ b/apps/ade-cli/src/services/sync/syncPairingStore.test.ts @@ -67,7 +67,11 @@ describe("sync SSH pairing trust", () => { dpopPublicKey: VALID_DPOP_PUBLIC_KEY, runtimeHostGranted: false, }); - expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + const stat = fs.statSync(filePath); + expect(stat.isFile()).toBe(true); + if (process.platform !== "win32") { + expect(stat.mode & 0o777).toBe(0o600); + } expect(fs.readFileSync(filePath, "utf8")).not.toContain(paired.secret); }); diff --git a/apps/ade-cli/src/services/sync/syncService.test.ts b/apps/ade-cli/src/services/sync/syncService.test.ts index d815db97f..d094cfded 100644 --- a/apps/ade-cli/src/services/sync/syncService.test.ts +++ b/apps/ade-cli/src/services/sync/syncService.test.ts @@ -5,6 +5,16 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb, type AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; import { createSyncService, type SyncService } from "./syncService"; +import { removeTestTree } from "../../test/filesystem"; + +vi.mock("../../../../desktop/src/main/services/state/crsqliteExtension", async (importOriginal) => { + const original = await importOriginal< + typeof import("../../../../desktop/src/main/services/state/crsqliteExtension") + >(); + return process.platform === "win32" + ? { ...original, resolveCrsqliteExtensionPath: () => null } + : original; +}); function createLogger() { return { @@ -69,9 +79,9 @@ function createService( describe("createSyncService", () => { const cleanupRoots: string[] = []; - afterEach(() => { + afterEach(async () => { for (const root of cleanupRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); + await removeTestTree(root); } }); diff --git a/apps/ade-cli/src/test/crrModelPickerWorker.ts b/apps/ade-cli/src/test/crrModelPickerWorker.ts new file mode 100644 index 000000000..c9c46daee --- /dev/null +++ b/apps/ade-cli/src/test/crrModelPickerWorker.ts @@ -0,0 +1,40 @@ +import path from "node:path"; +import { openKvDb } from "../../../desktop/src/main/services/state/kvDb"; +import { createModelPickerStore } from "../services/modelPickerStore"; + +const dbPath = process.argv[2]; +if (!dbPath) throw new Error("Expected a database path."); + +const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; + +try { + const db = await openKvDb(dbPath, logger); + const crsqliteAvailable = db.sync.isAvailable?.() === true; + if (!crsqliteAvailable) { + throw new Error("CR-SQLite did not load in the native Windows worker."); + } + const store = createModelPickerStore({ + db, + legacyFilePath: path.join(path.dirname(dbPath), "missing-model-picker.json"), + }); + store.toggleFavorite("gpt-5"); + store.pushRecent("claude-sonnet-5"); + process.stdout.write(JSON.stringify({ + crsqliteAvailable, + favorites: store.getFavorites(), + recents: store.getRecents(), + })); + // A Windows brain owns the native extension for its process lifetime. Exit + // the isolated worker so the OS unloads the DLL before the parent removes + // the fixture directory; closing a CRR-rich connection can block in the + // upstream Windows extension teardown path. + process.exit(0); +} catch (error) { + process.stderr.write(error instanceof Error ? error.stack ?? error.message : String(error)); + process.exit(1); +} diff --git a/apps/ade-cli/src/test/filesystem.ts b/apps/ade-cli/src/test/filesystem.ts new file mode 100644 index 000000000..aac499b61 --- /dev/null +++ b/apps/ade-cli/src/test/filesystem.ts @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Creates a directory link without requiring Windows Developer Mode or an + * elevated test process. Junctions preserve the realpath/containment contract + * exercised by these tests while remaining available to ordinary users. + */ +export function createTestDirectoryLink(target: string, linkPath: string): void { + fs.symlinkSync( + process.platform === "win32" ? path.resolve(target) : target, + linkPath, + process.platform === "win32" ? "junction" : "dir", + ); +} + +/** + * Windows can briefly retain SQLite and filesystem handles after close. Use + * Node's bounded recursive-rm retry support, and let the final error fail the + * test instead of hiding a leaked handle. + */ +export async function removeTestTree(target: string): Promise<void> { + await fs.promises.rm(target, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); +} diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts index 0867a7d5d..cba3f16a9 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts @@ -180,7 +180,7 @@ describe("AttentionAccountCoordinator", () => { expect(result.machines).toEqual(legacy.machines); expect(result.items[0]?.machine).toEqual(legacy.items[0]?.machine); - expect(result.availability?.hostName).toBe("this Mac"); + expect(result.availability?.hostName).toBe("this computer"); vi.useRealTimers(); }); From 97044ee8744762a75baf7f17fc2d1d70368e1ec9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 21:49:18 -0400 Subject: [PATCH 06/42] fix(windows): keep runtime recovery single-owner Prevent the CLI from launching a competing manual brain while a registered supervisor owns readiness recovery, and make stack readiness block on exact-SHA proof required at the current position. Co-authored-by: David Whatley <nsxdavid@gmail.com> Based-on: nsxdavid/ADE#999 --- .agents/skills/ship/SKILL.md | 10 ++++--- apps/ade-cli/src/cli.ts | 23 +++++++++++++--- .../ade-cli/src/serviceManager/common.test.ts | 27 +++++++++++++++++++ apps/ade-cli/src/serviceManager/common.ts | 9 +++++++ docs/playbooks/ship-lane.md | 27 +++++++++++++++---- 5 files changed, 85 insertions(+), 11 deletions(-) diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 1c0595f6a..4d4194c2b 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -37,8 +37,12 @@ the `/quality` rules. Persist `mode: "stack"` plus the complete stack binding: stack number, position, expected parent branch, validated head SHA, base SHA, content-tree SHA, test-evidence SHA, proof links, and quality/test status. -After the exact head is green, review-terminal, quality-clean, and test-clean, -write `status: "ready-stacked"` and return the binding to the coordinator. A +After the exact head is green, review-terminal, quality-clean, test-clean, and +every proof scenario required at this PR's current stack position has a current +evidence link, write `status: "ready-stacked"` and return the binding to the +coordinator. Cumulative clean-host/cross-client/release scenarios are assigned +to the top proof PR; they do not masquerade as lower-layer evidence. Any +missing required scenario is `blocked`, never `ready-stacked` with a caveat. A branch change, commit/rebase, or any lower-parent movement invalidates this entry and every entry above it. Missing or ambiguous metadata is `blocked`, not a fallback to `main`. @@ -278,7 +282,7 @@ self-resume signal. Either: | Status | Meaning | |--------|---------| -| `ready-stacked` | Opt-in stacked PR has a complete head/base/tree/test/proof binding; coordinator owns all stack mutation and landing | +| `ready-stacked` | Opt-in stacked PR has a complete head/base/tree/test/proof binding with no proof blocker required at its current position; coordinator owns all stack mutation and landing | | `done-clean` | PR merged on main | | `done-max` | 5 normal + 1 force-finalize exhausted, merge genuinely blocked | | `blocked` | Unrecoverable conflict, gate failure, API error, force-finalize CI failed, or a non-empty `/quality` gate awaiting an author decision | diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 7ae172881..defee5620 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -14887,6 +14887,7 @@ function shouldAllowRuntimeSelfShutdown(env: NodeJS.ProcessEnv = process.env): b } class RuntimeSelfShutdownBlockedError extends Error {} +class RuntimeServiceRecoveryOwnedError extends Error {} function isLocalRuntimeSocketPath(socketPath: string): boolean { return !socketPath.startsWith("tcp://"); @@ -14982,12 +14983,25 @@ async function repairMachineRuntimeServiceConnection(args: { }): Promise<SocketJsonRpcClient | null> { let client: SocketJsonRpcClient | null = null; try { - const { installRuntimeService, uninstallRuntimeService } = await import("./serviceManager"); + const [ + { installRuntimeService, uninstallRuntimeService }, + { serviceManagerOwnsRuntimeRecovery }, + ] = await Promise.all([ + import("./serviceManager"), + import("./serviceManager/common"), + ]); const result = await withAdeDefaultRole( args.options.role, () => installRuntimeService(), ); - if (!result.ok) return null; + if (!result.ok) { + if (serviceManagerOwnsRuntimeRecovery(result)) { + throw new RuntimeServiceRecoveryOwnedError( + `${result.message} The registered service still owns recovery for this endpoint, so ADE did not start a competing manual brain.`, + ); + } + return null; + } client = await SocketJsonRpcClient.connect( args.socketPath, args.options.timeoutMs, @@ -15016,7 +15030,10 @@ async function repairMachineRuntimeServiceConnection(args: { client = null; return repaired; } catch (error) { - if (error instanceof RuntimeSelfShutdownBlockedError) throw error; + if ( + error instanceof RuntimeSelfShutdownBlockedError + || error instanceof RuntimeServiceRecoveryOwnedError + ) throw error; return null; } finally { try { diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index 8c973bd7c..579fad79c 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -10,10 +10,37 @@ import { isStaleChannelServeCommandLine, renderCommand, resolveAdeServeCommand, + serviceManagerOwnsRuntimeRecovery, type AdeServiceCommand, type ServiceManagerProcessResult, type ServiceManagerSpawnSync, } from "./common"; + +describe("serviceManagerOwnsRuntimeRecovery", () => { + const base = { + serviceName: "com.ade.runtime", + action: "install" as const, + path: "runtime", + message: "test", + }; + + it("keeps manual fallback blocked while a registered replacement owns readiness retries", () => { + expect(serviceManagerOwnsRuntimeRecovery({ + ...base, + ok: false, + failureStep: "replacement_responsive", + })).toBe(true); + }); + + it("allows fallback before the replacement reaches supervisor-owned recovery", () => { + expect(serviceManagerOwnsRuntimeRecovery({ + ...base, + ok: false, + failureStep: "replacement_pid", + })).toBe(false); + expect(serviceManagerOwnsRuntimeRecovery({ ...base, ok: true })).toBe(false); + }); +}); import { installLaunchdService, isLaunchdPrintRunning, diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index cce2690cf..26d0af9fa 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -17,6 +17,15 @@ export type ServiceManagerResult = { failureStep?: "predecessor_exit" | "replacement_pid" | "replacement_responsive"; }; +/** + * A replacement that reached its readiness phase is already registered with + * the platform supervisor. That supervisor owns subsequent retries; starting + * an unmanaged daemon for the same endpoint would create a competing brain. + */ +export function serviceManagerOwnsRuntimeRecovery(result: ServiceManagerResult): boolean { + return !result.ok && result.failureStep === "replacement_responsive"; +} + export type ServiceManagerStatusResult = { ok: boolean; serviceName: string; diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md index decdc1c9e..a603521bb 100644 --- a/docs/playbooks/ship-lane.md +++ b/docs/playbooks/ship-lane.md @@ -164,8 +164,13 @@ SHAs are abbreviated here only for readability): "validatedHeadSha": "1111111111111111111111111111111111111111", "baseSha": "2222222222222222222222222222222222222222", "contentTreeSha": "3333333333333333333333333333333333333333", - "testEvidenceSha": "4444444444444444444444444444444444444444", - "proofLinks": ["https://github.com/example/ADE/actions/runs/123"], + "testEvidenceSha": "1111111111111111111111111111111111111111", + "requiredProofScenarioIds": ["windows-foundation-ci"], + "proofLinks": [{ + "scenarioId": "windows-foundation-ci", + "url": "https://github.com/example/ADE/actions/runs/123", + "evidenceSha": "1111111111111111111111111111111111111111" + }], "qualityStatus": "passed", "testStatus": "passed" }, @@ -177,6 +182,7 @@ Validate the shape before accepting the terminal state, for example: ```bash jq -e ' + .stackBinding.validatedHeadSha as $head | .mode == "stack" and .status == "ready-stacked" and (.stackBinding.stackNumber | type == "number" and . > 0) and (.stackBinding.position | type == "number" and . > 0) and @@ -184,7 +190,12 @@ jq -e ' .stackBinding.contentTreeSha, .stackBinding.testEvidenceSha] | all(test("^[0-9a-f]{40}$"))) and (.stackBinding.expectedParentBranch | length > 0) and - (.stackBinding.proofLinks | type == "array" and length > 0) and + (.stackBinding.requiredProofScenarioIds | type == "array") and + (.stackBinding.proofLinks | type == "array") and + ([.stackBinding.requiredProofScenarioIds[] as $scenario + | any(.stackBinding.proofLinks[]?; .scenarioId == $scenario and + (.url | type == "string" and length > 0) and + .evidenceSha == $head)] | all) and .stackBinding.testEvidenceSha == .stackBinding.validatedHeadSha and .stackBinding.qualityStatus == "passed" and .stackBinding.testStatus == "passed" @@ -736,14 +747,20 @@ ingestion remain supported and independently tested. Clean-host Stable/Beta coexistence, second-account pipe denial, reboot/restart recovery, signed/installed update proof, and real GUI captures are external host evidence; list missing artifacts as blockers rather than claiming them from mocks. +Cumulative full-system scenarios are assigned to the top proof PR. A missing +scenario required at the current stack position is `blocked`, never +`ready-stacked`. ### 3c.0 Finish stack-ready mode If `SHIP_MODE=stack`, validate the full `stackBinding`: stack number, position, expected parent branch, parent SHA, PR head SHA, content-tree SHA, test-evidence SHA, proof links, and passed quality/test status. Required -CI/review evidence must be terminal. Then set `status: "ready-stacked"`, retain -the state file, print the full binding and external proof blockers, and return +CI/review evidence must be terminal. Every proof scenario required at this +stack position must have a link whose evidence SHA equals the validated head; +otherwise set `status: "blocked"` and report the missing scenario IDs. Only +then set `status: "ready-stacked"`, retain the state file, print the full +binding, and return to the stack coordinator. Do not execute 3c.1–3c.5 or Phase 3d. A moved branch, parent, head, or lower layer invalidates this and all higher bindings and exits `stack-coordinator-sync-required`; the per-PR loop does not rebase or push. From 878a3b50af7f7ed89ff520980753f29ef0d083ed Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 22:01:18 -0400 Subject: [PATCH 07/42] test(windows): exercise named-pipe isolation Route the CLI brain listener through intended-user named-pipe options and replace an empty CI filter with a real native Windows contract test. Co-authored-by: David Whatley <nsxdavid@gmail.com> Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 4 +--- apps/ade-cli/src/cli.ts | 3 ++- .../runtime/localIpcListenOptions.test.ts | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 apps/ade-cli/src/services/runtime/localIpcListenOptions.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 517deca02..189a9ffc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -448,9 +448,7 @@ jobs: src/services/sync/syncService.test.ts - name: Test intended-user named-pipe listener contracts - run: >- - cd apps/ade-cli && npx vitest run src/cli.test.ts - -t "declares intended-user-only access|hosts headless RPC on a Windows named pipe" + run: cd apps/ade-cli && npx vitest run src/services/runtime/localIpcListenOptions.test.ts - name: Test Windows desktop, SQLite, and capability contracts run: cd apps/desktop && npx vitest run src/main/packagedRuntimeSmoke.test.ts src/main/services/computerUse/localComputerUse.test.ts src/renderer/lib/platform.test.ts diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index defee5620..439dab826 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -85,6 +85,7 @@ import { withRpcAuthParam, } from "./rpcAuth"; import { isAdeRuntimeNamedPipePath } from "../../desktop/src/shared/adeRuntimeIpc"; +import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions"; import { headlessMobileProjectSummary } from "./services/sync/headlessMobileProjectSummary"; import { isUnsupportedRecoveryActionError, @@ -16277,7 +16278,7 @@ async function runServe( server.once("listening", handleListening); server.once("error", handleError); if (typeof target === "string") { - server.listen(target); + server.listen(localIpcListenOptions(target)); } else { server.listen(target.port, target.host); } diff --git a/apps/ade-cli/src/services/runtime/localIpcListenOptions.test.ts b/apps/ade-cli/src/services/runtime/localIpcListenOptions.test.ts new file mode 100644 index 000000000..4e63217cd --- /dev/null +++ b/apps/ade-cli/src/services/runtime/localIpcListenOptions.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { localIpcListenOptions } from "./localIpcListenOptions"; + +describe("localIpcListenOptions", () => { + it("declares intended-user-only access for Windows named pipes", () => { + expect(localIpcListenOptions("\\\\.\\pipe\\ade-runtime-stable-S-1-5-21-1000")).toEqual({ + path: "\\\\.\\pipe\\ade-runtime-stable-S-1-5-21-1000", + readableAll: false, + writableAll: false, + }); + expect(localIpcListenOptions("//./pipe/ade-runtime-beta-S-1-5-21-1000")).toEqual({ + path: "//./pipe/ade-runtime-beta-S-1-5-21-1000", + readableAll: false, + writableAll: false, + }); + }); + + it("preserves Unix socket paths without Windows-only listen options", () => { + expect(localIpcListenOptions("/tmp/ade-runtime.sock")).toBe("/tmp/ade-runtime.sock"); + }); +}); From cbbfa7d2532afba986628cab8a2c534ccfc283cf Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 23:01:18 -0400 Subject: [PATCH 08/42] fix(windows): pin trusted system executables Resolve ADE-owned PowerShell, registry, task scheduler, and process termination commands through the kernel SystemRoot alias, validate their canonical System32 paths, and persist the absolute PowerShell path for startup supervision. Add native Windows cwd, PATH, SystemRoot, and windir poisoning regressions covering service and clipboard launch paths. Based-on: nsxdavid/ADE#999 --- .../src/lib/trustedWindowsTools.test.ts | 121 ++++++++++++++++++ apps/ade-cli/src/lib/trustedWindowsTools.ts | 83 ++++++++++++ .../src/serviceManager/installWindows.test.ts | 37 +++--- .../src/serviceManager/installWindows.ts | 20 +-- .../src/serviceManager/windowsSupervisor.ts | 3 +- .../src/services/sync/syncHostSingleton.ts | 3 +- apps/ade-cli/src/tuiClient/app.tsx | 5 +- apps/ade-cli/src/tuiClient/imageTargets.ts | 9 +- 8 files changed, 249 insertions(+), 32 deletions(-) create mode 100644 apps/ade-cli/src/lib/trustedWindowsTools.test.ts create mode 100644 apps/ade-cli/src/lib/trustedWindowsTools.ts diff --git a/apps/ade-cli/src/lib/trustedWindowsTools.test.ts b/apps/ade-cli/src/lib/trustedWindowsTools.test.ts new file mode 100644 index 000000000..bcc8ab9ec --- /dev/null +++ b/apps/ade-cli/src/lib/trustedWindowsTools.test.ts @@ -0,0 +1,121 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + resolveTrustedWindowsTool, + TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT, + trustedWindowsToolKernelPath, + type TrustedWindowsTool, +} from "./trustedWindowsTools"; + +describe("trusted Windows tool resolution", () => { + it("derives and validates the executable from the kernel SystemRoot alias", () => { + const kernelTool = trustedWindowsToolKernelPath("powershell"); + const canonicalRoot = String.raw`C:\Windows\System32`; + const canonicalTool = String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`; + + expect(resolveTrustedWindowsTool("powershell", { + platform: "win32", + realpathNative: (filePath) => { + if (filePath === TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT) return canonicalRoot; + if (filePath === kernelTool) return canonicalTool; + throw new Error(`unexpected path: ${filePath}`); + }, + statSync: () => ({ isFile: () => true }), + })).toBe(canonicalTool); + }); + + it("rejects a canonical tool redirected outside System32", () => { + expect(() => resolveTrustedWindowsTool("reg", { + platform: "win32", + realpathNative: (filePath) => filePath === TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT + ? String.raw`C:\Windows\System32` + : String.raw`C:\poison\reg.exe`, + statSync: () => ({ isFile: () => true }), + })).toThrow(/Refusing untrusted Windows reg executable/); + }); + + it("rejects a kernel root that does not canonicalize to System32", () => { + expect(() => resolveTrustedWindowsTool("taskkill", { + platform: "win32", + realpathNative: (filePath) => filePath === TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT + ? String.raw`C:\poison` + : String.raw`C:\poison\taskkill.exe`, + statSync: () => ({ isFile: () => true }), + })).toThrow(/Refusing untrusted Windows taskkill executable/); + }); + + it("rejects a trusted path that is not a file", () => { + expect(() => resolveTrustedWindowsTool("schtasks", { + platform: "win32", + realpathNative: (filePath) => filePath === TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT + ? String.raw`C:\Windows\System32` + : String.raw`C:\Windows\System32\schtasks.exe`, + statSync: () => ({ isFile: () => false }), + })).toThrow(/is not a file/); + }); + + const nativeWindowsTest = process.platform === "win32" ? it : it.skip; + nativeWindowsTest("ignores cwd, PATH, SystemRoot, and windir poisoning on Windows", () => { + const poisonDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-trusted-tools-poison-")); + + try { + for (const tool of ["powershell.exe", "reg.exe", "schtasks.exe", "taskkill.exe"]) { + fs.writeFileSync(path.join(poisonDir, tool), "not a Windows executable"); + } + const moduleUrl = pathToFileURL(path.resolve("src/lib/trustedWindowsTools.ts")).href; + const tsxCli = path.resolve("node_modules/tsx/dist/cli.mjs"); + const childScript = ` + import { spawnSync } from "node:child_process"; + import fs from "node:fs"; + import path from "node:path"; + import { resolveTrustedWindowsTool, TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT } from ${JSON.stringify(moduleUrl)}; + const originalSystemRoot = process.env.SystemRoot; + const originalWindir = process.env.windir; + process.env.SystemRoot = ${JSON.stringify(poisonDir)}; + process.env.windir = ${JSON.stringify(poisonDir)}; + const tools = ["powershell", "reg", "schtasks", "taskkill"]; + const canonicalRoot = fs.realpathSync.native(TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT); + const resolved = Object.fromEntries(tools.map((tool) => [tool, resolveTrustedWindowsTool(tool)])); + process.env.SystemRoot = originalSystemRoot; + process.env.windir = originalWindir; + const powershell = spawnSync( + resolved.powershell, + ["-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write('trusted')"], + { encoding: "utf8", windowsHide: true }, + ); + process.stdout.write(JSON.stringify({ + canonicalRoot, + resolved, + powershell: { status: powershell.status, stdout: powershell.stdout, error: powershell.error?.message }, + })); + `; + const child = spawnSync(process.execPath, [tsxCli, "--eval", childScript], { + cwd: poisonDir, + encoding: "utf8", + env: { + ...process.env, + PATH: poisonDir, + }, + windowsHide: true, + }); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + const result = JSON.parse(child.stdout) as { + canonicalRoot: string; + resolved: Record<TrustedWindowsTool, string>; + powershell: { status: number | null; stdout: string; error?: string }; + }; + for (const resolved of Object.values(result.resolved)) { + expect(path.win32.relative(result.canonicalRoot, resolved)).not.toMatch(/^\.\.(?:\\|$)/); + expect(resolved.toLowerCase()).not.toContain(poisonDir.toLowerCase()); + } + expect(result.powershell).toEqual({ status: 0, stdout: "trusted" }); + } finally { + fs.rmSync(poisonDir, { force: true, recursive: true }); + } + }); +}); diff --git a/apps/ade-cli/src/lib/trustedWindowsTools.ts b/apps/ade-cli/src/lib/trustedWindowsTools.ts new file mode 100644 index 000000000..44c800ce0 --- /dev/null +++ b/apps/ade-cli/src/lib/trustedWindowsTools.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type TrustedWindowsTool = "powershell" | "reg" | "schtasks" | "taskkill"; + +const TRUSTED_TOOL_RELATIVE_PATHS: Record<TrustedWindowsTool, string> = { + powershell: path.win32.join("WindowsPowerShell", "v1.0", "powershell.exe"), + reg: "reg.exe", + schtasks: "schtasks.exe", + taskkill: "taskkill.exe", +}; + +export const TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT = String.raw`\\?\GLOBALROOT\SystemRoot\System32`; + +type TrustedWindowsToolResolverDeps = { + platform?: NodeJS.Platform; + realpathNative?: (filePath: string) => string; + statSync?: (filePath: string) => { isFile(): boolean }; +}; + +const trustedToolCache = new Map<TrustedWindowsTool, string>(); + +export function trustedWindowsToolKernelPath(tool: TrustedWindowsTool): string { + return path.win32.join(TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT, TRUSTED_TOOL_RELATIVE_PATHS[tool]); +} + +/** + * Resolve an ADE-owned Windows command through the kernel's SystemRoot alias. + * + * Do not replace this with PATH, cwd, SystemRoot, or windir lookup: all four are + * caller-controlled in CLI launches. GLOBALROOT identifies the real OS tree; + * canonical-path validation then makes the returned normal Win32 path spawnable. + */ +export function resolveTrustedWindowsTool( + tool: TrustedWindowsTool, + deps: TrustedWindowsToolResolverDeps = {}, +): string { + const platform = deps.platform ?? process.platform; + if (platform !== "win32") { + // Windows-only modules are imported by cross-platform unit tests. Keep their + // rendered commands deterministic without consulting the host environment. + return trustedWindowsToolKernelPath(tool); + } + + const useCache = Object.keys(deps).length === 0; + const cached = useCache ? trustedToolCache.get(tool) : undefined; + if (cached) return cached; + + const realpathNative = deps.realpathNative ?? ((filePath: string) => fs.realpathSync.native(filePath)); + const statSync = deps.statSync ?? ((filePath: string) => fs.statSync(filePath)); + const kernelToolPath = trustedWindowsToolKernelPath(tool); + + let canonicalRoot: string; + let canonicalTool: string; + try { + canonicalRoot = realpathNative(TRUSTED_WINDOWS_SYSTEM32_KERNEL_ROOT); + canonicalTool = realpathNative(kernelToolPath); + } catch (error) { + throw new Error(`Unable to resolve trusted Windows ${tool} executable`, { cause: error }); + } + + const expectedTool = path.win32.join(canonicalRoot, TRUSTED_TOOL_RELATIVE_PATHS[tool]); + const relativeTool = path.win32.relative(canonicalRoot, canonicalTool); + const escapesRoot = relativeTool === ".." || relativeTool.startsWith(`..${path.win32.sep}`) || path.win32.isAbsolute(relativeTool); + if ( + path.win32.basename(canonicalRoot).toLowerCase() !== "system32" + || escapesRoot + || canonicalTool.toLowerCase() !== expectedTool.toLowerCase() + ) { + throw new Error(`Refusing untrusted Windows ${tool} executable: ${canonicalTool}`); + } + + let isFile = false; + try { + isFile = statSync(canonicalTool).isFile(); + } catch (error) { + throw new Error(`Unable to inspect trusted Windows ${tool} executable`, { cause: error }); + } + if (!isFile) throw new Error(`Trusted Windows ${tool} path is not a file: ${canonicalTool}`); + + if (useCache) trustedToolCache.set(tool, canonicalTool); + return canonicalTool; +} diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index 96fb7d22e..76990898d 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -29,6 +29,8 @@ import { resolveWindowsTaskUser, uninstallWindowsService, WINDOWS_POWERSHELL_COMMAND, + WINDOWS_REG_COMMAND, + WINDOWS_SCHTASKS_COMMAND, renderWindowsServiceLauncher, } from "./installWindows"; @@ -338,11 +340,14 @@ describe("Windows background service helpers", () => { launcherPath, ], }); + expect(path.win32.isAbsolute(WINDOWS_POWERSHELL_COMMAND)).toBe(true); + expect(scheduledCommand).toContain(WINDOWS_POWERSHELL_COMMAND); + expect(scheduledCommand.toLowerCase()).not.toMatch(/^powershell\.exe\b/); expect(calls).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, ]); }); @@ -373,8 +378,8 @@ describe("Windows background service helpers", () => { expect(calls.slice(0, 4)).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsEndTaskArgs(taskName) }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs(taskName) }, ]); expect(calls.at(-2)?.args).toEqual(expect.arrayContaining(["ADD", "/V", taskName])); expect(calls.at(-1)?.args).toEqual(buildWindowsStartLauncherArgs(launcherPath)); @@ -405,8 +410,8 @@ describe("Windows background service helpers", () => { expect(result.ok).toBe(true); expect(calls.slice(0, 3)).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs("ADE Runtime") }, ]); expect(calls.flatMap((call) => call.args)).not.toContain("ADE Runtime "); }); @@ -431,7 +436,7 @@ describe("Windows background service helpers", () => { expect(result.message).toContain("legacy ADE Runtime scheduled task"); expect(calls).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsEndTaskArgs("ADE Runtime") }, ]); }); @@ -503,8 +508,8 @@ describe("Windows background service helpers", () => { expect(calls).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, - expect.objectContaining({ command: "reg.exe", args: expect.arrayContaining(["ADD"]) }), + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, + expect.objectContaining({ command: WINDOWS_REG_COMMAND, args: expect.arrayContaining(["ADD"]) }), ]); }); @@ -538,12 +543,12 @@ describe("Windows background service helpers", () => { }); expect(calls).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs(taskName) }, { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, - { command: "reg.exe", args: buildWindowsRunKeyDeleteArgs(taskName) }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyDeleteArgs(taskName) }, ]); expect(fs.existsSync(launcherPath)).toBe(false); }); @@ -563,9 +568,9 @@ describe("Windows background service helpers", () => { expect(result.message).toContain("ERROR: The system cannot find the file specified."); expect(calls).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs(taskName) }, { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, - { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, ]); }); diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index bad3f3001..6ad8e08b3 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resolveTrustedWindowsTool } from "../lib/trustedWindowsTools"; import { type AdeServiceCommand, cmdQuote, @@ -38,6 +39,9 @@ export const TASK_NAME = "ADE Runtime"; export const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; const TASK_NOT_FOUND_EXIT_CODE = 3; const REGISTRY_VALUE_NOT_FOUND_EXIT_CODE = 1; +export const WINDOWS_REG_COMMAND = resolveTrustedWindowsTool("reg"); +export const WINDOWS_SCHTASKS_COMMAND = resolveTrustedWindowsTool("schtasks"); +export const WINDOWS_TASKKILL_COMMAND = resolveTrustedWindowsTool("taskkill"); type WindowsServiceManagerDeps = { command?: AdeServiceCommand; @@ -253,7 +257,7 @@ function removeWindowsTaskIfPresent( }; } if (isWindowsTaskStateRunning(query.stdout)) { - const end = run("schtasks.exe", buildWindowsEndTaskArgs(taskName), { + const end = run(WINDOWS_SCHTASKS_COMMAND, buildWindowsEndTaskArgs(taskName), { encoding: "utf8", windowsHide: true, }); @@ -264,7 +268,7 @@ function removeWindowsTaskIfPresent( }; } } - const remove = run("schtasks.exe", buildWindowsDeleteTaskArgs(taskName), { + const remove = run(WINDOWS_SCHTASKS_COMMAND, buildWindowsDeleteTaskArgs(taskName), { encoding: "utf8", windowsHide: true, }); @@ -299,7 +303,7 @@ function removeWindowsRunEntryIfPresent( launcherPath: string, pidPath: string, ): WindowsTaskRemovalResult { - const query = run("reg.exe", buildWindowsRunKeyQueryArgs(valueName), { + const query = run(WINDOWS_REG_COMMAND, buildWindowsRunKeyQueryArgs(valueName), { encoding: "utf8", windowsHide: true, }); @@ -314,7 +318,7 @@ function removeWindowsRunEntryIfPresent( const supervisor = queryWindowsSupervisor({ spawnSync: run, launcherPath, pidPath }); if (supervisor.error) return { ok: false, message: supervisor.error }; if (supervisor.running && supervisor.pid) { - const stop = run("taskkill.exe", ["/PID", String(supervisor.pid), "/T", "/F"], { + const stop = run(WINDOWS_TASKKILL_COMMAND, ["/PID", String(supervisor.pid), "/T", "/F"], { encoding: "utf8", windowsHide: true, }); @@ -330,7 +334,7 @@ function removeWindowsRunEntryIfPresent( } if (installed) { - const remove = run("reg.exe", buildWindowsRunKeyDeleteArgs(valueName), { + const remove = run(WINDOWS_REG_COMMAND, buildWindowsRunKeyDeleteArgs(valueName), { encoding: "utf8", windowsHide: true, }); @@ -431,7 +435,7 @@ export async function installWindowsService( } const command = windowsLauncherCommand(launcherPath); const registration = run( - "reg.exe", + WINDOWS_REG_COMMAND, buildWindowsRunKeyAddArgs(taskName, command), { encoding: "utf8", windowsHide: true }, ); @@ -449,7 +453,7 @@ export async function installWindowsService( windowsHide: true, }); if (start.status !== 0) { - run("reg.exe", buildWindowsRunKeyDeleteArgs(taskName), { + run(WINDOWS_REG_COMMAND, buildWindowsRunKeyDeleteArgs(taskName), { encoding: "utf8", windowsHide: true, }); @@ -619,7 +623,7 @@ export function getWindowsServiceStatus( "A legacy ADE Scheduled Task is installed, but runtime readiness cannot be verified. Run `ade brain start` to migrate it to the per-user startup supervisor.", }; } - const startupResult = run("reg.exe", buildWindowsRunKeyQueryArgs(taskName), { + const startupResult = run(WINDOWS_REG_COMMAND, buildWindowsRunKeyQueryArgs(taskName), { encoding: "utf8", windowsHide: true, }); diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts index e05c47814..359bb1e31 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { resolveTrustedWindowsTool } from "../lib/trustedWindowsTools"; import { type AdeServiceCommand, cmdQuote, @@ -7,7 +8,7 @@ import { type ServiceManagerSpawnSync, } from "./common"; -export const WINDOWS_POWERSHELL_COMMAND = "powershell.exe"; +export const WINDOWS_POWERSHELL_COMMAND = resolveTrustedWindowsTool("powershell"); export type WindowsServicePidRecord = { supervisorPid: number; diff --git a/apps/ade-cli/src/services/sync/syncHostSingleton.ts b/apps/ade-cli/src/services/sync/syncHostSingleton.ts index 91a0638ba..3ce664a56 100644 --- a/apps/ade-cli/src/services/sync/syncHostSingleton.ts +++ b/apps/ade-cli/src/services/sync/syncHostSingleton.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resolveTrustedWindowsTool } from "../../lib/trustedWindowsTools"; import { DEFAULT_SYNC_HOST_PORT, SYNC_HOST_MAX_PORT } from "./syncProtocol"; const LOCK_VERSION = 1; @@ -171,7 +172,7 @@ function defaultProcessMatchesOwner( let raw = ""; try { raw = execFileSync( - "powershell.exe", + resolveTrustedWindowsTool("powershell"), ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 65b79e624..c372666ee 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -3,6 +3,7 @@ import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { resolveTrustedWindowsTool } from "../lib/trustedWindowsTools"; import { Box, Text, useApp, useInput } from "ink"; import { getModelById, @@ -1733,10 +1734,10 @@ function readClipboardText(): string | null { const candidates = process.platform === "darwin" ? [["pbpaste"]] : process.platform === "win32" - ? [["powershell", "-NoProfile", "-Command", "Get-Clipboard"]] + ? [[resolveTrustedWindowsTool("powershell"), "-NoProfile", "-Command", "Get-Clipboard"]] : [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"]]; for (const [command, ...args] of candidates) { - if (!commandAvailable(command)) continue; + if (process.platform !== "win32" && !commandAvailable(command)) continue; const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 1024 * 1024 }); if (result.status === 0 && result.stdout.trim()) return result.stdout.trim(); } diff --git a/apps/ade-cli/src/tuiClient/imageTargets.ts b/apps/ade-cli/src/tuiClient/imageTargets.ts index b94f61804..54a0c075c 100644 --- a/apps/ade-cli/src/tuiClient/imageTargets.ts +++ b/apps/ade-cli/src/tuiClient/imageTargets.ts @@ -2,6 +2,7 @@ import path from "node:path"; import fs from "node:fs"; import { spawnSync } from "node:child_process"; import type { AgentChatEventEnvelope, AgentChatFileRef } from "../../../desktop/src/shared/types/chat"; +import { resolveTrustedWindowsTool } from "../lib/trustedWindowsTools"; const IMAGE_FILE_EXTENSION_RE = /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?|heic|heif|avif)$/i; const CLIPBOARD_MAX_BUFFER = 120 * 1024 * 1024; @@ -86,7 +87,7 @@ export function readClipboardImageAttachment(cacheRoot: string): AgentChatFileRe if (filePath) return { path: filePath, type: "image" }; } - if (process.platform === "win32" && commandAvailable("powershell")) { + if (process.platform === "win32") { const target = clipboardImageTarget(cacheRoot); if (!target) return null; const command = [ @@ -95,7 +96,7 @@ export function readClipboardImageAttachment(cacheRoot: string): AgentChatFileRe "$image = [System.Windows.Forms.Clipboard]::GetImage();", `if ($image -ne $null) { $image.Save(${powershellQuoted(target)}, [System.Drawing.Imaging.ImageFormat]::Png) }`, ].join(" "); - const result = spawnSync("powershell", ["-NoProfile", "-Command", command], { stdio: "ignore" }); + const result = spawnSync(resolveTrustedWindowsTool("powershell"), ["-NoProfile", "-Command", command], { stdio: "ignore" }); if (result.status === 0 && nonEmptyFile(target)) return { path: target, type: "image" }; } @@ -210,10 +211,10 @@ function readClipboardText(): string | null { const candidates = process.platform === "darwin" ? [["pbpaste"]] : process.platform === "win32" - ? [["powershell", "-NoProfile", "-Command", "Get-Clipboard"]] + ? [[resolveTrustedWindowsTool("powershell"), "-NoProfile", "-Command", "Get-Clipboard"]] : [["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"]]; for (const [command, ...args] of candidates) { - if (!commandAvailable(command)) continue; + if (process.platform !== "win32" && !commandAvailable(command)) continue; const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 1024 * 1024 }); if (result.status === 0 && result.stdout.trim()) return result.stdout.trim(); } From 57e6e84ce4b32c262738b2b5533d20144a0c4855 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 23:05:22 -0400 Subject: [PATCH 09/42] fix(windows): align Activity capability foundation Move platform-neutral Activity naming and native Notch gating into the foundation layer so every stacked diff is internally testable. Co-authored-by: David Whatley <nsxdavid@gmail.com> Based-on: nsxdavid/ADE#999 --- .../attentionAccountCoordinator.test.ts | 6 +- .../attention/attentionAccountCoordinator.ts | 14 +-- .../attention/attentionNotchHelper.test.ts | 14 ++- .../attention/attentionNotchHelper.ts | 2 +- .../activity/ActivitySettingsPopover.test.tsx | 8 ++ .../activity/ActivitySettingsPopover.tsx | 6 +- .../ActivitySettingsPopover.windows.test.tsx | 92 +++++++++++++++++++ .../activity/HeaderActivityControl.test.tsx | 4 +- .../settings/ActivitySection.test.tsx | 8 ++ .../settings/ActivitySettingsControls.tsx | 23 +++-- apps/desktop/src/shared/types/attention.ts | 2 +- .../ADE/Shared/AttentionActionIntents.swift | 2 +- .../Views/Activity/ActivityDrawerModel.swift | 4 +- 13 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.windows.test.tsx diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts index cba3f16a9..0c96ac9c4 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts @@ -22,7 +22,7 @@ function snapshot( generatedAt: "2026-07-29T12:00:00.000Z", machines: [{ machineKey: "machine-local", - name: "This MacBook", + name: "This computerBook", online: true, lastSeenAt: "2026-07-29T12:00:00.000Z", }], @@ -316,10 +316,10 @@ describe("AttentionAccountCoordinator", () => { state: "degraded", title: "Account session needs attention", recovery: "sign_in", - hostName: "This MacBook", + hostName: "This computerBook", }, }); - expect(result.availability?.message).toContain("Showing work from This MacBook"); + expect(result.availability?.message).toContain("Showing work from This computerBook"); expect(result.availability?.message).not.toMatch(/relay|bearer|401/i); expect(callAttention).toHaveBeenCalledWith("getMachineSnapshot", {}); expect(testLogger.warn).toHaveBeenCalledWith( diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts index 77b462d9a..cdd5f7606 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.ts @@ -134,7 +134,7 @@ export class AttentionAccountCoordinator { machine.machineKey === resolvedHostMachineKey ? { ...machine, online: true, lastSeenAt: generatedAt } : machine; - const machineName = hostMachine?.name?.trim() || "this Mac"; + const machineName = hostMachine?.name?.trim() || "this computer"; const accountAvailability = accountFailure ? this.describeAccountFailure(accountFailure) : null; @@ -176,7 +176,7 @@ export class AttentionAccountCoordinator { throw new Error( accountFailure ? ( - "Account Activity could not connect, and this Mac cannot provide a fallback. " + "Account Activity could not connect, and this computer cannot provide a fallback. " + compatibilityMessage ) : compatibilityMessage, @@ -190,11 +190,11 @@ export class AttentionAccountCoordinator { const presentation = this.describeAccountFailure(accountFailure); throw new Error( `${presentation.title}. ${presentation.message} ` - + "No safe machine-scoped fallback is available on this Mac.", + + "No safe machine-scoped fallback is available on this computer.", ); } throw new Error( - "Activity cannot reach this Mac's ADE brain. Restart ADE on this Mac, then try again.", + "Activity cannot reach this computer's ADE brain. Restart ADE on this computer, then try again.", ); } @@ -251,7 +251,7 @@ export class AttentionAccountCoordinator { ); } if (!this.options.localRuntimeConnectionPool) { - throw new Error("Machine Activity is unavailable until this Mac's ADE brain is ready."); + throw new Error("Machine Activity is unavailable until this computer's ADE brain is ready."); } await this.options.localRuntimeConnectionPool.callAttention<void>( "acknowledge", @@ -354,7 +354,7 @@ export class AttentionAccountCoordinator { return; } if (!this.options.localRuntimeConnectionPool) { - throw new Error("Account Activity is unavailable until this Mac's ADE brain is ready."); + throw new Error("Account Activity is unavailable until this computer's ADE brain is ready."); } await this.options.localRuntimeConnectionPool.callAttention<void>( "putPreferences", @@ -384,7 +384,7 @@ export class AttentionAccountCoordinator { !this.options.accountAttentionClient || !this.options.accountAttentionClient.putActivityMachinePreferences ) { - throw new Error("Account Activity is unavailable until this Mac's ADE brain is ready."); + throw new Error("Account Activity is unavailable until this computer's ADE brain is ready."); } await this.options.accountAttentionClient.putActivityMachinePreferences( accountOwnerId, diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts index ab56b52fd..984550583 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import path from "node:path"; import { PassThrough } from "node:stream"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -54,12 +55,21 @@ describe("AttentionNotchHelper", () => { isPackaged: true, resourcesPath: "/Applications/ADE.app/Contents/Resources", appPath: "/repo/apps/desktop", - })).toBe("/Applications/ADE.app/Contents/Resources/native/ade-attention-notch"); + })).toBe(path.join( + "/Applications/ADE.app/Contents/Resources", + "native", + "ade-attention-notch", + )); expect(resolveAttentionNotchExecutablePath({ isPackaged: false, resourcesPath: "/unused", appPath: "/repo/apps/desktop", - })).toBe("/repo/apps/desktop/resources/native/ade-attention-notch"); + })).toBe(path.join( + "/repo/apps/desktop", + "resources", + "native", + "ade-attention-notch", + )); }); it("publishes exact helper actions and rejects malformed output", () => { diff --git a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts index fe4170f0b..5f20b3754 100644 --- a/apps/desktop/src/main/services/attention/attentionNotchHelper.ts +++ b/apps/desktop/src/main/services/attention/attentionNotchHelper.ts @@ -203,7 +203,7 @@ export class AttentionNotchHelper { return { state: "disabled", title: "ADE Notch is off", - message: "Enable ADE Notch to show account activity on this Mac.", + message: "Enable ADE Notch to show account activity on this computer.", recovery: null, surface: null, }; diff --git a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.test.tsx b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.test.tsx index 59acc528c..d6bd23f4b 100644 --- a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.test.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.test.tsx @@ -4,6 +4,14 @@ import React from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("../../lib/platform", async () => { + const actual = await vi.importActual<typeof import("../../lib/platform")>("../../lib/platform"); + return { + ...actual, + supportsNativeNotch: true, + }; +}); + import { DEFAULT_ATTENTION_PREFERENCES } from "../../../shared/types"; import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; import { resetActivityStoreForTests } from "../../state/activityStore"; diff --git a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx index fb4376916..e596fb3db 100644 --- a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx +++ b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.tsx @@ -137,7 +137,11 @@ export function ActivitySettingsPopover() { </span> <span> <strong>Activity settings</strong> - <small>Account delivery and this Mac’s notch</small> + <small> + {model.notchSupported + ? "Account delivery and this computer’s notch" + : "Account delivery preferences"} + </small> </span> </div> <span className="activity-settings-account-badge">Account</span> diff --git a/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.windows.test.tsx b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.windows.test.tsx new file mode 100644 index 000000000..63823e742 --- /dev/null +++ b/apps/desktop/src/renderer/components/activity/ActivitySettingsPopover.windows.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../lib/platform", async () => { + const actual = await vi.importActual<typeof import("../../lib/platform")>("../../lib/platform"); + return { + ...actual, + supportsNativeNotch: false, + }; +}); + +import { DEFAULT_ATTENTION_PREFERENCES } from "../../../shared/types"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; +import { resetActivityStoreForTests } from "../../state/activityStore"; +import { ActivitySettingsPopover } from "./ActivitySettingsPopover"; + +const originalAde = window.ade; +const signedInAccount = { + signedIn: true as const, + userId: "account-windows", + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, +}; + +describe("ActivitySettingsPopover on Windows", () => { + const getPreferences = vi.fn(async () => DEFAULT_ATTENTION_PREFERENCES); + const putPreferences = vi.fn(async () => undefined); + const updateNotchSettings = vi.fn(async () => undefined); + + beforeEach(() => { + window.localStorage.clear(); + getPreferences.mockClear(); + putPreferences.mockClear(); + updateNotchSettings.mockClear(); + publishAccountStatus(signedInAccount); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { + ...(originalAde ?? {}), + account: { + ...(originalAde?.account ?? {}), + status: vi.fn(async () => signedInAccount), + }, + attention: { + getPreferences, + putPreferences, + }, + // The preload namespace may exist cross-platform. Platform capability, + // not property presence, decides whether native notch behavior runs. + attentionNotch: { + updateSettings: updateNotchSettings, + }, + }, + }); + }); + + afterEach(() => { + cleanup(); + resetActivityStoreForTests(); + publishAccountStatus(SIGNED_OUT_ACCOUNT); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); + }); + + it("hides notch controls and saves account delivery without calling the native bridge", async () => { + render(<ActivitySettingsPopover />); + + fireEvent.click(screen.getByRole("button", { name: "Activity settings" })); + await screen.findByRole("dialog", { name: "Activity settings" }); + await waitFor(() => expect(getPreferences).toHaveBeenCalledTimes(1)); + + expect(screen.queryByRole("switch", { name: "ADE notch" })).toBeNull(); + expect(screen.queryByRole("combobox", { name: "Notch behavior" })).toBeNull(); + expect(screen.getByText("Account delivery preferences")).toBeTruthy(); + expect(screen.getByRole("switch", { name: "Activity sounds" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("switch", { name: "Activity sounds" })); + + await waitFor(() => expect(putPreferences).toHaveBeenCalledTimes(1)); + expect(updateNotchSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/components/activity/HeaderActivityControl.test.tsx b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.test.tsx index 7db1cffb4..af061de99 100644 --- a/apps/desktop/src/renderer/components/activity/HeaderActivityControl.test.tsx +++ b/apps/desktop/src/renderer/components/activity/HeaderActivityControl.test.tsx @@ -405,10 +405,10 @@ describe("HeaderActivityControl", () => { snapshotScope: "machine", availability: { state: "signed_out", - title: "Showing this Mac", + title: "Showing this computer", message: "Sign in to combine Activity across every ADE machine.", recovery: "sign_in", - hostName: "This Mac", + hostName: "This computer", }, }); renderControl(); diff --git a/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx b/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx index 21fafad56..5c8833a15 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySection.test.tsx @@ -4,6 +4,14 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +vi.mock("../../lib/platform", async () => { + const actual = await vi.importActual<typeof import("../../lib/platform")>("../../lib/platform"); + return { + ...actual, + supportsNativeNotch: true, + }; +}); + import { ATTENTION_CONTRACT_VERSION, DEFAULT_ATTENTION_PREFERENCES, diff --git a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx index 8ba1dcc8c..2a0962ea5 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx @@ -30,6 +30,7 @@ import { type ActivityNotchPresentation, } from "../activity/activityNotchLocalSettings"; import { useAccountStatus } from "../../lib/account"; +import { supportsNativeNotch } from "../../lib/platform"; import { useActivityStore } from "../../state/activityStore"; import { SettingsCard, SettingsGroup, SettingsSelect, SettingsToggle } from "./primitives"; import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; @@ -67,7 +68,7 @@ const DOCK_BADGE_SCOPE_OPTIONS: { value: AttentionPreferences["account"]["dockBadgeScope"]; label: string; }[] = [ - { value: "local", label: "This Mac" }, + { value: "local", label: "This computer" }, { value: "account", label: "All machines" }, ]; @@ -177,12 +178,14 @@ export function useActivitySettings() { // localStorage stays the offline cache of record for notch presentation: // a signed-out or offline launch still opens the notch the way this Mac // last had it rather than snapping back to the shipped default. - const presentation = resolveActivityNotchPresentation(next); - writeActivityNotchEnabled(nextNotchEnabled); - writeActivityNotchPresentation(presentation); - await window.ade?.attentionNotch?.updateSettings( - activityNotchSettingsFromPreferences(next, nextNotchEnabled, presentation), - ); + if (supportsNativeNotch && activityNotchSupported()) { + const presentation = resolveActivityNotchPresentation(next); + writeActivityNotchEnabled(nextNotchEnabled); + writeActivityNotchPresentation(presentation); + await window.ade?.attentionNotch?.updateSettings( + activityNotchSettingsFromPreferences(next, nextNotchEnabled, presentation), + ); + } if (!mounted.current) return; setError(null); flashSaved(); @@ -284,7 +287,7 @@ export function useActivitySettings() { machines, notchEnabled, notchPresentation, - notchSupported: activityNotchSupported(), + notchSupported: supportsNativeNotch && activityNotchSupported(), updateAccount, toggleNotchEnabled, setNotchPresentation, @@ -470,7 +473,7 @@ export function ActivitySettingsControls({ <PopoverRow icon={DesktopTower} label="Dock badge counts" - description="Choose whether the dock badge counts this Mac or your whole account." + description="Choose whether the dock badge counts this computer or your whole account." disabled={busy} control={ <select @@ -727,7 +730,7 @@ export function ActivitySettingsControls({ <SettingsCard anchor="activity-dock-badge" title="Dock badge counts" - description="Count work waiting on this Mac, or across every machine on your account." + description="Count work waiting on this computer, or across every machine on your account." control={ <SettingsSelect ariaLabel="Dock badge counts" diff --git a/apps/desktop/src/shared/types/attention.ts b/apps/desktop/src/shared/types/attention.ts index 25a7e38c8..675cfddc9 100644 --- a/apps/desktop/src/shared/types/attention.ts +++ b/apps/desktop/src/shared/types/attention.ts @@ -293,7 +293,7 @@ export type AttentionPreferences = { }; /** - * How this Mac exposes its notch surface. Events never override the selected + * How this computer exposes its notch surface. Events never override the selected * interaction mode. * - `minimal`: keep a tiny status visible; hover or click opens a short peek. * - `hover`: stay visually dormant until the pointer enters the top-edge hot diff --git a/apps/ios/ADE/Shared/AttentionActionIntents.swift b/apps/ios/ADE/Shared/AttentionActionIntents.swift index b65313df6..33f7ab218 100644 --- a/apps/ios/ADE/Shared/AttentionActionIntents.swift +++ b/apps/ios/ADE/Shared/AttentionActionIntents.swift @@ -4,7 +4,7 @@ import Foundation /// App-intent actions used by the in-app Activity drawer. /// /// The drawer can approve or deny pending input, restart a failed session, and -/// rerun failing PR checks without pushing users back to the Mac. The intents +/// rerun failing PR checks without pushing users back to the computer. The intents /// route through `ADEIntentCommandBridge` so this file can stay shared without /// importing `SyncService`. diff --git a/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift index 627cdc3f9..c88ef102c 100644 --- a/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift +++ b/apps/ios/ADE/Views/Activity/ActivityDrawerModel.swift @@ -136,7 +136,7 @@ public final class ActivityDrawerModel: ObservableObject { public func rebuild(from snapshot: WorkspaceSnapshot) { let machine = AccountAttentionMachine( machineKey: Self.nonEmpty(snapshot.machineId) ?? "current-machine", - name: Self.nonEmpty(snapshot.machineName) ?? "Connected Mac", + name: Self.nonEmpty(snapshot.machineName) ?? "Connected computer", online: snapshot.connection.lowercased() != "disconnected", lastSeenAt: snapshot.generatedAt ) @@ -355,7 +355,7 @@ public final class ActivityDrawerModel: ObservableObject { for item in items where !item.machine.online { let scope = ActivityOfflineScope( machineKey: item.machine.machineKey, - machineName: nonEmpty(item.machine.name) ?? "Mac", + machineName: nonEmpty(item.machine.name) ?? "Computer", lastSeenAt: item.machine.lastSeenAt, projectId: item.project.projectId, laneId: nonEmpty(item.laneId) From e77c9a59d817ec430419a104b05e7babf528190c Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 23:14:08 -0400 Subject: [PATCH 10/42] test(windows): align local machine binding copy Based-on: nsxdavid/ADE#999 --- .../components/lanes/CreateLaneDialogHostBinding.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHostBinding.test.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHostBinding.test.tsx index 7acffd6b9..6d170f43b 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHostBinding.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHostBinding.test.tsx @@ -138,7 +138,7 @@ describe("CreateLaneDialogHost machine binding", () => { const view = render( <CreateLaneDialogHost open onOpenChange={vi.fn()} behavior="close-on-create" />, ); - await screen.findByText("pick:This Mac"); + await screen.findByText("pick:This computer"); view.rerender( <CreateLaneDialogHost open={false} onOpenChange={vi.fn()} behavior="close-on-create" />, From ad3e61d740ae3e6b7c6f7b7c4cfa238f0031a8a6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sat, 1 Aug 2026 23:25:22 -0400 Subject: [PATCH 11/42] fix(windows): unify local machine identity copy Based-on: nsxdavid/ADE#999 --- .../components/chat/AgentChatPane.test.tsx | 14 +++++++------- .../src/renderer/components/chat/AgentChatPane.tsx | 5 +++-- .../renderer/components/lanes/laneMachines.test.ts | 4 ++-- .../src/renderer/components/lanes/laneMachines.ts | 4 ++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index f21cac808..09fcaafb5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -5475,7 +5475,7 @@ describe("AgentChatPane submit recovery", () => { expect(screen.queryByText(/selected machine is not currently available/i)).toBeNull(); }); - it("auto-creates on This Mac from a remote-bound tab without rebinding the project", async () => { + it("auto-creates on this computer from a remote-bound tab without rebinding the project", async () => { seedRuntimeModelCatalog(); const { create } = installAdeMocks({ sessions: [] }); const onSessionCreated = vi.fn(); @@ -5501,7 +5501,7 @@ describe("AgentChatPane submit recovery", () => { const onDraftMachineChange = vi.fn(); const remoteLanes = [{ // Primary lane ids are intentionally duplicated across machines. The - // machine-qualified picker value must still route creation to This Mac. + // machine-qualified picker value must still route creation to this computer. id: "primary", name: "Primary", laneType: "primary", @@ -5526,7 +5526,7 @@ describe("AgentChatPane submit recovery", () => { crossMachineLanesByMachineId: { "this-mac": { machineId: "this-mac", - machineName: "This Mac", + machineName: "This computer", targetId: null, projectId: null, binding: localBinding, @@ -5591,7 +5591,7 @@ describe("AgentChatPane submit recovery", () => { fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); // Machine and lane are separate shelf controls now, so routing an - // auto-create launch onto This Mac is two choices rather than one + // auto-create launch onto this computer is two choices rather than one // machine-qualified row inside the lane list. const unavailableTrigger = await screen.findByRole("button", { name: /current machine unavailable; fallback Mac Studio/i, @@ -5606,7 +5606,7 @@ describe("AgentChatPane submit recovery", () => { const selectedTrigger = await screen.findByRole("button", { name: /currently Mac Studio/i }); fireEvent.click(selectedTrigger); const selectedStudioOption = await screen.findByRole("menuitemradio", { name: /Mac Studio/ }); - const thisMacOption = await screen.findByRole("menuitemradio", { name: /This Mac/ }); + const thisMacOption = await screen.findByRole("menuitemradio", { name: /This computer/ }); await waitFor(() => expect(document.activeElement).toBe(selectedStudioOption)); fireEvent.keyDown(selectedStudioOption, { key: "ArrowDown" }); expect(document.activeElement).toBe(thisMacOption); @@ -5618,7 +5618,7 @@ describe("AgentChatPane submit recovery", () => { expect(switchRemoteProject).not.toHaveBeenCalled(); expect(useAppStore.getState().projectBinding).toEqual(remoteBinding); expect(await screen.findByRole("button", { - name: "Choose machine, currently This Mac", + name: "Choose machine, currently This computer", })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Send" })); await waitFor(() => { @@ -5640,7 +5640,7 @@ describe("AgentChatPane submit recovery", () => { }, ); }); - expect(screen.queryByText(/Open this repository on This Mac first/i)).toBeNull(); + expect(screen.queryByText(/Open this repository on this computer first/i)).toBeNull(); }); it("keeps orchestrator lead mode on the first Claude draft send", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index aa022b1e7..be08f1d80 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -83,6 +83,7 @@ import { deriveDeterministicLaneTitleFromPrompt, } from "../../../shared/laneNameFallback"; import { isRuntimeTransportTimeoutError } from "../../../shared/runtimeErrors"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { LOCAL_PROVIDER_LABELS, MODEL_REGISTRY, @@ -10882,7 +10883,7 @@ export function AgentChatPane({ const draftAttachmentMachine = useMemo(() => ({ id: selectedDraftMachineId, name: selectedDraftMachine?.name ?? ( - selectedDraftMachineId === boundLaneMachineId ? "This Mac" : selectedDraftMachineId + selectedDraftMachineId === boundLaneMachineId ? THIS_MACHINE_NAME : selectedDraftMachineId ), binding: draftExecutionBinding, }), [ @@ -10944,7 +10945,7 @@ export function AgentChatPane({ ? `Switch this project tab to ${ activeComposerRuntimeBinding.kind === "remote" ? activeComposerRuntimeBinding.runtimeName - : "This Mac" + : THIS_MACHINE_NAME } before using this tool. Chat and attachments remain pinned to that machine.` : null; diff --git a/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts b/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts index aa28326a2..d6f214ebe 100644 --- a/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts @@ -66,12 +66,12 @@ beforeEach(() => { }); describe("deriveLaneMachineOptions", () => { - it("lists this Mac alone when nothing else is connected", () => { + it("lists this computer alone when nothing else is connected", () => { const options = deriveLaneMachineOptions({ connections: [], boundTargetId: null }); expect(options).toHaveLength(1); expect(options[0]?.id).toBe(THIS_MACHINE_ID); - expect(options[0]?.name).toBe("This Mac"); + expect(options[0]?.name).toBe("This computer"); expect(options[0]?.isBound).toBe(true); }); diff --git a/apps/desktop/src/renderer/components/lanes/laneMachines.ts b/apps/desktop/src/renderer/components/lanes/laneMachines.ts index 875813994..65bfa0469 100644 --- a/apps/desktop/src/renderer/components/lanes/laneMachines.ts +++ b/apps/desktop/src/renderer/components/lanes/laneMachines.ts @@ -11,7 +11,7 @@ * a machine doesn't report free-disk headroom we render the row without a size * rather than fetching one. * - * Naming rule: machines are named absolutely ("This Mac", "MacBook Pro (97)"). + * Naming rule: machines are named absolutely ("This computer", "MacBook Pro (97)"). * The word "remote" is never user-visible here — inside the create-lane dialog * it already means the git base-branch source ("Use fetched upstream"). */ @@ -54,7 +54,7 @@ export type LaneMachineProjectRef = { export type LaneMachineOption = { /** `THIS_MACHINE_ID`, or the remote-runtime target id. */ id: string; - /** Absolute machine name, e.g. "This Mac" or "MacBook Pro (97)". */ + /** Absolute machine name, e.g. "This computer" or "MacBook Pro (97)". */ name: string; /** Remote-runtime target id; null for the machine ADE runs on. */ targetId: string | null; From 4ba944148e96ea58f6615604b00f7959113df7cd Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:01:34 -0400 Subject: [PATCH 12/42] fix(workflow): let ship --stack-ready fix its own layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack-ready mode was attestation-only: it required a coordinator-created PR, refused to commit, push, open a PR, or fix red CI/review, and converted actionable feedback into a stack-coordinator-fix-required bail-out. A lane could not reach ready-stacked without a human doing its work first. Stack mode now runs the same loop as ordinary /ship — Phase 0 through Phase 5, the same poll/decide/fix machinery, the same 5-iteration budget — and stops one step short of landing. The lane commits and pushes its own layer branch, opens its PR against the resolved direct parent, polls CI and review bots, fixes red CI and verified findings on its own layer, and rebinds commit-bound quality revalidation to the exact resulting head. Reserved to the coordinator: rebases and restacks, gh stack sync/rebase/push/submit, base retargeting, and the merge. Force-finalize and every bypass-review path stay off in stack mode; the spent iteration budget escalates instead of forcing. The stack-coordinator-* states remain, re-scoped to what the lane genuinely cannot do, with a Stack escalation states table naming the exact case and the coordinator action for each. ready-stacked can no longer be recorded over a known gap. Every required proof scenario needs a link bound to the validated head, or a deferredProofScenarios entry naming a higher position and branch that exist in this stack; the top layer defers nothing, and the state-file jq check enforces it. Based-on: nsxdavid/ADE#999 (cherry picked from commit d0f922e10d292c1fd887ac19c82a8736b676def1) --- .agents/skills/ship/SKILL.md | 110 ++++++++---- docs/playbooks/ship-lane.md | 319 +++++++++++++++++++++++++---------- 2 files changed, 305 insertions(+), 124 deletions(-) diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 4d4194c2b..3d938f88f 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -7,7 +7,9 @@ description: >- only CI. Pure loop — it does not replace the baseline /quality or /test runs; run those first. It does revalidate quality after any ship-loop mutation so the final result is bound to the exact reviewed PR head and content tree. - Full phase logic lives + Opt-in --stack-ready runs the same loop for one layer of a coordinator-owned + stack: it fixes and pushes its own layer but stops at ready-stacked instead of + merging, rebasing, or running any gh stack command. Full phase logic lives in docs/playbooks/ship-lane.md. --- @@ -25,41 +27,62 @@ Print a compact status line each iteration (no banner): ship · iter 2/5 · PR #184 · POLL → DECIDE → FIX → MERGE · FIXING CI (test-desktop 3) + 2 comments ``` +Stack mode prints the layer and its terminal instead of `MERGE`: + +``` +ship · stack 12 layer 2/5 · iter 2/5 · PR #1007 · POLL → DECIDE → FIX → READY · FIXING CI (windows-foundation) + 1 comment +``` + **Invocation:** `/ship` (auto-detect state), `/ship <pr-number>`, or the opt-in `/ship --stack-ready [<pr-number>] --base <direct-parent-branch>`. ### Stack-ready mode (opt-in only) -`--stack-ready` prepares one dependent PR for its coordinator; it does not land -the stack. Resolve the direct parent from `--base`, an existing PR's +`--stack-ready` drives one layer of a coordinator-owned stack to *ready*, not to +*merged*. It is the same loop — Phase 0 through Phase 5, the same poll/fix +machinery, the same 5-iteration budget — with merging and every stack-wide +operation removed. Resolve the direct parent from `--base`, an existing PR's `baseRefName`, then non-interactive `gh stack view --json`; normalize it with the `/quality` rules. Persist `mode: "stack"` plus the complete stack binding: -stack number, position, expected parent branch, validated head SHA, base SHA, -content-tree SHA, test-evidence SHA, proof links, and quality/test status. - -After the exact head is green, review-terminal, quality-clean, test-clean, and -every proof scenario required at this PR's current stack position has a current -evidence link, write `status: "ready-stacked"` and return the binding to the -coordinator. Cumulative clean-host/cross-client/release scenarios are assigned -to the top proof PR; they do not masquerade as lower-layer evidence. Any -missing required scenario is `blocked`, never `ready-stacked` with a caveat. A -branch change, commit/rebase, or any lower-parent movement invalidates this -entry and every entry above it. Missing or ambiguous metadata is `blocked`, not -a fallback to `main`. - -**Coordinator ownership is absolute in stack mode.** Before any cap, -force-finalize, rebase, push, merge, or branch-deletion decision, branch on -`mode == "stack"`. The per-PR loop must never independently rebase a canonical -stack branch, mutate descendants, or push/submit it. It reports -`stack-coordinator-sync-required`; the coordinator alone runs the -non-interactive `gh stack sync --remote origin`, `gh stack rebase --upstack ---remote origin`, `gh stack push --remote origin`, or `gh stack submit --auto ---remote origin` workflow. Stack mode can never enter force-finalize or any -bypass-review logic. It also requires an existing coordinator-created PR and a -clean, already-tested head: it never commits, pushes, creates/updates a PR, or -fixes red CI/review on the canonical branch. Those cases return -`stack-coordinator-pr-required`, `stack-coordinator-sync-required`, or -`stack-coordinator-fix-required` with exact evidence. +stack number, size, position, expected parent branch, validated head SHA, base +SHA, content-tree SHA, test-evidence SHA, required and deferred proof scenarios, +proof links, and quality/test status. + +**The lane owns its layer; the coordinator owns the stack.** The lane commits +and pushes its own layer branch, opens its PR against the resolved direct parent +when none exists, polls CI and review bots, fixes red CI and verified findings on +its own layer, reruns commit-bound quality revalidation against the exact +resulting head, and repeats until the layer is genuinely clean. A red check on +its own code is work to do, not a reason to stop. + +The lane never merges, never enables auto-merge, never deletes a branch, never +rebases or restacks (`git rebase`, `gh stack sync --remote origin`, `gh stack +rebase --upstack --remote origin`, `gh stack push --remote origin`, and `gh +stack submit --auto --remote origin` are all coordinator-only), never retargets +a PR base, never touches another layer's branch or files, and never enters +force-finalize or any bypass-review path. Before any cap, force-finalize, +rebase, merge, or branch-deletion decision, branch on `mode == "stack"`. + +Escalate only what the lane genuinely cannot do, with exact evidence: +`stack-coordinator-sync-required` (a restack or base retarget is needed — the +parent moved, a lower layer changed, or the PR base is not the direct parent), +`stack-coordinator-fix-required` (the fix belongs to a lower layer, or the +iteration budget is spent and the layer is still red), +`stack-coordinator-pr-required` (the parent branch is missing on `origin`, or PR +creation failed on auth or an unusable base ref), and +`stack-coordinator-merged` (the coordinator already landed it). The playbook's +**Stack escalation states** table is authoritative. None of them is a general +stop at the first red check. + +Write `status: "ready-stacked"` only when the exact head is green, +review-terminal, quality-clean, test-clean, and every mandatory proof scenario +either has a current evidence link bound to the validated head or is recorded in +`deferredProofScenarios` against a named higher layer that exists in this stack. +The top layer defers nothing, and cumulative clean-host/cross-client/release +scenarios never masquerade as lower-layer evidence. A known-missing mandatory +scenario is `blocked` with the scenario ids listed — never `ready-stacked` with +a caveat. Missing or ambiguous stack metadata is `blocked`, not a fallback to +`main`. Without `--stack-ready`, every existing `/ship` default and merge behavior is unchanged: the base is `main`, green work proceeds through Phase 3c, and the @@ -73,8 +96,11 @@ terminal success state is `done-clean` only after merge confirmation. commands, decision rules, and bot-ping rules live there. This skill is the runtime-neutral entrypoint and the ADE-specific deltas below. If re-invoked by a scheduled wake, read the state file first; if `status == running`, skip Phase 0 -and go to Phase 1. If `status == ready-stacked`, print the persisted coordinator -handoff and exit without scheduling or mutating anything. +and go to Phase 1. If `status == ready-stacked`, revalidate the complete binding +first: when it holds, print the persisted coordinator handoff and exit without +scheduling or mutating anything; when it is stale, external movement exits +`stack-coordinator-sync-required` and this lane's own newer head re-enters the +loop at Phase 1. The playbook's Phase 0 is **checkpoint → commit-bound quality revalidation → push → open PR**. Baseline test generation and the local-CI gate are NOT part @@ -100,8 +126,10 @@ change this branch was not asked to make. Both need the author. available in the lane handoff, stop with `blocked`; unknown is not empty. - Any base movement, rebase, conflict resolution, Phase 3b edit, or force-finalize edit clears all three quality binding fields. In stack mode, - any such movement clears the complete stack binding and returns control to - the coordinator without rebasing or pushing. Run the + this lane's own Phase 3b edit clears them and is rebound by revalidation on + the head it then pushes; external movement of the parent, base, or head + instead clears the complete stack binding and returns + `stack-coordinator-sync-required` without rebasing. Run the playbook's single canonical **Commit-bound quality revalidation** procedure before pushing that mutation. - Never enter Phase 3c with a missing or mismatched binding. Revalidate first; @@ -153,7 +181,10 @@ Computer Use evidence is capability-specific: native OS capture/control may be explicitly blocked while App Control and proof ingestion remain supported and tested. Clean-host Stable/Beta coexistence, second-account pipe denial, restart/reboot, installed-update, and GUI artifacts remain named external proof -blockers until captured; never mark them proven from simulated tests. +blockers until captured; never mark them proven from simulated tests. A stack +entry cannot reach `ready-stacked` while any of them is required at its position +and still uncaptured — record it as `blocked` with the scenario id, or defer it +to a named higher layer in `deferredProofScenarios`. --- @@ -193,7 +224,8 @@ after quality validation: the final tree is no longer the reviewed head tree, so ordinary merge mode rebases and reruns the canonical quality procedure even when GitHub reports a clean merge. Stack mode instead invalidates the current and upstack bindings and returns `stack-coordinator-sync-required`; it never -rebases or pushes. Otherwise, skip needless rebases. +rebases and never pushes another layer, though it does push its own layer branch +in Phase 0 and Phase 3b. Otherwise, skip needless rebases. **Bot pings by iteration.** Never ping GitHub Copilot and never treat Copilot as an expected review signal; quota exhaustion otherwise leaves the loop waiting @@ -275,6 +307,10 @@ self-resume signal. Either: CI, never delete/skip tests or weaken lint/tsconfig, then merge on green. - **Phase 4/5:** post the iteration's `@codex review` ping after a fix push, update state, schedule the next wake (or stop per harness above). +- **Stack mode:** the same phases run, minus 3a, 3c.1–3c.5, and 3d. Phase 2 + routes remaining fix work to 3b and terminal-green to 3c.0 + (`ready-stacked`); the spent iteration budget escalates via + `stack-coordinator-fix-required` instead of forcing. --- @@ -282,10 +318,10 @@ self-resume signal. Either: | Status | Meaning | |--------|---------| -| `ready-stacked` | Opt-in stacked PR has a complete head/base/tree/test/proof binding with no proof blocker required at its current position; coordinator owns all stack mutation and landing | +| `ready-stacked` | Opt-in stacked layer is green, review-terminal, quality/test-clean, and every mandatory proof scenario is linked to the validated head or validly deferred to a named higher layer. The lane fixed its own layer; the coordinator owns restacking, base retargeting, submission, and landing | | `done-clean` | PR merged on main | | `done-max` | 5 normal + 1 force-finalize exhausted, merge genuinely blocked | -| `blocked` | Unrecoverable conflict, gate failure, API error, force-finalize CI failed, or a non-empty `/quality` gate awaiting an author decision | +| `blocked` | Unrecoverable conflict, gate failure, API error, force-finalize CI failed, a non-empty `/quality` gate awaiting an author decision, a missing mandatory proof scenario, or a `stack-coordinator-*` escalation | Always print the final summary (PR, branch, iterations, status, reason, per-iteration log, unaddressed items) on exit. Do NOT schedule a wake when diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md index a603521bb..64c1a5c90 100644 --- a/docs/playbooks/ship-lane.md +++ b/docs/playbooks/ship-lane.md @@ -14,20 +14,61 @@ Run this playbook once per lane, when the code on the branch is done (or nearly ### Optional stacked-PR mode -`/ship --stack-ready --base <direct-parent-branch>` is an opt-in preparation -mode for a coordinator-owned stack. Persist `mode: "stack"` and a complete -`stackBinding`. Resolve the base from the explicit flag first, an existing PR's -`baseRefName` second, and non-interactive `gh stack view --json` last. It must -be the current entry's direct parent; an ambiguous or unfetchable parent blocks -the run. Never silently substitute `main`. - -In this mode, quality and test scope use the direct parent. When the exact head -is terminal-green and the complete binding is current, set `status: -"ready-stacked"`, print the evidence, and return control to the coordinator. -Do not merge, enable auto-merge, delete branches, mutate any stack branch, -rebase, push, submit, or touch release publication flags. The coordinator alone -uses non-interactive `gh stack ... --remote origin` commands to mutate or -publish the canonical stack. +`/ship --stack-ready --base <direct-parent-branch>` is an opt-in mode for one +layer of a coordinator-owned stack. It runs the same loop as ordinary `/ship` — +Phase 0 through Phase 5, the same poll/decide/fix machinery, the same iteration +budget — and stops one step short of landing. The terminal success state is +`ready-stacked`: this layer is clean, current, and provably ready, and the +coordinator merges the whole stack atomically later. + +Persist `mode: "stack"` and a complete `stackBinding`. Resolve the base from the +explicit flag first, an existing PR's `baseRefName` second, and non-interactive +`gh stack view --json` last. It must be the current entry's direct parent; an +ambiguous or unfetchable parent blocks the run. Never silently substitute `main`. +Quality and test scope use that direct parent. + +**The lane owns its own layer. The coordinator owns the stack.** + +The lane does its own work on its own entry: + +- commit its layer's changes and push its own layer branch; +- create its PR against the resolved direct parent when none exists, and keep + that PR updated; +- poll CI and review bots on the same cadence as ordinary mode; +- fix red CI and verified review findings on its own layer, one push per + iteration; +- rerun commit-bound quality revalidation against the exact resulting head; +- repeat until its own layer is genuinely clean, then write `ready-stacked`. + +The lane never does any of the following, in any phase, at any iteration count: + +- merge, enable auto-merge, or delete a branch; +- rebase or restack anything — plain `git rebase`, `gh stack sync`, `gh stack + rebase --upstack`, `gh stack push`, and `gh stack submit` are all + coordinator-only; +- retarget a PR base, or touch another layer's branch, PR, worktree, or files; +- enter Phase 3d force-finalize, set `forceFinalize`, ignore review feedback, or + take any other bypass-review path; +- touch release publication flags. + +A red check on the lane's own layer is work to do, not a reason to stop. +Escalate only what the lane genuinely cannot do. + +#### Stack escalation states + +Each of these names an action only the coordinator can take. Use each one for +exactly the case described — never as a general stop at the first failure. + +| `exitReason` | `status` | Use when | Coordinator action | +| --- | --- | --- | --- | +| `stack-coordinator-sync-required` | `blocked` | The resolved direct parent is no longer an ancestor of HEAD, the parent head moved, a lower layer changed, or the PR base is not the resolved direct parent. The layer needs a restack or a base retarget. | `gh stack sync --remote origin`, `gh stack rebase --upstack --remote origin`, then `gh stack submit --auto --remote origin`; retarget the base when it drifted. | +| `stack-coordinator-fix-required` | `blocked` | A verified finding whose fix must land in a *lower* layer or outside this lane's diff, or the lane spent its full iteration budget and its own layer is still red. Report the exact check names, comment ids, and evidence. | Reassign the fix to the owning layer, or take the layer over. | +| `stack-coordinator-pr-required` | `blocked` | The lane could not open or update its own PR: the parent branch does not exist on `origin`, `ade`/`gh` auth failed, or GitHub refuses the base ref. | Push the parent layer or restore auth, then re-run this lane. | +| `stack-coordinator-merged` | `blocked` | The PR was already merged externally. Not a failure — the lane has nothing left to do and deliberately skips merge confirmation and branch deletion, which belong to the coordinator's atomic merge. | None; reconcile the stack. | + +`stack-coordinator-fix-required` is not a synonym for "CI is red." When the +failing check is on this layer's own code and the lane still has iteration +budget, the lane fixes it in Phase 3b and pushes. Invoking ordinary `/ship` without `--stack-ready` keeps every existing behavior in this playbook: `main` is the base and a green lane proceeds through Phase 3c @@ -36,13 +77,15 @@ until it is merged or genuinely blocked. ## Execution contract - **Autonomous.** Do not pause for user confirmation mid-loop. -- **Bounded with a force-finalize escape hatch in ordinary mode only.** Before - evaluating a cap or any force path, hard-branch on `mode == "stack"` and - return `ready-stacked` or `blocked` to the coordinator. Stack mode must never - enter force-finalize or bypass review. In ordinary merge mode, the existing - contract is unchanged: after 5 normal iterations, run the one Phase 3d - force-finalize pass and land the PR or report a genuine policy/CI block. -- **Rebase budget rebate.** A rebase, merge-from-main, or conflict-resolution pass moves the current iteration count down by 2 before the next cap check, with a floor of 0. Example: if the lane is on iteration 4 and must rebase because `main` moved, record the rebase and continue as iteration 2. +- **Bounded with a force-finalize escape hatch in ordinary mode only.** Both + modes share the 5-iteration soft cap and fix their own lane inside it. In + ordinary merge mode the existing contract is unchanged: after 5 normal + iterations, run the one Phase 3d force-finalize pass and land the PR or report + a genuine policy/CI block. Stack mode never enters Phase 3d and never bypasses + review — hard-branch on `mode == "stack"` before evaluating any force path, and + when the budget is spent with the layer still red, exit + `stack-coordinator-fix-required` with the outstanding checks and comment ids. +- **Rebase budget rebate.** A rebase, merge-from-main, or conflict-resolution pass moves the current iteration count down by 2 before the next cap check, with a floor of 0. Example: if the lane is on iteration 4 and must rebase because `main` moved, record the rebase and continue as iteration 2. Stack mode never rebases, so it earns the rebate only when the coordinator restacks the layer and the lane resumes on the new head — the lane did not spend that iteration on its own work. - **Scoped checks.** Never run the full test suite between iterations. For CI, fix and rerun only the failing test file(s) or failing check target. For review-only changes, rerun only directly affected existing tests, plus the narrow package typecheck/lint when the touched surface needs it. - **One push per iteration. Wait for BOTH signals before fixing anything.** Never push a CI-only fix while review bots are still running, and never push a review-only fix while CI is still running. Both signals must be **terminal** before the iteration commits — that is, every required check has a final conclusion AND every review bot with current-head start evidence has posted or settled. This is not just an efficiency rule: **review-comment fixes routinely introduce new CI failures**, so applying them on a partial signal means the next push fails and you've thrown away the prior CI cycle. Wait for both, then dispatch ci-fix-agent and review-fix-agent in parallel with full knowledge of both, and combine their edits into one commit. If only one signal has landed when you wake, do not iterate — reschedule and sleep. - **Absence is not a running review.** A review bot is pending only when the current head has positive start evidence: a queued/pending/in-progress check, review, trigger acknowledgement, or bot comment. Give bots one full 12-minute post-push grace window to appear. After that window, if every available ADE and GitHub surface has zero evidence for a bot, classify it as `inactive` / `not-triggered`, treat it as terminal-neutral, and continue. Put it in `inactiveReviewBots`, not `pendingReviewBots`; never schedule a second wait solely for an unobserved bot. A branch-protection rule that requires the absent check is handled later as merge policy, not invented as bot activity. @@ -120,6 +163,15 @@ These are operational mistakes this playbook explicitly guards against: means the integration is off or did not trigger for that head. Classify it inactive and continue; only explicit in-flight evidence belongs in `pendingReviewBots`. +7. **Do not escalate work the lane owns.** In stack mode, a failing check or a + valid review comment on this layer's own code is a Phase 3b iteration, not a + coordinator handoff. Reserve `stack-coordinator-*` for a restack, a base + retarget, a fix that belongs to a lower layer, or an exhausted iteration + budget. +8. **Do not record `ready-stacked` over a known gap.** A mandatory proof + scenario is satisfied by a captured artifact bound to the validated head, or + it is missing. "Ready, except the clean-host run is still outstanding" is + `blocked` with the scenario id, not a caveat on a terminal success. ## State file @@ -160,6 +212,7 @@ SHAs are abbreviated here only for readability): "stackBinding": { "stackNumber": 12, "position": 1, + "stackSize": 5, "expectedParentBranch": "main", "validatedHeadSha": "1111111111111111111111111111111111111111", "baseSha": "2222222222222222222222222222222222222222", @@ -171,6 +224,12 @@ SHAs are abbreviated here only for readability): "url": "https://github.com/example/ADE/actions/runs/123", "evidenceSha": "1111111111111111111111111111111111111111" }], + "deferredProofScenarios": [{ + "scenarioId": "clean-host-stable-beta-coexistence", + "assignedToPosition": 5, + "assignedToBranch": "codex/windows-release-proof" + }], + "missingProofScenarioIds": [], "qualityStatus": "passed", "testStatus": "passed" }, @@ -182,31 +241,56 @@ Validate the shape before accepting the terminal state, for example: ```bash jq -e ' - .stackBinding.validatedHeadSha as $head | + .stackBinding as $b | + $b.validatedHeadSha as $head | .mode == "stack" and .status == "ready-stacked" and - (.stackBinding.stackNumber | type == "number" and . > 0) and - (.stackBinding.position | type == "number" and . > 0) and - ([.stackBinding.validatedHeadSha, .stackBinding.baseSha, - .stackBinding.contentTreeSha, .stackBinding.testEvidenceSha] + ($b.stackNumber | type == "number" and . > 0) and + ($b.position | type == "number" and . > 0) and + ($b.stackSize | type == "number") and $b.position <= $b.stackSize and + ([$b.validatedHeadSha, $b.baseSha, $b.contentTreeSha, $b.testEvidenceSha] | all(test("^[0-9a-f]{40}$"))) and - (.stackBinding.expectedParentBranch | length > 0) and - (.stackBinding.requiredProofScenarioIds | type == "array") and - (.stackBinding.proofLinks | type == "array") and - ([.stackBinding.requiredProofScenarioIds[] as $scenario - | any(.stackBinding.proofLinks[]?; .scenarioId == $scenario and + ($b.expectedParentBranch | length > 0) and + ($b.requiredProofScenarioIds | type == "array" and length > 0) and + ($b.proofLinks | type == "array") and + ($b.deferredProofScenarios | type == "array") and + ([$b.requiredProofScenarioIds[] as $scenario + | any($b.proofLinks[]?; .scenarioId == $scenario and (.url | type == "string" and length > 0) and .evidenceSha == $head)] | all) and - .stackBinding.testEvidenceSha == .stackBinding.validatedHeadSha and - .stackBinding.qualityStatus == "passed" and - .stackBinding.testStatus == "passed" + ([$b.deferredProofScenarios[]? | . as $d + | ($d.scenarioId | type == "string" and length > 0) and + ($d.assignedToBranch | type == "string" and length > 0) and + ($d.assignedToPosition | type == "number") and + $d.assignedToPosition > $b.position and + $d.assignedToPosition <= $b.stackSize and + (($b.requiredProofScenarioIds | index($d.scenarioId)) == null)] | all) and + ($b.position < $b.stackSize or ($b.deferredProofScenarios | length) == 0) and + (($b.missingProofScenarioIds // []) | length) == 0 and + $b.testEvidenceSha == $b.validatedHeadSha and + $b.qualityStatus == "passed" and + $b.testStatus == "passed" ' "$STATE_FILE" ``` +The proof clauses are the machine-checkable half of Phase 3c.0: every required +scenario carries a link bound to the validated head, every deferral names a +higher position that exists in this stack, the top layer defers nothing, and +`missingProofScenarioIds` is empty. A binding that fails any of them is +`blocked`, not `ready-stacked`. + On every resume, compare the current branch and `gh stack view --json` stack -number, position, direct-parent branch/SHA, head SHA, and content tree with the -binding. Also require `testEvidenceSha == validatedHeadSha`. Any commit, rebase, -branch change, or lower-parent movement invalidates this entry and every entry -above it. Clear their bindings and return `stack-coordinator-sync-required`. +number, size, position, direct-parent branch/SHA, head SHA, and content tree +with the binding, and require `testEvidenceSha == validatedHeadSha`. Distinguish +the two kinds of movement: + +- **The lane's own push.** A commit this lane just made in Phase 0 or Phase 3b + clears the three quality binding fields and any `ready-stacked` claim, and the + lane rebinds them through the canonical revalidation procedure on the new + head. This is the normal fix loop, not an escalation. +- **External movement.** A parent-head change, a lower-layer change, a rebase or + restack the lane did not perform, a base retarget, or a head this lane did not + push invalidates this entry and every entry above it. Clear those bindings and + return `stack-coordinator-sync-required`. The `iteration` value is the active turn budget counter, not a raw count of pushes. Normal fix iterations increment it by 1. Rebase/merge/conflict recovery decrements it by 2 first, then the current pass records its result. Never let it go below 0. @@ -222,8 +306,11 @@ base, narrow test targets, and push command; they do not restate this algorithm. ancestor of the candidate head. If it is not, preserve the work, route through Phase 3a, and restart this procedure after the rebase; do not bind a behind-base tree. This works before PR creation; Phase 0 uses `main` in - ordinary mode and the persisted direct parent in stack mode. If stack mode - needs a rebase or push, stop this procedure and return coordinator action. + ordinary mode and the persisted direct parent in stack mode. In stack mode, + pushing this lane's own layer branch is part of the procedure, but rebasing + it is not: if the fetched direct parent is not an ancestor of the candidate + head, stop here and exit `stack-coordinator-sync-required` instead of routing + through Phase 3a. 2. Run both `/quality` tracks on the final combined diff, fix every accepted finding, and repeat both tracks until the same pass is clean. 3. Build the validation scope from the union of committed, unstaged, staged, @@ -289,9 +376,12 @@ Only then may state become `done-clean`. A head mismatch is ## Phase 0 — Setup (first invocation only) Skip this phase if `.ade/shipLane/<branch>.json` exists with `status: running`. -If it exists with `status: ready-stacked`, first revalidate the complete binding, -then print the persisted coordinator -handoff and exit without a poll, wake, push, rebase, merge, or branch deletion. +If it exists with `status: ready-stacked`, revalidate the complete binding +first. When it still holds, print the persisted coordinator handoff and exit +without a poll, wake, push, rebase, merge, or branch deletion. When it does not, +the binding is stale: external movement exits `stack-coordinator-sync-required`, +and this lane's own newer head re-enters the loop at Phase 1 with `status` +back to `running`. ### 0.1 Detect current state @@ -310,16 +400,29 @@ is present, its `baseRefName` must equal the resolved base. Export review only the incremental stack entry. In ordinary mode, if a PR exists for the current branch, skip to 0.4 (bot -pings) with `prNumber` captured. Stack mode follows the hard branch below. - -**Hard stack-mode branch:** stack mode is read-only with respect to the -canonical branch and PR. It requires an existing coordinator-created PR whose -head/base match the supplied binding and a clean working tree already bound to -completed `/quality` and `/test` evidence. If no PR exists, exit `blocked` with -`stack-coordinator-pr-required`. If the head/base or working tree differs, exit -with `stack-coordinator-sync-required`. Write the initial stack state and go -directly to Phase 1; do not execute 0.2–0.4, commit, push, create/update a PR, -or ping bots. The coordinator owns those mutations through `gh stack`. +pings) with `prNumber` captured. Stack mode follows the branch below. + +**Stack-mode branch:** stack mode owns its own layer branch and its own PR, and +nothing else. It runs 0.2–0.4 like any other lane: + +- **PR exists.** Require its `baseRefName` to equal the resolved direct parent. + A mismatch is `stack-coordinator-sync-required` — retargeting a base is the + coordinator's call, not the lane's. Otherwise capture `prNumber` and continue + to 0.4. +- **No PR exists.** Checkpoint, run the canonical Commit-bound quality + revalidation against the direct parent, `git push -u origin "$CURRENT_BRANCH"`, + and create the PR against `$SHIP_BASE_BRANCH`. If the parent branch is missing + on `origin`, or PR creation fails on auth or an unusable base ref, exit + `blocked` with `stack-coordinator-pr-required`. +- **Dirty working tree.** Changes that belong to this layer are committed like + any other lane. Unrelated changes still exit `blocked` with + `dirty-working-tree`. + +Write the initial stack state including the `stackBinding` (stack number, size, +position, and expected parent from `gh stack view --json`), then continue to +Phase 1. The lane still never rebases, restacks, submits, retargets a base, or +merges: pushing its own layer branch is the lane's job, and every `gh stack` +command is the coordinator's. ### 0.2 Pre-push expectation (no existing PR) @@ -408,7 +511,9 @@ Record in the state file which path was used (`prCreatedVia: "ade" | "gh"`). If ### 0.4 Post initial bot pings See Phase 4 rules. Do not ping GitHub Copilot. Add `@greptile` and `@coderabbit` -only when the diff touches more than 250 files. +only when the diff touches more than 250 files. Stack mode uses the same rules — +a stacked layer is reviewed like any other PR, and its review feedback is the +lane's to address. ### 0.5 Write initial state @@ -552,9 +657,9 @@ Pure logic on the poll summary: | Condition | Action | | --- | --- | -| `mode == "stack"` (evaluate before every ordinary row) | If externally merged, report `stack-coordinator-merged` without running merge confirmation/deletion; if any binding changed, clear this/upstack bindings and exit `stack-coordinator-sync-required`; if CI/review is pending, schedule a read-only poll; if terminal-red/actionable, exit `stack-coordinator-fix-required`; if terminal-green, go only to 3c.0. Never enter 3a, 3b, 3c.1–3c.5, or 3d. | +| `mode == "stack"` (evaluate before every ordinary row) | If externally merged, report `stack-coordinator-merged` without running merge confirmation/deletion. If the parent, base, or head moved outside this lane, clear this and upstack bindings and exit `stack-coordinator-sync-required`. If CI or review is still in flight, schedule the next wake. If both signals are terminal and fix work exists on this layer, go to **Phase 3b** and fix it. If terminal-green with nothing outstanding, go to **3c.0** only. Never enter 3a, 3c.1–3c.5, or 3d. | | `merged == true` | Run **Confirm the validated merge result**; exit `done-clean` only when it succeeds. | -| `behindBase == true` | In ordinary mode, go to Phase 3a (rebase), apply the rebase budget rebate, then schedule/poll according to Phase 5. | +| `behindBase == true` | In ordinary mode, go to Phase 3a (rebase), apply the rebase budget rebate, then schedule/poll according to Phase 5. In stack mode this is external movement and the stack row above already routed it to `stack-coordinator-sync-required`. | | `ciRunning == true` OR `reviewBotsRunning == true` | Do NOT iterate on a partial signal. Go to Phase 5 (schedule next wake). This applies even if the other signal already shows failures/comments — pushing a fix now means the next CI+review cycle races the fix and you likely re-push for the other half. | | `ciFailed` empty, `newComments` empty, `ciRunning == false`, `reviewBotsRunning == false` | Go to **Phase 3c**. Done-clean does not mean "stop and leave for human" — it means everything is green, and the lane should land on `main`. | | Otherwise (both signals terminal, fix work exists) | Go to Phase 3b (fix). Fix CI failures and review comments **in the same iteration / same push**. | @@ -563,13 +668,15 @@ Pure logic on the poll summary: ## Phase 3a — Rebase / merge -**Hard stack-mode branch:** if `SHIP_MODE=stack`, do not execute any command in -this phase. Clear the current and upstack bindings, set `status: "blocked"` and +**Coordinator-only in stack mode:** if `SHIP_MODE=stack`, do not execute any +command in this phase. Clear the current and upstack bindings, set `status: "blocked"` and `exitReason: "stack-coordinator-sync-required"`, and tell the coordinator to inspect `gh stack view --json` then use the appropriate non-interactive `gh stack sync --remote origin` or `gh stack rebase --upstack --remote origin`, -followed by `gh stack submit --auto --remote origin`. The per-PR loop never -rebases or pushes a canonical stack branch and never mutates descendants. +followed by `gh stack submit --auto --remote origin`. The lane never rebases its +own layer branch and never mutates another layer. It does push its own branch in +Phase 0 and Phase 3b — fast-forward pushes of its own commits only, never a +force push and never a restack. ```bash git fetch origin @@ -640,11 +747,20 @@ Post bot pings (Phase 4), update state (Phase 5), and schedule the next wake. Do ## Phase 3b — Fix -**Hard stack-mode branch:** if `SHIP_MODE=stack`, do not execute this phase. -Set `status: "blocked"` and `exitReason: "stack-coordinator-fix-required"`, -return the failing checks/actionable comments, and leave all branch/PR mutation -to the coordinator. Stack mode never dispatches fix agents, commits, pushes, or -mutates descendants. +**Stack mode runs this phase.** Fixing red CI and verified review findings on +its own layer is the lane's job, not the coordinator's, and a failing check is +not by itself a handoff. The scope rules: + +- Edit only files inside this layer's diff against its direct parent. +- If the real fix belongs in a *lower* layer, do not reach into it. Exit + `blocked` with `stack-coordinator-fix-required`, naming the owning layer, the + file, and the evidence. +- Commit and push this layer branch only — no rebase, no force push, no + `gh stack` command, no base change. +- Run the canonical Commit-bound quality revalidation with the direct parent as + the base, so the new binding is bound to the head you just pushed. +- Clear any `ready-stacked` claim on push. The layer is `running` again until + the new head proves clean. ### 3b.1 Parse failed CI @@ -747,23 +863,45 @@ ingestion remain supported and independently tested. Clean-host Stable/Beta coexistence, second-account pipe denial, reboot/restart recovery, signed/installed update proof, and real GUI captures are external host evidence; list missing artifacts as blockers rather than claiming them from mocks. -Cumulative full-system scenarios are assigned to the top proof PR. A missing -scenario required at the current stack position is `blocked`, never -`ready-stacked`. +Cumulative full-system scenarios are assigned to the top proof PR, and a lower +layer records that assignment in `deferredProofScenarios` rather than dropping +the scenario. A scenario required at the current stack position and still +uncaptured is `blocked`, never `ready-stacked`. ### 3c.0 Finish stack-ready mode -If `SHIP_MODE=stack`, validate the full `stackBinding`: stack number, position, -expected parent branch, parent SHA, PR head SHA, content-tree SHA, -test-evidence SHA, proof links, and passed quality/test status. Required -CI/review evidence must be terminal. Every proof scenario required at this -stack position must have a link whose evidence SHA equals the validated head; -otherwise set `status: "blocked"` and report the missing scenario IDs. Only -then set `status: "ready-stacked"`, retain the state file, print the full -binding, and return -to the stack coordinator. Do not execute 3c.1–3c.5 or Phase 3d. A moved branch, -parent, head, or lower layer invalidates this and all higher bindings and exits -`stack-coordinator-sync-required`; the per-PR loop does not rebase or push. +This is the only terminal success path in stack mode, and it does not merge. It +runs after the lane has already fixed everything it owns — not instead of that +work. + +Validate the full `stackBinding`: stack number, size, position, expected parent +branch, parent SHA, PR head SHA, content-tree SHA, test-evidence SHA, proof +links, deferrals, and passed quality/test status. Every required CI check and +every review bot with current-head evidence must be terminal, with no failing +required check and no unaddressed actionable comment. + +Proof is the part that cannot be waived: + +- Every scenario in `requiredProofScenarioIds` needs a proof link whose + `evidenceSha` equals `validatedHeadSha`. A link bound to an older head is + missing proof, not partial proof. +- A scenario is deferred only by an entry in `deferredProofScenarios` naming an + `assignedToPosition` higher than this layer's and an `assignedToBranch` that + exists in this stack. The top layer (`position == stackSize`) defers nothing. +- A scenario that is neither currently linked nor validly deferred is missing. + Set `status: "blocked"`, list the scenario ids, and stop. `ready-stacked` + cannot be recorded while a mandatory scenario is known-missing, with or + without a caveat in the summary. +- Clean-host Stable/Beta coexistence, second-account pipe denial, + reboot/restart recovery, signed installed-update proof, and real GUI captures + are external host evidence. Each is proven by a captured artifact or it is + missing; a passing simulated or mocked test never satisfies one. + +Only when all of that holds, set `status: "ready-stacked"`, retain the state +file, print the full binding, and hand off to the coordinator. Do not execute +3c.1–3c.5 or Phase 3d. Later external movement of this branch, its parent, its +head, or a lower layer invalidates this and every higher binding and exits +`stack-coordinator-sync-required`; the lane does not rebase. ### 3c.1 Resolve repo merge style @@ -822,9 +960,11 @@ Do NOT schedule another wake-up. Runs at most once per lane, only when iteration 5 has just completed and the PR is still not merged. The point of this phase is to **land** the lane — review feedback is intentionally bypassed; CI must end green. **Hard precondition before every other check:** `SHIP_MODE` must equal `merge`. -If it equals `stack`, return to Phase 3c.0 or exit -`stack-coordinator-sync-required`. Stack mode must never set `forceFinalize`, -ignore review feedback, or enter any bypass-review path. +Stack mode never enters this phase, never sets `forceFinalize`, and never +ignores review feedback. A stack lane that reaches the cap with its own layer +still red exits `blocked` with `stack-coordinator-fix-required` and the +outstanding check names and comment ids; it does not force anything. A stack +lane that reaches the cap green routes to Phase 3c.0 instead. ### 3d.1 Preconditions @@ -947,9 +1087,14 @@ These are separate comments (not a single body) so each bot handler parses its o ### 5.2 Decide exit vs next wake -- `mode == "stack"` → before evaluating iteration caps or `forceFinalize`, run - Phase 3c.0. Return `ready-stacked` when the complete binding is current; - otherwise exit `stack-coordinator-sync-required`. Never enter Phase 3d. +- `mode == "stack"` → never evaluate `forceFinalize` and never enter Phase 3d. + When the layer is terminal-green, run Phase 3c.0: `ready-stacked` if the + complete binding and all mandatory proof are current, `blocked` with the + missing scenario ids otherwise. When fix work remains and `iteration < 5`, + keep looping through Phase 3b and schedule the next wake. At `iteration >= 5` + with the layer still red, exit `blocked` with + `stack-coordinator-fix-required`. External movement at any point exits + `stack-coordinator-sync-required`. - `merged == true` → run **Confirm the validated merge result**; set `done-clean` only when it succeeds. - `iteration >= 5` AND `forceFinalize` unset/false AND not merged → run Phase 3d (force-finalize) on the next wake's fix turn. Do not exit; the cap is not a stop sign, it's a "land it now" trigger that switches the loop into review-ignoring CI-only mode. - `forceFinalize == true` AND CI green AND not merged → route immediately through Phase 3c. Only if Phase 3c has no authorized direct/admin path do you set `status: done-max` and leave a handoff comment. @@ -982,10 +1127,10 @@ The cadence is a hint, not a live polling budget. Prefer longer sleeps over freq | status | meaning | next action | | --- | --- | --- | -| `ready-stacked` | Opt-in stacked PR has a complete current head/base/tree/test/proof binding; no mutation or merge was attempted | retain state and return the binding to the stack coordinator | +| `ready-stacked` | Stacked layer is green, review-terminal, quality-clean, test-clean, and every mandatory proof scenario is linked to the validated head or validly deferred to a named higher layer. The lane fixed its own layer; it never merged, rebased, retargeted a base, or ran a `gh stack` command | retain state and return the binding to the stack coordinator | | `done-clean` | PR merged on `main` (Phase 3c succeeded, possibly after Phase 3d force-finalize) | clear state file; print summary | | `done-max` | 5 normal iterations + 1 force-finalize iteration exhausted AND Phase 3c has no authorized direct/admin merge path | leave state file; post PR handoff comment to human | -| `blocked` | Unrecoverable conflict, missing/non-empty quality gate, API error, or `force-finalize-ci-failed` (iteration 6 could not turn CI green) | leave state file; post PR comment with reason | +| `blocked` | Unrecoverable conflict, missing/non-empty quality gate, API error, `force-finalize-ci-failed` (iteration 6 could not turn CI green), a missing mandatory proof scenario, or any **Stack escalation state** | leave state file; post PR comment with reason | ## Summary output (always print on exit) From cb02fce217f2439e6b65a13695b8d2dbdad1db0d Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:06:15 -0400 Subject: [PATCH 13/42] fix(windows): keep legacy runtime migration inside its own channel The pre-channel Windows installer registered a single global "ADE Runtime" scheduled task. Install and uninstall both removed that name unconditionally before touching the channel-scoped task, so a Beta install, repair, or uninstall would end and delete a Stable channel's legacy runtime task -- a side-by-side isolation violation that silently kills a running Stable brain. Legacy migration is still needed, so gate it on ownership instead of dropping it: query the legacy task's actions and migrate only when its Execute path and CLI entry match the current channel's serve command. A legacy action never carried the runtime environment, so ADE_HOME is not recoverable from it; the packaged executable plus entry script is the ownership evidence that survives. Requiring both matters because development builds of every channel share process.execPath. When the action cannot be read the operation fails rather than guessing, and a foreign legacy task is left running untouched. The existing migration test asserted the broken behavior; it now pins the owned-task path, and new regressions assert the recorded schtasks argv for a Beta install and uninstall that find a Stable-owned legacy task. Based-on: nsxdavid/ADE#999 (cherry picked from commit d094524ef1662382ac09a3160584c3e7a2ea4beb) --- .../src/serviceManager/installWindows.test.ts | 175 +++++++++++++++++- .../src/serviceManager/installWindows.ts | 98 ++++++++++ 2 files changed, 270 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index 76990898d..b7b5bda20 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -15,6 +15,7 @@ import { buildWindowsCreateTaskArgs, buildWindowsDeleteTaskArgs, buildWindowsEndTaskArgs, + buildWindowsQueryTaskActionArgs, buildWindowsQueryTaskArgs, buildWindowsRunKeyAddArgs, buildWindowsRunKeyDeleteArgs, @@ -23,6 +24,7 @@ import { buildWindowsStartLauncherArgs, getWindowsServiceStatus, installWindowsService, + isWindowsLegacyTaskOwnedByCommand, readWindowsServicePidRecord, resolveWindowsServiceLauncherPath, resolveWindowsTaskName, @@ -31,6 +33,7 @@ import { WINDOWS_POWERSHELL_COMMAND, WINDOWS_REG_COMMAND, WINDOWS_SCHTASKS_COMMAND, + WINDOWS_TASK_ACTION_FIELD_SEPARATOR, renderWindowsServiceLauncher, } from "./installWindows"; @@ -87,6 +90,22 @@ describe("Windows background service helpers", () => { readPidRecord: () => readyPidRecord, readinessProbe: () => ({ ready: true, diagnostic: "ready" }), }; + // Get-ScheduledTask reports each action as an Execute/Arguments pair; the + // helper emits them unit-separator delimited in that order. + function taskActionOutput(execute: string, argumentsText: string): string { + return [execute, argumentsText].join(WINDOWS_TASK_ACTION_FIELD_SEPARATOR); + } + // A legacy `ADE Runtime` task that this Beta install created before the + // channel-scoped naming scheme existed. + const ownLegacyAction = taskActionOutput( + "C:\\Program Files\\ADE\\ade.exe", + "\"C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs\" \"serve\"", + ); + // A legacy `ADE Runtime` task owned by a side-by-side Stable install. + const stableLegacyAction = taskActionOutput( + "C:\\Program Files\\ADE Stable\\ADE.exe", + "\"C:\\Program Files\\ADE Stable\\resources\\ade-cli\\cli.cjs\" \"serve\"", + ); it("builds schtasks create, run, query, and delete arguments without invoking schtasks", () => { const renderedCommand = renderWindowsCommand({ @@ -385,10 +404,11 @@ describe("Windows background service helpers", () => { expect(calls.at(-1)?.args).toEqual(buildWindowsStartLauncherArgs(launcherPath)); }); - it("ends and deletes only the exact legacy task before installing the channel task", async () => { + it("ends and deletes only the exact legacy task it owns before installing the channel task", async () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: ownLegacyAction, stderr: "" }, { status: 0, stdout: "SUCCESS: ended", stderr: "" }, { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, { status: 3, stdout: "", stderr: "" }, @@ -408,18 +428,161 @@ describe("Windows background service helpers", () => { }); expect(result.ok).toBe(true); - expect(calls.slice(0, 3)).toEqual([ + expect(calls.slice(0, 4)).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskActionArgs("ADE Runtime") }, { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsEndTaskArgs("ADE Runtime") }, { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs("ADE Runtime") }, ]); expect(calls.flatMap((call) => call.args)).not.toContain("ADE Runtime "); }); + it("leaves another channel's legacy scheduled task running when Beta installs", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: stableLegacyAction, stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + { status: 0, stdout: "The operation completed successfully.", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join( + makeTempHome("ade-windows-service-foreign-legacy-"), + "brain-service.ps1", + ); + const scheduledCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); + + const result = await installWindowsService({ + ...immediateReadiness, + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + // The Stable brain is never ended or deleted: no schtasks call names the + // global legacy task, and the recorded argv proves it. + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskActionArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, + ]); + expect(calls.filter((call) => call.command === WINDOWS_SCHTASKS_COMMAND)).toEqual([]); + expect(calls.some((call) => call.args.includes("ADE Runtime") && call.args.includes("/End"))) + .toBe(false); + expect(calls.some((call) => call.args.includes("ADE Runtime") && call.args.includes("/Delete"))) + .toBe(false); + }); + + it("leaves another channel's legacy scheduled task running when Beta uninstalls", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: stableLegacyAction, stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + ]); + const launcherPath = path.join( + makeTempHome("ade-windows-service-foreign-legacy-uninstall-"), + "brain-service.ps1", + ); + fs.writeFileSync(launcherPath, "old launcher", "utf8"); + + const result = uninstallWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskActionArgs("ADE Runtime") }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, + ]); + expect(calls.filter((call) => call.command === WINDOWS_SCHTASKS_COMMAND)).toEqual([]); + expect(fs.existsSync(launcherPath)).toBe(false); + }); + + it("fails the install instead of guessing when the legacy task action cannot be read", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 4, stdout: "", stderr: "ERROR: access is denied" }, + ]); + const launcherPath = path.join( + makeTempHome("ade-windows-service-legacy-owner-fail-"), + "brain-service.ps1", + ); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(false); + expect(result.message).toBe( + "Unable to query the legacy ADE Runtime scheduled task: ERROR: access is denied", + ); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskActionArgs("ADE Runtime") }, + ]); + }); + + it("recognises only the current channel install as the legacy task owner", () => { + expect(isWindowsLegacyTaskOwnedByCommand(ownLegacyAction, serviceCommand)).toBe(true); + expect(isWindowsLegacyTaskOwnedByCommand(stableLegacyAction, serviceCommand)).toBe(false); + expect(isWindowsLegacyTaskOwnedByCommand("", serviceCommand)).toBe(false); + expect(isWindowsLegacyTaskOwnedByCommand(null, serviceCommand)).toBe(false); + // Same executable, different packaged CLI entry: still not ours. Development + // builds of every channel share process.execPath. + expect(isWindowsLegacyTaskOwnedByCommand( + taskActionOutput( + "C:\\Program Files\\ADE\\ade.exe", + "\"C:\\Program Files\\ADE Stable\\resources\\ade-cli\\cli.cjs\" \"serve\"", + ), + serviceCommand, + )).toBe(false); + // Quoting and path-separator differences must not defeat the match. + expect(isWindowsLegacyTaskOwnedByCommand( + taskActionOutput( + "\"C:/Program Files/ADE/ADE.EXE\"", + "\"C:/Program Files/ADE/resources/ade-cli/cli.cjs\" \"serve\"", + ), + serviceCommand, + )).toBe(true); + }); + it("does not register or start a channel task when the running legacy task cannot be ended", async () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: ownLegacyAction, stderr: "" }, { status: 1, stdout: "", stderr: "ERROR: access is denied" }, ]); const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-fail-"), "brain-service.ps1"); @@ -436,6 +599,7 @@ describe("Windows background service helpers", () => { expect(result.message).toContain("legacy ADE Runtime scheduled task"); expect(calls).toEqual([ { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskActionArgs("ADE Runtime") }, { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsEndTaskArgs("ADE Runtime") }, ]); }); @@ -519,6 +683,7 @@ describe("Windows background service helpers", () => { { status: 0, stdout: "Ready", stderr: "" }, { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: ownLegacyAction, stderr: "" }, { status: 0, stdout: "SUCCESS: ended", stderr: "" }, { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, { status: 0, stdout: "startup value", stderr: "" }, @@ -528,6 +693,7 @@ describe("Windows background service helpers", () => { fs.writeFileSync(launcherPath, "old launcher", "utf8"); const result = uninstallWindowsService({ + command: serviceCommand, launcherPath, serviceName, spawnSync, @@ -545,6 +711,7 @@ describe("Windows background service helpers", () => { { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs(taskName) }, { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskActionArgs("ADE Runtime") }, { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsEndTaskArgs("ADE Runtime") }, { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs("ADE Runtime") }, { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, @@ -633,11 +800,13 @@ describe("Windows background service helpers", () => { }> = []; const results: ServiceManagerProcessResult[] = [ { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: ownLegacyAction, stderr: "" }, { status: 0, stdout: "", stderr: "" }, { status: 0, stdout: "", stderr: "" }, { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, { status: 0, stdout: "", stderr: "" }, - { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, ]; const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { calls.push({ command, args, options }); diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 6ad8e08b3..1f5407d60 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -8,6 +8,7 @@ import { type AdeServiceCommand, cmdQuote, renderWindowsCommand, + resolveAdeServeCliScriptPath, resolveAdeServeCommand, resolveRuntimeServiceName, serviceManagerResultText, @@ -185,6 +186,72 @@ export function buildWindowsQueryTaskArgs( return ["-NoProfile", "-NonInteractive", "-Command", query]; } +/** Delimits the Execute/Arguments fields emitted by the task action query. */ +export const WINDOWS_TASK_ACTION_FIELD_SEPARATOR = "\u001f"; + +/** + * The pre-channel installer registered a single global `ADE Runtime` task whose + * name carried no channel or user identity, so the only durable ownership + * evidence it left behind is its action: the packaged executable plus CLI entry + * of the channel that created it. (Legacy actions never carried the runtime + * environment, so `ADE_HOME` is not recoverable from them.) This query returns + * the Execute/Arguments pair of every action, unit-separator delimited, so + * migration can prove the task belongs to the current install before ending it. + */ +export function buildWindowsQueryTaskActionArgs( + taskName = resolveWindowsTaskName(), +): string[] { + const taskNameLiteral = powerShellSingleQuotedLiteral(taskName); + const query = [ + "$ErrorActionPreference = 'Stop'", + `try { $task = Get-ScheduledTask -TaskPath '\\' -ErrorAction Stop | Where-Object { $_.TaskName -eq ${taskNameLiteral} } | Select-Object -First 1 } catch { [Console]::Error.Write($_.Exception.Message); exit 4 }`, + `if ($null -eq $task) { exit ${TASK_NOT_FOUND_EXIT_CODE} }`, + "$separator = [string][char]31", + "$fields = @($task.Actions | ForEach-Object { [string]$_.Execute; [string]$_.Arguments })", + "[Console]::Out.Write($fields -join $separator)", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; +} + +function normalizeWindowsPathText(value: string): string { + return value + .trim() + .replace(/^"+|"+$/g, "") + .replace(/\//g, "\\") + .replace(/\\+$/, "") + .toLowerCase(); +} + +/** + * True when a legacy task action launches *this* channel's install. Both the + * executable and the CLI entry must match, because development builds of every + * channel share `process.execPath`; requiring the entry script as well keeps a + * Beta operation from claiming a Stable task that happens to run the same Node. + */ +export function isWindowsLegacyTaskOwnedByCommand( + output: string | Buffer | null | undefined, + command: AdeServiceCommand, +): boolean { + const text = Buffer.isBuffer(output) ? output.toString("utf8") : output ?? ""; + if (!text.trim()) return false; + const executable = normalizeWindowsPathText(command.command); + if (!executable) return false; + const cliScript = normalizeWindowsPathText(resolveAdeServeCliScriptPath(command)); + const fields = text.split(WINDOWS_TASK_ACTION_FIELD_SEPARATOR); + for (let index = 0; index + 1 < fields.length; index += 2) { + const execute = normalizeWindowsPathText(fields[index] ?? ""); + if (execute !== executable) continue; + // An absolute entry path can only appear at a real token boundary, because + // its drive prefix cannot occur mid-token. + if (cliScript && cliScript !== executable) { + const args = normalizeWindowsPathText(fields[index + 1] ?? ""); + if (!args.includes(cliScript)) continue; + } + return true; + } + return false; +} + export function buildWindowsDeleteTaskArgs( taskName = resolveWindowsTaskName(), ): string[] { @@ -241,6 +308,13 @@ function removeWindowsTaskIfPresent( run: ServiceManagerSpawnSync, taskName: string, description: string, + /** + * Present only for the unnamespaced legacy task. Channel task names already + * encode the channel and the Windows principal, so they need no extra proof + * of ownership; the legacy name is global and shared across every channel on + * the machine, so it must not be touched without one. + */ + ownedBy?: AdeServiceCommand, ): WindowsTaskRemovalResult { const query = run( WINDOWS_POWERSHELL_COMMAND, @@ -256,6 +330,27 @@ function removeWindowsTaskIfPresent( message: `Unable to query the ${description}: ${serviceManagerResultText(query) || "PowerShell task query failed."}`, }; } + if (ownedBy) { + const action = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskActionArgs(taskName), + { encoding: "utf8", windowsHide: true }, + ); + if (action.status === TASK_NOT_FOUND_EXIT_CODE) { + return { ok: true, removed: false }; + } + if (action.status !== 0) { + return { + ok: false, + message: `Unable to query the ${description}: ${serviceManagerResultText(action) || "PowerShell task action query failed."}`, + }; + } + // Another channel's always-on brain. Leaving it running is the whole point + // of side-by-side isolation; `ade brain status` still reports it. + if (!isWindowsLegacyTaskOwnedByCommand(action.stdout, ownedBy)) { + return { ok: true, removed: false }; + } + } if (isWindowsTaskStateRunning(query.stdout)) { const end = run(WINDOWS_SCHTASKS_COMMAND, buildWindowsEndTaskArgs(taskName), { encoding: "utf8", @@ -394,6 +489,7 @@ export async function installWindowsService( run, TASK_NAME, "legacy ADE Runtime scheduled task", + serviceCommand, ); if (!legacyRemoval.ok) { return { @@ -502,6 +598,7 @@ export async function installWindowsService( export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; const env = deps.env ?? process.env; + const serviceCommand = deps.command ?? resolveAdeServeCommand(); const serviceName = resolvedServiceName(deps); let userName: string; try { @@ -529,6 +626,7 @@ export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): S run, TASK_NAME, "legacy ADE Runtime scheduled task", + serviceCommand, ); const startupRemoval = removeWindowsRunEntryIfPresent( run, From b1188a85d875918b28885f7f37303efed78a84fa Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:15:16 -0400 Subject: [PATCH 14/42] fix(windows): surface legacy runtime tasks in brain status getWindowsServiceStatus claimed "A legacy ADE Scheduled Task is installed... Run `ade brain start` to migrate it", but it only ever queried the channel-scoped task name, never the global pre-channel "ADE Runtime" task. The message could not fire for the situation it described. This matters more now that legacy migration is ownership-gated: a legacy task belonging to another install is deliberately left running, and until now no command anywhere told the user it was there. Status now probes the global task on the otherwise-"not installed" path, where the answer is actionable and no extra spawn is added to a healthy channel, and reuses isWindowsLegacyTaskOwnedByCommand to separate three real states: a task this channel owns (migratable via `ade brain start`), one belonging to a different ADE install (left running on purpose), and one whose owner could not be determined. The pre-existing channel-scoped message now says so explicitly so the two cannot be confused. The probe is supplementary, so its failure degrades to "no legacy task detected" instead of turning an answerable status into an error. Based-on: nsxdavid/ADE#999 (cherry picked from commit f2349cc170b12789796f217f5fd73a1f7ba4a91e) --- .../src/serviceManager/installWindows.test.ts | 107 ++++++++++++++++++ .../src/serviceManager/installWindows.ts | 60 +++++++++- 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index b7b5bda20..ce1fb4f89 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -831,13 +831,117 @@ describe("Windows background service helpers", () => { expect(calls.every((call) => call.options?.windowsHide === true)).toBe(true); }); + it("reports a legacy global runtime task this channel owns as migratable", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const status = getWindowsServiceStatus({ + command: serviceCommand, + serviceName, + spawnSync: spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "Ready", stderr: "" }, + { status: 0, stdout: ownLegacyAction, stderr: "" }, + ]), + userName: taskUser, + }); + + expect(status).toMatchObject({ + ok: true, + installed: true, + running: false, + path: "ADE Runtime", + }); + expect(status.message).toBe( + "A legacy ADE Runtime scheduled task from a pre-channel install belongs to this channel, " + + "but runtime readiness cannot be verified for it. Run `ade brain start` to migrate it to " + + "the per-user startup supervisor.", + ); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskActionArgs("ADE Runtime") }, + ]); + }); + + it("reports another install's legacy global runtime task without claiming it", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const status = getWindowsServiceStatus({ + command: serviceCommand, + serviceName, + spawnSync: spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: stableLegacyAction, stderr: "" }, + ]), + userName: taskUser, + }); + + expect(status).toMatchObject({ + ok: true, + installed: false, + running: false, + path: taskName, + }); + expect(status.message).toBe( + "ADE background service startup entry is not installed for this channel. A legacy ADE " + + "Runtime scheduled task belongs to a different ADE install and was left running. Run " + + "`ade brain start` to install this channel's startup entry, and uninstall the other ADE " + + "to clear its legacy task.", + ); + expect(calls.at(-1)?.args).toEqual(buildWindowsQueryTaskActionArgs("ADE Runtime")); + }); + + it("does not claim ownership when the legacy global task action cannot be read", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const status = getWindowsServiceStatus({ + command: serviceCommand, + serviceName, + spawnSync: spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 4, stdout: "", stderr: "ERROR: access is denied" }, + ]), + userName: taskUser, + }); + + expect(status).toMatchObject({ ok: true, installed: false, running: false }); + expect(status.message).toContain("its owning install could not be determined"); + }); + + it("keeps status answerable when the legacy global task probe itself fails", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const status = getWindowsServiceStatus({ + command: serviceCommand, + serviceName, + spawnSync: spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 4, stdout: "", stderr: "PowerShell unavailable" }, + ]), + userName: taskUser, + }); + + expect(status).toMatchObject({ ok: true, installed: false, running: false }); + expect(status.message).toBe("ADE background service startup entry is not installed."); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + ]); + }); + it("distinguishes an absent task from a failed locale-independent status query", () => { const absentCalls: Array<{ command: string; args: string[] }> = []; const absent = getWindowsServiceStatus({ + command: serviceCommand, serviceName, spawnSync: spawnSequence(absentCalls, [ { status: 3, stdout: "", stderr: "" }, { status: 1, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, ]), userName: taskUser, }); @@ -852,6 +956,9 @@ describe("Windows background service helpers", () => { }); expect(absent).toMatchObject({ ok: true, installed: false, running: false }); + expect(absent.message).toBe("ADE background service startup entry is not installed."); + // The failing query short-circuits before the supplementary legacy probe. + expect(failedCalls).toHaveLength(2); expect(failed).toMatchObject({ ok: false, installed: null, running: null }); }); diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 1f5407d60..52b4c0c4c 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -668,6 +668,38 @@ export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): S }; } +type WindowsLegacyTaskProbe = + | { present: false } + | { present: true; owned: boolean | null }; + +/** + * Looks for the global pre-channel `ADE Runtime` task and decides whether it + * belongs to this install. Install deliberately leaves a foreign legacy task + * running, so status is the only place a user can learn that one exists. + * + * This is a supplementary diagnostic on an already-answered status, so a probe + * failure degrades to "no legacy task detected" rather than failing the whole + * status call. + */ +function probeGlobalLegacyTask( + run: ServiceManagerSpawnSync, + command: AdeServiceCommand, +): WindowsLegacyTaskProbe { + const query = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsQueryTaskArgs(TASK_NAME), { + encoding: "utf8", + windowsHide: true, + }); + if (query.status !== 0) return { present: false }; + const action = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsQueryTaskActionArgs(TASK_NAME), { + encoding: "utf8", + windowsHide: true, + }); + if (action.status === TASK_NOT_FOUND_EXIT_CODE) return { present: false }; + // Present, but we cannot read its action: never claim it either way. + if (action.status !== 0) return { present: true, owned: null }; + return { present: true, owned: isWindowsLegacyTaskOwnedByCommand(action.stdout, command) }; +} + export function getWindowsServiceStatus( deps: Pick< WindowsServiceManagerDeps, @@ -718,7 +750,7 @@ export function getWindowsServiceStatus( running: false, path: taskName, message: - "A legacy ADE Scheduled Task is installed, but runtime readiness cannot be verified. Run `ade brain start` to migrate it to the per-user startup supervisor.", + "A legacy ADE scheduled task for this channel is installed, but runtime readiness cannot be verified. Run `ade brain start` to migrate it to the per-user startup supervisor.", }; } const startupResult = run(WINDOWS_REG_COMMAND, buildWindowsRunKeyQueryArgs(taskName), { @@ -799,6 +831,32 @@ export function getWindowsServiceStatus( message: serviceManagerResultText(startupResult) || "Unable to query the ADE per-user startup entry.", }; } + const legacy = probeGlobalLegacyTask(run, command); + if (legacy.present && legacy.owned === true) { + return { + ok: true, + serviceName, + action: "status", + installed: true, + running: false, + path: TASK_NAME, + message: + "A legacy ADE Runtime scheduled task from a pre-channel install belongs to this channel, but runtime readiness cannot be verified for it. Run `ade brain start` to migrate it to the per-user startup supervisor.", + }; + } + if (legacy.present) { + return { + ok: true, + serviceName, + action: "status", + installed: false, + running: false, + path: taskName, + message: legacy.owned === false + ? "ADE background service startup entry is not installed for this channel. A legacy ADE Runtime scheduled task belongs to a different ADE install and was left running. Run `ade brain start` to install this channel's startup entry, and uninstall the other ADE to clear its legacy task." + : "ADE background service startup entry is not installed for this channel. A legacy ADE Runtime scheduled task is registered on this machine, but its owning install could not be determined, so it was left running. Run `ade brain start` to install this channel's startup entry.", + }; + } return { ok: true, serviceName, From 39966e88eaa048519238143df770faea86e41a00 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:08:33 -0400 Subject: [PATCH 15/42] fix(windows): derive docs routes from OS-independent ids validate-docs.mjs fed native `path.relative()` output straight into its route/link namespace. On Windows every doc id came out backslash-separated (`configuration\ai-providers`), so no `.mdx` route ever matched a forward-slash link target and the `docs/` prefix test never fired -- 248 phantom errors, exit 1, and all 77 `docs/**/*.md` files silently skipped. Linux CI never saw it. Route ids are a URL-ish namespace and are now forward-slash on every host. `toRouteId()` is the single boundary where a native path becomes an id; `targetExists()` and the doc reader convert back to native segments before touching the filesystem. `toRouteId()` is the identity function when `path.sep === "/"`, so POSIX behaviour is bit-for-bit unchanged. Also sorts directory entries so error output is ordered identically on both platforms, and extracts the pure helpers behind an `isDirectRun` guard (matching scripts/posthog/provision.mjs) so the separator handling is unit-testable from a POSIX runner. Windows now reports 0 errors / exit 0, matching ubuntu CI. No validation rule was loosened. Based-on: nsxdavid/ADE#999 (cherry picked from commit 3689bebce3a489ef3e5fbaa18fc18574be72cddd) --- scripts/validate-docs.mjs | 178 ++++++++++++++++++++------------- scripts/validate-docs.test.mjs | 75 ++++++++++++++ 2 files changed, 182 insertions(+), 71 deletions(-) create mode 100644 scripts/validate-docs.test.mjs diff --git a/scripts/validate-docs.mjs b/scripts/validate-docs.mjs index 0ad3ee3b6..074508ab5 100644 --- a/scripts/validate-docs.mjs +++ b/scripts/validate-docs.mjs @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; const repoRoot = process.cwd(); const ignoredTopLevel = new Set([ @@ -13,54 +14,68 @@ const ignoredTopLevel = new Set([ "dist", ]); -const docFiles = []; +/** + * Doc identifiers (the values in `docFiles`, every `routeSet` entry, and every + * link target) live in a URL-ish namespace that is always forward-slash + * separated, on every host OS. Filesystem access keeps native paths. + * + * `toRouteId` is the single boundary where a native path becomes an identifier; + * call it there and nowhere else, so the rest of the script can compare route + * ids against link targets without caring about `path.sep`. On POSIX this is + * the identity function, because a backslash is a legal filename character + * there and must not be rewritten. + * + * `sep` is injectable so the Windows behaviour stays testable on a POSIX CI box. + */ +export function toRouteId(nativeRelativePath, sep = path.sep) { + if (sep === "/") return nativeRelativePath; + return nativeRelativePath.split(sep).join("/"); +} + +/** `routeId` is a repo-relative, forward-slash id produced by `toRouteId`. */ +export function isDocFile(routeId) { + return ( + routeId === "README.md" || + routeId === "AGENTS.md" || + routeId.endsWith(".mdx") || + (routeId.startsWith("docs/") && routeId.endsWith(".md")) + ); +} + +/** Maps an `.mdx` route id onto the absolute docs route it publishes. */ +export function docRouteForFile(routeId) { + const withoutExtension = routeId.replace(/\.mdx$/, ""); + if (withoutExtension === "index") return "/"; + if (withoutExtension.endsWith("/index")) { + return `/${withoutExtension.slice(0, -"/index".length)}`; + } + return `/${withoutExtension}`; +} -async function walkDocs(dir) { +async function walkDocs(dir, docFiles) { const entries = await fs.readdir(dir, { withFileTypes: true }); + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); for (const entry of entries) { if (entry.name.startsWith(".")) { if (entry.name !== ".well-known") continue; } const fullPath = path.join(dir, entry.name); - const relPath = path.relative(repoRoot, fullPath); + const routeId = toRouteId(path.relative(repoRoot, fullPath)); if (entry.isDirectory()) { if (dir === repoRoot && ignoredTopLevel.has(entry.name)) continue; - await walkDocs(fullPath); + await walkDocs(fullPath, docFiles); continue; } - if ( - relPath === "README.md" || - relPath === "AGENTS.md" || - relPath.endsWith(".mdx") || - (relPath.startsWith("docs/") && relPath.endsWith(".md")) - ) { - docFiles.push(relPath); + if (isDocFile(routeId)) { + docFiles.push(routeId); } } } -await walkDocs(repoRoot); - -const routeSet = new Set( - docFiles - .filter((file) => file.endsWith(".mdx")) - .map((file) => { - const withoutExtension = file.replace(/\.mdx$/, ""); - if (withoutExtension === "index") return "/"; - if (withoutExtension.endsWith("/index")) { - return `/${withoutExtension.slice(0, -"/index".length)}`; - } - return `/${withoutExtension}`; - }) -); - -const docsConfig = JSON.parse(await fs.readFile(path.join(repoRoot, "docs.json"), "utf8")); -const errors = []; - -function normalizeTarget(rawTarget, fromFile) { +export function normalizeTarget(rawTarget, fromFile) { const stripped = rawTarget.split("#")[0]?.split("?")[0] ?? ""; if (stripped === "…" || stripped === "...") return null; if (!stripped || stripped.startsWith("http://") || stripped.startsWith("https://") || stripped.startsWith("mailto:") || stripped.startsWith("tel:")) { @@ -71,19 +86,23 @@ function normalizeTarget(rawTarget, fromFile) { return { absolute: stripped, source: rawTarget, fromFile }; } - const fromDir = path.posix.dirname(fromFile.replaceAll(path.sep, "/")); + // `fromFile` is already a forward-slash route id, so posix path maths applies + // unchanged on every OS. + const fromDir = path.posix.dirname(fromFile); const resolved = path.posix.normalize(path.posix.join(fromDir === "." ? "" : fromDir, stripped)); return { absolute: `/${resolved}`, source: rawTarget, fromFile }; } -function targetExists(target) { +function targetExists(target, routeSet) { const clean = target.absolute; if (routeSet.has(clean)) { return true; } - const repoPath = path.join(repoRoot, clean.slice(1)); + // Cross back out of the route namespace: split on "/" and rejoin natively + // rather than handing a forward-slash string to the filesystem. + const repoPath = path.join(repoRoot, ...clean.slice(1).split("/").filter(Boolean)); return fs.access(repoPath).then(() => true).catch(() => false); } @@ -120,12 +139,6 @@ function collectConfigTargets(config) { return targets; } -for (const target of collectConfigTargets(docsConfig)) { - if (!(await targetExists(target))) { - errors.push(`${target.fromFile}: missing target ${target.source}`); - } -} - const inlineHrefPattern = /\b(?:href|src)=["']([^"']+)["']/g; const markdownLinkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g; const leakedAgentMarkupPattern = /<\/(?:invoke|content)>|<parameter\b|antml:/g; @@ -138,31 +151,39 @@ function lineNumberForIndex(content, index) { return line; } -for (const file of docFiles) { - const content = await fs.readFile(path.join(repoRoot, file), "utf8"); - const seenTargets = new Set(); - - if (file.endsWith(".mdx")) { - leakedAgentMarkupPattern.lastIndex = 0; - let artifactMatch; - while ((artifactMatch = leakedAgentMarkupPattern.exec(content)) !== null) { - errors.push(`${file}:${lineNumberForIndex(content, artifactMatch.index)}: remove leaked agent tool-call markup ${artifactMatch[0]}`); +async function validateLinks({ docFiles, routeSet, docsConfig, errors }) { + for (const target of collectConfigTargets(docsConfig)) { + if (!(await targetExists(target, routeSet))) { + errors.push(`${target.fromFile}: missing target ${target.source}`); } } - for (const pattern of [inlineHrefPattern, markdownLinkPattern]) { - pattern.lastIndex = 0; - let match; - while ((match = pattern.exec(content)) !== null) { - const rawTarget = match[1]?.trim(); - const normalized = normalizeTarget(rawTarget, file); - if (!normalized) continue; - const dedupeKey = `${file}:${normalized.absolute}`; - if (seenTargets.has(dedupeKey)) continue; - seenTargets.add(dedupeKey); - - if (!(await targetExists(normalized))) { - errors.push(`${file}: missing target ${rawTarget}`); + for (const file of docFiles) { + const content = await fs.readFile(path.join(repoRoot, ...file.split("/")), "utf8"); + const seenTargets = new Set(); + + if (file.endsWith(".mdx")) { + leakedAgentMarkupPattern.lastIndex = 0; + let artifactMatch; + while ((artifactMatch = leakedAgentMarkupPattern.exec(content)) !== null) { + errors.push(`${file}:${lineNumberForIndex(content, artifactMatch.index)}: remove leaked agent tool-call markup ${artifactMatch[0]}`); + } + } + + for (const pattern of [inlineHrefPattern, markdownLinkPattern]) { + pattern.lastIndex = 0; + let match; + while ((match = pattern.exec(content)) !== null) { + const rawTarget = match[1]?.trim(); + const normalized = normalizeTarget(rawTarget, file); + if (!normalized) continue; + const dedupeKey = `${file}:${normalized.absolute}`; + if (seenTargets.has(dedupeKey)) continue; + seenTargets.add(dedupeKey); + + if (!(await targetExists(normalized, routeSet))) { + errors.push(`${file}: missing target ${rawTarget}`); + } } } } @@ -203,7 +224,7 @@ function semverGitTags() { .sort(compareSemver); } -async function validateReleaseDocs() { +async function validateReleaseDocs({ routeSet, errors }) { const gitTags = semverGitTags(); if (gitTags === null) { errors.push("CHANGELOG.md: failed to read git tags; ensure git is installed and this is a git checkout"); @@ -250,13 +271,13 @@ async function validateReleaseDocs() { } } - if (!(await targetExists({ absolute: `/changelog/${docsLatestTag.raw}`, source: docsLatestTag.raw, fromFile: "CHANGELOG.md" }))) { + if (!(await targetExists({ absolute: `/changelog/${docsLatestTag.raw}`, source: docsLatestTag.raw, fromFile: "CHANGELOG.md" }, routeSet))) { errors.push(`changelog/${docsLatestTag.raw}.mdx: missing docs page for latest release ${docsLatestTag.raw}`); } let changelogIndex; try { - changelogIndex = await fs.readFile(path.join(repoRoot, "changelog/index.mdx"), "utf8"); + changelogIndex = await fs.readFile(path.join(repoRoot, "changelog", "index.mdx"), "utf8"); } catch { errors.push("changelog/index.mdx: file is missing; create it with a latest-release Card"); return; @@ -286,14 +307,29 @@ async function validateReleaseDocs() { } } -await validateReleaseDocs(); +async function main() { + const docFiles = []; + await walkDocs(repoRoot, docFiles); -if (errors.length > 0) { - console.error("Documentation validation failed:"); - for (const error of errors) { - console.error(`- ${error}`); + const routeSet = new Set(docFiles.filter((file) => file.endsWith(".mdx")).map(docRouteForFile)); + const docsConfig = JSON.parse(await fs.readFile(path.join(repoRoot, "docs.json"), "utf8")); + const errors = []; + + await validateLinks({ docFiles, routeSet, docsConfig, errors }); + await validateReleaseDocs({ routeSet, errors }); + + if (errors.length > 0) { + console.error("Documentation validation failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); } - process.exit(1); + + console.log(`Documentation validation passed for ${docFiles.length} files.`); } -console.log(`Documentation validation passed for ${docFiles.length} files.`); +const isDirectRun = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (isDirectRun) { + await main(); +} diff --git a/scripts/validate-docs.test.mjs b/scripts/validate-docs.test.mjs new file mode 100644 index 000000000..df6e0640e --- /dev/null +++ b/scripts/validate-docs.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { docRouteForFile, isDocFile, normalizeTarget, toRouteId } from "./validate-docs.mjs"; + +const WIN = "\\"; +const POSIX = "/"; + +test("converts native Windows paths into forward-slash route ids", () => { + assert.equal(toRouteId("configuration\\ai-providers.mdx", WIN), "configuration/ai-providers.mdx"); + assert.equal(toRouteId("docs\\guides\\deep\\nested.md", WIN), "docs/guides/deep/nested.md"); + assert.equal(toRouteId("welcome.mdx", WIN), "welcome.mdx"); +}); + +test("leaves POSIX paths untouched, including literal backslashes in filenames", () => { + assert.equal(toRouteId("configuration/ai-providers.mdx", POSIX), "configuration/ai-providers.mdx"); + // A backslash is a legal filename character on POSIX and must survive verbatim. + assert.equal(toRouteId("guides/odd\\name.mdx", POSIX), "guides/odd\\name.mdx"); +}); + +test("selects docs/**/*.md regardless of the host separator", () => { + // Regression: the raw Windows path never matched the "docs/" prefix, so every + // docs/**/*.md file was silently skipped on Windows. + assert.equal(isDocFile("docs\\playbooks\\ship-lane.md"), false); + assert.equal(isDocFile(toRouteId("docs\\playbooks\\ship-lane.md", WIN)), true); + assert.equal(isDocFile(toRouteId("docs/playbooks/ship-lane.md", POSIX)), true); + + assert.equal(isDocFile("README.md"), true); + assert.equal(isDocFile("AGENTS.md"), true); + assert.equal(isDocFile(toRouteId("configuration\\ai-providers.mdx", WIN)), true); + // Markdown outside docs/ is still out of scope on both platforms. + assert.equal(isDocFile(toRouteId("guides\\notes.md", WIN)), false); + assert.equal(isDocFile("guides/notes.md"), false); +}); + +test("derives the same absolute mdx route on Windows and POSIX", () => { + for (const [input, sep] of [ + ["configuration\\ai-providers.mdx", WIN], + ["configuration/ai-providers.mdx", POSIX], + ]) { + assert.equal(docRouteForFile(toRouteId(input, sep)), "/configuration/ai-providers"); + } + + assert.equal(docRouteForFile(toRouteId("index.mdx", WIN)), "/"); + assert.equal(docRouteForFile(toRouteId("changelog\\index.mdx", WIN)), "/changelog"); + assert.equal(docRouteForFile(toRouteId("changelog/index.mdx", POSIX)), "/changelog"); +}); + +test("resolves relative link targets against a Windows-derived source file", () => { + const fromFile = toRouteId("ai-tools\\claude-code.mdx", WIN); + + assert.equal(normalizeTarget("./cursor", fromFile).absolute, "/ai-tools/cursor"); + assert.equal(normalizeTarget("../quickstart", fromFile).absolute, "/quickstart"); + assert.equal(normalizeTarget("windsurf", fromFile).absolute, "/ai-tools/windsurf"); + assert.equal(normalizeTarget("/configuration/ai-providers", fromFile).absolute, "/configuration/ai-providers"); +}); + +test("resolves relative link targets identically from a POSIX source file", () => { + const fromFile = "ai-tools/claude-code.mdx"; + + assert.equal(normalizeTarget("./cursor", fromFile).absolute, "/ai-tools/cursor"); + assert.equal(normalizeTarget("../quickstart", fromFile).absolute, "/quickstart"); + assert.equal(normalizeTarget("windsurf", fromFile).absolute, "/ai-tools/windsurf"); +}); + +test("strips fragments and query strings and skips non-repo targets", () => { + const fromFile = "welcome.mdx"; + + assert.equal(normalizeTarget("/configuration#providers", fromFile).absolute, "/configuration"); + assert.equal(normalizeTarget("/configuration?tab=a", fromFile).absolute, "/configuration"); + + for (const skipped of ["https://example.com", "http://example.com", "mailto:a@b.c", "tel:+1", "…", "...", ""]) { + assert.equal(normalizeTarget(skipped, fromFile), null); + } +}); From 45b4253a3e8960b79ffa299a612d0ba5e2cb2db8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:31:24 -0400 Subject: [PATCH 16/42] test(windows): make local runtime pool suite portable and gate it on CI PR1 added Windows runtime startup timing and a `connectSpawnedRuntime` retry path, but the native Windows CI job ran only three unrelated desktop files, so none of it was gated. Adding the owning suite to that job first required making the suite runnable on Windows: it was 50 passed / 12 failed natively under Node 22. All 12 were test-layer portability defects, not production bugs: - Eight daemon-backed tests hardcoded `<ADE_HOME>/sock/ade.sock`. Node maps a path-style endpoint onto a named pipe on Windows, so a filesystem path is not a connectable address and each died with `connect ENOENT`. They now derive the endpoint through `resolveMachineAdeLayout`, exactly as production does, which is byte-identical on macOS/Linux and yields the real per-user pipe on Windows. - Those same eight also hit a second, stacked failure: the tsx loader was injected as `--import <absolute path>`. Node's ESM loader rejects a Windows absolute path (ERR_UNSUPPORTED_ESM_URL_SCHEME, "Received protocol 'c:'"), killing the spawned daemon before it could listen and surfacing only as a downstream connect error. It now passes a file:// URL, which is equally valid on POSIX. - The NODE_PATH test compared against forward-slash literals although the helper joins with the host path module; it now joins the expectation the same way. - Two release-build-output tests compared raw POSIX literals against values the production helpers return `path.resolve`d. No assertion was dropped or skipped; the POSIX expectations are unchanged. The suite is now 62 passed / 0 failed on Windows, and it is gated by a new step in the windows-foundation job. The readiness wait is also given the same Windows budget production already uses for runtime startup (30s vs 10s), since a cold Windows daemon start is genuinely slower. It is a ceiling rather than a sleep, so the suite still finishes in ~38s locally. Based-on: nsxdavid/ADE#999 (cherry picked from commit fa2f30e2a9237b2350a9e9c7a5c0ae820e0e29c9) --- .github/workflows/ci.yml | 7 ++ .../localRuntimeConnectionPool.test.ts | 96 +++++++++++++++---- 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189a9ffc3..2b947671e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -453,6 +453,13 @@ jobs: - name: Test Windows desktop, SQLite, and capability contracts run: cd apps/desktop && npx vitest run src/main/packagedRuntimeSmoke.test.ts src/main/services/computerUse/localComputerUse.test.ts src/renderer/lib/platform.test.ts + # Covers the Windows runtime startup timing and `connectSpawnedRuntime` + # retry path. This suite spawns real `ade serve` daemons and connects to + # them over the platform transport, so on this runner it is the only gate + # that exercises the named-pipe endpoint end to end. + - name: Test Windows local runtime connection pool contracts + run: cd apps/desktop && npx vitest run src/main/services/localRuntime/localRuntimeConnectionPool.test.ts + validate-docs: needs: install runs-on: ubuntu-latest diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 8e7f79421..53a3f0d87 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -3,7 +3,9 @@ import fs from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; import { recordLastFailure } from "../runtime/lastFailureStore"; vi.mock("electron", () => ({ @@ -119,12 +121,48 @@ class RawRuntimeSocketClient { } } +/** + * Resolve the machine runtime endpoint for a temp `ADE_HOME` exactly the way + * production does, so daemon-backed tests exercise the real per-platform + * transport: a Unix domain socket at `<ADE_HOME>/sock/ade.sock` on macOS and + * Linux, and a per-user named pipe on Windows. + * + * Hardcoding `<adeHome>/sock/ade.sock` is not portable. Node's `net` maps a + * path-style endpoint onto a named pipe on Windows, so a filesystem path is + * not a connectable address there and every such test dies with + * `connect ENOENT`. Deriving the endpoint keeps the POSIX expectation + * byte-identical while giving Windows the address the runtime actually listens + * on. + */ +function machineRuntimeSocketPath(adeHome: string): string { + return resolveMachineAdeLayout({ ...process.env, ADE_HOME: adeHome }).socketPath; +} + function withTsxNodeOptions(value: string | undefined, loaderPath: string): string { + // `--import` must be given a file:// URL, not a bare absolute path. Node's + // ESM loader rejects a Windows absolute path outright + // (ERR_UNSUPPORTED_ESM_URL_SCHEME: "Received protocol 'c:'"), which kills the + // spawned daemon before it can listen and surfaces here only as a downstream + // connect ENOENT. A file URL is equally valid on macOS/Linux and also + // percent-encodes spaces, which keeps NODE_OPTIONS parseable either way. + const loaderUrl = pathToFileURL(loaderPath).href; const existing = value?.trim(); - return existing ? `${existing} --import ${loaderPath}` : `--import ${loaderPath}`; + return existing ? `${existing} --import ${loaderUrl}` : `--import ${loaderUrl}`; } -async function waitForRuntimeSocket(socketPath: string, timeoutMs = 10_000): Promise<void> { +// Mirrors LOCAL_RUNTIME_STARTUP_TIMEOUT_MS in the pool itself: a cold Windows +// runtime start (process spawn + tsx transform + SQLite init) is genuinely +// slower than on macOS/Linux, so production already waits 30s there against 10s +// elsewhere. The readiness budget below is a ceiling, not a sleep — a healthy +// daemon is reachable in a few seconds on every platform — so widening it on +// Windows only removes a false failure under load without slowing the suite or +// relaxing a single assertion. +const RUNTIME_SOCKET_READY_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 10_000; + +async function waitForRuntimeSocket( + socketPath: string, + timeoutMs = RUNTIME_SOCKET_READY_TIMEOUT_MS, +): Promise<void> { await vi.waitFor(async () => { let client: RawRuntimeSocketClient | null = null; try { @@ -296,18 +334,24 @@ describe("local runtime connection pool", () => { }); it("builds packaged runtime NODE_PATH for macOS universal app layouts", () => { + const resourcesPath = "/Applications/ADE.app/Contents/Resources"; const nodePath = buildLocalRuntimeNodePath({ - resourcesPath: "/Applications/ADE.app/Contents/Resources", + resourcesPath, platform: "darwin", arch: "arm64", existingNodePath: "/custom/node_modules", }); + // The helper joins with the *host* path module, which is correct: it builds + // NODE_PATH for a runtime spawned on this machine. Only the app *layout* + // is macOS-specific, so join the expectation the same way rather than + // hardcoding separators. On macOS/Linux these are the same strings as + // before; on Windows they are the same segments with `\`. expect(nodePath?.split(path.delimiter)).toEqual([ - "/Applications/ADE.app/Contents/Resources/app-arm64.asar.unpacked/node_modules", - "/Applications/ADE.app/Contents/Resources/app.asar.unpacked/node_modules", - "/Applications/ADE.app/Contents/Resources/app-arm64.asar/node_modules", - "/Applications/ADE.app/Contents/Resources/app.asar/node_modules", + path.join(resourcesPath, "app-arm64.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app-arm64.asar", "node_modules"), + path.join(resourcesPath, "app.asar", "node_modules"), "/custom/node_modules", ]); }); @@ -342,8 +386,16 @@ describe("local runtime connection pool", () => { }); it("does not auto-install channel services from local release build output paths", () => { - const releaseCliPath = "/Users/admin/Projects/ADE/apps/desktop/release-beta/mac-arm64/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs"; - const installedCliPath = "/Applications/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs"; + // `localReleaseBuildOutputRuntimeBlock` reports the *resolved* CLI path, so + // resolve the fixtures the same way. A bare POSIX literal is a no-op here + // on macOS/Linux but picks up a drive letter and `\` separators on Windows, + // which the raw literal would then fail to match. + const releaseCliPath = path.resolve( + "/Users/admin/Projects/ADE/apps/desktop/release-beta/mac-arm64/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs", + ); + const installedCliPath = path.resolve( + "/Applications/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs", + ); const originalAllow = process.env.ADE_ALLOW_LOCAL_RELEASE_SERVICE_INSTALL; try { @@ -368,7 +420,11 @@ describe("local runtime connection pool", () => { }); it("records skipped service install status for local release build output paths", async () => { - const releaseCliPath = "/Users/admin/Projects/ADE/apps/desktop/release-beta/mac-arm64/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs"; + // Resolved for the same reason as the sibling test above: the reported + // `serviceInstall.path` is the resolved CLI path, not the raw literal. + const releaseCliPath = path.resolve( + "/Users/admin/Projects/ADE/apps/desktop/release-beta/mac-arm64/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs", + ); const originalCliJs = process.env.ADE_CLI_JS; const originalAllow = process.env.ADE_ALLOW_LOCAL_RELEASE_SERVICE_INSTALL; const logger = { @@ -413,7 +469,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-install-skip-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, ADE_HOME: process.env.ADE_HOME, @@ -2027,7 +2083,7 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")); const expectedProjectRoot = fs.realpathSync.native(projectRoot); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, ADE_HOME: process.env.ADE_HOME, @@ -2111,7 +2167,7 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); const secondProjectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, ADE_HOME: process.env.ADE_HOME, @@ -2244,7 +2300,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-newer-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, ADE_HOME: process.env.ADE_HOME, @@ -2334,7 +2390,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-eq-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, ADE_HOME: process.env.ADE_HOME, @@ -2414,7 +2470,7 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-dev-version-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-dev-version-project-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const expectedBuildHash = computeLocalRuntimeBuildHash(cliPath); expect(expectedBuildHash).toBeTruthy(); const originalEnv = { @@ -2493,7 +2549,7 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, ADE_HOME: process.env.ADE_HOME, @@ -2614,7 +2670,7 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-role-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-role-project-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const expectedBuildHash = computeLocalRuntimeBuildHash(cliPath); expect(expectedBuildHash).toBeTruthy(); const originalEnv = { @@ -3179,13 +3235,13 @@ describe("local runtime connection pool", () => { /refusing to spawn an app-owned brain on a primary channel socket/i, ); - expect(tryConnect).toHaveBeenCalledWith(path.join(adeHome, "sock", "ade.sock")); + expect(tryConnect).toHaveBeenCalledWith(machineRuntimeSocketPath(adeHome)); expect(tryRepair).toHaveBeenCalled(); expect(spawnRuntime).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( "local_runtime.primary_runtime_spawn_blocked", expect.objectContaining({ - socketPath: path.join(adeHome, "sock", "ade.sock"), + socketPath: machineRuntimeSocketPath(adeHome), preferServiceRepair: false, }), ); From 8b81999ff29166183a2ae7f2d19642d95cb255bc Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:31:34 -0400 Subject: [PATCH 17/42] test(windows): repair find/replace-mangled machine-name fixture A "This Mac" -> "This computer" copy sweep also matched the substring inside the machine-name fixture "This MacBook", leaving the nonsense name "This computerBook" in the Attention coordinator suite. That silently weakened the copy/parity regression: the assertion that the degraded-availability message names the host machine was checking for a string no machine could ever be called, so it could no longer catch the host name being dropped or replaced. These are user machine *names* (test data), not product copy, and were never in the sweep's scope: the same file's "Studio Mac" and "First Mac" fixtures are untouched, and the identical "This MacBook" fixture on the inline override at line 85 survived. Restoring the name makes the file self-consistent again. Swept the repo for other collateral damage from the same pass; these were the only three occurrences. Every remaining `computer`-adjacent token is a legitimate pre-existing identifier (`computerUse`, and the `desktopcomputer` / `laptopcomputer` SF Symbol names on iOS). Based-on: nsxdavid/ADE#999 (cherry picked from commit 25df8905cd784637e38db812a5f67d0c269e660c) --- .../services/attention/attentionAccountCoordinator.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts index 0c96ac9c4..cba3f16a9 100644 --- a/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts +++ b/apps/desktop/src/main/services/attention/attentionAccountCoordinator.test.ts @@ -22,7 +22,7 @@ function snapshot( generatedAt: "2026-07-29T12:00:00.000Z", machines: [{ machineKey: "machine-local", - name: "This computerBook", + name: "This MacBook", online: true, lastSeenAt: "2026-07-29T12:00:00.000Z", }], @@ -316,10 +316,10 @@ describe("AttentionAccountCoordinator", () => { state: "degraded", title: "Account session needs attention", recovery: "sign_in", - hostName: "This computerBook", + hostName: "This MacBook", }, }); - expect(result.availability?.message).toContain("Showing work from This computerBook"); + expect(result.availability?.message).toContain("Showing work from This MacBook"); expect(result.availability?.message).not.toMatch(/relay|bearer|401/i); expect(callAttention).toHaveBeenCalledWith("getMachineSnapshot", {}); expect(testLogger.warn).toHaveBeenCalledWith( From e806b4effcdbe3a97a62dfad3c6a97e2c35c97b2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:47:30 -0400 Subject: [PATCH 18/42] fix(windows): match equivalent named-pipe spellings in primary-endpoint guard `isPrimaryMachineRuntimeSocketPath` refuses to spawn an app-owned brain onto the machine's primary runtime endpoint. On Windows it was defeatable, because it compared named pipes as raw strings. Win32 accepts `/` and `\` interchangeably in a pipe path and matches pipe names case-insensitively, so `\.\pipe\ade-runtime-stable-abc`, `//./pipe/ade-runtime-stable-abc`, and a case variant all address one pipe. Verified on Windows 11 / Node 22.13.1: a server listening on the first accepts connections addressed to the other two. `isAdeRuntimeNamedPipePath` already encodes this by accepting both separator forms after lowercasing, but `normalizeComparableSocketPath` returned pipe paths verbatim, so equivalent spellings compared unequal. Because `createConnection` honours an operator-supplied ADE_RUNTIME_SOCKET_PATH, any equivalent spelling of the layout pipe slid past the guard and got a second brain on the machine's primary sync endpoint -- the split-brain the guard exists to prevent. Pipe paths are now folded to a lowercase, backslash-separated comparison key. That key is used only to compare: it is built inside a module-private helper whose sole consumer returns a boolean, and every address that is connected to, listened on, probed, logged, or reported still comes from the caller's original `socketPath`, so the lowercasing cannot reach a real endpoint. POSIX socket paths keep `path.resolve` and stay case-sensitive, since `/tmp/ADE.sock` and `/tmp/ade.sock` are genuinely different files. `defaultChannelRuntimeSocketPaths` -- the cross-channel half of the same guard -- was separately dead on Windows. It hardcoded `~/.ade{,-alpha,-beta}/sock/ade.sock`, addresses that never exist there, so a Windows user running Stable and Beta together got none of the protection macOS users get. It now derives each channel endpoint through `resolveMachineAdeLayout`, the same production derivation used everywhere else, which is byte-identical on POSIX. ADE_PACKAGE_CHANNEL and ADE_RUNTIME_SERVICE_NAME are dropped while enumerating: both outrank the home-name-inferred channel, so leaving them in would pin all three homes onto the running channel's own pipe. Regressions assert the guard's decision through `createConnection`, not the normalizer's return value, with `spawnRuntime` stubbed to throw so a guard that lets the spawn through fails loudly. All three Windows cases fail without this change; the cross-channel one reported `blocked: false, spawnAttempted: true` with requested `\.\pipe\ade-runtime-beta-*` against layout `\.\pipe\ade-runtime-stable-*`. A POSIX-only case pins that the pipe canonicalization does not bleed onto case-sensitive filesystem sockets. Based-on: nsxdavid/ADE#999 (cherry picked from commit c95efe27257125840f7bc4204156be86b028bed2) --- .../localRuntimeConnectionPool.test.ts | 166 ++++++++++++++++++ .../localRuntimeConnectionPool.ts | 52 +++++- 2 files changed, 214 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 53a3f0d87..9a585cf78 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -3254,6 +3254,172 @@ describe("local runtime connection pool", () => { } }); + /** + * Drive `createConnection` through the primary-endpoint guard with the + * service-connect and repair paths stubbed out, and report the guard's + * decision. `spawnRuntime` is stubbed to throw rather than actually launch a + * daemon, so a guard that wrongly lets the spawn through fails loudly instead + * of leaking a process. + * + * `requestedSocketPath` is resolved *after* the environment is in place, so + * the address under test and the layout the pool derives internally always + * agree on channel and ADE_HOME. + */ + async function runPrimaryEndpointGuard(args: { + adeHome: string; + packageChannel?: string; + requestedSocketPath: (layoutSocketPath: string) => string; + }): Promise<{ blocked: boolean; spawnAttempted: boolean; requested: string; layout: string }> { + const originalEnv = { + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + ADE_PACKAGE_CHANNEL: process.env.ADE_PACKAGE_CHANNEL, + ADE_RUNTIME_SERVICE_NAME: process.env.ADE_RUNTIME_SERVICE_NAME, + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never); + const internals = pool as unknown as { + createConnection: () => Promise<unknown>; + tryConnect: (socketPath: string) => Promise<unknown>; + tryRepairServiceConnection: (socketPath: string, reason: "missing") => Promise<unknown>; + spawnRuntime: (socketPath: string) => ChildProcess; + }; + vi.spyOn(internals, "tryConnect").mockResolvedValue(null); + vi.spyOn(internals, "tryRepairServiceConnection").mockResolvedValue(null); + const spawnRuntime = vi.spyOn(internals, "spawnRuntime").mockImplementation(() => { + throw new Error("primary-endpoint guard let the spawn through"); + }); + + try { + process.env.ADE_HOME = args.adeHome; + delete process.env.ADE_RUNTIME_SERVICE_NAME; + if (args.packageChannel === undefined) delete process.env.ADE_PACKAGE_CHANNEL; + else process.env.ADE_PACKAGE_CHANNEL = args.packageChannel; + + const layout = resolveMachineAdeLayout().socketPath; + const requested = args.requestedSocketPath(layout); + process.env.ADE_RUNTIME_SOCKET_PATH = requested; + + const error = await internals.createConnection().catch((caught) => caught) as Error; + return { + blocked: /refusing to spawn an app-owned brain on a primary channel socket/i.test(error.message), + spawnAttempted: spawnRuntime.mock.calls.length > 0, + requested, + layout, + }; + } finally { + pool.dispose(); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + /** + * Name a sibling channel's runtime endpoint the way a real install of that + * channel would compute it: the channel is inferred from the home directory + * name, so this process's own ADE_PACKAGE_CHANNEL / ADE_RUNTIME_SERVICE_NAME + * must not bleed into the derivation. + */ + function channelRuntimeSocketPath(homeName: string): string { + const { + ADE_RUNTIME_SERVICE_NAME: _ignoredServiceName, + ADE_PACKAGE_CHANNEL: _ignoredPackageChannel, + ...channelEnv + } = process.env; + return resolveMachineAdeLayout({ + ...channelEnv, + ADE_HOME: path.join(os.homedir(), homeName), + }).socketPath; + } + + it.runIf(process.platform === "win32")( + "treats a forward-slash named pipe spelling as the same primary endpoint", + async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pipe-slash-alias-")); + try { + // Win32 accepts `/` and `\` interchangeably, so this addresses the very + // pipe the layout resolves to. Comparing the raw strings would miss it + // and let a second app-owned brain onto the machine's primary endpoint. + const outcome = await runPrimaryEndpointGuard({ + adeHome, + requestedSocketPath: (layout) => layout.replace(/\\/g, "/"), + }); + + expect(outcome.layout.startsWith("\\\\.\\pipe\\")).toBe(true); + expect(outcome.requested).not.toBe(outcome.layout); + expect(outcome.requested.startsWith("//./pipe/")).toBe(true); + expect(outcome).toMatchObject({ blocked: true, spawnAttempted: false }); + } finally { + removeTempDir(adeHome); + } + }, + ); + + it.runIf(process.platform === "win32")( + "treats a differently-cased named pipe as the same primary endpoint", + async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pipe-case-alias-")); + try { + // Windows pipe names are case-insensitive: this connects to the same + // pipe object as the layout address. + const outcome = await runPrimaryEndpointGuard({ + adeHome, + requestedSocketPath: (layout) => layout.toUpperCase(), + }); + + expect(outcome.requested).not.toBe(outcome.layout); + expect(outcome).toMatchObject({ blocked: true, spawnAttempted: false }); + } finally { + removeTempDir(adeHome); + } + }, + ); + + it("blocks a spawn onto a sibling channel's runtime endpoint", async () => { + // A Stable desktop must not spawn an app-owned brain onto the Beta + // channel's endpoint. ADE_HOME points somewhere unrelated, so the layout + // comparison cannot match and only the cross-channel set can catch this. + // On Windows that set was hardcoded to `~/.ade*/sock/ade.sock` — addresses + // that never exist there — so this protection silently did not apply. + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cross-channel-")); + try { + const outcome = await runPrimaryEndpointGuard({ + adeHome, + packageChannel: "stable", + requestedSocketPath: () => channelRuntimeSocketPath(".ade-beta"), + }); + + expect(outcome.requested).not.toBe(outcome.layout); + expect(outcome).toMatchObject({ blocked: true, spawnAttempted: false }); + } finally { + removeTempDir(adeHome); + } + }); + + it.runIf(process.platform !== "win32")( + "keeps POSIX socket paths case-sensitive when matching the primary endpoint", + async () => { + // The named-pipe canonicalization must not bleed onto filesystem sockets: + // `/tmp/.../ADE.sock` and `/tmp/.../ade.sock` are genuinely different + // files on a case-sensitive filesystem. + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-posix-case-")); + try { + const outcome = await runPrimaryEndpointGuard({ + adeHome, + requestedSocketPath: (layout) => path.join(path.dirname(layout), "ADE.sock"), + }); + + expect(outcome.requested).not.toBe(outcome.layout); + expect(outcome).toMatchObject({ blocked: false, spawnAttempted: true }); + } finally { + removeTempDir(adeHome); + } + }, + ); + it("routes local sync calls through the project-scoped runtime RPC", async () => { const call = vi.fn().mockResolvedValue({ mode: "standalone", diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 4c4e43998..7e2061187 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -119,15 +119,59 @@ const COALESCED_LOCAL_RUNTIME_ACTIONS = new Set([ "tiling_tree.get", ]); +/** + * Collapse the equivalent spellings of one Windows named pipe onto a single + * comparison key. + * + * Win32 accepts `/` and `\` interchangeably in a pipe path and matches pipe + * names case-insensitively, so `\\.\pipe\ade-runtime-stable-abc`, + * `//./pipe/ade-runtime-stable-abc`, and `\\.\pipe\ADE-Runtime-Stable-ABC` all + * address the *same* pipe. `isAdeRuntimeNamedPipePath` already encodes that by + * accepting both separator forms after lowercasing. + * + * This value is a comparison key ONLY. It is deliberately not a valid + * substitute for the caller's address: callers keep using the original + * `socketPath` to connect, listen, probe, log, and report, so the lowercasing + * here can never reach a real endpoint. + */ +function namedPipeComparisonKey(socketPath: string): string { + return socketPath.trim().replace(/\//g, "\\").toLowerCase(); +} + function normalizeComparableSocketPath(socketPath: string): string { - return socketPath.startsWith("tcp://") || isAdeRuntimeNamedPipePath(socketPath) - ? socketPath - : path.resolve(socketPath); + if (socketPath.startsWith("tcp://")) return socketPath; + if (isAdeRuntimeNamedPipePath(socketPath)) return namedPipeComparisonKey(socketPath); + // POSIX socket paths stay case-sensitive: `/tmp/ADE.sock` and `/tmp/ade.sock` + // are genuinely different files. + return path.resolve(socketPath); } +/** + * The stable/alpha/beta runtime endpoints for this user, so the primary-socket + * guard also refuses to spawn an app-owned brain onto a *sibling* channel's + * endpoint. + * + * Derived through `resolveMachineAdeLayout` rather than hand-built, because the + * endpoint is a filesystem socket on macOS/Linux but a per-user named pipe on + * Windows. Hardcoding `~/.ade{,-alpha,-beta}/sock/ade.sock` made this set + * unmatchable on Windows, silently disabling the cross-channel half of the + * guard there. + */ function defaultChannelRuntimeSocketPaths(): Set<string> { + // `windowsChannelIdentity` prefers ADE_RUNTIME_SERVICE_NAME / ADE_PACKAGE_CHANNEL + // over the channel inferred from the home directory name. Both must be dropped + // while enumerating, or this process's own channel would pin all three homes to + // a single pipe and the other two channels would drop out of the set. + const { + ADE_RUNTIME_SERVICE_NAME: _ignoredServiceName, + ADE_PACKAGE_CHANNEL: _ignoredPackageChannel, + ...channelEnv + } = process.env; return new Set([".ade", ".ade-alpha", ".ade-beta"].map((homeName) => - path.join(os.homedir(), homeName, "sock", "ade.sock") + resolveMachineAdeLayout({ + ...channelEnv, + ADE_HOME: path.join(os.homedir(), homeName), + }).socketPath ).map(normalizeComparableSocketPath)); } From dd1c9337628678063d423818d8749876cba6bbcf Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 01:47:36 -0400 Subject: [PATCH 19/42] test: gate the docs-validator suite in CI `scripts/validate-docs.test.mjs` (7 tests, `node --test`) covers the docs validator that the `validate-docs` job runs, but nothing invoked it. Added its path to the existing `node --test` step in `typecheck-ade-cli`, alongside the archive and packaging guards. The file arrives with a sibling lane and does not exist on this branch, so this step cannot pass until the stack is composed; it is wired blind by design and needs verifying after composition. Based-on: nsxdavid/ADE#999 (cherry picked from commit 021e865baeaa4a50885b66c474bba035ff088a55) --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b947671e..294ef61ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,8 +104,11 @@ jobs: apps/push-relay/node_modules key: nm-v2-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/webhook-relay/package-lock.json','apps/push-relay/package-lock.json') }} - run: cd apps/ade-cli && npm run typecheck - - name: Test release runtime archive and packaging guards - run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs + # scripts/validate-docs.test.mjs covers the docs validator that the + # validate-docs job runs; it lands with a sibling lane, so this path does + # not resolve until the stack is composed. + - name: Test release runtime archive, packaging, and docs-validator guards + run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs scripts/validate-docs.test.mjs typecheck-web: needs: install From 3885d8abe672ddaacb08e87c80a337f495a04dbd Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 02:50:34 -0400 Subject: [PATCH 20/42] fix(windows): resolve parent pids so the runtime self-shutdown guard fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `readParentPid` is the default ancestry backend for `isCurrentProcessDescendantOfPid`, which `runtimeSelfShutdownBlock` and `runtimeServiceSelfShutdownBlock` use to refuse `ade runtime repair` / `ade runtime stop` when the command was issued from a shell running inside the runtime it is about to kill. It only ever ran `ps -o ppid= -p <pid>`. On Windows that query cannot succeed. Git Bash ships a `ps` that is found and then rejects the POSIX flags (status 1, no error code); a box without Git Bash reports ENOENT. Either way `status !== 0` returned null, the ancestry walk terminated on its first iteration, and the guard never fired — a user tore down their own live runtime and every active session on it with no warning. Dispatch per platform, matching how `serviceManager/index.ts` dispatches `getRuntimeServiceMainPid`. The win32 branch reads ParentProcessId from `Get-CimInstance Win32_Process` (not `wmic`, which is deprecated and being removed from Windows) through `resolveTrustedWindowsTool("powershell")`, so a poisoned PATH/SystemRoot cannot redirect a query that gates a destructive operation. Ancestry lookups now distinguish "no further parent" from "the query could not be answered". The guard fails CLOSED on the latter: a false block costs one refusal that names the ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION=1 override, while a false allow destroys live state. Only the first is recoverable. POSIX keeps its previous end-of-chain behaviour because `ps` exits 1 for both cases and cannot tell them apart. The existing tests injected a fake `parentPid` on all three cases, so `readParentPid` was never reached and the Windows CI job stayed green over a symbol that could not work. The added coverage drives the real default backend, plus two zero-mock cases that check the resolved parent against `process.ppid` on a real Windows host. Based-on: nsxdavid/ADE#999 (cherry picked from commit c8d6e4d0f951c6583ed806a13790cbfbc3a5a285) --- .../ade-cli/src/serviceManager/common.test.ts | 127 ++++++++++++++++++ apps/ade-cli/src/serviceManager/common.ts | 100 +++++++++++++- 2 files changed, 221 insertions(+), 6 deletions(-) diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index 579fad79c..45f3f8d8e 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -6,8 +6,11 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ADE_RUNTIME_SERVICE_NAME, + buildWindowsParentPidQueryArgs, isCurrentProcessDescendantOfPid, isStaleChannelServeCommandLine, + PARENT_PID_UNKNOWN, + readParentPid, renderCommand, resolveAdeServeCommand, serviceManagerOwnsRuntimeRecovery, @@ -286,6 +289,130 @@ describe("isCurrentProcessDescendantOfPid", () => { }); }); +// These exercise the DEFAULT parent-pid backend. The suite above injects a fake +// `parentPid` every time, which is exactly how a Windows host could ship a +// `readParentPid` that always returned null — and therefore a self-shutdown +// guard that never fired — with a green Windows CI job. +describe("readParentPid on win32", () => { + // A trusted powershell path, never a bare `powershell` resolved off PATH. + const TRUSTED_POWERSHELL = /[\\/]system32[\\/]windowspowershell[\\/]v1\.0[\\/]powershell\.exe$/i; + + const recordingRun = ( + reply: (pid: number) => ServiceManagerProcessResult, + ): { run: ServiceManagerSpawnSync; calls: Array<{ command: string; args: string[] }> } => { + const calls: Array<{ command: string; args: string[] }> = []; + const run: ServiceManagerSpawnSync = (command, args) => { + calls.push({ command, args }); + if (!TRUSTED_POWERSHELL.test(command)) { + // Mirror the real host: Git Bash's `ps` is found and rejects the POSIX + // flags with status 1; a clean Windows box reports ENOENT. Both land on + // a non-zero status with no usable stdout. + return { status: 1, stdout: "", stderr: "ps: unknown option -- o\n" }; + } + const filter = /ProcessId = (\d+)/.exec(args.join(" ")); + return reply(Number(filter?.[1] ?? 0)); + }; + return { run, calls }; + }; + + it("queries Win32_Process through the trusted PowerShell for the parent pid", () => { + const { run, calls } = recordingRun(() => ({ status: 0, stdout: "4321\r\n" })); + + expect(readParentPid(run, 1234, "win32")).toBe(4321); + expect(calls).toHaveLength(1); + expect(calls[0]!.command).toMatch(TRUSTED_POWERSHELL); + expect(calls[0]!.command).not.toBe("ps"); + expect(calls[0]!.args).toEqual(buildWindowsParentPidQueryArgs(1234)); + const script = calls[0]!.args.join(" "); + expect(script).toContain("Get-CimInstance Win32_Process"); + expect(script).toContain("ParentProcessId"); + // wmic is deprecated and is being removed from Windows. + expect(script).not.toMatch(/wmic/i); + }); + + it("treats a missing process as the definitive end of the chain", () => { + const { run } = recordingRun(() => ({ status: 3, stdout: "" })); + expect(readParentPid(run, 1234, "win32")).toBeNull(); + }); + + it("treats ParentProcessId 0 as the top of the tree", () => { + const { run } = recordingRun(() => ({ status: 0, stdout: "0" })); + expect(readParentPid(run, 1234, "win32")).toBeNull(); + }); + + it("reports an undetermined ancestry when the query itself fails", () => { + const failures: ServiceManagerProcessResult[] = [ + { status: 1, stdout: "", stderr: "Get-CimInstance is not recognized" }, + { status: null, stdout: null, stderr: null }, + { status: 0, stdout: "not-a-pid" }, + { status: 0, stdout: "" }, + ]; + for (const failure of failures) { + const { run } = recordingRun(() => failure); + expect(readParentPid(run, 1234, "win32")).toBe(PARENT_PID_UNKNOWN); + } + }); +}); + +describe("isCurrentProcessDescendantOfPid on win32", () => { + const win32Tree = (tree: Record<number, number>): ServiceManagerSpawnSync => + (command, args) => { + if (!/powershell\.exe$/i.test(command)) { + // Anything that is not the Windows query is the old POSIX `ps` path, + // which cannot answer on this platform. + return { status: 1, stdout: "", stderr: "ps: unknown option -- o\n" }; + } + const pid = Number(/ProcessId = (\d+)/.exec(args.join(" "))?.[1] ?? 0); + const parent = tree[pid]; + return parent == null + ? { status: 3, stdout: "" } + : { status: 0, stdout: String(parent) }; + }; + + it("blocks a self-shutdown issued from inside the runtime's process tree", () => { + // No `parentPid` injection: this drives the real default backend, so a + // Windows build without a win32 branch answers false and fails here. + expect(isCurrentProcessDescendantOfPid({ + targetPid: 100, + currentPid: 400, + platform: "win32", + run: win32Tree({ 400: 300, 300: 100, 100: 0 }), + })).toBe(true); + }); + + it("allows a shutdown issued from an unrelated process tree", () => { + expect(isCurrentProcessDescendantOfPid({ + targetPid: 100, + currentPid: 400, + platform: "win32", + run: win32Tree({ 400: 300, 300: 1, 1: 0 }), + })).toBe(false); + }); + + it("fails closed when the ancestry query is unavailable", () => { + // Destroying a live runtime is unrecoverable; a refusal that names the + // ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION override is not. + expect(isCurrentProcessDescendantOfPid({ + targetPid: 100, + currentPid: 400, + platform: "win32", + run: () => ({ status: 1, stdout: "", stderr: "powershell unavailable" }), + })).toBe(true); + }); +}); + +describe.runIf(process.platform === "win32")("readParentPid against the real Windows host", () => { + it("resolves this process's actual parent pid", () => { + // process.ppid is an independent oracle for the same fact. Before the win32 + // branch existed this returned null on every Windows host. + expect(readParentPid(spawnChildSync, process.pid)).toBe(process.ppid); + }, 30_000); + + it("reports the real parent as an ancestor of this process", () => { + expect(isCurrentProcessDescendantOfPid({ targetPid: process.ppid })).toBe(true); + }, 30_000); +}); + describe("isStaleChannelServeCommandLine", () => { const cliScriptPath = "/Applications/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs"; const primarySocketPath = "/Users/example/.ade-beta/sock/ade.sock"; diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index 26d0af9fa..e76b8e743 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { createHash } from "node:crypto"; import path from "node:path"; import { spawnSync, type SpawnSyncOptions } from "node:child_process"; +import { resolveTrustedWindowsTool } from "../lib/trustedWindowsTools"; export type ServiceManagerResult = { ok: boolean; @@ -71,32 +72,119 @@ function processOutputText(result: ServiceManagerProcessResult): string { return ""; } -function readParentPid( - run: ServiceManagerSpawnSync, - pid: number, -): number | null { +/** + * The ancestry query could not be answered at all — the mechanism itself is + * broken/absent, so we know NOTHING about the process tree. Distinct from + * `null`, which is the definitive answer "this process has no further parent". + */ +export const PARENT_PID_UNKNOWN = "unknown" as const; + +export type ParentPidLookup = number | null | typeof PARENT_PID_UNKNOWN; + +/** Exit code the win32 parent-pid query uses for "no such process". */ +const WINDOWS_PARENT_PID_NOT_FOUND_EXIT = 3; + +/** + * PowerShell to print a pid's ParentProcessId, or exit 3 when the pid is gone. + * + * `Get-CimInstance Win32_Process` rather than `wmic`: wmic is deprecated and is + * being removed from Windows, and this matches the CIM queries already used by + * `serviceManager/windowsSupervisor.ts`. + */ +export function buildWindowsParentPidQueryArgs(pid: number): string[] { + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error(`Invalid pid for the Windows parent-process query: ${String(pid)}`); + } + const query = [ + "$ErrorActionPreference = 'Stop'", + `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' -ErrorAction SilentlyContinue`, + `if ($null -eq $process) { exit ${WINDOWS_PARENT_PID_NOT_FOUND_EXIT} }`, + "[Console]::Out.Write([string]$process.ParentProcessId)", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; +} + +function readWindowsParentPid(run: ServiceManagerSpawnSync, pid: number): ParentPidLookup { + let result: ServiceManagerProcessResult; + try { + // Resolve through the hardened GLOBALROOT lookup: a bare `powershell` is + // redirectable via PATH/SystemRoot, and this guard protects a teardown. + result = run( + resolveTrustedWindowsTool("powershell"), + buildWindowsParentPidQueryArgs(pid), + { encoding: "utf8", timeout: 5_000, windowsHide: true }, + ); + } catch { + return PARENT_PID_UNKNOWN; + } + // Only "the process does not exist" is a definitive end of the chain. Any + // other non-zero status (spawn failure, CIM unavailable, timeout) means the + // ancestry is undetermined, NOT that we reached the root. + if (result.status === WINDOWS_PARENT_PID_NOT_FOUND_EXIT) return null; + if (result.status !== 0) return PARENT_PID_UNKNOWN; + const text = processOutputText(result); + if (!/^\d+$/.test(text)) return PARENT_PID_UNKNOWN; + const parentPid = Number.parseInt(text, 10); + if (!Number.isFinite(parentPid)) return PARENT_PID_UNKNOWN; + // ParentProcessId 0 is the Idle pseudo-process: the top of the tree. + return parentPid > 0 ? parentPid : null; +} + +function readPosixParentPid(run: ServiceManagerSpawnSync, pid: number): ParentPidLookup { + // `ps` exits 1 both for "no such pid" and for a genuine failure, so a POSIX + // host cannot distinguish the two the way the win32 branch above can. `ps` is + // part of every POSIX base system, so treat a failure as end-of-chain. const result = run("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }); if (result.status !== 0) return null; const parentPid = Number.parseInt(processOutputText(result), 10); return Number.isFinite(parentPid) && parentPid > 0 ? parentPid : null; } +/** + * Parent pid of `pid`, dispatched per platform the same way + * `serviceManager/index.ts` dispatches `getRuntimeServiceMainPid`. + */ +export function readParentPid( + run: ServiceManagerSpawnSync, + pid: number, + platform: NodeJS.Platform = process.platform, +): ParentPidLookup { + if (!Number.isInteger(pid) || pid <= 0) return null; + return platform === "win32" + ? readWindowsParentPid(run, pid) + : readPosixParentPid(run, pid); +} + +/** + * Whether this process is running inside the process tree rooted at `targetPid`. + * + * This is a SAFETY guard: its callers use it to refuse tearing down the very + * runtime the command was issued from. When ancestry cannot be determined + * (`PARENT_PID_UNKNOWN`) it therefore answers `true` — fail CLOSED. A false + * "yes" costs the user one refusal that names the + * `ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION=1` override; a false "no" silently + * destroys a live runtime and every active session on it. Only the first is + * recoverable. + */ export function isCurrentProcessDescendantOfPid(args: { targetPid: number; run?: ServiceManagerSpawnSync; currentPid?: number; - parentPid?: (pid: number) => number | null; + platform?: NodeJS.Platform; + parentPid?: (pid: number) => ParentPidLookup; }): boolean { const targetPid = Math.floor(args.targetPid); if (!Number.isFinite(targetPid) || targetPid <= 0) return false; const run = args.run ?? spawnSync; - const readPid = args.parentPid ?? ((pid) => readParentPid(run, pid)); + const platform = args.platform ?? process.platform; + const readPid = args.parentPid ?? ((pid: number) => readParentPid(run, pid, platform)); const seen = new Set<number>(); let cursor = Math.floor(args.currentPid ?? process.pid); while (Number.isFinite(cursor) && cursor > 0 && !seen.has(cursor)) { if (cursor === targetPid) return true; seen.add(cursor); const next = readPid(cursor); + if (next === PARENT_PID_UNKNOWN) return true; if (!next || next === cursor) return false; cursor = next; } From e58ed818295e06a687337370bbc043a0acc9e4f2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 03:03:46 -0400 Subject: [PATCH 21/42] fix(windows): identify sync-port holders so stale reclaim works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inspectSyncListenerPort` shelled out to `lsof` and `ps` unconditionally. Neither exists on Windows — and under Git Bash `ps` is found but rejects the POSIX flags — while `execFileText` swallows the failure and returns null, so every Windows diagnosis came back with an empty `holders`. Two consequences follow from that empty list. `createSharedSyncListener` diagnoses a port only after EADDRINUSE, and with no holders `findStaleHolder` can never recognise a wedged same-channel sibling, so the reclaim path is dead and mobile sync drifts onto a fallback port permanently — the port paired phones have saved is never recovered. The same empty list also stops the port being marked occupied, so the bind burns all eight preferred-port retries before drifting. And `ade doctor` fell through to its "no holders visible to this user" branch, which advises `tailscale serve status` and a root-owned tailscaled holder: advice that cannot apply on Windows. Dispatch per platform. The win32 branch pairs `Get-NetTCPConnection` with `Get-CimInstance Win32_Process` in a single PowerShell invocation, resolved through `resolveTrustedWindowsTool`, and returns the pid, command line, and an ISO creation time for the PID-reuse guard. One spawn replaces the 1 + 2N the POSIX path needs. `Get-NetTCPConnection` is the supported replacement for scraping `netstat`; when it is unavailable the query simply yields no holders, which is the current behaviour, so a `netstat -ano` fallback would add a PATH-reachable executable for no coverage gain. The 200ms budget that suits lsof/ps would kill every PowerShell query before it answered — the real query takes ~2s on a warm host — so the timeout is now per-call. Added coverage drives the real dispatch rather than the injected `inspectPort` seam the existing reclaim test uses, including a zero-mock case that binds a port and asserts this process is named as its holder on a real Windows host. Based-on: nsxdavid/ADE#999 (cherry picked from commit 8144099e63817d3fd24439584b849f83f687f0ef) --- .../services/sync/sharedSyncListener.test.ts | 129 +++++++++++++++++ .../src/services/sync/sharedSyncListener.ts | 132 ++++++++++++++++-- 2 files changed, 253 insertions(+), 8 deletions(-) diff --git a/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts b/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts index 09fc27fed..8f0a696f6 100644 --- a/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts +++ b/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts @@ -3,7 +3,10 @@ import { once } from "node:events"; import { describe, expect, it, vi } from "vitest"; import WebSocket from "ws"; import { + buildWindowsPortHolderQueryArgs, createSharedSyncListener, + inspectSyncListenerPort, + parseWindowsPortHolders, SYNC_RELAY_BRIDGE_PROOF_HEADER, } from "./sharedSyncListener"; @@ -143,3 +146,129 @@ describe("shared sync listener upgrade policy", () => { } }); }); + +// `inspectSyncListenerPort` used to shell out to lsof/ps unconditionally. On +// Windows both are absent (or, under Git Bash, present and hostile to the POSIX +// flags), execFileText swallows the failure, and every diagnosis came back with +// an empty `holders`. That silently disabled the stale-port reclaim above and +// left `ade doctor` unable to name the process holding the sync port. +describe("inspectSyncListenerPort on win32", () => { + const TRUSTED_POWERSHELL = /[\\/]system32[\\/]windowspowershell[\\/]v1\.0[\\/]powershell\.exe$/i; + + it("builds a Get-NetTCPConnection query joined to Win32_Process", () => { + const args = buildWindowsPortHolderQueryArgs(8787); + const script = args.join(" "); + expect(args.slice(0, 3)).toEqual(["-NoProfile", "-NonInteractive", "-Command"]); + expect(script).toContain("Get-NetTCPConnection -LocalPort 8787 -State Listen"); + expect(script).toContain("OwningProcess"); + expect(script).toContain("Get-CimInstance Win32_Process"); + expect(script).toContain("CommandLine"); + expect(script).toContain("CreationDate"); + // wmic is deprecated and is being removed from Windows. + expect(script).not.toMatch(/wmic/i); + }); + + it("refuses to interpolate anything that is not a real port", () => { + for (const port of [0, -1, 1.5, 70_000, Number.NaN]) { + expect(() => buildWindowsPortHolderQueryArgs(port)).toThrow(/Invalid port/); + } + }); + + it("queries the trusted PowerShell instead of lsof, with a workable timeout", async () => { + const calls: Array<{ command: string; args: string[]; timeoutMs?: number }> = []; + const diagnosis = await inspectSyncListenerPort(8788, { + platform: "win32", + exec: async (command, args, timeoutMs) => { + calls.push({ command, args, timeoutMs }); + return JSON.stringify([ + { pid: 4242, command: "C:\\ADE\\ade.exe serve", startTime: "2026-08-01T04:00:00.0000000Z" }, + ]); + }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]!.command).toMatch(TRUSTED_POWERSHELL); + expect(calls[0]!.command).not.toBe("lsof"); + // The POSIX budget is 200ms; PowerShell needs seconds to start and load CIM, + // so reusing it would kill every Windows query before it answered. + expect(calls[0]!.timeoutMs).toBeGreaterThan(1_000); + expect(diagnosis).toEqual({ + port: 8788, + holders: [{ + pid: 4242, + command: "C:\\ADE\\ade.exe serve", + startTime: "2026-08-01T04:00:00.0000000Z", + }], + }); + }); + + it("still uses lsof and ps off win32", async () => { + const commands: string[] = []; + await inspectSyncListenerPort(8789, { + platform: "darwin", + exec: async (command) => { + commands.push(command); + return command === "lsof" ? "p4242\n" : "ade serve\n"; + }, + }); + expect(commands[0]).toBe("lsof"); + expect(commands).toContain("ps"); + expect(commands.some((command) => /powershell/i.test(command))).toBe(false); + }); + + it("reports no holders when the query fails rather than inventing one", async () => { + expect(await inspectSyncListenerPort(8790, { + platform: "win32", + exec: async () => null, + })).toEqual({ port: 8790, holders: [] }); + }); + + it("parses PowerShell holder payloads defensively", () => { + expect(parseWindowsPortHolders(JSON.stringify([ + { pid: 10, command: "a.exe", startTime: "2026-08-01T04:00:00Z" }, + { pid: 11, command: "b.exe", startTime: "2026-08-01T05:00:00Z" }, + ]))).toEqual([ + { pid: 10, command: "a.exe", startTime: "2026-08-01T04:00:00Z" }, + { pid: 11, command: "b.exe", startTime: "2026-08-01T05:00:00Z" }, + ]); + // PowerShell 5.1 unwraps a single-element array into a bare object. + expect(parseWindowsPortHolders( + JSON.stringify({ pid: 12, command: "c.exe", startTime: "2026-08-01T04:00:00Z" }), + )).toEqual([{ pid: 12, command: "c.exe", startTime: "2026-08-01T04:00:00Z" }]); + // Another user's process yields no CommandLine without elevation. Keep the + // pid: it still proves the port is occupied. + expect(parseWindowsPortHolders(JSON.stringify([{ pid: 13, command: "", startTime: "" }]))) + .toEqual([{ pid: 13, command: null, startTime: null }]); + expect(parseWindowsPortHolders(JSON.stringify([{ pid: 14 }, { pid: 14 }]))).toHaveLength(1); + expect(parseWindowsPortHolders(JSON.stringify([{ pid: 0 }, { pid: -3 }, null, "x"]))).toEqual([]); + // An empty PowerShell collection prints nothing at all. + expect(parseWindowsPortHolders("")).toEqual([]); + expect(parseWindowsPortHolders(null)).toEqual([]); + expect(parseWindowsPortHolders("not json")).toEqual([]); + }); +}); + +describe.runIf(process.platform === "win32")("inspectSyncListenerPort against the real Windows host", () => { + it("names this process as the holder of a port it is listening on", async () => { + // No mocks: this is the case that shipped broken. Before the win32 branch + // existed every real Windows diagnosis came back with zero holders. + const holder = http.createServer(); + holder.listen(0, "127.0.0.1"); + await once(holder, "listening"); + const address = holder.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP holder."); + try { + const diagnosis = await inspectSyncListenerPort(address.port); + expect(diagnosis.port).toBe(address.port); + const self = diagnosis.holders.find((entry) => entry.pid === process.pid); + expect(self).toBeDefined(); + // The reclaim path matches on the command line and guards PID reuse with + // the start time, so neither may be null for a process we own. + expect(self?.command).toBeTruthy(); + expect(self?.startTime).toBeTruthy(); + expect(Number.isFinite(Date.parse(self!.startTime!))).toBe(true); + } finally { + await new Promise<void>((resolve) => holder.close(() => resolve())); + } + }, 30_000); +}); diff --git a/apps/ade-cli/src/services/sync/sharedSyncListener.ts b/apps/ade-cli/src/services/sync/sharedSyncListener.ts index f0e590306..4d0c067c5 100644 --- a/apps/ade-cli/src/services/sync/sharedSyncListener.ts +++ b/apps/ade-cli/src/services/sync/sharedSyncListener.ts @@ -25,6 +25,7 @@ import { terminatePidGracefullyAsync, } from "../../serviceManager/common"; import { getRuntimeServiceMainPid } from "../../serviceManager"; +import { resolveTrustedWindowsTool } from "../../lib/trustedWindowsTools"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; // Bind the sync host on all interfaces by default so phones on the same @@ -210,11 +211,26 @@ type ParkedEntry = { expireTimer: ReturnType<typeof setTimeout>; }; -function execFileText(command: string, args: string[]): Promise<string | null> { +// `lsof`/`ps` answer in a few milliseconds. PowerShell needs to start a +// runtime and load a CIM module first, so the POSIX budget would kill every +// Windows query before it produced a holder. +const PORT_INSPECT_TIMEOUT_MS = 200; +const WINDOWS_PORT_INSPECT_TIMEOUT_MS = 5_000; + +function execFileText( + command: string, + args: string[], + timeoutMs: number = PORT_INSPECT_TIMEOUT_MS, +): Promise<string | null> { return new Promise((resolve) => { - execFile(command, args, { encoding: "utf8", timeout: 200 }, (error, stdout) => { - resolve(error ? null : String(stdout ?? "")); - }); + execFile( + command, + args, + { encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024, windowsHide: true }, + (error, stdout) => { + resolve(error ? null : String(stdout ?? "")); + }, + ); }); } @@ -849,8 +865,85 @@ export function createSharedSyncListener(options: { }; } -export async function inspectSyncListenerPort(port: number): Promise<SyncListenerPortDiagnosis> { - const lsof = await execFileText( +/** + * PowerShell that reports every listening owner of `port` as JSON. + * + * `Get-NetTCPConnection` is the supported replacement for parsing `netstat` + * output, and pairing it with `Get-CimInstance Win32_Process` gets the command + * line and creation time in the SAME invocation — one process spawn instead of + * the 1 + 2N that the POSIX `lsof`/`ps` path needs. + */ +export function buildWindowsPortHolderQueryArgs(port: number): string[] { + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + throw new Error(`Invalid port for the Windows port-holder query: ${String(port)}`); + } + const query = [ + "$ErrorActionPreference = 'Stop'", + `$owners = @(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue` + + " | Select-Object -ExpandProperty OwningProcess | Sort-Object -Unique)", + "$holders = @(foreach ($owner in $owners) {", + " $target = Get-CimInstance Win32_Process -Filter \"ProcessId = $owner\" -ErrorAction SilentlyContinue", + " if ($null -eq $target) { continue }", + " $startTime = ''", + " try { $startTime = ([datetime]$target.CreationDate).ToUniversalTime().ToString('o') } catch { $startTime = '' }", + " [ordered]@{ pid = [int]$target.ProcessId; command = [string]$target.CommandLine; startTime = [string]$startTime }", + "})", + "[Console]::Out.Write((ConvertTo-Json -InputObject @($holders) -Compress -Depth 3))", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; +} + +export function parseWindowsPortHolders(raw: string | null): SyncListenerPortDiagnosis["holders"] { + const text = raw?.trim(); + // PowerShell emits nothing for an empty collection, which is a legitimate + // "no holders" answer rather than a parse failure. + if (!text) return []; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return []; + } + const entries = Array.isArray(parsed) ? parsed : [parsed]; + const holders: SyncListenerPortDiagnosis["holders"] = []; + const seen = new Set<number>(); + for (const entry of entries) { + if (typeof entry !== "object" || entry == null) continue; + const record = entry as { pid?: unknown; command?: unknown; startTime?: unknown }; + const pid = Number(record.pid); + if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) continue; + seen.add(pid); + // A holder owned by another user yields a null CommandLine without + // elevation. Keep the pid: it still proves the port is genuinely occupied, + // and `findStaleHolder` already refuses to reap a holder it cannot identify. + const command = typeof record.command === "string" ? record.command.trim() : ""; + const startTime = typeof record.startTime === "string" ? record.startTime.trim() : ""; + holders.push({ pid, command: command || null, startTime: startTime || null }); + } + return holders; +} + +async function inspectWindowsSyncListenerPort( + port: number, + exec: typeof execFileText, +): Promise<SyncListenerPortDiagnosis> { + let powershell: string; + let args: string[]; + try { + powershell = resolveTrustedWindowsTool("powershell"); + args = buildWindowsPortHolderQueryArgs(port); + } catch { + return { port, holders: [] }; + } + const raw = await exec(powershell, args, WINDOWS_PORT_INSPECT_TIMEOUT_MS); + return { port, holders: parseWindowsPortHolders(raw) }; +} + +async function inspectPosixSyncListenerPort( + port: number, + exec: typeof execFileText, +): Promise<SyncListenerPortDiagnosis> { + const lsof = await exec( "lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], ); @@ -866,8 +959,8 @@ export async function inspectSyncListenerPort(port: number): Promise<SyncListene port, holders: await Promise.all(pids.map(async (pid) => { const [commandResult, startResult] = await Promise.all([ - execFileText("ps", ["-p", String(pid), "-o", "command="]), - execFileText("ps", ["-p", String(pid), "-o", "lstart="]), + exec("ps", ["-p", String(pid), "-o", "command="]), + exec("ps", ["-p", String(pid), "-o", "lstart="]), ]); const command = commandResult?.trim() ?? ""; const startTime = startResult?.trim() ?? ""; @@ -879,3 +972,26 @@ export async function inspectSyncListenerPort(port: number): Promise<SyncListene })), }; } + +/** + * Processes listening on `port`, dispatched per platform. + * + * Both consumers degrade badly when this silently answers "nothing": the + * stale-port reclaim in `createSharedSyncListener` cannot recognise a wedged + * same-channel sibling and permanently drifts mobile sync onto a fallback port, + * and `ade doctor` reports "no holders visible to this user" with advice that + * only makes sense on macOS. + */ +export async function inspectSyncListenerPort( + port: number, + deps: { + platform?: NodeJS.Platform; + exec?: typeof execFileText; + } = {}, +): Promise<SyncListenerPortDiagnosis> { + const platform = deps.platform ?? process.platform; + const exec = deps.exec ?? execFileText; + return platform === "win32" + ? inspectWindowsSyncListenerPort(port, exec) + : inspectPosixSyncListenerPort(port, exec); +} From 58442e1c51afe697b0d9128212cd4b2962b11615 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 02:57:48 -0400 Subject: [PATCH 22/42] ci: assert every platform-gated test has a runner that executes it A platform-gated test assertion could exist with no CI runner that ever executed it, and nothing detected that. The gate reported as "skipped" on every runner the repo has -- or, in the `if (process.platform === "win32") return;` form, as a green pass -- so the file's presence in a job read as coverage that did not exist. scripts/validate-platform-gates.mjs parses every apps/**/*.test.ts(x) for platform gates in each form this repo uses: it.skipIf / it.runIf, the ternary-to-it form, alias constants such as `itUnix` and `crdtHostIt` whose call sites read as a plain `it(...)`, and the vacuous early `return`. It then parses .github/workflows/ci.yml for which test files each job runs and on which runs-on, and fails when a gated assertion has no runner -- including when no job for that platform exists at all, which is the case today for macOS. Adoption is incremental. scripts/platform-gate-baseline.json records the 16 known violations, keyed on (file, kind, form, requires) rather than line number so it survives edits. It is a ratchet: a new or grown entry fails immediately, and an entry that shrank fails too, with an instruction to re-record, so the backlog cannot go stale. `// WINDOWS-GATE: <reason>` and `// DARWIN-GATE: <reason>` are the documented escape hatch. The check is dependency-free and rides in the existing validate-docs job; its node --test suite joins the existing typecheck-ade-cli step. No new job. Based-on: nsxdavid/ADE#999 (cherry picked from commit 9105ca507743aac98cf3d5bc15170261d6930378) --- .github/workflows/ci.yml | 7 +- scripts/platform-gate-baseline.json | 80 +++ scripts/validate-platform-gates.mjs | 848 +++++++++++++++++++++++ scripts/validate-platform-gates.test.mjs | 459 ++++++++++++ 4 files changed, 1393 insertions(+), 1 deletion(-) create mode 100644 scripts/platform-gate-baseline.json create mode 100644 scripts/validate-platform-gates.mjs create mode 100644 scripts/validate-platform-gates.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 294ef61ee..cce44e36c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,7 @@ jobs: # validate-docs job runs; it lands with a sibling lane, so this path does # not resolve until the stack is composed. - name: Test release runtime archive, packaging, and docs-validator guards - run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs scripts/validate-docs.test.mjs + run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs scripts/validate-docs.test.mjs scripts/validate-platform-gates.test.mjs typecheck-web: needs: install @@ -484,6 +484,11 @@ jobs: apps/push-relay/node_modules key: nm-v2-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/webhook-relay/package-lock.json','apps/push-relay/package-lock.json') }} - run: node scripts/validate-docs.mjs + # Asserts that every platform-gated test assertion has a runner in this + # workflow that actually executes it, and bans the vacuous + # `if (process.platform === ...) return;` form that reports green while + # asserting nothing. Pure source + workflow parsing, no dependencies. + - run: node scripts/validate-platform-gates.mjs # ── Gate: all jobs must pass ────────────────────────────────────────── ci-pass: diff --git a/scripts/platform-gate-baseline.json b/scripts/platform-gate-baseline.json new file mode 100644 index 000000000..a1bc7f953 --- /dev/null +++ b/scripts/platform-gate-baseline.json @@ -0,0 +1,80 @@ +{ + "$comment": "Known platform-gate violations, tolerated by scripts/validate-platform-gates.mjs. This file is a ratchet: it may shrink, never grow. Every entry is a test assertion that no CI runner executes, or a vacuous `return` that reports green while asserting nothing. Re-record after burning one down with: node scripts/validate-platform-gates.mjs --update-baseline", + "$tracking": { + "uncovered-gate, requires darwin": "There is no macOS unit-test job in ci.yml at all, so every darwin-gated assertion in the repo runs nowhere. Adding one is its own lane; until it lands these entries cannot be burned down.", + "uncovered-gate, requires win32": "The file is not in any windows-latest job. Fix by adding it to `windows-foundation` in .github/workflows/ci.yml once it is verified green on a native Windows host, or -- for a deliberate exception -- by annotating the gate with `// WINDOWS-GATE: <reason>` and dropping the entry here.", + "vacuous-return": "`if (process.platform === ...) return;` inside a test body reports as a GREEN PASS while asserting nothing. Fix by converting the test to `it.skipIf(<same condition>)(...)` so the skip is reported. Owned by the lanes that own each file." + }, + "violations": [ + { + "file": "apps/ade-cli/src/cli.test.ts", + "kind": "uncovered-gate", + "form": "alias-declaration", + "requires": "darwin", + "count": 1 + }, + { + "file": "apps/ade-cli/src/cli.test.ts", + "kind": "uncovered-gate", + "form": "alias-use", + "requires": "darwin", + "count": 1 + }, + { + "file": "apps/ade-cli/src/cli.test.ts", + "kind": "vacuous-return", + "form": "vacuous-return", + "requires": "n/a", + "count": 1 + }, + { + "file": "apps/ade-cli/src/lib/trustedWindowsTools.test.ts", + "kind": "uncovered-gate", + "form": "alias-declaration", + "requires": "win32", + "count": 1 + }, + { + "file": "apps/ade-cli/src/lib/trustedWindowsTools.test.ts", + "kind": "uncovered-gate", + "form": "alias-use", + "requires": "win32", + "count": 1 + }, + { + "file": "apps/ade-cli/src/services/credentials/credentialStore.test.ts", + "kind": "vacuous-return", + "form": "vacuous-return", + "requires": "n/a", + "count": 1 + }, + { + "file": "apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts", + "kind": "uncovered-gate", + "form": "it.runIf", + "requires": "darwin", + "count": 3 + }, + { + "file": "apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts", + "kind": "vacuous-return", + "form": "vacuous-return", + "requires": "n/a", + "count": 4 + }, + { + "file": "apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts", + "kind": "vacuous-return", + "form": "vacuous-return", + "requires": "n/a", + "count": 2 + }, + { + "file": "apps/desktop/src/main/services/state/kvDb.rebuildRecovery.test.ts", + "kind": "uncovered-gate", + "form": "it.skipIf", + "requires": "darwin|win32", + "count": 1 + } + ] +} diff --git a/scripts/validate-platform-gates.mjs b/scripts/validate-platform-gates.mjs new file mode 100644 index 000000000..4f05d04f6 --- /dev/null +++ b/scripts/validate-platform-gates.mjs @@ -0,0 +1,848 @@ +/** + * Platform-gate registry check. + * + * A platform-gated test assertion can exist with no CI runner that ever + * executes it. Nothing in a normal test run detects that: the gate reports as + * "skipped" (or, in the vacuous-`return` form, as a green pass) on every runner + * the repo actually has, so the file's presence in a job reads as coverage that + * does not exist. + * + * This script closes that gap. It + * 1. parses every `apps/**\/*.test.ts(x)` for platform gates in all the forms + * this repo uses, including alias constants and the vacuous `return`; + * 2. emits a registry of `{file, line, gatedPlatform, form}`; + * 3. asserts the invariant that every gated assertion has a runner that + * executes it, by parsing `.github/workflows/ci.yml` for which test files + * each job runs and on which `runs-on`; and + * 4. bans the vacuous `return` form, which reports green while asserting + * nothing. + * + * Known violations live in `scripts/platform-gate-baseline.json`. The baseline + * is a ratchet: it is tolerated but may not grow, and it may not go stale. + * + * Usage: + * node scripts/validate-platform-gates.mjs + * node scripts/validate-platform-gates.mjs --registry # print the registry + * node scripts/validate-platform-gates.mjs --update-baseline + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const repoRoot = process.cwd(); + +/** + * The platforms a gate condition can select. `linux` is the platform every + * default `ubuntu-latest` job runs on, so a gate that still runs on linux is + * covered by the ordinary unit jobs and needs no dedicated runner. + */ +export const KNOWN_PLATFORMS = ["darwin", "linux", "win32"]; + +const TEST_FILE_PATTERN = /\.test\.tsx?$/; +const SKIPPED_DIRECTORIES = new Set([ + "node_modules", + "dist", + "dist-static", + "build", + "out", + "coverage", + ".vite", + ".next", + ".turbo", +]); + +/** + * File identifiers are repo-relative and always forward-slash separated, on + * every host OS, so a gate site parsed on Windows compares equal to the path + * spelled in `ci.yml`. This is the single boundary where a native path becomes + * an identifier. `sep` is injectable so Windows behaviour stays testable on a + * POSIX runner. + */ +export function toFileId(nativeRelativePath, sep = path.sep) { + if (sep === "/") return nativeRelativePath; + return nativeRelativePath.split(sep).join("/"); +} + +// ── Platform condition parsing ──────────────────────────────────────────── +// +// Conditions are parsed rather than evaluated so that an unrecognised +// expression (`!isCrsqliteAvailable()`, an env-var check) is reported as "not a +// platform gate" instead of being silently mis-classified. + +function tokenizeCondition(source) { + const tokens = []; + let index = 0; + while (index < source.length) { + const char = source[index]; + if (/\s/.test(char)) { + index += 1; + continue; + } + if (char === '"' || char === "'" || char === "`") { + const end = source.indexOf(char, index + 1); + if (end === -1) throw new Error("unterminated string literal"); + tokens.push({ type: "string", value: source.slice(index + 1, end) }); + index = end + 1; + continue; + } + const operator = ["===", "!==", "==", "!=", "&&", "||"].find((candidate) => + source.startsWith(candidate, index), + ); + if (operator) { + tokens.push({ type: "operator", value: operator }); + index += operator.length; + continue; + } + if (char === "(" || char === ")" || char === "!") { + tokens.push({ type: char === "!" ? "not" : char, value: char }); + index += 1; + continue; + } + const identifier = /^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*/.exec(source.slice(index)); + if (!identifier) throw new Error(`unexpected character ${JSON.stringify(char)}`); + tokens.push({ type: "identifier", value: identifier[0].replace(/\s+/g, "") }); + index += identifier[0].length; + } + return tokens; +} + +/** + * Parses a condition into a predicate over a platform string. Throws when the + * expression is not built purely out of `process.platform` comparisons. + */ +function parseConditionTokens(tokens) { + let position = 0; + const peek = () => tokens[position]; + + function parseOr() { + let left = parseAnd(); + while (peek()?.value === "||") { + position += 1; + const right = parseAnd(); + const previous = left; + left = (platform) => previous(platform) || right(platform); + } + return left; + } + + function parseAnd() { + let left = parseUnary(); + while (peek()?.value === "&&") { + position += 1; + const right = parseUnary(); + const previous = left; + left = (platform) => previous(platform) && right(platform); + } + return left; + } + + function parseUnary() { + if (peek()?.type === "not") { + position += 1; + const operand = parseUnary(); + return (platform) => !operand(platform); + } + return parsePrimary(); + } + + function parsePrimary() { + const token = peek(); + if (!token) throw new Error("unexpected end of condition"); + if (token.type === "(") { + position += 1; + const inner = parseOr(); + if (peek()?.type !== ")") throw new Error("unbalanced parentheses"); + position += 1; + return inner; + } + return parseComparison(); + } + + function parseComparison() { + const left = tokens[position]; + const operator = tokens[position + 1]; + const right = tokens[position + 2]; + if (!left || !operator || !right || operator.type !== "operator") { + throw new Error("expected a process.platform comparison"); + } + position += 3; + + const negated = operator.value === "!==" || operator.value === "!="; + let literal; + if (left.type === "identifier" && left.value === "process.platform" && right.type === "string") { + literal = right.value; + } else if ( + right.type === "identifier" && + right.value === "process.platform" && + left.type === "string" + ) { + literal = left.value; + } else { + throw new Error("comparison does not reference process.platform"); + } + return (platform) => (platform === literal) !== negated; + } + + const predicate = parseOr(); + if (position !== tokens.length) throw new Error("trailing tokens in condition"); + return predicate; +} + +/** + * Returns the sorted list of `KNOWN_PLATFORMS` the condition is true for, or + * `null` when the expression is not a platform condition at all. + */ +export function platformsMatching(conditionSource) { + if (!conditionSource || !conditionSource.includes("process.platform")) return null; + let predicate; + try { + predicate = parseConditionTokens(tokenizeCondition(conditionSource)); + } catch { + return null; + } + try { + return KNOWN_PLATFORMS.filter((platform) => predicate(platform)); + } catch { + return null; + } +} + +/** A human-readable label for the platforms a gated assertion runs on. */ +export function gatedPlatformLabel(platforms) { + if (platforms.length === 0) return "none"; + if (platforms.length === KNOWN_PLATFORMS.length) return "all"; + return platforms.join("|"); +} + +// ── Gate-site extraction ────────────────────────────────────────────────── + +const RUNNER_CALLEES = new Set(["it", "test", "describe"]); + +/** `it` and `test` run; `it.skip`/`describe.skip`/`it.todo` do not. */ +function calleeRuns(callee) { + const [head, ...rest] = callee.split("."); + if (!RUNNER_CALLEES.has(head)) return null; + if (rest.length === 0) return true; + if (rest.length === 1 && (rest[0] === "skip" || rest[0] === "todo")) return false; + if (rest.length === 1 && (rest[0] === "only" || rest[0] === "concurrent")) return true; + return null; +} + +function lineNumberForIndex(source, index) { + let line = 1; + for (let i = 0; i < index; i += 1) { + if (source.charCodeAt(i) === 10) line += 1; + } + return line; +} + +/** Reads the balanced `(...)` starting at `openIndex`; returns the inner text. */ +function readBalancedParens(source, openIndex) { + let depth = 0; + for (let i = openIndex; i < source.length; i += 1) { + const char = source[i]; + if (char === "(") depth += 1; + else if (char === ")") { + depth -= 1; + if (depth === 0) return { inner: source.slice(openIndex + 1, i), endIndex: i }; + } + } + return null; +} + +/** + * `// WINDOWS-GATE: <reason>` / `// DARWIN-GATE: <reason>` is the documented + * escape hatch. It is honoured on the gate line itself or on any of the three + * preceding lines, so a comment block above the gate works. + */ +const ANNOTATION_PATTERN = /\/\/\s*(WINDOWS|DARWIN)-GATE:\s*(\S.*)$/; +const ANNOTATION_LOOKBACK = 3; + +export function annotationFor(lines, lineNumber) { + const first = Math.max(1, lineNumber - ANNOTATION_LOOKBACK); + for (let candidate = lineNumber; candidate >= first; candidate -= 1) { + const match = ANNOTATION_PATTERN.exec(lines[candidate - 1] ?? ""); + if (match) { + return { platform: match[1] === "WINDOWS" ? "win32" : "darwin", reason: match[2].trim() }; + } + } + return null; +} + +const CONDITIONAL_RUNNER_PATTERN = /\b(it|test|describe)\s*\.\s*(skipIf|runIf)\s*\(/g; +const TERNARY_CALL_PATTERN = + /\(\s*([^;{}]*?process\.platform[^;{}]*?)\s*\?\s*((?:it|test|describe)(?:\.\w+)?)\s*:\s*((?:it|test|describe)(?:\.\w+)?)\s*\)\s*\(/g; +const ALIAS_DECLARATION_PATTERN = + /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;=]*?process\.platform[^;]*?)\s*\?\s*((?:it|test|describe)(?:\.\w+)?)\s*:\s*((?:it|test|describe)(?:\.\w+)?)\s*;/g; +const VACUOUS_RETURN_PATTERN = + /^\s*if\s*\(([^)]*process\.platform[^)]*)\)\s*(?:return;?|\{\s*return;?\s*\})\s*$/; + +/** + * The set of platforms a `cond ? whenTrue : whenFalse` runner selection + * actually runs on, or `null` when neither branch is a recognisable runner. + */ +function ternaryRunPlatforms(condition, whenTrue, whenFalse) { + const matching = platformsMatching(condition); + if (!matching) return null; + const trueRuns = calleeRuns(whenTrue); + const falseRuns = calleeRuns(whenFalse); + if (trueRuns === null || falseRuns === null) return null; + const matched = new Set(matching); + return KNOWN_PLATFORMS.filter((platform) => (matched.has(platform) ? trueRuns : falseRuns)); +} + +/** + * Extracts every platform gate in one test file. + * + * Returns records of `{file, line, form, platforms, gatedPlatform, snippet}` + * plus, for the banned form, `{form: "vacuous-return"}`. + */ +export function collectGates(source, file) { + const lines = source.split(/\r?\n/); + const gates = []; + + const push = (index, form, platforms, snippet, extra = {}) => { + const line = lineNumberForIndex(source, index); + gates.push({ + file, + line, + form, + platforms, + gatedPlatform: gatedPlatformLabel(platforms), + snippet: snippet.trim().slice(0, 160), + annotation: annotationFor(lines, line), + ...extra, + }); + }; + + // `it.skipIf(...)` / `describe.runIf(...)` + CONDITIONAL_RUNNER_PATTERN.lastIndex = 0; + let match; + while ((match = CONDITIONAL_RUNNER_PATTERN.exec(source)) !== null) { + const openIndex = CONDITIONAL_RUNNER_PATTERN.lastIndex - 1; + const balanced = readBalancedParens(source, openIndex); + if (!balanced) continue; + const matching = platformsMatching(balanced.inner); + if (!matching) continue; + const matched = new Set(matching); + const runs = + match[2] === "runIf" + ? KNOWN_PLATFORMS.filter((platform) => matched.has(platform)) + : KNOWN_PLATFORMS.filter((platform) => !matched.has(platform)); + push(match.index, `${match[1]}.${match[2]}`, runs, `${match[0]}${balanced.inner})`); + } + + // `(process.platform === "win32" ? it : it.skip)(...)` + TERNARY_CALL_PATTERN.lastIndex = 0; + while ((match = TERNARY_CALL_PATTERN.exec(source)) !== null) { + const runs = ternaryRunPlatforms(match[1], match[2], match[3]); + if (!runs) continue; + push(match.index, "ternary-runner", runs, match[0]); + } + + // `const itUnix = process.platform === "win32" ? it.skip : it;` and each use. + // The alias is the sneakiest form: the gate is invisible at the call site. + ALIAS_DECLARATION_PATTERN.lastIndex = 0; + const aliases = []; + while ((match = ALIAS_DECLARATION_PATTERN.exec(source)) !== null) { + const runs = ternaryRunPlatforms(match[2], match[3], match[4]); + if (!runs) continue; + aliases.push({ name: match[1], runs, declarationIndex: match.index }); + push(match.index, "alias-declaration", runs, match[0], { alias: match[1] }); + } + + for (const alias of aliases) { + const usePattern = new RegExp(`(^|[^\\w$.])(${alias.name})\\s*\\(`, "g"); + let use; + while ((use = usePattern.exec(source)) !== null) { + if (use.index === alias.declarationIndex) continue; + const useIndex = use.index + use[1].length; + if (useIndex < alias.declarationIndex + alias.name.length) continue; + push(useIndex, "alias-use", alias.runs, use[0], { alias: alias.name }); + } + } + + // `if (process.platform === "win32") return;` — reports as a GREEN PASS while + // asserting nothing. + for (let index = 0; index < lines.length; index += 1) { + const inline = VACUOUS_RETURN_PATTERN.exec(lines[index]); + let condition = inline?.[1] ?? null; + if (!condition) { + const opener = /^\s*if\s*\(([^)]*process\.platform[^)]*)\)\s*\{\s*$/.exec(lines[index]); + if (opener && /^\s*return;?\s*$/.test(lines[index + 1] ?? "") && /^\s*\}\s*$/.test(lines[index + 2] ?? "")) { + condition = opener[1]; + } + } + if (!condition) continue; + const matching = platformsMatching(condition); + if (!matching) continue; + const matched = new Set(matching); + const runs = KNOWN_PLATFORMS.filter((platform) => !matched.has(platform)); + gates.push({ + file, + line: index + 1, + form: "vacuous-return", + platforms: runs, + gatedPlatform: gatedPlatformLabel(runs), + snippet: lines[index].trim().slice(0, 160), + annotation: annotationFor(lines, index + 1), + }); + } + + gates.sort((a, b) => a.line - b.line || (a.form < b.form ? -1 : a.form > b.form ? 1 : 0)); + return gates; +} + +// ── CI workflow parsing ─────────────────────────────────────────────────── + +function indentOf(line) { + return line.length - line.trimStart().length; +} + +/** + * Minimal, dependency-free reader for the slice of `ci.yml` this check needs: + * every job's `runs-on` (including a `${{ matrix.os }}` fan-out) and the text + * of every step's `run:`, block scalars included. + */ +export function parseCiWorkflow(yamlText) { + const lines = yamlText.split(/\r?\n/); + const jobs = []; + let current = null; + let jobsIndent = null; + let jobIndent = null; + + let index = 0; + const readScalar = (rawValue, ownerIndent) => { + const trimmed = rawValue.trim(); + if (trimmed === "|" || trimmed === "|-" || trimmed === ">" || trimmed === ">-" || trimmed === "|+") { + const folded = trimmed.startsWith(">"); + const collected = []; + let cursor = index + 1; + while (cursor < lines.length) { + const line = lines[cursor]; + if (line.trim() !== "" && indentOf(line) <= ownerIndent) break; + collected.push(line.trim()); + cursor += 1; + } + index = cursor - 1; + return folded ? collected.join(" ") : collected.join("\n"); + } + return trimmed.replace(/^["'](.*)["']$/, "$1"); + }; + + for (; index < lines.length; index += 1) { + const line = lines[index]; + if (line.trim() === "" || line.trim().startsWith("#")) continue; + const indent = indentOf(line); + const content = line.trim(); + + if (jobsIndent === null) { + if (indent === 0 && content === "jobs:") jobsIndent = 0; + continue; + } + + const jobHeader = /^([A-Za-z0-9_.-]+):$/.exec(content); + if (jobHeader && (jobIndent === null || indent === jobIndent) && indent > jobsIndent) { + if (indent === 0) break; // left the jobs mapping entirely + jobIndent = indent; + current = { name: jobHeader[1], runsOn: [], runCommands: [] }; + jobs.push(current); + continue; + } + if (!current || indent <= jobsIndent) continue; + + const runsOn = /^runs-on:\s*(.+)$/.exec(content); + if (runsOn) { + current.runsOn.push(runsOn[1].trim().replace(/^["'](.*)["']$/, "$1")); + continue; + } + const matrixOs = /^(?:-\s+)?os:\s*(.+)$/.exec(content); + if (matrixOs) { + current.matrixOs = current.matrixOs ?? []; + current.matrixOs.push(matrixOs[1].trim().replace(/^["'](.*)["']$/, "$1")); + continue; + } + const run = /^(?:-\s+)?run:\s*(.*)$/.exec(content); + if (run) { + current.runCommands.push(readScalar(run[1], indent)); + } + } + + for (const job of jobs) { + if (job.runsOn.some((value) => value.includes("matrix.os"))) { + job.runsOn = job.runsOn.filter((value) => !value.includes("matrix.os")).concat(job.matrixOs ?? []); + } + delete job.matrixOs; + } + return jobs; +} + +/** Maps a GitHub `runs-on` label onto the `process.platform` it reports. */ +export function runnerPlatform(runsOnLabel) { + const label = runsOnLabel.toLowerCase(); + if (label.startsWith("windows")) return "win32"; + if (label.startsWith("macos")) return "darwin"; + if (label.startsWith("ubuntu")) return "linux"; + return null; +} + +const WHOLE_SUITE_PATTERN = /^(?:npm\s+(?:test|t)|npm\s+run\s+test|npx\s+vitest\s+run)(?:\s|$)/; + +/** + * Works out which test files a job's shell commands run, and in which package. + * Returns `{files: Set<string>, wholeSuiteDirs: Set<string>}` where a + * whole-suite dir means "every test file under this directory runs here" + * (`npm test`, or a sharded `vitest run` with no explicit file arguments). + */ +export function testTargetsForCommand(command, { defaultDir = "" } = {}) { + const files = new Set(); + const wholeSuiteDirs = new Set(); + + for (const rawLine of command.split("\n")) { + let cwd = defaultDir; + for (const rawSegment of rawLine.split(/&&|\|\||;/)) { + const segment = rawSegment.trim(); + if (!segment) continue; + const cd = /^cd\s+(\S+)$/.exec(segment); + if (cd) { + cwd = cd[1].replace(/\/+$/, ""); + continue; + } + const prefix = /--prefix\s+(\S+)/.exec(segment); + const segmentDir = prefix ? prefix[1].replace(/\/+$/, "") : cwd; + const normalized = segment.replace(/--prefix\s+\S+\s*/, "").replace(/\s+/g, " ").trim(); + if (!WHOLE_SUITE_PATTERN.test(normalized)) continue; + + const targets = normalized + .split(/\s+/) + .filter((token) => TEST_FILE_PATTERN.test(token) && !token.startsWith("-")); + if (targets.length === 0) { + wholeSuiteDirs.add(segmentDir); + continue; + } + for (const target of targets) { + const joined = segmentDir ? `${segmentDir}/${target}` : target; + files.add(path.posix.normalize(joined)); + } + } + } + return { files, wholeSuiteDirs }; +} + +/** Aggregates a workflow into `platform -> {files, wholeSuiteDirs}` coverage. */ +export function coverageByPlatform(jobs) { + const coverage = new Map(); + for (const job of jobs) { + const platforms = new Set(job.runsOn.map(runnerPlatform).filter(Boolean)); + if (platforms.size === 0) continue; + const targets = { files: new Set(), wholeSuiteDirs: new Set() }; + for (const command of job.runCommands) { + const parsed = testTargetsForCommand(command); + for (const file of parsed.files) targets.files.add(file); + for (const dir of parsed.wholeSuiteDirs) targets.wholeSuiteDirs.add(dir); + } + if (targets.files.size === 0 && targets.wholeSuiteDirs.size === 0) continue; + for (const platform of platforms) { + const bucket = coverage.get(platform) ?? { files: new Set(), wholeSuiteDirs: new Set(), jobs: [] }; + for (const file of targets.files) bucket.files.add(file); + for (const dir of targets.wholeSuiteDirs) bucket.wholeSuiteDirs.add(dir); + bucket.jobs.push(job.name); + coverage.set(platform, bucket); + } + } + return coverage; +} + +export function isFileCovered(coverage, platform, file) { + const bucket = coverage.get(platform); + if (!bucket) return false; + if (bucket.files.has(file)) return true; + for (const dir of bucket.wholeSuiteDirs) { + if (dir && file.startsWith(`${dir}/`)) return true; + } + return false; +} + +// ── Invariant evaluation ────────────────────────────────────────────────── + +export const VIOLATION_KINDS = { + uncoveredGate: "uncovered-gate", + unsatisfiableGate: "unsatisfiable-gate", + vacuousReturn: "vacuous-return", +}; + +function fixHintFor(violation) { + if (violation.kind === VIOLATION_KINDS.vacuousReturn) { + return `replace the bare \`return\` with \`it.skipIf(${violation.snippetCondition ?? "process.platform === \"win32\""})(...)\` so the skip is reported instead of passing green`; + } + if (violation.kind === VIOLATION_KINDS.unsatisfiableGate) { + return "the condition selects no platform, so this assertion can never run anywhere; delete the gate or fix the condition"; + } + const platforms = violation.platforms.join(" or "); + const label = violation.platforms.includes("win32") ? "windows-latest" : "macos-*"; + return `add ${violation.file} to a ${label} job in .github/workflows/ci.yml, or annotate the gate with \`// ${violation.platforms.includes("win32") ? "WINDOWS" : "DARWIN"}-GATE: <reason>\` (runs only on: ${platforms})`; +} + +export function evaluateGates(gates, coverage) { + const violations = []; + for (const gate of gates) { + if (gate.form === "vacuous-return") { + violations.push({ + kind: VIOLATION_KINDS.vacuousReturn, + file: gate.file, + line: gate.line, + form: gate.form, + platforms: gate.platforms, + requires: "n/a", + snippet: gate.snippet, + }); + continue; + } + if (gate.platforms.length === 0) { + violations.push({ + kind: VIOLATION_KINDS.unsatisfiableGate, + file: gate.file, + line: gate.line, + form: gate.form, + platforms: gate.platforms, + requires: "none", + snippet: gate.snippet, + }); + continue; + } + const covered = gate.platforms.some((platform) => isFileCovered(coverage, platform, gate.file)); + if (covered) continue; + if (gate.annotation && gate.platforms.includes(gate.annotation.platform)) continue; + violations.push({ + kind: VIOLATION_KINDS.uncoveredGate, + file: gate.file, + line: gate.line, + form: gate.form, + platforms: gate.platforms, + requires: gate.platforms.join("|"), + snippet: gate.snippet, + }); + } + return violations; +} + +// ── Baseline ratchet ────────────────────────────────────────────────────── + +export const BASELINE_PATH = "scripts/platform-gate-baseline.json"; + +/** + * Line numbers churn on every edit, so the baseline is keyed on the stable + * `(file, kind, form, requires)` tuple and counted. That lets the backlog burn + * down file by file while a new gate in an already-listed file still fails. + */ +export function baselineKey(entry) { + return [entry.file, entry.kind, entry.form, entry.requires].join("|"); +} + +export function summarizeViolations(violations) { + const counts = new Map(); + for (const violation of violations) { + const key = baselineKey(violation); + const existing = counts.get(key); + if (existing) { + existing.count += 1; + existing.lines.push(violation.line); + continue; + } + counts.set(key, { + file: violation.file, + kind: violation.kind, + form: violation.form, + requires: violation.requires, + count: 1, + lines: [violation.line], + }); + } + return [...counts.values()].sort((a, b) => (baselineKey(a) < baselineKey(b) ? -1 : 1)); +} + +/** + * Compares the current violations against the baseline. New or grown entries + * fail immediately; entries that shrank or disappeared fail too, with an + * instruction to re-record the baseline, so the ratchet cannot go stale. + */ +export function diffAgainstBaseline(violations, baseline) { + const current = new Map(summarizeViolations(violations).map((entry) => [baselineKey(entry), entry])); + const recorded = new Map((baseline?.violations ?? []).map((entry) => [baselineKey(entry), entry])); + + const newViolations = []; + const staleEntries = []; + + for (const [key, entry] of current) { + const allowed = recorded.get(key)?.count ?? 0; + if (entry.count > allowed) { + newViolations.push({ ...entry, allowed, excess: entry.count - allowed }); + } + } + for (const [key, entry] of recorded) { + const actual = current.get(key)?.count ?? 0; + if (actual < entry.count) staleEntries.push({ ...entry, actual }); + } + return { newViolations, staleEntries }; +} + +export function renderBaseline(violations) { + return `${JSON.stringify( + { + $comment: [ + "Known platform-gate violations, tolerated by scripts/validate-platform-gates.mjs.", + "This file is a ratchet: it may shrink, never grow. Every entry is a test", + "assertion that no CI runner executes, or a vacuous `return` that reports", + "green while asserting nothing.", + "Re-record after burning one down with:", + "node scripts/validate-platform-gates.mjs --update-baseline", + ].join(" "), + $tracking: { + "uncovered-gate, requires darwin": + "There is no macOS unit-test job in ci.yml at all, so every darwin-gated" + + " assertion in the repo runs nowhere. Adding one is its own lane; until it" + + " lands these entries cannot be burned down.", + "uncovered-gate, requires win32": + "The file is not in any windows-latest job. Fix by adding it to" + + " `windows-foundation` in .github/workflows/ci.yml once it is verified green" + + " on a native Windows host, or -- for a deliberate exception -- by annotating" + + " the gate with `// WINDOWS-GATE: <reason>` and dropping the entry here.", + "vacuous-return": + "`if (process.platform === ...) return;` inside a test body reports as a" + + " GREEN PASS while asserting nothing. Fix by converting the test to" + + " `it.skipIf(<same condition>)(...)` so the skip is reported. Owned by the" + + " lanes that own each file.", + }, + violations: summarizeViolations(violations).map(({ lines, ...entry }) => entry), + }, + null, + 2, + )}\n`; +} + +// ── Scanning ────────────────────────────────────────────────────────────── + +async function walkTestFiles(dir, found) { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (SKIPPED_DIRECTORIES.has(entry.name)) continue; + await walkTestFiles(fullPath, found); + continue; + } + if (TEST_FILE_PATTERN.test(entry.name)) { + found.push({ absolutePath: fullPath, file: toFileId(path.relative(repoRoot, fullPath)) }); + } + } +} + +export async function buildRegistry({ root = repoRoot } = {}) { + const found = []; + await walkTestFiles(path.join(root, "apps"), found); + const gates = []; + for (const { absolutePath, file } of found) { + const source = await fs.readFile(absolutePath, "utf8"); + if (!source.includes("process.platform")) continue; + gates.push(...collectGates(source, file)); + } + gates.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line)); + return { gates, scannedFiles: found.length }; +} + +async function readBaseline(baselinePath) { + try { + return JSON.parse(await fs.readFile(baselinePath, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return { violations: [] }; + throw error; + } +} + +async function main(argv) { + const wantsRegistry = argv.includes("--registry"); + const updateBaseline = argv.includes("--update-baseline"); + + const { gates, scannedFiles } = await buildRegistry(); + const workflowPath = path.join(repoRoot, ".github", "workflows", "ci.yml"); + const jobs = parseCiWorkflow(await fs.readFile(workflowPath, "utf8")); + const coverage = coverageByPlatform(jobs); + + if (wantsRegistry) { + console.log( + JSON.stringify( + { + scannedFiles, + coverage: Object.fromEntries( + [...coverage].map(([platform, bucket]) => [ + platform, + { jobs: bucket.jobs, wholeSuiteDirs: [...bucket.wholeSuiteDirs], files: [...bucket.files].sort() }, + ]), + ), + gates: gates.map(({ file, line, gatedPlatform, form, annotation }) => ({ + file, + line, + gatedPlatform, + form, + ...(annotation ? { annotation: annotation.reason } : {}), + })), + }, + null, + 2, + ), + ); + } + + const violations = evaluateGates(gates, coverage); + const baselinePath = path.join(repoRoot, ...BASELINE_PATH.split("/")); + + if (updateBaseline) { + await fs.writeFile(baselinePath, renderBaseline(violations), "utf8"); + console.log( + `Recorded ${violations.length} platform-gate violation(s) in ${BASELINE_PATH}.`, + ); + return; + } + + const baseline = await readBaseline(baselinePath); + const { newViolations, staleEntries } = diffAgainstBaseline(violations, baseline); + + if (newViolations.length > 0 || staleEntries.length > 0) { + console.error("Platform-gate validation failed:"); + const byKey = new Map(summarizeViolations(violations).map((entry) => [baselineKey(entry), entry])); + for (const entry of newViolations) { + const lines = (byKey.get(baselineKey(entry))?.lines ?? []).join(", "); + console.error( + `- ${entry.file}:${lines}: ${entry.count} ${entry.kind} (form: ${entry.form}) but the baseline allows ${entry.allowed}`, + ); + console.error( + ` fix: ${fixHintFor({ ...entry, platforms: entry.requires === "n/a" || entry.requires === "none" ? [] : entry.requires.split("|") })}`, + ); + } + for (const entry of staleEntries) { + console.error( + `- ${BASELINE_PATH}: stale entry for ${entry.file} (${entry.kind}, form: ${entry.form}) records ${entry.count} but only ${entry.actual} remain`, + ); + console.error(" fix: node scripts/validate-platform-gates.mjs --update-baseline, and commit the shrunk baseline"); + } + process.exit(1); + } + + const baselineCount = (baseline.violations ?? []).reduce((total, entry) => total + entry.count, 0); + console.log( + `Platform-gate validation passed: ${gates.length} gate site(s) across ${scannedFiles} test file(s); ` + + `${baselineCount} known violation(s) remain in ${BASELINE_PATH}.`, + ); +} + +const isDirectRun = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (isDirectRun) { + await main(process.argv.slice(2)); +} diff --git a/scripts/validate-platform-gates.test.mjs b/scripts/validate-platform-gates.test.mjs new file mode 100644 index 000000000..4ed3af3a3 --- /dev/null +++ b/scripts/validate-platform-gates.test.mjs @@ -0,0 +1,459 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + annotationFor, + baselineKey, + collectGates, + coverageByPlatform, + diffAgainstBaseline, + evaluateGates, + gatedPlatformLabel, + isFileCovered, + parseCiWorkflow, + platformsMatching, + runnerPlatform, + summarizeViolations, + testTargetsForCommand, + toFileId, + VIOLATION_KINDS, +} from "./validate-platform-gates.mjs"; + +const WIN = "\\"; +const POSIX = "/"; + +// A gate site parsed on Windows must produce the same identifier as the path +// spelled in ci.yml, so the separator is injected rather than read from the host. +test("normalizes native Windows test paths into forward-slash file ids", () => { + assert.equal( + toFileId("apps\\ade-cli\\src\\cli.test.ts", WIN), + "apps/ade-cli/src/cli.test.ts", + ); + assert.equal(toFileId("apps/ade-cli/src/cli.test.ts", POSIX), "apps/ade-cli/src/cli.test.ts"); + // A backslash is a legal filename character on POSIX and must survive verbatim. + assert.equal(toFileId("apps/odd\\name.test.ts", POSIX), "apps/odd\\name.test.ts"); +}); + +test("resolves platform conditions to the platforms they select", () => { + assert.deepEqual(platformsMatching('process.platform === "win32"'), ["win32"]); + assert.deepEqual(platformsMatching('process.platform !== "win32"'), ["darwin", "linux"]); + assert.deepEqual(platformsMatching('process.platform === "darwin"'), ["darwin"]); + assert.deepEqual(platformsMatching('"win32" === process.platform'), ["win32"]); + assert.deepEqual( + platformsMatching('process.platform === "darwin" || process.platform === "win32"'), + ["darwin", "win32"], + ); + assert.deepEqual( + platformsMatching('!(process.platform === "linux")'), + ["darwin", "win32"], + ); + assert.deepEqual( + platformsMatching('process.platform !== "linux" && process.platform !== "darwin"'), + ["win32"], + ); +}); + +test("reports non-platform conditions as unrecognised instead of guessing", () => { + assert.equal(platformsMatching("!isCrsqliteAvailable()"), null); + assert.equal(platformsMatching("!e2eConfig"), null); + assert.equal(platformsMatching(""), null); + // References process.platform but through a helper call this parser will not + // evaluate: better to say nothing than to classify it wrongly. + assert.equal(platformsMatching('normalize(process.platform).startsWith("win")'), null); +}); + +test("labels the platforms a gated assertion runs on", () => { + assert.equal(gatedPlatformLabel(["win32"]), "win32"); + assert.equal(gatedPlatformLabel(["darwin", "linux"]), "darwin|linux"); + assert.equal(gatedPlatformLabel(["darwin", "linux", "win32"]), "all"); + assert.equal(gatedPlatformLabel([]), "none"); +}); + +test("detects it.skipIf / it.runIf / describe.skipIf gates and inverts skipIf", () => { + const source = [ + 'it.skipIf(process.platform !== "win32")("windows only", () => {});', + 'it.skipIf(process.platform === "win32")("everything but windows", () => {});', + 'it.runIf(process.platform === "darwin")("mac only", () => {});', + 'describe.skipIf(process.platform !== "darwin")("mac suite", () => {});', + 'test.runIf(process.platform === "win32")("windows only", () => {});', + 'describe.skipIf(!isCrsqliteAvailable())("not a platform gate", () => {});', + ].join("\n"); + + const gates = collectGates(source, "apps/x/a.test.ts"); + assert.deepEqual( + gates.map((gate) => [gate.line, gate.form, gate.gatedPlatform]), + [ + [1, "it.skipIf", "win32"], + [2, "it.skipIf", "darwin|linux"], + [3, "it.runIf", "darwin"], + [4, "describe.skipIf", "darwin"], + [5, "test.runIf", "win32"], + ], + ); +}); + +test("detects the ternary-to-it call form", () => { + const source = [ + '(process.platform === "win32" ? it : it.skip)(', + ' "creates the service definition", () => {},', + ");", + '(process.platform === "win32" ? it.skip : it)("posix only", () => {});', + ].join("\n"); + + const gates = collectGates(source, "apps/x/b.test.ts"); + assert.deepEqual( + gates.map((gate) => [gate.line, gate.form, gate.gatedPlatform]), + [ + [1, "ternary-runner", "win32"], + [4, "ternary-runner", "darwin|linux"], + ], + ); +}); + +// The alias form is the one that hides best in review: the call site reads as a +// plain `it(...)` and the gate lives a few hundred lines away. +test("resolves alias constants and attributes every use of them", () => { + const source = [ + 'const itUnix = process.platform === "win32" ? it.skip : it;', + 'const crdtHostIt = process.platform === "darwin" ? it : it.skip;', + "", + 'describe("suite", () => {', + ' itUnix("keeps the runtime alive", async () => {});', + ' crdtHostIt("hosts crdt sync", async () => {});', + ' itUnix("restarts a stale daemon", async () => {});', + ' it("ungated", () => {});', + "});", + ].join("\n"); + + const gates = collectGates(source, "apps/x/c.test.ts"); + assert.deepEqual( + gates.map((gate) => [gate.line, gate.form, gate.alias ?? null, gate.gatedPlatform]), + [ + [1, "alias-declaration", "itUnix", "darwin|linux"], + [2, "alias-declaration", "crdtHostIt", "darwin"], + [5, "alias-use", "itUnix", "darwin|linux"], + [6, "alias-use", "crdtHostIt", "darwin"], + [7, "alias-use", "itUnix", "darwin|linux"], + ], + ); +}); + +test("does not mistake a same-named property access for an alias use", () => { + const source = [ + 'const posixIt = process.platform === "win32" ? it.skip : it;', + "helpers.posixIt(1);", + ' posixIt("real use", () => {});', + ].join("\n"); + + const gates = collectGates(source, "apps/x/d.test.ts"); + assert.deepEqual( + gates.filter((gate) => gate.form === "alias-use").map((gate) => gate.line), + [3], + ); +}); + +// This form reports as a GREEN PASS on the gated platform while asserting +// nothing, which is why it is banned outright rather than merely registered. +test("detects the vacuous early-return form, inline and as a block", () => { + const source = [ + 'it("bounds doctor when a dead socket never responds", async () => {', + ' if (process.platform === "win32") return;', + " expect(1).toBe(1);", + "});", + 'it("block form", async () => {', + ' if (process.platform !== "linux") {', + " return;", + " }", + " expect(1).toBe(1);", + "});", + 'it("branching assertion, not a gate", () => {', + ' if (process.platform === "win32") {', + ' expect(command).toContain("ade-tool-gate.cjs");', + " }", + "});", + ].join("\n"); + + const gates = collectGates(source, "apps/x/e.test.ts"); + assert.deepEqual( + gates.map((gate) => [gate.line, gate.form, gate.gatedPlatform]), + [ + [2, "vacuous-return", "darwin|linux"], + [6, "vacuous-return", "linux"], + ], + ); +}); + +test("reads the WINDOWS-GATE / DARWIN-GATE escape hatch on and above the gate line", () => { + const lines = [ + "// WINDOWS-GATE: no ConPTY on the hosted runner", + 'it.runIf(process.platform === "win32")("a", () => {});', + "", + 'it.runIf(process.platform === "darwin")("b", () => {}); // DARWIN-GATE: needs a signed bundle', + "", + "", + "", + 'it.runIf(process.platform === "win32")("c", () => {});', + ]; + assert.deepEqual(annotationFor(lines, 2), { + platform: "win32", + reason: "no ConPTY on the hosted runner", + }); + assert.deepEqual(annotationFor(lines, 4), { + platform: "darwin", + reason: "needs a signed bundle", + }); + // Four lines above is out of the lookback window. + assert.equal(annotationFor(lines, 8), null); +}); + +test("parses ci.yml jobs, runs-on, block scalars, and a matrix os fan-out", () => { + const workflow = [ + "name: CI", + "on:", + " push:", + " branches: [main]", + "jobs:", + " test-desktop:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + " - run: cd apps/desktop && npx vitest run --shard=1/8", + " windows-foundation:", + " runs-on: windows-latest", + " steps:", + " - name: Test Windows contracts", + " run: >-", + " cd apps/ade-cli && npx vitest run", + " src/bootstrap.test.ts", + " src/serviceManager/common.test.ts", + " - run: cd apps/desktop && npx vitest run src/renderer/lib/platform.test.ts", + " build-runtime-binaries:", + " runs-on: ${{ matrix.os }}", + " strategy:", + " matrix:", + " include:", + " - target: darwin-arm64", + " os: macos-15", + " - target: linux-x64", + " os: ubuntu-latest", + " steps:", + " - run: |", + " cd apps/ade-cli && npm ci", + " cd apps/ade-cli && npm run build:static", + ].join("\n"); + + const jobs = parseCiWorkflow(workflow); + assert.deepEqual( + jobs.map((job) => job.name), + ["test-desktop", "windows-foundation", "build-runtime-binaries"], + ); + assert.deepEqual(jobs[1].runsOn, ["windows-latest"]); + assert.equal( + jobs[1].runCommands[0], + "cd apps/ade-cli && npx vitest run src/bootstrap.test.ts src/serviceManager/common.test.ts", + ); + assert.deepEqual(jobs[2].runsOn, ["macos-15", "ubuntu-latest"]); + assert.equal(jobs[2].runCommands[0].split("\n").length, 2); +}); + +test("maps runs-on labels onto the platform they report", () => { + assert.equal(runnerPlatform("windows-latest"), "win32"); + assert.equal(runnerPlatform("macos-15-intel"), "darwin"); + assert.equal(runnerPlatform("ubuntu-24.04-arm"), "linux"); + assert.equal(runnerPlatform("self-hosted"), null); +}); + +test("resolves vitest targets through cd, &&, --prefix, and whole-suite runs", () => { + const explicit = testTargetsForCommand( + "cd apps/ade-cli && npx vitest run src/bootstrap.test.ts src/serviceManager/common.test.ts", + ); + assert.deepEqual([...explicit.files], [ + "apps/ade-cli/src/bootstrap.test.ts", + "apps/ade-cli/src/serviceManager/common.test.ts", + ]); + assert.equal(explicit.wholeSuiteDirs.size, 0); + + const sharded = testTargetsForCommand("cd apps/desktop && npx vitest run --shard=1/8"); + assert.deepEqual([...sharded.wholeSuiteDirs], ["apps/desktop"]); + + const npmTest = testTargetsForCommand("cd apps/ade-cli && npm test"); + assert.deepEqual([...npmTest.wholeSuiteDirs], ["apps/ade-cli"]); + + const prefixed = testTargetsForCommand( + "npm --prefix apps/desktop run test -- src/main/windowAppearance.test.ts", + ); + assert.deepEqual([...prefixed.files], ["apps/desktop/src/main/windowAppearance.test.ts"]); + + // Typechecks and installs are not test runs and must not imply coverage. + const notTests = testTargetsForCommand( + "npm --prefix apps/ade-cli ci\nnpm --prefix apps/desktop run typecheck", + ); + assert.equal(notTests.files.size, 0); + assert.equal(notTests.wholeSuiteDirs.size, 0); +}); + +function fixtureCoverage({ macos = false } = {}) { + const jobs = [ + { name: "test-ade-cli", runsOn: ["ubuntu-latest"], runCommands: ["cd apps/ade-cli && npm test"] }, + { name: "test-desktop", runsOn: ["ubuntu-latest"], runCommands: ["cd apps/desktop && npx vitest run --shard=1/8"] }, + { + name: "windows-foundation", + runsOn: ["windows-latest"], + runCommands: ["cd apps/ade-cli && npx vitest run src/serviceManager/installWindows.test.ts"], + }, + ]; + if (macos) { + jobs.push({ + name: "macos-foundation", + runsOn: ["macos-15"], + runCommands: ["cd apps/ade-cli && npx vitest run src/services/sync/syncLoopbackCollision.test.ts"], + }); + } + return coverageByPlatform(jobs); +} + +test("treats a whole-suite job as covering every test file beneath its directory", () => { + const coverage = fixtureCoverage(); + assert.equal(isFileCovered(coverage, "linux", "apps/ade-cli/src/anything.test.ts"), true); + assert.equal(isFileCovered(coverage, "linux", "apps/desktop/src/main/a.test.ts"), true); + assert.equal(isFileCovered(coverage, "win32", "apps/ade-cli/src/serviceManager/installWindows.test.ts"), true); + assert.equal(isFileCovered(coverage, "win32", "apps/ade-cli/src/anything.test.ts"), false); + assert.equal(isFileCovered(coverage, "darwin", "apps/ade-cli/src/anything.test.ts"), false); +}); + +test("a win32 gate in a file no windows job runs is a violation", () => { + const gates = collectGates( + '(process.platform === "win32" ? it : it.skip)("windows only", () => {});', + "apps/ade-cli/src/lib/trustedWindowsTools.test.ts", + ); + const violations = evaluateGates(gates, fixtureCoverage()); + assert.equal(violations.length, 1); + assert.equal(violations[0].kind, VIOLATION_KINDS.uncoveredGate); + assert.equal(violations[0].requires, "win32"); + + // The same gate in a file the windows job does run is fine. + const covered = evaluateGates( + collectGates( + '(process.platform === "win32" ? it : it.skip)("windows only", () => {});', + "apps/ade-cli/src/serviceManager/installWindows.test.ts", + ), + fixtureCoverage(), + ); + assert.deepEqual(covered, []); +}); + +// This is the condition that hid three of the five bugs: the darwin gate is not +// merely in the wrong job, there is no macOS unit job at all. +test("a darwin gate is a violation when no macOS job exists, and passes once one does", () => { + const source = 'it.runIf(process.platform === "darwin")("mac only", () => {});'; + const file = "apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts"; + + const withoutMac = evaluateGates(collectGates(source, file), fixtureCoverage()); + assert.equal(withoutMac.length, 1); + assert.equal(withoutMac[0].requires, "darwin"); + + const withMac = evaluateGates(collectGates(source, file), fixtureCoverage({ macos: true })); + assert.deepEqual(withMac, []); +}); + +test("a gate that still runs on linux needs no dedicated runner", () => { + const gates = collectGates( + 'const posixIt = process.platform === "win32" ? it.skip : it;\nposixIt("a", () => {});', + "apps/ade-cli/src/cli.test.ts", + ); + assert.deepEqual(evaluateGates(gates, fixtureCoverage()), []); +}); + +test("an unsatisfiable gate is reported even though nothing skips visibly", () => { + const gates = collectGates( + 'it.skipIf(process.platform === "win32" || process.platform !== "win32")("never", () => {});', + "apps/ade-cli/src/cli.test.ts", + ); + const violations = evaluateGates(gates, fixtureCoverage()); + assert.equal(violations.length, 1); + assert.equal(violations[0].kind, VIOLATION_KINDS.unsatisfiableGate); +}); + +test("the vacuous return is banned even in a file the matching job runs", () => { + const gates = collectGates( + 'it("a", () => {\n if (process.platform === "win32") return;\n});', + "apps/ade-cli/src/serviceManager/installWindows.test.ts", + ); + const violations = evaluateGates(gates, fixtureCoverage()); + assert.equal(violations.length, 1); + assert.equal(violations[0].kind, VIOLATION_KINDS.vacuousReturn); + assert.equal(violations[0].line, 2); +}); + +test("the inline annotation suppresses the coverage requirement, matched by platform", () => { + const accepted = evaluateGates( + collectGates( + '// WINDOWS-GATE: needs a real ConPTY host\nit.runIf(process.platform === "win32")("a", () => {});', + "apps/desktop/src/main/services/pty/ptyService.test.ts", + ), + fixtureCoverage(), + ); + assert.deepEqual(accepted, []); + + // A DARWIN-GATE annotation does not excuse a win32 gate. + const mismatched = evaluateGates( + collectGates( + '// DARWIN-GATE: wrong platform\nit.runIf(process.platform === "win32")("a", () => {});', + "apps/desktop/src/main/services/pty/ptyService.test.ts", + ), + fixtureCoverage(), + ); + assert.equal(mismatched.length, 1); +}); + +test("summarizes violations on a line-number-independent key", () => { + const violations = [ + { file: "a.test.ts", kind: "uncovered-gate", form: "alias-use", requires: "darwin", line: 10 }, + { file: "a.test.ts", kind: "uncovered-gate", form: "alias-use", requires: "darwin", line: 42 }, + { file: "a.test.ts", kind: "vacuous-return", form: "vacuous-return", requires: "n/a", line: 7 }, + ]; + const summary = summarizeViolations(violations); + assert.equal(summary.length, 2); + assert.equal(summary.find((entry) => entry.form === "alias-use").count, 2); + assert.deepEqual(summary.find((entry) => entry.form === "alias-use").lines, [10, 42]); + assert.equal( + baselineKey(summary[0]), + `${summary[0].file}|${summary[0].kind}|${summary[0].form}|${summary[0].requires}`, + ); +}); + +test("the baseline tolerates known violations, fails on growth, and fails when stale", () => { + const known = { + violations: [ + { file: "a.test.ts", kind: "uncovered-gate", form: "alias-use", requires: "darwin", count: 2 }, + ], + }; + const at = (line) => ({ + file: "a.test.ts", + kind: "uncovered-gate", + form: "alias-use", + requires: "darwin", + line, + }); + + const unchanged = diffAgainstBaseline([at(10), at(42)], known); + assert.deepEqual(unchanged.newViolations, []); + assert.deepEqual(unchanged.staleEntries, []); + + const grown = diffAgainstBaseline([at(10), at(42), at(99)], known); + assert.equal(grown.newViolations.length, 1); + assert.equal(grown.newViolations[0].excess, 1); + + const shrunk = diffAgainstBaseline([at(10)], known); + assert.equal(shrunk.newViolations.length, 0); + assert.equal(shrunk.staleEntries.length, 1); + assert.equal(shrunk.staleEntries[0].actual, 1); + + // A violation in a brand-new file fails immediately, whatever the backlog holds. + const fresh = diffAgainstBaseline( + [at(10), at(42), { ...at(1), file: "b.test.ts" }], + known, + ); + assert.equal(fresh.newViolations.length, 1); + assert.equal(fresh.newViolations[0].file, "b.test.ts"); +}); From a4b71377c28c71fee6b0b3f2c8422bd7c2061056 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 02:58:07 -0400 Subject: [PATCH 23/42] ci(windows): run the documented Windows gate on the Windows runner windows-foundation ran 17 files. Two groups were missing. The suites docs/development/windows-port-lane.md names as the Windows validation -- pathUtils and processExecution, plus the window, app-control, and auto-update services -- had an empty intersection with this job, so the documented gate had never actually run on Windows. Measured here: 5 files, 87 tests, ~4.3s. trustedWindowsTools is a security control whose only substantive case is win32-gated, so before this it ran on no runner at all; the credential store and the `ade://` deeplink command-injection guard are both filesystem- and quoting-sensitive in ways a Linux-hosted job cannot exercise. Measured here: 3 files, 54 tests, ~3.5s. All eight files verified green on a native Windows 11 host under Node 22.13.1. Total measured wall-clock added: 8-13s warm against a 25 minute budget. Deliberately not added: - ptyService.test.ts, 81 of 354 cases fail natively -- the reap path asserts POSIX process-group signalling, `kill(-pid, "SIGKILL")`; - cli.test.ts, 5 of 340 fail natively on POSIX-absolute path assumptions such as expecting "/explicit/project-root" where Windows resolves "C:\explicit\project-root"; - kvDb.rebuildRecovery.test.ts and the CR-SQLite/kvDb group, where 12 cases fail with EBUSY on `fs.rmSync` because a SQLite handle is not closed before teardown -- fatal on Windows, a no-op on POSIX. Each needs a source fix before it can join a required job. Adding trustedWindowsTools to the job retires its two baseline entries, so the platform-gate baseline shrinks from 16 violations to 14. Based-on: nsxdavid/ADE#999 (cherry picked from commit a4a30a980099ce9b07cc72c3b4b56ba1562858d8) --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ scripts/platform-gate-baseline.json | 14 -------------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cce44e36c..0084c40e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -453,9 +453,33 @@ jobs: - name: Test intended-user named-pipe listener contracts run: cd apps/ade-cli && npx vitest run src/services/runtime/localIpcListenOptions.test.ts + # `trustedWindowsTools` is a security control whose only substantive case + # is win32-gated, so before this step it ran on no runner at all. The + # credential store and the `ade://` deeplink command-injection guard are + # both filesystem- and argument-quoting sensitive, which is exactly what + # a Linux-hosted job cannot exercise. + - name: Test Windows trusted-tool, credential, and deeplink guards + run: >- + cd apps/ade-cli && npx vitest run + src/commands/deeplinks.test.ts + src/lib/trustedWindowsTools.test.ts + src/services/credentials/credentialStore.test.ts + - name: Test Windows desktop, SQLite, and capability contracts run: cd apps/desktop && npx vitest run src/main/packagedRuntimeSmoke.test.ts src/main/services/computerUse/localComputerUse.test.ts src/renderer/lib/platform.test.ts + # The suites docs/development/windows-port-lane.md names as the Windows + # validation set. Before this step the intersection with this job was + # empty, so the documented gate was never actually run on Windows. + - name: Test Windows path, spawn, window, and update contracts + run: >- + cd apps/desktop && npx vitest run + src/main/services/appControl/appControlService.test.ts + src/main/services/shared/processExecution.test.ts + src/main/services/updates/autoUpdateService.test.ts + src/main/windowAppearance.test.ts + src/renderer/lib/pathUtils.test.ts + # Covers the Windows runtime startup timing and `connectSpawnedRuntime` # retry path. This suite spawns real `ade serve` daemons and connects to # them over the platform transport, so on this runner it is the only gate diff --git a/scripts/platform-gate-baseline.json b/scripts/platform-gate-baseline.json index a1bc7f953..eeb593955 100644 --- a/scripts/platform-gate-baseline.json +++ b/scripts/platform-gate-baseline.json @@ -27,20 +27,6 @@ "requires": "n/a", "count": 1 }, - { - "file": "apps/ade-cli/src/lib/trustedWindowsTools.test.ts", - "kind": "uncovered-gate", - "form": "alias-declaration", - "requires": "win32", - "count": 1 - }, - { - "file": "apps/ade-cli/src/lib/trustedWindowsTools.test.ts", - "kind": "uncovered-gate", - "form": "alias-use", - "requires": "win32", - "count": 1 - }, { "file": "apps/ade-cli/src/services/credentials/credentialStore.test.ts", "kind": "vacuous-return", From d4dd6f2628ebeeb7e088717ff879efa8b325655e Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 03:07:27 -0400 Subject: [PATCH 24/42] test(windows): bind local-machine assertions to the shared identity helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR1 renamed the local machine's absolute display name from the macOS-only "This Mac" to the platform-neutral "This computer" and moved it behind `shared/machineIdentity.ts`, because ADE now runs on Windows and calling a Windows box "This Mac" is wrong. Four renderer tests still spelled the old literal and failed against the new implementation. Rather than re-typing the new string, the assertions now import THIS_MACHINE_NAME and compose from it: TopBar matches the machine menu on a regex built from the constant (the menu appends a lane count, so the accessible name is a substring), PersonalChatsPage and ProjectlessSidebar interpolate it into the picker's aria-label sentence, and SessionCard's hover-card row checks it directly. A future rename of the label now moves these tests with it instead of breaking them a fourth time. ProjectlessSidebar was not failing — it feeds the label in as a prop — but it is the same fixture and is switched over for the same reason. The SessionCard case is renamed to "on this computer": it describes local-machine behaviour, not Apple hardware. Docs that quoted the constant's value, the machine picker's option label, or the push-divergence warning are corrected; the "This Mac" card in Connections is a component name whose UI copy PR1 did not change, so those references stand. Based-on: nsxdavid/ADE#999 (cherry picked from commit d6fc636843a904512cadfa32ebc9d6525784f333) --- .../src/renderer/components/app/TopBar.test.tsx | 16 ++++++++++++---- .../personalChats/PersonalChatsPage.test.tsx | 7 ++++++- .../personalChats/ProjectlessSidebar.test.tsx | 9 ++++++--- .../components/terminals/SessionCard.test.tsx | 5 +++-- .../components/terminals/SessionCard.tsx | 4 ++-- docs/features/chat/README.md | 2 +- docs/features/lanes/README.md | 2 +- docs/features/personal-chats/README.md | 8 ++++---- docs/features/remote-runtime/README.md | 16 +++++++++------- .../remote-runtime/internal-architecture.md | 2 +- docs/features/search/README.md | 2 +- 11 files changed, 46 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 931c3eaaf..3422088c9 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -13,6 +13,7 @@ import { resetActivityStoreForTests, } from "../../state/activityStore"; import { ATTENTION_CONTRACT_VERSION } from "../../../shared/types"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "../../lib/account"; import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation"; import { @@ -23,6 +24,13 @@ import { const PROJECT_TAB_ROOT_MIME = "application/x-ade-project-root"; const PROJECT_TAB_WINDOW_MIME = "application/x-ade-window-id"; +// The machine menu appends a lane count ("This computer 3 lanes"), so the +// accessible name has to be matched as a substring. Built from +// THIS_MACHINE_NAME rather than a literal: this label was macOS-only copy +// ("This Mac") until ADE shipped on Windows, and re-pinning the string here is +// exactly what made the rename break these tests. The helper owns the name. +const THIS_MACHINE_NAME_PATTERN = new RegExp(THIS_MACHINE_NAME); + vi.mock("../settings/SyncDevicesSection", () => ({ useSyncConnections: () => ({ loading: false, status: null, devices: [], busy: false }), ThisMacCard: () => <div data-testid="this-mac-card">This Mac</div>, @@ -831,7 +839,7 @@ describe("TopBar", () => { // …and the machine it displaced must stay reachable, or the tab bar has // stranded a checkout the user cannot get back to. fireEvent.click(screen.getByLabelText("Machines for ADE")); - fireEvent.click(screen.getByRole("menuitemradio", { name: /This Mac/ })); + fireEvent.click(screen.getByRole("menuitemradio", { name: THIS_MACHINE_NAME_PATTERN })); expect(useAppStore.getState().switchProjectToPath).toHaveBeenCalledWith( "/Users/arul/ADE", @@ -843,12 +851,12 @@ describe("TopBar", () => { const tab = await screen.findByTitle("/Users/arul/ADE"); // A single-machine group spends no tab width naming the machine. - expect(tab.textContent).not.toContain("This Mac"); + expect(tab.textContent).not.toContain(THIS_MACHINE_NAME); const caret = screen.getByLabelText("Machines for ADE"); fireEvent.mouseDown(caret); fireEvent.click(caret); - expect(screen.getByRole("menuitemradio", { name: /This Mac/ })).toBeTruthy(); + expect(screen.getByRole("menuitemradio", { name: THIS_MACHINE_NAME_PATTERN })).toBeTruthy(); const connectItem = screen.getByRole("menuitem", { name: /Connect another machine/ }); expect(connectItem).toBeTruthy(); fireEvent.keyDown(window, { key: "ArrowUp" }); @@ -856,7 +864,7 @@ describe("TopBar", () => { fireEvent.mouseDown(caret); fireEvent.click(caret); - expect(screen.queryByRole("menuitemradio", { name: /This Mac/ })).toBeNull(); + expect(screen.queryByRole("menuitemradio", { name: THIS_MACHINE_NAME_PATTERN })).toBeNull(); }); it("renders a remote project tab with the connections control without immediate polling", async () => { diff --git a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx index eefa8c0ac..a21767475 100644 --- a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx +++ b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx @@ -11,6 +11,7 @@ import type { AgentChatSessionSummary, } from "../../../shared/types"; import type { ModelDescriptor } from "../../../shared/modelRegistry"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { ADE_OPEN_BUILT_IN_BROWSER_EVENT, openUrlInAdeBrowser } from "../../lib/openExternal"; // Deliberately a LIGHT accent (Codex-style) so the contrast tests can tell the @@ -756,8 +757,12 @@ describe("PersonalChatsPage", () => { storeState.openRemoteProjectTabs = [remoteTab]; await renderPage(); + // Composed from THIS_MACHINE_NAME, not spelled out: the local machine's + // absolute name is the helper's to define (it stopped being "This Mac" when + // ADE started running on Windows), and this assertion is about the sentence + // shape, not the noun. const trigger = await screen.findByRole("button", { - name: "Chats run on This Mac. Choose a machine.", + name: `Chats run on ${THIS_MACHINE_NAME}. Choose a machine.`, }); // "This machine" was ambiguous once a tab's machine became switchable. expect(document.body.textContent).not.toMatch(/this machine/i); diff --git a/apps/desktop/src/renderer/components/personalChats/ProjectlessSidebar.test.tsx b/apps/desktop/src/renderer/components/personalChats/ProjectlessSidebar.test.tsx index c7d57f4c5..dc879e6b9 100644 --- a/apps/desktop/src/renderer/components/personalChats/ProjectlessSidebar.test.tsx +++ b/apps/desktop/src/renderer/components/personalChats/ProjectlessSidebar.test.tsx @@ -3,16 +3,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { ProjectlessSidebar } from "./ProjectlessSidebar"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; afterEach(cleanup); function renderSidebar(overrides: Partial<Parameters<typeof ProjectlessSidebar>[0]> = {}) { const props: Parameters<typeof ProjectlessSidebar>[0] = { standalone: false, - machineLabel: "This Mac", + machineLabel: THIS_MACHINE_NAME, machineId: "local", machineOptions: [ - { id: "local", name: "This Mac" }, + { id: "local", name: THIS_MACHINE_NAME }, { id: "target-1", name: "MacBook Pro (97)" }, ], onSelectMachine: vi.fn(), @@ -50,7 +51,9 @@ describe("ProjectlessSidebar machine picker", () => { const props = renderSidebar(); fireEvent.click( - screen.getByRole("button", { name: "Chats run on This Mac. Choose a machine." }), + screen.getByRole("button", { + name: `Chats run on ${THIS_MACHINE_NAME}. Choose a machine.`, + }), ); fireEvent.click(screen.getByRole("menuitem", { name: /MacBook Pro \(97\)/ })); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 1383e382a..5954cc4c2 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -10,6 +10,7 @@ import { resetSessionHoverCardGroupForTests, } from "./SessionHoverCard"; import { setLaneNaming } from "../../state/laneNamingStore"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; const { navigateMock, sessionDeltaMock } = vi.hoisted(() => ({ navigateMock: vi.fn(), @@ -1617,7 +1618,7 @@ describe("SessionCard where line", () => { expect(badged.querySelector("[data-session-status-slot]")?.contains(marker)).toBe(false); }); - it("never badges a lane that is on this Mac, but still names it in the detail card", () => { + it("never badges a lane that is on this computer, but still names it in the detail card", () => { // The whole vocabulary of the badge is "this work isn't here", so a local // binding earns no glyph however it is pinned. The fact is not lost — the // hover card still answers it, and still says the MACHINE rather than the @@ -1641,7 +1642,7 @@ describe("SessionCard where line", () => { expect(container.querySelector("[data-session-machine]")).toBeNull(); openRowTooltip(container); - expect(hoverRow("machine").textContent).toContain("This Mac"); + expect(hoverRow("machine").textContent).toContain(THIS_MACHINE_NAME); expect(hoverRow("machine").textContent).not.toContain("t3code-6754bb34"); }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index a810fa593..2b5ca4f2e 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -471,10 +471,10 @@ export const SessionCard = React.memo(function SessionCard({ bug, not a style choice. */ /* Two different facts, and they must not be conflated: - `machineName` is WHICH machine, for the hover card. Any row that knows its - runtime can answer it, including one on this very Mac. + runtime can answer it, including one on this very computer. - `machineMarker` is whether that machine is ELSEWHERE. Only the union resolver decides that, and it is the sole gate on the glyph below. - Deriving the glyph from `machineName` would badge this Mac's own lanes + Deriving the glyph from `machineName` would badge this computer's own lanes whenever the tab was bound elsewhere — they carry a local `runtimePin` and are perfectly named, they are just not somewhere else. */ const machineName = machineMarker?.machineName diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 4fa59db1f..6d9b97862 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -108,7 +108,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/chatTurnState.ts` | Shared renderer turn-state invariant used by cache hydration, history snapshots, live event flushes, and locked-session summary refreshes. A terminal `status`/`done` at the end of the transcript outranks an eventually consistent `status: "active"` session summary, so failed/interrupted turns restore an idle composer. Also resolves the user message associated with a failed turn, including Codex optimistic user rows that predate assignment of a provider `turnId`. | | `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for <model>" classified message. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx`, `DraftMachinePicker.tsx`, `useDraftMachineRouting.ts`, `draftAttachmentTransfer.ts` | Composer UI and draft runtime routing: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, parallel launch slot configuration, and inline smart-link chips. Running chats show a read-only amber tower plus their owning machine name beside the model and thinking controls; moving a chat is the explicit Chat actions → Handoff → Continue on another machine flow. Completed GitHub, Linear, ADE, and generic web URLs become atomic violet chips while their literal URL remains the serialized prompt text; click/keyboard actions offer Copy link and Remove link, hover exposes the canonical URL, and Backspace/Delete removes the whole token. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted, dropped, and native-path attachments are copied through `ade.agentChat.saveTempAttachment` on the draft's selected runtime, so a MacBook chat never receives a Studio-only path (and vice versa). The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. The empty-draft launch shelf separates machine selection (`DraftMachinePicker`) from the lane list, scopes lanes to the chosen machine, and keeps Shell and Import beside the resulting target. It hides the machine control when there is only one choice and preserves Auto-create across machine changes. Attachment storage, model/auth discovery, slash commands, file search, parallel launch state, creation, rollback, and recovery all carry that captured `OpenProjectBinding`; unresolved or disconnected bindings fail closed instead of falling back to the tab's machine. `useDraftMachineRouting` restores the project/tab-selected machine before enabling the composer. When the user changes machines within the same draft scope, `draftAttachmentTransfer` copies pasted/local image bytes from the owning runtime into the newly selected runtime and rewrites their attachment paths; portable image URLs remain unchanged. Non-image files and iOS/App Control/built-in-browser visual context are removed because their paths and ownership cannot move safely. The composer blocks sends while a copy is pending, and a failed copy keeps the source images visible but blocks sending until the user switches back or removes them. A project-tab scope change establishes the restored machine as the attachment owner instead of treating tab hydration as a user-requested transfer. The **This Mac** option resolves to *this repository's* local checkout through `thisMachineProjectRoot.ts` rather than to the first open local tab; when no matching local checkout exists, the composer shows an inline dismissible amber notice. | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx`, `DraftMachinePicker.tsx`, `useDraftMachineRouting.ts`, `draftAttachmentTransfer.ts` | Composer UI and draft runtime routing: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, parallel launch slot configuration, and inline smart-link chips. Running chats show a read-only amber tower plus their owning machine name beside the model and thinking controls; moving a chat is the explicit Chat actions → Handoff → Continue on another machine flow. Completed GitHub, Linear, ADE, and generic web URLs become atomic violet chips while their literal URL remains the serialized prompt text; click/keyboard actions offer Copy link and Remove link, hover exposes the canonical URL, and Backspace/Delete removes the whole token. During an active Claude turn, the split Send caret selects inline, after-turn, or interrupt delivery without sending; the primary button and Enter execute the chosen mode. Staged messages expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted, dropped, and native-path attachments are copied through `ade.agentChat.saveTempAttachment` on the draft's selected runtime, so a MacBook chat never receives a Studio-only path (and vice versa). The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. The empty-draft launch shelf separates machine selection (`DraftMachinePicker`) from the lane list, scopes lanes to the chosen machine, and keeps Shell and Import beside the resulting target. It hides the machine control when there is only one choice and preserves Auto-create across machine changes. Attachment storage, model/auth discovery, slash commands, file search, parallel launch state, creation, rollback, and recovery all carry that captured `OpenProjectBinding`; unresolved or disconnected bindings fail closed instead of falling back to the tab's machine. `useDraftMachineRouting` restores the project/tab-selected machine before enabling the composer. When the user changes machines within the same draft scope, `draftAttachmentTransfer` copies pasted/local image bytes from the owning runtime into the newly selected runtime and rewrites their attachment paths; portable image URLs remain unchanged. Non-image files and iOS/App Control/built-in-browser visual context are removed because their paths and ownership cannot move safely. The composer blocks sends while a copy is pending, and a failed copy keeps the source images visible but blocks sending until the user switches back or removes them. A project-tab scope change establishes the restored machine as the attachment owner instead of treating tab hydration as a user-requested transfer. The **This computer** option resolves to *this repository's* local checkout through `thisMachineProjectRoot.ts` rather than to the first open local tab; when no matching local checkout exists, the composer shows an inline dismissible amber notice. | | `apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx` | Desktop prompt-stash control mounted immediately left of the context meter. Cmd/Ctrl+S and the bookmark share one path: non-empty text is persisted before the exact saved draft is cleared, while an empty draft opens the keyboard-navigable stash menu. Restore is a take operation, but it puts text into the composer before waiting for a remote delete so edits cannot be overwritten; delete failure intentionally favors a duplicate over lost text. Attachments and context items never enter the stash. | | `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient, and a directional roll transition for the active tier label, GPT-5.6 labels (Light, Medium, High, Extra High, Max, and Ultra where supported), and an Ultra multi-agent usage note. The collapsed trigger uses full tier names on desktop, keeps abbreviations for narrow/mobile layouts, and does not add a second border around the label. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. | | `apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx` | Pending-input card used when ADE asks the user to choose a model for a new or rerouted agent. It renders the agent briefing, touched files, run-after dependencies, provider/model controls, cancel/confirm states, and leaves the model unset until the user chooses one. | diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 395355a2f..119fececd 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -91,7 +91,7 @@ Renderer components: | `renderer/components/lanes/LaneBranchDrift.tsx` | Branch-drift renderer surface. `useLaneBranchDrift(laneId)` reads `branchDrift` straight off the lane in the app store, so it costs nothing and stays exactly as fresh as the rest of the lane's git state. `LaneBranchDriftChip` is the compact always-visible chip that `WorkSurfaceHeader` renders next to the lane chip while a lane is drifted. `LaneBranchDriftStrip` is the fuller warning strip, shown only once something is about to act on the branch; `armLaneBranchDriftWarning(laneId)` is the imperative arming call, backed by a module-level armed-lane set plus a `useSyncExternalStore` subscription. Arm sites are `AgentChatPane.submit` and `ChatGitToolbar`'s PR button / `handlePr`; the strip itself renders above the composer in `AgentChatPane`. See [Branch drift](#branch-drift). | | `renderer/components/lanes/LaneStackPane.tsx` | Stack graph sidebar, integration source chips, canvas jump | | `renderer/components/lanes/LaneDiffPane.tsx` | Lane diff list + per-file stage/unstage/discard; file content uses shared `AdeDiffViewer` (commit comparisons read-only; working-tree file can be editable when unstaged) | -| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Stashing includes untracked files when the unstaged set contains untracked paths, and stash restore uses the ordinal `stash@{N}` ref returned by `git stash list`. After commit/stash operations it refreshes changes, lane git status, and git metadata while skipping snapshot decorations (`refreshLanes({ includeStatus: true, includeSnapshots: false })`). Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. Push is guarded: at click time (never in a memo, so a single-machine project pays nothing) it runs `detectPushDivergence` over the cross-machine lane union via `selectOtherMachineBranchStates`, and shows `PushDivergenceDialog` when another machine holds the same branch with unpushed commits. An explicit `otherMachineBranchStates` prop overrides the union for callers that already have the set. Machine identity comes from `shared/machineIdentity.ts`, so the guard cannot warn that This Mac diverged from itself. | +| `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Stashing includes untracked files when the unstaged set contains untracked paths, and stash restore uses the ordinal `stash@{N}` ref returned by `git stash list`. After commit/stash operations it refreshes changes, lane git status, and git metadata while skipping snapshot decorations (`refreshLanes({ includeStatus: true, includeSnapshots: false })`). Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. Push is guarded: at click time (never in a memo, so a single-machine project pays nothing) it runs `detectPushDivergence` over the cross-machine lane union via `selectOtherMachineBranchStates`, and shows `PushDivergenceDialog` when another machine holds the same branch with unpushed commits. An explicit `otherMachineBranchStates` prop overrides the union for callers that already have the set. Machine identity comes from `shared/machineIdentity.ts`, so the guard cannot warn that This computer diverged from itself. | | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | | `renderer/components/lanes/useLaneWorkSessions.ts` | Hook behind the lane Work pane's chat/session list. Tracks the latest lane id, project root, and scope key in refs so a refresh that was queued during a lane or project switch replays against the newest target and ignores stale rows from the old scope. It also consumes renderer-local chat-session creation announcements for the current project/lane, inserts the new chat optimistically, and schedules a short background refresh. `launchPtySession` accepts `WorkPtyLaunchArgs` (including `disposition` and `startupDelayMs`) and returns `WorkPtyLaunchResult`; background disposition skips `selectLane`/`focusSession`/`openSessionTab`. The launcher creates an optimistic `TerminalSessionSummary` snapshot from the `ptyCreate` result and upserts it into the session list immediately, then fires the forced session-list refresh as fire-and-forget so the tab and session card appear without waiting for the IPC round-trip. | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | diff --git a/docs/features/personal-chats/README.md b/docs/features/personal-chats/README.md index 3764af98b..91273b4f0 100644 --- a/docs/features/personal-chats/README.md +++ b/docs/features/personal-chats/README.md @@ -125,13 +125,13 @@ surface while a project remains selected keeps the window's existing project binding, so a remote-bound window still addresses the remote machine's personal chats. Returning to a project route does not require reopening the project. -The page's machine picker rebinds that window, so its **This Mac** option +The page's machine picker rebinds that window, so its **This computer** option resolves through `renderer/components/chat/thisMachineProjectRoot.ts`: it finds *this repository's* local checkout by repo identity rather than taking whichever -local tab happens to be first, and reports "Open this repository on This Mac +local tab happens to be first, and reports "Open this repository on This computer first, then switch back here." when there is none instead of silently switching -the window to an unrelated repo. Machine ids and the "This Mac" name come from -`shared/machineIdentity.ts`. +the window to an unrelated repo. Machine ids and the "This computer" name come +from `shared/machineIdentity.ts`. The ADE CLI uses the same machine endpoint through explicit `--personal` commands, for example: diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 1ccdc3ff5..a82a59f94 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -172,17 +172,19 @@ relay payload E2E encryption is planned security work. See the trust boundary in format the join and every per-project cache are keyed by. `TopBar.tsx` renders the group as one tab plus a machine menu; the machine name only earns inline space when it is ambiguous (more than one machine in - the group, or a checkout that is not on This Mac), and the menu also offers + the group, or a checkout that is not on This computer), and the menu also offers **Connect another machine…**. - `apps/desktop/src/shared/machineIdentity.ts` — the single definition of "the machine ADE is running on": `THIS_MACHINE_ID` (`"this-mac"`), - `THIS_MACHINE_NAME` (`"This Mac"`), `isThisMachineId`, and + `THIS_MACHINE_NAME` (`"This computer"`), `isThisMachineId`, and `machineDisplayName`. Every producer and consumer of a machine id imports from here — `laneMachines.ts`, `projectTabGrouping.ts`, `crossMachineLanes.ts`, `LaneGitActionsPane.tsx`, the composer, and the Chats page — because the push-divergence guard decides "is this another machine?" by comparing ids, so - two spellings of this machine make it warn that This Mac diverged from itself. - Machines are named **absolutely** ("This Mac", "MacBook Pro (97)"); "remote" + two spellings of this machine make it warn that This computer diverged from + itself. The name is deliberately platform-neutral — ADE also runs on Windows, + where "This Mac" was simply wrong. + Machines are named **absolutely** ("This computer", "MacBook Pro (97)"); "remote" is never a machine name, since the machine a tab is bound to can change and the create-lane dialog already uses "remote" for the git base-branch source. - `apps/desktop/src/main/services/projects/recentProjectSummary.ts` — reads @@ -283,7 +285,7 @@ relay payload E2E encryption is planned security work. See the trust boundary in compared, never names), the other machine is on a different branch, or the other machine has nothing unpushed. - `apps/desktop/src/renderer/components/chat/thisMachineProjectRoot.ts` — - resolves the machine picker's "This Mac" option back to *this repository's* + resolves the machine picker's "This computer" option back to *this repository's* local checkout by repo identity (reusing `deriveLaneMachineOptions`' rule) rather than to the first local tab in insertion order, and refuses to switch when there is no local counterpart. Used by the chat composer's machine picker @@ -421,14 +423,14 @@ A repository is one tab. Local and remote checkouts of the same repo — joined their normalized git origin — collapse into a single tab whose **machine** is a dimension inside it, switched from a dropdown on the tab. There is no separate "remote" tab, and "remote" is not a machine name: machines are named absolutely -("This Mac", "MacBook Pro (97)"). +("This computer", "MacBook Pro (97)"). The tab's machine is the global execution context — Lanes, PRs, Files, Git, and Run all follow it. Two things are deliberately wider than that: - **The Work sidebar is a union.** It shows chats in flight on *every* connected machine for this repository, regardless of which machine the tab is bound to. - Lanes not on This Mac carry a small monochrome machine marker that promotes to + Lanes not on This computer carry a small monochrome machine marker that promotes to the machine's name when a glyph alone would be ambiguous (the machine is offline, two or more foreign machines are on screen, or the same branch exists elsewhere). Foreign lanes appear only when they have sessions — the union is diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index 09afedf93..b2b5303f8 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -134,7 +134,7 @@ identity. Opening a project on another machine is not gated by a confirmation. The git-origin comparison that used to power one now feeds tab grouping (`projectTabGrouping.ts`), and the risk it was really guarding against — two machines pushing the same branch from different commits — is caught at push time by `shared/laneDivergence.ts`. -`detectPushDivergence` runs at click time on the push button, from lane state the renderer already holds (`LaneSummary.branchRef` + `LaneStatus.ahead/behind`, unioned across machines by `renderer/state/crossMachineLanes.ts`). No lane record in ADE carries a head sha, so the rule is grounded in `ahead` instead: another machine holding the same branch with unpushed commits would have them stranded when the upstream tip moves. Head shas are used only to silence the guard when two machines are proven to sit on the same commit — an unknown head never suppresses a warning, because the false-negative direction on a destructive push is the expensive one. Machine identity is compared by id (`shared/machineIdentity.ts`), never by name, so the guard cannot mistake This Mac for another machine. +`detectPushDivergence` runs at click time on the push button, from lane state the renderer already holds (`LaneSummary.branchRef` + `LaneStatus.ahead/behind`, unioned across machines by `renderer/state/crossMachineLanes.ts`). No lane record in ADE carries a head sha, so the rule is grounded in `ahead` instead: another machine holding the same branch with unpushed commits would have them stranded when the upstream tip moves. Head shas are used only to silence the guard when two machines are proven to sit on the same commit — an unknown head never suppresses a warning, because the false-negative direction on a destructive push is the expensive one. Machine identity is compared by id (`shared/machineIdentity.ts`), never by name, so the guard cannot mistake This computer for another machine. ## Per-session runtime routing diff --git a/docs/features/search/README.md b/docs/features/search/README.md index 7173272de..421857aea 100644 --- a/docs/features/search/README.md +++ b/docs/features/search/README.md @@ -116,7 +116,7 @@ Desktop ⌘K command palette: (chat/terminal/pr/lane/commit/branch/file/linear/artifact → the matching tab, relying on the deep-link navigate listener to focus the target). Thread entries always retain their owner machine name for matching; results show an amber - name marker only when that owner is not This Mac, including threads from the + name marker only when that owner is not This computer, including threads from the remote-bound active tab. `ade search` CLI + agent skill: From c0afd636736964d8721898d75da3002edc7b9d3d Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 03:20:30 -0400 Subject: [PATCH 25/42] docs(ci): drop the stale cross-layer note now the stack is composed The docs-validator guard landed with the foundation layer, so the path it runs resolves on this branch. The note described a transient state during stack composition. Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0084c40e2..5c6cf612e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,8 +105,7 @@ jobs: key: nm-v2-${{ hashFiles('apps/desktop/package-lock.json','apps/ade-cli/package-lock.json','apps/web/package-lock.json','apps/webhook-relay/package-lock.json','apps/push-relay/package-lock.json') }} - run: cd apps/ade-cli && npm run typecheck # scripts/validate-docs.test.mjs covers the docs validator that the - # validate-docs job runs; it lands with a sibling lane, so this path does - # not resolve until the stack is composed. + # validate-docs job runs. - name: Test release runtime archive, packaging, and docs-validator guards run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs scripts/validate-docs.test.mjs scripts/validate-platform-gates.test.mjs From f51b4999c542ec42e25b3e8d08c0a797a01eb8aa Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 03:27:25 -0400 Subject: [PATCH 26/42] ci(windows): run sync-port holder identification on the Windows runner The holder lookup is win32-gated: on Windows it goes through PowerShell rather than lsof/ps, and a broken lookup silently disables stale-port reclaim instead of failing. Only a Windows runner executes that gate. Caught by validate-platform-gates at stack composition -- the gate and the checker landed in separate lanes, so neither could see the gap alone. Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c6cf612e..341a94866 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,6 +452,13 @@ jobs: - name: Test intended-user named-pipe listener contracts run: cd apps/ade-cli && npx vitest run src/services/runtime/localIpcListenOptions.test.ts + # Sync-port holder identification is win32-gated: on Windows the holder + # lookup goes through PowerShell rather than lsof/ps, and a broken lookup + # silently disables stale-port reclaim instead of failing. Only a Windows + # runner executes that gate. + - name: Test Windows sync-port holder identification + run: cd apps/ade-cli && npx vitest run src/services/sync/sharedSyncListener.test.ts + # `trustedWindowsTools` is a security control whose only substantive case # is win32-gated, so before this step it ran on no runner at all. The # credential store and the `ade://` deeplink command-injection guard are From c70076e95f800e107b244c912b2cea193724457a Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 13:42:51 -0400 Subject: [PATCH 27/42] fix(windows): canonicalize project-icon roots against 8.3 short names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `windows-foundation` failed five projectIconResolver cases on every stacked PR. The GitHub runner's account is `runneradmin`, so `os.tmpdir()` reports the 8.3 short form `C:\Users\RUNNER~1\AppData\Local\Temp`. The fixtures canonicalized that with the JS `fs.realpathSync`, which resolves symlinks but leaves 8.3 aliases alone, while the resolver canonicalizes with `fs.realpathSync.native`, which expands them. Every `path.join(root, …)` expectation then compared two spellings of one directory. Point both icon fixtures at `fs.realpathSync.native` so they build roots in the spelling the resolver answers in. The investigation also turned up a real defect behind the fixture mismatch. `resolveProjectIcon`, `resolveProjectIconPath`, `setProjectIconOverride`, `removeProjectIconOverride` and `setProjectIconOverrideFromSelection` normalized the project root with `path.resolve` alone, while every path they return is realpath-canonical. A root that arrives as an 8.3 short name — or through a junction or symlink — therefore makes `toProjectRelative` emit a `..` traversal, and `setProjectIconOverride` persists that traversal into the shared, committed `.ade/ade.yaml` as the project's `iconPath`: iconPath: ../../../../shortpath-repro/runneradmin/Temp/…/brand/custom-icon.png Today's shipped callers all pre-canonicalize (the desktop IPC through `resolveAllowedProjectRoot`, the CLI through `normalizeProjectRootPath`), so this is latent rather than reachable — but the resolver never stated that precondition and cannot rely on it. Canonicalize the root inside the module with the realpath it already uses for candidates, and cover it with a desktop test that drives a junction-spelled root; that test fails on the old code on every platform. A matching CLI test pins the "icons come back in the filesystem's spelling" contract the fixtures depend on. Verified by reproducing the runner exactly: with `TEMP` pointed at a real 8.3 short path, the step's twelve suites failed 5/183 before and pass 189/189 after. Based-on: nsxdavid/ADE#999 (cherry picked from commit 106cb1d69f2e748c14cd93832647055a77e67ce6) --- .../projects/projectIconResolver.test.ts | 30 +++++++++++-- .../projects/projectIconResolver.test.ts | 43 +++++++++++++++++-- .../services/projects/projectIconResolver.ts | 43 +++++++++++++++---- 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts index 9430872e6..aa62ac3b4 100644 --- a/apps/ade-cli/src/services/projects/projectIconResolver.test.ts +++ b/apps/ade-cli/src/services/projects/projectIconResolver.test.ts @@ -12,9 +12,15 @@ import { createTestDirectoryLink, removeTestTree } from "../../test/filesystem"; const tempRoots = new Set<string>(); function makeTempRoot(prefix = "ade-project-icon-"): string { - // realpath collapses the macOS /var -> /private/var tmpdir symlink so the - // resolver's within-root containment checks compare like-for-like paths. - const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + // Canonicalize with the *same* realpath the resolver uses + // (`fs.realpathSync.native`, via the desktop shared path helpers), not the + // JS `fs.realpathSync`. Both collapse the macOS /var -> /private/var tmpdir + // symlink, but only the native one expands Windows 8.3 short names: on a box + // whose account name exceeds eight characters `os.tmpdir()` is reported as + // `C:\Users\RUNNER~1\AppData\Local\Temp`, the resolver returns the long + // `C:\Users\runneradmin\...` spelling of the same directory, and every + // `path.join(root, …)` expectation below compares two spellings of one path. + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); tempRoots.add(root); return root; } @@ -165,6 +171,24 @@ describe("resolveRemoteProjectIcon", () => { expect(icon.sourcePath).toBe(path.join(root, "logo.png")); }); + it("reports the icon in the filesystem's spelling when the root is spelled otherwise", () => { + const realRoot = makeTempRoot(); + writeFileEnsuringDir(path.join(realRoot, "logo.png"), Buffer.from([1, 2, 3])); + // A directory link reproduces, on every platform, the divergence a Windows + // 8.3 short root creates: two spellings of one directory. `sourcePath` is + // always the canonical one, which is the contract `makeTempRoot` above has + // to honor for the `path.join(root, …)` expectations in this file to hold. + const linkRoot = path.join(path.dirname(realRoot), `link-${path.basename(realRoot)}`); + createTestDirectoryLink(realRoot, linkRoot); + tempRoots.add(linkRoot); + expect(fs.realpathSync.native(linkRoot)).toBe(realRoot); + + const icon = resolveRemoteProjectIcon(linkRoot); + + expect(icon.sourcePath).toBe(path.join(realRoot, "logo.png")); + expect(icon.sourcePath).not.toBe(path.join(linkRoot, "logo.png")); + }); + it("returns an all-null icon for an empty or whitespace root path", () => { const icon = resolveRemoteProjectIcon(" "); expect(icon).toEqual({ dataUrl: null, sourcePath: null, mimeType: null }); diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts index 0daf48896..0cb59818d 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts @@ -22,10 +22,26 @@ const PNG_DATA = Buffer.from( ); function makeProjectRoot(): string { - // Resolve through realpath so the assertions still hold on platforms - // (macOS) where the system tmpdir is itself a symlink (e.g. `/var` -> - // `/private/var`). The resolver returns canonical realpaths for callers. - return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-icon-"))); + // Resolve through the *same* realpath the resolver uses + // (`fs.realpathSync.native`). Both spellings collapse the macOS tmpdir + // symlink (`/var` -> `/private/var`), but only the native one expands + // Windows 8.3 short names, which `os.tmpdir()` reports whenever the account + // name exceeds eight characters (`C:\Users\RUNNER~1\...`). The resolver + // returns canonical realpaths for callers. + return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-icon-"))); +} + +/** + * Creates a directory link without requiring Windows Developer Mode or an + * elevated test process. Junctions preserve the realpath/containment contract + * exercised by these tests while remaining available to ordinary users. + */ +function linkProjectRoot(target: string, linkPath: string): void { + fs.symlinkSync( + process.platform === "win32" ? path.resolve(target) : target, + linkPath, + process.platform === "win32" ? "junction" : "dir", + ); } function writeFile(root: string, relativePath: string, contents: string | Buffer): string { @@ -128,6 +144,25 @@ describe("projectIconResolver", () => { expect(fs.readFileSync(path.join(root, ".ade", "ade.yaml"), "utf8")).toContain("iconPath: assets/icon.svg"); }); + it("persists a project-relative icon path when the root is spelled non-canonically", () => { + const realRoot = makeProjectRoot(); + const iconPath = writeFile(realRoot, "assets/icon.svg", "<svg>brand</svg>"); + // A directory link reproduces, on every platform, the divergence a Windows + // 8.3 short root creates: two spellings of one directory. Resolved icons + // always come back in the canonical spelling, so a root left in the + // caller's spelling makes `path.relative` emit a `..` traversal — and that + // traversal used to be written into the shared, committed `.ade/ade.yaml`. + const linkRoot = path.join(path.dirname(realRoot), `link-${path.basename(realRoot)}`); + linkProjectRoot(realRoot, linkRoot); + expect(fs.realpathSync.native(linkRoot)).toBe(realRoot); + + const icon = setProjectIconOverride(linkRoot, path.join(linkRoot, "assets", "icon.svg")); + + expect(icon.sourcePath).toBe(iconPath); + expect(fs.readFileSync(path.join(realRoot, ".ade", "ade.yaml"), "utf8")) + .toContain("iconPath: assets/icon.svg"); + }); + it("imports selected icons from outside the project root", () => { const root = makeProjectRoot(); const outside = makeProjectRoot(); diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts index 55736ac3f..ce6024b53 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -126,6 +126,32 @@ function realpathExisting(filePath: string): string { : fs.realpathSync(filePath); } +/** + * Canonicalize a project root the same way every path this module hands back is + * canonicalized: `resolvePathWithinRoot` realpaths each existing segment, so an + * icon path is always spelled the way the filesystem spells it. + * + * `path.resolve` alone does not get the root there. On Windows a root can + * arrive as an 8.3 short name (`C:\Users\RUNNER~1\...` whenever the account + * name exceeds eight characters — `Administrator`, most `First Last` accounts, + * and GitHub's own `runneradmin`), and on every platform it can arrive through + * a junction or symlink. The root and the resolved icon then name the same file + * with different strings, and `path.relative` between them yields a `..` + * traversal instead of a project-relative path — which `setProjectIconOverride` + * would persist into the shared `.ade/ade.yaml` as the project's `iconPath`. + * + * Falls back to the lexical resolve when the root does not exist, so callers + * that probe a stale project directory still get "no icon" rather than a throw. + */ +function canonicalProjectRoot(projectRoot: string): string { + const resolved = path.resolve(projectRoot); + try { + return realpathExisting(resolved); + } catch { + return resolved; + } +} + function toProjectRelative(projectRoot: string, filePath: string): string { const relative = path.relative(projectRoot, filePath).split(path.sep).join("/"); return relative || "."; @@ -264,7 +290,7 @@ function setProjectIconPathCache(key: string, entry: ProjectIconPathCacheEntry): } function clearProjectIconResultCache(projectRoot: string): void { - const root = path.resolve(projectRoot); + const root = canonicalProjectRoot(projectRoot); for (const key of projectIconResultCache.keys()) { if (key === root || key.startsWith(`${root}\0`)) { projectIconResultCache.delete(key); @@ -558,7 +584,7 @@ export function resolveProjectIconPath( projectRoot: string, options: { iconPathOverride?: string | null } = {}, ): string | null { - const root = path.resolve(projectRoot); + const root = canonicalProjectRoot(projectRoot); const cacheKey = projectIconResultCacheKey(root, options); const rootMtimeMs = dirMtimeMs(root); const appsMtimeMs = dirMtimeMs(path.join(root, "apps")); @@ -690,7 +716,7 @@ function writeProjectIconPathOverride(projectRoot: string, iconPath: string | nu } export function setProjectIconOverride(projectRoot: string, iconPath: string): ProjectIcon { - const root = path.resolve(projectRoot); + const root = canonicalProjectRoot(projectRoot); const resolvedIconPath = resolvePathWithinRoot(root, iconPath, { allowMissing: false }); assertUsableProjectIconFile(resolvedIconPath); @@ -725,12 +751,13 @@ function importedProjectIconRelativePath(sourcePath: string, data: Buffer): stri } export function setProjectIconOverrideFromSelection(projectRoot: string, iconPath: string): ProjectIcon { - const root = path.resolve(projectRoot); + // Both sides are canonicalized with the same realpath, so the containment + // check below compares like-for-like spellings. + const root = canonicalProjectRoot(projectRoot); const selectedPath = realpathExisting(path.resolve(iconPath)); assertUsableProjectIconFile(selectedPath); - const rootReal = realpathExisting(root); - if (isWithinDir(rootReal, selectedPath)) { + if (isWithinDir(root, selectedPath)) { return setProjectIconOverride(root, selectedPath); } @@ -756,7 +783,7 @@ export function setProjectIconOverrideFromSelection(projectRoot: string, iconPat } export function removeProjectIconOverride(projectRoot: string): ProjectIcon { - const root = path.resolve(projectRoot); + const root = canonicalProjectRoot(projectRoot); writeProjectIconPathOverride(root, null); clearProjectIconResultCache(root); return resolveProjectIcon(root, { iconPathOverride: null }); @@ -766,7 +793,7 @@ export function resolveProjectIcon( projectRoot: string, options: { iconPathOverride?: string | null } = {}, ): ProjectIcon { - const root = path.resolve(projectRoot); + const root = canonicalProjectRoot(projectRoot); const cacheKey = projectIconResultCacheKey(root, options); const rootMtimeMs = dirMtimeMs(root); const appsMtimeMs = dirMtimeMs(path.join(root, "apps")); From 7d7eb66519a0cb1912767ec88d70e8130394fcce Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 14:36:03 -0400 Subject: [PATCH 28/42] fix(ci): run the crsqlite-gated CRDT suites on the Windows runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isCrsqliteAvailable()` resolves `vendor/crsqlite/<platform>-<arch>/`, and only darwin-arm64, darwin-x64, and win32-x64 are vendored. `test-desktop` runs on ubuntu-latest, where that gate is false, so 57 tests across kvDb, kvDb.migrations, kvDb.sync, deviceRegistryService, syncHostService, and syncService skip silently — including two files that skip in full. The ubuntu shard logs confirm it: syncService 19/19 skipped, kvDb.sync 10/10 skipped, syncHostService 17 of 24, kvDb 9 of 20, and one `it.skipIf` each in kvDb.migrations and deviceRegistryService. No other job picked them up, so the CRDT replication, sync host, and device registry contracts had no coverage on any runner. windows-foundation already runs on windows-latest with the vendored crsqlite.dll present, so it is the one place these can execute. Run the six suites there. Locally all 88 tests execute with zero skips. kvDb.rebuildRecovery.test.ts is deliberately excluded: its temp-dir teardown unlinks a still-open SQLite handle and fails EBUSY on Windows. Based-on: nsxdavid/ADE#999 (cherry picked from commit e487c79457e66dc86657e5a8bd6c471ef7b23a7d) --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 341a94866..6a422873f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -493,6 +493,24 @@ jobs: - name: Test Windows local runtime connection pool contracts run: cd apps/desktop && npx vitest run src/main/services/localRuntime/localRuntimeConnectionPool.test.ts + # The CRDT/sync layer. These suites are gated on `isCrsqliteAvailable()`, + # which resolves `vendor/crsqlite/<platform>-<arch>/`. Only darwin-arm64, + # darwin-x64, and win32-x64 are vendored, so on the ubuntu-latest + # `test-desktop` job the gate is false and 57 tests skip silently — a + # green checkmark over unrun CRR replication, sync host, sync service, + # and device registry coverage. windows-latest is the only runner in this + # workflow that has the extension, so this is where those tests actually + # execute. Do not add kvDb.rebuildRecovery.test.ts here: its temp-dir + # teardown unlinks an open SQLite handle, which is EBUSY on Windows. + - name: Test Windows CRDT, sync, and device registry contracts + run: >- + cd apps/desktop && npx vitest run + src/main/services/state/kvDb.test.ts + src/main/services/state/kvDb.migrations.test.ts + src/main/services/state/kvDb.sync.test.ts + src/main/services/sync/deviceRegistryService.test.ts + src/main/services/sync/syncHostService.test.ts + src/main/services/sync/syncService.test.ts validate-docs: needs: install runs-on: ubuntu-latest From 73117ae2160ac5f700c53e9b50c4bc9cf430e428 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 17:02:13 -0400 Subject: [PATCH 29/42] test(windows): canonicalize local runtime roots against 8.3 short names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `windows-foundation` failed four localRuntimeConnectionPool cases on every stacked PR, all with the same shape: expected 'C:\Users\runneradmin\AppData\Local\Te…' to be 'C:\Users\RUNNER~1\AppData\Local\Temp\…' Same bug class as 106cb1d6. The GitHub runner's account is `runneradmin`, so `os.tmpdir()` reports the 8.3 short form `C:\Users\RUNNER~1\AppData\Local\Temp`. Five assertions compared `fs.realpathSync(registered.rootPath)` against `fs.realpathSync(projectRoot)`, and the JS `fs.realpathSync` resolves symlinks but leaves 8.3 aliases alone. The runtime registers roots through `normalizeProjectRootPath` -> `realpathIfExists` -> `fs.realpathSync.native`, which does expand them, so the two sides named one directory with two spellings. The earlier 8.3 sweep predated the `resolveMachineAdeLayout`-derived socket path that let these daemon-backed tests run on Windows at all, so it never saw them. This is a fixture defect, not a production one: the value coming back over the wire is the canonical long form, which is what the registry is supposed to return. Point the fixtures at the runtime's own `realpathIfExists` through a single named helper, and route the one assertion that already reached for `fs.realpathSync.native` through it too, so the file has one canonicalizer rather than three spellings of the same intent. Reproduced the runner exactly by pointing `TEMP`/`TMP` at a real 8.3 path: the suite went 4 failed / 61 passed / 1 skipped before and 65 passed / 1 skipped after, and is unchanged at 65 passed / 1 skipped under a normal `TEMP`. Swept the rest of the file and every other suite in the `windows-foundation` job for the same JS-vs-native mismatch; these five were the only ones. The remaining `fs.realpathSync` fixture in `apps/ade-cli/src/bootstrap.test.ts` matches its production counterpart, which also uses the JS realpath, so both sides agree in either spelling. Based-on: nsxdavid/ADE#999 (cherry picked from commit f065f68950aa878ea9007ab3ed14bba8c19abf02) --- .../localRuntimeConnectionPool.test.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 9a585cf78..e51ed8c62 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { realpathIfExists } from "../../../../../ade-cli/src/services/projects/projectRoots"; import { recordLastFailure } from "../runtime/lastFailureStore"; vi.mock("electron", () => ({ @@ -138,6 +139,27 @@ function machineRuntimeSocketPath(adeHome: string): string { return resolveMachineAdeLayout({ ...process.env, ADE_HOME: adeHome }).socketPath; } +/** + * Canonicalize a temp project root with the *same* helper the runtime applies + * to every root it registers: `normalizeProjectRootPath` delegates to + * `realpathIfExists`, so `rootPath` always comes back off the wire in the + * spelling `fs.realpathSync.native` produces. + * + * The JS `fs.realpathSync` is not that helper. Both resolve symlinks — which is + * all these assertions used to need, to collapse the macOS `/var` -> + * `/private/var` tmpdir link — but only the native one expands Windows 8.3 + * short names. Whenever the account name exceeds eight characters + * (`Administrator`, most `First Last` accounts, and GitHub's own + * `runneradmin`), `os.tmpdir()` is reported as + * `C:\Users\RUNNER~1\AppData\Local\Temp`, and a root built from it stays in + * that spelling under the JS realpath while the registered root comes back as + * `C:\Users\runneradmin\...`. Comparing the two then fails on two spellings of + * one directory rather than on any behavior. + */ +function canonicalProjectRoot(rootPath: string): string { + return realpathIfExists(rootPath); +} + function withTsxNodeOptions(value: string | undefined, loaderPath: string): string { // `--import` must be given a file:// URL, not a bare absolute path. Node's // ESM loader rejects a Windows absolute path outright @@ -2082,7 +2104,7 @@ describe("local runtime connection pool", () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-")); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")); - const expectedProjectRoot = fs.realpathSync.native(projectRoot); + const expectedProjectRoot = canonicalProjectRoot(projectRoot); const socketPath = machineRuntimeSocketPath(adeHome); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -2209,7 +2231,7 @@ describe("local runtime connection pool", () => { pool = new LocalRuntimeConnectionPool("2.0.0", logger as never, { disableSync: true }); const registered = await pool.ensureProject(projectRoot); - expect(fs.realpathSync(registered.rootPath)).toBe(fs.realpathSync(projectRoot)); + expect(canonicalProjectRoot(registered.rootPath)).toBe(canonicalProjectRoot(projectRoot)); expect(logger.info).toHaveBeenCalledWith("local_runtime.version_mismatch_detected", expect.objectContaining({ runtimeVersion: "1.0.0", @@ -2267,7 +2289,7 @@ describe("local runtime connection pool", () => { secondPool = new LocalRuntimeConnectionPool("2.0.0", logger as never, { disableSync: true }); const secondRegistered = await secondPool.ensureProject(secondProjectRoot); - expect(fs.realpathSync(secondRegistered.rootPath)).toBe(fs.realpathSync(secondProjectRoot)); + expect(canonicalProjectRoot(secondRegistered.rootPath)).toBe(canonicalProjectRoot(secondProjectRoot)); const secondConnection = await (secondPool as unknown as { connection: Promise<{ socketPath: string; child: unknown }> }).connection; expect(secondConnection.socketPath).toBe(connection.socketPath); expect(secondConnection.child).toBeNull(); @@ -2514,7 +2536,7 @@ describe("local runtime connection pool", () => { pool = new LocalRuntimeConnectionPool("1.0.0-beta.1", logger as never, { disableSync: true }); const registered = await pool.ensureProject(projectRoot); - expect(fs.realpathSync(registered.rootPath)).toBe(fs.realpathSync(projectRoot)); + expect(canonicalProjectRoot(registered.rootPath)).toBe(canonicalProjectRoot(projectRoot)); expect(logger.info).not.toHaveBeenCalledWith( "local_runtime.version_mismatch_detected", expect.anything(), @@ -2593,7 +2615,7 @@ describe("local runtime connection pool", () => { expect(expectedBuildHash).toBeTruthy(); pool = new LocalRuntimeConnectionPool("1.0.0", logger as never, { disableSync: true }); const registered = await pool.ensureProject(projectRoot); - expect(fs.realpathSync(registered.rootPath)).toBe(fs.realpathSync(projectRoot)); + expect(canonicalProjectRoot(registered.rootPath)).toBe(canonicalProjectRoot(projectRoot)); expect(logger.info).toHaveBeenCalledWith("local_runtime.build_mismatch_detected", expect.objectContaining({ runtimeBuildHash: "old-build", @@ -2715,7 +2737,7 @@ describe("local runtime connection pool", () => { pool = new LocalRuntimeConnectionPool("1.0.0", logger as never, { disableSync: true }); const registered = await pool.ensureProject(projectRoot); - expect(fs.realpathSync(registered.rootPath)).toBe(fs.realpathSync(projectRoot)); + expect(canonicalProjectRoot(registered.rootPath)).toBe(canonicalProjectRoot(projectRoot)); expect(logger.info).toHaveBeenCalledWith("local_runtime.role_mismatch_detected", expect.objectContaining({ runtimeDefaultRole: "agent", From e417773a165b2dd3172fb3b2fb8641d62e36ed8e Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 18:09:12 -0400 Subject: [PATCH 30/42] fix(windows): survive a starved runner in the Windows process probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windows-foundation "service, layout, and IPC contracts" step went red on a runner that was ~6x slower than its peers: the same 12 files finished in 17.98s on the sibling job for the branch stacked directly on top of this one, and in 115.50s here. Nothing in the composed tree explains it — the failing files' import graph contains no behavioural change from this layer — but every win32-gated probe that waits on a real detached subprocess has a hard-coded deadline with no headroom, so a slow host turns each of them into a null read: - readWindowsParentPid gave powershell.exe + the first CIM call of the session 5s. On a loaded host that expires, and because a timeout is indistinguishable from a broken mechanism the lookup degrades to PARENT_PID_UNKNOWN. That is a product bug, not just a test one: the guard fails closed, so a busy machine makes ADE refuse a runtime teardown the user is entitled to. The ancestry walk stops at the first unknown, so the wider budget is spent at most once per chain. - The supervisor, bootstrap, and CRR-worker specs waited 5s/5s/15s on a detached PowerShell or a cold tsx child, then asserted on whatever had been written by then. The assertions are unchanged; only the patience is. - The bootstrap spec's teardown read the pid record once to find the supervisor to kill. Losing that race against the supervisor's first Write-PidRecord orphaned a process that kept restarting its child for the rest of the run, holding the temp tree open (EBUSY on unlink) and starving every later suite. Teardown now waits for the record before giving up. Reproduced by pinning the run to two logical CPUs under contention, which yields the same "expected null to be +0" and "expected 'unknown' to be <pid>" failures seen across this branch's runs; the same starved run is green after, and leaves no orphaned supervisor. Based-on: nsxdavid/ADE#999 (cherry picked from commit d20d7363826a0108905ab719684ce590322b911f) --- apps/ade-cli/src/serviceManager/common.ts | 8 ++++++- .../src/serviceManager/installWindows.test.ts | 20 +++++++++++++++--- .../serviceManager/windowsSupervisor.test.ts | 9 ++++++-- .../src/services/modelPickerStore.test.ts | 21 +++++++++++++++---- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index e76b8e743..a07229178 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -109,10 +109,16 @@ function readWindowsParentPid(run: ServiceManagerSpawnSync, pid: number): Parent try { // Resolve through the hardened GLOBALROOT lookup: a bare `powershell` is // redirectable via PATH/SystemRoot, and this guard protects a teardown. + // 5s was not enough: powershell.exe cold start plus the first CIM call in a + // session routinely exceeds it on a contended Windows host, and the timeout + // is indistinguishable from a real failure, so the guard below falls back to + // PARENT_PID_UNKNOWN and refuses a teardown the user is entitled to. The + // walk in isCurrentProcessDescendantOfPid stops at the first + // PARENT_PID_UNKNOWN, so this budget is spent at most once per lookup chain. result = run( resolveTrustedWindowsTool("powershell"), buildWindowsParentPidQueryArgs(pid), - { encoding: "utf8", timeout: 5_000, windowsHide: true }, + { encoding: "utf8", timeout: 15_000, windowsHide: true }, ); } catch { return PARENT_PID_UNKNOWN; diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index ce1fb4f89..45ab7fa4d 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -286,7 +286,9 @@ describe("Windows background service helpers", () => { ); try { expect(bootstrap.status).toBe(0); - const deadline = Date.now() + 5_000; + // Waits on a detached powershell.exe cold start plus a node child; 5s + // is not enough headroom on a loaded Windows CI runner. + const deadline = Date.now() + 45_000; while (!fs.existsSync(outputPath) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 20)); } @@ -295,7 +297,19 @@ describe("Windows background service helpers", () => { args: ["quoted \"value\"", "O'Brien", "100% & $HOME", "naïve-東京-🚀"], }); } finally { - const record = readWindowsServicePidRecord({ pidPath: `${launcherPath}.pid.json` }); + // The supervisor is detached via Start-Process, so this pid record is + // the only handle on it. Reading it once races the supervisor's first + // Write-PidRecord: when the read lost, the supervisor was never killed + // and kept restarting its child for the rest of the run, holding the + // temp tree open (EBUSY on unlink) and starving every later suite. + // Wait for the record before giving up on the kill. + const pidPath = `${launcherPath}.pid.json`; + const killDeadline = Date.now() + 15_000; + let record = readWindowsServicePidRecord({ pidPath }); + while (!record?.supervisorPid && Date.now() < killDeadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + record = readWindowsServicePidRecord({ pidPath }); + } if (record?.supervisorPid) { spawnChildSync("taskkill.exe", ["/PID", String(record.supervisorPid), "/T", "/F"], { encoding: "utf8", @@ -304,7 +318,7 @@ describe("Windows background service helpers", () => { } } }, - 10_000, + 90_000, ); it("registers and starts the per-user background service without Task Scheduler", async () => { diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts index f3428a1eb..749c95064 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts @@ -134,7 +134,12 @@ describe("Windows runtime supervisor", () => { launcherPath, ], { stdio: "ignore", windowsHide: true }); try { - const deadline = Date.now() + 5_000; + // The supervisor is a real detached PowerShell process, so this waits on + // powershell.exe cold start plus two full launch-failure backoff cycles. + // A 5s budget is a coin flip on a loaded Windows CI runner, where the + // record simply had not been written yet and the assertion below read + // null. Widen the patience; the assertion itself is unchanged. + const deadline = Date.now() + 45_000; let record = readWindowsServicePidRecord({ pidPath }); while ((!record?.lastLaunchError || record.restartCount < 2) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 20)); @@ -157,6 +162,6 @@ describe("Windows runtime supervisor", () => { } } }, - 10_000, + 60_000, ); }); diff --git a/apps/ade-cli/src/services/modelPickerStore.test.ts b/apps/ade-cli/src/services/modelPickerStore.test.ts index adde321b3..115912caa 100644 --- a/apps/ade-cli/src/services/modelPickerStore.test.ts +++ b/apps/ade-cli/src/services/modelPickerStore.test.ts @@ -29,6 +29,9 @@ describe("modelPickerStore (db-backed)", () => { const cleanupRoots: string[] = []; const openDbs: AdeDb[] = []; + // Closing SQLite handles and unlinking the db trees is IO-bound, and the + // default 10s hook budget was exhausted on a loaded Windows CI runner, + // reporting "Hook timed out in 10000ms" on top of the real failure. afterEach(async () => { vi.useRealTimers(); for (const db of openDbs.splice(0)) { @@ -37,7 +40,7 @@ describe("modelPickerStore (db-backed)", () => { for (const root of cleanupRoots.splice(0)) { await removeTestTree(root); } - }); + }, 60_000); async function makeDb(): Promise<{ db: AdeDb; root: string }> { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-model-picker-db-")); @@ -60,21 +63,31 @@ describe("modelPickerStore (db-backed)", () => { cleanupRoots.push(root); const workerPath = path.resolve(process.cwd(), "src", "test", "crrModelPickerWorker.ts"); const tsxPath = path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"); + // tsx has to transform the worker and load the crsqlite native extension + // in a cold child process; 15s is not enough headroom on a loaded Windows + // CI runner, where the kill left status null and the message below empty. const result = spawnSync(process.execPath, [tsxPath, workerPath, path.join(root, "ade.db")], { encoding: "utf8", env: process.env, - timeout: 15_000, + timeout: 60_000, windowsHide: true, }); - expect(result.status, result.stderr || result.stdout).toBe(0); + // A timeout kill reports status null with no output, so name that case + // explicitly rather than failing with a bare "expected null to be 0". + const failure = result.error + ? `spawn failed: ${result.error.message}` + : result.signal + ? `worker killed by ${result.signal} (timed out?)` + : result.stderr || result.stdout; + expect(result.status, failure).toBe(0); expect(JSON.parse(result.stdout.trim())).toEqual({ crsqliteAvailable: true, favorites: ["gpt-5"], recents: ["claude-sonnet-5"], }); }, - 20_000, + 90_000, ); it("starts empty on a fresh db", async () => { From b8212c56931a69da4b1fde3fcd1599e51eef7015 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 19:21:10 -0400 Subject: [PATCH 31/42] fix(windows): accept the quoted serve verb in the runtime readiness probe The readiness probe matched the runtime's command line against '(?:^|\s)serve(?:\s|$)', requiring the verb to sit between whitespace or string boundaries. Windows quotes spawned arguments, so a live brain's command line actually ends: "...\node.exe" "...\cli.cjs" "serve" The verb is wrapped in quotes, which are neither whitespace nor a boundary, so the predicate was always false and the probe exited 4 -- reporting a healthy runtime as "stale or does not match this channel executable". Found by running `ade brain start` on Windows, not by a test. It failed after 17.8s claiming the brain never became ready, while the brain was in fact running and serving on its named pipe. `ade brain status` then contradicted itself in one response: the runtime section reported the pid healthy while the service section called that same pid stale. A user would reasonably have repaired a working runtime. Verified against the live brain: with no restart and no other change, service.running went false -> true and the diagnostic became "ADE per-user channel brain is ready". Optional quotes still reject a different verb (serveless) and a different subcommand (rpc --stdio). Based-on: nsxdavid/ADE#999 --- apps/ade-cli/src/serviceManager/windowsSupervisor.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts index 359bb1e31..a25cd7de4 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts @@ -215,7 +215,13 @@ export function buildWindowsRuntimeQueryArgs(pid: number, command: AdeServiceCom expectedEntry === "$null" ? "$matchesEntry = $true" : `$matchesEntry = $commandLine.IndexOf(${expectedEntry}, [StringComparison]::OrdinalIgnoreCase) -ge 0`, - "$matchesServe = $commandLine -match '(?:^|\\s)serve(?:\\s|$)'", + // Windows quotes spawned arguments, so a live brain's command line ends + // `... "cli.cjs" "serve"` -- the verb is wrapped in quotes, not delimited by + // whitespace. Requiring a bare whitespace boundary made this predicate + // always false on Windows, so every readiness probe reported a healthy + // runtime as stale. Optional quotes accept both spellings without matching + // a different verb (`serveless`) or a different subcommand. + "$matchesServe = $commandLine -match '(?:^|\\s)\"?serve\"?(?:\\s|$)'", "if (-not $matchesExecutable -or -not $matchesEntry -or -not $matchesServe) { exit 4 }", "[Console]::Out.Write($process.ProcessId)", ].join("; "); From b1c3ab223ed570e1c405e86ea06267f0b21c5672 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 19:49:55 -0400 Subject: [PATCH 32/42] fix(windows): start the brain supervisor outside the caller's job object `ade brain start` launched the PowerShell supervisor with `Process.Start` / `ShellExecuteEx`, which makes it an ordinary descendant of whoever ran the command. On Windows, job-object membership is inherited and cannot be escaped: `CREATE_BREAKAWAY_FROM_JOB` fails with ERROR_ACCESS_DENIED unless the job sets `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, which `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` jobs do not. Terminals, editors, CI agents and Electron all place their children in exactly such jobs, so when the session that installed the brain went away Windows called TerminateProcess on the supervisor *and* the brain it guards -- no exit code, no log, no restart, and no chance for the supervisor's `finally` to run. A later `brain status` then cleared the now-stale PID record and reported "The startup entry has no valid PID record yet", erasing the last evidence that anything had ever been running. macOS never had this failure because `launchctl load` hands ownership of the process to launchd rather than to the invoking shell. Windows now gets the same handover: the supervisor is started through a transient one-shot scheduled task, so the Task Scheduler service spawns it and it is parented to `svchost.exe`, belonging to no job of ours. This also matches the login path, where `explorer.exe` -- likewise job-free -- runs the HKCU `Run` entry. Login persistence deliberately stays on the HKCU `Run` key: registering an ONLOGON task requires elevation (verified: `schtasks /Create /SC ONLOGON` and `Register-ScheduledTask -AtLogOn` both fail with "Access is denied" for a standard user), while a one-shot task does not. `Register-ScheduledTask` is used instead of `schtasks /Create` because the latter caps `/TR` at 261 characters, which a deep `ADE_HOME` exceeds, and `ExecutionTimeLimit` is zeroed so the scheduler cannot terminate an always-on brain after its three-day default. The supervisor also gained a log file next to its launcher, the equivalent of launchd's `StandardOutPath`/`StandardErrorPath`. It is spawned detached with a hidden window and no redirection, so until now every supervisor death was completely invisible; it now records its own start, each brain spawn and exit with code and lifetime, each backoff, and any terminating error that unwinds the supervise loop. If the Task Scheduler route is unavailable or denied by policy the in-session launch is still used as a fallback, so the brain always comes up -- it is just bound to the session, which status reports. Based-on: nsxdavid/ADE#999 (cherry picked from commit ba0f8f5cd9d9dcd6a38331e8b1fd5b111a6a8161) --- .../src/serviceManager/installWindows.test.ts | 25 +++- .../src/serviceManager/installWindows.ts | 118 ++++++++++++++++-- .../src/serviceManager/windowsSupervisor.ts | 32 +++++ 3 files changed, 164 insertions(+), 11 deletions(-) diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index 45ab7fa4d..65bd1670d 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -22,11 +22,13 @@ import { buildWindowsRunKeyQueryArgs, buildWindowsRunTaskArgs, buildWindowsStartLauncherArgs, + buildWindowsStartTaskArgs, getWindowsServiceStatus, installWindowsService, isWindowsLegacyTaskOwnedByCommand, readWindowsServicePidRecord, resolveWindowsServiceLauncherPath, + resolveWindowsStartTaskName, resolveWindowsTaskName, resolveWindowsTaskUser, uninstallWindowsService, @@ -352,7 +354,10 @@ describe("Windows background service helpers", () => { message: "ADE per-user startup entry installed and channel brain is ready.", }); expect(fs.readFileSync(launcherPath, "utf8")).toBe( - `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, + `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { + pidPath, + logPath: `${launcherPath}.log`, + })}`, ); expect(readinessProbe).toHaveBeenCalledWith(expect.objectContaining({ command: serviceCommand, @@ -381,7 +386,10 @@ describe("Windows background service helpers", () => { { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, + { + command: WINDOWS_POWERSHELL_COMMAND, + args: buildWindowsStartTaskArgs(launcherPath, resolveWindowsStartTaskName(taskName)), + }, ]); }); @@ -415,7 +423,9 @@ describe("Windows background service helpers", () => { { command: WINDOWS_SCHTASKS_COMMAND, args: buildWindowsDeleteTaskArgs(taskName) }, ]); expect(calls.at(-2)?.args).toEqual(expect.arrayContaining(["ADD", "/V", taskName])); - expect(calls.at(-1)?.args).toEqual(buildWindowsStartLauncherArgs(launcherPath)); + expect(calls.at(-1)?.args).toEqual( + buildWindowsStartTaskArgs(launcherPath, resolveWindowsStartTaskName(taskName)), + ); }); it("ends and deletes only the exact legacy task it owns before installing the channel task", async () => { @@ -497,7 +507,10 @@ describe("Windows background service helpers", () => { { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyQueryArgs(taskName) }, { command: WINDOWS_REG_COMMAND, args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, - { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, + { + command: WINDOWS_POWERSHELL_COMMAND, + args: buildWindowsStartTaskArgs(launcherPath, resolveWindowsStartTaskName(taskName)), + }, ]); expect(calls.filter((call) => call.command === WINDOWS_SCHTASKS_COMMAND)).toEqual([]); expect(calls.some((call) => call.args.includes("ADE Runtime") && call.args.includes("/End"))) @@ -625,6 +638,9 @@ describe("Windows background service helpers", () => { { status: 3, stdout: "", stderr: "" }, { status: 1, stdout: "", stderr: "" }, { status: 0, stdout: "SUCCESS: created", stderr: "" }, + // The job-escaping start task is unavailable, so the launch falls back to + // the in-session PowerShell start, which also fails. + { status: 1, stdout: "", stderr: "ERROR: task scheduler unavailable" }, { status: 1, stdout: "", stderr: "ERROR: access is denied" }, { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, ]); @@ -658,6 +674,7 @@ describe("Windows background service helpers", () => { buildWindowsQueryTaskArgs(taskName), buildWindowsRunKeyQueryArgs(taskName), buildWindowsRunKeyAddArgs(taskName, scheduledCommand), + buildWindowsStartTaskArgs(launcherPath, resolveWindowsStartTaskName(taskName)), buildWindowsStartLauncherArgs(launcherPath), buildWindowsRunKeyDeleteArgs(taskName), ]); diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 52b4c0c4c..3988a9d9d 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -125,6 +125,18 @@ export function resolveWindowsServicePidPath(args: { return `${resolveWindowsServiceLauncherPath(args)}.pid.json`; } +/** + * launchd parity for `StandardOutPath`/`StandardErrorPath`: the detached, + * hidden supervisor has nowhere to write, so without this a supervisor death + * leaves no evidence anywhere on the machine. + */ +export function resolveWindowsSupervisorLogPath(args: { + env?: NodeJS.ProcessEnv; + serviceName?: string; +} = {}): string { + return `${resolveWindowsServiceLauncherPath(args)}.log`; +} + export function readWindowsServicePidRecord(args: { env?: NodeJS.ProcessEnv; serviceName?: string; @@ -270,6 +282,99 @@ export function buildWindowsRunKeyDeleteArgs(valueName: string): string[] { return ["DELETE", WINDOWS_RUN_KEY, "/V", valueName, "/F"]; } +/** Transient one-shot task used only to escape the caller's job object. */ +export function resolveWindowsStartTaskName(taskName = resolveWindowsTaskName()): string { + return `${taskName} (start)`; +} + +/** + * Registers, runs, and immediately unregisters a one-shot task whose action is + * the supervisor launcher. + * + * `Register-ScheduledTask` is used rather than `schtasks /Create` because + * `schtasks` caps `/TR` at 261 characters, which a deep `ADE_HOME` blows past; + * the cmdlet has no such limit. `ExecutionTimeLimit` is explicitly zeroed -- + * the Task Scheduler default is three days, and an always-on brain must not be + * terminated by its own launcher on day four. Unregistering does not terminate + * the process the task already started, so nothing is left registered. + */ +export function buildWindowsStartTaskArgs( + launcherPath: string, + startTaskName: string, +): string[] { + const launcherArguments = [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + cmdQuote(launcherPath), + ].join(" "); + const nameLiteral = powerShellSingleQuotedLiteral(startTaskName); + const script = [ + "$ErrorActionPreference = 'Stop'", + `$action = New-ScheduledTaskAction -Execute ${powerShellSingleQuotedLiteral(WINDOWS_POWERSHELL_COMMAND)} -Argument ${powerShellSingleQuotedLiteral(launcherArguments)}`, + // Far-future one-shot trigger: the task only ever runs because we start it. + "$trigger = New-ScheduledTaskTrigger -Once -At ([DateTime]::Now.AddDays(3650))", + "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)", + [ + `try { Register-ScheduledTask -TaskName ${nameLiteral} -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`, + `Start-ScheduledTask -TaskName ${nameLiteral}`, + // Unregistering before the engine has spawned the action would cancel it, + // so wait for the task to actually enter Running first. + "$deadline = [DateTime]::UtcNow.AddSeconds(15)", + `while ([DateTime]::UtcNow -lt $deadline -and (Get-ScheduledTask -TaskName ${nameLiteral} -ErrorAction SilentlyContinue).State -ne 'Running') { Start-Sleep -Milliseconds 100 } } finally { Unregister-ScheduledTask -TaskName ${nameLiteral} -Confirm:$false -ErrorAction SilentlyContinue }`, + ].join("; "), + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", script]; +} + +/** + * Starts the supervisor **outside the caller's job object**. + * + * A process started with `Process.Start`/`ShellExecuteEx` is an ordinary + * descendant of whoever ran `ade brain start`, and on Windows job membership is + * inherited and cannot be escaped: `CREATE_BREAKAWAY_FROM_JOB` fails with + * ERROR_ACCESS_DENIED unless the job sets `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, + * which kill-on-close jobs do not. Terminals, editors, CI agents and Electron + * all put their children in `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` jobs, so the + * supervisor *and* the brain it guards were silently terminated the moment the + * session that installed them went away -- no exit code, no log, no restart. + * + * The Task Scheduler service starts task actions itself, so a process launched + * through a task is parented to `svchost.exe` and belongs to no job of ours. + * That is the same ownership handover macOS gets from `launchctl load`, and it + * matches the login path, where `explorer.exe` (also job-free) runs the HKCU + * `Run` entry. + * + * A one-shot task is used rather than an ONLOGON one because ONLOGON task + * registration requires elevation while a one-shot does not -- which is exactly + * why login persistence stays on the HKCU `Run` key. + */ +function startWindowsSupervisorDetached( + run: ServiceManagerSpawnSync, + launcherPath: string, + taskName: string, +): string | null { + const viaTask = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsStartTaskArgs(launcherPath, resolveWindowsStartTaskName(taskName)), + { encoding: "utf8", windowsHide: true }, + ); + if (viaTask.status === 0) return null; + // Task Scheduler is unavailable or denied by policy. Fall back to the + // in-session launch: the brain still comes up, it is just bound to the + // lifetime of the session that started it, which status will report. + const start = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsStartLauncherArgs(launcherPath), { + encoding: "utf8", + windowsHide: true, + }); + if (start.status === 0) return null; + return serviceManagerResultText(start) || "PowerShell launch failed."; +} + export function buildWindowsStartLauncherArgs(launcherPath: string): string[] { const childArgs = [ "-NoProfile", @@ -467,10 +572,11 @@ export async function installWindowsService( const runtimeEnv = { ...env, ...(serviceCommand.env ?? {}) }; const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env: runtimeEnv, serviceName }); const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + const logPath = `${launcherPath}.log`; const socketPath = resolveMachineAdeLayout(runtimeEnv, "win32").socketPath; try { fs.mkdirSync(path.dirname(launcherPath), { recursive: true }); - fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, { + fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath, logPath })}`, { encoding: "utf8", mode: 0o600, }); @@ -544,11 +650,8 @@ export async function installWindowsService( message: serviceManagerResultText(registration) || "Unable to create the ADE per-user startup entry.", }; } - const start = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsStartLauncherArgs(launcherPath), { - encoding: "utf8", - windowsHide: true, - }); - if (start.status !== 0) { + const startFailure = startWindowsSupervisorDetached(run, launcherPath, taskName); + if (startFailure) { run(WINDOWS_REG_COMMAND, buildWindowsRunKeyDeleteArgs(taskName), { encoding: "utf8", windowsHide: true, @@ -558,7 +661,7 @@ export async function installWindowsService( serviceName, action: "install", path: taskName, - message: `ADE per-user startup entry was installed, but the background service failed to start: ${serviceManagerResultText(start) || "PowerShell launch failed."}`, + message: `ADE per-user startup entry was installed, but the background service failed to start: ${startFailure}`, }; } const readiness = await waitForWindowsRuntimeReadiness({ @@ -646,6 +749,7 @@ export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): S message: removalErrors.join(" "), }; } + try { fs.rmSync(`${launcherPath}.log`, { force: true }); } catch { /* advisory log */ } try { fs.rmSync(launcherPath, { force: true }); } catch (error) { diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts index a25cd7de4..74d146c89 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts @@ -69,6 +69,14 @@ export function renderWindowsServiceLauncher( command: AdeServiceCommand, options: { pidPath: string; + /** + * launchd gives macOS `StandardOutPath`/`StandardErrorPath` for free, so a + * brain that dies leaves a trace on disk. The Windows supervisor is spawned + * detached with a hidden window and no redirection, so until this log + * existed every supervisor death -- graceful or externally terminated -- + * was completely invisible. Optional so existing callers keep working. + */ + logPath?: string; initialRestartDelayMs?: number; maxRestartDelayMs?: number; healthyRuntimeMs?: number; @@ -84,8 +92,20 @@ export function renderWindowsServiceLauncher( return `[System.Environment]::SetEnvironmentVariable(${powerShellSingleQuotedLiteral(key)}, ${powerShellSingleQuotedLiteral(value)}, 'Process')`; }); const commandLine = command.args.map(cmdQuote).join(" "); + const logLines = options.logPath + ? [ + `$logPath = ${powerShellSingleQuotedLiteral(options.logPath)}`, + "function Write-SupervisorLog([string]$message) {", + " try {", + " $stamp = [DateTimeOffset]::UtcNow.ToString('o')", + " [IO.File]::AppendAllText($logPath, \"$stamp supervisor=$PID $message`r`n\", [Text.Encoding]::UTF8)", + " } catch { }", + "}", + ] + : ["function Write-SupervisorLog([string]$message) { }"]; const processLines = [ `$pidPath = ${powerShellSingleQuotedLiteral(options.pidPath)}`, + ...logLines, `$initialRestartDelayMs = ${Math.max(100, Math.floor(options.initialRestartDelayMs ?? 1_000))}`, `$maxRestartDelayMs = ${Math.max(100, Math.floor(options.maxRestartDelayMs ?? 30_000))}`, `$healthyRuntimeMs = ${Math.max(1_000, Math.floor(options.healthyRuntimeMs ?? 60_000))}`, @@ -107,6 +127,7 @@ export function renderWindowsServiceLauncher( " }", " [IO.File]::WriteAllText($pidPath, ($record | ConvertTo-Json -Compress), [Text.Encoding]::ASCII)", "}", + "Write-SupervisorLog 'supervisor started'", "try {", " while ($true) {", " $runtimeStartedAt = [DateTimeOffset]::UtcNow", @@ -116,10 +137,12 @@ export function renderWindowsServiceLauncher( " $lastLaunchError = $null", " $nextRestartAt = $null", " Write-PidRecord -runtimePid $process.Id -runtimeStartedAtMs $runtimeStartedAt.ToUnixTimeMilliseconds()", + " Write-SupervisorLog \"brain started pid=$($process.Id) restartCount=$restartCount\"", " $process.WaitForExit()", " $lastExitCode = $process.ExitCode", " $lastExitAt = [DateTimeOffset]::UtcNow.ToString('o')", " $runtimeLifetimeMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $runtimeStartedAt.ToUnixTimeMilliseconds()", + " Write-SupervisorLog \"brain exited pid=$($process.Id) exitCode=$lastExitCode lifetimeMs=$runtimeLifetimeMs\"", " if ($runtimeLifetimeMs -ge $healthyRuntimeMs) { $restartCount = 0 } else { $restartCount += 1 }", " } catch {", " $lastExitCode = $null", @@ -127,14 +150,23 @@ export function renderWindowsServiceLauncher( " $lastLaunchError = [string]$_.Exception.Message", " if ($lastLaunchError.Length -gt 512) { $lastLaunchError = $lastLaunchError.Substring(0, 512) }", " $restartCount += 1", + " Write-SupervisorLog \"brain launch failed: $lastLaunchError\"", " }", " $exponent = [Math]::Min([Math]::Max($restartCount - 1, 0), 20)", " $restartDelayMs = [Math]::Min($maxRestartDelayMs, $initialRestartDelayMs * [Math]::Pow(2, $exponent))", " $nextRestartAt = [DateTimeOffset]::UtcNow.AddMilliseconds($restartDelayMs).ToString('o')", " Write-PidRecord -runtimePid $null -runtimeStartedAtMs $null", + " Write-SupervisorLog \"restarting in ${restartDelayMs}ms (nextRestartAt=$nextRestartAt)\"", " Start-Sleep -Milliseconds ([int]$restartDelayMs)", " }", + "} catch {", + // Any terminating error outside the inner try (a failed pid-record write, + // a broken Start-Sleep) used to unwind straight through `finally` and take + // the always-on brain down with no trace at all. + " Write-SupervisorLog \"supervisor loop aborted: $($_.Exception.Message)\"", + " throw", "} finally {", + " Write-SupervisorLog 'supervisor exiting; clearing pid record'", " Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue", "}", ]; From aee4a4e4fa1f5b9607ec04af9ad205ff022f70c5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 22:59:32 -0400 Subject: [PATCH 33/42] fix(windows): fall back to WMI when Task Scheduler denies the job escape `ade brain start` escaped the caller's job object through a transient one-shot scheduled task. On a machine where Group Policy denies task registration that route fails and the launch fell back to an in-session `Process.Start`, which puts the supervisor right back inside the caller's kill-on-close job. The brain came up, `install` reported "channel brain is ready", `status` reported `running: true`, and then Windows terminated supervisor and brain together the moment the session exited -- after which `status` said only "Cleared stale supervisor PID record", erasing the evidence. Silent degradation of the headline guarantee, in the shape that looks exactly like success. Reproduced end to end by shadowing the ScheduledTasks module so `Register-ScheduledTask` fails with the same "Access is denied." that policy blocking produces, running `ade brain start` inside a JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE job, and closing the job. Four launch routes were measured on a real kill-on-close job, not reasoned about: `Process.Start`/`ShellExecuteEx`, `WScript.Shell.Run` and `Shell.Application.ShellExecute` all stayed in the caller's job and died with it -- the shell delegates nothing for a plain executable, so the "explorer.exe is job-free" intuition does not transfer. The one-shot task produced a supervisor parented to `svchost.exe`, and `Win32_Process.Create` produced one parented to `WmiPrvSE.exe` and in no job at all; both outlived the job close. WMI is now the second escape. This is documented behaviour, not a trick: *Job Objects* states that child processes created using `Win32_Process.Create` are not associated with the job. It is the fallback rather than the primary because WMI process creation is the more commonly blocked of the two -- it is a known lateral-movement technique that endpoint-protection rules disable -- and the two failure modes are largely independent, which is what makes chaining them worth doing. `Win32_ProcessStartup.ShowWindow` is set to SW_HIDE because a process created this way gets default startup information rather than the caller's. If a machine refuses both, the in-session launch still runs, but it is no longer silent. The supervisor probes its own job at startup with `QueryInformationJobObject(NULL, JobObjectExtendedLimitInformation)` -- documented to use the job associated with the calling process, so no handle is needed -- and publishes `sessionBound` in its PID record; `brain start` and `brain status` both report in full that the always-on guarantee does not hold and why. That is measured about the running supervisor rather than inferred from which route the installer took, because the two disagree: the same launcher is started job-free by `explorer.exe` at the next sign-in, and a record of the installer's intent would keep warning about a brain that is no longer session-bound. This is the Windows cost of what launchd gives macOS for free, where process ownership makes "installed" and "survives this session" the same fact. Here they are two facts, and reporting only the first is how a session-bound brain came to look like a healthy one. Based-on: nsxdavid/ADE#999 (cherry picked from commit be0c0cd40a5e7d73c69d51d443276cb3fa7caaca) --- apps/ade-cli/README.md | 47 +++++++++- .../src/serviceManager/installWindows.test.ts | 8 +- .../src/serviceManager/installWindows.ts | 94 +++++++++++++++++-- .../serviceManager/windowsSupervisor.test.ts | 1 + .../src/serviceManager/windowsSupervisor.ts | 58 +++++++++++- 5 files changed, 195 insertions(+), 13 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index fabcde19d..d5164e4da 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -93,10 +93,55 @@ The ADE brain runs as a per-user login service. The implementations live in `src | --- | --- | --- | | macOS | launchd `LaunchAgent` | `~/Library/LaunchAgents/com.ade.runtime.plist` | | Linux | `systemctl --user` | `~/.config/systemd/user/<ADE_RUNTIME_SERVICE_NAME>.service` | -| Windows | `schtasks.exe ONLOGON` | scheduled task `ADE Runtime` | +| Windows | HKCU `Run` entry + PowerShell supervisor | `HKCU\...\CurrentVersion\Run` value `ADE Runtime (<channel>-<hash>)` | The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright and is used for both launchd and systemd unit names. macOS writes `launchd.{out,err}.log` under `ADE_HOME/runtime/`. +### Windows: how the always-on guarantee is actually obtained + +launchd and systemd own the process they start, so on macOS and Linux "installed" and +"survives this session" are the same fact. Windows has no unelevated equivalent, and the two +halves have to be built separately. + +**Login persistence** is the HKCU `Run` key. An ONLOGON scheduled task would be the closer +analogue but requires elevation — both `schtasks /Create /SC ONLOGON` and +`Register-ScheduledTask -AtLogOn` fail with *Access is denied* for a standard user. + +**Session survival** is the harder half. [Job +objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects): "by default +any child processes it creates using `CreateProcess` are also associated with the job", job +membership cannot be broken once assigned, and `CREATE_BREAKAWAY_FROM_JOB` fails with +`ERROR_ACCESS_DENIED` unless the job opted in with `JOB_OBJECT_LIMIT_BREAKAWAY_OK` — which the +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` jobs used by terminals, editors, CI agents and Electron do +not. So a supervisor started as an ordinary child of `ade brain start` is terminated with the +session that started it. The fix is to have something else create the process: + +1. **A transient one-shot scheduled task.** The Task Scheduler service spawns the action itself, + so the supervisor is parented to `svchost.exe` and belongs to none of our jobs. Registering a + one-shot task does not require elevation. The task is unregistered as soon as it has started. +2. **`Win32_Process.Create` over WMI**, if task registration is unavailable or denied by policy. + The same Microsoft page states it explicitly: "Child processes created using + `Win32_Process.Create` are not associated with the job." The process is created by the WMI + provider host `WmiPrvSE.exe` and ends up in no job at all. This is second rather than first + because WMI process creation is the more commonly blocked of the two — it is a known + lateral-movement technique and endpoint-protection rules disable it. The two failure modes are + largely independent, which is why both are worth having. +3. **An in-session launch**, if a machine refuses both. The brain still comes up, but it is bound + to the lifetime of the session that started it. + +At sign-in none of this is needed: `explorer.exe` runs the `Run` entry and is itself job-free. + +**The limitation, stated plainly.** On a machine that denies both handovers, ADE's always-on +guarantee does not hold — the brain dies when your terminal, editor or agent exits, and returns +only at your next sign-in. ADE does not hide this. The supervisor probes its own job on startup +(`QueryInformationJobObject(NULL, JobObjectExtendedLimitInformation)`, checking for +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) and publishes the answer as `sessionBound` in its PID +record; `ade brain start` and `ade brain status` both say so in full. That is measured about the +running supervisor rather than inferred from which launch route the installer used, so a brain +that was session-bound today stops being reported that way once `explorer.exe` starts it +job-free at the next sign-in. `null` means the probe could not run and is never reported as a +guarantee either way. + Manage the service from the CLI: ```bash diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index 65bd1670d..7f9986a4f 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -23,6 +23,7 @@ import { buildWindowsRunTaskArgs, buildWindowsStartLauncherArgs, buildWindowsStartTaskArgs, + buildWindowsWmiStartArgs, getWindowsServiceStatus, installWindowsService, isWindowsLegacyTaskOwnedByCommand, @@ -87,6 +88,7 @@ describe("Windows background service helpers", () => { lastExitAt: null, nextRestartAt: null, lastLaunchError: null, + sessionBound: false, }; const immediateReadiness = { readPidRecord: () => readyPidRecord, @@ -638,9 +640,10 @@ describe("Windows background service helpers", () => { { status: 3, stdout: "", stderr: "" }, { status: 1, stdout: "", stderr: "" }, { status: 0, stdout: "SUCCESS: created", stderr: "" }, - // The job-escaping start task is unavailable, so the launch falls back to - // the in-session PowerShell start, which also fails. + // Both job-escaping handovers are unavailable, so the launch falls all the + // way back to the in-session PowerShell start, which also fails. { status: 1, stdout: "", stderr: "ERROR: task scheduler unavailable" }, + { status: 5, stdout: "", stderr: "Win32_Process.Create failed with return value 2." }, { status: 1, stdout: "", stderr: "ERROR: access is denied" }, { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, ]); @@ -675,6 +678,7 @@ describe("Windows background service helpers", () => { buildWindowsRunKeyQueryArgs(taskName), buildWindowsRunKeyAddArgs(taskName, scheduledCommand), buildWindowsStartTaskArgs(launcherPath, resolveWindowsStartTaskName(taskName)), + buildWindowsWmiStartArgs(launcherPath), buildWindowsStartLauncherArgs(launcherPath), buildWindowsRunKeyDeleteArgs(taskName), ]); diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 3988a9d9d..3c99d4b2c 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -282,6 +282,24 @@ export function buildWindowsRunKeyDeleteArgs(valueName: string): string[] { return ["DELETE", WINDOWS_RUN_KEY, "/V", valueName, "/F"]; } +/** + * What a user is told when the always-on guarantee does not hold. + * + * macOS never needs this: `launchctl` owns the process, so "installed" and + * "survives this session" are the same fact. On Windows they are two different + * facts, and reporting only the first is how a session-bound brain came to look + * like a healthy one. + */ +export const WINDOWS_SESSION_BOUND_WARNING = + "The always-on guarantee does NOT hold: this machine refused both handovers " + + "(Task Scheduler registration and WMI Win32_Process.Create), so the brain is " + + "running inside the job object of the session that started it and Windows " + + "will terminate it -- with no exit code and no restart -- as soon as that " + + "terminal, editor or agent exits. It will come back at your next sign-in via " + + "the startup entry. To make it always-on now, allow scheduled-task " + + "registration or WMI process creation for your user, or run `ade brain " + + "start` from a session that is not job-confined."; + /** Transient one-shot task used only to escape the caller's job object. */ export function resolveWindowsStartTaskName(taskName = resolveWindowsTaskName()): string { return `${taskName} (start)`; @@ -331,6 +349,42 @@ export function buildWindowsStartTaskArgs( return ["-NoProfile", "-NonInteractive", "-Command", script]; } +/** + * Second escape route, used when Task Scheduler is unavailable or denied by + * policy: create the supervisor through WMI's `Win32_Process.Create`. + * + * This is not a heuristic. Windows documents the behaviour directly in *Job + * Objects*: "by default any child processes it creates using CreateProcess are + * also associated with the job. (Child processes created using + * Win32_Process.Create are not associated with the job.)" The process is + * spawned by the WMI provider host `WmiPrvSE.exe`, which lives under the DCOM + * service host, so it is not a descendant of the caller at all -- and unlike + * the Task Scheduler route it ends up in no job whatsoever. + * + * It is the fallback rather than the primary because it is the more commonly + * blocked of the two: WMI process creation is a well-known lateral-movement + * technique and is what endpoint-protection rules disable first. The two + * failure modes are largely independent, which is exactly what makes chaining + * them worth doing. + * + * `Win32_ProcessStartup.ShowWindow` is set to SW_HIDE because a process created + * this way gets default startup information rather than the caller's, so + * without it an always-on brain would flash a console window. + */ +export function buildWindowsWmiStartArgs(launcherPath: string): string[] { + const commandLine = windowsLauncherCommand(launcherPath); + const script = [ + "$ErrorActionPreference = 'Stop'", + "$startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 }", + `$arguments = @{ CommandLine = ${powerShellSingleQuotedLiteral(commandLine)}; CurrentDirectory = ${powerShellSingleQuotedLiteral(path.win32.dirname(launcherPath))}; ProcessStartupInformation = [CimInstance]$startup }`, + "$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $arguments", + "if ($null -eq $result) { [Console]::Error.Write('Win32_Process.Create returned no result.'); exit 5 }", + "if ($result.ReturnValue -ne 0) { [Console]::Error.Write(\"Win32_Process.Create failed with return value $($result.ReturnValue).\"); exit 5 }", + "[Console]::Out.Write($result.ProcessId)", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", script]; +} + /** * Starts the supervisor **outside the caller's job object**. * @@ -364,9 +418,18 @@ function startWindowsSupervisorDetached( { encoding: "utf8", windowsHide: true }, ); if (viaTask.status === 0) return null; - // Task Scheduler is unavailable or denied by policy. Fall back to the - // in-session launch: the brain still comes up, it is just bound to the - // lifetime of the session that started it, which status will report. + // Task Scheduler is unavailable or denied by policy. WMI is a second, + // independently documented escape: `Win32_Process.Create` children are not + // associated with the caller's job. + const viaWmi = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsWmiStartArgs(launcherPath), { + encoding: "utf8", + windowsHide: true, + }); + if (viaWmi.status === 0) return null; + // Both handovers refused. The in-session launch still brings the brain up, + // but it is bound to the lifetime of the session that started it. The + // supervisor detects that about itself and records it, so `brain status` + // says so out loud instead of reporting a healthy always-on brain. const start = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsStartLauncherArgs(launcherPath), { encoding: "utf8", windowsHide: true, @@ -664,14 +727,15 @@ export async function installWindowsService( message: `ADE per-user startup entry was installed, but the background service failed to start: ${startFailure}`, }; } + const readPidRecord = deps.readPidRecord + ?? ((target: string) => readWindowsServicePidRecord({ pidPath: target })); const readiness = await waitForWindowsRuntimeReadiness({ command: serviceCommand, launcherPath, pidPath, socketPath, spawnSync: run, - readPidRecord: deps.readPidRecord - ?? ((target) => readWindowsServicePidRecord({ pidPath: target })), + readPidRecord, readinessProbe: deps.readinessProbe ?? defaultWindowsRuntimeReadiness, timeoutMs: deps.handoverTimeoutMs ?? 15_000, pollMs: deps.handoverPollMs ?? 100, @@ -689,12 +753,18 @@ export async function installWindowsService( + readiness.diagnostic, }; } + // Ask the supervisor what it actually is, rather than trusting which launch + // route reported success: a "successful" in-session launch is precisely the + // case that used to be reported as a healthy always-on brain. + const sessionBound = readPidRecord(pidPath)?.sessionBound === true; return { ok: true, serviceName, action: "install", path: taskName, - message: "ADE per-user startup entry installed and channel brain is ready.", + message: sessionBound + ? `ADE per-user startup entry installed and the channel brain is ready, but it is bound to this session. ${WINDOWS_SESSION_BOUND_WARNING}` + : "ADE per-user startup entry installed and channel brain is ready.", }; } @@ -901,6 +971,9 @@ export function getWindowsServiceStatus( socketPath, spawnSync: run, }); + const base = readiness.ready + ? `ADE per-user channel brain is ready on ${socketPath}.` + : readiness.diagnostic; return { ok: true, serviceName, @@ -908,9 +981,12 @@ export function getWindowsServiceStatus( installed: true, running: readiness.ready, path: taskName, - message: readiness.ready - ? `ADE per-user channel brain is ready on ${socketPath}.` - : readiness.diagnostic, + // "Running" and "always-on" are the same thing on macOS and two different + // things here, so a session-bound brain must not be reported as healthy + // without saying which of the two it is. + message: supervisor.record.sessionBound === true + ? `${base} ${WINDOWS_SESSION_BOUND_WARNING}` + : base, }; } if (taskResult.status !== TASK_NOT_FOUND_EXIT_CODE) { diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts index 749c95064..cdfd22e2b 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts @@ -59,6 +59,7 @@ describe("Windows runtime supervisor", () => { lastExitAt: null, nextRestartAt: null, lastLaunchError: null, + sessionBound: null, }); fs.writeFileSync(pidPath, JSON.stringify({ diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts index 74d146c89..96fd078cf 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts @@ -19,6 +19,21 @@ export type WindowsServicePidRecord = { lastExitAt: string | null; nextRestartAt: string | null; lastLaunchError: string | null; + /** + * Ground truth, measured by the supervisor about itself: `true` when it is + * running inside a job object that carries + * `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, meaning Windows will terminate it the + * moment the session that started it goes away. + * + * This is recorded by the supervisor rather than inferred from which launch + * route the installer used, because the two can disagree: a supervisor that + * had to fall back to an in-session launch today is started job-free by + * `explorer.exe` at the next sign-in, and a record of the installer's intent + * would keep warning about a brain that is no longer session-bound. `null` + * means the probe could not run, and is never reported as a guarantee either + * way. + */ + sessionBound: boolean | null; }; export type WindowsRuntimeReadiness = { @@ -114,6 +129,42 @@ export function renderWindowsServiceLauncher( "$lastExitAt = $null", "$nextRestartAt = $null", "$lastLaunchError = $null", + // Whether this supervisor dies with the session that started it is not + // something the installer can know -- the same launcher is also run by + // `explorer.exe` at sign-in, where it is job-free. So the supervisor + // measures it about itself and publishes the answer, and `brain status` + // reports what is actually true right now rather than what the installer + // hoped for. `QueryInformationJobObject(NULL, ...)` is documented to use + // "the job associated with the calling process", so no handle is needed. + "$sessionBound = $null", + "try {", + " Add-Type -Namespace AdeSupervisor -Name JobApi -MemberDefinition @'", + "[DllImport(\"kernel32.dll\", SetLastError=true)]", + "public static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool result);", + "[DllImport(\"kernel32.dll\", SetLastError=true)]", + "public static extern bool QueryInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length, IntPtr returned);", + "[DllImport(\"kernel32.dll\")]", + "public static extern IntPtr GetCurrentProcess();", + "'@", + " $inJob = $false", + " [void][AdeSupervisor.JobApi]::IsProcessInJob([AdeSupervisor.JobApi]::GetCurrentProcess(), [IntPtr]::Zero, [ref]$inJob)", + " if (-not $inJob) {", + // No job at all: the Win32_Process.Create handover, which Windows + // documents as producing a child that is not associated with the job. + " $sessionBound = $false", + " } else {", + // sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION); LimitFlags sits at offset + // 16 in both bitnesses because the two preceding fields are LARGE_INTEGERs. + " $jobInfoSize = if ([IntPtr]::Size -eq 8) { 144 } else { 112 }", + " $jobInfo = [Runtime.InteropServices.Marshal]::AllocHGlobal($jobInfoSize)", + " try {", + // JobObjectExtendedLimitInformation = 9, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000. + " if ([AdeSupervisor.JobApi]::QueryInformationJobObject([IntPtr]::Zero, 9, $jobInfo, $jobInfoSize, [IntPtr]::Zero)) {", + " $sessionBound = [bool]([Runtime.InteropServices.Marshal]::ReadInt32($jobInfo, 16) -band 0x2000)", + " }", + " } finally { [Runtime.InteropServices.Marshal]::FreeHGlobal($jobInfo) }", + " }", + "} catch { $sessionBound = $null }", "function Write-PidRecord([Nullable[int]]$runtimePid, [Nullable[long]]$runtimeStartedAtMs) {", " $record = [ordered]@{", " supervisorPid = $PID", @@ -124,10 +175,12 @@ export function renderWindowsServiceLauncher( " lastExitAt = $lastExitAt", " nextRestartAt = $nextRestartAt", " lastLaunchError = $lastLaunchError", + " sessionBound = $sessionBound", " }", " [IO.File]::WriteAllText($pidPath, ($record | ConvertTo-Json -Compress), [Text.Encoding]::ASCII)", "}", - "Write-SupervisorLog 'supervisor started'", + "Write-SupervisorLog \"supervisor started sessionBound=$sessionBound\"", + "if ($sessionBound -eq $true) { Write-SupervisorLog 'WARNING: this supervisor is inside a kill-on-job-close job object; Windows will terminate it with the session that started it.' }", "try {", " while ($true) {", " $runtimeStartedAt = [DateTimeOffset]::UtcNow", @@ -213,6 +266,9 @@ export function readWindowsServicePidRecord(pidPath: string): WindowsServicePidR lastExitAt: boundedText(parsed.lastExitAt), nextRestartAt: boundedText(parsed.nextRestartAt), lastLaunchError: boundedText(parsed.lastLaunchError), + // Absent in records written by an older supervisor, and absent when the + // probe itself failed. Both mean "unknown", never "safe". + sessionBound: typeof parsed.sessionBound === "boolean" ? parsed.sessionBound : null, }; } catch { return null; From f30a154d66042d2d0c0b661e48255aa25ba46abc Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 22:51:06 -0400 Subject: [PATCH 34/42] test(windows): report the win32-gated tests as skipped instead of green Eight test bodies opened with `if (process.platform === "win32") return;`. On Windows that reports as a passing test while asserting nothing, so the Windows suite overstated its own coverage by eight assertions. Convert each to `it.skipIf(process.platform === "win32")(...)`, the idiom already used in builtInBrowserSecurity.test.ts and modelPickerStore.test.ts, so the runner reports them as skipped. All eight are POSIX-only harnesses: `/bin/sh` wrapper execution, AF_UNIX `listen(path)`, POSIX mode bits, and `fs.symlinkSync`. No assertion changes. Shrink scripts/platform-gate-baseline.json by exactly those entries: the ratchet goes from 8 entries / 14 violations to 4 entries / 6 violations, and no vacuous-return entry remains. Based-on: nsxdavid/ADE#999 (cherry picked from commit cd4e0c45537753df8373e809d6feb484ef999fa7) --- apps/ade-cli/src/cli.test.ts | 3 +- .../credentials/credentialStore.test.ts | 3 +- .../main/services/chat/cursorSdkHooks.test.ts | 12 +++----- .../services/chat/cursorSdkPolicy.test.ts | 6 ++-- scripts/platform-gate-baseline.json | 28 ------------------- 5 files changed, 8 insertions(+), 44 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index ca3065e5f..92bab15e5 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -5836,8 +5836,7 @@ describe("ADE CLI", () => { expect(buildCliPlan(["doctor", "--online"])).toEqual({ kind: "doctor", online: true }); }); - it("bounds doctor when a dead socket accepts a connection but never responds", async () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("bounds doctor when a dead socket accepts a connection but never responds", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-doctor-dead-sock-")); const socketPath = path.join(root, "ade.sock"); const acceptedSockets = new Set<net.Socket>(); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts index cdab152bb..dbcce21da 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.test.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -82,8 +82,7 @@ describe("EncryptedFileCredentialStore", () => { expect(sibling).toHaveBeenCalledTimes(1); }); - it("creates the secrets directory and files with private permissions", async () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("creates the secrets directory and files with private permissions", async () => { const secretsDir = path.join(tempDir, "secrets"); const store = new EncryptedFileCredentialStore({ secretsDir }); diff --git a/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts index 788ed0ec0..76148cf4e 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts @@ -93,8 +93,7 @@ describe("Cursor SDK hook installation", () => { } }); - it("uses a shell wrapper that allows non-ADE Cursor when Node is unavailable", () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("uses a shell wrapper that allows non-ADE Cursor when Node is unavailable", () => { const home = tempHome(); try { ensureCursorSdkUserHook({ userHomeDir: home }); @@ -112,8 +111,7 @@ describe("Cursor SDK hook installation", () => { } }); - it("fails closed for ADE Cursor hook invocations when no Node runner is available", () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("fails closed for ADE Cursor hook invocations when no Node runner is available", () => { const home = tempHome(); try { ensureCursorSdkUserHook({ userHomeDir: home }); @@ -201,8 +199,7 @@ describe("Cursor SDK hook installation", () => { } }); - it("fails closed when ADE accepts the hook connection but does not answer", async () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("fails closed when ADE accepts the hook connection but does not answer", async () => { const home = tempHome(); const socketPath = path.join(home, "silent.sock"); const server = net.createServer((socket) => { @@ -284,8 +281,7 @@ describe("Cursor SDK hook installation", () => { } }); - it("writes the POSIX shell wrapper with escaped paths", () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("writes the POSIX shell wrapper with escaped paths", () => { const home = tempHome(); try { const commandPath = cursorSdkHookShellCommandPath(home); diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index 5268a4d77..0f9c62ae9 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -201,8 +201,7 @@ describe("Cursor SDK policy", () => { expect(evaluateCursorSdkHook({ request: writeTranscript, policy, laneRoot, userHomeDir })).toBe("deny"); }); - it("denies Cursor support reads when the active project support root is symlinked outside Cursor projects", () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("denies Cursor support reads when the active project support root is symlinked outside Cursor projects", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-support-")); const home = path.join(root, "home"); const laneRoot = path.join(root, "repo", ".ade", "worktrees", "lane"); @@ -232,8 +231,7 @@ describe("Cursor SDK policy", () => { } }); - it("denies symlink escapes through paths that appear to be inside the lane", () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("denies symlink escapes through paths that appear to be inside the lane", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-policy-")); const laneRoot = path.join(root, "lane"); const outside = path.join(root, "outside"); diff --git a/scripts/platform-gate-baseline.json b/scripts/platform-gate-baseline.json index eeb593955..e16e22bf6 100644 --- a/scripts/platform-gate-baseline.json +++ b/scripts/platform-gate-baseline.json @@ -20,20 +20,6 @@ "requires": "darwin", "count": 1 }, - { - "file": "apps/ade-cli/src/cli.test.ts", - "kind": "vacuous-return", - "form": "vacuous-return", - "requires": "n/a", - "count": 1 - }, - { - "file": "apps/ade-cli/src/services/credentials/credentialStore.test.ts", - "kind": "vacuous-return", - "form": "vacuous-return", - "requires": "n/a", - "count": 1 - }, { "file": "apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts", "kind": "uncovered-gate", @@ -41,20 +27,6 @@ "requires": "darwin", "count": 3 }, - { - "file": "apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts", - "kind": "vacuous-return", - "form": "vacuous-return", - "requires": "n/a", - "count": 4 - }, - { - "file": "apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts", - "kind": "vacuous-return", - "form": "vacuous-return", - "requires": "n/a", - "count": 2 - }, { "file": "apps/desktop/src/main/services/state/kvDb.rebuildRecovery.test.ts", "kind": "uncovered-gate", From 8d20f7a51320a0f2d741a00e36ed79f5a18fe7fd Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 22:59:37 -0400 Subject: [PATCH 35/42] fix(repo): stop npm install from injecting an "ade" self-dependency `npm --prefix apps/<app> install` does not mean "install apps/<app>". `--prefix` only redirects where npm writes node_modules; the package npm treats as the one being installed is still the package in the *current working directory*. Run from the repo root -- which is what `install:apps` did, seven times -- that package is the root `ade`, so npm dutifully installed the repo into each sub-app: `"ade": "file:../.."` in the app's package.json and package-lock.json, plus an `apps/<app>/node_modules/ade` symlink back to the root. Confirmed by bisecting the invocation form on this branch: `cd apps/ade-cli && npm install` leaves the manifest untouched, `npm --prefix apps/ade-cli install` from the repo root reproduces the churn every time. Running with `--prefix` from a cwd that has no package.json fails outright with `ENOENT ... /package.json`, which is the same fact stated the other way. Replace `install:apps` with scripts/install-apps.mjs, which spawns `npm install` with `cwd` set per app -- the same shape .github/workflows/ci.yml already uses (`(cd apps/<app> && npm ci)`), so CI was never affected and needs no change. Also fix the two user-facing hints in adeCliService's shell fallbacks and apps/webhook-relay/README.md, which told people to run the polluting command, and document the trap in AGENTS.md's validation section. `npm --prefix <app> run <script>` and `npm --prefix <app> exec` install nothing and stay valid. Verified: `npm run install:apps` across all seven apps leaves zero package.json changes and creates no node_modules/ade symlink. Based-on: nsxdavid/ADE#999 (cherry picked from commit 4fe9f9165557cdfe5afa99ff0c11cf648f561ca3) --- AGENTS.md | 1 + .../src/main/services/cli/adeCliService.ts | 4 +- apps/webhook-relay/README.md | 2 +- package.json | 2 +- scripts/install-apps.mjs | 54 +++++++++++++++++++ 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 scripts/install-apps.mjs diff --git a/AGENTS.md b/AGENTS.md index 3e72b5220..433384c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ Utilities (run when relevant, not part of the core loop): **/audit** (targeted b - `npm --prefix apps/ade-cli run build` - Run the smallest relevant subset first when iterating, then finish with the broader checks that cover the touched surfaces. - Run full desktop tests with the root `npm run test:desktop:sharded` command; use single-file or single-shard Vitest commands for iteration. +- Installing deps: use `npm run install:apps` from the repo root, or `cd apps/<app> && npm install`. Never `npm --prefix apps/<app> install`. `--prefix` only redirects where npm writes `node_modules`; the package npm treats as "the one being installed" is still the one in the *current working directory*. From the repo root that is the root package `ade`, so npm installs the repo into the sub-app: it writes `"ade": "file:../.."` into the app's `package.json` and `package-lock.json` and leaves an `apps/<app>/node_modules/ade` symlink back to the root. Revert that churn if you hit it. `npm --prefix apps/<app> run <script>` and `npm --prefix apps/<app> exec` do not install anything and are unaffected -- but note `exec` runs Vitest with the *current* working directory, so run whole suites as `cd apps/<app> && npx vitest run` or the app's own `npm run test`. ## Terminology diff --git a/apps/desktop/src/main/services/cli/adeCliService.ts b/apps/desktop/src/main/services/cli/adeCliService.ts index 1e00c36b9..f75e80945 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.ts @@ -212,7 +212,7 @@ function createWindowsShimScript(args: { " exit /b %ERRORLEVEL%", " )", " if not exist \"%TSX_IMPORT%\" (", - " echo ade: Local source CLI fallback requires repo-local tsx. Run npm --prefix apps/ade-cli install or npm --prefix apps/ade-cli run build. 1>&2", + " echo ade: Local source CLI fallback requires repo-local tsx. Run npm run install:apps or npm --prefix apps/ade-cli run build. 1>&2", " exit /b 127", " )", " if defined ADE_CLI_NODE (", @@ -374,7 +374,7 @@ function writeDevShim(args: { " exec \"$TSX_BIN\" \"$CLI_JS\" \"$@\"", " fi", " if [ ! -f \"$TSX_IMPORT\" ]; then", - " echo \"ade: Local source CLI fallback requires repo-local tsx. Run npm --prefix apps/ade-cli install or npm --prefix apps/ade-cli run build.\" >&2", + " echo \"ade: Local source CLI fallback requires repo-local tsx. Run npm run install:apps or npm --prefix apps/ade-cli run build.\" >&2", " exit 127", " fi", " if [ -n \"${ADE_CLI_NODE:-}\" ]; then", diff --git a/apps/webhook-relay/README.md b/apps/webhook-relay/README.md index c41a5e6ce..08629d8d0 100644 --- a/apps/webhook-relay/README.md +++ b/apps/webhook-relay/README.md @@ -42,7 +42,7 @@ coalesces repository bursts to at most one delivery frame per second. ## Local development ```sh -npm --prefix apps/webhook-relay install +(cd apps/webhook-relay && npm install) npm --prefix apps/webhook-relay run typecheck npm --prefix apps/webhook-relay run test npm --prefix apps/webhook-relay run build diff --git a/package.json b/package.json index 4be2d84ed..8ff069277 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev:runtime": "node scripts/dev-runtime.mjs", "dev:stop": "node scripts/dev-runtime-stop.mjs", "dev:runtime:stop": "node scripts/dev-runtime-stop.mjs", - "install:apps": "npm --prefix apps/ade-cli install && npm --prefix apps/desktop install && npm --prefix apps/web install && npm --prefix apps/webhook-relay install && npm --prefix apps/tunnel-relay install && npm --prefix apps/push-relay install && npm --prefix apps/account-directory install", + "install:apps": "node scripts/install-apps.mjs", "package:alpha": "node scripts/package-channel.mjs alpha", "package:beta": "node scripts/package-channel.mjs beta", "runtime:build": "npm --prefix apps/ade-cli run build", diff --git a/scripts/install-apps.mjs b/scripts/install-apps.mjs new file mode 100644 index 000000000..ac98a75b6 --- /dev/null +++ b/scripts/install-apps.mjs @@ -0,0 +1,54 @@ +/** + * Install every sub-app's dependencies. + * + * This exists because `npm --prefix <app> install` is the wrong tool for the + * job. `--prefix` only moves where npm *writes* node_modules; the package npm + * considers "the one being installed" is still the one in the current working + * directory. Run from the repo root, `npm --prefix apps/ade-cli install` + * therefore means "install the root package `ade` into apps/ade-cli", which + * npm faithfully does: it writes `"ade": "file:../.."` into + * apps/ade-cli/package.json and package-lock.json and drops an + * apps/ade-cli/node_modules/ade symlink pointing back at the repo root. + * + * Spawning `npm install` with `cwd` set to the app directory is the form that + * means what everyone actually wants. `.github/workflows/ci.yml` already uses + * the equivalent `(cd apps/<app> && npm ci)`. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const APPS = [ + "ade-cli", + "desktop", + "web", + "webhook-relay", + "tunnel-relay", + "push-relay", + "account-directory", +]; + +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const passthrough = process.argv.slice(2); + +for (const app of APPS) { + const cwd = path.join(repoRoot, "apps", app); + if (!fs.existsSync(path.join(cwd, "package.json"))) { + throw new Error(`[install:apps] apps/${app} has no package.json`); + } + console.log(`[install:apps] npm install (cwd apps/${app})`); + const result = spawnSync(npm, ["install", ...passthrough], { + cwd, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`[install:apps] apps/${app} install failed with exit code ${result.status}`); + } +} + +console.log(`[install:apps] installed ${APPS.length} app(s)`); From 6f0ed0fa332b25af12e0bd6a8b044f5ed3f28b97 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 22:56:05 -0400 Subject: [PATCH 36/42] fix(release): pin a space-free mac artifact name so latest-mac.yml stays valid `build.mac.artifactName` interpolated `${productName}`. app-builder-lib's `updateInfoBuilder` rewrites the updater feed's `url` to `safeArtifactName` whenever `publish.provider === "github"` (out/publish/updateInfoBuilder.js:101), and `computeSafeArtifactNameIfNeeded` produces that name by replacing spaces with dashes -- returning null only when the name is already GitHub-safe (out/platformPackager.js:709). electron-builder can do that rewrite because its own GitHub publisher uploads under the safe name. This repo packages with `--publish never` and uploads via `gh release upload`, which uses the on-disk basename. A `productName` with a space would therefore put a name in latest-mac.yml that nothing ever published, and mac auto-update would 404 on the first channel release. `productName` is `ADE` today, so this is latent, not live -- but it becomes live the moment a channel build (`ADE Beta`) reaches the mac path. Pinning the literal keeps `safeArtifactName` null forever. The pinned value renders byte-identically to the current output (ADE-<version>-<arch>.{dmg,zip}), and every consumer already assumes that literal prefix: - apps/desktop/scripts/validate-mac-artifacts.mjs:756 -- /^ADE-.+-<arch>\.dmg$/ - apps/desktop/scripts/create-mac-dmg.mjs:68 -- `ADE-${version}-${arch}.dmg` - .github/workflows/release-core.yml -- name-agnostic globs (*.dmg, *.zip) - .agents/skills/release/SKILL.md:381-385 -- required-assets list - release-assets/runtime/SHA256SUMS covers the standalone runtime only So this is a config-to-consumer alignment, not a rename. Based-on: nsxdavid/ADE#999 (cherry picked from commit 3b02c3e6cdd6ffddf112a5a58210040800419340) --- apps/desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d614daabd..6bbdfc886 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -380,7 +380,7 @@ "notarize": true, "mergeASARs": false, "x64ArchFiles": "**/{*darwin-arm64*,*darwin-x64*,darwin-arm64,darwin-x64,whisper-cli}{,/**}", - "artifactName": "${productName}-${version}-${arch}.${ext}", + "artifactName": "ADE-${version}-${arch}.${ext}", "extraResources": [ { "from": "resources/native/ade-attention-notch", From 88ad3ab41f309d75e0086f7bc845015378a17c77 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 22:50:45 -0400 Subject: [PATCH 37/42] fix(windows): finalize cr-sqlite before closing the project database `openKvDb(...).close()` called `DatabaseSync.close()` directly. cr-sqlite requires `select crsql_finalize()` first so the extension can tear down its virtual tables and per-connection state; without it the extension keeps resources attached to the connection. On POSIX that is an invisible no-op, which is why it went unnoticed. On Windows the OS keeps the `ade.db` file handle open, so after a caller has closed the database the file still cannot be unlinked, renamed or moved -- a Windows user could not delete or relocate their own `.ade` directory, and any in-process consumer that closes and then replaces the file gets EBUSY. Route every teardown through a `closeDatabase()` helper that finalizes best-effort and then closes. This covers the public `close()`, the failed-init cleanup path, and the three reopen points in `openKvDb` (primary-key retrofit, foreign-key retrofit, site-id correction), each of which previously abandoned a live handle to the same file. Verified on native Windows: before, `openKvDb().close()` followed by `fs.rmSync` throws `EBUSY: resource busy or locked, unlink ...\ade.db` while a plain `DatabaseSync` open/close does not; after, both succeed. `kvDb.rebuildRecovery.test.ts` goes from 4/16 to 16/16 with no test changes. Based-on: nsxdavid/ADE#999 (cherry picked from commit 6e60d643692d1b6db6073569391fa99f69059948) --- apps/desktop/src/main/services/state/kvDb.ts | 32 +++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index ab09fb3c7..30abec34a 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -211,6 +211,28 @@ function openRawDatabase(dbPath: string): DatabaseSyncType { return db; } +/** + * Close a connection that may have the cr-sqlite extension loaded. + * + * cr-sqlite requires `SELECT crsql_finalize()` before the connection is closed + * so the extension can tear down its virtual tables and per-connection state. + * Skipping it leaves extension-owned resources attached to the connection: on + * POSIX that is an invisible no-op, but on Windows the OS keeps the underlying + * `ade.db` file handle open, so the file can no longer be unlinked, renamed or + * moved even though every caller believes the database is closed. + * + * Always route connection teardown through here instead of calling + * `db.close()` directly. + */ +function closeDatabase(db: DatabaseSyncType): void { + try { + db.exec("select crsql_finalize()"); + } catch { + // Extension not loaded (or already finalized) — nothing to tear down. + } + db.close(); +} + export function openReadonlyDatabase(dbPath: string): DatabaseSyncType { const db = new DatabaseSync(dbPath, { readOnly: true }); db.exec("PRAGMA busy_timeout = 5000"); @@ -3825,7 +3847,7 @@ export async function openKvDb( if (!isReadonlyDatabaseError(error)) throw error; } if (retrofittedLegacyPrimaryKeySchema) { - db.close(); + closeDatabase(db); db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); @@ -3843,7 +3865,7 @@ export async function openKvDb( if (!isReadonlyDatabaseError(error)) throw error; } if (retrofittedForeignKeySchema) { - db.close(); + closeDatabase(db); db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); @@ -3862,7 +3884,7 @@ export async function openKvDb( forceSiteId(db, desiredSiteId); if (readCurrentSiteId(db) !== desiredSiteId) { - db.close(); + closeDatabase(db); db = openRawDatabase(dbPath); crsqliteLoaded = false; loadCrsqliteIfAvailable(); @@ -3881,7 +3903,7 @@ export async function openKvDb( } } catch (err) { try { - db.close(); + closeDatabase(db); } catch { // best effort cleanup } @@ -4349,7 +4371,7 @@ export async function openKvDb( getRow(db, "pragma wal_checkpoint(TRUNCATE)"); }, close: () => { - db.close(); + closeDatabase(db); }, }; } From bded2c9fb52ad5ac2634a6fd0150842009aad3dc Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 23:06:01 -0400 Subject: [PATCH 38/42] fix(windows): kill the runtime daemon tree instead of the daemon pid `disposeOwnedRuntimeChild` tore down an owned `ade serve` daemon with `child.kill()`, which signals exactly one pid. A runtime daemon is not a leaf: observed live on a Windows box, 6 of 8 daemons this code spawns had their own `node.exe` children at the moment of teardown. Windows has no process groups for these children and no reaping parent, so every grandchild survived the daemon and kept holding the runtime named pipe. That is how `node cli.cjs serve` trees were found still alive ~9.5 hours after the process that owned them exited, still holding `\.\pipe\ade-runtime`. For a desktop user this means quitting ADE leaves runtime processes behind that block the next launch from binding its own pipe. Route disposal through `signalChildProcessTree` from shared/utils -- the helper `ptyService` and `agentChatService` already use, which signals the process group on POSIX and shells out to `taskkill /T` on Windows, falling back to a direct `child.kill()` if the tree signal fails. Also spawn the daemon with `detached` on POSIX so the group signal has a group to hit, matching the convention `spawnAsync` in shared/utils already follows. Previously the POSIX branch had the same one-pid blind spot, just with orphans reparented to init rather than stranded. Based-on: nsxdavid/ADE#999 (cherry picked from commit 1670b6e1c67d283f9d99aaa432e427f9af35f0c9) --- .../localRuntimeConnectionPool.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 7e2061187..975bfd024 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { app } from "electron"; import { isAdeRuntimeNamedPipePath } from "../../../shared/adeRuntimeIpc"; +import { signalChildProcessTree } from "../shared/utils"; import { isRuntimeProtocolCompatible, parseRuntimeLastWedge, @@ -713,10 +714,26 @@ export function isRetryableReadAction(domain: string, action: string): boolean { ); } +/** + * Signal an owned `ade serve` daemon and everything it started. + * + * A runtime daemon is not a leaf: it spawns its own `node` children (agent + * runners, the brain, tool subprocesses). `child.kill()` signals exactly one + * pid. On POSIX that at least leaves the orphans reparented to init and + * reachable by group signal; on Windows there is no process group and no + * reaping parent, so every grandchild survives indefinitely and keeps holding + * the runtime named pipe -- which is how `ade serve` trees were observed still + * alive hours after the process that owned them exited. + * + * `signalChildProcessTree` is the repo's existing answer to this (process-group + * signal on POSIX, `taskkill /T` on Windows, same helper `ptyService` and + * `agentChatService` use), and it falls back to a direct `child.kill()` if the + * tree signal fails. + */ function signalRuntimeChildProcess(child: ChildProcess | null, signal: NodeJS.Signals): void { if (!child?.pid) return; try { - child.kill(signal); + signalChildProcessTree(child, signal); } catch {} } @@ -2371,7 +2388,11 @@ export class LocalRuntimeConnectionPool { const child = spawn(process.execPath, args, { env, stdio: ["ignore", "pipe", "pipe"], - detached: false, + // Put the daemon in its own process group on POSIX so disposal can signal + // the whole tree with `process.kill(-pid)` instead of just the daemon -- + // the same convention `spawnAsync` in shared/utils already uses. Windows + // has no process groups here; `signalChildProcessTree` uses `taskkill /T`. + detached: process.platform !== "win32", windowsHide: true, }); this.ownedRuntimeChild = child; From fd7f15f72a4d891c6300efd31f6cd40a92d52b34 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 23:06:11 -0400 Subject: [PATCH 39/42] test(windows): reap every spawned runtime daemon tree in the pool suite The suite spawns real `ade serve` daemons and relied entirely on per-test `finally` blocks calling `child.kill()`. That left two gaps on Windows: a test that threw before its `finally` was reached leaked its daemon outright, and even the blocks that did run killed a single pid while the daemon's `node` children survived holding the runtime named pipe. Since this suite is a gate in the `windows-foundation` CI job, a leak also means the job can exit with live children. Track every daemon the suite starts in a set, and add an `afterEach` that tree-kills whatever is still registered, so cleanup no longer depends on any individual test reaching its own teardown. Route the existing per-test cleanups through the same `reapDaemonTree` helper so the pipe is released before the next test starts rather than at end of file, and spawn detached on POSIX so the group signal has a group. Verified on native Windows: three consecutive runs, each followed by a `Win32_Process` scan for `cli.cjs serve`/`brain-service` and a `\.\pipe\` scan for `ade` pipes, reported zero survivors and zero pipes every time. Suite result is unchanged at 65 passed / 1 skipped. Based-on: nsxdavid/ADE#999 (cherry picked from commit 85fda9f31ade873389b7c32872f953728fb8c021) --- .../localRuntimeConnectionPool.test.ts | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index e51ed8c62..24442974c 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -4,7 +4,8 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { signalChildProcessTree } from "../shared/utils"; import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; import { realpathIfExists } from "../../../../../ade-cli/src/services/projects/projectRoots"; import { recordLastFailure } from "../runtime/lastFailureStore"; @@ -200,17 +201,47 @@ async function waitForRuntimeSocket( }, { timeout: timeoutMs, interval: 100 }); } +/** + * Every real `ade serve` daemon this suite starts, so teardown can reap them + * even when a test throws before reaching its own `finally`. + * + * Per-test cleanup alone is not enough on Windows: a daemon is not a leaf + * process (it spawns `node` children of its own), and an unreaped tree keeps + * holding the runtime named pipe, so the next test -- or the next CI job -- + * inherits a live listener it did not start. + */ +const spawnedDaemons = new Set<ChildProcess>(); + +/** Kill a daemon and everything it started. `taskkill /T` on Windows, process group on POSIX. */ +function reapDaemonTree(child: ChildProcess): void { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + try { + signalChildProcessTree(child, "SIGKILL"); + } catch { + // Best effort: the tree may already be gone. + } +} + +afterEach(() => { + for (const child of spawnedDaemons) reapDaemonTree(child); + spawnedDaemons.clear(); +}); + function startServeProcess(args: { cliPath: string; cwd: string; env: NodeJS.ProcessEnv; socketPath: string; }): ChildProcess { - return spawn(process.execPath, [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync"], { + const child = spawn(process.execPath, [args.cliPath, "serve", "--socket", args.socketPath, "--no-sync"], { cwd: args.cwd, env: args.env, stdio: ["ignore", "ignore", "ignore"], + detached: process.platform !== "win32", }); + spawnedDaemons.add(child); + child.once("exit", () => spawnedDaemons.delete(child)); + return child; } function runningTestServiceStatus() { @@ -557,7 +588,7 @@ describe("local runtime connection pool", () => { } finally { pool.dispose(); await shutdownRuntime(socketPath); - if (!daemon.killed) daemon.kill("SIGKILL"); + reapDaemonTree(daemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; @@ -932,7 +963,7 @@ describe("local runtime connection pool", () => { expect((pool as unknown as { projectsByRoot: Map<string, unknown> }).projectsByRoot.size).toBe(0); expect(client.close).toHaveBeenCalledTimes(1); } finally { - if (child && !child.killed) child.kill(); + if (child) reapDaemonTree(child); if (originalAdeCliJs === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalAdeCliJs; removeTempDir(tempDir); @@ -992,8 +1023,8 @@ describe("local runtime connection pool", () => { expect((pool as unknown as { projectsByRoot: Map<string, unknown> }).projectsByRoot.size).toBe(1); expect(replacementClient.close).not.toHaveBeenCalled(); } finally { - if (oldChild && !oldChild.killed) oldChild.kill(); - if (replacementChild && !replacementChild.killed) replacementChild.kill(); + if (oldChild) reapDaemonTree(oldChild); + if (replacementChild) reapDaemonTree(replacementChild); if (originalAdeCliJs === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalAdeCliJs; removeTempDir(tempDir); @@ -2165,7 +2196,7 @@ describe("local runtime connection pool", () => { firstPool?.dispose(); secondPool?.dispose(); await shutdownRuntime(socketPath); - if (!daemon.killed) daemon.kill("SIGKILL"); + reapDaemonTree(daemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; @@ -2297,9 +2328,7 @@ describe("local runtime connection pool", () => { secondPool?.dispose(); pool?.dispose(); await shutdownRuntime(socketPath); - if (!oldDaemon.killed) { - try { oldDaemon.kill("SIGKILL"); } catch {} - } + reapDaemonTree(oldDaemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; @@ -2391,7 +2420,7 @@ describe("local runtime connection pool", () => { } finally { pool?.dispose(); await shutdownRuntime(socketPath); - if (!daemon.killed) daemon.kill("SIGKILL"); + reapDaemonTree(daemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; @@ -2470,7 +2499,7 @@ describe("local runtime connection pool", () => { } finally { pool?.dispose(); await shutdownRuntime(socketPath); - if (!daemon.killed) daemon.kill("SIGKILL"); + reapDaemonTree(daemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; @@ -2548,7 +2577,7 @@ describe("local runtime connection pool", () => { } finally { pool?.dispose(); await shutdownRuntime(socketPath); - if (!devDaemon.killed) devDaemon.kill(); + reapDaemonTree(devDaemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; @@ -2667,9 +2696,7 @@ describe("local runtime connection pool", () => { } finally { pool?.dispose(); await shutdownRuntime(socketPath); - if (!oldDaemon.killed) { - try { oldDaemon.kill("SIGKILL"); } catch {} - } + reapDaemonTree(oldDaemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; @@ -2791,9 +2818,7 @@ describe("local runtime connection pool", () => { } finally { pool?.dispose(); await shutdownRuntime(socketPath); - if (!oldDaemon.killed) { - try { oldDaemon.kill("SIGKILL"); } catch {} - } + reapDaemonTree(oldDaemon); if (originalEnv.ADE_CLI_JS === undefined) delete process.env.ADE_CLI_JS; else process.env.ADE_CLI_JS = originalEnv.ADE_CLI_JS; if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; From b76990eb2aac0c013be7ab7e13c6c5192ac0bcf5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 23:08:18 -0400 Subject: [PATCH 40/42] ci(windows): gate the CRR rebuild-recovery suite on the Windows runner The suite was excluded because 12 of its 16 tests failed with EBUSY on Windows. The comment blamed the test's temp-dir teardown; that diagnosis was wrong. openKvDb's close() never called crsql_finalize(), so the OS kept the ade.db handle open after every caller believed the database was closed -- fixed in 6e60d643, with the test file itself untouched. It now passes 16/16, and this also makes its skipIf(linux) CRR tombstone compaction case reachable for the first time: the file otherwise only ran in test-desktop on ubuntu, where that case is skipped. Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a422873f..e892af225 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -500,13 +500,13 @@ jobs: # green checkmark over unrun CRR replication, sync host, sync service, # and device registry coverage. windows-latest is the only runner in this # workflow that has the extension, so this is where those tests actually - # execute. Do not add kvDb.rebuildRecovery.test.ts here: its temp-dir - # teardown unlinks an open SQLite handle, which is EBUSY on Windows. + # execute. - name: Test Windows CRDT, sync, and device registry contracts run: >- cd apps/desktop && npx vitest run src/main/services/state/kvDb.test.ts src/main/services/state/kvDb.migrations.test.ts + src/main/services/state/kvDb.rebuildRecovery.test.ts src/main/services/state/kvDb.sync.test.ts src/main/services/sync/deviceRegistryService.test.ts src/main/services/sync/syncHostService.test.ts From 37b2c4d6ba16020fa0f17e3530d4d7250c495c67 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Sun, 2 Aug 2026 23:10:26 -0400 Subject: [PATCH 41/42] ci(windows): shrink the platform-gate baseline after CRR coverage landed kvDb.rebuildRecovery.test.ts carries an it.skipIf(linux) gate that ran on no runner while the file was excluded from the Windows job. Gating it in that job covers the gate, so its baseline entry is stale and the ratchet correctly refuses to pass until the baseline shrinks. Based-on: nsxdavid/ADE#999 --- scripts/platform-gate-baseline.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/scripts/platform-gate-baseline.json b/scripts/platform-gate-baseline.json index e16e22bf6..a9a618f13 100644 --- a/scripts/platform-gate-baseline.json +++ b/scripts/platform-gate-baseline.json @@ -26,13 +26,6 @@ "form": "it.runIf", "requires": "darwin", "count": 3 - }, - { - "file": "apps/desktop/src/main/services/state/kvDb.rebuildRecovery.test.ts", - "kind": "uncovered-gate", - "form": "it.skipIf", - "requires": "darwin|win32", - "count": 1 } ] } From dad94fb701282f10dac81fb6c7b31d3ed5d85b02 Mon Sep 17 00:00:00 2001 From: Arul Sharma <arulsharma1028@gmail.com> Date: Mon, 3 Aug 2026 13:35:25 -0400 Subject: [PATCH 42/42] fix(windows): probe named pipes for a live owner before the brain binds A Windows brain skipped every socket-ownership check on its way to `listen()`. All three guards were gated on `!isAdeRuntimeNamedPipePath`, and `probeLocalSocketForLiveness` returned `"unknown"` for any pipe without dialing it, so the brain went straight to a bare bind. When the endpoint was already owned the raw `EADDRINUSE` escaped, the recovery classifier could not match Node's wording, and the failure was filed as `code: "unknown"` with "ADE's background service could not start." and no next action. The gating inherited a POSIX assumption that is not just unnecessary on Windows but backwards. A unix socket leaves a file behind when its owner dies, so the path existing proves nothing and only a probe can tell. A named pipe has no filesystem corpse: the name lives exactly as long as some process holds a handle and the kernel releases it the moment the last handle closes -- SIGKILL the owner and the next `listen()` succeeds. So dialing a pipe is decisive where dialing a socket is a hint, and `ENOENT` on connect is itself the existence check that `existsSync` was being asked for. Probe pipes like any other local endpoint, run the ownership check before binding one (without the unlink step, which has nothing to unlink), and translate a racing `EADDRINUSE` at the bind into the same `socket_owned_by_other` error the pre-bind path already raises. Windows now reports what macOS has always reported, with a cause that describes Windows' actual semantics rather than hedging about a stale endpoint the platform cannot produce. Verified against a scratch ADE_HOME and pipe name: the recorded failure goes from `code: "unknown"` with Node's raw text to `code: "socket_owned_by_other"` with a cause and a next action, and a brain binding a free pipe still starts normally. Based-on: nsxdavid/ADE#999 --- apps/ade-cli/src/cli.ts | 98 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 439dab826..2d4d173c3 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -13353,17 +13353,58 @@ function createSocketConnection(socketPath: string): net.Socket { async function assertBrainSocketUnowned(socketPath: string): Promise<void> { const liveness = await probeLocalSocketForLiveness(socketPath); if (liveness !== "live" && liveness !== "unknown") return; - throw Object.assign(new CliExecutionError("ADE brain socket is already in use.", { - socketPath, - cause: liveness === "live" - ? "Another ADE brain is accepting connections on this socket." - : "ADE could not prove the existing socket is stale.", - nextAction: "Stop the existing ADE brain or choose a different --socket path.", - }), { code: "socket_owned_by_other" as const }); + throw Object.assign( + new CliExecutionError("ADE brain socket is already in use.", { + socketPath, + cause: brainSocketOwnedCause(socketPath, liveness), + nextAction: "Stop the existing ADE brain or choose a different --socket path.", + }), + { code: "socket_owned_by_other" as const }, + ); +} + +/** + * Why the endpoint is unavailable, in the terms of the platform it runs on. + * + * On Windows an unavailable pipe name always has a live owner holding a + * handle, so there is no "maybe it is stale" branch to hedge about — telling a + * Windows user we "could not prove the socket is stale" describes a failure + * mode their OS does not have, and points them at a cleanup that cannot help. + */ +function brainSocketOwnedCause( + socketPath: string, + liveness: "live" | "unknown", +): string { + if (isAdeRuntimeNamedPipePath(socketPath)) { + return liveness === "live" + ? "Another ADE brain is accepting connections on this named pipe." + : "Another process holds this named pipe. Windows releases a pipe name as " + + "soon as its owner exits, so the name being taken means an ADE brain " + + "(or another build of ADE) is still running."; + } + return liveness === "live" + ? "Another ADE brain is accepting connections on this socket." + : "ADE could not prove the existing socket is stale."; } +/** + * Windows named pipes are probeable, and more decisively than a unix socket. + * + * This used to bail out with `"unknown"` for any `\\.\pipe\...` address, which + * inherited a POSIX assumption that does not hold: a unix socket leaves a file + * behind after its owner dies, so "the path exists" says nothing about + * liveness and the probe is the only way to tell. A named pipe has no + * filesystem corpse at all — the name lives exactly as long as some process + * holds a handle to it, and the kernel releases it the moment the last handle + * closes (verified: SIGKILL the owner and the very next `listen()` succeeds). + * + * So dialing a pipe answers the question outright: `ENOENT` means nobody owns + * this name, and a completed connect means a live brain does. Refusing to dial + * threw that signal away and left every Windows caller with `"unknown"`, which + * is the *most* alarming verdict the callers act on. + */ async function probeLocalSocketForLiveness(socketPath: string): Promise<"live" | "stale" | "unknown"> { - if (socketPath.startsWith("tcp://") || isAdeRuntimeNamedPipePath(socketPath)) { + if (socketPath.startsWith("tcp://")) { return "unknown"; } return await new Promise((resolve) => { @@ -16291,7 +16332,11 @@ async function runServe( // the bind check below and simply lived on as a zombie — we found 18 of them // stacked up on one dev socket, all of them still dialing the relay. A brain // that cannot own its socket has no reason to exist, so fail fast. - if (!isAdeRuntimeNamedPipePath(socketPath) && fs.existsSync(socketPath)) { + // + // A named pipe never shows up as a file to `existsSync` once its owner is + // gone, and dialing it is itself the existence check (`ENOENT` when free), so + // Windows skips straight to the probe instead of gating on the filesystem. + if (isAdeRuntimeNamedPipePath(socketPath) || fs.existsSync(socketPath)) { try { await assertBrainSocketUnowned(socketPath); } catch (error) { @@ -16318,7 +16363,7 @@ async function runServe( // provably live owner so a probe hiccup can't make a brain quit on // itself. abortIf: async () => { - if (isAdeRuntimeNamedPipePath(socketPath) || !fs.existsSync(socketPath)) return false; + if (!isAdeRuntimeNamedPipePath(socketPath) && !fs.existsSync(socketPath)) return false; return await probeLocalSocketForLiveness(socketPath) === "live"; }, }); @@ -16354,7 +16399,14 @@ async function runServe( } fs.mkdirSync(layout.adeDir, { recursive: true, mode: 0o700 }); - if (!isAdeRuntimeNamedPipePath(socketPath)) { + if (isAdeRuntimeNamedPipePath(socketPath)) { + // No directory to create and nothing to unlink: a pipe name is a kernel + // object, not a file. The ownership check still applies though — this used + // to be skipped wholesale on Windows, which sent the brain straight into a + // bare `listen()` and turned an "another brain owns this" conflict into an + // unclassified crash. + await assertBrainSocketUnowned(socketPath); + } else { fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 }); if (fs.existsSync(socketPath)) { await assertBrainSocketUnowned(socketPath); @@ -16366,7 +16418,29 @@ async function runServe( const socketState = createHeadlessRpcServer(createHandler); states.push(socketState); - await listen(socketState.server, socketPath); + try { + await listen(socketState.server, socketPath); + } catch (error) { + // The ownership check above is a check, not a lock: two brains that both + // probe a free endpoint race to bind it and the loser lands here. Without + // this, `EADDRINUSE` escaped as Node's raw text, which the recovery + // classifier below could not match, so the failure was filed as `unknown` + // with "ADE's background service could not start." and no next action — + // exactly the shape of the startup failure we found recorded on Windows. + // Give the loser the same coded error the pre-bind path already raises. + if ((error as NodeJS.ErrnoException)?.code === "EADDRINUSE") { + await disposeServeResources(); + throw Object.assign( + new CliExecutionError("ADE brain socket is already in use.", { + socketPath, + cause: brainSocketOwnedCause(socketPath, "live"), + nextAction: "Stop the existing ADE brain or choose a different --socket path.", + }), + { code: "socket_owned_by_other" as const }, + ); + } + throw error; + } if (!isAdeRuntimeNamedPipePath(socketPath)) { try { fs.chmodSync(socketPath, 0o600);