From 7db6e6259e9d4f6f16e34243c9c2b8875cde465e Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Tue, 27 Oct 2026 20:17:58 +0100 Subject: [PATCH 1/7] fix(machines): wait for Windows sign-in before cold start --- DESIGN.md | 10 ++ changelog.d/windows-interactive-start.md | 5 + docs/remote-pcs.md | 16 +++- scripts/machine-dialog-regression.ts | 42 +++++++- server/machines.test.ts | 77 ++++++++++++++- server/machines.ts | 52 ++++++++-- server/remote-entry.ts | 40 ++++---- server/remote-host.ts | 14 ++- server/windows-herdr-start.ts | 116 +++++++++++++++++++++++ server/windows-native.test.ts | 63 +++++++++++- shared/machines.ts | 8 +- src/components/MachineDialog.tsx | 19 ++-- src/components/MachineSidebar.tsx | 13 ++- src/components/Machines.css | 2 + src/lib/i18n/machines.ja.ts | 7 ++ src/lib/i18n/machines.ko.ts | 7 ++ src/lib/i18n/machines.zh.ts | 7 ++ 17 files changed, 445 insertions(+), 53 deletions(-) create mode 100644 changelog.d/windows-interactive-start.md create mode 100644 server/windows-herdr-start.ts diff --git a/DESIGN.md b/DESIGN.md index ada8ad19d..0ca8f21d0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3284,6 +3284,16 @@ 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. + ### 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..429bb2038 --- /dev/null +++ b/changelog.d/windows-interactive-start.md @@ -0,0 +1,5 @@ +### 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, with the sandbox and desktop-tool limitations shown before that one start. Running herdr + servers and their panes are preserved. diff --git a/docs/remote-pcs.md b/docs/remote-pcs.md index 07f55179b..69546d44c 100644 --- a/docs/remote-pcs.md +++ b/docs/remote-pcs.md @@ -42,10 +42,24 @@ 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. + +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. + +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 ``` 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..424014d0d 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: "?" }); diff --git a/server/machines.ts b/server/machines.ts index 3fc2fb45d..0502d831e 100644 --- a/server/machines.ts +++ b/server/machines.ts @@ -65,6 +65,8 @@ interface Runtime { } interface JobState { update: boolean; + /** Explicit consent for the next Windows cold-start attempt only. Never persisted. */ + headlessStart?: 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 +552,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"); + 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"); @@ -654,6 +658,7 @@ export class MachineManager { if (job.update && !moving) 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; @@ -708,6 +713,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") 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"); @@ -728,6 +740,7 @@ export class MachineManager { 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.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 +771,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 +908,34 @@ 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; + 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 } : {}) }); + for (let i = 0; i < 40; 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; + await Bun.sleep(250); + } + if (!descriptor) notice = await host.startupNotice?.(ssh, session) ?? null; + if (notice && job) { + if (!job.headlessStart) { + job.public.action_required = "windows_login"; + if (!moving) runtime.machine.action_required = "windows_login"; + this.stage(job, "starting", notice); + } + await this.pause(job.abort.signal); + } + } while (!descriptor && notice && job); + if (!descriptor) { + if (notice) throw new MachineActionRequired(notice, "windows_login"); + 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 = null; + if (runtime.machine.action_required === "windows_login") runtime.machine.action_required = null; } learnServer(descriptor); let verified: Awaited>; @@ -1173,13 +1207,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 +1392,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..8c71c3893 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,9 @@ 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 { // 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 +57,17 @@ 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); + rmSync(status, { force: true }); + if (process.env["OVIS_WINDOWS_HEADLESS_START"] !== "1") { + if (await startInteractiveWindowsHerdr(herdr, args, log) === "waiting_login") { + writeFileSync(status, "waiting_login"); + 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 +91,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..7377d124b 100644 --- a/server/remote-host.ts +++ b/server/remote-host.ts @@ -21,6 +21,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 +90,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 +132,8 @@ export interface RemoteHost { installHerdr?(ssh: SshConnection): Promise; stop(ssh: SshConnection, pid: number): Promise; start(ssh: SshConnection, options: StartOptions): Promise; + /** A cold start waiting for the account's desktop login, rather than a failed bridge. */ + startupNotice?(ssh: SshConnection, session?: string): Promise; descriptors(ssh: SshConnection): Promise; } @@ -341,7 +344,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 +354,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`, @@ -365,6 +369,12 @@ export const windowsHost: RemoteHost = { "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) }`); + return result.trim() === "waiting_login" ? WAITING_FOR_WINDOWS_LOGIN : 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..7cbebc04b --- /dev/null +++ b/server/windows-herdr-start.ts @@ -0,0 +1,116 @@ +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) { 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. + if ($null -ne $registered) { $folder.DeleteTask($taskName, 0) } + 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..9c5131ddb 100644 --- a/server/windows-native.test.ts +++ b/server/windows-native.test.ts @@ -5,10 +5,11 @@ * 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 { 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,69 @@ 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)("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; + 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([]); + } finally { + 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); + } + } + } +}, 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..868b26487 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 continues retrying while the user may choose headless startup. */ 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"; /** `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..ba4e2abfc 100644 --- a/src/components/MachineDialog.tsx +++ b/src/components/MachineDialog.tsx @@ -28,6 +28,8 @@ 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 waitingForLogin = job?.phase === "starting" && job.action_required === "windows_login"; + 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 +38,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 +60,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") || inheritedStarted.current) return; inheritedStarted.current = true; void begin(); }, []); @@ -91,10 +93,12 @@ 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("Waiting for Windows sign-in")} : running && job.progress ? : {job.step}}{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.")}

} {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 +108,9 @@ export function MachineDialog({ machine, updateRemote = false, onClose, onConnec {error &&

{error}

}