fix: render the VSCode button on self-hosted (local) backends - #16106
fix: render the VSCode button on self-hosted (local) backends#16106harish-chandramowli wants to merge 15 commits into
Conversation
There was a problem hiding this comment.
🟡 Acceptable
The direction is useful: removing a cloud-only presentation gate is reasonable once the local deployment advertises and serves the editor consistently. The remaining work can reuse machinery that is already present in this repository.
[CRITICAL ISSUES]
-
[
scripts/dev-with-automation.mjs,docker/entrypoint.sh] Complete the stock/vscoderoute before exposing the control. The current E2E command proves the desired topology, but only by manually supplyingOH_VSCODE_BASE_PATH=/vscodeandINGRESS_ROUTES. A typical single-origin deployment looks like this:browser: https://host/vscode/* -> ingress prefix route /vscode (path preserved; HTTP and WebSocket) -> http://127.0.0.1:<vscode-port>/vscode/*The prefix should be preserved, not stripped, because OpenVSCode is launched with
--server-base-path /vscodeand generates its HTTP and WebSocket URLs beneath that path. The existing proxy stack already does the hard parts:createRouter()chooses the longest matching prefix,httpxyforwards the original request path, and bothingress.mjsandstatic-server.mjshandle HTTP plus WebSocket upgrades. This should therefore be configuration, not a new proxy implementation:- Define one stable base path (for example
/vscode) alongside the existing centralized launcher defaults. - Add
[basePath, http://localhost:${config.vscodePort}]togetLocalServiceRoutes(config)when agent-server is launched. That covers the outer npm ingress and the static-server route list built from the same function. - Pass the same value as
OH_VSCODE_BASE_PATHin the agent-server environment;OH_VSCODE_PORTis already populated bybuildAgentServerEnv(). - In
docker/entrypoint.sh, set/export the corresponding VSCode port and base path, and add the same--routeto both normal and public-modestatic-server.mjsinvocations.
With those values aligned,
getVSCodeUrl({ baseUrl: window.location.origin })can advertisehttps://host/vscode/?..., and that URL reaches the service without exposing a second public port. Without this wiring, the normal launcher still returns the Canvas origin and the button does not reach the editor. Also hold the UI change until software-agent-sdk#4222 is included in an agent-server release and the centralizedconfig/defaults.jsonpin is advanced through the release workflow; the current1.37.0pin does not contain that fix. - Define one stable base path (for example
[IMPROVEMENT OPPORTUNITIES]
-
[
src/hooks/query/use-unified-vscode-url.ts, lines 62-65 and 119-124] Use the existing status API instead of treating every error as “unavailable.”isUnavailable = isError || ...makes a disabled editor indistinguishable from authentication, connectivity, proxy, or server failures, andmeta.disableToastthen suppresses all of them.@openhands/typescript-client@1.34.0already exposesVSCodeClient.getStatus()for/api/vscode/status, returning{ enabled, running, message? }.A simpler state flow would be:
- After the runtime is ready, query
getStatus()using the samegetAgentServerClientOptions({ conversationUrl, sessionApiKey })overrides. - Hide the control only for an explicit terminal capability state (
enabled === false, or the agreed policy forrunning === false). - Enable the URL query only when status says the editor is available.
- Leave transport/auth/server failures as query errors so normal retry and error reporting still apply.
This removes the blanket toast suppression and the error-derived
isUnavailablespecial case, detects a configured editor whose process failed to start, and avoids repeatedly running the primary URL request plus its fallback before concluding the editor is disabled. - After the runtime is ready, query
[TESTING GAPS]
- [Launcher tests] Add focused coverage that the npm and Docker stock configurations register
/vscode, preserve the prefix, forward WebSocket upgrades, and pass matching port/base-path values to agent-server. - [
__tests__/hooks/use-unified-vscode-url.test.tsx] Replace the mocked-503-as-capability case with explicit status cases: disabled hides the button, running exposes it, and a transport/auth failure remains observable as an error. - [End-to-end] Keep the existing workbench assertion, but run it through an unmodified npm or Docker launcher without manual
INGRESS_ROUTES/OH_VSCODE_BASE_PATH. That is the regression test for the actual self-hosted install path.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
The UI change itself is small, but it spans the frontend, agent-server release compatibility, and two shared deployment entrypoints. The risk becomes manageable once the route, advertised base path, and status model are driven from the same configuration and exercised through a stock launcher.
VERDICT:
❌ Needs rework: Complete the existing ingress/status integration and release sequencing, then the render-gate removal should be straightforward.
KEY INSIGHT:
Treat /vscode as one explicitly configured service route and availability as one typed capability state; the repository already contains the proxy and client machinery needed for both.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it is merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
4d38c9d to
fbf38d7
Compare
The button was gated on `backend.kind === "cloud"`, so it never mounted on a
self-hosted install. `useUnifiedVSCodeUrl` already has a complete non-cloud
branch (`enabled: !isCloud && …`) that reads the URL from the agent server's
`/api/vscode/url`, so the data path exists — only the render gate blocked it.
The gate looks like a workaround for the agent server advertising an unusable
URL, which the hook's own comment describes ("only knows its internal
localhost:8001"). That is fixed server-side by
OpenHands/software-agent-sdk#4181 (port) and OpenHands#4222 (base path), so the
premise for gating on backend kind no longer holds.
Also drops the cloud-only `pr-1` padding, since the wrapper now always has
content, and updates the test that asserted the button stays hidden on local.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012g6hxLKWGBmNH9fwfFAyTx
Rendering the button on local backends exposes a case the cloud-only gate used to hide: a backend with nothing to open still gets a control whose click is a no-op. `enable_vscode: false` makes agent-server answer `GET /vscode/url` with 503, so both resolvers reject and the query settles in `error` carrying no data — `data && !data.url` does not catch it. A successful response with a null URL is a separate path that `isError` alone does not catch. `useUnifiedVSCodeUrl` now folds both into `isUnavailable`, and `DrawerVSCodeLink` renders nothing when it is set. Both conditions are final rather than transient: the query has already exhausted its three retries. Cloud is deliberately excluded. A sandbox that is still STARTING reports no VSCODE entry in `exposed_urls` and will populate one shortly, so the control stays visible and cloud behavior is unchanged by this PR. `retryDelay: 0` in the hook test's query client keeps the new error-path test from spending the default exponential backoff, since the hook's own `retry: 3` overrides the wrapper's `retry: false`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012g6hxLKWGBmNH9fwfFAyTx
Rendering the button on local backends means `useUnifiedVSCodeUrl`'s query now actually runs there — previously it never did, because the only consumer never mounted outside cloud. On a backend with `enable_vscode: false` that query gets a 503, which the global QueryCache handler turns into a user-facing error toast reading "HTTP request failed (503 Service Unavailable)". That is a deployment setting, not a failure the user should be told about, and the intended response to it is already to render no button. Opt this query out via the existing `meta.disableToast` escape hatch so the two agree. Caught while recording the demo video: the toast was visible in the frame that was supposed to show a clean "no editor offered" state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012g6hxLKWGBmNH9fwfFAyTx
The previous commit derived `isUnavailable` from `isError`, which made a
deliberately disabled editor indistinguishable from an auth, proxy, or server
failure — and the `meta.disableToast` that came with it suppressed all of them.
A 401 against the URL endpoint silently removed the button with nothing said.
`/api/vscode/status` is the typed capability answer for this and is already
exposed by the pinned typescript-client as `VSCodeClient.getStatus()`. It
answers 200 with `{enabled: false, running: false}` when a deployment sets
`enable_vscode: false`, so the disabled case arrives as a value rather than as
the 503 that `/vscode/url` returns.
The URL request is now gated on that probe, which means:
- a disabled editor never issues the 503 in the first place, so the blanket
toast suppression is gone and genuine failures stay loud and retryable;
- a configured editor whose process failed to start is caught. `running: false`
is terminal, not a startup race: agent-server awaits `VSCodeService.start()`
in its lifespan before serving any request. `/vscode/url` still hands back a
URL in that state, so the probe is the only way to see it;
- a failed probe is explicitly not "unavailable" — nothing about a transport
fault says the deployment has no editor, so the control stays and the error
stays observable.
`/api/vscode/status` predates the 1.28.0 floor in `config/defaults.json`, so
this needs no pin change.
Tests replace the mocked-503-as-capability case with explicit status cases:
disabled hides, enabled-but-not-running hides, and a failing probe stays an
error with the control intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012N8DQCJCnLoUhvZdp6Ag2s
Rendering the button is only useful if a stock install can reach the editor.
Until now neither launcher routed to it: the E2E in this PR's description had
to supply `OH_VSCODE_BASE_PATH` and `INGRESS_ROUTES` by hand, and without them
`getVSCodeUrl({baseUrl: window.location.origin})` is answered with the canvas
origin — the button opens agent-canvas, not the workbench.
Treat the editor as one configured service route, driven from a single value in
`config/defaults.json` (`paths.vscodeBasePath`) so the advertised URL and the
route that serves it cannot drift:
- npm: `getLocalServiceRoutes()` gains `[basePath, localhost:${vscodePort}]`,
which covers the outer ingress and the static-server route list built from
the same function; `buildAgentServerEnv()` passes the same value as
`OH_VSCODE_BASE_PATH` alongside the `OH_VSCODE_PORT` it already set.
- Docker: `entrypoint.sh` resolves the port and base path from the generated
`defaults.env`, exports both to agent-server, and adds the `--route` to both
the normal and public-mode `static-server.mjs` invocations.
The prefix is preserved rather than stripped. agent-server launches
openvscode-server with `--server-base-path`, so the editor generates its HTTP
and WebSocket URLs beneath the prefix and answers only there. No new proxy code
was needed: `createRouter()` already picks the longest matching prefix and the
proxy forwards the original path, and both `ingress.mjs` and `static-server.mjs`
already handle WebSocket upgrades. In frontend-only mode the prefix joins the
reject list so an editor request 503s instead of getting the canvas shell.
The editor port is not published — the single-origin shape is the point, so it
inherits the canvas's ingress posture instead of needing a second open port.
This route is only observable once agent-server includes the base path in the
advertised URL, which is software-agent-sdk#4222 and first shipped in
agent-server 1.38.0 — the pin this branch is rebased onto (OpenHands#16129). So the route
is live rather than inert. On an older pin the URL simply omits the prefix,
exactly as it did before, so the wiring is never a regression either way.
Note that `openvscode-server` lives at a path baked into the agent-server
container image (`/openhands/.openvscode-server`), so a uvx/PyPI install reports
`enabled: true, running: false` and the button correctly stays hidden there. The
route matters for the Docker path, where the editor actually runs.
Covered by launcher tests for route registration, prefix preservation against
the real router, env/route agreement, and frontend-only rejection, plus a
drift-detection test for the Docker half, which has no importable surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012N8DQCJCnLoUhvZdp6Ag2s
… the proxy
The entrypoint resolved VSCODE_PORT / VSCODE_BASE_PATH for the static-server
route but only fell back to them when exporting agent-server's own
OH_VSCODE_PORT / OH_VSCODE_BASE_PATH. A deployment that sets the documented
agent-server variable — OH_VSCODE_BASE_PATH=/editor — therefore launched and
advertised the editor on /editor while the proxy kept routing /vscode, so the
button opened a path nothing serves. The port override failed the same way.
Both names now collapse to one effective pair before anything reads it, with
the OH_* variables taking precedence, and the pair is normalized to a single
spelling ("editor", "/editor" and "/editor/" all become /editor) so the
advertised URL and the route cannot differ by a slash either. A prefix that
resolves to the site root is refused rather than handing the editor the whole
origin. Both static servers register one VSCODE_ROUTE string derived from the
exported pair, so there is no second place for the route to be built.
The npm launchers were already single-sourced: vscodeBasePath comes from
config/defaults.json and the spawn env spreads buildAgentServerEnv() after
process.env, so the launcher's value wins over an ambient OH_VSCODE_BASE_PATH.
The Docker half has no importable surface, so the test now extracts the
config block between its markers and executes it under bash, asserting the
advertised pair and the route agree for defaults, defaults.env values, either
variable name alone, both names disagreeing, and every slash spelling.
fbf38d7 to
086991e
Compare
…n-renders-for-local-backends
OH_VSCODE_BASE_PATH was set inside buildAgentServerEnv, a helper shared by every launcher, while the matching route was added in only two of them. The setting that changes the advertised URL was centralized; the setting that makes that URL resolvable was not. That matters because getVSCodeUrl sends baseUrl: window.location.origin, and agent-server appends the prefix to whatever base_url it is given (1.39.1's get_vscode_url, vscode_service.py:110-120). So dev:minimal, dev:static and dev:extra-backend all advertised <origin>/vscode/?tkn=... and served the canvas SPA shell there — the editor button opened a second copy of the canvas. Note that simply dropping the prefix does not restore a direct-port URL: the port default only applies when base_url is None, and the frontend always sends one. A launcher therefore has to route the prefix or advertise a URL that does not work, so this makes the pairing explicit rather than optional: - buildAgentServerEnv takes vscodeBasePath as an argument and omits the variable unless asked, so a launcher cannot enter prefix-mode by accident. - dev-with-automation opts in, unchanged — getLocalServiceRoutes already registered the route. - dev-static opts in and now builds both its static-server and ingress route tables from getLocalServiceRoutes. Its inline copies claimed to stay identical to that table (scripts/dev-static.mjs:373-374) but nothing enforced it and they had already drifted past the editor prefix. - dev:minimal opts in and proxies the prefix through Vite. It runs agent-server and Vite with nothing in front of them, so Vite's proxy is the only thing that can serve the prefix on the browser's origin; the editor needs its own target since it is a separate process from agent-server. - dev-extra-backend stays out. Its browser origin belongs to another stack, so a global prefix either does not resolve or resolves to the bundled stack's editor and hands back a different container's workspace. __tests__/scripts/vscode-base-path-opt-in.test.ts asserts the pairing directly so a future launcher cannot advertise a prefix it does not serve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4sJiyf9EYRvVynFM3efdW
…validation
Two ways the editor route could reach further than intended.
1. The editor's connection token is not a token of its own: agent-server sets
it to session_api_keys[0] (vscode_service.py:247-249) and puts it in the
URL's query string (:120). The entrypoint exports that same value as
OH_SESSION_API_KEYS_0 and reuses it for the automation keys, so one secret
is the agent-server key, the automation key and the editor's tkn.
Registering the editor route on the PUBLIC_MODE_PORT instance put that
secret in a browser-navigable URL on the origin that advertises itself as
credential-required. --auth-required does not gate it: it only controls
whether the session key is injected into the served HTML
(static-server.mjs:299-301), and the dispatcher matches routes and returns
before it is ever consulted (:558-563, :581-588). The other routes on that
port are safe on that footing because agent-server checks the session key
itself; the editor is not.
- Drop --route "$VSCODE_ROUTE" from the public-mode static server. That port
exists to drive the auth-mode E2E suite, which does not exercise the
editor.
- Add --no-referrer-prefix to static-server and ingress, and register the
editor prefix with it on the normal instance and in the npm launchers. The
workbench renders webviews, previews and extension content from the
document whose URL carries the token, and nothing in this stack set a
Referrer-Policy. Deliberately scoped to the prefix, not applied
origin-wide.
2. normalize_base_path rejected only "/". static-server keys its route table by
prefix (static-server.mjs:119) and the editor route is registered last, so a
colliding prefix silently replaces the earlier one rather than failing:
OH_VSCODE_BASE_PATH=/api sent every API call to the editor port. The route
parser also cuts at the first "=", so /vs=code parsed as prefix "/vs" with a
garbage target — an outage under /vs instead of a startup error.
Reject collisions with the existing routes and AGENT_CANVAS_BASE_PATH,
multi-segment paths (agent-server strips the slashes when building the
advertised URL, so the two sides would disagree), and anything outside
[A-Za-z0-9._-]. VSCODE_PORT is checked as numeric for the same reason: it is
interpolated into a proxy target, so a bad value failed on the first editor
request rather than at startup.
Also softens the "never published" note on VSCODE_PORT: the image does not
EXPOSE it, but openvscode-server binds 0.0.0.0, so `docker run --network host`
does leave it reachable behind only its token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4sJiyf9EYRvVynFM3efdW
The button's width is folded into how many tabs fit inline (conversation-tabs.tsx:190, :197), and its presence used to be a synchronous function of backend.kind, which the effect's dep array covered. It is now resolved asynchronously by the /api/vscode/status probe, and it sits inside an `ml-auto shrink-0` wrapper — so when the probe reports no editor and the link unmounts, the row's own box does not change, the ResizeObserver never fires, and no dep changed. The fit stays computed against a button that is no longer on screen, so a self-hosted deployment with enable_vscode:false permanently shows fewer inline tabs than fit. Observe the wrapper as well as the row. Also documents why the local refetch is not a no-op after a failed probe: an observer's own refetch() does not consult `enabled` — only queryClient.refetchQueries skips disabled queries — so a click still retries. The sibling API behaves the opposite way, which makes this worth stating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4sJiyf9EYRvVynFM3efdW
….com:harish-chandramowli/OpenHands into fix/vscode-button-renders-for-local-backends
This comment was marked as outdated.
This comment was marked as outdated.
The comment explains why the editor route is kept off the public-mode origin but leaves the reader without a way to find out whether the underlying problem is being fixed. OpenHands/software-agent-sdk#4317 tracks it: agent-server seeds the editor's connection token from session_api_keys[0], so the tkn in the URL is the API key. If that changes, both this exclusion and --no-referrer-prefix are worth revisiting rather than carrying forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4sJiyf9EYRvVynFM3efdW
… just the server Two supported launch modes advertised an editor their browser origin could not serve, both for the same reason: this PR made the control render on any local backend whose agent-server reports an editor, and that report says nothing about whether the page's own origin has a route to it. - Public mode (docker/entrypoint.sh --auth-required) shares one agent-server with the main instance but deliberately omits the editor route, so the probe answered `enabled: true, running: true` and the button appeared. Clicking it fell through to the SPA. - An extra backend (dev-extra-backend.mjs) opts out of prefix-mode but still starts the editor, so the probe was equally affirmative. With no prefix configured, agent-server appends nothing to the `window.location.origin` the frontend sends and hands back the canvas root — so the click reopened this app or, on an origin that does route /vscode, reached the bundled stack's editor and a different container's workspace. Editor availability is server capability intersected with this origin's route table. The server half already existed (`/api/vscode/status`); this adds the origin half: - static-server gains `--vscode-base-path`, injected as `window.__AGENT_CANVAS_VSCODE_BASE_PATH__`, and refuses to start unless a matching `--route` exists — so the advertisement cannot drift from what is actually routed. Vite's equivalent is VITE_VSCODE_BASE_PATH, already present. - `#/utils/vscode-origin` reads it and answers both questions: does this origin serve an editor at all, and does a given URL land under the prefix it serves. - `useUnifiedVSCodeUrl` reports `isUnavailable` when the origin advertises nothing (public mode), or when the resolved URL is outside its editor route (extra backend). Both queries are skipped in the first case rather than probing for a capability that cannot be used. Hiding the control where the origin cannot serve it restores the behavior every local backend had before this PR, so no deployment that worked can regress. Coverage is behavioral rather than structural, per review: the hook tests drive status → URL → isUnavailable for public mode, for an extra backend, and for the bundled stack that must keep working.
…gins Two holes in the route-advertisement invariant this branch introduced. Full-stack dev baked VITE_VSCODE_BASE_PATH but not VITE_VSCODE_TARGET. vite.config.ts registers the editor proxy only when it has both, and this stack has two supported browser origins — the ingress and Vite's own port, which is in AUTOMATION_CORS_ORIGINS precisely so it can be browsed direct. On the ingress the prefix is routed by the ingress; on the Vite origin only that proxy can serve it, so advertising the prefix alone put a visible button there whose URL fell through to the SPA. Set both, as dev:minimal already does. The docker collision guard compared a normalized editor prefix against a raw canvas one, so AGENT_CANVAS_BASE_PATH=canvas with OH_VSCODE_BASE_PATH=/canvas passed validation and then landed both on /canvas once static-server normalized the mount — where the editor route, registered after the SPA mount, takes the application over. Normalize both sides with the same function, and resolve AGENT_CANVAS_BASE_PATH inside the extracted config block so the guard is testable against its real default rather than only against injected values. Tests: the launcher's two env vars must be set together in one block, and the collision guard must reject every accepted spelling of the canvas mount (canvas, /canvas, /canvas/, //canvas//) plus the untold default, while still accepting a noncanonical mount that does not collide. Each new assertion was confirmed to fail with its fix reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4sJiyf9EYRvVynFM3efdW
|
Both confirmed against the code and fixed in d477beb. Advertised route absent on the direct Vite origin. Correct. Collision guard compares normalized and raw paths. Correct, and the takeover is real: One thing I did beyond the literal fix. Both testing gaps covered:
Each new assertion was checked to fail with its own fix reverted — 3 of the 4 spellings did, |
✅ Mock-LLM E2E Tests60/60 passed Commit: Details
Posted by the Mock-LLM E2E workflow · results are deterministic (scripted LLM responses) |
enyst
left a comment
There was a problem hiding this comment.
👋 I'm an AI agent (Opus 5) reviewing this PR on behalf of Engel Nyst (@enyst).
🔴 Needs improvement - The routing mechanics are carefully synchronized, but the design removes the browser security boundary between Canvas and the bundled editor.
[CRITICAL ISSUES]
- [
docker/entrypoint.sh, Line 382;scripts/dev-with-automation.mjs, Lines 701-712] Credential isolation regression: Proxying OpenVSCode at/vscodeputs it on exactly the same browser origin as Canvas. Paths are not security boundaries: JavaScript served anywhere on that origin can read the samelocalStorageand make same-origin API calls. Canvas persists the complete backend registry, including every configured backend'sapiKey, inlocalStorage(src/api/backend-registry/storage.ts, Lines 95-99), so an editor-side XSS, compromised OpenVSCode asset, or unsafe editor/extension web content can now exfiltrate credentials unrelated to the current editor session.Referrer-Policy: no-referreronly stops the token-bearing URL from being sent as a Referer; it does nothing to isolate storage or JavaScript authority. Keep the editor on a distinct origin (a separate host/subdomain or port), or first redesign credential storage and establish an equally strong browser isolation boundary; a path-prefix proxy cannot provide one.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🔴 HIGH
This changes the trust boundary around authentication material across every bundled local/Docker deployment. The blast radius includes all backend API keys saved in the Canvas registry, while the editor is a large, extensible application with a materially broader content and dependency surface. Recommendation: Do not auto-merge. Request review from a human security architect/reviewer to validate the editor/Canvas origin model and credential exposure.
VERDICT:
❌ Needs rework: Correct route plumbing does not compensate for collapsing two applications into one browser origin.
KEY INSIGHT:
A URL path separates routing, not trust; browser storage and script authority are scoped to the entire origin.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
|
Re: the same-origin credential review — I'm not implementing this one, because the fix it prescribes is the design a previous review on this same PR rejected. I'd like a maintainer to break the tie before I move more code. @enyst @neubig The finding is technically correct
And the principle is right: a path prefix routes, it doesn't isolate. But it contradicts the earlier review on this PRReview from 2026-07-30 13:14, same reviewer, essentially this architecture:
A separate port is the thing the first review counted as the win and the second one requires. I can't satisfy both, and picking one myself would mean discarding four rounds of review that were given on the assumption of the other. Three options, as I see them
My preference is (1) if the second port is acceptable to maintainers, since it's the only one that's actually a boundary — but that's a project posture call about the self-hosted deployment story, not something the PR author should decide unilaterally. |
|
👋 Hi, I'm smolpaws — a small AI cat agent (OpenHands under the hood), here on Engel's behalf. 🐾 Thanks, @harish-chandramowli — you were right to stop and ask for a human call rather than pick an architecture unilaterally, and the same-origin finding is real (I checked it in the code). Where we landed: Ship it. We want the functionality, and the practical risk is narrow. The editor already has full shell + filesystem access to its workspace, so its own session isn't the concern. The new exposure is that editor-side script could read other backends' API keys from Two things we're documenting rather than fixing here:
So: merge with the shared-origin tradeoff documented (a short deployment-docs note + pointer to #16492), rather than blocking on the credential redesign here. Only asks before merge: it needs a rebase (currently conflicting), and a one-line note naming the tradeoff so the next person isn't surprised. Thanks again for the careful work. 🐾 |
enyst
left a comment
There was a problem hiding this comment.
(HUMAN:)
LGTM, thank you for this @harish-chandramowli !
A note: we could, as you suggest in your last comment, make it opt-in, rather than opt-out. But personally, I'm torn whether we really want to, considering that the risk that the agents grumble about is dependent on some other exploit, and we do aim to fix the local storage anyway.
If I may suggest, rather, after it's ready give it another spin just to see that we didn't break things with the recent fixes, and please let me know.
Thank you again!
HUMAN:
I tested this end to end locally to get VSCode button working from my mac mini / docker setups
AGENT:
Why
DrawerVSCodeLinkis gated onbackend.kind === "cloud", and that is its only render site — so the VSCode button never appears on a self-hosted install.The data path for local backends is already fully implemented.
useUnifiedVSCodeUrlhas a complete non-cloud branch, so the hook supports local; only the render gate prevents it from mounting.The gate reads as a workaround for the agent server advertising a URL that could not work, which the hook's own comment states:
That is a server-side defect, and it is now fixed.
config/defaults.jsonpinsagentServer: 1.39.1, whoseget_vscode_urlappends the configured base path to the origin it is given (vscode_service.py:110-120, with a docstring naming exactly the path-routing case). With that, the local URL is correct and the reason for gating on backend kind no longer holds.This matters for the ordinary self-hosting shape — a machine reached through a single hostname (Cloudflare Tunnel, Tailscale Serve, a reverse proxy), where VSCode is routed by path rather than given its own public port.
Summary
Render the button, and gate it on the editor's actual capability state.
DrawerVSCodeLinkunconditionally instead of only whenbackend.kind === "cloud", and drop the cloud-onlypr-1padding since the ref'd wrapper now always has content.isUnavailabletouseUnifiedVSCodeUrl, driven by/api/vscode/status(VSCodeClient.getStatus()) rather than by treating errors as "unavailable". The URL request only runs once the probe says there is an editor, so a deployment withenable_vscode: falsegets no button and no failing request — and transport, auth and server faults stay visible as ordinary query errors rather than being silently swallowed.enabled: false— the deployment switch.running: falsealongsideenabled: true— terminal, not a startup window:VSCodeService.start()returnsFalsewhen the binary is missing or the port is taken (vscode_service.py:54-62), and the lifespan awaits it before yielding (api.py:205).url: null.isUnavailable, so cloud behavior is unchanged (see Notes).ResizeObserverwatches the button's wrapper as well as the row — otherwise a deployment withenable_vscode:falsepermanently shows one fewer inline tab than fits.Make the advertised URL resolvable, from stock configuration.
The button is only useful if
<origin>/vscode/…reaches the editor.getVSCodeUrlsendsbaseUrl: window.location.originand agent-server appends the prefix to it, so the origin has to route the prefix to the editor port.config/defaults.json, flowing to both agent-server (OH_VSCODE_BASE_PATH/OH_VSCODE_PORT) and the proxy route table. The prefix is preserved, not stripped: openvscode-server is launched with--server-base-pathand generates its own HTTP and WebSocket URLs beneath it.docker/entrypoint.shcollapsesVSCODE_*and the pre-existingOH_VSCODE_*into one effective pair before anything reads them, so a deployment that already setsOH_VSCODE_BASE_PATH=/editormoves the editor and the route together.OH_VSCODE_BASE_PATHis an explicit argument tobuildAgentServerEnv, not a field on its config. It changes what gets advertised, so a launcher must opt in and register the route; the helper is shared by every launcher, and when the prefix was always-on three of them advertised a prefix they did not serve.dev-staticnow builds its route tables fromgetLocalServiceRoutesinstead of a hand-maintained copy,dev:minimalproxies the prefix through Vite, anddev-extra-backenddeliberately stays out (its browser origin belongs to a different stack, so no global prefix can disambiguate).Keep the editor's credential inside one perimeter.
agent-server sets the editor's connection token to
session_api_keys[0](vscode_service.py:247-249) and puts it in the URL query string (:120) — so the editor'stknis also the agent-server API key.PUBLIC_MODE_PORTstatic server.--auth-requiredonly controls whether the session key is injected into the served HTML; the dispatcher matches routes and returns before consulting it, so it does not gate proxied paths. The other routes there are fine because agent-server checks the key itself; the editor is not.--no-referrer-prefixon static-server and ingress sendsReferrer-Policy: no-referreron the editor path only. The workbench renders webviews, previews and extension content from the document whose URL carries the token.normalize_base_pathnow rejects prefixes that would collide with an existing route (/apisilently replaced the agent-server route, since the table is keyed by prefix and the editor route is registered last), multi-segment paths, and anything outside[A-Za-z0-9._-].VSCODE_PORTis checked as numeric.Issue Number
Relates to #15434 (self-hosted VSCode button unreachable) — deliberately not "Fixes", since that issue is written up as the URL/port problem and this PR addresses the render gate plus the deployment wiring around it.
How to Test
End-to-end through a stock launcher. No manual
INGRESS_ROUTESorOH_VSCODE_BASE_PATH— that is the point of the wiring above:Then start a conversation and open the right panel.
document.querySelector('[data-testid="drawer-vscode-link"]')returnsnull.<origin>/vscode/?tkn=…&folder=…in a new tab.OH_ENABLE_VSCODE=false: no button, and no error toast.Two things worth knowing if you reproduce this:
getVSCodeUrlcall sites sendbaseUrl: window.location.origin, so with novscode_base_paththe server is told the origin and returns<origin>/?tkn=…— agent-canvas itself, not the editor. Verified on a patched build:GET /api/vscode/url?base_url=http://localhost:18300→http://localhost:18300/vscode/?tkn=…, and that URL gives302→vscode-tkncookie →200workbench (2504 bytes).openvscode-serverpublishes Linux-only builds (the 1.109.5 release has exactly three assets, alllinux-*), and agent-server resolves it from a hardcoded/openhands/.openvscode-server. On a Linux host, symlink that path and agent-server spawns the editor itself. On macOS it cannot, so in the recording below the editor was run in Docker (gitpod/openvscode-server,linux/arm64) on:19000with the same flags agent-server would pass —--server-base-path /vscode --connection-token <session key>— with the canvas ingress routing/vscodeto it. The URL construction and routing under test are unchanged by that; only who spawns the process differs.Video/Screenshots
Two recordings from the stack described above — same build, same backend, the only difference being
enable_vscode:enable_vscodeon — a conversation on alocalbackend, right panel open, the VSCode button present in the tab bar. Clicking it openshttp://localhost:18300/vscode/?tkn=…&folder=…, which loads the real workbench (.monaco-workbench, with the conversation's workspace folder in the Explorer).enable_vscode: false— identical layout and the same tab bar, with no VSCode button and no error toast.01-button-works-enable_vscode-true.webm


02-button-hidden-enable_vscode-false.webm
Both are also asserted by unit tests, but the recordings are what show the base path actually resolving end to end.
Type
Notes
This changes an existing tested behavior, deliberately.
conversation-tabs.test.tsxassertedshould hide the vscode link when the active backend is local; both it and the gate arrived together in OpenHands/agent-canvas#1288 ("UI polish: drawer tabs, empty states, and browser chrome"). If hiding the button on local backends was an intentional product decision rather than a consequence of the unusable server URL, then this PR is wrong and I would rather know that than have it merged.Sequencing is resolved. The earlier revision of this description said it depended on software-agent-sdk#4222. That is merged and released:
config/defaults.jsonpinsagentServer: 1.39.1, whoseget_vscode_urlemits the base path. No hold needed.Cloud is deliberately left alone. A cloud sandbox that is still
STARTINGhas noVSCODEentry inexposed_urlsyet and will get one shortly, so treating that as "unavailable" would hide the button during startup.isUnavailableis therefore hardcodedfalseon the cloud branch and cloud rendering is byte-identical to today. That does leave cloud's own dead-button case (a sandbox that never exposes VSCode) unaddressed; happy to extend it there in a follow-up if you consider that a real state rather than a transient one.docker run --network hoststill reaches the editor port directly. The image does notEXPOSE 8001and a test asserts that, but openvscode-server binds--host 0.0.0.0(vscode_service.py:170), so under host networking — whichplaywright.mock-llm-docker.config.ts:52uses — port 8001 is host-reachable with only?tkn=in front of it. Not introduced here, but worth stating plainly rather than claiming the port is unreachable.History
The branch has been reworked twice under review, and the description above describes only the current state. For reviewers returning to it:
meta.disableToast. That was replaced with the/api/vscode/statusprobe, on @neubig's suggestion — it removes the failing request instead of hiding it, and keeps genuine faults loud.