fix: write workspace SSH hosts to a shared glob include that ssh reads first - #1061
fix: write workspace SSH hosts to a shared glob include that ssh reads first#1061EhabY wants to merge 9 commits into
Conversation
5368805 to
b4db800
Compare
dc556b6 to
216491f
Compare
code-asher
left a comment
There was a problem hiding this comment.
Nice, I like how minimal the edits are to my main config now. One comment on the path for our config.
7095b82 to
53ae245
Compare
9a97ce0 to
90df8e5
Compare
1355573 to
3369b82
Compare
code-asher
left a comment
There was a problem hiding this comment.
I think this is the right direction! The main concern I have is if we should use a different file structure to account for concurrency, or just not worry about concurrency at all.
Also I have not been able to test it yet because every time I build I get a timeout for one of the dependencies. Not sure why pnpm insists on downloading every time I build...
f8a9f7f to
5b5e0a9
Compare
f1c13d9 to
b388518
Compare
b388518 to
803d31a
Compare
| useCustom: true, | ||
| detail: | ||
| "This editor now uses its own SSH hosts, but switching an unsaved multi-root workspace would drop its folders. " + | ||
| "To switch, save the workspace, then reload the window.", |
There was a problem hiding this comment.
I think we have to still do remote setup in this case rather than aborting.
Because currently in this case we skip the remote setup, which may mean the connection will fail (for example if the SSH config needs to be regenerated), and now there will be no way to save the workspace file since it requires being connected.
There was a problem hiding this comment.
Oooh good catch, I made it continue using the legacy authority which means it might fight with the VS Code one (though unlikely and this is better than breaking). This is also self-healing so the next run without an untitled workspace should migrate it!
| await sshConfig.updateInclude(sharedSshConfigDir, safeHostname); | ||
| // Our file was just written, so only other unused deployments are swept. | ||
| // Never throws, and the connection does not depend on it. | ||
| void cleanupStaleSshConfigs(sharedSshConfigDir, this.logger); |
There was a problem hiding this comment.
IMO we should not do this; IDE sessions can be long lived (leaving editors open for weeks, the only interruption being putting the machine to sleep).
If we need to clean up, we could have the plugin periodically touch the file or something like that. But I feel like the number of deployment/IDE combinations will be so small it is probably not worth the effort.
We were not previously cleaning them up either, maybe we can revisit if we end up needing this?
There was a problem hiding this comment.
Fair enough, I've removed this. Though tbh having something that deletes every month for example should be safe. Like on the next reconnect it's recreated, so the only issue is if we do not reconnect BUT the SSH itself reconnects.
| const INCLUDE_MARKERS: BlockMarkers = { | ||
| start: "# --- START CODER ---", | ||
| end: "# --- END CODER ---", | ||
| }; |
There was a problem hiding this comment.
I think we need a more unique prefix because JetBrains may end up doing something similar and having its own wildcard includes.
Maybe we can still use VSCODE? Kinda unfortunate though because it implies just the main VS Code IDE.
CODER CODE-OSS maybe? To indicate anything based off the Code OSS? CODER VSCODE FAMILY perhaps? CODER.CODER-REMOTE?
Also...if we do this with JetBrains (and maybe one day the cli) they will fight over who gets to be at the top which seems unfortunate. Still wondering if we should leave it in place if it already exists in the file.
Or...not sure how sketchy this is but we could share a directory with all the plugins and the cli. Put it in $data/coderv2/ssh for example. Or sub-directories for each plugin, and include with */*.conf.
There was a problem hiding this comment.
I've went with CODER.CODER-REMOTE since this is the extension ID so it seems like a natural fit + I added a comment explaining this.
Regarding the include fight, how about we just ignore CODER include blocks at the top? Of course, every IDE has to follow through but that's always gonna be the case. In fact, I've just changed the block to START CODER INCLUDE CODER-REMOTE, Jetbrains can do START CODER INCLUDE JETBRAINS, etc...
I have not implemented the ignore logic here though since we do not have another client yet. wdyt?
| return sshRemotePrefix.length; | ||
| } | ||
|
|
||
| for (const wrapper of [`@${sshRemotePrefix}`, `://${sshRemotePrefix}`]) { |
There was a problem hiding this comment.
Oo nice I like the idea, seems better compared to splitting on +.
Just a thought but it could maybe be more stable to pass in the host by parsing the URL via new URL(authority).host or URI.parse or something so we avoid having to sort of jankily handle @ and ://.
There was a problem hiding this comment.
I gave it a shot, but two of the three shapes we receive are not URLs: the bare ssh-remote+host form has no scheme, and in nested remotes like dev-container+abc@ssh-remote+host the @ is VS Code authority nesting, not userinfo. new URL (and URI.parse) throws on those, so we'd keep the same string handling anyway.
Where it does parse, new URL also lowercases the host, which would mangle case-sensitive usernames and workspace names (those currently are all lowercase but yeah).
3a57688 to
56a8ab6
Compare
a68532d to
0626218
Compare
SSH uses the first value it obtains for each option, so a catch-all "Host *" in the user's config beat the block we appended to the end of it, and connections aborted with "Unexpected SSH Config Option". Writing the block higher up would not be enough: it still loses to hosts pulled in by an Include above it, and a Host line moved over the options someone wrote outside any block would capture them. Write the blocks to ~/.ssh/coder/config instead and include that file from the first line of the user's config, where nothing can be parsed before it. Their config is written once to add the include, and the deployment's old block moves out of it on the next connect. The include path keeps its tilde: relative includes resolve against ~/.ssh no matter where the including file lives, and an absolute path would not survive a config synced between machines. Since placement now guarantees the options apply, the block that recomputed them and aborted the connection on a mismatch is gone. What remains of computeSshProperties reads RemoteCommand, which can only come from the user's config.
Antigravity and Windsurf/Devin renamed the setting to remote.antigravitySSH.configFile and remote.devinSSH.configFile, then spawn ssh without -F, so ssh reads ~/.ssh/config no matter what any of them say. The setting only feeds their own host tree. Honoring it, or a stale remote.SSH.configFile synced in from another editor, writes the workspace host to a file the connection never reads. Ignore it on those two and keep reading remote.SSH.configFile elsewhere: Microsoft's extension and Cursor's fork pass it to ssh with -F, and VSCodium's fork parses the file itself instead of running ssh. This drops the per-extension section map from #1060: the three extensions that do connect through the setting all read remote.SSH.
- Rename the ambiguous getSshConfigPath pair to getMainSshConfigPath and getIncludedSshConfigPath. - Move the generated-file header back to the top if the user prepended content, instead of duplicating it. - Drop the legacy marker lookup in mergeDeployment; the editor-owned file is new, so it can never contain legacy blocks. - Report unbalanced START/END marker counts accurately. - Note that mutate() only retries conflicts and blame concurrent editors in its failure message. - Rename readForConflict to read. - classifySshHost always returns a classification; foreign and undefined were handled identically everywhere.
Replace the per-editor include block with a single editor-agnostic block: Include "~/.local/share/coder.coder-remote/ssh/*.conf" Each (editor, deployment) pair owns one file in that shared directory (vscode--dev.coder.com.conf), fully regenerated on connect, so concurrent writers are single-writer per file and last-writer-wins is correct. The include line is identical no matter which editor writes it, so the user's config stops churning once it is in place and the cross-editor race on it disappears in steady state; the optimistic-retry machinery now only guards the include placement and legacy cleanup. The directory lives in the platform data dir (XDG_DATA_HOME, Application Support, APPDATA) instead of per-editor global storage so every editor emits the same include. OpenSSH resolves glob includes through glob(3) on every platform, including Win32-OpenSSH since v7.7, and a missing directory is a non-fatal no-match, verified by the real-ssh tests. Connects always rewrite the deployment file so its mtime marks the last connect, and any editor sweeps files older than a week on connect; the next connect to that deployment recreates its file.
- Derive the editor identity once in currentEditorId and use it for both authority prefixes and generated config file names, so the two encodings cannot drift and both reject an empty URI scheme. - Let pathResolver own the generated-file extension; the include glob and the stale-file sweep reuse it instead of repeating ".conf". - Drop the discarded parseRemoteAuthority call in retargetRemoteAuthority; both callers operate on already-parsed authorities, and without it isRemoteAuthorityCompatible no longer needs a try/catch. - Collapse the platform switch in getSshConfigDir to vary only the data root. - Deduplicate the "no SSH config generated" message in commands.
env.remoteAuthority is part of the resolvers API proposal, which our own vscode module is not granted, so reading it in pickSshHostname threw "CANNOT use API proposal: resolvers" and the Open Generated SSH Configuration File command failed. Read it via vscodeProposed like every other call site, and fall through to the deployment picker when the proposed API is unavailable. A lint rule now rejects plain vscode.env.remoteAuthority so the mistake cannot come back; the test doubles cannot catch it because both modules resolve to the same mock.
0626218 to
a695de2
Compare
Fixes SSH config precedence without letting VS Code-based editors overwrite one another's generated Coder hosts.
Approach
Shared SSH config directory
Each editor writes one generated file per deployment into a directory shared by all editors under the platform data dir (
$XDG_DATA_HOME,~/Library/Application Support,%APPDATA%):The user's SSH config gets a single editor-agnostic block, placed first because OpenSSH uses the first value it finds:
On connect, the extension:
*.conffiles (from any editor) not connected to for a week; the next connect recreates them.Correctness without locking: each generated file has a single writer, and concurrent writers of the same file produce equivalent content, so last-writer-wins is correct. The Include block is byte-identical from every editor, so once placed the user's config stops changing and concurrent editors converge instead of racing. Host patterns are namespaced per editor and deployment, so glob ordering between the files is irrelevant.
Other deployments' direct blocks and the deployment-unaware historical block are preserved; hosts matching the historical
coder-vscode--*pattern parse as foreign, so their windows keep connecting through the preserved block instead of failing setup.Since first-line placement now guarantees the managed options apply, the post-write check that recomputed them and aborted the connection with "Unexpected SSH Config Option" on a mismatch (typically from a catch-all
Host *) is removed. Validation of deployment-sent options (the deny list) is unchanged.Generated files carry a do-not-edit header, since they are rewritten on every connection. A new Coder: Open Generated SSH Configuration File command opens them read-only: a connected window opens its own deployment's file, a local window offers a deployment picker.
Include paths inside
$HOMEare emitted as~/..., so quirks in the home path (spaces,%, glob characters) never reach the argument ssh reads. Paths outside$HOMEare quoted with Windows separators normalized and glob characters escaped; CR, LF, NUL,", and%are rejected because ssh cannot read them back (OpenSSH 9.9+ percent-expands Include arguments and exits fatally on unknown tokens).Which file gains the include
Microsoft's and Cursor's Remote-SSH pass
remote.SSH.configFileto ssh with-F, and VSCodium's fork parses that file in-process, so the setting is honored there. Antigravity and Windsurf/Devin renamed the setting but spawn ssh without-F, so ssh always reads~/.ssh/config; on those two the setting (or a stale syncedremote.SSH.configFile) is ignored rather than writing the include where the connection never looks.Editor-specific authorities
The SSH host prefix uses
vscode.env.uriSchemeunchanged —coder-vscode,coder-cursor,coder-windsurf,coder-vscode-insiders— and the generated wildcard host and CLI--ssh-host-prefixuse the same prefix.Legacy authority migration
Only the actual historical
coder-vscodeauthority is migrated in other editors; foreigncoder-*authorities are ignored rather than retargeted.Important
Migration reopens the window automatically. Connecting to a legacy authority in another editor retargets it and reopens the same folder or saved workspace once, preserving the full URI (scheme, path, query, and fragment). Empty windows reopen with the retargeted authority.
An untitled multi-root workspace cannot be reopened without dropping folders, so it keeps connecting through the old host and a modal explains how to finish the switch (save the workspace, then reload). Recent-folder entries with legacy authorities stay compatible when reopening a workspace. Plain, URI-wrapped, and nested
ssh-remote+authorities retain their surrounding wrappers when retargeted.Concurrent config changes
All writes are atomic temp-file renames with retries for transient Windows errors, following the CLI
config-sshshape: read, merge, skip when unchanged, write. No locks or optimistic retries; every editor writes an identical Include block, so concurrent updates converge, and a lost update stays shadowed by the include (first value wins) until the next connect rewrites it.Compatibility
ssh -Gagainst generated configs: a quoted glob include in a path with spaces wins over a laterHost *, escaped glob characters in the directory name resolve, two deployments and two editors are served through one glob include, and deleting the generated directory degrades gracefully instead of breaking ssh.jeanp413/open-remote-sshsource (VSCodium's Remote-SSH): it parses the config in-process viassh-config, follows top-level quoted Includes (untildify+ glob), and implements first-value-wins, on both current and years-old releases.PowerShell/openssh-portablesource and release history: every in-box Windows ssh.exe resolves Include arguments throughglob(3)(wildcard or not) since the first bundled 7.7p1, 8.1p1+ (Windows 10 2004+) also honors quoted forward-slash absolute paths, and a missing include directory is a non-fatal "matched no files".Diff size
Relative to
main(15d4c8f2):src/)test/)Validation
pnpm test: 2,374 passed, 1 skipped (includes the real-OpenSSH tests, skipped where ssh is absent)pnpm typecheckpnpm lintpnpm format:checkpnpm buildgit diff --checkImplementation plan and decisions
vscode.env.uriSchemeunchanged and require a non-empty editor ID where it is consumed.START|END CODER <hostname>markers in generated files and one parameterlessSTART|END CODERinclude block in the user's config. LegacyCODER VSCODE <hostname>markers are still matched for user-config cleanup.coder-vscode, preserving wrappers and complete URI/workspace state; parse deployment-unawarecoder-vscode--*hosts as foreign.remote.SSH.configFilewhere ssh (or an in-process parser) does.typeof-based filesystem interface; tests mocknode:fs/promiseswith memfs instead of hand-rolled fixtures.Generated by Coder Agents for @EhabY.