Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
fde2f6b
docs(mux): Phase 5 spec (brief + rulings) and implementation plan
benyblack Oct 8, 2026
6f82d3c
fix(mux): never block a caller on a full outbound queue
benyblack Oct 8, 2026
3b39e5a
fix(ssh): no vault password for askpass when a jump host could ask as…
benyblack Oct 8, 2026
d6919ff
fix(ssh): an extra-args proxy or a user@host hop never gets the vault
benyblack Oct 8, 2026
403de65
fix(ssh): one known-hosts store per file, written atomically
benyblack Oct 8, 2026
5b9c8b0
fix(ssh): a known-hosts store that cannot be read is never overwritten
benyblack Oct 8, 2026
fcd1120
fix(mux): install ntilde-mux under $XDG_DATA_HOME like the daemon root
benyblack Oct 8, 2026
6e516a3
fix(mux): quote a recorded ntilde-mux path instead of replacing it
benyblack Oct 8, 2026
da6a4a6
fix(mux): refuse every format and separator character in a recorded path
benyblack Oct 8, 2026
5bc3050
fix(mux): private askpass folder, clean server text in toasts, cleare…
benyblack Oct 8, 2026
5681207
ci: build ntilde-mux for the E2E when its tests change
benyblack Oct 8, 2026
8a6fd8a
fix(mux): give remote shells an agent socket link the proxy keeps cur…
benyblack Oct 8, 2026
cec9520
fix(mux): the agent link probe never blocks and accepts only this use…
benyblack Oct 8, 2026
cc734f1
perf(ssh): wait for native exec events instead of sleeping 10 ms
benyblack Oct 8, 2026
d86bd41
test(ssh): robust timing for the native exec wait tests
benyblack Oct 8, 2026
fad1d08
ci(release): sign and notarize ntilde-mux on macOS and pin its hashes…
benyblack Oct 8, 2026
70d137d
ci(release): fail on a rejected notarization; pin tests
benyblack Oct 8, 2026
343bddc
feat(mux): readScreen, and public session info and unattached input o…
benyblack Oct 8, 2026
066c531
test(ssh): the torn-write reader runs during the writes
benyblack Oct 8, 2026
e8148e5
fix(ssh): a known-hosts write that cannot complete always fails with …
benyblack Oct 8, 2026
c0bcd9e
fix(mux): readScreen replies use the snapshot budget; decode errors a…
benyblack Oct 8, 2026
2929d94
feat(agent): a window-owned source of windowless mux sessions
benyblack Oct 8, 2026
ea691cf
fix(agent): one deadline per windowless operation; act checks share t…
benyblack Oct 8, 2026
bd31ddf
feat(agent): list, read, status, capture, input and close for windowl…
benyblack Oct 8, 2026
bf5e5b2
fix(agent): coalesce windowless reads in the journal; act checks hono…
benyblack Oct 8, 2026
e245266
feat(mcp): show windowless mux sessions to agents
benyblack Oct 8, 2026
03798bd
refactor(settings): name the session persistence default; designer wi…
benyblack Oct 8, 2026
51cf42f
feat(mux): ask once whether closing the window keeps the shells running
benyblack Oct 8, 2026
4243331
fix(mux): first close never kills shared shells; Cmd+Q honours the re…
benyblack Oct 8, 2026
fccd387
feat(mux): Quit and close all shells
benyblack Oct 8, 2026
00b23d0
docs(settings): describe session persistence for a default-on reader
benyblack Oct 8, 2026
1bbee41
feat(mux): start fresh shells quietly after a reboot
benyblack Oct 8, 2026
5118887
test(settings): pin persistence off where test windows took the default
benyblack Oct 8, 2026
5756680
feat(settings): keep local shells running by default
benyblack Oct 8, 2026
196df0a
fix(mux): a real boot/logon boundary for quiet restore
benyblack Oct 8, 2026
e725df2
test(mux): designer windows can never start a daemon
benyblack Oct 8, 2026
35801a9
feat(mux): the daemon reports its build version
benyblack Oct 8, 2026
dcdfe00
feat(mux): on a Windows install the daemon runs from its own copy
benyblack Oct 8, 2026
bf7df07
fix: CI whitespace and a SonarCloud false positive (Quit and close all)
benyblack Oct 8, 2026
7807080
fix(mux): the daemon copy is identified by content; uninstall stops t…
benyblack Oct 8, 2026
f049d12
fix(mux): only our own daemon copies are ever deleted
benyblack Oct 8, 2026
260c296
ci(aot-gate): accept the daemon's shell spawn now that persistence is…
benyblack Oct 8, 2026
a659378
feat(update): keep a compatible multiplexer running across an update
benyblack Oct 8, 2026
5c43cae
fix(update): with session persistence off, updates behave as before P…
benyblack Oct 8, 2026
46c0b75
ci(aot-gate): require the full daemon path when the GUI started the d…
benyblack Oct 8, 2026
e2efc00
refactor(update): one helper each for marking shells ended, saving, t…
benyblack Oct 8, 2026
39158ad
feat(mux): offer to restart a multiplexer from a previous build
benyblack Oct 8, 2026
8a07dad
fix(mux): a multiplexer restart keeps the panes and never stops this …
benyblack Oct 8, 2026
8ff5c2f
fix(mux): hold Enter through a restart; remote panes let go too; outc…
benyblack Oct 8, 2026
e6a945c
fix(mux): a pane let go for a restart starts a shell only through Enter
benyblack Oct 8, 2026
6f51ef7
feat(update): a local update source for verification runs
benyblack Oct 8, 2026
08aff25
feat(mux): a hidden spawn-for-test verb for verification runs
benyblack Oct 8, 2026
ed9b280
test(update): an update-survival run against a sandboxed Velopack ins…
benyblack Oct 9, 2026
80c2c4d
fix(update): honour NTILDE_UPDATE_SOURCE_DIR only for the verificatio…
benyblack Oct 9, 2026
576691d
fix(test): the survival scripts own their sandbox, and macOS never se…
benyblack Oct 9, 2026
28ad7db
fix(test): resolve the .sh's temp roots with valid zsh expansions
benyblack Oct 9, 2026
570f554
fix(test): sweep only Velopack's exact state paths, and gate the macO…
benyblack Oct 9, 2026
8e4f81f
fix(test): never follow a symlink when sweeping Velopack's state
benyblack Oct 9, 2026
5ad949a
feat(mux): Attach to session lists remote hosts
benyblack Oct 9, 2026
4f236dc
fix(mux): picker connect feedback, one attach at a time, held hosts
benyblack Oct 9, 2026
89bfa53
test(mux): give the parallel-listing test room on a loaded runner
benyblack Oct 9, 2026
86c6dda
fix(mux): a shared remote tab stays shared and never kills on close
benyblack Oct 9, 2026
862efa2
fix(mux): say a detached share's shell kept running, and keep it on quit
benyblack Oct 9, 2026
d6719ee
feat(mux): ntilde mux ls --all lists remote hosts
benyblack Oct 9, 2026
e32f76d
fix(mux): ls --all skips OpenSSH jump hosts off Windows and kills cut…
benyblack Oct 9, 2026
d4c6598
fix(mux): log a failed release in ls --all instead of losing it
benyblack Oct 9, 2026
ecb0cd7
feat(mux): SFTP and remote files on native persistent tabs
benyblack Oct 9, 2026
a9cf7c4
fix(mux): a shared session keeps every window's registration; retarge…
benyblack Oct 9, 2026
59f93c4
docs(mux): one persistent sessions chapter; README, changelog, releas…
benyblack Oct 9, 2026
80fdeb0
docs(mux): state the on-by-default persistence (drop with the flip)
benyblack Oct 9, 2026
8747239
release: 0.12.0
benyblack Oct 9, 2026
de724c3
docs(mux): qualify reconnects, add R30, review fixes
benyblack Oct 9, 2026
2fd09a7
docs(mux): Phase 5 manual checklist
benyblack Oct 9, 2026
ea6168f
style(test): a local function for the held-attach release
benyblack Oct 9, 2026
8ad2418
docs(mux): checklist warns where the harness and restarts reach real …
benyblack Oct 9, 2026
0887ad2
fix(mux): pass the listing's token to Task.Run in ls --all
benyblack Oct 9, 2026
8022c2f
fix(mux): judge a remote ntilde-mux against the version installed on …
benyblack Oct 9, 2026
650fc27
fix(mcp): name the session tools a windowless id works with
benyblack Oct 9, 2026
adaaf36
fix(mux): post off-thread work through the window's own dispatcher
benyblack Oct 9, 2026
3b19cd4
fix(mux): a nonsense boot time reads as no boundary, not a crash
benyblack Oct 9, 2026
259d00c
fix(mux): "Close them" counts and ends the shells of tabs not shown yet
benyblack Oct 9, 2026
d379582
docs(manual): agent captures ride observe and are not journaled
benyblack Oct 9, 2026
990d458
test(mux): tabs not shown yet whose shells are gone close without asking
benyblack Oct 9, 2026
ec21b7f
fix(mux): a stale install record never loops a remote restart
benyblack Oct 9, 2026
869218f
fix(mux): "Close them" lists again before ending, and its hold always…
benyblack Oct 9, 2026
01ce6a5
fix(mux): "Close them" counts and ends the shells of startup tabs not…
benyblack Oct 9, 2026
c6f8efd
fix(mux): stopping the whole daemon leaves no unvisited tab's shell i…
benyblack Oct 9, 2026
3ca832f
fix(agent): a listing with windowless rows lights the window
benyblack Oct 9, 2026
3c1c2eb
fix(update): the update's question gives the real reason it closes th…
benyblack Oct 9, 2026
b98f7cb
feat(update): debug.log says why the startup gate held an update; pin…
benyblack Oct 9, 2026
041082d
docs(architecture): the update question's reason and the startup gate…
benyblack Oct 9, 2026
cbfc799
fix(mux): the quit drops unvisited tabs' shells only once they are kn…
benyblack Oct 9, 2026
1e9d258
test(mux): a null child and the quiet-lost and remote exclusions in a…
benyblack Oct 9, 2026
5c2147b
refactor(update): drop the boolean wrappers only tests called
benyblack Oct 9, 2026
d9ae916
fix(release): refuse a run that would replace a published ntilde-mux
benyblack Oct 9, 2026
131c827
fix(mux): a password the server refuses leaves the host's transfer scope
benyblack Oct 9, 2026
752e2fc
fix(ssh): make an existing askpass record folder private before writing
benyblack Oct 9, 2026
d733bdc
docs(spec): R14 - an existing askpass folder is now tightened to 0700
benyblack Oct 9, 2026
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
190 changes: 117 additions & 73 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@ jobs:
command -v "$py" >/dev/null 2>&1 || py=python
"$py" scripts/tests/build_wrapper_sdk_guard_tests.py

# release.yml writes the multiplexer protocol range this script reads from MuxProtocol.cs into
# every Velopack release's notes (Phase 5 R10); a release must fail rather than ship a guessed
# one, so the parse is tested here, against the real file, before any release depends on it.
- name: Self-test the release's multiplexer protocol range script
shell: bash
run: |
py=python3
command -v "$py" >/dev/null 2>&1 || py=python
"$py" scripts/tests/mux_protocol_range_tests.py

# release.yml refuses a run that would replace a published ntilde-mux, whose hash every installed App
# pins (Codex review of PR #511): scripts/ci/mux-release-guard.sh, tested here against a fake gh.
- name: Self-test the release's ntilde-mux rerun guard
shell: bash
run: |
py=python3
command -v "$py" >/dev/null 2>&1 || py=python
"$py" scripts/tests/mux_release_guard_tests.py

# ── Rust native ────────────────────────────────────────────────────────────
# Cache the compiled binary keyed on all Rust source files.
# On a hit the toolchain install, Swatinem, and cargo build are all skipped.
Expand Down Expand Up @@ -1542,6 +1561,14 @@ jobs:
# linux_packaging_detect, which this job deliberately mirrors.
fetch-depth: 0

- name: Self-test the AOT gate path filter
shell: bash
run: |
py=python3
command -v "$py" >/dev/null 2>&1 || py=python
"$py" scripts/tests/aot_gate_paths_tests.py
"$py" scripts/tests/aot_smoke_verdict_tests.py

# Dependency-free path filter, matching linux_packaging_detect rather than
# introducing a third-party action this repo uses nowhere.
#
Expand Down Expand Up @@ -1581,18 +1608,17 @@ jobs:
changed="$(git diff --name-only "$base"...HEAD)"
echo "Changed files vs merge base:"
echo "$changed"
# Here-string, not `echo | grep`: under pipefail a changed-file list bigger than
# the pipe buffer makes echo take SIGPIPE, the pipeline non-zero, this `if` false,
# and the gate skip itself SILENTLY. Same trap linux_packaging_detect calls out.
# scripts/mux-daemon-smoke.sh: mux_daemon_aot and mux_daemon_no_openssl run it, so an
# edit to it alone must run them.
if grep -qE '^(src/|Directory\.(Build|Packages)\.props$|global\.json$|\.github/workflows/[^/]+\.ya?ml$|scripts/mux-daemon-smoke\.sh$)' <<<"$changed"; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "Production sources or build inputs changed - running the AOT gate."
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "Nothing under src/ or the build inputs changed - skipping the AOT gate."
fi
# The path rule lives in scripts/ci/aot-gate-paths.sh (self-tested by the step above),
# so it is a directory rule with one place to read it. It reads the changed paths on
# stdin, as a here-string rather than `echo |` for the SIGPIPE reason
# linux_packaging_detect calls out, and prints run=true or run=false.
# Beyond src/ and the build inputs it covers the test directories that feed
# native_ssh_docker_e2e: that job needs the linux-x64 ntilde-mux binary this gate
# builds, and without it the remote-persistence step skips with a notice and stays
# green, so a PR changing only those tests never ran them.
result="$(bash scripts/ci/aot-gate-paths.sh <<<"$changed")"
echo "$result" >> "$GITHUB_OUTPUT"
echo "AOT gate decision: $result"

aot_gate:
name: AOT Gate (windows-latest)
Expand Down Expand Up @@ -1663,79 +1689,97 @@ jobs:
$env:NTILDE_APPDATA_ROOT = $root
$errorFile = Join-Path $root 'logs/startup_error.txt'
$debugLog = Join-Path $root 'logs/debug.log'
$muxLog = Join-Path $root 'logs/mux.log'

$exe = Join-Path $PWD 'artifacts/publish/win-x64/Ntilde.exe'
if (-not (Test-Path $exe)) { throw "The publish produced no Ntilde.exe at $exe." }

$proc = Start-Process -FilePath $exe -PassThru
Write-Output "Started pid $($proc.Id); watching for 45s."
try {
$proc = Start-Process -FilePath $exe -PassThru
Write-Output "Started pid $($proc.Id); watching for 45s."

# Poll rather than sleep-then-look, so a fast crash is reported as a crash
# instead of waiting out the full window first.
$deadline = (Get-Date).AddSeconds(45)
while ((Get-Date) -lt $deadline) {
if ($proc.HasExited -or (Test-Path $errorFile)) { break }
Start-Sleep -Seconds 2
}
# Poll rather than sleep-then-look, so a fast crash is reported as a crash
# instead of waiting out the full window first.
$deadline = (Get-Date).AddSeconds(45)
while ((Get-Date) -lt $deadline) {
if ($proc.HasExited -or (Test-Path $errorFile)) { break }
Start-Sleep -Seconds 2
}

# Diagnostics BEFORE the assertions: whatever the outcome, the log is what a
# human needs, and an assertion that throws first would take it away.
foreach ($f in @($debugLog, $errorFile)) {
if (Test-Path $f) {
Write-Output "---- $f ----"
Get-Content $f -Tail 60
# Diagnostics BEFORE the assertions: whatever the outcome, the log is what a
# human needs, and an assertion that throws first would take it away.
foreach ($f in @($debugLog, $muxLog, $errorFile)) {
if (Test-Path $f) {
Write-Output "---- $f ----"
Get-Content $f -Tail 60
}
}
}

if (Test-Path $errorFile) {
throw "The bundle failed during startup and wrote startup_error.txt (contents above). Read the exception before retrying: a trimmed-away type or a missing native is a real AOT break and reruns will not clear it. The one environmental cause to rule out is the runner being unable to host a desktop window at all, which shows up as an Avalonia/windowing exception on StartWithClassicDesktopLifetime rather than as anything about types or files."
}
if ($proc.HasExited) {
throw "The bundle exited on its own within 45s (exit code $($proc.ExitCode)) without writing startup_error.txt, so it died outside Program.cs's own try/catch - suspect the native side or the host."
}
if (-not (Test-Path $debugLog)) {
throw "The process is alive but never wrote $debugLog, so managed Main did not reach its startup logging. Suspect a failure before the log sink is wired, in the AOT host itself."
}
if (Test-Path $errorFile) {
throw "The bundle failed during startup and wrote startup_error.txt (contents above). Read the exception before retrying: a trimmed-away type or a missing native is a real AOT break and reruns will not clear it. The one environmental cause to rule out is the runner being unable to host a desktop window at all, which shows up as an Avalonia/windowing exception on StartWithClassicDesktopLifetime rather than as anything about types or files."
}
if ($proc.HasExited) {
throw "The bundle exited on its own within 45s (exit code $($proc.ExitCode)) without writing startup_error.txt, so it died outside Program.cs's own try/catch - suspect the native side or the host."
}
if (-not (Test-Path $debugLog)) {
throw "The process is alive but never wrote $debugLog, so managed Main did not reach its startup logging. Suspect a failure before the log sink is wired, in the AOT host itself."
}

# Program.cs logs three lines at startup, and this asserts on the THIRD, not the
# first. The first ("Ntilde started with args: ...") never reaches the file -
# not in CI and not in a local install either - so the obvious assertion is a
# guaranteed false failure. That is its own bug, worth fixing separately; it is not
# this gate's job to depend on it.
#
# Build: is the better anchor anyway. It proves managed Main got through its startup
# sequence, and it proves DescribeBuild resolved the executable path - the one
# remaining IL3000 in the tree is Assembly.Location in that very method, and
# `path=...Ntilde.exe` appearing here is the standing proof that
# Environment.ProcessPath carries it and the warned fallback never runs.
if (-not (Select-String -Path $debugLog -Pattern 'Build:.*path=.*Ntilde\.exe' -Quiet)) {
throw "$debugLog carries no 'Build: ... path=...Ntilde.exe' line. Either managed Main did not reach its startup logging, or DescribeBuild could not resolve the executable path - which in an AOT bundle means Environment.ProcessPath came back empty and the Assembly.Location fallback (IL3000) was reached after all."
}
# Program.cs logs three lines at startup, and this asserts on the THIRD, not the
# first. The first ("Ntilde started with args: ...") never reaches the file -
# not in CI and not in a local install either - so the obvious assertion is a
# guaranteed false failure. That is its own bug, worth fixing separately; it is not
# this gate's job to depend on it.
#
# Build: is the better anchor anyway. It proves managed Main got through its startup
# sequence, and it proves DescribeBuild resolved the executable path - the one
# remaining IL3000 in the tree is Assembly.Location in that very method, and
# `path=...Ntilde.exe` appearing here is the standing proof that
# Environment.ProcessPath carries it and the warned fallback never runs.
if (-not (Select-String -Path $debugLog -Pattern 'Build:.*path=.*Ntilde\.exe' -Quiet)) {
throw "$debugLog carries no 'Build: ... path=...Ntilde.exe' line. Either managed Main did not reach its startup logging, or DescribeBuild could not resolve the executable path - which in an AOT bundle means Environment.ProcessPath came back empty and the Assembly.Location fallback (IL3000) was reached after all."
}

# One assertion beyond "Main ran": the window and a terminal view actually came up.
# This is the half that matters for a trimming break, which typically survives startup
# and dies when the UI is constructed. Both lines are emitted on a pristine profile -
# the default pane is created and themed before anything is interactive.
#
# If a log-message reword breaks this, re-anchor it to whatever the renderer now says
# on first paint. Do not delete the check: without it this step only proves the
# process launched, which the palette bug did too.
#
# `Spawned ... pid=<n>` rather than the `Spawning` line this step first shipped with:
# RustPtySession logs `Spawning` BEFORE calling pty_spawn, so a PTY that fails to
# spawn writes it anyway and then throws into TerminalPane.InitializeSessionCore's
# catch, which shows an error banner and leaves the process alive and themed - all
# four assertions green over a terminal that cannot open a shell. `Spawned` is
# emitted after the IsInvalid check and carries the child pid, so it is only
# reachable with a real session behind it. (Codex P1 on #445.)
foreach ($pattern in @('\[TerminalView\] Theme applied', '\[RustPtySession\] Spawned .*pid=\d+')) {
if (-not (Select-String -Path $debugLog -Pattern $pattern -Quiet)) {
throw "$debugLog has no line matching '$pattern', so the bundle started but its UI never finished coming up. A trimmed-away type reached only while building the terminal view looks exactly like this."
# The verdict lives in scripts/ci/aot-smoke-verdict.ps1 (self-tested by
# scripts/tests/aot_smoke_verdict_tests.py). The smoke runs on the real default
# (KeepOnClose), so the shell is spawned by the multiplexer daemon (`Ntilde.exe mux
# serve`), not the GUI, and this also proves the AOT daemon path. When the GUI started
# the daemon, the daemon must have served AND spawned a shell (mux.log): a GUI-side
# `Spawned` line alone would be the local fallback hiding a broken daemon. The GUI-only
# line is accepted only when no daemon was started (persistence off).
# `Spawned ... pid=<n>` rather than `Spawning`: Spawning is logged BEFORE pty_spawn
# (Codex P1 on #445). If a log reword breaks this, re-anchor it; do not delete it.
& (Join-Path $PWD 'scripts/ci/aot-smoke-verdict.ps1') -DebugLog $debugLog -MuxLog $muxLog

Write-Output "Bundle started from a pristine profile, brought up its UI, and stayed up."
}
finally {
# The daemon outlives the GUI process and must never be left running: stop it by its
# own verb first (-Wait: the exe is a WinExe, so a bare call returns at once), then by
# command line (it runs from a staged copy under the profile root, or from the publish
# directory). Nothing here may fail the step.
try { Start-Process -FilePath $exe -ArgumentList 'mux', 'kill-server' -Wait -WindowStyle Hidden } catch { Write-Output "kill-server failed: $_" }
if ($proc) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }
Start-Sleep -Seconds 1
try {
$publishDir = Split-Path $exe -Parent
Get-CimInstance Win32_Process -Filter "Name = 'Ntilde.exe'" |
Where-Object {
$_.CommandLine -match 'mux\s+serve' -and $_.ExecutablePath -and
($_.ExecutablePath.StartsWith($publishDir, [StringComparison]::OrdinalIgnoreCase) -or
$_.ExecutablePath.StartsWith($root, [StringComparison]::OrdinalIgnoreCase))
} |
ForEach-Object {
Write-Output "Stopping leftover daemon pid $($_.ProcessId)"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
catch { Write-Output "daemon sweep failed: $_" }
}

Write-Output "Bundle started from a pristine profile, brought up its UI, and stayed up."
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
# The runner's pwsh wrapper ends the step with `exit $LASTEXITCODE`; kill-server's code
# must not leak into it.
$global:LASTEXITCODE = 0
exit 0

# ntilde.com (docs/superpowers/specs/2026-10-05-ntilde-mux-phase4.md §10.2, §11): the
# console launcher release.yml ships beside Ntilde.exe, so a prompt waits for
Expand Down
Loading
Loading