Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3284,6 +3284,20 @@ The values are the ones in `src/styles.css`.
bindings on phones and desktops.
- `document.title` is `<pane title> · 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 |
Expand Down
10 changes: 10 additions & 0 deletions changelog.d/windows-interactive-start.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 21 additions & 2 deletions docs/remote-pcs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<N>` (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<N>` (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

```
Expand Down Expand Up @@ -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:<machine-id>`). 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.

Expand Down
42 changes: 41 additions & 1 deletion scripts/machine-dialog-regression.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(); }
Expand Down
Loading
Loading