When the Coder Remote plugin handles a request to open a workspace, it invokes Microsoft's Remote - SSH extension using the following URI structure:
vscode://ssh-remote+<hostname><path>
The ssh-remote scheme is registered by Microsoft's Remote - SSH extension and
indicates that it should connect to the provided host name using SSH.
The host name takes the format
coder-<editor>.<domain>--<username>--<workspace>, where <editor> comes from
that product's URI scheme, such as vscode, cursor, or devin/windsurf. The CLI is
invoked through SSH's ProxyCommand with this prefix so it can route SSH to the
right workspace. A legacy coder-vscode authority opened in another editor is
reopened once with that editor's prefix; legacy recent-folder entries remain
compatible when opening the same workspace.
The Coder Remote extension also registers for the
onResolveRemoteAuthority:ssh-remote extension activation
event to hook
into this process, running before the Remote - SSH extension actually connects.
On activation of this event, we check whether the remote authority belongs to the current editor, and if so we delay activation to:
- Parse the host name to get the domain, username, and workspace.
- Ensure the workspace is running.
- Download the matching server binary to the client.
- Configure the binary with the URL and token, asking the user for them if they are missing. Each domain gets its own config directory.
- Write an entry for
coder-<editor>.<domain>--*to a per-editor, per-deployment file in a data directory shared by every editor, such as~/.local/share/coder.coder-remote/ssh/cursor--dev.coder.com.conf. - Keep a shared
Includeblock at the top of the user's SSH config that globs the whole directory. Every editor writes the identical block, so concurrent writers converge on the same content. TheCODER INCLUDE <id>marker convention lets other Coder integrations recognize the block, since Coder-managed includes route disjoint hosts and are order-independent.
# --- START CODER INCLUDE CODER-REMOTE ---
# Managed by the Coder extension for VS Code and its forks.
# Moves back to the top on connect; override options via coder.sshConfig.
Include "~/.local/share/coder.coder-remote/ssh/*.conf"
# --- END CODER INCLUDE CODER-REMOTE ---
Each generated file contains only its own editor's host entries for one deployment:
Host coder-cursor.dev.coder.com--*
ProxyCommand "/tmp/coder" --global-config "/home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/dev.coder.com" ssh --stdio --network-info-dir "/home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/net" --ssh-host-prefix coder-cursor.dev.coder.com-- %h
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
Which main file gains the include depends on the Remote - SSH extension.
Microsoft's and Cursor's pass remote.SSH.configFile to ssh with -F, and
VSCodium's parses the file itself instead of running ssh, so all three connect
through it. Antigravity and Windsurf/Devin renamed the setting but spawn ssh
without -F, so ssh reads ~/.ssh/config regardless; we ignore the renamed
setting there rather than add the include where the connection never looks.
If any step fails, we show an error message. Once the error message is closed we close the remote so the Remote - SSH connection does not continue to connection. Otherwise, we yield, which lets the Remote - SSH continue.
VS Code SSH uses the ssh -D <port> flag to start a SOCKS server on the
specified port. This port is printed to the Remote - SSH log file in the VS
Code Output panel in the format -> socksPort <port> ->. We use this port to
find the SSH process ID that is being used by the remote session.
The ssh subcommand on the coder binary periodically flushes its network
information to network-info-dir + "/" + process.ppid. SSH executes
ProxyCommand, which means the process.ppid will always be the matching SSH
command.
Coder Remote periodically reads the network-info-dir + "/" + matchingSSHPID
file to display network information.
Windows files inherit their permissions from the directory they live in, so a
config the extension generates under %APPDATA%\coder.coder-remote\ssh can end
up readable by other accounts. OpenSSH rejects such a file with "Bad owner or
permissions" and skips the whole Include, which blocks every Coder host, not
just the one it came from.
Before each managed write, src/remote/windowsAcl.ts locks the directory down
and lets its files inherit from it:
| Step | Command |
|---|---|
| Read the current user's SID | whoami.exe /user /fo csv /nh |
| Clear the directory's own grants | icacls.exe <dir> /reset |
| Grant that user, SYSTEM, and Administrators full control | icacls.exe <dir> /inheritance:r /grant:r <trustee> |
Clear each *.conf file so it inherits the directory |
icacls.exe <file> /reset |
Resetting every *.conf file, not only the one being written, also repairs
files left behind by other deployments and editors.
Worth knowing:
- Like VS Code, the code checks exit codes but never reads ACLs back. It needs no script, native module, ownership change, or elevation, and it leaves the user's own SSH config alone.
- Links and non-files are rejected before the repair, because inheritable
grants reach children even without
/T. That stops mistakes, not an attacker racing the check. - The repair is not atomic: a failure after
/resetcan leave the directory with its parent's grants.
windowsAcl.native.test.ts drives the real icacls.exe, whoami.exe, and
OpenSSH. Run it unelevated as well as in CI to catch privilege assumptions.
The extension provides several sidebar panels:
- My Workspaces / All Workspaces - tree views showing workspaces with status indicators, quick actions, and search.
- Coder Tasks - a React webview for creating, monitoring, and managing AI agent tasks with real-time log streaming.
There are also notifications for outdated workspace templates and for workspaces that are close to shutting down.
The extension ships rich UI panels as webviews built with Vite, organized as a
pnpm workspace in packages/. The canonical guide for building one covers
the IPC contract, exhaustiveness rules, the "no dropped events" guarantee,
and a new-panel checklist. It lives next to the code:
packages/webview-shared/README.md
Existing webviews as references:
packages/tasks+src/webviews/tasks/: React (usesuseIpc).packages/speedtest+src/webviews/speedtest/: vanilla TS (usesonNotification/sendCommand).
pnpm watch # Rebuild extension and webviews on changesPress F5 to launch the Extension Development Host. Use "Developer: Reload Webviews" to see webview changes.
Local telemetry instrumentation follows a shared style: how spans are threaded, how events and properties are named, and properties vs measurements. Read this before adding new telemetry so it stays consistent across the codebase. It lives next to the code:
src/instrumentation/CONVENTIONS.md
The extension logs to the "Coder" output channel, a LogOutputChannel that gates
messages by the level chosen in its gear menu. To help Support diagnose
connection failures without asking users to reproduce with debug logging enabled,
a BufferingLogger (src/logging/logBuffer.ts)
wraps the channel and keeps a bounded, in-memory ring of the entries that sit
below the current level, which the channel would otherwise drop.
When a WebSocket fails terminally, a remote session closes after a failed or
canceled open, or you collect a support bundle, the extension replays the ring
into the channel. The first physical line of each replayed entry carries a
[buffered] marker with its original timestamp and level, and any continuation
lines carry the bare marker.
Capture is best-effort: the channel writes on its own schedule, so a bundle may
miss the most recent lines, but a later failure flush still replays them.
Transient reconnects and intentional teardown never flush, and neither does a
handshake 401 (a 401 explains itself, and with OAuth a refresh reconnects the
same socket). Nothing is recorded or flushed while the channel is at Off.
The buffer size is set by coder.connectionLogBuffer.size (number of entries;
0 disables it) and lives in memory, so a hard kill or out-of-memory event
loses it. Extension SSH debug logs that pass through the shared logger are
buffered; the CLI ProxyCommand file logs under coder.proxyLogDirectory are
not, since support bundles already collect them from disk.
There are a few ways you can test the "Open in VS Code" flow:
- Use the "VS Code Desktop" button from a Coder dashboard.
- Manually open the link with
Developer: Open URLfrom inside VS Code. - Use
code --open-urlon the command line.
The link format is vscode://coder.coder-remote/open?${query}. For example:
code --open-url 'vscode://coder.coder-remote/open?url=dev.coder.com&owner=my-username&workspace=my-ws&agent=my-agent'The project uses Vitest with separate test configurations for extension and webview code:
pnpm test:extension # Extension tests (runs in Electron)
pnpm test:webview # Webview tests (runs in Electron with jsdom)
pnpm test # Both extension and webview tests (CI mode)Test files are organized by type:
test/
├── unit/ # Extension unit tests
├── webview/ # Webview unit tests (jsdom environment)
├── integration/ # Integration tests (real VS Code)
└── mocks/ # Shared test mocks
Integration tests run inside a real VS Code instance:
pnpm test:integrationLimitations:
- Must use Mocha (VS Code test runner requirement), not Vitest
- Cannot run while another VS Code instance is open (they share state)
- Requires closing VS Code or running in a clean environment
- Test files in
test/integration/are compiled toout/before running
Important
Reasoning about networking gets really wonky trying to develop this extension from a coder workspace. We currently recommend cloning the repo locally
To view your local changes to the extension within VS Code:
- Run
pnpm package. This creates a file named coder-remote-VERSION.vsix in your repo directory. - In the "Extensions" tab of VS Code, open the kebab menu and select "Install from VSIX...". You can also run "Extensions: Install from VSIX..." from the command palette. Select the VSIX file created by
pnpm package.
Alternatively:
-
Run
pnpm watchin the background. -
OPTIONAL: Compile the
coderbinary and place it in the equivalent ofos.tmpdir() + "/coder". If this is missing, it will download the binary from the Coder deployment, as it normally would. Reading from/tmp/coderis only done in development mode.On Linux or Mac:
# Inside https://github.com/coder/coder $ go build -o /tmp/coder ./cmd/coder -
Press
F5or navigate to the "Run and Debug" tab of VS Code and click "Run Extension". -
If your change is something users ought to be aware of, add an entry in the changelog.
Linting runs in two stages: Oxlint handles all JS/TS/TSX
rules, and a residual ESLint pass covers what Oxlint cannot do yet. When an
Oxlint equivalent lands, remove the corresponding entry from
eslint.config.mjs and this list.
| ESLint rule/plugin | Why Oxlint can't do it |
|---|---|
import-x/order |
Oxlint has no import ordering rule; Oxfmt's sortImports reorders differently |
@eslint/markdown (markdown/no-missing-label-refs, etc.) |
Oxlint only lints source extensions; Markdown needs processors |
eslint-plugin-package-json (58 rules) |
Oxlint only lints source extensions |
eslint-plugin-oxlint reads .oxlintrc.jsonc and disables every rule Oxlint
already covers, so the two stages never overlap.
TypeScript 7 has no programmatic API yet, so typescript-eslint cannot load
against it. The two run side-by-side, following the
official guidance:
typescriptis aliased to@typescript/typescript6: the 6.0 API thattypescript-eslintconsumes. Its binary istsc6.@typescript/nativeis aliased totypescript@^7: thetscbinary used bypnpm typecheckand editors.
When TypeScript 7 ships an API, drop @typescript/native and point typescript
back at a single version.
This extension targets the Node.js version bundled with VS Code's Electron:
| VS Code | Electron | Node.js | Status |
|---|---|---|---|
| 1.105 | 37 | 22 | Minimum supported |
| stable | latest | varies | Also tested in CI |
When updating the minimum Node.js version, update these files:
- package.json:
engines.vscode,engines.node,@types/node,@tsconfig/nodeXX - tsconfig.json:
extends(the@tsconfig/nodeXXpackage),lib(match base ESNext version) - esbuild.mjs:
target - .github/workflows/ci.yaml:
electron-versionandvscode-versionmatrices
Some dependencies are not directly used in the source but are required anyway.
bufferutilandutf-8-validateare peer dependencies ofws. Their source builds are off, so Windows on ARM64 uses their JavaScript fallback.ua-parser-jsanddayjsare used by the Coder API client.
The coder client is vendored from coder/coder. Pin it to a release tag in
pnpm-workspace.yaml (e.g. coder: github:coder/coder#v2.33.1), not #main.
A tag gives reproducible builds and is not re-fetched on every pnpm install,
unlike #main which can drift between installs. To update, bump the tag and
run pnpm install (or pnpm update coder).
After running pnpm update, always run pnpm dedupe to consolidate duplicate
package versions across the workspace. Without this, workspace packages can
resolve to different versions of the same dependency, causing issues like broken
React context propagation when two copies of a library are loaded.
For both stable and pre-releases:
- Check that the changelog lists all the important changes.
- Update the package.json version and add a version heading to the changelog.
- Push a tag
v<version>(e.g.v1.15.0) from themainbranch. The release pipeline will only run for tags onmain. - The pipeline builds, publishes to the VS Code Marketplace and Open VSX, and creates a draft GitHub release.
- Update the draft release with the changelog contents and publish it.
- Push a tag
v<version>-pre(e.g.v1.15.0-pre) from any branch. The version in the tag must match package.json (the-presuffix is stripped during validation). Pre-release tags are not restricted tomain. - The pipeline builds with
--pre-release, publishes to both marketplaces, and creates a draft pre-release on GitHub.