| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"); |
|
|
| |
| |
| |
| 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]); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| |
| advertisedBasePath: string; |
| advertisedPort: string; |
| |
| route: string; |
| } |
|
|
| function resolveEditorConfig( |
| env: Record<string, string> = {}, |
| ): ResolvedEditorConfig { |
| const script = [ |
| "set -uo pipefail", |
| |
| `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"); |
|
|
| |
| |
| 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, |
| }; |
| } |
|
|
| |
| 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", () => { |
| |
| |
| |
| 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(); |
| |
| |
| expect(invocations).toHaveLength(2); |
|
|
| const [normal] = invocations; |
| expect(normal).toContain('--route "$VSCODE_ROUTE"'); |
|
|
| |
| |
| |
| const assignments = entrypoint.match(/^VSCODE_ROUTE=/gm) ?? []; |
| expect(assignments).toHaveLength(1); |
| }); |
|
|
| it("advertises the editor prefix on the instance that routes it", () => { |
| |
| |
| |
| |
| |
| const [normal] = staticServerInvocations(); |
| expect(normal).toContain('--vscode-base-path "$VSCODE_BASE_PATH"'); |
| }); |
|
|
| it("keeps the editor off the public-mode (--auth-required) instance", () => { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const publicMode = staticServerInvocations().find((invocation) => |
| invocation.includes("--auth-required"), |
| ); |
| expect(publicMode).toBeDefined(); |
| expect(publicMode).not.toContain("VSCODE_ROUTE"); |
| |
| |
| |
| |
| expect(publicMode).not.toContain("--vscode-base-path"); |
| }); |
|
|
| it("sends Referrer-Policy: no-referrer on the editor path", () => { |
| |
| |
| |
| const [normal] = staticServerInvocations(); |
| expect(normal).toContain('--no-referrer-prefix "$VSCODE_BASE_PATH"'); |
| }); |
|
|
| it("routes the editor to its own port, not the agent-server", () => { |
| |
| |
| 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", () => { |
| |
| |
| |
| expect(dockerfile).not.toMatch( |
| new RegExp(`^\\s*EXPOSE\\s+${defaults.ports.vscode}\\b`, "m"), |
| ); |
| }); |
| }); |
|
|
| |
| |
| describe.skipIf(process.platform === "win32")( |
| "docker editor config resolution", |
| () => { |
| it("advertises and routes the same pair with no overrides", () => { |
| const resolved = resolveEditorConfig(); |
| expectRouteMatchesAdvertised(resolved); |
| |
| |
| 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"); |
| }); |
|
|
| |
| |
| |
| |
| 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", |
| }); |
| |
| |
| 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", () => { |
| |
| |
| const resolved = resolveEditorConfig({ OH_VSCODE_BASE_PATH: "/" }); |
| expect(resolved.status).not.toBe(0); |
| expect(resolved.stderr).toContain("site root"); |
| }); |
|
|
| |
| |
| |
| |
| |
| 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"); |
| }); |
|
|
| |
| |
| |
| |
| |
| 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", () => { |
| |
| |
| |
| 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", () => { |
| |
| |
| 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([ |
| |
| |
| |
| ["/vs=code", "may only contain"], |
| ["/a b", "may only contain"], |
| ["/x?y", "may only contain"], |
| ["/x#y", "may only contain"], |
| |
| |
| |
| ["/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", () => { |
| |
| |
| const resolved = resolveEditorConfig({ OH_VSCODE_PORT: "not-a-port" }); |
| expect(resolved.status).not.toBe(0); |
| expect(resolved.stderr).toContain("must be a number"); |
| }); |
| }, |
| ); |
|
|