Skip to content

fix: render the VSCode button on self-hosted (local) backends - #16106

Open
harish-chandramowli wants to merge 15 commits into
OpenHands:mainfrom
harish-chandramowli:fix/vscode-button-renders-for-local-backends
Open

fix: render the VSCode button on self-hosted (local) backends#16106
harish-chandramowli wants to merge 15 commits into
OpenHands:mainfrom
harish-chandramowli:fix/vscode-button-renders-for-local-backends

Conversation

@harish-chandramowli

@harish-chandramowli harish-chandramowli commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

HUMAN:

I tested this end to end locally to get VSCode button working from my mac mini / docker setups

  • A human has tested these changes.

AGENT:


Migration: this re-opens OpenHands/agent-canvas#1945, closed 2026-07-27 at @neubig's request following Agent Canvas's archival into this repository. The original PR was the render gate alone; review here has since grown it to cover the deployment wiring that makes the rendered button actually reach an editor. This description was rewritten to match the branch — see History at the bottom for what changed and why.

Why

DrawerVSCodeLink is gated on backend.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. useUnifiedVSCodeUrl has 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:

Cloud mode: read VSCode URL from the cloud-computed exposed_urls on the conversation's sandbox. The runtime's /api/vscode/url only knows its internal localhost:8001, so calling it returned a URL […]

That is a server-side defect, and it is now fixed. config/defaults.json pins agentServer: 1.39.1, whose get_vscode_url appends 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.

  • Render DrawerVSCodeLink unconditionally instead of only when backend.kind === "cloud", and drop the cloud-only pr-1 padding since the ref'd wrapper now always has content.
  • Add isUnavailable to useUnifiedVSCodeUrl, 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 with enable_vscode: false gets 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: false alongside enabled: true — terminal, not a startup window: VSCodeService.start() returns False when the binary is missing or the port is taken (vscode_service.py:54-62), and the lifespan awaits it before yielding (api.py:205).
    • probe succeeded but the URL query returns url: null.
  • Cloud is excluded from isUnavailable, so cloud behavior is unchanged (see Notes).
  • Re-measure the tab row when the button appears or disappears. Its width feeds the inline-tab fit calculation, and its presence is now async, so the ResizeObserver watches the button's wrapper as well as the row — otherwise a deployment with enable_vscode:false permanently 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. getVSCodeUrl sends baseUrl: window.location.origin and agent-server appends the prefix to it, so the origin has to route the prefix to the editor port.

  • One editor prefix and port, defined in 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-path and generates its own HTTP and WebSocket URLs beneath it.
  • docker/entrypoint.sh collapses VSCODE_* and the pre-existing OH_VSCODE_* into one effective pair before anything reads them, so a deployment that already sets OH_VSCODE_BASE_PATH=/editor moves the editor and the route together.
  • OH_VSCODE_BASE_PATH is an explicit argument to buildAgentServerEnv, 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-static now builds its route tables from getLocalServiceRoutes instead of a hand-maintained copy, dev:minimal proxies the prefix through Vite, and dev-extra-backend deliberately 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's tkn is also the agent-server API key.

  • The editor route is not registered on the PUBLIC_MODE_PORT static server. --auth-required only 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-prefix on static-server and ingress sends Referrer-Policy: no-referrer on the editor path only. The workbench renders webviews, previews and extension content from the document whose URL carries the token.
  • normalize_base_path now rejects prefixes that would collide with an existing route (/api silently 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_PORT is 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

npm ci
npm run make-i18n
npx vitest run __tests__/scripts/ __tests__/components/features/conversation/ __tests__/hooks/use-unified-vscode-url.test.tsx
# 32 files, 383 tests passed
npm run lint

End-to-end through a stock launcher. No manual INGRESS_ROUTES or OH_VSCODE_BASE_PATH — that is the point of the wiring above:

node bin/agent-canvas.mjs

Then start a conversation and open the right panel.

  • Before: no button in the tab bar; document.querySelector('[data-testid="drawer-vscode-link"]') returns null.
  • After: the button renders and opens the workbench at <origin>/vscode/?tkn=…&folder=… in a new tab.
  • With OH_ENABLE_VSCODE=false: no button, and no error toast.

Two things worth knowing if you reproduce this:

  1. A path prefix is required for the test to mean anything. Both getVSCodeUrl call sites send baseUrl: window.location.origin, so with no vscode_base_path the 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:18300http://localhost:18300/vscode/?tkn=…, and that URL gives 302vscode-tkn cookie → 200 workbench (2504 bytes).
  2. openvscode-server publishes Linux-only builds (the 1.109.5 release has exactly three assets, all linux-*), 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 :19000 with the same flags agent-server would pass — --server-base-path /vscode --connection-token <session key> — with the canvas ingress routing /vscode to 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:

  1. enable_vscode on — a conversation on a local backend, right panel open, the VSCode button present in the tab bar. Clicking it opens http://localhost:18300/vscode/?tkn=…&folder=…, which loads the real workbench (.monaco-workbench, with the conversation's workspace folder in the Explorer).
  2. 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
still-01-button-visible
still-02-button-absent

Both are also asserted by unit tests, but the recordings are what show the base path actually resolving end to end.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

This changes an existing tested behavior, deliberately. conversation-tabs.test.tsx asserted should 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.json pins agentServer: 1.39.1, whose get_vscode_url emits the base path. No hold needed.

Cloud is deliberately left alone. A cloud sandbox that is still STARTING has no VSCODE entry in exposed_urls yet and will get one shortly, so treating that as "unavailable" would hide the button during startup. isUnavailable is therefore hardcoded false on 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 host still reaches the editor port directly. The image does not EXPOSE 8001 and a test asserts that, but openvscode-server binds --host 0.0.0.0 (vscode_service.py:170), so under host networking — which playwright.mock-llm-docker.config.ts:52 uses — 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:

  • The original diff suppressed the 503 from a VSCode-disabled backend with meta.disableToast. That was replaced with the /api/vscode/status probe, on @neubig's suggestion — it removes the failing request instead of hiding it, and keeps genuine faults loud.
  • The Docker/launcher wiring (route registration, one normalized port/prefix pair, prefix opt-in, public-mode exclusion, prefix validation) was added in response to review, and is the larger half of the diff. It is what makes the rendered button reach an editor from a stock install rather than only from a hand-configured one.

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 /vscode route before exposing the control. The current E2E command proves the desired topology, but only by manually supplying OH_VSCODE_BASE_PATH=/vscode and INGRESS_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 /vscode and generates its HTTP and WebSocket URLs beneath that path. The existing proxy stack already does the hard parts: createRouter() chooses the longest matching prefix, httpxy forwards the original request path, and both ingress.mjs and static-server.mjs handle HTTP plus WebSocket upgrades. This should therefore be configuration, not a new proxy implementation:

    1. Define one stable base path (for example /vscode) alongside the existing centralized launcher defaults.
    2. Add [basePath, http://localhost:${config.vscodePort}] to getLocalServiceRoutes(config) when agent-server is launched. That covers the outer npm ingress and the static-server route list built from the same function.
    3. Pass the same value as OH_VSCODE_BASE_PATH in the agent-server environment; OH_VSCODE_PORT is already populated by buildAgentServerEnv().
    4. In docker/entrypoint.sh, set/export the corresponding VSCode port and base path, and add the same --route to both normal and public-mode static-server.mjs invocations.

    With those values aligned, getVSCodeUrl({ baseUrl: window.location.origin }) can advertise https://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 centralized config/defaults.json pin is advanced through the release workflow; the current 1.37.0 pin does not contain that fix.

[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, and meta.disableToast then suppresses all of them. @openhands/typescript-client@1.34.0 already exposes VSCodeClient.getStatus() for /api/vscode/status, returning { enabled, running, message? }.

    A simpler state flow would be:

    1. After the runtime is ready, query getStatus() using the same getAgentServerClientOptions({ conversationUrl, sessionApiKey }) overrides.
    2. Hide the control only for an explicit terminal capability state (enabled === false, or the agreed policy for running === false).
    3. Enable the URL query only when status says the editor is available.
    4. 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 isUnavailable special 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.

[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:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger 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.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. 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 /iterate to automatically drive this PR through CI, review, and QA until it is merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.


enyst

This comment was marked as outdated.

@harish-chandramowli
harish-chandramowli force-pushed the fix/vscode-button-renders-for-local-backends branch 3 times, most recently from 4d38c9d to fbf38d7 Compare July 30, 2026 08:02
enyst

This comment was marked as outdated.

Harish Chandramowli and others added 6 commits July 30, 2026 08:31
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.
@harish-chandramowli
harish-chandramowli force-pushed the fix/vscode-button-renders-for-local-backends branch from fbf38d7 to 086991e Compare July 30, 2026 12:31
enyst

This comment was marked as outdated.

smolpaws

This comment was marked as outdated.

harish-chandramowli and others added 5 commits July 30, 2026 18:09
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
@harish-chandramowli

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
enyst

This comment was marked as outdated.

… 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.
enyst

This comment was marked as outdated.

…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
@harish-chandramowli

Copy link
Copy Markdown
Contributor Author

Both confirmed against the code and fixed in d477beb.

Advertised route absent on the direct Vite origin. Correct. startVite set VITE_VSCODE_BASE_PATH and nothing else, and vite.config.ts:448 spreads the proxy entry only when VITE_VSCODE_BASE_PATH && VITE_VSCODE_TARGET. What settles that the Vite origin is a supported endpoint rather than an internal detail is AUTOMATION_CORS_ORIGINS at dev-with-automation.mjs:973, which allowlists http://localhost:3001 and http://127.0.0.1:3001 alongside the ingress. So the advertisement was reaching an origin with no route behind it. Now set together in one block, targeting http://127.0.0.1:${config.vscodePort} as dev-safe.mjs:1015 does.

Collision guard compares normalized and raw paths. Correct, and the takeover is real: static-server.mjs:134 runs --base-path through normalizeBasePath, which turns canvas into /canvas, while the reserved-prefix loop compared it raw against an already-normalized VSCODE_BASE_PATH. Both sides now go through normalize_base_path.

One thing I did beyond the literal fix. AGENT_CANVAS_BASE_PATH was resolved above the >>> vscode-config markers, so the extracted block the docker test executes never saw its default — the guard could only ever be tested against values a test injected, and the likeliest real instance of this bug is a deployment that moves only the editor and never sets AGENT_CANVAS_BASE_PATH at all. Moved its resolution inside the block so that case is reachable; guards the default canvas mount without being told it covers it.

Both testing gaps covered:

  • full-stack dev advertises the prefix only where Vite also proxies it — asserts both env vars are set inside the same guarded block, and that the target is the editor's own port rather than the backend host.
  • The collision guard now runs against canvas, /canvas, /canvas/ and //canvas//, plus a non-colliding noncanonical mount (canvas/ + editor) so normalizing both sides doesn't start rejecting layouts that are fine.

Each new assertion was checked to fail with its own fix reverted — 3 of the 4 spellings did, /canvas being already covered by the existing case. Typecheck clean, bash -n and shellcheck clean on the entrypoint (only pre-existing SC2329 on the trap-invoked cleanup). Full suite 4135 passed / 13 failed, and those 13 reproduce identically with this branch's changes stashed — conversation-runtime-info, recommended-automations and use-websocket, all failing on localStorage.clear() being undefined in this environment.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Mock-LLM E2E Tests

60/60 passed

Commit: d477beb1 · Workflow run · Test artifacts

Details
Status Test Duration
automations/mock-llm-automation.spec.ts › mock-LLM automation lifecycle › step 1: setup LLM profile and register automation trajectory 6.6s
automations/mock-llm-automation.spec.ts › mock-LLM automation lifecycle › step 2: create automation and dispatch run via the UI 28.5s
automations/mock-llm-automation.spec.ts › mock-LLM automation lifecycle › step 3: verify automation and run on the automations page 6.4s
automations/mock-llm-preset-automation.spec.ts › preset automation → slash command conversation › automation card sends the correct slash command to a conversation 15.3s
automations/mock-llm-preset-automation.spec.ts › preset automation → slash command conversation › direct slash command from home page triggers skill activation 13.0s
backends/mock-llm-auth-modes.spec.ts › auth mode: fresh install with runtime-injected key › reaches the onboarding modal without pre-seeded localStorage 1.4s
backends/mock-llm-auth-modes.spec.ts › auth mode: non-public key rotation › recovers when localStorage has a stale session API key 5.4s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › shows first-run onboarding before the auth screen when no key is configured 1.5s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › rejects an incorrect key with an inline error 1.6s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › allows access after pasting the correct key 1.7s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › skips auth screen for returning user with valid stored key 830ms
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › re-prompts when the server rotates its key (stale localStorage) 1.4s
backends/mock-llm-cross-connect.spec.ts › cross-connect: frontend-only → backend-only › frontend-only connects to a separate backend-only instance 17.0s
backends/mock-llm-cross-connect.spec.ts › cross-connect: frontend-only → multiple backends › connects to two separate backends and switches between them 20.9s
backends/mock-llm-partial-stack.spec.ts › partial stack: --frontend-only › serves the frontend but returns 503 for backend routes 7.4s
backends/mock-llm-partial-stack.spec.ts › partial stack: --backend-only › serves backend APIs but returns 503 for the frontend root 15.1s
backends/mock-llm-partial-stack.spec.ts › partial stack: port conflict › fails with a clear error when the ingress port is occupied 109ms
backends/mock-llm-partial-stack.spec.ts › partial stack: port conflict › starts successfully on a free port after a conflict 6.0s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 1: create an LLM profile pointing at the mock LLM server 6.3s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 2: activate the mock-llm profile and verify settings API 7.9s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 3: run a conversation with the mock LLM 7.3s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 4: resume conversation from sidebar after navigating away 5.9s
conversations/mock-llm-image-upload.spec.ts › mock-LLM image upload › attaching an image embeds it as base64 in the LLM completion call 13.1s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 1: ensure mock LLM profile is configured 6.6s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 2: start conversation and attach workspace metadata 11.8s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 3: git control bar shows workspace pill and git actions 25.4s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 4: files tab can enable diff view for attached workspace 5.9s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 5: browser tab shows empty state 6.3s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 6: files tab defaults to file-tree view without attached workspace 8.3s
home/mock-llm-folder-workspace.spec.ts › mock-LLM folder browser → workspace → conversation › step 1: browse to a folder, add it as a workspace, and launch a conversation with the correct working_dir 8.7s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 1: GitHub card is visible on the MCP marketplace page 5.5s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 2: clicking GitHub add control opens the install modal with correct fields 5.7s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 3: full install flow — fill PAT, submit, verify installed 12.4s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 4: installed GitHub server can be deleted 6.0s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › install: invalid Slack credentials are blocked with a credential-check error 5.9s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › install: a valid token missing only a scope still installs (missing_scope is not a credential failure) 5.9s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › install: an older agent server that omits tool_result still installs (compat) 6.0s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › edit: Test Connection verifies the stored credentials and surfaces a credential failure 5.8s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › edit: Test Connection reports success for valid stored credentials 5.9s
mcp/mock-llm-mcp-slack-credentials.spec.ts › MCP Test Connection credential verification (Slack) › custom (non-catalog) server: Test Connection attaches no verification probe 5.8s
onboarding/mock-llm-onboarding-happy-path.spec.ts › onboarding happy path › completes the full onboarding flow and launches a conversation 4.4s
onboarding/mock-llm-onboarding-regressions.spec.ts › onboarding recent regressions › keeps the modal open on backdrop click and Escape 1.5s
onboarding/mock-llm-onboarding-regressions.spec.ts › onboarding recent regressions › defaults the LLM setup step to OpenAI GPT-5.5 1.6s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › scopes standalone styles to the agent-server-ui shell 1.3s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › renders critic results on agent messages and finish actions 1.6s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › loads older events when scrolling up 1.8s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › selected workspace persists after navigating away and returning 2.1s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › cleared sessionStorage yields empty workspace selection 1.0s
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 1: configure ACP agent via Settings → Agent UI 13.2s
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 2: reload and verify ACP settings are persisted in UI 5.8s
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 3: start ACP conversation and verify agent reply 6.7s
settings/mock-llm-acp-agent.spec.ts › mock-LLM ACP agent conversation › step 4: resume ACP conversation from sidebar after navigating away 5.8s
settings/mock-llm-model-switch.spec.ts › mock-LLM /model slash command › step 1: configure LLM, create switch-target profile, register trajectory 7.2s
settings/mock-llm-model-switch.spec.ts › mock-LLM /model slash command › step 2: start conversation, switch profile via /model, verify switch 7.1s
settings/mock-llm-profile-management.spec.ts › active profile deletion + reconciliation › active profile is deletable and reconciliation activates another profile 8.1s
settings/mock-llm-profile-management.spec.ts › same-model profile identity › chat header shows the correct profile when two profiles share the same model 14.9s
settings/mock-llm-profile-management.spec.ts › OpenHands provider hidden base_url preservation › re-saving an OpenHands profile from Basic view preserves hidden base_url 7.6s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › project skill in workspace/.agents/skills/ triggers on matching keyword 13.1s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › user skill in ~/.openhands/skills/ triggers on matching keyword 8.6s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › deleting a user skill removes it from subsequent conversations 8.0s

Posted by the Mock-LLM E2E workflow · results are deterministic (scripted LLM responses)

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋 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 /vscode puts it on exactly the same browser origin as Canvas. Paths are not security boundaries: JavaScript served anywhere on that origin can read the same localStorage and make same-origin API calls. Canvas persists the complete backend registry, including every configured backend's apiKey, in localStorage (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-referrer only 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:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger 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.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. 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 /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@all-hands-bot
all-hands-bot removed the request for review from smolpaws July 31, 2026 07:49
@harish-chandramowli

harish-chandramowli commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

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

  • The editor is genuinely new on this origin. git show origin/main:docker/entrypoint.sh | grep -i vscode returns nothing — the stock image never exposed the bundled editor to a browser at all. So this isn't a pre-existing property I can wave off; serving it at /vscode on the Canvas origin is introduced by this branch (579a507e6).
  • The credentials are genuinely reachable. src/api/backend-registry/storage.ts:98 writes the whole backend array to localStorage, and Backend.apiKey is a plain string (src/api/backend-registry/types.ts:8). Same origin means editor-side script authority reads every configured backend's key, not just this one's.

And the principle is right: a path prefix routes, it doesn't isolate. Referrer-Policy: no-referrer addresses URL leakage, not storage or script authority. No disagreement there.

But it contradicts the earlier review on this PR

Review from 2026-07-30 13:14, same reviewer, essentially this architecture:

KEY INSIGHT: Proxying the editor behind the authenticated agent-server preserves one security perimeter instead of creating a second public port.

VERDICT:Worth merging

Review from 2026-07-31 04:10:

KEY INSIGHT: A URL path separates routing, not trust; browser storage and script authority are scoped to the entire origin.

Keep the editor on a distinct origin (a separate host/subdomain or port)

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

  1. Separate origin (what the latest review asks for). Correct isolation, and it matches how Cloud already works — the editor lives on a different host there, which is precisely why Cloud never had this problem. Cost: reopens the second-public-port posture the earlier review objected to, and for the all-in-one Docker image it means a second published port and a second thing to secure in every self-hosted deployment.
  2. Keep the path proxy, fix the actual hazard. What makes the shared origin dangerous is not that the editor is on it — the editor already has full filesystem and shell access to the workspace, so its own session is not what's being protected. It's that Canvas persists other backends' API keys in localStorage where editor-side script can reach them. Scoping or removing that storage closes the cross-backend exposure without a second origin. This is a Canvas credential-storage change, though — separate PR, and larger than this one.
  3. Ship the editor off by default on the shared origin, opt-in per deployment with the tradeoff documented. Smallest change; leaves the sharp edge in place for anyone who opts in.

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.

@smolpaws

smolpaws commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

👋 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 localStorage — but that needs the editor XSS'd first (or a hostile extension / compromised asset), and this is single-user self-hosted. Small, acceptable edge for a typical install.

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 enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants