Skip to content

feat(remote): control a Windows daemon over ssh - #242

Merged
artyomsv merged 27 commits into
masterfrom
feat/windows-ssh-remote
Sep 27, 2026
Merged

artyomsv merged 27 commits into
masterfrom
feat/windows-ssh-remote

Conversation

@artyomsv

@artyomsv artyomsv commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

Phase 2 of the multi-client epic: a TUI on another machine can control a quil daemon that runs on Windows, over the built-in OpenSSH server. The daemon and its panes now survive ssh disconnects, and it is never started with an admin token from ssh.

Ref #236. Part of #234.

What changed

Daemon start inside an ssh session (FR-1, FR-2)

  • Win32-OpenSSH puts every session in a kill-on-close job, so a daemon that quil --stdio auto-started died with the session (measured, see feat(remote): Windows daemon as an ssh remote (phase 2) #236). An admin account over ssh also gets a full (High) token.
  • New internal/winjob: every daemon spawn from inside such a job (--stdio, daemon start/restart, mcp, a TUI over ssh -t, the upgrade restart) goes through one decision:
    1. the per-user logon task, when one exists and you have a desktop session: the daemon starts in your desktop session with your normal rights;
    2. else wait, if a daemon is already starting;
    3. else a breakaway spawn with a lowered token (Medium, admin group deny-only, owner/DACL reset so ConPTY works). It is never the unlowered token.
  • New quil daemon install-logon [--remove]: registers that task (Task Scheduler XML: no 72 h time limit, normal priority, least privilege). No admin rights needed; from an elevated/ssh session it registers under the lowered token, so a normal desktop shell can remove it.
  • quil-activate.exe start-daemon: the task's windowless launcher (reachable only as argv[1], never from a quil:// click).
  • internal/daemonspawn: one shared spawn helper, so the CLI and the launcher cannot drift.
  • quild now holds an exclusive lock on <QUIL_HOME>\quild.lock and writes its pid file before it starts restoring. Two daemons starting at the same moment for one home could previously both win (measured: two daemons, each with its own panes).
  • [limited] in the status bar for a daemon in session 0 (no saved Credential Manager logins, invisible pane windows), with a short flash once per attach.

Remote setup and attach (FR-3, FR-4, FR-7)

  • The recorded remote binary is quoted for the host's shell (POSIX / cmd / PowerShell). Windows paths are accepted only as drive paths without quote or cmd metacharacters; UNC is refused. Existing POSIX records are byte-identical.
  • quil remote setup detects a Windows host (no sh, or Git-Bash MINGW) and probes it with an encoded PowerShell script.
  • Windows install: Windows PowerShell under OpenSSH cannot read ssh stdin (measured), so the archive is extracted by the host's own tar.exe (Windows 10 1803+), in three round trips: prepare, copy, verify each file's SHA-256 and swap (a running quil.exe is renamed to quil.exe.old). Installs to %LOCALAPPDATA%\Programs\quil, does not touch PATH, then registers the logon task.
  • A missing command exits 1 on Windows (not 9009). An exit 1 before any byte now triggers the host probe, but only for hosts that may be Windows (no record, or a recorded cmd/PowerShell shell). Recorded POSIX hosts keep today's behaviour.
  • Over ssh the version check now waits for the far side (up to 60 s, a dead link still ends early), and a dead link gets up to 5 s to exit on its own, so the real exit status is read even when the remote shell starts slowly.
  • Windows process checks use OpenProcess instead of tasklist (tasklist is denied to a standard user over ssh).
  • Works with cmd.exe or Windows PowerShell as the OpenSSH DefaultShell. If the shell changes after setup, the next attach notices it, re-records the shell and reconnects.

Clients (FR-6)

  • Windows pane folders show correctly on Linux/macOS clients (displayBase, recent-folder dedupe by path shape).

Docs

  • New docs/remote-windows.md (OpenSSH setup, keys, default shell, Tailscale, setup, logon task, [limited]).
  • docs/features.md, docs/installation.md, docs/roadmap/remote-daemon.md, docs/README.md.
  • .claude/CLAUDE.md, .claude/rules/remote-transport.md, windows-pty.md, daemon-lifecycle.md.
  • Changelog fragment changelog.d/added-windows-ssh-remote.md.

Test plan

  • CI-exact in golang:1.25: go vet ./..., go test -race ./..., go test -tags=integration -race ./...
  • Windows-native test binaries: internal/winjob (lowered child is Medium with admins deny-only; schtasks accepts the task XML), cmd/quild startup lock
  • Windows go vet for every touched package
  • Live, Linux client → Windows 10 OpenSSH (cmd shell), dev build: ssh-started daemon survives the disconnect; logon-task path runs in session 1 at Medium; lowered path runs in session 0 at Medium (panes too); a task registered over ssh is removable from the desktop; three parallel starts give one daemon
  • Live: the encoded Windows probe; tar.exe extract from ssh stdin into a path with spaces; a cmd-quoted path with spaces, (x86) and @
  • Live, standard (non-admin) account from the Linux VM: quil remote setup fresh install and upgrade (files byte-identical, no leftovers, per-user logon task); cold-start attach on the first try; quil removed → "not installed" + install offer; declining aborts cleanly
  • Live, Windows PowerShell as the OpenSSH DefaultShell: setup upgrade end to end; a stale cmd record healed on attach; attach; missing quil detected and offered; decline aborts
  • Manual: a Windows desktop TUI and a remote TUI on the same daemon (phase 1 rules), a long command across a disconnect

A daemon spawned inside a kill-on-close job (every Win32-OpenSSH session) died with the session, and an admin's ssh token is High. Spawns there now use the logon task, else a Medium-token breakaway start, and never the unlowered token.

Ref #236. Part of #234.
Two daemons that started together both passed the healthy-socket probe; the second removed the first's socket and orphaned it with its panes still running. quild now holds an exclusive lock on <home>/quild.lock for its lifetime.

Ref #236. Part of #234.
Windows PowerShell started by Win32-OpenSSH cannot read ssh stdin, so
the install is three round trips: a PowerShell step creates a staging
dir under the install dir, the host's own tar.exe extracts the tar.gz
from stdin into it, and a second PowerShell step checks every file's
SHA-256 and swaps the set in, renaming a running quil.exe aside.

Exit 1 before any byte is now RemedyProbe: cmd and PowerShell answer 1
for a missing command, and so does quil refusing to start. The attach
path and background dials probe the host to tell them apart. A Windows
install registers the daemon's logon task and records the host's shell
with the binary path.

Ref #236. Part of #234.
- quild writes its PID file before Start, right after the startup lock
  and the healthy-peer probe. Start respawns panes before it listens, so
  a client asking "is a daemon starting?" during a heavy restore always
  heard no and spawned a second daemon. The file is now removed only
  while it still names this process, so a losing daemon cannot delete
  the winner's.
- install-logon pins the quild beside quil before any quild on PATH,
  as the upgrade path already does; an older quild on PATH made the
  task start a mismatched daemon at every logon.
- quil remote setup refuses a PowerShell OpenSSH DefaultShell before
  any remote write until the stdin pipe to tar.exe is measured there.
- The lowered-spawn message no longer claims "limited".
- remote-prepare.ps1 creates directories with CreateDirectory ([ and ]
  are wildcards to New-Item -Path) and clears stale staging dirs.
- A test pins New's session-0 check; docs describe the final exit-1
  behaviour, the setup shell requirement and bash as DefaultShell.

Ref #236. Part of #234.

@code-spire-beaver code-spire-beaver left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed Windows SSH remote support at 17220d6. Three blocking findings: nested-job token handling, per-user scheduled-task identity, and shell reconciliation after the documented cmd-to-PowerShell switch. All three have focused decision-level reproductions in an isolated archive; native Windows execution remains unverified.

Findings: 1 HIGH, 2 MEDIUM. Reviewed head 17220d6185 as @code-spire-beaver.

Comment thread internal/winjob/job.go Outdated
Comment thread internal/winjob/taskname.go Outdated
Comment thread cmd/quil/remote_setup.go

@code-spire-beaver code-spire-beaver left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CHANGES REQUESTED

Request changes: one HIGH and two MEDIUM findings, each posted as a separate inline thread.

  1. Keep an ambiguous nested job on the task/wait/refusal path; it currently bypasses token lowering.
  2. Give default production logon tasks a per-user identity.
  3. Reconcile the shell at an unchanged binary path after the documented cmd-to-PowerShell switch.

Security review covered token lowering, job nesting, shell/path injection, archive integrity, staging boundaries, and task ownership. Correctness review covered spawning/readiness, startup locking, remote setup/reconciliation, and Windows path/display behavior. The nested-job finding also violates the Windows privilege rule; no separate rule-only finding was added. Test review found the three missing scenarios reproduced in the inline threads.

Validation at 17220d618596dd94daa2f116bbb809339e2e41ba:

  • Nine affected packages passed on Linux: internal/winjob, internal/daemonspawn, internal/remoteinstall, internal/config, internal/daemon, internal/tui, cmd/quil, cmd/quild, cmd/quil-activate. 3,748 top-level tests and 2,197 subtests passed; nine top-level tests skipped.
  • Targeted race checks across winjob, daemonspawn, remoteinstall, quild and quil passed: 106 top-level tests and 28 subtests. No race reports.
  • go vet ./... and Windows/amd64 go vet -unsafeptr=false ./... passed. The Windows check used the real, SHA-256-verified bundled ConPTY assets. Context-document size check passed.
  • Three additional decision-level regression probes fail as described in the inline findings.
  • GitHub CI test is failing: TestClientDispatch_AttachMasterChangeReachesOthersOnly receives two workspace frames and TestDefaultCWD_PerClientAndBridge receives an empty CWD. These tests concern existing client synchronization; this review has not established that this PR caused those failures. CodeQL, changelog and site checks pass.

Native Windows token/task/SSH execution and end-to-end installation were not available on this Linux reviewer. Windows PowerShell attachment and standard-user behavior therefore remain runtime validation gaps. Integration-tag tests were not run.

Agent findings: 0 resolved, 3 open. CI: 6/7 checks passing. Head 17220d6185, reviewed by @code-spire-beaver.

Open findings:

@code-spire-beaver code-spire-beaver added the review: changes-requested Agent review verdict: changes requested label Sep 27, 2026
@codecov

codecov Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.87296% with 213 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.63%. Comparing base (819743d) to head (5ea39f9).

Files with missing lines Patch % Lines
cmd/quil/remote_setup.go 68.88% 36 Missing and 6 partials ⚠️
cmd/quil/logontask.go 57.37% 19 Missing and 7 partials ⚠️
internal/remoteinstall/install.go 70.93% 13 Missing and 12 partials ⚠️
cmd/quil/daemonstart.go 28.12% 21 Missing and 2 partials ⚠️
cmd/quild/main.go 25.92% 19 Missing and 1 partial ⚠️
cmd/quil/main.go 42.85% 12 Missing ⚠️
internal/winjob/other.go 0.00% 8 Missing ⚠️
cmd/quil/handshake.go 61.11% 6 Missing and 1 partial ⚠️
cmd/quild/lock.go 61.11% 4 Missing and 3 partials ⚠️
cmd/quil/dialall.go 72.72% 5 Missing and 1 partial ⚠️
... and 10 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #242      +/-   ##
==========================================
+ Coverage   74.37%   74.63%   +0.26%     
==========================================
  Files         269      288      +19     
  Lines       39718    40532     +814     
==========================================
+ Hits        29539    30252     +713     
- Misses       8244     8299      +55     
- Partials     1935     1981      +46     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The job query sees only the innermost job. A job with neither
KILL_ON_JOB_CLOSE nor BREAKAWAY_OK was read as "not in a job", so an
admin's High ssh token started the daemon elevated inside an unseen
outer sshd kill-on-close job.

JobState now returns a JobInfo (InJob, KillOnClose, BreakawayOK) that
maps the flags only. StartDaemon handles the ambiguous job: an
above-Medium token (or an unreadable one) spawns with the lowered token
and no breakaway flag (Via lowered-in-place); a Medium token keeps
today's normal spawn.

Ref #236. Part of #234.
Task Scheduler names are machine-wide. The production task on the
default home was the plain "Quil daemon" for every user, so a second
user's non-elevated /Create /F hit the first user's task (access
denied), and a name-only /Query or /Run from an elevated session could
pick another user's task.

Every name is now "Quil daemon (<daemon> <8 hex of the folded home>)".
The default home is per-user, so the names differ per user.

Ref #236. Part of #234.
Installing with cmd.exe as DefaultShell and then switching back to
PowerShell left the record at shell "cmd". Every attach then sent a
cmd-quoted command to PowerShell, which failed before quil started.
The probe found the same path, and reconciliation compared only the
path, so nothing changed and every attach failed the same way.

Both reconciliation paths (the 127 heal and the exit-1 probe) now
record the probe's shell beside the unchanged path when a Windows probe
reports a different shell, and re-dial once. The re-dial sees an
unchanged pair, so the existing handling applies. A failed probe or a
POSIX probe changes nothing.

Ref #236. Part of #234.

@code-spire-beaver code-spire-beaver left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 37dcb3f against 17220d6. Verified all three fixes and reviewed the complete 23-file follow-up diff across security, correctness, project rules and tests. No new blocking findings. Native Windows execution remains unverified; the lowered-in-place fallback cannot guarantee survival of an unseen outer kill-on-close job.

Findings: none. Reviewed head 37dcb3fa2d as @code-spire-beaver.

@code-spire-beaver code-spire-beaver left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ APPROVED

Verified all three review findings at 37dcb3fa2de53b7ec8ec3444205adb2b051685e4; the three threads are resolved. No new blocking findings in the 23-file follow-up diff.

  • Security: ambiguous job membership is preserved, elevated tokens are lowered in place, and lowering failures cannot fall back to normal spawning. Task/wait paths remain first. Windows syscall wiring and the renamed JobInfo API callers were checked.
  • Correctness: production task names now separate per-user default homes. Both remote reconciliation paths update a changed shell at the same binary path and stop retrying when the pair is unchanged.
  • Rules: checked updated Windows privilege and remote reconciliation rules, daemon lifecycle, dev isolation, Go conventions/testing, error handling and documentation. No new rule finding.
  • Tests: five packages passed with -race (internal/winjob, internal/daemonspawn, internal/remoteinstall, cmd/quil, cmd/quil-activate): 358 top-level tests and 268 subtests passed, one test skipped. Linux go vet ./..., Windows/amd64 go vet -unsafeptr=false ./..., and the context-document size check passed.

Residual risk: native Windows SSH/token/task execution was not available. The deliberate lowered-in-place fallback closes the privilege defect but cannot guarantee daemon survival if an unseen outer job kills its members on close. Semgrep was not installed; GitHub CodeQL passes. The main checkout and production Quil state remain unchanged.

All GitHub checks now pass, including the full race and integration suites, builds, CodeQL, coverage, changelog and site checks. Approved.

Agent findings: 3 resolved, 0 open. CI: 9/9 checks passing. Head 37dcb3fa2d, reviewed by @code-spire-beaver.

@code-spire-beaver code-spire-beaver added review: approved Agent review verdict: approved and removed review: changes-requested Agent review verdict: changes requested labels Sep 27, 2026
For a standard user in an ssh session, tasklist prints "ERROR: Access
denied" and exits 0. processProbe read that as "no such process", so
quil --stdio reported the daemon it had just spawned as dead at once
("daemon did not come up within 30s") and the first attach failed.
LiveDaemonPID was blind the same way.

processProbe now opens the process with PROCESS_QUERY_LIMITED_
INFORMATION: ERROR_INVALID_PARAMETER is the only answer that reads
dead; access denied reads alive with an empty name, which no identity
check treats as quild; an opened handle is alive only while its exit
code is STILL_ACTIVE; the name is the full image path. The mapping
lives in an untagged file with a Linux test; native Windows tests
cover self, a free PID and an exited child whose handle is held.

Ref #236. Part of #234.
For a standard account in an ssh session WTSEnumerateSessions fails
with "No more data is available". The answer (no session, so a lowered
daemon) is right, but newStartDeps logged it with log.Printf, which in
quil --stdio lands on stderr and ssh relays it to the client's terminal
above the TUI. The error is no longer logged.

docs/remote-windows.md says what this means for a standard account:
from ssh the logon task is not used, the daemon starts limited, and the
task still starts it at the next desktop logon.

Ref #236. Part of #234.
Setup refused a Windows host whose OpenSSH DefaultShell is PowerShell,
because the extract step pipes the archive through that shell and it
had not been measured. It now has been, on Windows 10: under Windows
PowerShell, & 'C:\Windows\System32\tar.exe' -tvzf - reads a 2 MB
archive from ssh stdin and exits 0, and & '<quil.exe>' --stdio carries
a version round trip. The refusal and its test are gone; a new test
runs the whole PowerShell install and checks each command is quoted
for PowerShell.

docs/remote-windows.md and the remote-transport rule say PowerShell is
supported for setup and attach; bash stays untested.

Ref #236. Part of #234.
Answering N at "Continue? [y/N]" after "Quil is not installed on
<host>" was followed by the full "Cannot reach the Quil daemon ... the
problem is reaching the host" block, which contradicts the line the
user had just answered: the probe before it reached the host. A decline
on either offer path (exit 127, and exit 1 settled by the probe) now
prints one "Aborted" line and sets remoteFailureReported, so the gate
exits 1 without the link-failure report.

Ref #236. Part of #234.
With Windows PowerShell as the OpenSSH DefaultShell and a stale cmd
record, the remote command fails with exit 1 (measured). But the shell
takes a second or two to start, so the gate gave up first, Close killed
ssh, the status became -1 and read as RemedyNone: the probe that heals
the record or offers the install never ran, and the user was told the
host could not be reached.

LinkStatus gains WaitExited(d), which reaps from its own goroutine
(pump reaps only after stdout EOF). The gate's dead-link branch and
gateExtraVersion's DaemonUnknown arm (dialExtra and the MCP host dial)
now wait deadLinkExitGrace (5 s) for a natural exit before LinkErr and
Close; a child still running after that is closed exactly as before.

The gate also gave up before the far side could answer. A stamped
client (every binary dev.sh builds, dev and debug included, carries
VERSION, so IsRelease is true) waited only the local 2 s handshake
timeout, which a remote still connecting, starting a cold daemon or
starting PowerShell exceeds; that is the measured exit=-1. An unstamped
build (version "dev", e.g. a plain go build) skipped the request
entirely, so the dead-link guard ran microseconds after ssh started.
Over ssh the gate now always makes the round trip and waits
remoteGateTimeout (= extraDialTimeout); a dead link still ends early
because ssh exits and the read fails. Tests show both old behaviours
fail, and a slow exit 1 now reaches RemedyProbe.

Ref #236. Part of #234.
When the install offered at attach fails for any reason other than a
decline (the dev-build refusal, a download, the push), offerRemoteInstall
and resolveExitOne print "Install failed: ..." and the gate then also
printed the "Cannot reach the Quil daemon ... not anything about Quil"
block beneath it, contradicting it. failedRemoteInstall now sets
remoteFailureReported too, so the gate exits 1 with the install error
alone. The test that pinned the old flag is inverted, and a new one
drives the dev-build refusal through the gate on both offer paths.

docs/remote-windows.md and the remote-transport rule now name what was
measured live under Windows PowerShell as the DefaultShell: setup
upgrading an install, a stale cmd record healed on attach, the attach
itself, a missing quil offered the install, and a decline that aborts.
bash stays untested.

Ref #236. Part of #234.
The PowerShell entry in docs/remote-windows.md section 4 named only
three of the five facts measured live. It now lists all of them, as
the remote-transport rule does: setup upgrading an install, a stale
cmd record healed on attach (fails once, probes, re-records, reconnects),
the attach, a missing quil offered the install, and a decline that
aborts. The generic switch-shell sentence is folded into that list.

Ref #236. Part of #234.

@code-spire-beaver code-spire-beaver left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 5ea39f959504901be52aa33610ebdd4353e96d17 against the previously approved 37dcb3fa2de53b7ec8ec3444205adb2b051685e4 (21 changed files). No new blocking findings; the previous three fixes remain intact.

  • Security: checked the new Windows process query's unknown-identity handling, PID/name guards, bounded and sanitized version replies, and PowerShell command quoting. No new finding. Semgrep unavailable; GitHub CodeQL passes.
  • Correctness: traced WaitExited/reap/Close ordering, bounded waits for natural SSH exit, foreground and background classification, release and unstamped handshakes, process liveness mapping, and install decline/failure reporting. No new finding.
  • Rules: checked daemon lifecycle and remote transport changes against the updated rules, Windows privilege rules, dev isolation, Go conventions/testing, resilience and documentation. No new code/rule finding.
  • Validation: go test -race passed for cmd/quil, internal/transport, internal/remoteinstall, and internal/winjob: 441 top-level tests and 305 subtests passed, three tests skipped. Linux vet and Windows/amd64 cross-vet (-unsafeptr=false) passed for those packages. Context-document size check and all GitHub checks pass.

Native Windows execution was not repeated on this Linux reviewer. The documentation now reports live PowerShell and standard-account measurements; the PR description still says PowerShell setup is refused and its old test checklist has not been updated to match.

The existing limitation of lowered-in-place spawning under an unseen outer kill-on-close job remains. Main checkout and production state were not modified.

Findings: none. Reviewed head 5ea39f9595 as @code-spire-beaver.

@code-spire-beaver code-spire-beaver left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ APPROVED

Reviewed 5ea39f959504901be52aa33610ebdd4353e96d17 against the previously approved 37dcb3fa2de53b7ec8ec3444205adb2b051685e4 (21 changed files). No new blocking findings; the previous three fixes remain intact.

  • Security: checked the new Windows process query's unknown-identity handling, PID/name guards, bounded and sanitized version replies, and PowerShell command quoting. No new finding. Semgrep unavailable; GitHub CodeQL passes.
  • Correctness: traced WaitExited/reap/Close ordering, bounded waits for natural SSH exit, foreground and background classification, release and unstamped handshakes, process liveness mapping, and install decline/failure reporting. No new finding.
  • Rules: checked daemon lifecycle and remote transport changes against the updated rules, Windows privilege rules, dev isolation, Go conventions/testing, resilience and documentation. No new code/rule finding.
  • Validation: go test -race passed for cmd/quil, internal/transport, internal/remoteinstall, and internal/winjob: 441 top-level tests and 305 subtests passed, three tests skipped. Linux vet and Windows/amd64 cross-vet (-unsafeptr=false) passed for those packages. Context-document size check and all GitHub checks pass.

Native Windows execution was not repeated on this Linux reviewer. The documentation now reports live PowerShell and standard-account measurements; the PR description still says PowerShell setup is refused and its old test checklist has not been updated to match.

The existing limitation of lowered-in-place spawning under an unseen outer kill-on-close job remains. Main checkout and production state were not modified.

Agent findings: 3 resolved, 0 open. CI: 9/9 checks passing. Head 5ea39f9595, reviewed by @code-spire-beaver.

@artyomsv
artyomsv merged commit 057a48b into master Sep 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review: approved Agent review verdict: approved

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants