Local code-server mode on Windows - #30
Open
denusklo wants to merge 6 commits into
Open
Conversation
None of this makes tode run there yet — opening anything still needs a code-server, and none is published for windows. What it does is stop the pieces underneath from being wrong. Nothing on windows can spawn a shell script, so rather than writing one the browser is started directly and told through its environment what the script would have exported. Everything that starts the browser now goes through one helper, which is also the only place that knows this. There is no windows build of terminal-browser to download, so instead of asking for one and unpacking whatever comes back, it says so and names the setting that points at a build you already have. A tree that is already here still works. The rest is right everywhere, not only there: copying a tree is something node does without borrowing cp, a home directory is something it can find without reading HOME, and a uri path is separated by forward slashes even when the file it names was not. Building the pages set an environment variable the way one shell spells it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWTMgPSikQHHeSjT6wqxqT
The two scripts that start a session on the far side call a third to make sure tode is installed there. They called it by name, which asks for an executable bit that a bundle written on windows cannot have. Asking sh to read it needs no bit and reads the same everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWTMgPSikQHHeSjT6wqxqT
The release's bin/code-server is a shell script, and its whole job is to exec the bundled node against the release root. Windows can spawn neither that nor a .cmd without a shell, and a shell is the last thing to want around paths a user chose, so windows does that job itself: the same node, the same root, no launcher in between. This is the shape the browser already takes on windows, where electron is started directly rather than through a script that cannot be written. So resolving code-server now yields a command rather than a path, and the four places that ran it spread its arguments: the boot spawn, the version probe beside it, and the two extension calls. The version probe is worth naming because it is the one that hides — it runs the same binary with --version and would have gone on answering "unknown" forever rather than failing. Nothing changes off windows, where the command is the launcher and the arguments are empty. TODE_CODE_SERVER may now name either the launcher or the release around it. On windows the launcher is not runnable, so naming the directory is the thing a person will reach for, and the root is what is actually wanted in both cases. Proven against the windows build of code-server 4.132.0, under electron as node: the command resolves to lib\node.exe with the release root as its argument from either spelling of the override, runs, and reports 4.132.0 ... with Code 1.132.0; and ensureServer brings the server and the injector up, with the injector serving the workbench as tode rather than as code-server. Typecheck and build clean. The test suite fails three tests on windows, all of them listen EACCES on a unix socket path; the same suite on the commit before this one fails those three and a fourth, so this is the pre-existing shape of unix sockets on windows and not something added here.
Opening a folder showed "Unable to resolve resource C:%5Cbin%5Czenbu" — an encoded backslash, from the one line the floor commit did not reach. workbenchUrl normalised the file half of the url and passed the folder straight through, so windows sent the workbench a path it reads as a scheme: the explorer showed a folder called \bin\zenbu, having taken C: for one. Which spelling the workbench actually wants was read rather than assumed, because the title lies about it. With a bare drive, C:/bin/zenbu, the title says "zenbu" and looks right while the explorer holds one empty node with an error marker. Only /C:/bin/zenbu — the same shape uriPath already produced for files — lists the tree. So the fix is uriPath on both halves, and the symmetry that looked likely is also true. The same bug had a second home. The bridge builds a workspace uri out of a path handed to it over the window socket, and two of its three branches put that path inside a uri rather than through Uri.file, so folders sent to a running window — tode -a, tode -r — would have arrived with backslashes in a uri path. Fixed there too, with the normalisation kept local to the function: that file is serialised whole into the generated extension and cannot reach anything it did not bring with it. Checked against the generated source, which carries the helper and no require. Off windows every one of these is the identity it was before. Verified against the windows build of code-server: workbenchUrl now emits ?folder=/C:/bin/zenbu from C:\bin\zenbu, and loading exactly that url opens the tree, with git decorations, rather than an unresolvable resource.
Starting tode put a second, empty terminal window beside the real one. It was code-server's: the release's node.exe is a console program, and a console program started detached by a parent that owns a window is given a console of its own. Nothing was ever printed into it, because the output goes to a log, so it sat there empty. windowsHide is the obvious answer and does not work. Measured: detached plus windowsHide still produces the window, because the two flags are not combined the way the name suggests. Dropping detached does hide it, and also kills the server the moment tode exits, which is the thing detached is there to prevent. So neither half of that pair can move. What changes instead is which node runs the release. tode is already running under one -- electron, as node -- and it is a gui program, so it is given no console whatever detached does. It is also node 24.18.1 against the release's own 24.18.0, so this is not a downgrade, and the injector next door has always been started exactly this way. That covers all four places the command is used at once, rather than hiding a window at each of them, because none of them can now start a console program at all. Verified against the windows build: spawned through the release's node, one console window appears; spawned through this, none, with the same detached options and the same enumerator counting. The server serves, and its terminal still opens and round-trips a command -- which is the check that mattered, since electron and node do not share a native module abi and the pty host is native. Nothing changes off windows, where the launcher is still the launcher.
|
@denusklo is attempting to deploy a commit to the zenbu-labs Team on Vercel. A member of the Team first needs to authorize it. |
denusklo
marked this pull request as ready for review
September 4, 2026 18:27
A tode window listens so that `tode <file>` typed inside it lands in that
window rather than opening another. It has never done that on windows. The
bridge builds a path under the ipc directory and calls listen on it, and node
on windows will not listen on a path -- a listening endpoint there is a named
pipe. The line above the listen is `server.on("error", () => {})`, so the
failure said nothing, TODE_IPC was never exported into the window's terminals,
and every `tode <file>` opened a new window as if that were the design.
The evidence it has always been dark is the ipc directory itself: mkdirSync
runs before the listen, so `~/.local/state/tode/ipc` exists on this machine and
is empty. Every window ever opened here created it and left nothing in it.
Three things had to move together.
Windows listens on a pipe, and the file in the ipc directory holds its name,
the way herdr's own rendezvous file holds the address of its socket. Everywhere
else the file is still the socket, so listing the directory finds windows on
both and only the resolution step is new. That resolution is endpointOf: a file
that is a socket is the address, and a file that is not says what the address
is.
runningWindow asked statSync whether the path was a socket. A named pipe leaves
nothing on disk -- stat on one succeeds and isSocket is false -- so that test
could not survive the move. It now asks by connecting, which is also the only
test that was ever true: a socket file outlives the window that made it, so its
presence never meant anyone was listening.
And the swallowed error now says which address it could not have and what that
costs, because a dead feature that looks alive is the expensive kind.
117 tests pass on windows where 113 did. The four that were failing were not
testing the wrong thing; they were failing for the same reason production was.
The suite exercises the real bridge in a sandbox and waits for TODE_IPC, so
what is proven here is the production listen, not a mock of it. Nothing changes
off windows except runningWindow becoming honest, which the test for it now
says out loud.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Windows support for tode, complete: the platform floor, ssh mode, local code-server mode, and window IPC. Six commits, paired with zenbu-labs/terminal-browser#96. Supersedes #29, whose two commits are the first two here. Addresses #25.
The floor (commits 1-2). The browser is spawned directly with the environment the generated sh launcher used to export, through one spawnRuntime() helper. Windows cannot spawn a shell script, and node refuses .cmd without shell:true since the 2024 argument-injection fix. The runtime resolver stops before downloading on windows (there is no windows release to fetch) and names TODE_TERMINAL_BROWSER_BIN instead. fs.cpSync replaces cp, os.homedir() replaces reading HOME, workbenchUrl produces well-formed vscode-remote URIs for windows paths, and the ssh bundle's scripts call
sh ./ensureso a bundle packed without an exec bit still runs on the remote.Local mode (commits 3-5). code-server publishes no windows release, so we built one: a package-windows CI job against coder's v4.132.0 tag produces a working artifact. That work is upstreamed to coder as five PRs, of which two are merged (coder/code-server#7982, #7983) and three open (#7984, #7985, #7987 with the script fixes in #7986). tode runs it as its bundled runtime against the release root:
lib\node.exe <root>, no launcher, because the release's bin/code-server is a shell script and a .cmd needs a shell that user-controlled paths should never get. One command shape at all four execution sites; posix reduces to the identity it was. The folder and workspace paths the workbench receives are URI-normalized (a windows path in a uri renders asC:%5Cbin%5C...and fails to resolve), and the server is started under a gui-subsystem node so no console window appears beside the terminal.Window IPC (commit 6). A tode window listens so that
tode <file>typed inside it lands in that window. On windows it never did: the bridge listened on a unix-socket path, node cannot listen on a path there, and the error was swallowed, so everytode <file>opened a new window as if that were the design. Now a window listens on a named pipe and the file in the ipc directory holds its name, the way herdr's rendezvous file holds the address of its socket, so directory enumeration finds windows unchanged on both platforms. runningWindow() asks by connecting instead of statSync().isSocket(), which is also the only test that was ever true: a socket file outlives the window that made it. The four bridge tests that were red on windows failed for production's real reason and pass now.Verified. Build and typecheck green on windows; 117 tests pass. ssh mode ran end to end against a real sshd. Local mode verified interactively: workbench painted in the terminal, explorer tree resolving, integrated terminal round-tripping through conpty,
code-server <file>from that terminal opening an editor tab, andtode <file>from that terminal landing in the same window.Still open on windows, unchanged by this PR: local code-server acquisition waits on a published windows asset with a pinnable sha; until then TODE_CODE_SERVER points at an unpacked tree.