From 12964375c0dc44999f545f955792028d8b6df3de Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Mon, 24 Aug 2026 14:21:00 +0200 Subject: [PATCH] feat: open the active workspace in VS Code, local or over SSH (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project row and the file viewer now hand the workspace to a desktop editor. A remote SSH project opens through VS Code Remote-SSH already at the right path, instead of reconnecting and hunting for the folder by hand. Two ways exist to do this from a web app, and the studio needs both, because it is not always running on the machine the browser is on: * the `code` CLI, spawned by the server. Detectable — it either resolves on $PATH or it does not — but it opens a window on the SERVER host, which is wrong inside Docker, over a tunnel, or from a phone. * a `vscode://` deep link, followed by the browser. Lands on the machine with the screen, by definition. Nothing about it is detectable: no browser reports whether a protocol handler exists or ran. `POST /api/editor/open` prefers the first and falls back to the second, so a workstation opens its own window and a container hands the browser a link — one rule, no `process.platform` test, correct in every deployment. The SPA states the condition every time it takes the deep-link path rather than claiming an error it cannot observe. `editor-links.js` is the pure half. The two URI shapes are NOT the same string and conflating them silently breaks one path: browser : vscode://vscode-remote/ssh-remote+user@host/srv/app CLI : --folder-uri vscode-remote://ssh-remote+user@host/srv/app Notes on the guards: - $PATH is walked in-process, never through `which`/`where`. A subprocess would need a shell on Windows, and the launched value is a user-chosen filesystem path — the BatBadBut re-parse delegate-terminal.js already had to work around. `_EXEC_EXTS` excludes `.CMD` deliberately: Node refuses to spawn one without `shell: true`, so accepting it would turn every Windows launch into a throw instead of the deep-link fallback that works there. - Authorisation reuses `resolveFilesWorkdir()` — "you may open in an editor whatever you may browse" — which also supplies the default-WORKDIR fallback and `isRemote` from the PROJECT RECORD. Inferring remoteness from the path would hand a local POSIX-looking workdir to Remote-SSH. - A `~/project` remote workdir is refused by name: a URI has no shell, so Remote-SSH would look for a directory literally called `~`. - Per-file open is local-only. The remote FOLDER link works; VS Code's URL handler opens a remote FILE uri as a folder (microsoft/vscode-remote-release#4333). - The editor is a fixed catalog (VS Code, Insiders, VSCodium, Cursor, Windsurf), not a binary-name field: the value becomes both a URI scheme and an argv[0]. Settings gets its row from `config-resolve.js`. Tests: `test/editor-links.test.js` (57) pins the URI shapes literally plus the wiring; `test/editor-open-api.test.js` (22) boots a real server with $PATH pointed at an EMPTY directory, which makes the `opened:'client'` fallback deterministic and guarantees the suite never launches an editor window on a developer's desktop. Closes #63 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 ++++++ CLAUDE.md | 49 ++++++++- README.md | 3 + config-resolve.js | 9 ++ editor-links.js | 190 ++++++++++++++++++++++++++++++++ package.json | 4 +- public/index.html | 100 ++++++++++++++++- server.js | 87 ++++++++++++++- test/editor-links.test.js | 208 +++++++++++++++++++++++++++++++++++ test/editor-open-api.test.js | 166 ++++++++++++++++++++++++++++ 10 files changed, 840 insertions(+), 7 deletions(-) create mode 100644 editor-links.js create mode 100644 test/editor-links.test.js create mode 100644 test/editor-open-api.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c70e2cff..25c272ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 7.8.0 + +### Open the workspace in VS Code + +- **One button, local or remote.** A project row and the file viewer now hand the + workspace to your desktop editor. A remote SSH project opens through **VS Code + Remote-SSH**, already at the right path — no reconnecting by hand, no hunting for + the folder. + +- **It opens on the machine you are sitting at.** The studio launches the editor + itself when it can find a `code` binary on the host; when it cannot — inside + Docker, on a headless server, on Windows — it hands your browser a `vscode://` + link instead, and the desktop handler takes it from there. One rule, no + platform-sniffing, and it lands correctly whether the studio runs on your laptop + or three time zones away. + +- **Not only VS Code.** Insiders, VSCodium, Cursor and Windsurf are selectable in + Settings → *UI*. All four are VS Code forks, so the remote-SSH link means the same + thing in each; only the scheme and the binary change. + +- **A remote workspace on a non-standard port carries it.** Omitting the port would + not be the cautious choice — the connection would simply be attempted against 22 + and fail. + +- **Refusals say what is wrong.** A remote project recorded with a `~/project` + workdir cannot be expressed as a URI (Remote-SSH would look for a directory + literally named `~`), so it is refused by name rather than opening the wrong + folder. Per-file opening stays local-only on purpose: VS Code's own URL handler + opens a remote *file* link as a folder, so the button comes off rather than doing + the wrong thing — the project row still opens the remote workspace. + ## 7.7.0 Three things that were true only in one place become true everywhere: the file diff --git a/CLAUDE.md b/CLAUDE.md index 1b07f991..afe4dbb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ docker compose up -d docker compose logs -f claude-chat ``` -No linting and no build step configured. `npm test` chains 63 test files under `test/`: 18 DOM-less render/UI-logic tests (`test/render/*.test.mjs`, run through `node --test`) plus 45 plain-`node` suites in `test/` covering the overload detector, env load order, multi-agent results, terminals, bots, telegram, updates, kanban scheduling, i18n completeness, the config precedence resolver plus its secret masking, usage-limit detection, the filesystem path guard (including the SVG sandbox header and the symlink rule on the `@`-mention search endpoints) plus the tunnel-blocks-terminal rule, WS session re-subscription, the SSH remote CLI-session import, the live engine pane / interactive-prompt watchdog, the cross-project global workspace aggregation, the rule that an SSH credential never leaves the server process, the Windows command-quoting oracle, the auth token lifecycle, the multi-agent dependency scheduler (waves, plan sanitising, and the rule that a failure warning must survive dep-context truncation), the SSH stream parser's three guards, the remote non-interactive shell environment (`remote-env.test.js`, which runs the generated prelude through real `bash -lc`: it must parse, print nothing on stdout, and never end on a false test — the caller chains `&& claude …` behind it), the remote CLI-list framing parser, the bot inbox's SQL seam (`bot-inbox.test.js` pins that `from_bot AS "from"` keeps the exact key `planInboxDelivery` reads — rename one without the other and every letter is silently retired as malformed), the one-time config/.env migration onto CCS_CONFIG_PATH and the mid-task clarification delivery contract on the subscription engine (`interrupt-delivery.test.js` — pins that the tmux injection block sits BEFORE the poll loop's completion `break`, that draining does not imply delivery, that a failed paste is re-queued and warns non-terminally, and that the task runner passes the same callbacks the chat path does), the CLAUDE.md / AGENTS.md discovery rules (`agents-md.test.js`, which also pins that AGENTS.md reaches the subprocess as `--append-system-prompt` and never as `--system-prompt`), and the remote file browser's three guard layers (`remote-files.test.js` runs the generated POSIX script through a real `/bin/sh` against a temp tree that contains symlinks OUT of the project; `remote-files-api.test.js` boots a server against a fake remote via `CCS_REMOTE_EXEC_HOOK` and drives `/api/files` the way the SPA does), and the new-chat defaults chain (`chat-defaults.test.js` pins the pure resolver — the built-ins are asserted to be exactly what the SPA hardcoded before #58, and the choice lists to be exactly the toolbar's `data-v` sets and `MODEL_MAP`'s aliases; `chat-defaults-api.test.js` boots a real server in a throwaway `APP_DIR` and pins that a project writes back a SPARSE override object — a five-key snapshot passes every other assertion in that file and still breaks the feature). On the render side, `tables.test.mjs` also pins the ReDoS bound in renderMd step 3.4, `xss.test.mjs` runs 24 adversarial payloads end-to-end, and `forged-tokens.test.mjs` covers the case where user text contains the renderer's own placeholder control bytes, and `pane-font.test.mjs` pins the clamp DIRECTION of `_fitEnginePaneFont` (a wide engine pane may only shrink; a narrow split pane must be allowed to grow). `script-scope.test.mjs` pins which `