mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
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:
co-authored by
Harish Chandramowli
Engel Nyst
Claude Opus 5
parent
ab23be62ad
commit
bad1687dec
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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) &&
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}/`)
|
||||
);
|
||||
}
|
||||
@@ -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/**"],
|
||||
|
||||
Reference in New Issue
Block a user