Skip to content
19 changes: 16 additions & 3 deletions .xum/skills/dev-server-sandbox/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,25 @@ The backend runs with auth on. `make dev-server` generates a random token per ru

```bash
TOKEN=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token' "$XUM_ROOT/server.lock")
printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -X POST -H @- "http://127.0.0.1:$BACKEND_PORT/api/config/getConfig"
printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -s -X POST -H @- "http://127.0.0.1:$BACKEND_PORT/api/config/getConfig"
```

Command lines are readable by every local account. So node gets only the lockfile path, and the header goes to `curl -H @-` on stdin from the `printf` builtin. Never pass the token as an argument.

- Browser: open `http://localhost:$VITE_PORT/?token=$(node -p 'encodeURIComponent(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).token)' "$XUM_ROOT/server.lock")` once. The app keeps the token and strips it from the address bar. Without it, the app asks for the token. The URL reaches the browser tool as an argument, so other local accounts can see it for the length of that call.
Open the app in a browser once with the token in the URL. Node builds the URL and pipes it to `agent-browser batch` on stdin, so the token is in no command line. The batch prints the URL it opened, with the token, so the recipe keeps that output in a variable: it prints nothing when the open works, and prints it with the token removed when it fails:

```bash
out=$(node -e '
const [lock, port] = process.argv.slice(1);
const token = JSON.parse(require("fs").readFileSync(lock, "utf8")).token;
const url = `http://localhost:${port}/?token=${encodeURIComponent(token)}`;
process.stdout.write(JSON.stringify([["open", url]]));
' "$XUM_ROOT/server.lock" "$VITE_PORT" | agent-browser batch --bail 2>&1) ||
{ sed 's/token=[^&[:space:]]*/token=<redacted>/g' <<<"$out" >&2; false; }
```

The app keeps the token and strips it from the address bar, so later `agent-browser` commands carry no token. Without it, the app asks for the token. Do not use `agent-browser open "<url with token>"`: it puts the token in the command line. Do not pipe the batch straight to the terminal: its output repeats the URL with the token.

- `XUM_ROOT=<sandbox root> xum api ...` reads the token from the lockfile.
- The Vite proxy adds no credentials, so a request to the Vite port needs the token too.
- Keep `XUM_ROOT` outside the checkout. Vite serves the checkout to anyone who can reach its port, and its `public/` folder without any file checks. The sandbox root is under the system temp dir.
Expand Down Expand Up @@ -109,7 +122,7 @@ MAKE=gmake make dev-server-sandbox

## Security notes

- The sandbox backend runs with `--no-auth`, and Vite proxies `/api`, `/orpc` and `/auth` to it. Any Host header Vite accepts therefore reaches an unauthenticated backend and the checkout's source. A web page that rebinds its DNS to this machine can use `Host: <its own name>` for that. So the sandbox never defaults to `all`.
- Vite proxies `/api`, `/orpc` and `/auth` to a backend that requires the token, but it serves the checkout's source to any Host header it accepts. A web page that rebinds its DNS to this machine can use `Host: <its own name>` for that. So the sandbox never defaults to `all`.
- For another remote or proxy host, opt in with `XUM_VITE_ALLOWED_HOSTS=<host>[,<host>]` (the unprefixed `VITE_ALLOWED_HOSTS` also works). A Vite `403 Blocked request. This host ("...") is not allowed` means the Host is missing from that list. Use `all` only on a machine that no other user or browser can reach.
- `providers.jsonc` may contain API keys.
- The sandbox root directory is created on disk (usually under your system temp dir).
Expand Down
8 changes: 4 additions & 4 deletions scripts/dev-server-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,10 @@ async function main(): Promise<number> {
}

// Vite answers any Host header it does not allow with 403 (DNS-rebinding guard), and always
// allows loopback names and IPs. The sandbox backend runs --no-auth, so never default to `all`:
// any web page that rebinds its DNS to this machine could then read the checkout and call the
// backend. Remote access is an explicit opt-in (VITE_ALLOWED_HOSTS, any spelling), or the one
// Coder port-forward host of this sandbox.
// allows loopback names and IPs. The sandbox backend needs its token, but Vite serves the checkout
// without one, so never default to `all`: any web page that rebinds its DNS to this machine could
// then read the checkout. Remote access is an explicit opt-in (VITE_ALLOWED_HOSTS, any spelling),
// or the one Coder port-forward host of this sandbox.
const requestedHosts =
resolveXumEnvironmentValue("VITE_ALLOWED_HOSTS", process.env) ?? process.env.VITE_ALLOWED_HOSTS;
const allowedHosts = requestedHosts?.trim()
Expand Down
40 changes: 26 additions & 14 deletions scripts/perf/firstRunViewParity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { chromium, type Browser, type Page } from "playwright";
import sharp from "sharp";
import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView";
import { FIRST_RUN_VIEW_HTML } from "@/node/orpc/firstRunView";
import { newServerToken, serverTokenEnv, withServerToken } from "./serverToken";

export interface Box {
x: number;
Expand Down Expand Up @@ -108,8 +109,15 @@ async function startServer(tree: string, root: string, logFile: string) {
const { port } = probePort.address() as net.AddressInfo;
probePort.close();
const log = fs.openSync(logFile, "w");
const env = { PATH: process.env.PATH, HOME: root, XUM_ROOT: root, XUM_DISABLE_TELEMETRY: "1" };
const args = `dist/cli/index.js server --no-auth --host 127.0.0.1 --port ${port}`.split(" ");
const token = newServerToken();
const env = {
PATH: process.env.PATH,
HOME: root,
XUM_ROOT: root,
XUM_DISABLE_TELEMETRY: "1",
...serverTokenEnv(token),
};
const args = `dist/cli/index.js server --host 127.0.0.1 --port ${port}`.split(" ");
const server = spawn("node", args, {
cwd: tree,
env: { ...env, GIT_CONFIG_GLOBAL: "/dev/null" },
Expand All @@ -133,7 +141,7 @@ async function startServer(tree: string, root: string, logFile: string) {
await stop();
throw new Error(`the server did not start, see ${logFile}`);
}
return { origin, stop };
return { origin, token, stop };
}

/** The box of the first `selector` match starting with `text`, in the static view or outside it. */
Expand Down Expand Up @@ -171,7 +179,7 @@ async function main(): Promise<number> {
let injected = false;
try {
server = await startServer(path.resolve(a.tree), root, path.join(out, "server.log"));
const { origin } = server;
const { origin, token } = server;
// A route-fulfilled document has no local address space, so Local Network Access would block the
// app's WebSocket to 127.0.0.1. pageTiming.ts navigates without fulfill and needs no such flag.
browser = await chromium.launch({
Expand All @@ -190,20 +198,24 @@ async function main(): Promise<number> {
const page = await context.newPage();
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
await page.route(`${origin}/`, async (route) => {
const response = await route.fetch();
const html = await response.text();
injected ||= !html.includes(`id="${FIRST_RUN_VIEW_ID}"`);
const body = html.includes(`id="${FIRST_RUN_VIEW_ID}"`)
? html
: withFirstRunView(html, FIRST_RUN_VIEW_HTML);
await route.fulfill({ status: response.status(), contentType: "text/html", body });
});
// The page loads with `?token=`, so match the path, not the whole URL.
await page.route(
(url) => url.origin === origin && url.pathname === "/",
async (route) => {
const response = await route.fetch();
const html = await response.text();
injected ||= !html.includes(`id="${FIRST_RUN_VIEW_ID}"`);
const body = html.includes(`id="${FIRST_RUN_VIEW_ID}"`)
? html
: withFirstRunView(html, FIRST_RUN_VIEW_HTML);
await route.fulfill({ status: response.status(), contentType: "text/html", body });
}
);
await page.route(/\/main-[^/]+\.js$/, async (route) => {
await held;
await route.continue();
});
await page.goto(`${origin}/`, { waitUntil: "commit" });
await page.goto(withServerToken(`${origin}/`, token), { waitUntil: "commit" });
// Measure the painted static view: stylesheets applied (a stylesheet that never loads is an
// invalid run after 60 s), then the fonts that layout asked for.
const sheets = "link[rel=stylesheet]";
Expand Down
16 changes: 12 additions & 4 deletions scripts/perf/generatePhoneChrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { chromium, type Browser } from "playwright";
import { PHONE_CHROME_JSON } from "../../src/common/staticWorkspaceView/phoneChrome.generated";
import { PHONE_CHROME_WIDTHS } from "../../src/common/staticWorkspaceView/phoneChrome";
import { writeNeutralSeed } from "./neutralSeed";
import { newServerToken, serverTokenEnv, withServerToken } from "./serverToken";

const OUT = path.join(
import.meta.dirname,
Expand All @@ -33,9 +34,16 @@ async function startServer(root: string, home: string, log: string) {
await once(probe, "listening");
const { port } = probe.address() as net.AddressInfo;
probe.close();
const args = `dist/cli/index.js server --no-auth --host 127.0.0.1 --port ${port}`.split(" ");
const args = `dist/cli/index.js server --host 127.0.0.1 --port ${port}`.split(" ");
const fd = fs.openSync(log, "w");
const env = { PATH: process.env.PATH, HOME: home, XUM_ROOT: root, XUM_DISABLE_TELEMETRY: "1" };
const token = newServerToken();
const env = {
PATH: process.env.PATH,
HOME: home,
XUM_ROOT: root,
XUM_DISABLE_TELEMETRY: "1",
...serverTokenEnv(token),
};
const server = spawn("node", args, {
cwd: path.join(import.meta.dirname, "../.."),
env: { ...env, GIT_CONFIG_GLOBAL: "/dev/null" },
Expand All @@ -59,7 +67,7 @@ async function startServer(root: string, home: string, log: string) {
const tail = fs.readFileSync(log, "utf8").slice(-2000); // the temp folder goes next
throw new Error(`xum server did not start. Its log ends with:\n${tail}`);
}
return { origin, stop };
return { origin, token, stop };
}

/** Runs in the page: the skeleton of the live DOM. Self-contained, Playwright serializes it. */
Expand Down Expand Up @@ -175,7 +183,7 @@ async function main() {
try {
const seed = writeNeutralSeed(path.join(tmp, "seed"));
server = await startServer(seed.root, seed.home, path.join(tmp, "server.log"));
const url = `${server.origin}/workspace/${seed.workspaceId}`;
const url = withServerToken(`${server.origin}/workspace/${seed.workspaceId}`, server.token);
browser = await chromium.launch({ executablePath: values.chrome });
for (const width of PHONE_CHROME_WIDTHS.checked)
trees.push(await captureAt(browser, url, width));
Expand Down
21 changes: 15 additions & 6 deletions scripts/perf/pageTiming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as core from "./pageTimingCore";
import { probe, STATIC_VIEW_IDS } from "./pageTimingProbe";
import * as stats from "./pageTimingStats";
import { BLOCK_PAIRS, gate, kthLargest, launchPlan, median, type Signal } from "./pageTimingStats";
import { newServerToken, serverTokenEnv, withServerToken } from "./serverToken";

/** Lighthouse's mobile values, applied as DevTools throttling: real timings, not the simulator's. */
const viewport = { width: 412, height: 823 };
Expand Down Expand Up @@ -105,8 +106,15 @@ async function startServer(tree: string, root: string, home: string, logFile: st
const { port } = probePort.address() as net.AddressInfo;
probePort.close();
const log = fs.openSync(logFile, "w");
const env = { PATH: process.env.PATH, HOME: home, XUM_ROOT: root, XUM_DISABLE_TELEMETRY: "1" };
const args = `dist/cli/index.js server --no-auth --host 127.0.0.1 --port ${port}`.split(" ");
const token = newServerToken();
const env = {
PATH: process.env.PATH,
HOME: home,
XUM_ROOT: root,
XUM_DISABLE_TELEMETRY: "1",
...serverTokenEnv(token),
};
const args = `dist/cli/index.js server --host 127.0.0.1 --port ${port}`.split(" ");
const server = spawn("node", args, {
cwd: tree,
env: { ...env, GIT_CONFIG_GLOBAL: "/dev/null" },
Expand Down Expand Up @@ -136,7 +144,7 @@ async function startServer(tree: string, root: string, home: string, logFile: st
throw new Error(`${logFile}: ${String(error)}`);
});
};
return { origin, stop };
return { origin, token, stop };
}

/** One navigation with a fresh root (and seed), server and browser. Values in seconds. */
Expand All @@ -157,7 +165,7 @@ async function measure(o: Common & { tree: string; mode: Signal; name: string })
const before = seed && snapshotSeed(seedDir);
for (const dir of [root, home]) fs.mkdirSync(dir, { recursive: true });
const server = await startServer(o.tree, root, home, log);
const run = await browse(o, `${server.origin}${route}`).finally(server.stop);
const run = await browse(o, `${server.origin}${route}`, server.token).finally(server.stop);
const now = (rel: string) => fs.readFileSync(path.join(seedDir, rel));
const diff = before && diffSeed(before, seedDir);
const rewrites = diff ? core.seedRewrites(diff, before, now) : [];
Expand All @@ -172,7 +180,8 @@ async function measure(o: Common & { tree: string; mode: Signal; name: string })
});
}

async function browse(o: Parameters<typeof measure>[0], url: string) {
/** `url` is what the record keeps. The navigation adds the server's token, which stays out of the record. */
async function browse(o: Parameters<typeof measure>[0], url: string, token: string) {
const browser = await chromium.launch({ executablePath: o.chrome, headless: true }); // fresh profile
try {
const context = await browser.newContext(PHONE.context);
Expand All @@ -190,7 +199,7 @@ async function browse(o: Parameters<typeof measure>[0], url: string) {
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await cdp.send("Network.emulateNetworkConditions", PHONE.network);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: PHONE.cpuSlowdown });
await tab.goto(url, { waitUntil: "load", timeout: 180_000 });
await tab.goto(withServerToken(url, token), { waitUntil: "load", timeout: 180_000 });
const ucsDone = "r.visibleLive !== null && r.elementTiming.some((e) => e.live)";
const done = o.mode === "ucs" ? ucsDone : "r.ir !== null";
await tab.waitForFunction(`((r) => !!r && ${done})(window.__pageTiming)`, null, {
Expand Down
32 changes: 32 additions & 0 deletions scripts/perf/serverToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
import { resolveXumEnvironmentValue } from "../../src/common/compat/xumEnv";
import { newServerToken, serverTokenEnv, withServerToken } from "./serverToken";

test("every perf server gets its own random token", () => {
const [a, b] = [newServerToken(), newServerToken()];
expect(a).toMatch(/^[0-9a-f]{64}$/);
expect(b).toMatch(/^[0-9a-f]{64}$/);
expect(a).not.toBe(b);
});

// The app and the static views read `?token=` with URLSearchParams, so a token with URL metacharacters
// must come back byte for byte.
test("withServerToken puts the token in the query and keeps the path", () => {
const url = new URL(withServerToken("http://127.0.0.1:4100/workspace/abc?x=1", "a+b#c d"));
expect(url.pathname).toBe("/workspace/abc");
expect(url.searchParams.get("x")).toBe("1");
expect(url.searchParams.get("token")).toBe("a+b#c d");
expect(url.hash).toBe("");
});

// A tree built before the MUX_ to XUM_ rename reads only the MUX_ name, and a current tree reads the
// XUM_ name first. Both must see the same token, or the benchmark's browser is refused.
test("serverTokenEnv gives current and pre-rename servers the same token", () => {
const env = serverTokenEnv("t0k3n");
expect(resolveXumEnvironmentValue("SERVER_AUTH_TOKEN", env)).toBe("t0k3n");
expect(
resolveXumEnvironmentValue("SERVER_AUTH_TOKEN", {
MUX_SERVER_AUTH_TOKEN: env.MUX_SERVER_AUTH_TOKEN,
})
).toBe("t0k3n");
});
32 changes: 32 additions & 0 deletions scripts/perf/serverToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { randomBytes } from "node:crypto";
import { assignXumEnvironmentValue, type XumEnvironment } from "../../src/common/compat/xumEnv";

/**
* A perf server listens on loopback, which every local account can reach, so it starts with a token
* (the server reads `XUM_SERVER_AUTH_TOKEN`) instead of `--no-auth`. Pass it in the environment only:
* command lines are readable by every local account.
*/
export function newServerToken(): string {
return randomBytes(32).toString("hex");
}

/**
* The environment entries that hand `token` to a server. The `--tree` benchmarks also start built
* trees from before the MUX_ to XUM_ rename, which read only `MUX_SERVER_AUTH_TOKEN`, so this
* writes both names through the shared compat helper.
*/
export function serverTokenEnv(token: string): XumEnvironment {
const env: XumEnvironment = {};
assignXumEnvironmentValue(env, "SERVER_AUTH_TOKEN", token);
return env;
}

/**
* `url` with the `?token=` that the app and the static views read. Use it to navigate only: it must
* not reach a log or a result file, so record the URL without it.
*/
export function withServerToken(url: string, token: string): string {
const withToken = new URL(url);
withToken.searchParams.set("token", token);
return withToken.href;
}
7 changes: 5 additions & 2 deletions scripts/perf/workspace-scale/run-server-bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { RouterClient } from "@orpc/server";
import { z } from "zod";
import type { AppRouter } from "../../../src/node/orpc/router";
import { isWorkspaceArchived } from "../../../src/common/utils/archive";
import { newServerToken, serverTokenEnv } from "../serverToken";
import {
benchArgs,
copyFixture,
Expand Down Expand Up @@ -77,13 +78,14 @@ async function runOnce(root: string, label: string, repetition: number, launch:
const workspaceId = workspace.id;
const port = await freePort();
const origin = "http://127.0.0.1:" + port;
const token = newServerToken();
const start = performance.now();
const child = spawn(
"node",
["dist/cli/index.js", "server", "--host", "127.0.0.1", "--port", String(port), "--no-auth"],
["dist/cli/index.js", "server", "--host", "127.0.0.1", "--port", String(port)],
{
cwd: repoRoot,
env: isolatedEnv(root),
env: { ...isolatedEnv(root), ...serverTokenEnv(token) },
stdio: ["ignore", "pipe", "pipe"],
}
);
Expand Down Expand Up @@ -156,6 +158,7 @@ async function runOnce(root: string, label: string, repetition: number, launch:
new RPCLink({
origin,
url: "/orpc",
headers: { Authorization: `Bearer ${token}` },
fetch: (request, init) => fetch(request, { ...init, signal: AbortSignal.timeout(120_000) }),
})
);
Expand Down
Loading