File size: 16,730 Bytes
3201ca6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
// @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");
    });
  },
);