diff --git a/DESIGN.md b/DESIGN.md index ada8ad19d..bbaebada6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3284,6 +3284,20 @@ The values are the ones in `src/styles.css`. bindings on phones and desktops. - `document.title` is ` · Ovis` while selected, otherwise `Ovis`. +### Windows remote startup + +An absent Windows herdr waits for the account's desktop sign-in. The PC's existing status notice +offers **Review startup options**; the existing setup dialog explains the sandbox and possible +desktop-tool limitations before **Start without Windows sign-in** authorizes one start. It adds no +persistent preference, second dialog, or new visual treatment. This follows the visible-state and +explicit-override pattern of [VS Code Workspace Trust](https://code.visualstudio.com/docs/editing/workspaces/workspace-trust), +adapted to choosing a remote startup environment rather than granting workspace trust. No code or +visual assets are copied. Cancel stops pending attempts; existing servers and panes stay running. +Task Scheduler failures use the same notice and dialog with **Windows desktop startup failed** +and the scheduler's error, rather than instructions to sign in. They stop background retries until +the user reviews startup options. The existing VS Code-inspired explicit override remains the only +headless choice; sign-in waits continue reconnecting automatically. + ### Accepted debt | Item | Location | Why accepted | Exit | diff --git a/changelog.d/windows-interactive-start.md b/changelog.d/windows-interactive-start.md new file mode 100644 index 000000000..2c25b02b0 --- /dev/null +++ b/changelog.d/windows-interactive-start.md @@ -0,0 +1,10 @@ +### Fixed +- Windows remote PCs wait for Windows sign-in before starting herdr, so Codex can use its + private-desktop sandbox. Startup options offer an explicit start without sign-in for headless + PCs, including when Task Scheduler cannot start herdr, with the error and sandbox and desktop-tool + limitations shown before that one start. Existing herdr servers are reused without restarting + their panes. Signing out of Windows ends a desktop-started herdr and its panes; lock the PC + instead. SSH disconnects and bridge updates leave them running. +- Slow Windows launchers keep their startup options through the launch deadline. Failed startup + reviews restore saved PCs' reconnection attempts; Task Scheduler failures show their own error + instead of a sign-in instruction and stop background retries until startup options are reviewed. diff --git a/docs/remote-pcs.md b/docs/remote-pcs.md index 07f55179b..f2110bf61 100644 --- a/docs/remote-pcs.md +++ b/docs/remote-pcs.md @@ -42,10 +42,29 @@ The web UI can itself run on Windows x64 and connect to other PCs with Git for W ### Windows PCs -Setup first asks the PC in `sh`; one that cannot run it (cmd or PowerShell as the OpenSSH default shell, whichever) is asked again in PowerShell, as a base64 `-EncodedCommand` that both shells pass through untouched. The bundle for `win32-x64` carries Bun only, under `%LOCALAPPDATA%\herdr-web-ui\remote-v` (a junction to a checksum-named release directory). herdr is not bundled: an installed one is used (`%LOCALAPPDATA%\Programs\Herdr\bin\herdr.exe`, the stable alias its installer keeps), and otherwise setup runs herdr's own `install.cmd` from herdr.dev, which the approval list names. The bundle goes over by sftp, since PowerShell owns a redirected stdin. Registrations live in `%USERPROFILE%\.config\herdr-web-ui\bridges\`; the bridge and the herdr daemon are started through WMI (`Win32_Process.Create`), outside the SSH session's and the bridge's job objects, so they survive the logout and each other. The app key goes to `%ProgramData%\ssh\administrators_authorized_keys` for an administrator account (what Windows OpenSSH reads for one) and to `.ssh\authorized_keys` otherwise. herdr's socket there is a named pipe; the `herdr.sock` file is a `pid:start` marker. +Setup first asks the PC in `sh`; one that cannot run it (cmd or PowerShell as the OpenSSH default shell, whichever) is asked again in PowerShell, as a base64 `-EncodedCommand` that both shells pass through untouched. The bundle for `win32-x64` carries Bun only, under `%LOCALAPPDATA%\herdr-web-ui\remote-v` (a junction to a checksum-named release directory). herdr is not bundled: an installed one is used (`%LOCALAPPDATA%\Programs\Herdr\bin\herdr.exe`, the stable alias its installer keeps), and otherwise setup runs herdr's own `install.cmd` from herdr.dev, which the approval list names. The bundle goes over by sftp, since PowerShell owns a redirected stdin. Registrations live in `%USERPROFILE%\.config\herdr-web-ui\bridges\`; the bridge is started through WMI (`Win32_Process.Create`), outside the SSH session's job. A missing herdr daemon is started in the same account's signed-in Windows session through a temporary, ordinary-privilege task; the task is removed after launch and herdr survives bridge updates and SSH disconnects. The app key goes to `%ProgramData%\ssh\administrators_authorized_keys` for an administrator account (what Windows OpenSSH reads for one) and to `.ssh\authorized_keys` otherwise. herdr's socket there is a named pipe; the `herdr.sock` file is a `pid:start` marker. herdr has no `terminal attach` on Windows yet ([herdrdev/herdr#4821](https://github.com/herdrdev/herdr/issues/4821)), so a Windows PC reports `terminal_attach: false` and `terminal_mirror: true`. The chat lens works as on any PC (transcripts, status, prompts, files and input). The terminal lens is a stopgap: the bridge reads the pane's visible screen a few times a second (`pane.read`, ansi) and repaints it in the browser, so output and colours show and typing works, sent through `pane.send_text`. What a repaint cannot give: the cursor is not shown, output that comes and goes between two reads is not seen, and the grid is the pane's own size on that PC (`pane-geometry` with `fixed: true`), never the browser's. A phone narrower than that grid draws it folded to its width; the pane menu's Original width draws it as it is, panned sideways, and Wrap to screen folds it on a desktop too. The real terminal does not turn on by itself: it also needs the PTY sidecar (Node and `@lydell/node-pty`), which the `win32-x64` bundle does not ship, so a bridge without it keeps mirroring whatever herdr reports. It takes over once herdr attaches on Windows and a bundle ships the sidecar. WSL gives a Linux herdr, not the native Windows session. +### Windows sign-in and headless startup + +After a reboot, an approved Windows PC keeps reconnecting until the SSH account signs in to Windows. +A locked desktop still counts as signed in. Ovis starts herdr only for a pending connection; it adds +no permanent logon task, stores no Windows password and does not request administrator privileges. +Signing out of Windows ends a desktop-started herdr, its panes and the agents running in them. +Lock the PC instead to keep that work running. SSH disconnects and bridge updates do not end it. + +If the PC must run without a desktop login, choose **Review startup options** and then +**Start without Windows sign-in**. The warning applies to that one start: Codex commands that need +its private-desktop sandbox can fail in this headless Windows session, and desktop-control tools may +be unavailable. Ovis does not disable an agent's sandbox. A later cold start waits for sign-in again. +If Windows Task Scheduler cannot start herdr, **Windows desktop startup failed** shows its error +and offers the same one-time headless choice through **Review startup options**. Background retries +stop for that failure; signing in alone does not fix it. + +An existing herdr server is always reused. Signing in later does not move an already-running +headless server into the desktop session, and Ovis never restarts it or its panes automatically. +An initial connection can be cancelled while waiting; already-started work stays running. ## Runtime and endpoints ``` @@ -105,7 +124,7 @@ socket and refuses such a host instead ([above](#a-windows-connection-server)). **Disconnect** closes that PC’s observer, forwards and terminal attachments, preserving remote processes. **Remove PC** additionally forgets its local registration/key; the public key line on the remote account remains visible for manual removal (`ovis:`). No unrelated authorized keys are removed. -**Updating a bridge.** A bridge from another bundle version is refused, and the PC waits instead of retrying. With **Settings → Remote PCs → Update PC bridges automatically** on (the default), the connection server then updates it in the background: installing the app update was the approval, and SSH uses the PC's saved key only (`BatchMode`). A PC that needs a password or passphrase fails with the reason and waits; its **Sign in and update…** button opens the dialog. An update that cannot reach the PC (switched off, asleep, off the network, or gone in the middle) says nothing about its bridge: the PC shows **Reconnecting…** and keeps retrying, and the version check, with its update, runs again once the PC answers. A cancelled update (its **Cancel update**, a closed dialog, the setup's time limit) also sends the PC back to reconnecting; if the bridge is still out of date when the PC answers, it shows the **Update bridge** button and does not start the cancelled update again by itself until the PC has connected. With the setting off, the PC's **Update bridge** button starts the same background update, and the tap is the approval. A first install still asks for approval in the dialog. +**Updating a bridge.** A bridge from another bundle version is refused, and the PC waits instead of retrying. With **Settings → Remote PCs → Update PC bridges automatically** on (the default), the connection server then updates it in the background: installing the app update was the approval, and SSH uses the PC's saved key only (`BatchMode`). A PC that needs a password or passphrase fails with the reason and waits; its **Sign in and update…** button opens the dialog. An update that cannot reach the PC (switched off, asleep, off the network, or gone in the middle) says nothing about its bridge: the PC shows **Reconnecting…** and keeps retrying, and the version check, with its update, runs again once the PC answers. An explicitly cancelled update (**Cancel update**, or closing a dialog that still needs an answer) also sends the PC back to reconnecting; if the bridge is still out of date when the PC answers, it shows the **Update bridge** button and does not start the cancelled update again by itself until the PC has connected. Reaching the setup's time limit instead reports a timeout and resumes reconnection without suppressing automatic bridge updates. With the setting off, the PC's **Update bridge** button starts the same background update, and the tap is the approval. A first install still asks for approval in the dialog. The dialog offers the update itself: a connect or reconnect that is refused on the version check turns its **Retry connection** into **Update bridge and connect**, which re-runs the setup with the bridge update included — through the same approval list a first install shows, so a PC that was never registered (and has no sidebar row) can be updated and connected in one go. diff --git a/scripts/machine-dialog-regression.ts b/scripts/machine-dialog-regression.ts index 83caab8d6..343197844 100644 --- a/scripts/machine-dialog-regression.ts +++ b/scripts/machine-dialog-regression.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; @@ -97,6 +97,46 @@ try { assert.equal(approvals, 1); assert.deepEqual(errors, []); console.log(`PASS ${width}px: version failure → validated update by Enter → separate approval → Open PC`); + + await page.unroute("**/api/machines/setup**"); + for (const decision of ["early_close", "cancel", "close", "start_headless"] as const) { + const actions: unknown[] = []; + await page.route("**/api/machines/setup**", async (route) => { + const request = route.request(); + if (request.method() === "POST" && new URL(request.url()).pathname === "/api/machines/setup") { + assert.deepEqual(request.postDataJSON(), { destination: "fixture-only" }); + job = { id: "login", machine_id: "fixture", target: { destination: "fixture-only" }, windows_startup: true, phase: decision === "early_close" ? "installing" : "starting", step: decision === "early_close" ? "Installing verified Windows bundle…" : "Waiting for Windows sign-in on this PC.", action_required: decision === "early_close" ? null : "windows_login", installations: [], challenge: null, error: null, ssh_output: null }; + } else if (request.method() === "POST") { + actions.push(request.postDataJSON()); + job = { ...job!, action_required: null, phase: decision === "start_headless" ? "connected" : "cancelled", step: decision === "start_headless" ? "Connected" : "Cancelled" }; + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(job!) }); + }); + await page.reload(); + await destination.fill("fixture-only"); + await destination.press("Enter"); + if (decision === "early_close") await dialog.getByText("Keep this dialog open until the PC connects.", { exact: true }).waitFor(); + else { + await dialog.getByText("Waiting for Windows sign-in", { exact: true }).waitFor(); + await dialog.getByText(/Starting without Windows sign-in can break Codex sandbox commands/).waitFor(); + } + assert.deepEqual(actions, [], "waiting and polling never authorize a headless start"); + assert.equal(await dialog.getByRole("button", { name: "Continue in background", exact: true }).count(), 0, "an unsaved waiting PC must not become unreachable in the background"); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + if (decision === "start_headless") { + const shots = join(repo, "evidence/windows-startup"); mkdirSync(shots, { recursive: true }); + await page.screenshot({ path: join(shots, `waiting-${width}.png`) }); + } + await Promise.all([ + page.waitForResponse((response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/machines/setup/login"), + dialog.getByRole("button", { name: decision === "cancel" ? "Cancel connection" : decision === "close" || decision === "early_close" ? "Close PC setup" : "Start without Windows sign-in", exact: true }).click(), + ]); + assert.deepEqual(actions, [{ action: decision === "close" || decision === "early_close" ? "cancel" : decision }]); + if (decision === "start_headless") await dialog.getByRole("button", { name: "Open PC", exact: true }).waitFor(); + await page.unroute("**/api/machines/setup**"); + } + assert.deepEqual(errors, []); + console.log(`PASS ${width}px: Windows login waits without consent → cancel or explicit headless start`); } finally { await context.close(); } } } finally { await browser.close(); } diff --git a/server/machines.test.ts b/server/machines.test.ts index 30f1a1bb9..02c9f243f 100644 --- a/server/machines.test.ts +++ b/server/machines.test.ts @@ -11,7 +11,7 @@ import { canSendSecret, sameOrigin, shellQuote, validateTarget } from "./machine import { handleMachineRequest, MACHINE_PROXY_PATH } from "./machine-api.ts"; import type { BridgeDescriptor } from "./bridge.ts"; import { createServer } from "./index.ts"; -import { MachineManager } from "./machines.ts"; +import { MachineActionRequired, MachineManager } from "./machines.ts"; import { detectHost, psQuote, UNSUPPORTED_HOST, windowsBridgeFiles, windowsHost } from "./remote-host.ts"; import { decodeClixml, SshConnection } from "./ssh.ts"; import { terminalAttachSupported } from "./herdr/client.ts"; @@ -362,7 +362,7 @@ describe("machine boundaries", () => { // A setup job placed directly in the manager: driving a whole setup needs a real SSH host, but // action() and stage() are the code that decides what the dialog shows. -type TestJob = { public: SetupJob; abort: AbortController; timer: ReturnType; stageStartedAt: number; pending?: { resolve(value: string): void; reject(error: Error): void }; update: boolean; auto: boolean; finished: Promise }; +type TestJob = { public: SetupJob; abort: AbortController; timer: ReturnType; stageStartedAt: number; pending?: { resolve(value: string): void; reject(error: Error): void }; update: boolean; auto: boolean; finished: Promise; headlessStart?: boolean }; describe("setup job labels", () => { const dirs: string[] = []; const managers: MachineManager[] = []; @@ -394,6 +394,79 @@ describe("setup job labels", () => { expect(job.public.step).toBe("Installing on this PC…"); }); + it("requires current Windows-login consent for each headless start and rejects duplicate or cancelled requests", () => { + const { manager, job } = fixture("starting", "Waiting for Windows sign-in on this PC."); + job.pending = undefined; + expect(() => manager.action("job", { action: "start_headless" })).toThrow("not waiting"); + job.public.action_required = "windows_login"; + manager.action("job", { action: "start_headless" }); + expect(job.headlessStart).toBe(true); + expect(manager.job("job")?.action_required).toBeNull(); + expect(() => manager.action("job", { action: "start_headless" })).toThrow("not waiting"); + job.headlessStart = false; + job.public.action_required = "windows_login"; + manager.action("job", { action: "cancel" }); + expect(() => manager.action("job", { action: "start_headless" })).toThrow("not waiting"); + expect(job.headlessStart).toBe(false); + }); + + it("keeps retrying a login-waiting PC and reattaches its existing setup without changing the SSH target", () => { + const { manager, job } = fixture("starting", "Waiting for Windows sign-in on this PC."); + job.public.action_required = "windows_login"; + type Waiting = { machine: { enabled: boolean; action_required?: string | null; state: string }; generation: number; retry?: unknown }; + const internals = manager as unknown as { machines: Map; runtime(machine: unknown): Waiting; lost(runtime: Waiting, generation: number, error: Error): void }; + const runtime = internals.runtime({ id: "machine", name: "pc", kind: "ssh", enabled: true, state: "connected", target: { destination: "pc" }, snapshot: null, error: null }); + internals.machines.set("machine", runtime); + internals.lost(runtime, runtime.generation, new MachineActionRequired("Waiting for Windows sign-in on this PC.", "windows_login")); + expect(runtime.machine.state).toBe("reconnecting"); + expect(runtime.machine.action_required).toBe("windows_login"); + expect(runtime.retry).toBeDefined(); + expect(manager.setup({ destination: "pc", machine_id: "machine" }).id).toBe("job"); + expect(() => manager.setup({ destination: "another-pc", machine_id: "machine" })).toThrow("already running"); + manager.patch("machine", { enabled: false }); + expect(runtime.machine.action_required).toBeNull(); + expect(runtime.machine.state).toBe("disconnected"); + expect(runtime.retry).toBeUndefined(); + }); + + it("consumes headless consent once even on failure, and cancels before another waiting-loop launch", async () => { + const inspection = { platform: "win32-x64" as const, home: "C:\\Users\\u", herdrPath: "C:\\herdr.exe", expectedSocket: "C:\\socket", bundleReady: true, bundleOlder: false, descriptors: [], runtimeDir: "C:\\runtime", registryDir: "C:\\registry" }; + const mocks = [ + spyOn(windowsHost, "inspect").mockResolvedValue(inspection), + spyOn(windowsHost, "herdrVersion").mockResolvedValue("herdr 0.9.5"), + spyOn(windowsHost, "descriptors").mockResolvedValue([]), + spyOn(windowsHost, "startupNotice").mockResolvedValue("Waiting for Windows sign-in on this PC."), + spyOn(Bun, "sleep").mockResolvedValue(undefined), + ]; + try { + for (const decision of ["force", "cancel", "move"] as const) { + const { manager, job } = fixture("checking", "Checking the remote environment…"); + job.auto = true; + if (decision === "move") job.public.target = { destination: "another-pc" }; + const waiting = Promise.withResolvers(), resume = Promise.withResolvers(); + const drive = manager as unknown as { runtime(machine: unknown): { ssh: unknown; machine: { action_required?: string } }; prepare(runtime: unknown, job: TestJob): Promise; pause(): Promise }; + drive.pause = () => { waiting.resolve(); return resume.promise; }; + const runtime = drive.runtime({ id: "machine", name: "pc", kind: "ssh", enabled: true, target: { destination: "pc" }, snapshot: null, error: null }); + runtime.ssh = { run: async () => { throw new Error("no sh on Windows"); }, close() {} }; + const starts: boolean[] = []; + const start = spyOn(windowsHost, "start").mockImplementation(async (_ssh, options) => { + starts.push(options.headless === true); + if (options.headless) throw new Error("forced launch failed"); + }); + try { + const outcome = drive.prepare(runtime, job).then(() => null, (error: Error) => error); + await within(waiting.promise, "the setup did not wait for Windows sign-in"); + if (decision === "move") expect(runtime.machine.action_required).not.toBe("windows_login"); + manager.action("job", { action: decision === "cancel" ? "cancel" : "start_headless" }); + resume.resolve(); + expect((await outcome)?.message).toBe(decision === "cancel" ? "Setup cancelled" : "forced launch failed"); + expect(starts).toEqual(decision === "cancel" ? [false] : [false, true]); + expect(job.headlessStart).toBe(false); + } finally { start.mockRestore(); } + } + } finally { for (const mock of mocks) mock.mockRestore(); } + }); + it("drops the SSH question's heading as soon as it is answered", () => { for (const [step, kind] of [["Verify the server fingerprint", "host_key"], ["SSH authentication required", "secret"]] as const) { const { manager } = fixture("authentication", step, { id: "c1", kind, prompt: "?" }); @@ -403,6 +476,230 @@ describe("setup job labels", () => { } }); + function windowsSetup(bundleReady = true) { + const { manager } = fixture("cancelled", "Cancelled"); + manager["jobs"].delete("job"); + const runtime = manager["runtime"]({ id: "windows", name: "pc", kind: "ssh", enabled: true, state: "reconnecting", action_required: "windows_login", target: { destination: "pc" }, snapshot: null, error: null }); + manager["machines"].set("windows", runtime); + manager["retryLater"](runtime); + const approval = Promise.withResolvers(), waiting = Promise.withResolvers(), resume = Promise.withResolvers(); + const wait = manager["wait"].bind(manager); + manager["wait"] = (job) => { const answer = wait(job); approval.resolve(); return answer; }; + manager["pause"] = async (signal) => { + waiting.resolve(); + if (signal?.aborted) return; + await new Promise((resolve) => { + const done = () => resolve(); + signal?.addEventListener("abort", done, { once: true }); + void resume.promise.then(() => { signal?.removeEventListener("abort", done); resolve(); }); + }); + }; + const mocks = [ + spyOn(SshConnection.prototype, "start").mockResolvedValue(undefined), + spyOn(SshConnection.prototype, "run").mockRejectedValue(new Error("no sh on Windows")), + spyOn(SshConnection.prototype, "close").mockImplementation(() => {}), + spyOn(windowsHost, "inspect").mockResolvedValue({ platform: "win32-x64", home: "C:\\Users\\u", herdrPath: "C:\\herdr.exe", expectedSocket: "C:\\socket", bundleReady, bundleOlder: !bundleReady, descriptors: [], runtimeDir: "C:\\runtime", registryDir: "C:\\registry" }), + spyOn(windowsHost, "herdrVersion").mockResolvedValue("herdr 0.9.5"), + spyOn(windowsHost, "installBundle").mockResolvedValue(undefined), + spyOn(windowsHost, "start").mockResolvedValue(undefined), + spyOn(windowsHost, "descriptors").mockResolvedValue([]), + spyOn(windowsHost, "startupNotice").mockResolvedValue("Waiting for Windows sign-in on this PC."), + spyOn(Bun, "sleep").mockResolvedValue(undefined), + ]; + return { manager, runtime, approval, waiting, resume, close: () => { manager.stop(); resume.resolve(); for (const mock of mocks) mock.mockRestore(); } }; + } + + for (const phase of ["approval", "starting"] as const) it(`resumes a saved Windows retry when startup review is cancelled during ${phase}`, async () => { + // Given a dialog which has pre-empted a saved PC's background reconnection. + const f = windowsSetup(); + try { + const job = f.manager.setup({ destination: "pc", machine_id: "windows" }); + await within(f.approval.promise); + if (phase === "starting") { + f.manager.action(job.id, { action: "approve" }); + await within(f.waiting.promise); + } + // When Cancel connection or dialog unmount cancels that job. + f.manager.action(job.id, { action: "cancel" }); + await f.manager["jobs"].get(job.id)?.finished; + // Then background reconnection is restored, without headless consent. + expect(job.phase).toBe("cancelled"); + expect(f.runtime.machine.state).toBe("reconnecting"); + expect(f.runtime.retry).toBeDefined(); + expect(f.manager["jobs"].get(job.id)?.headlessStart).toBe(false); + } finally { f.close(); } + }); + + for (const bundleReady of [true, false]) it(`resumes a saved Windows retry when sign-in expires ${bundleReady ? "without installation" : "after installation clears the setup deadline"}`, async () => { + // Given a setup waiting for sign-in, with or without an installation stage. + const f = windowsSetup(bundleReady); + const deadlines = new Map void>(); + const set = globalThis.setTimeout, clear = globalThis.clearTimeout; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(new Proxy(set, { + apply(target, _receiver, [fn, delay, ...args]: unknown[]) { + if (typeof fn !== "function" || delay !== undefined && typeof delay !== "number") throw new Error("Expected a timer callback and delay"); + const callback = () => fn(...args); + const handle = target(callback, delay); + if (delay === 600_000) deadlines.set(Number(handle), callback); + return handle; + }, + })); + const cleared = spyOn(globalThis, "clearTimeout").mockImplementation((handle) => { deadlines.delete(Number(handle)); clear(Number(handle)); }); + try { + const job = f.manager.setup({ destination: "pc", machine_id: "windows" }); + await within(f.approval.promise); + f.manager.action(job.id, { action: "approve" }); + await within(f.waiting.promise); + const state = f.manager["jobs"].get(job.id); + if (!state) throw new Error("Setup job missing"); + const expire = deadlines.get(Number(state.timer)); + expect(expire).toBeDefined(); + if (!expire) return; + // When the active deadline expires, without waiting on wall-clock time. + expire(); + await state.finished; + // Then this is a timeout, not explicit cancellation, and the PC keeps retrying. + expect(job.phase).toBe("failed"); + expect(f.runtime.machine.state).toBe("reconnecting"); + expect(f.runtime.retry).toBeDefined(); + expect(f.runtime.updateCancelled).not.toBe(true); + } finally { timer.mockRestore(); cleared.mockRestore(); f.close(); } + }); + + it("releases the automatic update queue when Windows needs sign-in", async () => { + // Given an old Windows bundle followed by another PC in the global update queue. + const f = windowsSetup(false); + f.runtime.machine.action_required = "update_bridge"; + const linux = f.manager["runtime"]({ id: "linux", name: "linux", kind: "ssh", enabled: true, state: "error", action_required: "update_bridge", target: { destination: "linux" }, snapshot: null, error: null }); + f.manager["machines"].set("linux", linux); + const second = Promise.withResolvers(); + const update = f.manager.updateBridge.bind(f.manager); + const started: string[] = []; + const updates = spyOn(f.manager, "updateBridge").mockImplementation((id, scheduled) => { + started.push(id); + const job = update(id, scheduled); + if (id === "linux") { f.manager.action(job.id, { action: "cancel" }); second.resolve(); } + return job; + }); + try { + // When both updates are queued, nobody signs in or cancels Windows. + f.manager["queueAutoUpdate"]("windows"); + f.manager["queueAutoUpdate"]("linux"); + await within(second.promise, "Windows sign-in held the automatic update queue"); + await f.manager["autoChain"]; + // Then the next update starts while Windows reconnects with startup choices available. + expect(started).toEqual(["windows", "linux"]); + expect(f.runtime.machine).toMatchObject({ action_required: "windows_login", state: "reconnecting" }); + expect(f.runtime.retry).toBeDefined(); + } finally { updates.mockRestore(); f.close(); } + }, 10_000); + + for (const automatic of [false, true]) for (const refused of [false, true]) it(`waits for delayed Windows startup status during ${automatic ? "automatic update" : "initial setup"} (${refused ? "scheduler failure" : "sign-in"})`, async () => { + // Given a launcher which publishes its result only at its 30-second deadline. + const f = windowsSetup(!automatic); + if (!automatic) f.manager["machines"].delete("windows"); + else f.runtime.machine.state = "error"; + let elapsed = 0; + const result = refused ? "Task registration denied" : "Waiting for Windows sign-in on this PC."; + const action = refused ? "windows_start_failed" : "windows_login"; + const sleep = spyOn(Bun, "sleep").mockImplementation(async (ms) => { elapsed += Number(ms); }); + const notice = spyOn(windowsHost, "startupNotice").mockImplementation(async () => elapsed >= 30_000 ? result : null); + try { + // When setup or an unattended update starts that launcher. + const job = automatic ? f.manager.updateBridge("windows", true) : f.manager.setup({ destination: "pc" }); + if (!automatic) { await within(f.approval.promise); f.manager.action(job.id, { action: "approve" }); } + const state = f.manager["jobs"].get(job.id); + if (!state) throw new Error("Setup job missing"); + await within(Promise.race([f.waiting.promise, state.finished])); + // Then startup options survive the delay; only a sign-in wait retries in the background. + expect(job.action_required).toBe(action); + expect(elapsed).toBeGreaterThanOrEqual(30_000); + if (automatic) { + expect(f.runtime.machine).toMatchObject({ state: refused ? "error" : "reconnecting", action_required: action, error: result }); + if (refused) expect(f.runtime.retry).toBeUndefined(); + else expect(f.runtime.retry).toBeDefined(); + } else expect(job.phase).toBe("starting"); + } finally { sleep.mockRestore(); notice.mockRestore(); f.close(); } + }); + + for (const headless of [false, true]) it(`restores saved Windows retries when ${headless ? "headless startup fails" : "SSH drops during sign-in waiting"}`, async () => { + // Given a saved PC whose startup review has replaced its background connection. + const f = windowsSetup(); + if (!headless) f.runtime.machine.state = "disconnected"; + const failure = headless ? "Headless launch failed" : "SSH connection ended"; + try { + const job = f.manager.setup({ destination: "pc", machine_id: "windows" }); + await within(f.approval.promise); + f.manager.action(job.id, { action: "approve" }); + await within(f.waiting.promise); + const failed = headless + ? spyOn(windowsHost, "start").mockRejectedValue(new Error(failure)) + : spyOn(windowsHost, "descriptors").mockRejectedValue(new Error(failure)); + try { + // When the waiting connection drops, or its explicit headless launch fails. + if (headless) f.manager.action(job.id, { action: "start_headless" }); + f.resume.resolve(); + await f.manager["jobs"].get(job.id)?.finished; + // Then the failure stays visible while the enabled saved PC retries. + expect(job.phase).toBe("failed"); + expect(f.runtime.machine).toMatchObject({ state: "reconnecting", error: failure, action_required: null }); + expect(f.runtime.retry).toBeDefined(); + } finally { failed.mockRestore(); } + } finally { f.close(); } + }); + + for (const automatic of [false, true]) it(`stops retrying scheduler failures during ${automatic ? "automatic update" : "background reconnection"}`, async () => { + // Given a real startup-status reader receiving a Task Scheduler failure. + const startupNotice = windowsHost.startupNotice; + const f = windowsSetup(!automatic); + const failure = "Task registration denied (0x80070005)"; + windowsHost.startupNotice = startupNotice; + const reader = spyOn(SshConnection.prototype, "runPowerShell").mockResolvedValue(`interactive_failed: ${failure}`); + try { + // When an unattended connection encounters the scheduler refusal. + if (automatic) { + const job = f.manager.updateBridge("windows", true); + await f.manager["jobs"].get(job.id)?.finished; + expect(job.action_required).toBe("windows_start_failed"); + } else await f.manager["reconnect"](f.runtime); + // Then it offers startup review with the actual failure, without endless retries. + expect(f.runtime.machine).toMatchObject({ state: "error", action_required: "windows_start_failed", error: failure }); + expect(f.runtime.retry).toBeUndefined(); + } finally { reader.mockRestore(); f.close(); } + }); + + it("offers one-time headless consent when the Windows scheduler refuses startup", async () => { + // Given a scheduler failure transported through the real startup-status reader. + const startupNotice = windowsHost.startupNotice; + const f = windowsSetup(); + const schedulerError = "Task registration denied (0x80070005)"; + // Restore the reader that this fixture normally replaces. + const statusReader = spyOn(SshConnection.prototype, "runPowerShell").mockResolvedValue(`interactive_failed: ${schedulerError}`); + windowsHost.startupNotice = startupNotice; + const forced = Promise.withResolvers(); + const starts: boolean[] = []; + const start = spyOn(windowsHost, "start").mockImplementation(async (_ssh, options) => { + starts.push(options.headless === true); + if (options.headless) { forced.resolve(); throw new Error("fixture stops after consent is consumed"); } + }); + try { + const job = f.manager.setup({ destination: "pc", machine_id: "windows" }); + await within(f.approval.promise); + f.manager.action(job.id, { action: "approve" }); + await within(f.waiting.promise, "scheduler failure did not offer startup choices"); + expect(job.step).toBe(schedulerError); + expect(job.action_required).toBe("windows_start_failed"); + // When the user explicitly chooses a headless start. + f.manager.action(job.id, { action: "start_headless" }); + f.resume.resolve(); + await within(forced.promise); + await f.manager["jobs"].get(job.id)?.finished; + // Then only that next launch carries headless consent. + expect(starts).toEqual([false, true]); + expect(f.manager["jobs"].get(job.id)?.headlessStart).toBe(false); + } finally { start.mockRestore(); statusReader.mockRestore(); f.close(); } + }, 10_000); + it("labels the first start of a bridge as its own step, not as a restart", () => { const { manager, drive, job } = fixture("installing", "Installing verified linux-x64 bundle…"); drive.progress(job, "install", 0, null); @@ -844,9 +1141,9 @@ describe("host detection", () => { expect(decodeClixml(stderr)).toBe("Another installation is in progress; retry shortly\nAt line:4 char:1 "); expect(decodeClixml("#< CLIXML\n")).toBe("PowerShell failed"); }); - it("gives each herdr session its own bridge log and launcher on Windows", () => { - expect(windowsBridgeFiles()).toEqual({ log: "bridge.log", launcher: "start-bridge.cmd" }); - expect(windowsBridgeFiles("web-qa")).toEqual({ log: "bridge-web-qa.log", launcher: "start-bridge-web-qa.cmd" }); + it("gives each herdr session its own bridge log, launcher and startup status on Windows", () => { + expect(windowsBridgeFiles()).toEqual({ log: "bridge.log", launcher: "start-bridge.cmd", status: "bridge.startup" }); + expect(windowsBridgeFiles("web-qa")).toEqual({ log: "bridge-web-qa.log", launcher: "start-bridge-web-qa.cmd", status: "bridge-web-qa.startup" }); }); it("quotes for PowerShell with doubled single quotes only", () => { expect(psQuote("C:\\Users\\o'brien\\x")).toBe("'C:\\Users\\o''brien\\x'"); @@ -1130,3 +1427,55 @@ describe("saved PCs that cannot be read", () => { expect(readdirSync(dir).filter((name) => name.includes("corrupt"))).toEqual([]); }); }); + +describe("a Windows startup retried after a scheduler failure", () => { + it("does not read the previous attempt's startup status as this one's", async () => { + // Given a PC whose last start left a scheduler failure in its startup status file. + const dir = mkdtempSync(join(tmpdir(), "herdr-windows-stale-status-")); + const manager = new MachineManager(dir, {} as never, {} as never, async () => { throw new Error("fixture local offline"); }); + const runtime = manager["runtime"]({ id: "windows", name: "pc", kind: "ssh", enabled: true, state: "error", action_required: "windows_start_failed", target: { destination: "pc" }, snapshot: null, error: "Task registration denied" }); + manager["machines"].set("windows", runtime); + const job = { public: { id: "review", machine_id: "windows", target: { destination: "pc" }, phase: "starting", step: "Starting", challenge: null, installations: [], error: null, ssh_output: null } as SetupJob, abort: new AbortController(), timer: setTimeout(() => {}, 600_000), update: false, auto: false, stageStartedAt: Date.now(), finished: Promise.resolve() }; + job.timer.unref(); + manager["jobs"].set(job.public.id, job); + manager["wait"] = async () => "approved"; + const socket = "C:\\socket"; + const descriptor = { pid: 4242, port: 7317, token: "a".repeat(64), socket_path: socket, bridge_protocol: BRIDGE_PROTOCOL, bundle_version: REMOTE_BUNDLE_VERSION, managed_remote: true }; + let status: string | null = "interactive_failed: Task registration denied"; + let launched = false, registered = false, pauses = 0; + runtime.ssh = { + run: async () => { throw new Error("Windows has no sh"); }, + runPowerShell: async (script: string) => { + // The remote registry: the launch script may clear the status before it starts the bridge. + if (script.includes("Win32_Process")) { + if (script.includes(`Remove-Item -LiteralPath "$dir\\${windowsBridgeFiles().status}"`)) status = null; + launched = true; + return ""; + } + if (script.includes("-Filter *.json")) return registered ? JSON.stringify(descriptor) : ""; + if (script.includes("ReadAllText($status)")) return status ?? ""; + throw new Error("Unexpected remote script"); + }, + close() {}, + } as never; + const mocks = [ + spyOn(windowsHost, "inspect").mockResolvedValue({ platform: "win32-x64", home: "C:\\Users\\u", herdrPath: "C:\\herdr.exe", expectedSocket: socket, bundleReady: true, bundleOlder: false, descriptors: [], runtimeDir: "C:\\runtime", registryDir: "C:\\registry" }), + spyOn(windowsHost, "herdrVersion").mockResolvedValue("herdr 0.9.5"), + spyOn(manager as never, "verify").mockResolvedValue({ endpoint: { url: "http://fixture", token: "fixture" }, identity: { herdr: { version: "0.9.5", protocol: 22 } } } as never), + // The new bridge loads, clears any status itself and registers after the first poll. + spyOn(Bun, "sleep").mockImplementation(async () => { if (launched) { status = null; registered = true; } }), + ]; + manager["pause"] = async () => { if (++pauses >= 3) job.abort.abort(); }; + try { + // When the user retries after fixing Task Scheduler. + const outcome = await manager["prepare"](runtime, job).then(() => "connected", (error: Error) => error.message); + // Then the new bridge connects instead of the old failure being shown again. + expect(outcome).toBe("connected"); + expect(job.public.action_required).not.toBe("windows_start_failed"); + } finally { + manager.stop(); clearTimeout(job.timer); + for (const mock of mocks) mock.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/server/machines.ts b/server/machines.ts index 3fc2fb45d..01e66614e 100644 --- a/server/machines.ts +++ b/server/machines.ts @@ -22,6 +22,7 @@ import { detectHost, HERDR_INSTALL_CMD, installLockOwner, type RemoteHost } from import { REVISION, type UpdateStatus } from "../shared/update.ts"; import { discard, readCappedJson, REMOTE_LIMITS } from "./remote-json.ts"; import { SshConnection, sshClient } from "./ssh.ts"; +import { WAITING_FOR_WINDOWS_LOGIN } from "./windows-herdr-start.ts"; interface StoredMachine { id: string; name: string; target: SshTarget; enabled: boolean; snapshot?: SessionSnapshot | null } interface Runtime { @@ -65,6 +66,9 @@ interface Runtime { } interface JobState { update: boolean; + /** Explicit consent for the next Windows cold-start attempt only. Never persisted. */ + headlessStart?: boolean; + timedOut?: boolean; installationApproved?: boolean; /** started by the server for a PC whose bridge is out of date: key-only SSH, approval already given */ auto: boolean; @@ -550,6 +554,8 @@ export class MachineManager { // a PC saved in herdr is named by its catalog (any length); a name sent with it is not read const name = existing?.machine.herdr_profile_id ? undefined : request.name; if (name !== undefined && (typeof name !== "string" || name.length > 100)) throw new Error("PC name must be at most 100 characters"); + const waiting = existing && [...this.jobs.values()].find((j) => j.public.machine_id === existing.machine.id && j.public.phase === "starting" && (j.public.action_required === "windows_login" || j.public.action_required === "windows_start_failed")); + if (waiting && !request.update_remote && sameSshSession(target, waiting.public.target) && target.identity_file === waiting.public.target.identity_file) return waiting.public; if ([...this.jobs.values()].filter((j) => !["connected", "failed", "cancelled"].includes(j.public.phase)).length >= 4) throw new Error("Finish or cancel the current connection jobs first"); if (request.machine_id && !existing) throw new Error("PC not found"); if (existing && [...this.jobs.values()].some((j) => j.public.machine_id === existing.machine.id && !["connected", "failed", "cancelled"].includes(j.public.phase))) throw new Error("A connection job for this PC is already running"); @@ -564,7 +570,8 @@ export class MachineManager { // A saved PC set up at another SSH address stays at its own until it connects there: that address is the user's to // review in this dialog, and a reconnect or an update that asks nobody goes only where the PC is (#2075) const moving = !!existing && !sameSshSession(target, existing.machine.target!); - const job: JobState = { update: request.update_remote === true, auto, public: { id, machine_id: machineId, target, phase: "connecting", step: "Connecting with SSH keys and ssh-agent…", challenge: null, installations: [], error: null, ssh_output: null, progress: null }, abort: new AbortController(), timer: setTimeout(() => this.cancelJob(id), 600_000), stageStartedAt: Date.now(), finished: Promise.resolve() }; + const resumeRetry = existing?.machine.state === "reconnecting"; + const job: JobState = { update: request.update_remote === true, auto, public: { id, machine_id: machineId, target, phase: "connecting", step: "Connecting with SSH keys and ssh-agent…", challenge: null, installations: [], error: null, ssh_output: null, progress: null }, abort: new AbortController(), timer: setTimeout(() => this.cancelJob(id, true), 600_000), stageStartedAt: Date.now(), finished: Promise.resolve() }; if (job.update && existing) job.runtime = existing; if (job.update && existing && !auto) existing.updateCancelled = false; job.timer.unref(); @@ -643,22 +650,24 @@ export class MachineManager { const unreachable = ownsRuntime && job.update && !!existing && !(e instanceof MachineActionRequired) && (error === OWN_OVIS_AWAY || error === OWN_CHECK_RUNNING || (reached ? !ssh.connected() : !SSH_REFUSED.test(error))); if (ownsRuntime) this.disconnect(runtime); - // a cancelled update (its Cancel, a closed dialog, the setup's time limit) says nothing - // about the bridge either: the PC tries again on its own, and only a version check on a PC - // that answers asks for the update again, without starting the cancelled one by itself + // An interrupted setup says nothing about the bridge: restore a saved PC's retries. + // Only explicit cancellation suppresses automatic updates; a deadline is not a refusal. // A setup at another SSH address that did not connect there leaves the PC as it was, trying its own address // again when it is enabled: what that setup met there is not this PC's, and its dialog says how it ended (#2075). - if (job.public.phase === "cancelled") { - if (ownsRuntime && (job.update || moving) && existing && runtime.machine.enabled && !this.stopped) { + if (job.public.phase === "cancelled" || job.timedOut) { + if (ownsRuntime && !moving && runtime.machine.action_required === "windows_start_failed") { runtime.machine.state = "error"; this.emit(); } + else if (ownsRuntime && (job.update || moving || resumeRetry || runtime.machine.action_required === "windows_login") && existing && runtime.machine.enabled && !this.stopped) { runtime.machine.error = null; runtime.machine.action_required = null; - if (job.update && !moving) runtime.updateCancelled = true; + if (job.update && !moving && !job.timedOut) runtime.updateCancelled = true; this.retryLater(runtime); } + else if (ownsRuntime && runtime.machine.action_required === "windows_login") { runtime.machine.action_required = null; runtime.machine.error = null; this.emit(); } } else { job.public.phase = "failed"; job.public.step = "Connection failed"; job.public.error = error; if (moving) { if (ownsRuntime && runtime.machine.enabled && !this.stopped) { runtime.machine.error = null; runtime.machine.action_required = null; this.retryLater(runtime); } } - else if (ownsRuntime && unreachable && runtime.machine.enabled) { runtime.machine.error = error; runtime.machine.action_required = null; this.retryLater(runtime); } + else if (ownsRuntime && existing && runtime.machine.enabled && !this.stopped && e instanceof MachineActionRequired && e.action === "windows_login") { runtime.machine.error = error; runtime.machine.action_required = e.action; this.retryLater(runtime); } + else if (ownsRuntime && existing && runtime.machine.enabled && !this.stopped && !(e instanceof MachineActionRequired) && (unreachable || resumeRetry || runtime.machine.action_required === "windows_login")) { runtime.machine.error = error; runtime.machine.action_required = null; this.retryLater(runtime); } // a failed bridge update on a PC that answered keeps its button: the bridge is still // out of date, and the error says why this attempt failed (a password needed, …) else if (ownsRuntime) { runtime.machine.state = "error"; runtime.machine.error = error; runtime.machine.action_required = e instanceof MachineActionRequired ? e.action : job.update && existing ? "update_bridge" : null; } @@ -708,6 +717,13 @@ export class MachineManager { const job = this.jobs.get(id); if (!job) throw new Error("Setup job not found"); if (action.action === "cancel") { this.cancelJob(id); return; } + if (action.action === "start_headless") { + if (job.abort.signal.aborted || job.public.phase !== "starting" || (job.public.action_required !== "windows_login" && job.public.action_required !== "windows_start_failed")) throw new Error("This PC is not waiting for Windows sign-in"); + job.public.action_required = null; + job.public.step = "Starting without Windows sign-in…"; + job.headlessStart = true; + return; + } if (!job.pending) throw new Error("This setup step has already changed"); if (action.action === "approve") { if (job.public.phase !== "approval") throw new Error("No installation is awaiting approval"); @@ -724,10 +740,13 @@ export class MachineManager { job.public.step = actionStep(action.action); pending.resolve(action.action === "answer" ? action.answer : "approved"); } - private cancelJob(id: string): void { + private cancelJob(id: string, timedOut = false): void { const job = this.jobs.get(id); if (!job || ["connected", "failed", "cancelled"].includes(job.public.phase)) return; - job.public.phase = "cancelled"; job.public.step = "Cancelled"; job.public.challenge = null; job.public.ssh_output = null; + job.timedOut = timedOut; + job.public.phase = timedOut ? "failed" : "cancelled"; job.public.step = timedOut ? "Connection timed out" : "Cancelled"; job.public.challenge = null; job.public.ssh_output = null; + if (timedOut) job.public.error = "Connection timed out"; + job.public.action_required = null; job.headlessStart = false; job.abort.abort(); job.pending?.reject(new Error("Setup cancelled")); job.pending = undefined; job.ssh?.close(); clearTimeout(job.timer); } @@ -758,6 +777,7 @@ export class MachineManager { if (runtime.generation === generation && !moving) runtime.machine.remote_ovis = left; }; const { host, inspection } = await detectHost(ssh, session); + if (job && host.kind === "windows") job.public.windows_startup = moving || !this.machines.has(runtime.machine.id); const { platform, expectedSocket } = inspection; // what the PC's own Ovis said is read anew on every try: it may have been updated or replaced since if (!moving) runtime.machine.remote_ovis = null; @@ -894,14 +914,55 @@ export class MachineManager { if (!descriptor) { if (job?.abort.signal.aborted || runtime.generation !== generation || this.stopped) throw new Error("Setup cancelled"); if (job) this.stage(job, "starting", "Starting the remote bridge…"); - await host.start(ssh, { session, herdrPath, inspection }); - for (let i = 0; i < 40; i++) { - if (job?.abort.signal.aborted || this.stopped) throw new Error("Setup cancelled"); - descriptor = (await host.descriptors(ssh)).find((d) => d.socket_path === expectedSocket && d.pid !== stalePid); - if (descriptor) break; - await Bun.sleep(250); + let notice: string | null = null; + let waitingForLogin = false; + do { + if (job?.abort.signal.aborted || runtime.generation !== generation || this.stopped) throw new Error("Setup cancelled"); + const headless = job?.headlessStart === true; + if (job) job.headlessStart = false; + await host.start(ssh, { session, herdrPath, inspection, ...(headless ? { headless: true } : {}) }); + notice = null; + // Windows allows 30 seconds for the desktop launcher, then 30 for herdr + // readiness. Leave a further ten seconds for bridge registration. + const pollLimit = host.kind === "windows" ? 280 : 40; + for (let i = 0; i < pollLimit; i++) { + if (job?.abort.signal.aborted || runtime.generation !== generation || this.stopped) throw new Error("Setup cancelled"); + descriptor = (await host.descriptors(ssh)).find((d) => d.socket_path === expectedSocket && d.pid !== stalePid); + if (descriptor) break; + if (!headless) notice = await host.startupNotice?.(ssh, session) ?? null; + if (notice) break; + await Bun.sleep(250); + } + if (!descriptor && !headless && !notice) notice = await host.startupNotice?.(ssh, session) ?? null; + const startupAction = notice === WAITING_FOR_WINDOWS_LOGIN ? "windows_login" : "windows_start_failed"; + // An unattended update has installed its bundle. Waiting for a person belongs to + // reconnection, not the global update queue or one of the four setup slots. + if (notice && job?.auto && job.update) throw new MachineActionRequired(notice, startupAction); + if (notice && job) { + if (!waitingForLogin) { + waitingForLogin = true; + clearTimeout(job.timer); + job.timer = setTimeout(() => this.cancelJob(job.public.id, true), 600_000); + job.timer.unref(); + } + if (!job.headlessStart) { + job.public.action_required = startupAction; + if (!moving) { runtime.machine.action_required = startupAction; runtime.machine.error = notice; } + this.stage(job, "starting", notice); + } + do { + await this.pause(job.abort.signal); + if (job.abort.signal.aborted || runtime.generation !== generation || this.stopped) throw new Error("Setup cancelled"); + // A scheduler refusal needs a decision, not another automatic launch. + } while (job.public.action_required === "windows_start_failed"); + } + } while (!descriptor && notice && job); + if (!descriptor) { + if (notice) throw new MachineActionRequired(notice, notice === WAITING_FOR_WINDOWS_LOGIN ? "windows_login" : "windows_start_failed"); + throw new Error(`Bridge did not start. Check ${host.logHint} on the PC.`); } - if (!descriptor) throw new Error(`Bridge did not start. Check ${host.logHint} on the PC.`); + if (job?.public.action_required === "windows_login" || job?.public.action_required === "windows_start_failed") job.public.action_required = null; + if (runtime.machine.action_required === "windows_login" || runtime.machine.action_required === "windows_start_failed") runtime.machine.action_required = null; } learnServer(descriptor); let verified: Awaited>; @@ -1173,13 +1234,14 @@ export class MachineManager { this.remoteDiagnostics.failed(runtime.machine.id, error instanceof MachineActionRequired ? "action_required" : "connection_lost", { operation: "bridge", machine_id: runtime.machine.id }); this.disconnect(runtime); runtime.machine.error = error instanceof Error ? error.message : String(error); - if (error instanceof MachineActionRequired) { + if (error instanceof MachineActionRequired && error.action !== "windows_login") { runtime.machine.state = "error"; runtime.machine.action_required = error.action; this.emit(); // SSH itself just worked without a password, so the update can run unattended if (error.action === "update_bridge" && this.preferences.auto_update_bridges && this.mayManageBridge(runtime) && !runtime.updateCancelled) this.queueAutoUpdate(runtime.machine.id); return; } + runtime.machine.action_required = error instanceof MachineActionRequired ? error.action : null; this.retryLater(runtime); } /** a disconnected PC tries again on its own, waiting longer after each failed try */ @@ -1357,6 +1419,7 @@ export class MachineManager { if (patch.enabled !== undefined) { if (typeof patch.enabled !== "boolean") throw new Error("enabled must be boolean"); runtime.machine.enabled = patch.enabled; + if (!patch.enabled && runtime.machine.action_required === "windows_login") { runtime.machine.action_required = null; runtime.machine.error = null; } this.disconnect(runtime); for (const [id, job] of this.jobs) if (job.public.machine_id === runtime.machine.id) this.cancelJob(id); if (patch.enabled) void this.reconnect(runtime); diff --git a/server/remote-entry.ts b/server/remote-entry.ts index f4f066461..1e3da8d5b 100644 --- a/server/remote-entry.ts +++ b/server/remote-entry.ts @@ -5,9 +5,9 @@ import { createServer } from "./index.ts"; import { bridgeIdentity, descriptorLive, descriptorPath, registerBridge } from "./bridge.ts"; import { acquireOwnerDirectory } from "./owner-guard.ts"; import { parseProcessLine, refusedSocket, staleMarker } from "./herdr-marker.ts"; -import { psQuote } from "./powershell.ts"; +import { startHeadlessWindowsHerdr, startInteractiveWindowsHerdr, windowsStartupStatus } from "./windows-herdr-start.ts"; import { ShellAgentLocator } from "./shell-agent.ts"; -import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; import { homedir } from "node:os"; @@ -34,29 +34,10 @@ function staleWindowsMarker(path: string): boolean { }); } -/** - * Bun's children on Windows share its job object and die with it (live-verified: a bridge - * killed for an update took its herdr along). WMI creates the daemon from outside that job, - * so herdr's sessions outlive every bridge. WMI does not pass the caller's environment on, - * and herdr finds its socket directory through it (APPDATA), so it goes along explicitly. - */ -async function spawnDetachedWindows(exe: string, args: string[], log: string): Promise { - for (const value of [exe, ...args, log]) if (/["\r\n\0]/.test(value)) throw new Error(`Invalid characters in ${value}`); - const quote = psQuote; - const line = `cmd.exe /d /c ""${exe}" ${args.join(" ")} >> "${log}" 2>&1"`; - const environment = Object.entries(process.env).filter(([name, value]) => value !== undefined && !/[\r\n\0=]/.test(name) && !/[\r\n\0]/.test(value)).map(([name, value]) => quote(`${name}=${value}`)); - const script = [ - `$startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0; EnvironmentVariables = [string[]]@(${environment.join(", ")}) }`, - `$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${quote(line)}; CurrentDirectory = ${quote(homedir())}; ProcessStartupInformation = $startup }`, - "exit $r.ReturnValue", - ].join("\n"); - const ps = Bun.spawn(["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { stdin: "ignore", stdout: "ignore", stderr: "pipe" }); - const [code, stderr] = await Promise.all([ps.exited, new Response(ps.stderr).text()]); - if (code !== 0) throw new Error(`herdr could not be started through WMI (${code}): ${stderr.trim()}`); -} - const descriptor = descriptorPath(); mkdirSync(dirname(descriptor), { recursive: true, mode: 0o700 }); +async function startBridge(): Promise { +if (windows) rmSync(windowsStartupStatus(dirname(descriptor), session), { force: true }); // the lock records its owner's birth: a PID that now belongs to another process does not block this start const lock = acquireOwnerDirectory(descriptor + ".lock", { running: "Another bridge is starting; retry shortly", starting: "Another bridge is starting; retry shortly" }); try { @@ -77,7 +58,22 @@ try { const args = session ? ["--session", session, "server"] : ["server"]; // one log per session on Windows: cmd holds the file a running daemon's output goes to const log = join(dirname(descriptor), windows && session ? `herdr-${session}.log` : "herdr.log"); - if (windows) await spawnDetachedWindows(herdr, args, log); + if (windows) { + const registry = dirname(descriptor); + const status = windowsStartupStatus(registry, session); + if (process.env["OVIS_WINDOWS_HEADLESS_START"] !== "1") { + try { + if (await startInteractiveWindowsHerdr(herdr, args, log) === "waiting_login") { + writeFileSync(status, "waiting_login"); + return; + } + } catch (error) { + if (!(error instanceof Error)) throw error; + writeFileSync(status, `interactive_failed: ${error.message}`); + return; + } + } else await startHeadlessWindowsHerdr(herdr, args, log); + } else Bun.spawn([herdr, ...args], { stdin: "ignore", stdout: Bun.file(log), stderr: Bun.file(log) }).unref(); let ready = false; // a cold first start (fresh HOME, slow disk or CI runner) can take well over ten seconds @@ -101,3 +97,5 @@ try { process.on("SIGINT", close); } } finally { lock.release(); } +} +await startBridge(); diff --git a/server/remote-host.ts b/server/remote-host.ts index 01b9a928c..38ffdf7ea 100644 --- a/server/remote-host.ts +++ b/server/remote-host.ts @@ -7,8 +7,7 @@ * Windows differs in three places that are easy to get wrong: * - herdr's socket is a marker file plus a named pipe of the same path (herdr/client.ts). * - A process started inside the SSH session dies with it. The bridge is created through - * WMI (Win32_Process.Create), whose parent is the WMI host, so it survives logout like - * herdr's own server does. + * WMI (Win32_Process.Create), whose parent is the WMI host, so the bridge survives logout. * - An administrator's authorized keys live in %ProgramData%\ssh\administrators_authorized_keys * with a fixed ACL; everyone else's in ~\.ssh\authorized_keys. */ @@ -21,6 +20,7 @@ import { shellQuote } from "./machine-security.ts"; import { bundleFile, type InstallStage } from "./remote-bundle.ts"; import type { SshConnection } from "./ssh.ts"; import { psQuote } from "./powershell.ts"; +import { WAITING_FOR_WINDOWS_LOGIN } from "./windows-herdr-start.ts"; export const REMOTE_PATH = 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; '; export const BUNDLE_DIR = `.local/share/herdr-web-ui/remote-v${REMOTE_BUNDLE_VERSION}`; @@ -89,7 +89,7 @@ export interface HostInspection { runtimeDir: string; registryDir: string; } -export interface StartOptions { session?: string; herdrPath: string; inspection: HostInspection } +export interface StartOptions { session?: string; herdrPath: string; inspection: HostInspection; headless?: boolean } /** * The Windows lock's stable reclaim prefix. It is ASCII-safe even when the state directory, * destination or account is not. The state directory and target outlive a restart (a PID does @@ -131,6 +131,8 @@ export interface RemoteHost { installHerdr?(ssh: SshConnection): Promise; stop(ssh: SshConnection, pid: number): Promise; start(ssh: SshConnection, options: StartOptions): Promise; + /** A cold start needing desktop sign-in or consent after an interactive-launch failure. */ + startupNotice?(ssh: SshConnection, session?: string): Promise; descriptors(ssh: SshConnection): Promise; } @@ -201,9 +203,9 @@ function cmdValue(value: string): string { * redirects into, so a second bridge sent to the same log never starts: "The process cannot * access the file because it is being used by another process" (live-verified). */ -export function windowsBridgeFiles(session?: string): { log: string; launcher: string } { +export function windowsBridgeFiles(session?: string): { log: string; launcher: string; status: string } { const suffix = session ? `-${session}` : ""; - return { log: `bridge${suffix}.log`, launcher: `start-bridge${suffix}.cmd` }; + return { log: `bridge${suffix}.log`, launcher: `start-bridge${suffix}.cmd`, status: `bridge${suffix}.startup` }; } const WINDOWS_BUNDLE = `${WINDOWS_BUNDLE_BASE}\\remote-v${REMOTE_BUNDLE_VERSION}`; @@ -341,7 +343,7 @@ export const windowsHost: RemoteHost = { "Write-Output ''", ].join("\n")); }, - async start(ssh, { session, herdrPath, inspection }) { + async start(ssh, { session, herdrPath, inspection, headless }) { if (!herdrPath) throw new Error("herdr is not installed on this PC"); const files = windowsBridgeFiles(session); // a launcher script: cmd can set the environment and redirect the log without any @@ -351,6 +353,7 @@ export const windowsHost: RemoteHost = { // the paths below are UTF-8: a profile such as C:\Users\홍길동 is lost in an ANSI batch file "chcp 65001 >nul", `set "HERDR_REMOTE_SESSION=${cmdValue(session ?? "")}"`, + `set "OVIS_WINDOWS_HEADLESS_START=${headless ? "1" : ""}"`, `set "OVIS_HERDR_BIN=${cmdValue(herdrPath)}"`, `set "HERDR_WEB_HERDR_BIN=${cmdValue(herdrPath)}"`, `"${cmdValue(`${inspection.runtimeDir}\\bin\\bun.exe`)}" "${cmdValue(`${inspection.runtimeDir}\\server\\remote-entry.ts`)}" >> "${cmdValue(`${inspection.registryDir}\\${files.log}`)}" 2>&1`, @@ -360,11 +363,23 @@ export const windowsHost: RemoteHost = { `$dir = "${WINDOWS_REGISTRY}"; New-Item -ItemType Directory -Force $dir | Out-Null`, // one literal per line: WriteAllLines ends each with the CRLF cmd expects `[IO.File]::WriteAllLines("$dir\\${files.launcher}", [string[]]@(${launcher.map(psQuote).join(", ")}), (New-Object Text.UTF8Encoding $false))`, + // the previous attempt's startup status must not answer for this one: the new bridge + // clears it only once it has loaded, after the manager's first status read + `Remove-Item -LiteralPath "$dir\\${files.status}" -Force -ErrorAction SilentlyContinue`, // not Start-Process: that child belongs to the SSH session's job and dies with it `$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = "cmd.exe /d /c \`"$dir\\${files.launcher}\`""; CurrentDirectory = $env:USERPROFILE }`, "if ($r.ReturnValue -ne 0) { throw \"Could not start the bridge (Win32_Process.Create returned $($r.ReturnValue))\" }", ].join("\n")); }, + async startupNotice(ssh, session) { + const suffix = session ? `-${session}` : ""; + const name = psQuote(`bridge${suffix}.startup`); + const result = await ssh.runPowerShell(`$status = Join-Path "${WINDOWS_REGISTRY}" ${name}; if (Test-Path -LiteralPath $status) { [IO.File]::ReadAllText($status) }`); + const status = result.trim(); + if (status === "waiting_login") return WAITING_FOR_WINDOWS_LOGIN; + if (status.startsWith("interactive_failed: ")) return status.slice("interactive_failed: ".length); + return null; + }, async descriptors(ssh) { return parseDescriptors((await ssh.runPowerShell(WINDOWS_DESCRIPTORS)).split("\n")); }, diff --git a/server/windows-herdr-start.ts b/server/windows-herdr-start.ts new file mode 100644 index 000000000..32af82e0a --- /dev/null +++ b/server/windows-herdr-start.ts @@ -0,0 +1,120 @@ +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +export const WAITING_FOR_WINDOWS_LOGIN = "Waiting for Windows sign-in on this PC."; + +/** Session-specific, separate from the bridge descriptor (which always means a usable bridge). */ +export function windowsStartupStatus(registry: string, session?: string): string { + return join(registry, `bridge${session ? `-${session}` : ""}.startup`); +} + +const desktopLauncher = ` +import { readFileSync, rmSync, openSync, closeSync } from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +const path = fileURLToPath(new URL("./launch.json", import.meta.url)); +const launch = JSON.parse(readFileSync(path, "utf8")); +rmSync(dirname(path), { recursive: true, force: true }); +const log = openSync(launch.log, "a"); +const child = Bun.spawn([launch.exe, ...launch.args], { cwd: launch.cwd, env: launch.env, stdin: "ignore", stdout: log, stderr: log, windowsHide: true }); +closeSync(log); +process.exit(await child.exited); +`; + +const interactiveScript = String.raw` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::InputEncoding = [Text.Encoding]::UTF8 +$payloadText = [Console]::In.ReadToEnd() +$payload = $payloadText | ConvertFrom-Json +$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User +$directory = Join-Path $payload.directory ('herdr-start-' + [Guid]::NewGuid().ToString('N')) +$taskName = 'Ovis-' + [IO.Path]::GetFileName($directory) +$registered = $null +$running = $null +try { + # Windows file modes do not restrict readers: protect the directory before writing credentials. + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($sid) + foreach ($owner in @($sid, (New-Object Security.Principal.SecurityIdentifier 'S-1-5-18'))) { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($owner, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))) + } + [IO.Directory]::CreateDirectory($directory, $acl) | Out-Null + $data = Join-Path $directory 'launch.json' + [IO.File]::WriteAllText($data, $payloadText, (New-Object Text.UTF8Encoding $false)) + $launcher = Join-Path $directory 'start.ts' + [IO.File]::WriteAllText($launcher, [string]$payload.launcher, (New-Object Text.UTF8Encoding $false)) + $scheduler = New-Object -ComObject Schedule.Service + $scheduler.Connect() + $folder = $scheduler.GetFolder('\') + $task = $scheduler.NewTask(0) + $task.Principal.UserId = $sid.Value + $task.Principal.LogonType = 3 # TASK_LOGON_INTERACTIVE_TOKEN: no password or noninteractive fallback + $task.Principal.RunLevel = 0 # ordinary user privileges + $task.Settings.ExecutionTimeLimit = 'PT0S' + $task.Settings.DisallowStartIfOnBatteries = $false + $task.Settings.StopIfGoingOnBatteries = $false + $action = $task.Actions.Create(0) + # Only the private launcher's path enters the task definition, never its environment. + $action.Path = $payload.bun + $action.Arguments = '"' + $launcher + '"' + $registered = $folder.RegisterTaskDefinition($taskName, $task, 2, $sid.Value, $null, 3) + try { $running = $registered.Run($null) } + catch { + $exception = $_.Exception + while ($null -ne $exception.InnerException) { $exception = $exception.InnerException } + if ($exception.HResult -eq -2147216608) { Write-Output 'waiting_login'; exit 0 } # SCHED_E_USER_NOT_LOGGED_ON + throw + } + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([IO.Directory]::Exists($directory)) { + if ($running.State -ne 2 -and $running.State -ne 4) { + # A signed-out user's fresh task can stay Ready/never-run without Run throwing an error. + if ($registered.State -eq 3 -and $registered.LastTaskResult -eq 267011) { Write-Output 'waiting_login'; exit 0 } # SCHED_S_TASK_HAS_NOT_RUN + throw 'The interactive herdr launcher exited before reading its payload' + } + if ([DateTime]::UtcNow -ge $deadline) { + $running.Stop() + throw 'The interactive herdr launcher did not read its payload within 30 seconds' + } + Start-Sleep -Milliseconds 100 + } + Write-Output 'started' +} finally { + # Unregistering a task leaves its running action (and herdr) alive. + try { if ($null -ne $registered) { $folder.DeleteTask($taskName, 0) } } + finally { if ([IO.Directory]::Exists($directory)) { Remove-Item -LiteralPath $directory -Recurse -Force } } +} +`; + +/** A launch-only task escapes the bridge job without taking herdr into Session 0. */ +export async function startInteractiveWindowsHerdr(exe: string, args: string[], log: string): Promise<"started" | "waiting_login"> { + const child = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Buffer.from(interactiveScript, "utf16le").toString("base64")], { stdin: "pipe", stdout: "pipe", stderr: "pipe", windowsHide: true }); + child.stdin.write(JSON.stringify({ exe, args, log, directory: dirname(log), cwd: homedir(), env: process.env, bun: process.execPath, launcher: desktopLauncher })); + child.stdin.end(); + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + if (code !== 0) throw new Error(`herdr could not be started in the Windows desktop session: ${stderr.trim()}`); + if (stdout.trim() === "waiting_login") return "waiting_login"; + if (stdout.trim() !== "started") throw new Error("The interactive herdr launcher returned no startup result"); + return "started"; +} + +/** WMI keeps existing headless hosts independent of the bridge's kill-on-close job. */ +export async function startHeadlessWindowsHerdr(exe: string, args: string[], log: string): Promise { + for (const value of [exe, ...args, log]) if (/["\r\n\0]/.test(value)) throw new Error(`Invalid characters in ${value}`); + const line = `cmd.exe /d /c ""${exe}" ${args.join(" ")} >> "${log}" 2>&1"`; + const environment = Object.entries(process.env).filter(([name, value]) => value !== undefined && !/[\r\n\0=]/.test(name) && !/[\r\n\0]/.test(value)).map(([name, value]) => `${name}=${value}`); + const script = [ + "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'", + "[Console]::InputEncoding = [Text.Encoding]::UTF8; $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json", + "$startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0; EnvironmentVariables = [string[]]$payload.environment }", + "$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $payload.line; CurrentDirectory = $payload.cwd; ProcessStartupInformation = $startup }", + "exit $r.ReturnValue", + ].join("\n"); + const ps = Bun.spawn(["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { stdin: "pipe", stdout: "ignore", stderr: "pipe", windowsHide: true }); + ps.stdin.write(JSON.stringify({ line, environment, cwd: homedir() })); + ps.stdin.end(); + const [code, stderr] = await Promise.all([ps.exited, new Response(ps.stderr).text()]); + if (code !== 0) throw new Error(`herdr could not be started through WMI (${code}): ${stderr.trim()}`); +} diff --git a/server/windows-native.test.ts b/server/windows-native.test.ts index e36b607d9..d3f897912 100644 --- a/server/windows-native.test.ts +++ b/server/windows-native.test.ts @@ -4,11 +4,12 @@ * Windows branches is tested with rows and paths handed in (gjc-runtime.test.ts); these run * on the Windows runners of reusable CI and the remote-bundle workflow (#271). */ -import { expect, it } from "bun:test"; -import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { expect, it, spyOn } from "bun:test"; +import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, watch, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { rm } from "node:fs/promises"; import { claudeProcessSession, forgetClaudeSessions, processClaudeConfigDir } from "./claude-store.ts"; import { Database } from "bun:sqlite"; @@ -22,9 +23,108 @@ import { psQuote } from "./powershell.ts"; import { REMOTE_BUNDLE_VERSION } from "../shared/machines.ts"; import { isAgyProcessName, windowsHolders } from "./agy-store.ts"; import { ancestorsToRepoRoot } from "./skills/walk.ts"; +import { startInteractiveWindowsHerdr } from "./windows-herdr-start.ts"; const onWindows = process.platform === "win32"; +it.skipIf(!onWindows)("waits when Windows accepts an interactive task without running it, but rejects a failed launcher", async () => { + const root = mkdtempSync(join(tmpdir(), "ovis-desktop-not-run-")); + const marker = join(root, "unexpected-child"); + const script = join(root, "child.ts"); + writeFileSync(script, `await Bun.write(${JSON.stringify(marker)}, 'launched');`); + const spawn = Bun.spawn.bind(Bun); + let failedResult = false; + const failures: unknown[] = []; + // Replay Run returning no instance while the real, fresh task remains Ready/never-run. + // Registration, status queries, private payload creation and cleanup still run natively. + const intercepted = spyOn(Bun, "spawn").mockImplementation(((command: string[], options: Parameters[1]) => { + const args = [...command]; + const encoded = args.indexOf("-EncodedCommand") + 1; + let source = Buffer.from(args[encoded]!, "base64").toString("utf16le"); + source = source.replace("try { $running = $registered.Run($null) }", "try { $running = $null }"); + // A completed launcher error must not be mistaken for a never-run task. + if (failedResult) source = source.replace("$deadline = [DateTime]::UtcNow.AddSeconds(30)", "$registered = [pscustomobject]@{ State = 3; LastTaskResult = 1 }; $deadline = [DateTime]::UtcNow.AddSeconds(30)"); + args[encoded] = Buffer.from(source, "utf16le").toString("base64"); + return spawn(args, options); + }) as typeof Bun.spawn); + try { + expect(await startInteractiveWindowsHerdr(process.execPath, [script], join(root, "herdr.log"))).toBe("waiting_login"); + expect(existsSync(marker)).toBe(false); + expect(readdirSync(root).filter((name) => name.startsWith("herdr-start-"))).toEqual([]); + failedResult = true; + await expect(startInteractiveWindowsHerdr(process.execPath, [script], join(root, "herdr.log"))).rejects.toThrow("exited before reading its payload"); + expect(existsSync(marker)).toBe(false); + expect(readdirSync(root).filter((name) => name.startsWith("herdr-start-"))).toEqual([]); + } catch (error) { failures.push(error); } + intercepted.mockRestore(); + try { rmSync(root, { recursive: true, force: true }); } catch (error) { failures.push(error); } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, "Desktop wait test and cleanup failed"); +}, 30_000); + +it.skipIf(!onWindows)("starts a desktop child outside the launcher, preserving quoted arguments and private environment, or waits without launching", async () => { + const root = mkdtempSync(join(tmpdir(), "ovis-desktop-start-")); + const resultPath = join(root, "result.json"); + const stopPath = join(root, "stop"); + const script = join(root, "child with spaces.ts"); + const sentinel = "private-launch-sentinel-\"&%!\n한글"; + const previous = process.env["OVIS_START_TEST_SECRET"]; + let pid: number | undefined; + const watcher = watch(root); + let deadline: ReturnType | undefined; + const result = new Promise<{ pid: number; secret: string; args: string[]; session: number }>((resolve, reject) => { + deadline = setTimeout(() => reject(new Error("the desktop child did not answer")), 15_000); + watcher.on("change", () => { + if (existsSync(resultPath)) { clearTimeout(deadline); resolve(JSON.parse(readFileSync(resultPath, "utf8"))); } + }); + }); + // A waiting host has no child and therefore no result; still observe a later rejection on failure. + void result.catch(() => {}); + writeFileSync(script, `import { watch, existsSync, writeFileSync } from 'node:fs'; +import { dlopen, FFIType } from 'bun:ffi'; +const { symbols } = dlopen('kernel32.dll', { ProcessIdToSessionId: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.i32 } }); +const session = new Uint32Array(1); if (!symbols.ProcessIdToSessionId(process.pid, session)) throw new Error('no session'); +watch(${JSON.stringify(root)}, () => { if (existsSync(${JSON.stringify(stopPath)})) process.exit(0); }); +writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({pid:process.pid, secret:process.env.OVIS_START_TEST_SECRET, args:process.argv.slice(2), session:session[0]})); +`); + process.env["OVIS_START_TEST_SECRET"] = sentinel; + const failures: unknown[] = []; + try { + const started = await startInteractiveWindowsHerdr(process.execPath, [script, "argument with spaces & 한글"], join(root, "herdr.log")); + if (started === "waiting_login") { + expect(existsSync(resultPath)).toBe(false); + } else { + // The launcher already exited, but this child's watch still runs in the desktop session. + const child = await result; + pid = child.pid; + expect(child.session).toBeGreaterThan(0); + expect(child.secret).toBe(sentinel); + expect(child.args).toEqual(["argument with spaces & 한글"]); + expect(() => process.kill(child.pid, 0)).not.toThrow(); + } + expect(readdirSync(root).filter((name) => name.startsWith("herdr-start-"))).toEqual([]); + } catch (error) { failures.push(error); } + try { + clearTimeout(deadline); + const closed = new Promise((resolve) => watcher.once("close", resolve)); + watcher.close(); + await closed; + if (previous === undefined) delete process.env["OVIS_START_TEST_SECRET"]; else process.env["OVIS_START_TEST_SECRET"] = previous; + // Only this fixture's child is stopped, never a herdr daemon or another test's task. + if (pid !== undefined) try { process.kill(pid); } catch {} + const cleanupBy = Date.now() + 5000; + for (;;) { + try { await rm(root, { recursive: true, force: true }); break; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBUSY" || Date.now() >= cleanupBy) throw error; + await Bun.sleep(25); + } + } + } catch (error) { failures.push(error); } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, "Desktop startup test and cleanup failed"); +}, 60_000); + it.skipIf(!onWindows)("stops skill discovery at the user's home whatever its letter case", () => { const home = mkdtempSync(join(tmpdir(), "herdr-home-case-")); const before = process.env["HOME"]; diff --git a/shared/machines.ts b/shared/machines.ts index 054cd70fe..232d03b52 100644 --- a/shared/machines.ts +++ b/shared/machines.ts @@ -24,7 +24,7 @@ export interface Machine { enabled: boolean; state: MachineState; error: string | null; - /** set when retrying cannot help: the user has to update the remote bridge or approve setup */ + /** Attention needed; windows_login retries, windows_start_failed waits for a startup decision. */ action_required?: MachineAction | null; /** a bridge update running for this PC right now (in the background, or from its dialog) */ updating?: MachineUpdate | null; @@ -84,7 +84,7 @@ export interface HerdrIdentity { /** without attach, the terminal lens shows the pane's screen repainted a few times a second (server/mirror.ts) */ terminal_mirror?: boolean; } -export type MachineAction = "connect" | "update_bridge" | "setup" | "bridge_conflict"; +export type MachineAction = "connect" | "update_bridge" | "setup" | "bridge_conflict" | "windows_login" | "windows_start_failed"; /** `ovis`: the PC's own Ovis updates itself (Machine.remote_ovis); absent for a bridge this app installs */ export interface MachineUpdate { job_id: string; step: string; progress: SetupProgress | null; ovis?: boolean } /** @@ -114,6 +114,8 @@ export interface SetupJob { challenge: SetupChallenge | null; installations: string[]; error: string | null; + /** An unsaved Windows destination may wait for login; its setup must stay in the current dialog. */ + windows_startup?: boolean; /** * set on a failed job when retrying as-is cannot connect: the user has to update the * bridge or approve setup. The dialog turns "update_bridge" into its update-and-connect @@ -130,7 +132,7 @@ export interface SetupJob { /** the PC's own Ovis, when it is older than this app (Machine.remote_ovis): a first connect has no PC to carry it */ remote_ovis?: RemoteOvis | null; } -export type SetupAction = { action: "answer"; challenge_id: string; answer: string } | { action: "approve" } | { action: "cancel" }; +export type SetupAction = { action: "answer"; challenge_id: string; answer: string } | { action: "approve" } | { action: "cancel" } | { action: "start_headless" }; /** * A device was durably enrolled (server/devices.ts): its registry write succeeded, whether at a * gated pairing or when a first-pair cookie came back. Never for an unconfirmed credential, a diff --git a/src/components/MachineDialog.tsx b/src/components/MachineDialog.tsx index 831b1a237..c5785d2d4 100644 --- a/src/components/MachineDialog.tsx +++ b/src/components/MachineDialog.tsx @@ -28,6 +28,9 @@ export function MachineDialog({ machine, updateRemote = false, onClose, onConnec const finished = !!job && ["failed", "cancelled", "connected"].includes(job.phase); const secretAllowed = window.isSecureContext; const needsBridgeUpdate = job?.phase === "failed" && job.action_required === "update_bridge"; + const startupFailed = job?.phase === "starting" && job.action_required === "windows_start_failed"; + const waitingForLogin = job?.phase === "starting" && (job.action_required === "windows_login" || startupFailed); + const keepOpen = job?.windows_startup === true; // the PC's own Ovis, older than this app: the update asks it to update itself, or it cannot be from here const own = job?.remote_ovis ?? (job || !machine ? null : ownOvisAction(machine)); const ownUpdate = own?.updatable === true; @@ -36,7 +39,7 @@ export function MachineDialog({ machine, updateRemote = false, onClose, onConnec // approved install keeps going on the server and shows in the sidebar // React's autoFocus runs at mount, while the dialog is still closed; showModal() then moves focus // to the first focusable element (Close), so the field is focused again once the dialog is open - useEffect(() => { mounted.current = true; dialog.current?.showModal(); destinationField.current?.focus(); return () => { mounted.current = false; const current = jobRef.current; if (current && !["connected", "failed", "cancelled", "installing", "starting"].includes(current.phase)) void answerMachineSetup(current.id, { action: "cancel" }).catch(() => {}); }; }, []); + useEffect(() => { mounted.current = true; dialog.current?.showModal(); destinationField.current?.focus(); return () => { mounted.current = false; const current = jobRef.current; if (current && !["connected", "failed", "cancelled"].includes(current.phase) && (current.windows_startup || !["installing", "starting"].includes(current.phase))) void answerMachineSetup(current.id, { action: "cancel" }).catch(() => {}); }; }, []); const running = !!job && ["installing", "starting"].includes(job.phase); useEffect(() => { if (!job || finished) return; @@ -58,14 +61,14 @@ export function MachineDialog({ machine, updateRemote = false, onClose, onConnec // a PC saved in herdr keeps its catalog name, which the server does not read from here try { const next = await startMachineSetup({ destination: destination.trim(), ...(name.trim() && !machine?.herdr_profile_id ? { name: name.trim() } : {}), ...(port ? { port: Number(port) } : {}), ...(key.trim() ? { identity_file: key.trim() } : {}), ...(session.trim() ? { session: session.trim() } : {}), ...(machine ? { machine_id: machine.id } : {}), ...(needsBridgeUpdate || updateRemote ? { update_remote: true } : {}) }); if (mounted.current) setJob(next); - else if (!["connected", "failed", "cancelled", "installing", "starting"].includes(next.phase)) await answerMachineSetup(next.id, { action: "cancel" }); + else if (!["connected", "failed", "cancelled"].includes(next.phase) && (next.windows_startup || !["installing", "starting"].includes(next.phase))) await answerMachineSetup(next.id, { action: "cancel" }); } catch (e) { if (mounted.current) setError(e instanceof Error ? e.message : String(e)); } finally { if (mounted.current) setPending(false); } }; const inheritedStarted = useRef(false); useEffect(() => { - if (!machine?.herdr_profile_id || inheritedStarted.current) return; + if ((!machine?.herdr_profile_id && machine?.action_required !== "windows_login" && machine?.action_required !== "windows_start_failed") || inheritedStarted.current) return; inheritedStarted.current = true; void begin(); }, []); @@ -91,10 +94,13 @@ export function MachineDialog({ machine, updateRemote = false, onClose, onConnec

{t("Uses the web server account’s SSH config and ssh-agent. Agent CLI tools and logins use the environment on the target PC.")}

} } - {job && job.challenge?.kind !== "host_key" &&
{running && job.progress ? : {job.step}}{job.error &&

{machineErrorText(job.error, t)}

}{job.ssh_output && }
} + {job && job.challenge?.kind !== "host_key" &&
{waitingForLogin ? {t(startupFailed ? "Windows desktop startup failed" : "Waiting for Windows sign-in")} : running && job.progress ? : {job.step === "Starting without Windows sign-in…" ? t("Starting without Windows sign-in…") : job.step}}{waitingForLogin && job.step !== "Waiting for Windows sign-in on this PC." &&

{machineErrorText(job.step, t)}

}{job.error &&

{machineErrorText(job.error, t)}

}{job.ssh_output && }
} + {waitingForLogin &&

{t("Starting without Windows sign-in can break Codex sandbox commands, and desktop-control tools may be unavailable. This choice applies to one start.")}

} + {waitingForLogin &&

{t("Signing out of Windows ends herdr and its panes; lock the PC instead.")}

} {needsBridgeUpdate &&

{t(ownUpdate ? "It updates itself through its own updater, then this PC reconnects; herdr sessions keep running." : "This PC runs a bridge from a different version of Ovis. Update it to reconnect; herdr sessions keep running.")}

} {job?.phase === "failed" && own && !own.updatable && } - {running &&

{t("You can close this; the install keeps going and the sidebar shows it.")}

} + {running && keepOpen &&

{t("Keep this dialog open until the PC connects.")}

} + {running && !waitingForLogin && !keepOpen &&

{t("You can close this; the install keeps going and the sidebar shows it.")}

} {job?.phase === "approval" && <>
    {job.installations.map((item) =>
  • {item}
  • )}

{t(ownUpdate ? "That PC's own updater installs it there. Existing herdr sessions keep running." : "Installs into your home directory. Existing herdr sessions keep running.")}

} {job?.challenge &&
{job.challenge.kind === "host_key" ? : <>
{job.challenge.prompt}
{ e.preventDefault(); void act({ action: "answer", challenge_id: job.challenge!.id, answer: secret }); }}> @@ -104,8 +110,9 @@ export function MachineDialog({ machine, updateRemote = false, onClose, onConnec {error &&

{error}

}