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

Co-authored-by: Harish Chandramowli <harish.jhu@gmail.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harish Chandramowli
2026-08-21 17:38:43 +02:00
committed by GitHub
co-authored by Harish Chandramowli Engel Nyst Claude Opus 5
parent ab23be62ad
commit bad1687dec
25 changed files with 1984 additions and 94 deletions
@@ -58,7 +58,9 @@ vi.mock("#/hooks/query/use-unified-vscode-url", () => ({
useUnifiedVSCodeUrl: () => ({
data: { url: "http://localhost:8001", error: null },
isLoading: false,
refetch: vi.fn().mockResolvedValue({ data: { url: "http://localhost:8001" } }),
refetch: vi
.fn()
.mockResolvedValue({ data: { url: "http://localhost:8001" } }),
}),
}));
@@ -521,7 +523,7 @@ describe("ConversationTabs localStorage behavior", () => {
mockConversationId = REAL_CONVERSATION_ID;
});
it("should hide the vscode link when the active backend is local", () => {
it("should show the vscode link when the active backend is local", () => {
// Arrange
seedActiveBackend({
id: "local-test",
@@ -536,10 +538,10 @@ describe("ConversationTabs localStorage behavior", () => {
wrapper: createWrapper(REAL_CONVERSATION_ID),
});
// Assert
expect(
screen.queryByTestId("drawer-vscode-link"),
).not.toBeInTheDocument();
// Assert — self-hosted backends serve VSCode too; the URL comes from
// the agent server's /api/vscode/url via useUnifiedVSCodeUrl's local
// branch, rather than from cloud `exposed_urls`.
expect(screen.getByTestId("drawer-vscode-link")).toBeInTheDocument();
});
it("should show the vscode link when the active backend is cloud", () => {
@@ -560,6 +562,52 @@ describe("ConversationTabs localStorage behavior", () => {
// Assert
expect(screen.getByTestId("drawer-vscode-link")).toBeInTheDocument();
});
it("re-measures the tab row when the vscode button's own width changes", () => {
// The button's width is folded into how many tabs fit inline, and its
// presence is now resolved asynchronously (the hook probes
// /api/vscode/status). It sits inside an `ml-auto shrink-0` wrapper, so
// it appearing or disappearing leaves the row's own box unchanged —
// observing only the row would leave the fit computed against a button
// that is no longer on screen, permanently costing an inline tab.
const observed: Element[] = [];
class RecordingResizeObserver {
observe = (el: Element) => {
observed.push(el);
};
unobserve = vi.fn();
disconnect = vi.fn();
}
// Swap only this global back afterwards: vi.unstubAllGlobals() would
// also drop the localStorage/ResizeObserver stubs vitest.setup.ts
// installs in beforeAll, breaking every later test in the file.
const originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver =
RecordingResizeObserver as unknown as typeof ResizeObserver;
try {
seedActiveBackend({
id: "local-test",
name: "Local Test",
host: "http://localhost:8000",
apiKey: "",
kind: "local",
});
render(<ConversationTabs />, {
wrapper: createWrapper(REAL_CONVERSATION_ID),
});
const vscodeWrapper =
screen.getByTestId("drawer-vscode-link").parentElement;
expect(vscodeWrapper).not.toBeNull();
expect(observed).toContain(vscodeWrapper);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
}
});
});
describe("ellipsis context menu", () => {
+205 -11
View File
@@ -86,9 +86,7 @@ function makeConversation(
} as AppConversation;
}
function makeSandbox(
overrides: Partial<V1SandboxInfo> = {},
): V1SandboxInfo {
function makeSandbox(overrides: Partial<V1SandboxInfo> = {}): V1SandboxInfo {
return {
id: "sandbox-9",
created_by_user_id: null,
@@ -108,7 +106,10 @@ function makeSandbox(
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
// `retry` is overridden per-query by the hook's own `retry: 3`, so error
// paths do retry here; `retryDelay: 0` keeps them from spending the
// default exponential backoff before the query settles.
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
@@ -117,16 +118,45 @@ function createWrapper() {
);
}
/** The prefix this origin serves the editor under, as static-server injects it. */
function advertiseEditorOnOrigin(basePath: string | null) {
if (basePath === null) {
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_VSCODE_BASE_PATH__;
return;
}
(
window as unknown as Record<string, unknown>
).__AGENT_CANVAS_VSCODE_BASE_PATH__ = basePath;
}
/** An editor URL of the shape agent-server builds: this origin + its prefix. */
function editorUrlOnThisOrigin(basePath = "/vscode") {
return `${window.location.origin}${basePath}/?tkn=local-key&folder=workspace`;
}
beforeEach(() => {
vi.clearAllMocks();
// Default to an origin that routes the editor. Availability is server
// capability ∩ this origin's route table, and every pre-existing case here
// is about the server half; the origin half has its own cases below.
advertiseEditorOnOrigin("/vscode");
vi.mocked(useRuntimeIsReady).mockReturnValue(true);
vi.mocked(useActiveConversation).mockReturnValue({
data: makeConversation(),
} as unknown as ReturnType<typeof useActiveConversation>);
// Default the capability probe to "editor present and running". Local-mode
// tests that care about the capability state override this; the rest would
// otherwise never reach the URL request, which is gated on it.
vi.mocked(AgentServerConversationService.getVSCodeStatus).mockResolvedValue({
enabled: true,
running: true,
});
});
afterEach(() => {
vi.clearAllMocks();
advertiseEditorOnOrigin(null);
});
describe("useUnifiedVSCodeUrl", () => {
@@ -149,9 +179,7 @@ describe("useUnifiedVSCodeUrl", () => {
expect(result.current.data?.url).toBe(
"https://vscode-abc.staging-runtime.all-hands.dev/?tkn=sek&folder=%2Fworkspace%2Fproject",
);
expect(
AgentServerConversationService.getVSCodeUrl,
).not.toHaveBeenCalled();
expect(AgentServerConversationService.getVSCodeUrl).not.toHaveBeenCalled();
});
it("returns null url in cloud mode when the sandbox has no VSCODE exposed_url", async () => {
@@ -172,6 +200,10 @@ describe("useUnifiedVSCodeUrl", () => {
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.url).toBeNull();
// Cloud is deliberately excluded from `isUnavailable`: a sandbox that is
// still STARTING will populate exposed_urls shortly, so the control stays
// visible. Only self-hosted backends can report a final "no editor".
expect(result.current.isUnavailable).toBe(false);
});
it("falls through to AgentServerConversationService.getVSCodeUrl in local mode", async () => {
@@ -180,7 +212,7 @@ describe("useUnifiedVSCodeUrl", () => {
// for the cloud/local branch that was added to the hook.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
vi.mocked(AgentServerConversationService.getVSCodeUrl).mockResolvedValue({
vscode_url: "http://localhost:8001/?tkn=local-key&folder=workspace",
vscode_url: editorUrlOnThisOrigin(),
});
// Act
@@ -190,14 +222,176 @@ describe("useUnifiedVSCodeUrl", () => {
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(
AgentServerConversationService.getVSCodeUrl,
).toHaveBeenCalledWith(
expect(AgentServerConversationService.getVSCodeUrl).toHaveBeenCalledWith(
"conv-123",
"http://abc.staging-runtime.all-hands.dev/api/conv/1",
"sek",
);
expect(batchGetCloudSandboxes).not.toHaveBeenCalled();
expect(ConversationService.getVSCodeUrl).not.toHaveBeenCalled();
// A backend that hands back a usable URL is available, so consumers
// render the control.
expect(result.current.isUnavailable).toBe(false);
});
it("reports isUnavailable in local mode when the backend has VSCode disabled", async () => {
// Arrange — `enable_vscode: false`. The capability probe answers 200 with
// `enabled: false`, so this is a value rather than an error: the control
// is dropped without the URL request ever running, which is what keeps
// the 503 from `/vscode/url` (and its toast) off the screen entirely.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
vi.mocked(AgentServerConversationService.getVSCodeStatus).mockResolvedValue(
{
enabled: false,
running: false,
message: "VSCode is disabled in configuration",
},
);
// Act
const { result } = renderHook(() => useUnifiedVSCodeUrl(), {
wrapper: createWrapper(),
});
// Assert
await waitFor(() => expect(result.current.isUnavailable).toBe(true));
expect(result.current.isError).toBe(false);
expect(AgentServerConversationService.getVSCodeUrl).not.toHaveBeenCalled();
expect(ConversationService.getVSCodeUrl).not.toHaveBeenCalled();
});
it("reports isUnavailable in local mode when the editor is enabled but not running", async () => {
// Arrange — configured, but the process failed to start (or has died).
// agent-server awaits VSCodeService.start() in its lifespan before it
// serves any request, so `running: false` here is terminal rather than a
// startup window. `/vscode/url` would still hand back a URL, so this
// state is only visible through the probe.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
vi.mocked(AgentServerConversationService.getVSCodeStatus).mockResolvedValue(
{
enabled: true,
running: false,
},
);
// Act
const { result } = renderHook(() => useUnifiedVSCodeUrl(), {
wrapper: createWrapper(),
});
// Assert
await waitFor(() => expect(result.current.isUnavailable).toBe(true));
expect(result.current.isError).toBe(false);
expect(AgentServerConversationService.getVSCodeUrl).not.toHaveBeenCalled();
});
it("keeps a failing capability probe observable as an error rather than hiding the control", async () => {
// Arrange — a transport, auth or server fault on the probe itself. This
// is the case the previous `isError`-derived `isUnavailable` conflated
// with a disabled editor: it must stay an error (so retry and the global
// toast still apply) and must NOT silently remove the control, because
// nothing here says the deployment has no editor.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
vi.mocked(AgentServerConversationService.getVSCodeStatus).mockRejectedValue(
new Error("Request failed with status code 401"),
);
// Act
const { result } = renderHook(() => useUnifiedVSCodeUrl(), {
wrapper: createWrapper(),
});
// Assert
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.isUnavailable).toBe(false);
});
it("reports isUnavailable in local mode when the backend reports no URL", async () => {
// Arrange — the probe reports a running editor, but the URL request
// carries no URL to point at (e.g. no connection token). Distinct from
// both cases above: the capability state is fine and the query settles in
// `success`, so neither the probe nor `isError` would catch it.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
vi.mocked(AgentServerConversationService.getVSCodeUrl).mockResolvedValue({
vscode_url: null,
});
// Act
const { result } = renderHook(() => useUnifiedVSCodeUrl(), {
wrapper: createWrapper(),
});
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.url).toBeNull();
expect(result.current.isUnavailable).toBe(true);
});
it("reports isUnavailable when this origin serves no editor (public mode)", async () => {
// Arrange — docker's public-mode static server shares one agent-server
// with the main instance but deliberately omits the editor route, because
// the editor's connection token is the session API key and that origin
// exists to test the unauthenticated case. The shared agent-server still
// answers `enabled: true, running: true`, so a control gated on the probe
// alone renders here and then falls through to the SPA.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
advertiseEditorOnOrigin(null);
// Act
const { result } = renderHook(() => useUnifiedVSCodeUrl(), {
wrapper: createWrapper(),
});
// Assert — hidden, and cheaply: neither request is worth making.
await waitFor(() => expect(result.current.isUnavailable).toBe(true));
expect(result.current.isError).toBe(false);
expect(
AgentServerConversationService.getVSCodeStatus,
).not.toHaveBeenCalled();
expect(AgentServerConversationService.getVSCodeUrl).not.toHaveBeenCalled();
});
it("reports isUnavailable when the URL resolves outside this origin's editor route", async () => {
// Arrange — a conversation on an extra backend (dev-extra-backend.mjs),
// registered from a browser whose origin belongs to the bundled stack.
// That backend configures no prefix of its own, so agent-server appends
// nothing to the origin we send it and hands back the canvas root. Same
// origin, and the probe is truthful about *that* server — but clicking
// would reopen this app, or reach the bundled stack's editor and hence a
// different container's workspace.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
vi.mocked(AgentServerConversationService.getVSCodeUrl).mockResolvedValue({
vscode_url: `${window.location.origin}/?tkn=extra-backend-key&folder=workspace`,
});
// Act
const { result } = renderHook(() => useUnifiedVSCodeUrl(), {
wrapper: createWrapper(),
});
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.isUnavailable).toBe(true);
});
it("renders the control when the URL lands under this origin's editor route", async () => {
// Arrange — the bundled stack: the origin advertises `/vscode` because it
// routes `/vscode`, and the conversation's own agent-server is configured
// with the matching prefix. This is the case the whole feature exists for,
// pinned here so the guards above cannot be tightened into hiding it.
vi.mocked(useActiveBackend).mockReturnValue(localBackend);
vi.mocked(AgentServerConversationService.getVSCodeUrl).mockResolvedValue({
vscode_url: editorUrlOnThisOrigin("/vscode"),
});
// Act
const { result } = renderHook(() => useUnifiedVSCodeUrl(), {
wrapper: createWrapper(),
});
// Assert
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.isUnavailable).toBe(false);
expect(result.current.data?.url).toBe(editorUrlOnThisOrigin("/vscode"));
});
});
+7 -1
View File
@@ -433,17 +433,23 @@ describe("buildAgentServerTelemetryEnv", () => {
it("includes telemetry defaults in the full agent-server environment", () => {
const env = buildAgentServerEnv(
{
cwd: "/tmp/cwd",
backendPort: 18000,
tmuxTmpDir: "/tmp/tmux",
stateDir: "/tmp/state",
conversationsPath: "/tmp/conversations",
workspacesPath: "/tmp/workspaces",
bashEventsDir: "/tmp/bash-events",
vscodePort: 19000,
vscodeBasePath: "/vscode",
secretKey: "secret",
sessionApiKey: "session",
backendBaseUrl: "http://127.0.0.1:18000",
backendHost: "127.0.0.1:18000",
workingDir: "/tmp/workspaces",
canvasToolsDir: "/tmp/tools",
},
{},
{ env: {} },
);
expect(env).toMatchObject({
+92 -1
View File
@@ -24,6 +24,7 @@ import {
getAgentServerBaseUrl,
getFrontendBackend,
getLocalServiceRoutes,
getRejectPrefixes,
setServiceLogListener,
spawnService,
validateLocalAutomationPath,
@@ -33,7 +34,12 @@ import {
DEFAULT_BACKEND_PORT,
DEFAULT_AUTOMATION_PORT,
} from "../../scripts/dev-with-automation.mjs";
import { resetPersistedSessionApiKeyCache } from "../../scripts/dev-safe.mjs";
import {
buildAgentServerEnv,
buildSafeDevConfig,
resetPersistedSessionApiKeyCache,
} from "../../scripts/dev-safe.mjs";
import { createRouter } from "../../scripts/proxy-utils.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -630,6 +636,79 @@ describe("stack mode routing", () => {
expect(routeArgs).not.toContain("--default");
});
it("routes the editor base path to the vscode port in the stock config", async () => {
// The whole point of the base path is that a stock launcher — no
// INGRESS_ROUTES, no OH_VSCODE_BASE_PATH — already reaches the editor
// through the single ingress origin. Both the outer ingress and the
// static-server route list are built from getLocalServiceRoutes, so
// asserting it here covers both.
const config = await buildConfig({}, envWithIsolatedKeyPath());
expect(config.vscodeBasePath).toBe("/vscode");
const routes = getLocalServiceRoutes(config);
expect(routes).toContainEqual([
config.vscodeBasePath,
`http://127.0.0.1:${config.vscodePort}`,
]);
expect(buildRouteArgs(routes)).toContain(
`/vscode=http://127.0.0.1:${config.vscodePort}`,
);
// The editor must not collide with the agent-server or automation ports;
// it is a separate process reached through the same origin.
expect(config.vscodePort).not.toBe(config.agentServerPort);
expect(config.vscodePort).not.toBe(config.autoBackendPort);
});
it("passes the agent-server a base path matching the ingress route", async () => {
// The advertised URL and the route that serves it come from two different
// places (agent-server's /api/vscode/url vs. the proxy route table). They
// only agree because both read the same config value — assert that rather
// than each side in isolation.
const config = await buildConfig({}, envWithIsolatedKeyPath());
const env = buildAgentServerEnv(
buildSafeDevConfig(process.cwd(), {
...envWithIsolatedKeyPath(),
OH_CANVAS_SAFE_BACKEND_PORT: String(config.agentServerPort),
OH_CANVAS_SAFE_VSCODE_PORT: String(config.vscodePort),
}),
{ vscodeBasePath: config.vscodeBasePath },
);
expect(env.OH_VSCODE_BASE_PATH).toBe(config.vscodeBasePath);
expect(env.OH_VSCODE_PORT).toBe(String(config.vscodePort));
const [, vscodeBackend] =
getLocalServiceRoutes(config).find(
([prefix]) => prefix === env.OH_VSCODE_BASE_PATH,
) ?? [];
expect(vscodeBackend).toBe(`http://127.0.0.1:${env.OH_VSCODE_PORT}`);
});
it("preserves the editor prefix rather than stripping it", async () => {
// openvscode-server is launched with --server-base-path, so it generates
// its HTTP and WebSocket URLs beneath the prefix and only answers there.
// A router that stripped the prefix would 404 every asset.
const config = await buildConfig({}, envWithIsolatedKeyPath());
const routes = Object.fromEntries(getLocalServiceRoutes(config));
const route = createRouter(routes);
const vscodeBackend = `http://127.0.0.1:${config.vscodePort}`;
expect(route("/vscode")).toBe(vscodeBackend);
expect(route("/vscode/")).toBe(vscodeBackend);
// Workbench assets and the WebSocket upgrade path both sit under the
// prefix; the proxy forwards req.url unchanged, so matching is all that
// is needed for the prefix to survive.
expect(route("/vscode/static/out/vs/workbench/workbench.web.main.js")).toBe(
vscodeBackend,
);
expect(route("/vscode/stable-abc/?tkn=k")).toBe(vscodeBackend);
// Longest-prefix matching must not let /vscode swallow /api or vice versa.
expect(route("/api/vscode/url")).toBe(
`http://127.0.0.1:${config.agentServerPort}`,
);
});
it("addresses the agent-server over IPv4 for readiness and secret seeding", async () => {
const config = await buildConfig({}, envWithIsolatedKeyPath());
@@ -642,6 +721,18 @@ describe("stack mode routing", () => {
);
});
it("rejects the editor prefix when no agent-server is launched", async () => {
// Without an agent-server there is no editor behind the prefix. Falling
// back to index.html would answer an editor request with the canvas shell.
const config = await buildConfig(
{ frontendOnly: true },
envWithIsolatedKeyPath(),
);
expect(getLocalServiceRoutes(config)).toEqual([]);
expect(getRejectPrefixes(config)).toContain("/vscode");
});
it("rejects mutually exclusive partial-stack modes", async () => {
await expect(
buildConfig(
@@ -0,0 +1,384 @@
// @vitest-environment node
//
// Drift-detection for the Docker install path's editor route.
//
// The VSCode button advertises a URL built by agent-server from
// OH_VSCODE_BASE_PATH, and that URL only resolves because the static server
// carries a route for the same prefix to the same port. Those two facts live in
// separate files (docker/entrypoint.sh, config/defaults.json via the Dockerfile's
// generated defaults.env), so nothing but a test stops them drifting apart and
// leaving a button that points at the canvas shell instead of the editor.
//
// The npm launcher's equivalent wiring is covered in dev-with-automation.test.ts
// against the real functions. This file covers the shell/Docker half: the
// entrypoint has no importable surface, so the env-resolution block is extracted
// between its markers and executed under bash, which exercises the shipped
// precedence rather than asserting that particular strings appear in the file.
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
function read(rel: string): string {
return readFileSync(path.join(repoRoot, rel), "utf-8");
}
const defaults = JSON.parse(read("config/defaults.json")) as {
ports: { vscode: number; proxy: number };
paths: { vscodeBasePath: string };
};
const entrypoint = read("docker/entrypoint.sh");
const dockerfile = read("docker/Dockerfile");
// Both static-server invocations (the normal one and the --auth-required
// public-mode one started when PUBLIC_MODE_PORT is set) must carry the route;
// the public-mode server is what the auth-mode E2E suite drives.
function staticServerInvocations(): string[] {
return entrypoint
.split("node /opt/agent-canvas/static-server.mjs")
.slice(1)
.map((chunk) => chunk.split("\nSTATIC_PID")[0].split("\n PIDS")[0]);
}
// ── Executing the entrypoint's editor-config block ──────────────────────────
// The block resolves the editor port/prefix from the OH_* variables, this
// image's aliases and the generated defaults.env, then exports the pair to
// agent-server and builds the route string the static servers register. Those
// are two consumers of one setting, so the tests below run the real block and
// compare what each consumer ends up seeing.
const BLOCK_START = "# >>> vscode-config";
const BLOCK_END = "# <<< vscode-config";
function editorConfigBlock(): string {
const start = entrypoint.indexOf(BLOCK_START);
const end = entrypoint.indexOf(BLOCK_END);
if (start === -1 || end === -1) {
throw new Error(
`docker/entrypoint.sh is missing the "${BLOCK_START}"/"${BLOCK_END}" markers; ` +
"the editor-config block can no longer be located, so its behavior is untested.",
);
}
return entrypoint.slice(start, end);
}
interface ResolvedEditorConfig {
status: number | null;
stderr: string;
/** What agent-server is told, and therefore what /api/vscode/url advertises. */
advertisedBasePath: string;
advertisedPort: string;
/** What every static-server instance registers. */
route: string;
}
function resolveEditorConfig(
env: Record<string, string> = {},
): ResolvedEditorConfig {
const script = [
"set -uo pipefail",
// Defined near the top of entrypoint.sh, above the extracted block.
`log_error() { printf 'ERROR: %s\\n' "$*" >&2; }`,
editorConfigBlock(),
`printf '%s\\n%s\\n%s\\n' "$OH_VSCODE_BASE_PATH" "$OH_VSCODE_PORT" "$VSCODE_ROUTE"`,
].join("\n");
// Deliberately not inheriting the ambient environment: a developer with
// OH_VSCODE_* exported would otherwise change what these tests measure.
const res = spawnSync("bash", ["-c", script], {
encoding: "utf-8",
env: { PATH: process.env.PATH ?? "", ...env },
});
const [advertisedBasePath = "", advertisedPort = "", route = ""] = res.stdout
.trim()
.split("\n");
return {
status: res.status,
stderr: res.stderr,
advertisedBasePath,
advertisedPort,
route,
};
}
/** The invariant: the advertised URL's prefix/port are the ones being routed. */
function expectRouteMatchesAdvertised(resolved: ResolvedEditorConfig): void {
expect(resolved.status).toBe(0);
expect(resolved.route).toBe(
`${resolved.advertisedBasePath}=http://127.0.0.1:${resolved.advertisedPort}`,
);
}
describe("docker editor route", () => {
it("centralizes the base path and port in defaults.json", () => {
expect(defaults.paths.vscodeBasePath).toBe("/vscode");
expect(defaults.paths.vscodeBasePath.startsWith("/")).toBe(true);
expect(Number.isInteger(defaults.ports.vscode)).toBe(true);
});
it("exports both values from defaults.json into the generated defaults.env", () => {
// The container has no jq/python, so the Dockerfile bakes defaults.json
// into a shell-sourceable env file. A value missing here silently falls
// back to the hardcoded default in entrypoint.sh.
expect(dockerfile).toContain(
"'CONFIG_VSCODE_BASE_PATH=' + c.paths.vscodeBasePath",
);
expect(dockerfile).toContain("'CONFIG_VSCODE_PORT=' + c.ports.vscode");
});
it("registers the editor route on the normal static-server instance", () => {
const invocations = staticServerInvocations();
// Normal + public-mode. If this count changes, decide deliberately which
// of the two the new instance resembles.
expect(invocations).toHaveLength(2);
const [normal] = invocations;
expect(normal).toContain('--route "$VSCODE_ROUTE"');
// The route string is assigned once, beside the exports it is derived
// from. Two independently-built route strings are the drift this whole
// file exists to prevent.
const assignments = entrypoint.match(/^VSCODE_ROUTE=/gm) ?? [];
expect(assignments).toHaveLength(1);
});
it("advertises the editor prefix on the instance that routes it", () => {
// Routing the editor and telling the frontend about it are the same
// decision. static-server refuses to start if the advertised prefix has no
// route, so this pins the other direction: an instance that routes the
// editor must also advertise it, or the control never renders and the
// feature is silently off.
const [normal] = staticServerInvocations();
expect(normal).toContain('--vscode-base-path "$VSCODE_BASE_PATH"');
});
it("keeps the editor off the public-mode (--auth-required) instance", () => {
// --auth-required only decides whether the session key is injected into
// the served HTML: the dispatcher matches routes before consulting it, so
// it does not gate proxied paths. The other routes are safe on that
// footing because agent-server checks the session key itself, but the
// editor's own credential is the connection token in its query string,
// and agent-server derives that from session_api_keys[0] — the same secret
// that authenticates /api. Routing it here would publish that secret in a
// browser-navigable URL on the origin whose whole purpose is to exercise
// the unauthenticated case.
const publicMode = staticServerInvocations().find((invocation) =>
invocation.includes("--auth-required"),
);
expect(publicMode).toBeDefined();
expect(publicMode).not.toContain("VSCODE_ROUTE");
// And it must not advertise one either. Omitting only the route would
// leave the control rendering — the agent-server this instance shares with
// the main one still reports the editor as available — and the click would
// fall through to the SPA.
expect(publicMode).not.toContain("--vscode-base-path");
});
it("sends Referrer-Policy: no-referrer on the editor path", () => {
// The advertised URL carries the connection token as a query parameter and
// the workbench loads webviews, previews and extension content from that
// document, so a Referer would carry the token to each of them.
const [normal] = staticServerInvocations();
expect(normal).toContain('--no-referrer-prefix "$VSCODE_BASE_PATH"');
});
it("routes the editor to its own port, not the agent-server", () => {
// The editor is a separate process. Pointing the prefix at the
// agent-server port would 404 the workbench.
expect(entrypoint).toMatch(
/^VSCODE_ROUTE="\$\{VSCODE_BASE_PATH\}=http:\/\/127\.0\.0\.1:\$\{VSCODE_PORT\}"$/m,
);
expect(defaults.ports.vscode).not.toBe(defaults.ports.proxy);
});
it("does not publish the editor port", () => {
// The single-origin shape is the point: the editor is reachable only
// through the proxy port's path prefix, so it inherits the canvas's
// auth/ingress posture instead of needing a second exposed port.
expect(dockerfile).not.toMatch(
new RegExp(`^\\s*EXPOSE\\s+${defaults.ports.vscode}\\b`, "m"),
);
});
});
// The entrypoint only ever runs inside the Linux image; bash is not a given on a
// Windows developer machine, and CI runs the unit suite on ubuntu only.
describe.skipIf(process.platform === "win32")(
"docker editor config resolution",
() => {
it("advertises and routes the same pair with no overrides", () => {
const resolved = resolveEditorConfig();
expectRouteMatchesAdvertised(resolved);
// Literal fallbacks used when defaults.env is absent — they must not
// drift from the central config either.
expect(resolved.advertisedBasePath).toBe(defaults.paths.vscodeBasePath);
expect(resolved.advertisedPort).toBe(String(defaults.ports.vscode));
});
it("takes the defaults baked into defaults.env", () => {
const resolved = resolveEditorConfig({
CONFIG_VSCODE_BASE_PATH: "/editor",
CONFIG_VSCODE_PORT: "9001",
});
expectRouteMatchesAdvertised(resolved);
expect(resolved.advertisedBasePath).toBe("/editor");
expect(resolved.advertisedPort).toBe("9001");
});
// The regression this block was restructured for: agent-server's own
// documented variables are what a self-hosted deployment is most likely to
// already set, and setting one of them used to move the editor without
// moving the route.
it("moves the route when only OH_VSCODE_BASE_PATH is set", () => {
const resolved = resolveEditorConfig({
OH_VSCODE_BASE_PATH: "/editor",
CONFIG_VSCODE_BASE_PATH: "/vscode",
});
expectRouteMatchesAdvertised(resolved);
expect(resolved.advertisedBasePath).toBe("/editor");
expect(resolved.route).toContain("/editor=");
});
it("moves the route when only OH_VSCODE_PORT is set", () => {
const resolved = resolveEditorConfig({
OH_VSCODE_PORT: "9001",
CONFIG_VSCODE_PORT: "8001",
});
expectRouteMatchesAdvertised(resolved);
expect(resolved.advertisedPort).toBe("9001");
expect(resolved.route).toBe("/vscode=http://127.0.0.1:9001");
});
it("honours this image's aliases too", () => {
const resolved = resolveEditorConfig({
VSCODE_BASE_PATH: "/editor",
VSCODE_PORT: "9001",
});
expectRouteMatchesAdvertised(resolved);
expect(resolved.advertisedBasePath).toBe("/editor");
expect(resolved.advertisedPort).toBe("9001");
});
it("keeps one effective pair when both names are set and disagree", () => {
const resolved = resolveEditorConfig({
OH_VSCODE_BASE_PATH: "/editor",
OH_VSCODE_PORT: "9001",
VSCODE_BASE_PATH: "/vscode",
VSCODE_PORT: "8001",
});
// Whichever wins, the two consumers must not disagree — and the OH_*
// variables win, since they are what agent-server itself documents.
expectRouteMatchesAdvertised(resolved);
expect(resolved.advertisedBasePath).toBe("/editor");
expect(resolved.advertisedPort).toBe("9001");
});
it.each(["editor", "/editor", "/editor/", "//editor//"])(
"normalizes %j to one spelling for both consumers",
(given) => {
const resolved = resolveEditorConfig({ OH_VSCODE_BASE_PATH: given });
expectRouteMatchesAdvertised(resolved);
expect(resolved.advertisedBasePath).toBe("/editor");
},
);
it("refuses a base path that resolves to the site root", () => {
// Routing "/" to the editor would hand it the whole origin, including the
// canvas itself — fail loudly at startup instead of serving that.
const resolved = resolveEditorConfig({ OH_VSCODE_BASE_PATH: "/" });
expect(resolved.status).not.toBe(0);
expect(resolved.stderr).toContain("site root");
});
// static-server keys its route table by prefix and the editor route is
// registered last, so a colliding prefix silently *replaces* the earlier
// route instead of failing. `/api` is the dangerous one: every API call
// would be proxied to the editor port, which reads as a total outage with
// no error to explain it. The "/" guard above does not catch these.
it.each([
"/api",
"/sockets",
"/server_info",
"/health",
"/openapi.json",
"/canvas",
])("refuses %j, which would take over an existing route", (given) => {
const resolved = resolveEditorConfig({
AGENT_CANVAS_BASE_PATH: "/canvas",
OH_VSCODE_BASE_PATH: given,
});
expect(resolved.status).not.toBe(0);
expect(resolved.stderr).toContain("collides");
});
// The collision guard compares two prefixes, so both have to be normalized
// the same way or a noncanonical spelling walks straight past it.
// static-server normalizes whatever `--base-path` it is handed, so every
// spelling below mounts the canvas at `/canvas` — the guard has to be
// looking at the same value the router will.
it.each(["canvas", "/canvas", "/canvas/", "//canvas//"])(
"refuses an editor prefix colliding with AGENT_CANVAS_BASE_PATH spelled %j",
(canvasBasePath) => {
const resolved = resolveEditorConfig({
AGENT_CANVAS_BASE_PATH: canvasBasePath,
OH_VSCODE_BASE_PATH: "/canvas",
});
expect(resolved.status).not.toBe(0);
expect(resolved.stderr).toContain("collides");
},
);
it("guards the default canvas mount without being told it", () => {
// Resolving AGENT_CANVAS_BASE_PATH inside the extracted block is what
// makes this reachable: a deployment that moves only the editor onto the
// canvas's default mount never sets AGENT_CANVAS_BASE_PATH at all.
const resolved = resolveEditorConfig({ OH_VSCODE_BASE_PATH: "/canvas" });
expect(resolved.status).not.toBe(0);
expect(resolved.stderr).toContain("collides");
});
it("accepts a noncanonical canvas mount that does not collide", () => {
// The guard must reject overlap, not coexistence: normalizing both sides
// must not start rejecting layouts that are actually fine.
const resolved = resolveEditorConfig({
AGENT_CANVAS_BASE_PATH: "canvas/",
OH_VSCODE_BASE_PATH: "editor",
});
expect(resolved.status).toBe(0);
expectRouteMatchesAdvertised(resolved);
expect(resolved.advertisedBasePath).toBe("/editor");
});
it.each([
// static-server's --route parser cuts at the *first* '=', so this parses
// as prefix "/vs" pointing at the garbage url "code=http://…" — a silent
// outage under /vs rather than a startup failure.
["/vs=code", "may only contain"],
["/a b", "may only contain"],
["/x?y", "may only contain"],
["/x#y", "may only contain"],
// Multi-segment prefixes are not wrong in principle, but agent-server
// strips the slashes when building the advertised URL, so the two sides
// would disagree. Reject rather than silently half-support it.
["/deep/path", "single path segment"],
["/../api", "single path segment"],
])("refuses %j", (given, expectedMessage) => {
const resolved = resolveEditorConfig({ OH_VSCODE_BASE_PATH: given });
expect(resolved.status).not.toBe(0);
expect(resolved.stderr).toContain(expectedMessage);
});
it("refuses a non-numeric port", () => {
// The port is interpolated straight into a proxy target URL, so without
// this it fails on the first editor request instead of at startup.
const resolved = resolveEditorConfig({ OH_VSCODE_PORT: "not-a-port" });
expect(resolved.status).not.toBe(0);
expect(resolved.stderr).toContain("must be a number");
});
},
);
+70
View File
@@ -738,3 +738,73 @@ describe("ingress socket-error resilience", () => {
expect(ingressStderr).not.toContain("Unhandled 'error' event");
});
});
describe("ingress --no-referrer-prefix", () => {
// The editor is advertised as `<origin><prefix>/?tkn=<token>`, and
// agent-server derives that token from session_api_keys[0] — the same secret
// that authenticates /api. The workbench then loads webviews, previews and
// extension content from that document, so without a Referrer-Policy the
// token rides along on each of those subrequests.
let backend: Server | undefined;
let ingressProcess: ChildProcess | undefined;
let ingressPort: number;
beforeAll(async () => {
backend = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ path: req.url }));
});
const backendPort = await listenOnLoopback(backend);
ingressPort = await getFreePort();
ingressProcess = spawn(
process.execPath,
[
ingressScript,
"--port",
ingressPort.toString(),
"--route",
`/vscode=${originForPort(backendPort)}`,
"--route",
`/api=${originForPort(backendPort)}`,
"--no-referrer-prefix",
"/vscode",
],
{ cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] },
);
await waitForPort(ingressPort, ingressProcess);
});
afterAll(async () => {
await stopChild(ingressProcess);
await closeServer(backend);
});
it("sets no-referrer on the editor prefix", async () => {
const response = await fetch(
`${originForPort(ingressPort)}/vscode/?tkn=secret`,
);
expect(response.status).toBe(200);
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
});
it("covers assets under the prefix, which is where the leak would happen", async () => {
const response = await fetch(
`${originForPort(ingressPort)}/vscode/static/out/vs/workbench/workbench.web.main.js`,
);
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
});
it("leaves other routes alone", async () => {
// Deliberately scoped rather than a blanket policy for the origin: the
// canvas itself is served here too, and its outbound requests are not the
// problem being solved.
const response = await fetch(`${originForPort(ingressPort)}/api/anything`);
expect(response.status).toBe(200);
expect(response.headers.get("referrer-policy")).toBeNull();
});
});
+129 -1
View File
@@ -2,7 +2,7 @@ import { createServer, request, type Server } from "node:http";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseArgs, startStaticServer } from "../../scripts/static-server.mjs";
@@ -172,6 +172,108 @@ describe("static-server.mjs", () => {
});
});
describe("--vscode-base-path", () => {
it("parses the prefix and normalizes a trailing slash", () => {
const config = parseArgs([
"--route",
"/vscode=http://127.0.0.1:8001",
"--vscode-base-path",
"/vscode/",
]);
expect(config.vscodeBasePath).toBe("/vscode");
});
it("defaults to null so an origin advertises nothing unless asked", () => {
expect(parseArgs([]).vscodeBasePath).toBeNull();
});
it("rejects a value that is not a path", () => {
expect(() => parseArgs(["--vscode-base-path", "vscode"])).toThrow(
/must start with '\//,
);
});
// The guard that makes this flag trustworthy: advertising the editor and
// routing it are the same decision, so a prefix with no route behind it
// must not start. Without it, the frontend would render the control and
// the navigation would fall through to the SPA — the exact bug the flag
// exists to prevent.
it("refuses to start when the advertised prefix has no matching route", () => {
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as never);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
expect(() =>
parseArgs([
"--route",
"/api=http://127.0.0.1:8000",
"--vscode-base-path",
"/vscode",
]),
).toThrow("process.exit");
expect(exit).toHaveBeenCalledWith(1);
expect(error.mock.calls[0][0]).toContain("has no matching --route");
} finally {
exit.mockRestore();
error.mockRestore();
}
});
});
describe("vscode base path injection", () => {
async function startServerAdvertising(
dir: string,
vscodeBasePath: string | null,
) {
const server = await startStaticServer({
port: 0,
host: "127.0.0.1",
dir,
routes: {},
vscodeBasePath,
});
servers.push(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Static server did not bind to a TCP port");
}
return `http://127.0.0.1:${address.port}`;
}
function makeBuildDir() {
const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
tempDirs.push(buildDir);
writeFileSync(
path.join(buildDir, "index.html"),
"<html><head></head><body>app</body></html>",
);
return buildDir;
}
// Read by getOriginVSCodeBasePath() in src/utils/vscode-origin.ts, which
// is what lets the frontend tell "this server has an editor" apart from
// "this origin can reach it".
it("exposes the prefix on window.__AGENT_CANVAS_VSCODE_BASE_PATH__", async () => {
const origin = await startServerAdvertising(makeBuildDir(), "/vscode");
const body = await (await fetch(`${origin}/`)).text();
expect(body).toContain(
'window.__AGENT_CANVAS_VSCODE_BASE_PATH__="/vscode"',
);
});
// Public mode: the same document, served without the advertisement, is
// what hides the control on an origin that has no editor route.
it("injects nothing when the origin serves no editor", async () => {
const origin = await startServerAdvertising(makeBuildDir(), null);
const body = await (await fetch(`${origin}/`)).text();
expect(body).not.toContain("__AGENT_CANVAS_VSCODE_BASE_PATH__");
});
});
describe("runtime services info exposure", () => {
async function startServerWithRuntimeInfo(
dir: string,
@@ -628,6 +730,32 @@ describe("static-server.mjs", () => {
});
});
describe("--no-referrer-prefix", () => {
it("defaults to none", () => {
expect(parseArgs([]).noReferrerPrefixes).toEqual([]);
});
it("collects repeated prefixes", () => {
const config = parseArgs([
"--no-referrer-prefix",
"/vscode",
"--no-referrer-prefix",
"/editor",
]);
expect(config.noReferrerPrefixes).toEqual(["/vscode", "/editor"]);
});
it("rejects a prefix without a leading slash", () => {
expect(() => parseArgs(["--no-referrer-prefix", "vscode"])).toThrow(
/must start with/,
);
});
// Behaviour on a live proxied response is covered in ingress.test.ts,
// which drives a real child process; an in-process proxy deadlocks against
// the MSW interceptor this suite installs globally.
});
it("keeps paths confined to the static directory", async () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-parent-"));
tempDirs.push(parentDir);
@@ -0,0 +1,239 @@
// @vitest-environment node
//
// Guards the pairing between advertising the editor prefix and serving it.
//
// `OH_VSCODE_BASE_PATH` changes what `/api/vscode/url` advertises: agent-server
// appends the prefix to the browser origin the frontend sends. Nothing about
// setting it makes that URL resolve — the origin has to route the prefix to the
// editor port as well. A launcher that sets it without registering the route
// advertises `<origin>/vscode/…`, which serves the canvas SPA shell, so the
// editor button opens a second copy of the canvas.
//
// `buildAgentServerEnv` is shared by every launcher, so when the prefix was a
// field on its config object it was on for all of them while only some had the
// route. It is now an explicit argument, and this file asserts that no launcher
// passes it without also serving it.
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
buildAgentServerEnv,
buildSafeDevConfig,
} from "../../scripts/dev-safe.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
function readScript(name: string): string {
return readFileSync(path.join(repoRoot, "scripts", name), "utf-8");
}
/**
* Source text of every `buildAgentServerEnv(...)` call in `source`, with
* balanced parentheses so nested calls and object literals are included.
*/
function buildAgentServerEnvCalls(source: string): string[] {
const calls: string[] = [];
const needle = "buildAgentServerEnv(";
let from = 0;
for (;;) {
const start = source.indexOf(needle, from);
if (start === -1) break;
from = start + needle.length;
// Skip the definition and the import/export lists, which are not calls.
const lineStart = source.lastIndexOf("\n", start) + 1;
const line = source.slice(lineStart, start);
if (line.includes("function ")) continue;
let depth = 0;
let end = start + needle.length - 1;
for (let i = end; i < source.length; i += 1) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
}
}
calls.push(source.slice(start, end + 1));
}
return calls;
}
function optsIntoPrefixMode(source: string): boolean {
return buildAgentServerEnvCalls(source).some((call) =>
call.includes("vscodeBasePath"),
);
}
describe("editor base path is opt-in", () => {
it("omits OH_VSCODE_BASE_PATH unless the caller asks for it", () => {
const config = buildSafeDevConfig(process.cwd(), {
OH_SESSION_API_KEY_PATH: path.join(repoRoot, "node_modules", ".test-key"),
});
const env = buildAgentServerEnv(config);
// Not "" or undefined-but-present: agent-server reads the variable's
// presence, so an empty string would still put it into prefix-mode.
expect(env).not.toHaveProperty("OH_VSCODE_BASE_PATH");
// The port is unconditional — the editor still runs, it is just advertised
// on its own port rather than under a prefix.
expect(env.OH_VSCODE_PORT).toBe(String(config.vscodePort));
});
it("sets OH_VSCODE_BASE_PATH to exactly what the caller passed", () => {
const config = buildSafeDevConfig(process.cwd(), {
OH_SESSION_API_KEY_PATH: path.join(repoRoot, "node_modules", ".test-key"),
});
const env = buildAgentServerEnv(config, { vscodeBasePath: "/editor" });
expect(env.OH_VSCODE_BASE_PATH).toBe("/editor");
});
});
describe("every launcher that advertises the prefix also serves it", () => {
it("dev-with-automation opts in and routes through getLocalServiceRoutes", () => {
const source = readScript("dev-with-automation.mjs");
expect(optsIntoPrefixMode(source)).toBe(true);
// The route lives in getLocalServiceRoutes, which both the static server
// and the ingress build their tables from. Behaviour is asserted directly
// in dev-with-automation.test.ts.
expect(source).toContain("function getLocalServiceRoutes");
// IPv4 loopback, not `localhost`: these services bind to 127.0.0.1 and
// `localhost` can resolve to ::1, which is why every other local proxy
// target in this repo is written the same way.
expect(source).toMatch(
/config\.vscodeBasePath,\s*\n\s*`http:\/\/127\.0\.0\.1/,
);
});
it("dev-static opts in and reuses the same route table", () => {
const source = readScript("dev-static.mjs");
expect(optsIntoPrefixMode(source)).toBe(true);
// Both of its proxies (static server and ingress) must build their routes
// from the shared helper rather than a hand-maintained copy. The copy this
// replaced had already drifted: it was missing the editor prefix.
expect(source).toMatch(
/function buildLocalServiceRouteArgs[\s\S]*?buildRouteArgs\(\s*getLocalServiceRoutes\(/,
);
const routeArgUses = source.match(
/\.\.\.buildLocalServiceRouteArgs\(config\)/g,
);
expect(routeArgUses).toHaveLength(2);
});
it("dev:minimal opts in and proxies the prefix through Vite", () => {
// This mode runs agent-server and Vite with nothing in front of them, so
// Vite's own proxy is the only thing that can serve the prefix on the
// origin the browser is on.
const source = readScript("dev-safe.mjs");
expect(optsIntoPrefixMode(source)).toBe(true);
expect(source).toContain("VITE_VSCODE_BASE_PATH: config.vscodeBasePath");
expect(source).toContain("VITE_VSCODE_TARGET:");
const viteConfig = readFileSync(
path.join(repoRoot, "vite.config.ts"),
"utf-8",
);
expect(viteConfig).toContain("VITE_VSCODE_BASE_PATH");
// The editor upgrades to a WebSocket as soon as the workbench loads, so
// the proxy entry has to carry ws.
expect(viteConfig).toMatch(
/\[VITE_VSCODE_BASE_PATH\]: \{[^}]*target: VITE_VSCODE_TARGET,[^}]*ws: true,/s,
);
});
it("dev-extra-backend stays out of prefix-mode", () => {
// Its browser origin belongs to a different stack, so a prefix there either
// does not resolve or resolves to the bundled stack's editor — handing back
// another container's workspace. No global prefix can disambiguate them.
//
// Opting out is not by itself what hides the control: this launcher still
// starts the editor, so its /api/vscode/status reports it available. What
// hides it is that with no prefix configured, agent-server appends nothing
// to the origin and the URL comes back as the canvas root — which
// `isVSCodeUrlServedByOrigin` rejects. That behaviour is asserted in
// __tests__/hooks/use-unified-vscode-url.test.tsx; this only pins the
// launcher's half.
const source = readScript("dev-extra-backend.mjs");
expect(optsIntoPrefixMode(source)).toBe(false);
});
it("advertises the prefix on the servers that inject into the document", () => {
// Only the static server rewrites index.html, so only it can tell the
// frontend what this origin serves; the ingress in front of it routes the
// same prefix but proxies the document through untouched. Passing the flag
// to the ingress would also be a hard error — it does not accept it.
for (const script of ["dev-with-automation.mjs", "dev-static.mjs"]) {
const source = readScript(script);
const advertises = source.match(
/\.\.\.getVSCodeAdvertiseArgs\(config\)/g,
);
expect(advertises, `${script} advertises exactly once`).toHaveLength(1);
}
// Vite serves the document in full-stack dev mode, so the advertisement is
// an env var there rather than a server flag.
expect(readScript("dev-with-automation.mjs")).toContain(
"viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath",
);
});
it("full-stack dev advertises the prefix only where Vite also proxies it", () => {
// This stack has two supported browser origins: the ingress, and Vite's own
// port — the latter is in AUTOMATION_CORS_ORIGINS precisely so it can be
// browsed directly. The ingress routes the prefix itself, but on the Vite
// origin only Vite's proxy can, and vite.config.ts registers that proxy
// only when it has a target as well as a prefix. Advertising the prefix
// without the target would put a visible button on the Vite origin whose
// URL falls through to the SPA — the dead button this gating exists to
// prevent. So the two env vars have to be set together, in one block.
const source = readScript("dev-with-automation.mjs");
// Matched to the block's closing brace at its own indent, so a `${...}`
// inside the body does not end the match early.
const block = source.match(
/if \(config\.launchAgentServer && config\.vscodeBasePath\) \{\n([\s\S]*?VITE_VSCODE[\s\S]*?)\n {2}\}/,
);
expect(
block,
"the viteEnv editor block is still recognizable",
).not.toBeNull();
expect(block?.[1]).toContain(
"VITE_VSCODE_BASE_PATH = config.vscodeBasePath",
);
// The editor is its own process on its own port, so the proxy target is
// that port and not the backend/ingress host.
expect(block?.[1]).toContain(
"VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`",
);
});
it("gates advertising on exactly the condition that adds the route", () => {
// If these two guards ever disagree, one of the two failure modes returns:
// an advertised prefix with no route (control opens the SPA), or a routed
// prefix nobody advertises (feature silently off). static-server rejects
// the first at startup; this pins the source of both.
const source = readScript("dev-with-automation.mjs");
const advertiseGuard = source.match(
/function getVSCodeAdvertiseArgs\(config\) \{\s*if \(([^)]*)\) return \[\];/,
);
const referrerGuard = source.match(
/function getNoReferrerPrefixArgs\(config\) \{\s*if \(([^)]*)\) return \[\];/,
);
expect(advertiseGuard?.[1]).toBe(
"!config.launchAgentServer || !config.vscodeBasePath",
);
expect(advertiseGuard?.[1]).toBe(referrerGuard?.[1]);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it } from "vitest";
import {
getOriginVSCodeBasePath,
isVSCodeUrlServedByOrigin,
} from "#/utils/vscode-origin";
function setInjected(value: unknown) {
if (value === undefined) {
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_VSCODE_BASE_PATH__;
return;
}
(
window as unknown as Record<string, unknown>
).__AGENT_CANVAS_VSCODE_BASE_PATH__ = value;
}
afterEach(() => setInjected(undefined));
describe("getOriginVSCodeBasePath", () => {
it("returns null when nothing advertises an editor", () => {
// The public-mode origin and any deployment predating this flag land here.
// Hiding the control matches the behavior every local backend had before
// the control was made to render at all, so this default cannot regress a
// deployment that used to work.
expect(getOriginVSCodeBasePath()).toBeNull();
});
it("reads the value static-server injects into the document", () => {
setInjected("/vscode");
expect(getOriginVSCodeBasePath()).toBe("/vscode");
});
it("normalizes a missing leading slash and a trailing slash", () => {
// The flag is validated at the server, but the value also arrives from
// VITE_VSCODE_BASE_PATH, so normalize rather than trust the shape.
setInjected("vscode/");
expect(getOriginVSCodeBasePath()).toBe("/vscode");
});
it("treats a blank or non-string value as no editor", () => {
setInjected(" ");
expect(getOriginVSCodeBasePath()).toBeNull();
setInjected(42);
expect(getOriginVSCodeBasePath()).toBeNull();
});
});
describe("isVSCodeUrlServedByOrigin", () => {
const origin = window.location.origin;
it("accepts a URL under the advertised prefix", () => {
expect(
isVSCodeUrlServedByOrigin(`${origin}/vscode/?tkn=abc`, "/vscode"),
).toBe(true);
});
it("accepts the prefix itself with no trailing path", () => {
expect(isVSCodeUrlServedByOrigin(`${origin}/vscode`, "/vscode")).toBe(true);
});
it("rejects the origin root, which is what an unprefixed backend returns", () => {
// The extra-backend case: agent-server appends nothing, so the "editor"
// URL is the canvas itself. Same origin, wrong destination.
expect(isVSCodeUrlServedByOrigin(`${origin}/?tkn=abc`, "/vscode")).toBe(
false,
);
});
it("rejects a prefix that merely shares a string prefix", () => {
// `/vscode-other` starts with `/vscode` as a string but is a different
// route; only a full segment boundary counts.
expect(
isVSCodeUrlServedByOrigin(`${origin}/vscode-other/`, "/vscode"),
).toBe(false);
});
it("rejects a cross-origin URL", () => {
expect(
isVSCodeUrlServedByOrigin("http://elsewhere.test/vscode/", "/vscode"),
).toBe(false);
});
it("rejects when there is no advertised prefix or no URL", () => {
expect(isVSCodeUrlServedByOrigin(`${origin}/vscode/`, null)).toBe(false);
expect(isVSCodeUrlServedByOrigin(null, "/vscode")).toBe(false);
expect(isVSCodeUrlServedByOrigin("not a url", "/vscode")).toBe(false);
});
});
+6 -2
View File
@@ -19,14 +19,18 @@
"ports": {
"agentServer": 18000,
"automation": 18001,
"proxy": 8000
"proxy": 8000,
"_vscodeComment": "Port the bundled editor binds to inside the container; agent-server's own default. Only the Docker entrypoint reads this — the npm launchers derive the editor port from the agent-server port so several stacks can run side by side. It is never published: the editor is reached through paths.vscodeBasePath on the proxy port.",
"vscode": 8001
},
"paths": {
"stateSubdir": "agent-canvas",
"conversations": "agent-canvas/conversations",
"bashEvents": "agent-canvas/bash_events",
"automationDb": "automation/automations.db",
"canvasBasePath": "/canvas"
"canvasBasePath": "/canvas",
"_vscodeBasePathComment": "Path prefix the bundled editor is served under, on the same origin as the canvas. Passed to agent-server as OH_VSCODE_BASE_PATH (it launches openvscode-server with --server-base-path and advertises the prefix in /api/vscode/url), and registered as an ingress route to the VSCode port. Keeping one value on both sides is what lets a single-origin deployment reach the editor without publishing a second port. Tradeoff: a path prefix routes but does not isolate, so the editor shares the canvas's browser origin and therefore its localStorage — see docs/SELF_HOSTING.md and OpenHands/OpenHands#16492.",
"vscodeBasePath": "/vscode"
},
"packages": {
"agentServer": "openhands-agent-server",
+2
View File
@@ -56,11 +56,13 @@ RUN node -e " \
'CONFIG_AGENT_SERVER_PORT=' + c.ports.agentServer, \
'CONFIG_AUTOMATION_PORT=' + c.ports.automation, \
'CONFIG_PROXY_PORT=' + c.ports.proxy, \
'CONFIG_VSCODE_PORT=' + c.ports.vscode, \
'CONFIG_STATE_SUBDIR=' + c.paths.stateSubdir, \
'CONFIG_CONVERSATIONS=' + c.paths.conversations, \
'CONFIG_BASH_EVENTS=' + c.paths.bashEvents, \
'CONFIG_AUTOMATION_DB=' + c.paths.automationDb, \
'CONFIG_CANVAS_BASE_PATH=' + c.paths.canvasBasePath, \
'CONFIG_VSCODE_BASE_PATH=' + c.paths.vscodeBasePath, \
'CONFIG_POSTHOG_API_KEY=' + c.telemetry.posthogApiKey, \
'CONFIG_POSTHOG_HOST=' + c.telemetry.posthogHost, \
]; \
+132 -1
View File
@@ -17,6 +17,19 @@
# AGENT_SERVER_PORT – Internal agent-server port (default: 18000)
# AUTOMATION_PORT – Internal automation port (default: 18001)
# AGENT_CANVAS_BASE_PATH – Static frontend mount path (default: /canvas)
# VSCODE_PORT – Internal editor port (default: 8001). The image does
# not EXPOSE it and the editor is reached through
# VSCODE_BASE_PATH on $PORT, but openvscode-server
# binds 0.0.0.0, so `docker run --network host` does
# leave it directly reachable with only its connection
# token in front of it.
# VSCODE_BASE_PATH – Path prefix the editor is served under on $PORT
# (default: /vscode). Exported to agent-server as
# OH_VSCODE_BASE_PATH and routed by the static server.
# agent-server's own OH_VSCODE_PORT / OH_VSCODE_BASE_PATH
# take precedence over these aliases; whichever is set,
# one effective pair drives both the editor process and
# the proxy route.
# PUBLIC_MODE_PORT – If set, starts a second static server on this port
# with --auth-required (no session key injected)
# OH_SECRET_KEY – Secret key for settings encryption (auto-generated
@@ -56,7 +69,100 @@ fi
PORT="${PORT:-${CONFIG_PROXY_PORT:-8000}}"
AGENT_SERVER_PORT="${AGENT_SERVER_PORT:-${CONFIG_AGENT_SERVER_PORT:-18000}}"
AUTOMATION_PORT="${AUTOMATION_PORT:-${CONFIG_AUTOMATION_PORT:-18001}}"
# The bundled editor is reached through a path prefix on the proxy port rather
# than a published port of its own. The same prefix has to reach agent-server
# (it launches openvscode-server with --server-base-path and advertises the
# prefix from /api/vscode/url) and the static-server route table below, or the
# advertised URL and the route serving it disagree.
#
# Two env var names reach the same setting: OH_VSCODE_PORT / OH_VSCODE_BASE_PATH
# are agent-server's own documented variables, which a deployment may already
# set and which this entrypoint passes through like any other OH_* var, while
# VSCODE_PORT / VSCODE_BASE_PATH are this image's aliases. They collapse to one
# effective pair here, before anything reads them — resolving them
# independently would let `OH_VSCODE_BASE_PATH=/editor` move the editor without
# moving the route, leaving the button pointing at a path the proxy never
# serves.
# >>> vscode-config: this block is extracted and executed by
# >>> __tests__/scripts/docker-vscode-route-sync.test.ts — keep the markers.
# The canvas mount is resolved here rather than alongside the ports above
# because the collision guard below compares the two prefixes: keeping both
# inside the extracted block is what lets that comparison be tested against the
# real defaults instead of only against values a test injects.
AGENT_CANVAS_BASE_PATH="${AGENT_CANVAS_BASE_PATH:-${CONFIG_CANVAS_BASE_PATH:-/canvas}}"
VSCODE_PORT="${OH_VSCODE_PORT:-${VSCODE_PORT:-${CONFIG_VSCODE_PORT:-8001}}}"
VSCODE_BASE_PATH="${OH_VSCODE_BASE_PATH:-${VSCODE_BASE_PATH:-${CONFIG_VSCODE_BASE_PATH:-/vscode}}}"
# Accept "editor", "/editor" and "/editor/" alike: agent-server strips the
# slashes when it builds the advertised URL, the static-server route table
# needs the leading one, so settle on one spelling rather than one per use site.
normalize_base_path() {
local p="$1"
while [ "${p#/}" != "$p" ]; do p="${p#/}"; done
while [ "${p%/}" != "$p" ]; do p="${p%/}"; done
printf '/%s' "$p"
}
VSCODE_BASE_PATH="$(normalize_base_path "$VSCODE_BASE_PATH")"
if [ "$VSCODE_BASE_PATH" = "/" ]; then
log_error "VSCODE_BASE_PATH resolved to the site root — that would route the whole origin to the editor instead of the canvas. Set a prefix such as /vscode."
exit 1
fi
# The canvas mount gets the same treatment, for the same reason and with the
# same function. static-server normalizes whatever `--base-path` it is handed
# (`canvas` and `/canvas/` both mount at `/canvas`), so comparing a normalized
# editor prefix against a raw canvas one below would let `AGENT_CANVAS_BASE_PATH=canvas`
# with `OH_VSCODE_BASE_PATH=/canvas` past the collision guard and then land both
# on `/canvas` — where the editor route, registered after the SPA mount, takes
# the application over. Normalizing here rather than at the comparison keeps the
# value passed to `--base-path` further down identical to the one guarded.
AGENT_CANVAS_BASE_PATH="$(normalize_base_path "$AGENT_CANVAS_BASE_PATH")"
# static-server keys its route table by prefix and the editor route is
# registered last, so a prefix that collides with an earlier route silently
# replaces it rather than failing: OH_VSCODE_BASE_PATH=/api would send every
# API call to the editor port. Reject collisions and anything that is not a
# plain single-segment path — '=' would be mis-split by the --route parser
# (it cuts at the first '='), and whitespace, '?', '#' or '..' have no
# meaningful reading as a route prefix.
VSCODE_PATH_SEGMENT="${VSCODE_BASE_PATH#/}"
case "$VSCODE_PATH_SEGMENT" in
*/*)
log_error "VSCODE_BASE_PATH must be a single path segment (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode."
exit 1
;;
.|..)
log_error "VSCODE_BASE_PATH must not be a relative path segment (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode."
exit 1
;;
*[!A-Za-z0-9._-]*)
log_error "VSCODE_BASE_PATH may only contain letters, digits, '.', '_' and '-' (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode."
exit 1
;;
esac
for reserved in /api /sockets /server_info /alive /health /ready /docs /redoc /openapi.json "${AGENT_CANVAS_BASE_PATH:-}"; do
if [ -n "$reserved" ] && [ "$VSCODE_BASE_PATH" = "$reserved" ]; then
log_error "VSCODE_BASE_PATH '$VSCODE_BASE_PATH' collides with an existing route and would take it over. Set a different prefix, such as /vscode."
exit 1
fi
done
# The port ends up in a proxy target URL, so a non-numeric value fails at the
# first editor request instead of at startup. Catch it here.
case "$VSCODE_PORT" in
''|*[!0-9]*)
log_error "VSCODE_PORT must be a number (got '$VSCODE_PORT')."
exit 1
;;
esac
export OH_VSCODE_PORT="$VSCODE_PORT"
export OH_VSCODE_BASE_PATH="$VSCODE_BASE_PATH"
# The single route string every static-server instance registers. Derived from
# the exported pair above so the advertised URL and the route cannot diverge.
VSCODE_ROUTE="${VSCODE_BASE_PATH}=http://127.0.0.1:${VSCODE_PORT}"
# <<< vscode-config
# Persistence paths — keep settings, conversations, bash history under a
# single well-known directory that the VOLUME directive exposes.
@@ -291,7 +397,10 @@ node /opt/agent-canvas/static-server.mjs \
--route "/ready=http://127.0.0.1:${AGENT_SERVER_PORT}" \
--route "/docs=http://127.0.0.1:${AGENT_SERVER_PORT}" \
--route "/redoc=http://127.0.0.1:${AGENT_SERVER_PORT}" \
--route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" &
--route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" \
--route "$VSCODE_ROUTE" \
--vscode-base-path "$VSCODE_BASE_PATH" \
--no-referrer-prefix "$VSCODE_BASE_PATH" &
STATIC_PID=$!
PIDS+=("$STATIC_PID")
@@ -300,6 +409,28 @@ PIDS+=("$STATIC_PID")
# serves the same frontend WITHOUT injecting the session key into the HTML
# (--auth-required). This is used by auth-mode E2E tests to verify the
# ApiKeyEntryScreen gate, key rotation recovery, etc.
#
# Neither the editor route nor --vscode-base-path is registered here, and the
# pair is deliberate: the route is what would serve the editor, and the flag is
# what tells the frontend this origin can. Omitting only the route would leave
# the control rendering and falling through to the SPA, because the agent-server
# it shares with the main instance still reports the editor as available.
#
# --auth-required only
# controls whether the session key is injected into the served HTML; the
# dispatcher matches routes before it reaches that flag, so proxied paths are
# not gated by it. The routes above are safe on that footing because
# agent-server enforces the session key itself, but the editor's own
# credential is the connection token agent-server puts in the query string —
# and agent-server derives that token from session_api_keys[0], so it is the
# same secret that authenticates /api. Registering the route here would put
# that secret in a browser-navigable URL on the origin that exists precisely
# to test the unauthenticated case, where it would persist in history and
# leak by Referer from the workbench's own subresources.
#
# The token's scope is upstream's to fix and is tracked in
# OpenHands/software-agent-sdk#4317; if the editor gets a credential of its own,
# this exclusion and the --no-referrer-prefix below can both be revisited.
if [ -n "${PUBLIC_MODE_PORT:-}" ]; then
log "Starting public-mode frontend on port $PUBLIC_MODE_PORT (--auth-required)..."
node /opt/agent-canvas/static-server.mjs \
+11
View File
@@ -92,6 +92,17 @@ the agent (step 3) and access the UI through an SSH tunnel. If you also want
to reach it from a browser without tunneling, you'll open ports 80 and 443
in step 4.
> [!NOTE]
> **The bundled editor shares the canvas's browser origin.** OpenVSCode is
> served under a path prefix (`/vscode` by default) on the proxy port rather
> than on a published port of its own — that is what keeps the deployment to a
> single port, but a path prefix routes requests, it does not isolate them.
> Script running anywhere on that origin, including editor content reached
> through an extension or a compromised asset, can read the canvas's
> `localStorage`, which holds the SESSION API key of _every_ backend registered
> in that browser. Tracked in
> [#16492](https://github.com/OpenHands/OpenHands/issues/16492).
## 3. Run Agent Canvas
Install the prerequisites on the machine. On Ubuntu:
+22 -9
View File
@@ -43,7 +43,10 @@ function parsePort(value, fallback) {
* @param {string} cwd
* @param {Record<string, string | undefined>} env
*/
export function buildExtraBackendConfig(cwd = process.cwd(), env = process.env) {
export function buildExtraBackendConfig(
cwd = process.cwd(),
env = process.env,
) {
const base = buildSafeDevConfig(cwd, env);
const backendPort = parsePort(
@@ -70,7 +73,7 @@ function isEnoentError(error) {
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT") ||
/ENOENT/.test(String(error)),
/ENOENT/.test(String(error)),
);
}
@@ -94,10 +97,14 @@ async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
}
function spawnProcess(command, args, options = {}) {
const child = spawn(command, args, getProcessTreeSpawnOptions({
stdio: "inherit",
...options,
}));
const child = spawn(
command,
args,
getProcessTreeSpawnOptions({
stdio: "inherit",
...options,
}),
);
child.once("error", (error) => {
if (isEnoentError(error) && command === "uvx") {
@@ -164,6 +171,14 @@ async function main() {
{
cwd: config.cwd,
env: {
// Deliberately not opting into the editor path prefix. This server is
// reached by registering it as an extra backend from a browser whose
// origin belongs to some *other* stack, so a prefix on that origin
// either does not resolve or — worse — resolves to the bundled
// stack's editor, silently handing back a different container's
// workspace. No single global prefix can disambiguate the two, so this
// launcher stays out of prefix-mode; the editor button is unavailable
// for conversations on an extra backend.
...process.env,
...buildAgentServerEnv(config),
},
@@ -221,9 +236,7 @@ async function main() {
backend.once("exit", (code) => {
if (!shuttingDown) {
console.error(
`agent-server exited unexpectedly with code ${code ?? 0}`,
);
console.error(`agent-server exited unexpectedly with code ${code ?? 0}`);
shutdown();
process.exitCode = code ?? 1;
} else {
+60 -7
View File
@@ -37,6 +37,11 @@ const SHARED_DEFAULTS = JSON.parse(
);
const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer;
// Path prefix the bundled editor is served under. The same value has to reach
// agent-server (as OH_VSCODE_BASE_PATH, so openvscode-server is launched with
// --server-base-path and advertises the prefix) and the ingress route table,
// or the advertised URL and the route that serves it disagree.
export const VSCODE_BASE_PATH = SHARED_DEFAULTS.paths.vscodeBasePath;
const DEFAULT_VITE_PORT = 3001;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
const DEFAULT_AGENT_SERVER_PACKAGE = SHARED_DEFAULTS.packages.agentServer;
@@ -599,6 +604,7 @@ export async function buildSafeDevConfigAsync(
* @property {string} cwd
* @property {number} backendPort
* @property {number} vscodePort
* @property {string} vscodeBasePath
* @property {string} stateDir
* @property {string} tmuxTmpDir
* @property {string} conversationsPath
@@ -657,6 +663,7 @@ function buildConfigFromPorts(ports, cwd, env) {
cwd,
backendPort,
vscodePort,
vscodeBasePath: VSCODE_BASE_PATH,
stateDir,
// tmux socket directory. Defaults to <stateDir>/tmux (under
// ~/.openhands/agent-canvas), matching where the rest of dev state lives
@@ -688,13 +695,13 @@ function buildConfigFromPorts(ports, cwd, env) {
}
/**
* Build the environment variables object for spawning the agent-server process.
* Telemetry-related env vars for the agent-server process.
*
* This is exported so downstream consumers (e.g., automation service) can use
* the same env vars without duplicating the mapping logic.
* Split out from `buildAgentServerEnv` so callers that assemble their own
* agent-server environment can reuse the same mapping.
*
* @param {ReturnType<typeof buildSafeDevConfig>} config - Config from buildSafeDevConfig
* @returns {Record<string, string>} Environment variables for agent-server
* @param {Record<string, string | undefined>} [env] - Source environment.
* @returns {Record<string, string>} Telemetry env vars for agent-server
*/
export function buildAgentServerTelemetryEnv(env = process.env) {
const telemetryDisabled =
@@ -739,7 +746,35 @@ export function buildAgentServerTelemetryEnv(env = process.env) {
return result;
}
export function buildAgentServerEnv(config, env = process.env) {
/**
* Build the environment variables object for spawning the agent-server process.
*
* This is exported so downstream consumers (e.g., automation service) can use
* the same env vars without duplicating the mapping logic.
*
* `vscodeBasePath` is an explicit opt-in rather than a field read off `config`,
* and that is deliberate. Setting it changes the URL `/api/vscode/url`
* advertises: agent-server appends the prefix to the browser origin the
* frontend sends, so the editor is only reachable if the same origin also
* routes that prefix to the editor port. A launcher that sets it without
* registering the route advertises `<origin>/vscode/…`, which serves the
* canvas SPA shell instead of the editor.
*
* Requiring the caller to name it makes the pairing greppable: every call site
* that passes `vscodeBasePath` must also register a matching route, and
* `__tests__/scripts/vscode-base-path-opt-in.test.ts` asserts that no launcher
* opts in without one.
*
* @param {ReturnType<typeof buildSafeDevConfig>} config - Config from buildSafeDevConfig
* @param {{vscodeBasePath?: string | null, env?: Record<string, string | undefined>}} [options]
* @param {string | null} [options.vscodeBasePath] - Opt into prefix-mode by
* passing the path prefix the caller also routes to `config.vscodePort`.
* @param {Record<string, string | undefined>} [options.env] - Source
* environment for the telemetry mapping (defaults to `process.env`).
* @returns {Record<string, string>} Environment variables for agent-server
*/
export function buildAgentServerEnv(config, options = {}) {
const { vscodeBasePath = null, env = process.env } = options;
return {
...buildAgentServerTelemetryEnv(env),
// Force Python to use UTF-8 for all file I/O and streams.
@@ -759,6 +794,13 @@ export function buildAgentServerEnv(config, env = process.env) {
OH_CONVERSATIONS_PATH: config.conversationsPath,
OH_BASH_EVENTS_DIR: config.bashEventsDir,
OH_VSCODE_PORT: String(config.vscodePort),
// Serve the editor under a path prefix on the canvas origin rather than on
// its own published port. agent-server passes this to openvscode-server as
// --server-base-path and includes it in the URL from /api/vscode/url, which
// matches the ingress route the caller registers for the same prefix.
//
// Omitted unless the caller opts in — see the note on this function.
...(vscodeBasePath ? { OH_VSCODE_BASE_PATH: vscodeBasePath } : {}),
OH_SECRET_KEY: config.secretKey,
// Use OH_SESSION_API_KEYS_0 for agent-server V1 config format
OH_SESSION_API_KEYS_0: config.sessionApiKey,
@@ -954,7 +996,12 @@ async function main() {
cwd: config.cwd,
env: {
...process.env,
...buildAgentServerEnv(config),
// Opt into prefix-mode: the Vite dev server proxies the same prefix to
// `config.vscodePort` (see VITE_VSCODE_TARGET below), so the advertised
// URL resolves on the frontend origin the browser is actually on.
...buildAgentServerEnv(config, {
vscodeBasePath: config.vscodeBasePath,
}),
},
},
);
@@ -1023,6 +1070,12 @@ async function main() {
VITE_WORKING_DIR: config.workingDir,
// Pass session API key so frontend can authenticate with agent-server
VITE_SESSION_API_KEY: config.sessionApiKey,
// This mode has no static server or ingress in front of Vite, so Vite's
// own proxy is the only thing that can serve the editor prefix on the
// frontend origin. The editor is a separate process on a port of its
// own, so it needs its own proxy target rather than VITE_BACKEND_HOST.
VITE_VSCODE_BASE_PATH: config.vscodeBasePath,
VITE_VSCODE_TARGET: `http://127.0.0.1:${config.vscodePort}`,
// dev:minimal deliberately does NOT supply runtime-services info (the
// frontend here talks straight to the agent-server over
// VITE_BACKEND_BASE_URL — there is no ingress or static-server in front
+35 -37
View File
@@ -63,6 +63,11 @@ import {
buildAutomationTelemetryEnv,
buildAutomationRuntimeServicesInfo,
buildConfig,
buildRouteArgs,
getAgentServerBaseUrl,
getLocalServiceRoutes,
getNoReferrerPrefixArgs,
getVSCodeAdvertiseArgs,
} from "./dev-with-automation.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -285,42 +290,25 @@ async function waitForService(name, url, timeoutMs = 30000) {
// dev-with-automation; the only difference is the frontend service.)
// ═══════════════════════════════════════════════════════════════════════════
// Both backends bind to `0.0.0.0`, which only accepts IPv4, but localhost can
// resolve to ::1 first (notably on Windows). Every proxy target and readiness
// probe pointed at them must therefore address IPv4 explicitly.
function getAgentServerBaseUrl(config) {
return `http://127.0.0.1:${config.agentServerPort}`;
}
function getAutomationBaseUrl(config) {
return `http://127.0.0.1:${config.autoBackendPort}`;
}
const AUTOMATION_ROUTE_PREFIX = "/api/automation";
const AGENT_SERVER_ROUTE_PREFIXES = [
"/api",
"/sockets",
"/server_info",
"/health",
"/ready",
"/alive",
"/docs",
"/redoc",
"/openapi.json",
];
// The static server and the ingress proxy front the same two local backends,
// so they share one route table.
// The static server and the ingress proxy front the same local backends, so
// they share one route table — dev-with-automation's, rather than a second
// copy here. The copy this replaces claimed to stay identical to that table
// but nothing enforced it, and it had already drifted: the editor prefix was
// missing, so `/vscode` fell through to the SPA fallback and answered editor
// requests with the canvas shell.
//
// This mode always launches both local backends (it never runs frontend-only),
// so it asks for their routes unconditionally. Every target is IPv4 loopback:
// the backends bind to `0.0.0.0`, which only accepts IPv4, but localhost can
// resolve to ::1 first (notably on Windows).
function buildLocalServiceRouteArgs(config) {
const agentServerUrl = getAgentServerBaseUrl(config);
return [
"--route",
`${AUTOMATION_ROUTE_PREFIX}=${getAutomationBaseUrl(config)}`,
...AGENT_SERVER_ROUTE_PREFIXES.flatMap((prefix) => [
"--route",
`${prefix}=${agentServerUrl}`,
]),
];
return buildRouteArgs(
getLocalServiceRoutes({
...config,
launchAgentServer: true,
launchAutomation: true,
}),
);
}
function startAgentServer(config) {
@@ -341,7 +329,12 @@ function startAgentServer(config) {
});
const agentServerEnv = {
...buildAgentServerEnv(safeConfig),
// Opt into prefix-mode: both the static server and the ingress below build
// their route tables from `getLocalServiceRoutes`, which registers this
// same prefix against `config.vscodePort`.
...buildAgentServerEnv(safeConfig, {
vscodeBasePath: config.vscodeBasePath,
}),
...buildAgentServerAutomationEnv(config),
};
@@ -444,6 +437,11 @@ function startStaticServer(config) {
"--runtime-services-info",
runtimeServicesInfo,
...buildLocalServiceRouteArgs(config),
// Only the static server injects into the document, so only it can tell
// the frontend this origin serves the editor. The ingress below routes
// the same prefix but proxies the HTML through untouched.
...getVSCodeAdvertiseArgs(config),
...getNoReferrerPrefixArgs(config),
],
{
cwd: config.canvasPath,
@@ -473,6 +471,7 @@ function startIngress(config) {
"--runtime-services-info",
runtimeServicesInfo,
...buildLocalServiceRouteArgs(config),
...getNoReferrerPrefixArgs(config),
"--default",
`http://localhost:${config.vitePort}`,
],
@@ -645,7 +644,6 @@ export {
buildAutomationBackendEnv,
buildFrontend,
buildLocalServiceRouteArgs,
getAgentServerBaseUrl,
startStaticServer,
};
+82 -1
View File
@@ -485,6 +485,10 @@ async function buildConfig(args, env = process.env) {
autoBackendPort: preferredAutomationPort,
vitePort: preferredVitePort,
vscodePort,
// Prefix the editor is served under on the ingress origin. Carried on the
// config so the route table and the agent-server env are built from one
// value (see getLocalServiceRoutes / buildAgentServerEnv).
vscodeBasePath: safeConfig.vscodeBasePath,
// Paths
canvasPath: projectRoot,
@@ -757,6 +761,20 @@ function getLocalServiceRoutes(config) {
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
routes.push([prefix, getAgentServerBaseUrl(config)]);
}
// The editor is a separate process on its own port, but it is reached
// through the same origin as the canvas so no second port has to be
// published. The prefix is deliberately preserved rather than stripped:
// agent-server launches openvscode-server with `--server-base-path`, so
// the editor generates its own HTTP and WebSocket URLs beneath the prefix
// and only answers there. `createRouter` matches the longest prefix and
// the proxy forwards the original path, so both are already handled.
if (config.vscodeBasePath) {
routes.push([
config.vscodeBasePath,
`http://127.0.0.1:${config.vscodePort}`,
]);
}
}
return routes;
@@ -766,6 +784,32 @@ function buildRouteArgs(routes) {
return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]);
}
/**
* The editor prefix, if this mode serves it, as `--no-referrer-prefix` args.
*
* agent-server hands the editor a connection token derived from its session
* key and advertises it in the URL's query string, so the workbench document
* must not leak a Referer to the subresources it loads.
*/
function getNoReferrerPrefixArgs(config) {
if (!config.launchAgentServer || !config.vscodeBasePath) return [];
return ["--no-referrer-prefix", config.vscodeBasePath];
}
/**
* The editor prefix, if this mode serves it, as `--vscode-base-path` args.
*
* Gated on exactly the same condition as the editor route in
* `getLocalServiceRoutes`, because they answer the same question: an origin
* advertises the editor if and only if it routes it. static-server enforces
* that pairing at startup, so a future edit that breaks it fails loudly rather
* than shipping a control that opens the SPA.
*/
function getVSCodeAdvertiseArgs(config) {
if (!config.launchAgentServer || !config.vscodeBasePath) return [];
return ["--vscode-base-path", config.vscodeBasePath];
}
/**
* Build --reject-prefix args for the static server.
* In frontend-only mode, API paths that have no backend should return 503
@@ -780,6 +824,12 @@ function getRejectPrefixes(config) {
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
prefixes.push(prefix);
}
// No agent-server means no editor behind this prefix either. Reject it
// rather than SPA-fallbacking to index.html, which would answer an editor
// request with the canvas shell.
if (config.vscodeBasePath) {
prefixes.push(config.vscodeBasePath);
}
}
return prefixes;
}
@@ -872,7 +922,12 @@ function startAgentServer(config) {
});
const agentServerEnv = {
...buildAgentServerEnv(safeConfig),
// Opt into prefix-mode: `getLocalServiceRoutes` registers the matching
// route on both the static server and the ingress, so the prefix this
// advertises resolves to the editor port on the canvas origin.
...buildAgentServerEnv(safeConfig, {
vscodeBasePath: config.vscodeBasePath,
}),
...buildAgentServerAutomationEnv(config),
OPENHANDS_REMOTE_WS_READY_REQUIRED:
process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
@@ -1061,6 +1116,7 @@ function startIngress(config) {
? ["--runtime-services-info", runtimeServicesInfo]
: []),
...buildRouteArgs(getLocalServiceRoutes(config)),
...getNoReferrerPrefixArgs(config),
...(frontendBackend ? ["--default", frontendBackend] : []),
],
{
@@ -1108,6 +1164,23 @@ function startVite(config) {
viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
}
// Vite serves the HTML for this mode's browser origin, so this is where the
// editor-capability advertisement has to be baked. The ingress in front of it
// routes the prefix but is a pure proxy — it injects nothing into the
// document, so it cannot tell the frontend what it serves.
//
// Both variables or neither: `vite.config.ts` only registers the editor proxy
// when it has a target as well as a prefix, and this stack has two supported
// browser origins — the ingress and Vite's own port, which is why the latter
// is in AUTOMATION_CORS_ORIGINS. On the ingress the prefix is routed by the
// ingress itself; on the Vite origin only this proxy can serve it. Baking the
// prefix alone would advertise an editor on the Vite origin whose URL then
// falls through to the SPA — the dead button this gating exists to prevent.
if (config.launchAgentServer && config.vscodeBasePath) {
viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath;
viteEnv.VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`;
}
// In local mode, bake the session key into the frontend so the user
// never has to paste it. In public mode, omit the key and set
// VITE_AUTH_REQUIRED so the frontend shows the API key entry screen
@@ -1571,6 +1644,11 @@ function startStaticFrontend(config, staticDir) {
: []),
// Proxy routes only to services that this launch mode started.
...buildRouteArgs(getLocalServiceRoutes(config)),
// Only the static server injects into the document, so only it can tell
// the frontend this origin serves the editor. The ingress routes the same
// prefix but proxies the HTML through untouched.
...getVSCodeAdvertiseArgs(config),
...getNoReferrerPrefixArgs(config),
// Reject known API prefixes that have no backend — returns 503
// instead of SPA-fallbacking to index.html.
...buildRejectPrefixArgs(getRejectPrefixes(config)),
@@ -1596,6 +1674,9 @@ export {
getAgentServerBaseUrl,
getFrontendBackend,
getLocalServiceRoutes,
getNoReferrerPrefixArgs,
getRejectPrefixes,
getVSCodeAdvertiseArgs,
main,
registerShutdownHook,
spawnService,
+27 -1
View File
@@ -30,6 +30,7 @@ import {
createRouter,
isBenignSocketError,
isServerInfoRequest,
matchesPathPrefix,
proxyServerInfoRequest,
} from "./proxy-utils.mjs";
@@ -43,6 +44,7 @@ function parseArgs() {
port: 8000,
routes: {},
defaultBackend: null,
noReferrerPrefixes: [],
runtimeServicesInfo: null,
};
@@ -62,6 +64,16 @@ function parseArgs() {
case "--default":
config.defaultBackend = args[++i];
break;
case "--no-referrer-prefix": {
const prefix = args[++i];
if (!prefix || !prefix.startsWith("/")) {
throw new Error(
`--no-referrer-prefix value must start with '/': ${prefix ?? "(empty)"}`,
);
}
config.noReferrerPrefixes.push(prefix);
break;
}
case "--runtime-services-info":
config.runtimeServicesInfo = args[++i] || null;
break;
@@ -88,6 +100,9 @@ OPTIONS:
-p, --port <port> Port to listen on (default: 8000)
-r, --route <path=url> Add a route (can be repeated)
-d, --default <url> Default backend for unmatched routes
--no-referrer-prefix <p> Send "Referrer-Policy: no-referrer" on proxied
responses under <p>. For upstreams whose URL
carries a credential in the query string.
--runtime-services-info Runtime services JSON for /server_info
-h, --help Show this help
@@ -136,6 +151,7 @@ function buildConfig(args, env = process.env) {
port: args.port || parseInt(env.INGRESS_PORT, 10) || 8000,
routes,
defaultBackend: args.defaultBackend || env.INGRESS_DEFAULT || null,
noReferrerPrefixes: args.noReferrerPrefixes ?? [],
runtimeServicesInfo:
args.runtimeServicesInfo || env.INGRESS_RUNTIME_SERVICES_INFO || null,
};
@@ -150,8 +166,11 @@ export function startIngress(config) {
const proxy = createProxyHandlers({ label: `ingress:${config.port}` });
const uninstallDiagnostics = proxy.installDiagnostics();
const noReferrerPrefixes = config.noReferrerPrefixes ?? [];
const server = createServer((req, res) => {
const backend = route(req.url ?? "/");
const url = req.url ?? "/";
const backend = route(url);
if (!backend) {
res.writeHead(503);
@@ -159,6 +178,13 @@ export function startIngress(config) {
return;
}
// See the matching note in static-server.mjs: the editor's URL carries
// agent-server's session key as a query parameter, so the document must
// not send a Referer on the subresources the workbench loads.
if (noReferrerPrefixes.some((prefix) => matchesPathPrefix(url, prefix))) {
res.setHeader("Referrer-Policy", "no-referrer");
}
if (
config.runtimeServicesInfo &&
isServerInfoRequest(req) &&
+74 -1
View File
@@ -84,11 +84,13 @@ export function parseArgs(argv = process.argv.slice(2)) {
dir: "build",
routes: {},
rejectPrefixes: [],
noReferrerPrefixes: [],
sessionApiKey: null,
authRequired: false,
runtimeServicesInfo: null,
lockToCloud: null,
basePath: "/",
vscodeBasePath: null,
};
for (let i = 0; i < argv.length; i++) {
@@ -133,6 +135,16 @@ export function parseArgs(argv = process.argv.slice(2)) {
case "--base-path":
config.basePath = normalizeBasePath(argv[++i]);
break;
case "--vscode-base-path": {
const prefix = argv[++i];
if (!prefix || !prefix.startsWith("/")) {
throw new Error(
`--vscode-base-path value must start with '/': ${prefix ?? "(empty)"}`,
);
}
config.vscodeBasePath = prefix.replace(/\/+$/, "") || "/";
break;
}
case "--auth-required":
config.authRequired = true;
@@ -147,6 +159,16 @@ export function parseArgs(argv = process.argv.slice(2)) {
config.rejectPrefixes.push(prefix);
break;
}
case "--no-referrer-prefix": {
const prefix = argv[++i];
if (!prefix || !prefix.startsWith("/")) {
throw new Error(
`--no-referrer-prefix value must start with '/': ${prefix ?? "(empty)"}`,
);
}
config.noReferrerPrefixes.push(prefix);
break;
}
case "-h":
case "--help":
showHelp();
@@ -169,6 +191,20 @@ export function parseArgs(argv = process.argv.slice(2)) {
process.exit(1);
}
// Guard: advertising the editor and routing it are the same decision, so
// they cannot be allowed to drift. This flag is what the frontend gates the
// editor control on; if it named a prefix with no route behind it, the
// control would render and the navigation would fall through to the SPA —
// which is precisely the bug this flag exists to prevent.
if (config.vscodeBasePath && !config.routes[config.vscodeBasePath]) {
console.error(
`ERROR: --vscode-base-path ${config.vscodeBasePath} has no matching --route.\n` +
" This server would advertise an editor it does not serve.\n" +
` Add --route ${config.vscodeBasePath}=<editor-url>, or drop --vscode-base-path.`,
);
process.exit(1);
}
return config;
}
@@ -211,7 +247,17 @@ OPTIONS:
--base-path <path> Mount the SPA under <path> (default: /).
For example, --base-path /canvas serves
index.html and assets under /canvas.
--vscode-base-path <path> Advertise to the frontend that this origin
serves the editor under <path>, so the editor
control renders here. Requires a matching
--route; the server refuses to start otherwise,
since advertising a prefix it does not route
produces a control that opens the SPA. Omit on
any origin without the editor route.
--reject-prefix <prefix> Return 503 for requests matching <prefix>
--no-referrer-prefix <p> Send "Referrer-Policy: no-referrer" on proxied
responses under <p>. For upstreams whose URL
carries a credential in the query string.
instead of SPA-fallbacking to index.html;
may be repeated. Useful in --frontend-only
mode to cleanly reject API paths.
@@ -268,6 +314,13 @@ ROUTING:
* - `basePath`: the path prefix the SPA is mounted under, exposed as
* `window.__AGENT_CANVAS_BASE_PATH__` so runtime static assets like locale
* files can resolve through the same subpath as the built bundle.
*
* - `vscodeBasePath`: the prefix *this origin* serves the editor under, exposed
* as `window.__AGENT_CANVAS_VSCODE_BASE_PATH__`. Read by
* `getOriginVSCodeBasePath()` in `#/utils/vscode-origin` to decide whether the
* editor control can render here at all. Absent means this origin serves no
* editor — which is the correct answer for the public-mode instance, whose
* route table deliberately omits it.
*/
function makeConfigInjectionScript(
sessionApiKey,
@@ -275,6 +328,7 @@ function makeConfigInjectionScript(
runtimeServicesInfo,
lockToCloud,
basePath,
vscodeBasePath,
) {
const parts = [];
@@ -324,6 +378,12 @@ function makeConfigInjectionScript(
);
}
if (vscodeBasePath) {
parts.push(
`window.__AGENT_CANVAS_VSCODE_BASE_PATH__=${JSON.stringify(vscodeBasePath)};`,
);
}
if (parts.length === 0) return "";
return `<script>(function(){${parts.join("")}}());</script>`;
@@ -343,6 +403,7 @@ async function serveInjectedIndexHtml(
runtimeServicesInfo,
lockToCloud,
basePath,
vscodeBasePath,
} = {},
) {
let content;
@@ -358,6 +419,7 @@ async function serveInjectedIndexHtml(
runtimeServicesInfo,
lockToCloud,
basePath,
vscodeBasePath,
);
// Inject right before </head> so the key is available before any app code runs.
// replace() targets the first (and only) </head> in well-formed HTML.
@@ -406,6 +468,7 @@ function needsRuntimeInjection(injectionOpts) {
injectionOpts.authRequired ||
injectionOpts.runtimeServicesInfo ||
injectionOpts.lockToCloud ||
injectionOpts.vscodeBasePath ||
(injectionOpts.basePath && injectionOpts.basePath !== "/"),
);
}
@@ -550,16 +613,26 @@ export function startStaticServer(config) {
runtimeServicesInfo: config.runtimeServicesInfo || null,
lockToCloud: config.lockToCloud || null,
basePath: normalizeBasePath(config.basePath),
vscodeBasePath: config.vscodeBasePath || null,
};
const basePath = injectionOpts.basePath;
const rejectPrefixes = config.rejectPrefixes ?? [];
const noReferrerPrefixes = config.noReferrerPrefixes ?? [];
const staticMiddleware = createStaticMiddleware(dirAbs);
const uninstallDiagnostics = proxy.installDiagnostics();
const server = createServer((req, res) => {
const backend = route(req.url ?? "/");
const url = req.url ?? "/";
const backend = route(url);
if (backend) {
// The editor is advertised as `<origin><prefix>/?tkn=<token>`, and that
// token is agent-server's session key. The workbench loads webviews,
// previews and extension content from that document, so without this a
// Referer carrying the key rides along on those subrequests.
if (matchesAnyPrefix(url, noReferrerPrefixes)) {
res.setHeader("Referrer-Policy", "no-referrer");
}
if (
config.runtimeServicesInfo &&
isServerInfoRequest(req) &&
@@ -2,6 +2,7 @@ import {
ConversationSortOrder,
type ForkConversationRequest,
type LLMConfig,
type VSCodeStatusResponse,
} from "@openhands/typescript-client";
import {
ConversationClient,
@@ -565,6 +566,27 @@ class AgentServerConversationService {
return { vscode_url: vscodeUrl };
}
/**
* Read the editor's capability state from the agent-server.
*
* `/api/vscode/status` answers 200 with `enabled: false` when the
* deployment set `enable_vscode: false`, which distinguishes "this
* deployment offers no editor" from a transport, auth, or server
* failure — `/api/vscode/url` answers 503 for the former and so
* cannot be told apart from the latter.
*/
static async getVSCodeStatus(
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<VSCodeStatusResponse> {
return new VSCodeClient(
getAgentServerClientOptions({
conversationUrl,
sessionApiKey,
}),
).getStatus();
}
static async resolveConversationWorkingDir(
conversationId: string,
): Promise<string> {
@@ -233,6 +233,12 @@ export function ConversationTabs({
if (typeof ResizeObserver === "undefined") return undefined;
const ro = new ResizeObserver(measure);
ro.observe(rowInner);
// The editor button's presence is resolved asynchronously (the hook probes
// /api/vscode/status), and it sits inside an `ml-auto shrink-0` wrapper, so
// it appearing or disappearing does not change `rowInner`'s own box and
// would not otherwise re-measure. Its width is folded into the fit
// calculation above, so a stale value permanently costs an inline tab.
ro.observe(vscodeEl);
return () => ro.disconnect();
}, [
unpinnedSignature,
@@ -355,16 +361,10 @@ export function ConversationTabs({
</div>
</div>
</div>
{/* Keep the ref'd wrapper mounted on local backends too — the
overflow measurement effect above bails if it's missing. */}
<div
ref={vscodeButtonRef}
className={cn(
"ml-auto shrink-0",
backend.kind === "cloud" && "pr-1",
)}
>
{backend.kind === "cloud" && <DrawerVSCodeLink />}
{/* The ref'd wrapper must stay mounted — the overflow measurement
effect above bails if it's missing. */}
<div ref={vscodeButtonRef} className="ml-auto shrink-0 pr-1">
<DrawerVSCodeLink />
</div>
</div>
</div>
@@ -10,7 +10,7 @@ import { cn } from "#/utils/utils";
export function DrawerVSCodeLink() {
const { t } = useTranslation("openhands");
const { curAgentState } = useAgentState();
const { data, refetch, isLoading } = useUnifiedVSCodeUrl();
const { data, refetch, isLoading, isUnavailable } = useUnifiedVSCodeUrl();
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
const handleClick = async () => {
@@ -26,6 +26,13 @@ export function DrawerVSCodeLink() {
}
};
// Backends that report no editor to open — `enable_vscode: false`, a
// configured editor whose process is not running, or no URL reported — get
// no button rather than one that does nothing when clicked.
if (isUnavailable) {
return null;
}
return (
<button
type="button"
+91 -4
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import type { VSCodeStatusResponse } from "@openhands/typescript-client";
import { useTranslation } from "react-i18next";
import { useConversationId } from "#/hooks/use-conversation-id";
import { I18nKey } from "#/i18n/declaration";
@@ -9,6 +10,10 @@ import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useCloudSandbox } from "#/hooks/query/use-cloud-sandbox";
import {
getOriginVSCodeBasePath,
isVSCodeUrlServedByOrigin,
} from "#/utils/vscode-origin";
interface VSCodeUrlResult {
url: string | null;
@@ -28,12 +33,55 @@ export const useUnifiedVSCodeUrl = () => {
const sandboxId = conversation?.sandbox_id ?? null;
const isCloud = active.backend.kind === "cloud";
// The origin half of the availability question. The agent-server will
// happily report an editor this page has no route to — see
// `#/utils/vscode-origin`. `null` means this origin serves no editor, in
// which case there is nothing to probe for and nothing to render.
const originBasePath = getOriginVSCodeBasePath();
const originServesEditor = originBasePath !== null;
// 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
// the user's browser couldn't reach.
const cloudSandboxQuery = useCloudSandbox(isCloud ? sandboxId : null);
// Capability probe. `/api/vscode/status` answers 200 with
// `enabled: false` when the deployment set `enable_vscode: false`, so a
// deliberately editor-less deployment is a value here rather than an
// error — unlike `/api/vscode/url`, which answers 503 and is therefore
// indistinguishable from an auth, proxy, or server failure.
//
// Gating the URL request on this means a disabled editor never issues
// the 503 in the first place, so the global error toast needs no
// blanket suppression and genuine failures stay observable.
const statusQuery = useQuery<VSCodeStatusResponse>({
queryKey: [
"unified",
"vscode_status",
"local",
conversationId,
conversationUrl,
sessionApiKey,
],
queryFn: () =>
AgentServerConversationService.getVSCodeStatus(
conversationUrl,
sessionApiKey,
),
enabled:
!isCloud && originServesEditor && runtimeIsReady && !!conversationId,
refetchOnMount: true,
});
// `enabled: false` is the deployment switch. `running: false` alongside
// `enabled: true` means the process failed to start or has died: the
// agent-server awaits `VSCodeService.start()` in its lifespan before it
// serves any request, so this is a terminal state rather than a startup
// window we would be racing.
const editorIsAvailable =
statusQuery.data?.enabled === true && statusQuery.data?.running === true;
const localQuery = useQuery<VSCodeUrlResult>({
// Include conversation host + key in the cache key so different
// conversations don't share VSCode URL data.
@@ -56,7 +104,12 @@ export const useUnifiedVSCodeUrl = () => {
return { url: transformVSCodeUrl(response.vscode_url) };
},
enabled: !isCloud && runtimeIsReady && !!conversationId,
enabled:
!isCloud &&
originServesEditor &&
runtimeIsReady &&
!!conversationId &&
editorIsAvailable,
refetchOnMount: true,
retry: 3,
});
@@ -68,6 +121,9 @@ export const useUnifiedVSCodeUrl = () => {
let status: typeof localQuery.status;
let error: unknown;
let refetch: () => Promise<{ data: VSCodeUrlResult | undefined }>;
// True once we know there is nothing to open, so callers can render nothing
// instead of a control whose activation is a no-op.
let isUnavailable: boolean;
if (isCloud) {
const sandbox = cloudSandboxQuery.data;
@@ -94,17 +150,47 @@ export const useUnifiedVSCodeUrl = () => {
: undefined,
};
};
// Cloud behavior is deliberately unchanged: a sandbox with no VSCODE
// entry in `exposed_urls` still surfaces the control. Narrowing this
// change to self-hosted keeps its blast radius off the cloud path.
isUnavailable = false;
} else {
data = localQuery.data;
isLoading = localQuery.isLoading;
isError = localQuery.isError;
// The URL request only starts once the capability probe has cleared it,
// so the control is "loading" for the probe as well — otherwise it would
// look ready while there is still nothing to open.
isLoading = statusQuery.isLoading || localQuery.isLoading;
isError = statusQuery.isError || localQuery.isError;
isSuccess = localQuery.isSuccess;
status = localQuery.status;
error = localQuery.error;
error = statusQuery.error ?? localQuery.error;
refetch = async () => {
// Load-bearing and easy to misread: this fires a real request even when
// the probe failed and left the URL query `enabled: false`. An observer's
// own `refetch()` does not consult `enabled` — only
// `queryClient.refetchQueries` skips disabled queries — so a click after
// a failed probe still retries rather than silently doing nothing.
const result = await localQuery.refetch();
return { data: result.data };
};
// Hide only on an explicit, terminal capability answer:
// - this origin serves no editor at all, so nothing it advertises is
// reachable from this page,
// - the probe succeeded and reports no usable editor (disabled, or
// enabled but not running),
// - the editor is there but reports no URL to open, or
// - the URL resolves somewhere this origin does not route the editor —
// an extra backend with no prefix of its own hands back the canvas
// root, which would open this app again instead of an editor.
// A failed probe is deliberately not "unavailable": transport, auth and
// server faults stay visible as query errors with their normal retry and
// toast, rather than silently removing the control.
isUnavailable =
!originServesEditor ||
(statusQuery.isSuccess && !editorIsAvailable) ||
(isSuccess && !localQuery.data?.url) ||
(isSuccess &&
!isVSCodeUrlServedByOrigin(localQuery.data?.url, originBasePath));
}
// Derive the i18n'd "URL unavailable" message outside `queryFn` so the
@@ -118,6 +204,7 @@ export const useUnifiedVSCodeUrl = () => {
isLoading,
isError,
isSuccess,
isUnavailable,
status,
refetch,
};
+109
View File
@@ -0,0 +1,109 @@
/**
* Whether *this browser origin* can serve the editor, and whether a given
* editor URL is one it will actually route.
*
* Editor availability is not a property of the agent-server process alone. The
* agent-server answers `/api/vscode/status` for whatever conversation you ask
* about, and builds `/api/vscode/url` from the `base_url` the caller sends —
* which is always `window.location.origin`. So a server can truthfully report
* an editor that the page's own origin has no route to. Two supported layouts
* hit that:
*
* - **Public mode** (`docker/entrypoint.sh`, `--auth-required`): a second
* static server on its own port, sharing the same agent-server but
* deliberately without the editor route, because the editor's connection
* token is the session API key (OpenHands/software-agent-sdk#4317) and that
* origin exists to test the *unauthenticated* case.
* - **Extra backends** (`scripts/dev-extra-backend.mjs`): a second
* agent-server registered from a browser whose origin belongs to another
* stack. That origin's editor route, if any, points at the *bundled*
* stack's editor — a different container's workspace.
*
* In both cases a control gated only on the server probe renders and then
* resolves to the canvas SPA or, worse, someone else's workspace. So the
* frontend has to intersect the server's capability with the origin's route
* table, and this module is the origin half of that.
*/
const VSCODE_BASE_PATH_WINDOW_KEY = "__AGENT_CANVAS_VSCODE_BASE_PATH__";
/**
* The path prefix this origin serves the editor under, or `null` if it serves
* no editor at all.
*
* Two sources, in order — mirroring `getRawRuntimeServicesInfo` in
* `agent-server-adapter.ts`:
* 1. `VITE_VSCODE_BASE_PATH` — baked in at build time by the dev launchers
* that proxy the editor (`npm run dev`, dev:static).
* 2. `window.__AGENT_CANVAS_VSCODE_BASE_PATH__` — injected into index.html at
* serve time by `scripts/static-server.mjs --vscode-base-path <path>`,
* which refuses to start unless a matching `--route` exists. That is the
* path used by static builds (the Docker image, the published binary),
* where the env var is empty in the prebuilt bundle.
*
* Absent from both means "this origin does not serve the editor" and the
* control is hidden. That is the pre-existing behavior for every local backend,
* so a deployment this cannot detect is no worse off than before.
*/
export function getOriginVSCodeBasePath(): string | null {
const envRaw = import.meta.env.VITE_VSCODE_BASE_PATH?.trim();
if (envRaw) return normalizePrefix(envRaw);
if (typeof window !== "undefined") {
const injected = (window as unknown as Record<string, unknown>)[
VSCODE_BASE_PATH_WINDOW_KEY
];
if (typeof injected === "string" && injected.trim()) {
return normalizePrefix(injected.trim());
}
}
return null;
}
function normalizePrefix(value: string): string {
const withLeadingSlash = value.startsWith("/") ? value : `/${value}`;
return withLeadingSlash.length > 1
? withLeadingSlash.replace(/\/+$/, "")
: withLeadingSlash;
}
/**
* Whether `url` is an editor URL this origin will actually route.
*
* The agent-server appends its own `vscode_base_path` to the origin we send it,
* so the returned URL carries the prefix of the server that answered — not
* necessarily the prefix this origin routes. An extra backend that configures
* no prefix yields `<origin>/?tkn=…`, which is the canvas root: same origin,
* wrong destination. Comparing paths is what separates the two.
*/
export function isVSCodeUrlServedByOrigin(
url: string | null | undefined,
basePath: string | null,
): boolean {
if (!url || !basePath) return false;
let parsed: URL;
try {
parsed = new URL(
url,
typeof window !== "undefined" ? window.location.href : undefined,
);
} catch {
return false;
}
// A cross-origin URL is somebody else's problem to route, not ours to vouch
// for. The local path always builds on `window.location.origin`, so this only
// fires on a value we did not construct.
if (
typeof window !== "undefined" &&
parsed.origin !== window.location.origin
) {
return false;
}
return (
parsed.pathname === basePath || parsed.pathname.startsWith(`${basePath}/`)
);
}
+24
View File
@@ -95,6 +95,8 @@ export default defineConfig(({ mode }) => {
VITE_FRONTEND_PORT = "3001",
VITE_INSECURE_SKIP_VERIFY = "false",
VITE_BASE_PATH,
VITE_VSCODE_BASE_PATH,
VITE_VSCODE_TARGET,
// Runtime-services metadata for the dev server, passed by launchers that
// run the Vite dev server directly (e.g. dev:minimal). Unlike
// ingress/static-server, the Vite proxy cannot post-process the upstream
@@ -437,6 +439,28 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: !INSECURE_SKIP_VERIFY,
},
// The bundled editor, when the launcher put agent-server into
// prefix-mode (`dev:minimal` — see VITE_VSCODE_TARGET in
// scripts/dev-safe.mjs). agent-server then advertises
// `<origin><prefix>/?tkn=…`, and this origin is Vite's, so without
// this entry the prefix falls through to the SPA and the editor
// button opens a second copy of the canvas.
//
// The prefix is preserved, not rewritten: openvscode-server is
// launched with `--server-base-path`, generates its HTTP and
// WebSocket URLs beneath the prefix, and only answers there.
// `ws: true` because the workbench upgrades to a WebSocket
// immediately on load.
...(VITE_VSCODE_BASE_PATH && VITE_VSCODE_TARGET
? {
[VITE_VSCODE_BASE_PATH]: {
target: VITE_VSCODE_TARGET,
ws: true,
changeOrigin: true,
secure: !INSECURE_SKIP_VERIFY,
},
}
: {}),
},
watch: {
ignored: ["**/node_modules/**", "**/.git/**"],