Skip to content
This repository was archived by the owner on Jul 27, 2026. It is now read-only.

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

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

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

Conversation

@harish-chandramowli

@harish-chandramowli harish-chandramowli commented Jul 25, 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:


Sequencing: this depends on OpenHands/software-agent-sdk#4222

See Notes.

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:

enabled: !isCloud && runtimeIsReady && !!conversationId,
queryFn:  agentServerConversationService.getVSCodeUrl()  transformVSCodeUrl()

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, now fixed there: OpenHands/software-agent-sdk#4181 makes the endpoint advertise the configured port instead of a hardcoded :8001 (merged, released in agent-server 1.37.1), and #4222 makes it include vscode_base_path for deployments routed by path. With both, 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 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 so a self-hosted backend with no editor to offer renders no button rather than a dead one. It covers both terminal cases, which need different checks:
    • enable_vscode: falseGET /vscode/url answers 503, so both resolvers reject and the query settles in error carrying no data. data && !data.url does not see this.
    • the server reports no URL — settles in success with url: null. isError does not see this.
  • Cloud is excluded from isUnavailable, so cloud behavior is unchanged (see Notes).
  • Opt the local URL query out of the global error toast via the existing meta.disableToast. Because this query never ran on local backends before, the 503 above now reaches the QueryCache onError handler and renders a user-facing "HTTP request failed (503 Service Unavailable)" toast. A deployment that switched VSCode off should get no button and no error.
  • Tests updated/added for the render gate and both unavailable paths.

Issue Number

Related: OpenHands/OpenHands#15434 (self-hosted VSCode button unreachable). That issue is written up as the URL/port problem; this PR addresses the render gate, which is the second, independent reason the button is unusable self-hosted.

How to Test

npm ci
npm run make-i18n
npx vitest run __tests__/components/features/conversation/ __tests__/hooks/use-unified-vscode-url.test.tsx
# 14 files, 116 tests passed
npm run lint
# typecheck + eslint + prettier all clean

End-to-end against a locally patched agent-server. OH_AGENT_SERVER_LOCAL_PATH builds agent-server from a checkout, so both halves of the fix run together without publishing anything:

gh repo clone OpenHands/software-agent-sdk /tmp/sdk && (cd /tmp/sdk && gh pr checkout 4222)

OH_AGENT_SERVER_LOCAL_PATH=/tmp/sdk \
OH_VSCODE_BASE_PATH=/vscode \
INGRESS_ROUTES='{"/vscode":"http://localhost:19000"}' \
  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 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 http://localhost:8000/?tkn=… — agent-canvas itself, not the editor. Verified on the 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 #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 — the alternative is to leave the gate and document that self-hosted users should open VSCode out of band.

Please confirm the sequencing. Merging this before software-agent-sdk#4222 would surface a button whose URL omits the base path on path-routed deployments. Happy to hold until that lands, or to rebase if you would prefer the two changes land together.

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.

The error toast was only found by recording it. Suppressing it is a behavior change to a query you own, so it is worth an explicit look: if you would rather the 503 stayed loud, the alternative is to leave the toast and accept that any self-hosted deployment with VSCode disabled shows an error on every conversation open. I judged silence correct because the button already communicates the state by not being there, but this is your call.

One test-only change outside the feature. retryDelay: 0 was added to the hook test's QueryClient, because the hook's own retry: 3 overrides the wrapper's retry: false and the new 503 test would otherwise spend the default exponential backoff before the query settles.

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 #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
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the openhands Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the type: fix A bug fix label Jul 25, 2026
harish-chandramowli and others added 2 commits July 25, 2026 16:25
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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Mock-LLM E2E Tests

60/60 passed

Commit: 70b85554 · 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 29.4s
automations/mock-llm-automation.spec.ts › mock-LLM automation lifecycle › step 3: verify automation and run on the automations page 6.0s
automations/mock-llm-preset-automation.spec.ts › preset automation → slash command conversation › automation card sends the correct slash command to a conversation 15.5s
automations/mock-llm-preset-automation.spec.ts › preset automation → slash command conversation › direct slash command from home page triggers skill activation 13.8s
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.3s
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.4s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › rejects an incorrect key with an inline error 1.7s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › allows access after pasting the correct key 1.6s
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › skips auth screen for returning user with valid stored key 832ms
backends/mock-llm-auth-modes.spec.ts › auth mode: public gate › re-prompts when the server rotates its key (stale localStorage) 1.5s
backends/mock-llm-cross-connect.spec.ts › cross-connect: frontend-only → backend-only › frontend-only connects to a separate backend-only instance 17.1s
backends/mock-llm-cross-connect.spec.ts › cross-connect: frontend-only → multiple backends › connects to two separate backends and switches between them 20.7s
backends/mock-llm-partial-stack.spec.ts › partial stack: --frontend-only › serves the frontend but returns 503 for backend routes 7.3s
backends/mock-llm-partial-stack.spec.ts › partial stack: --backend-only › serves backend APIs but returns 503 for the frontend root 15.3s
backends/mock-llm-partial-stack.spec.ts › partial stack: port conflict › fails with a clear error when the ingress port is occupied 106ms
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.4s
conversations/mock-llm-conversation.spec.ts › mock-LLM agent-server conversation › step 2: activate the mock-llm profile and verify settings API 6.2s
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.8s
conversations/mock-llm-image-upload.spec.ts › mock-LLM image upload › attaching an image embeds it as base64 in the LLM completion call 13.7s
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.8s
files/mock-llm-files-and-git.spec.ts › files tab, git control bar, and browser tab › step 2: start conversation and attach workspace metadata 12.4s
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.3s
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.2s
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.6s
mcp/mock-llm-mcp-github.spec.ts › MCP GitHub server install flow › step 1: GitHub card is visible on the MCP marketplace page 5.6s
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.6s
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 5.9s
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.8s
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) 6.0s
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) 5.9s
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.8s
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.6s
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.1s
regressions/mock-llm-ui-regressions.spec.ts › UI regressions › renders critic results on agent messages and finish actions 1.4s
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.2s
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.3s
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.8s
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.4s
settings/mock-llm-model-switch.spec.ts › mock-LLM /model slash command › step 2: start conversation, switch profile via /model, verify switch 7.7s
settings/mock-llm-profile-management.spec.ts › active profile deletion + reconciliation › active profile is deletable and reconciliation activates another profile 8.3s
settings/mock-llm-profile-management.spec.ts › same-model profile identity › chat header shows the correct profile when two profiles share the same model 15.5s
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.8s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › project skill in workspace/.agents/skills/ triggers on matching keyword 13.7s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › user skill in ~/.openhands/skills/ triggers on matching keyword 8.7s
skills/mock-llm-skills.spec.ts › skill loading: project, user, and deletion › deleting a user skill removes it from subsequent conversations 8.7s

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

@harish-chandramowli
harish-chandramowli marked this pull request as ready for review July 26, 2026 10:07
@neubig

neubig commented Jul 27, 2026

Copy link
Copy Markdown
Member

This repository has moved to https://github.com/OpenHands/OpenHands. We’d appreciate it if you re-opened this pull request there. Thank you!

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

Labels

type: fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants