fix(security): require proof that x-9r-real-ip came from the socket (GHSA-pjm4-8fpg-f9p6)

x-9r-real-ip and the Host fallback were trusted from client-controlled
headers whenever custom-server.js was not in the request path (npm run
start, start:bun), letting a remote caller pose as local to skip API key
auth and reach LOCAL_ONLY_PATHS (/api/mcp/*, /api/tunnel/enable,
/api/auth/reset-password).

custom-server.js now generates a per-process secret at boot and stamps it
as x-9r-peer-token on every request it sanitizes. hasTrustedPeerHeaders()
(src/lib/auth/trustedPeer.js) gates trust in x-9r-real-ip on that secret;
otherwise the guard falls back to Host only in development, and fails
closed in production. Same gate on loginLimiter.getClientIp() so a spoofed
header cannot rotate the login lockout bucket.

Also: fix isLoopbackHostname for IPv6 (::1, ::ffff:127.0.0.1) which the
old split(":")[0] reduced to empty string; route npm run start /
start:bun through custom-server.js (postbuild copies it into
.next/standalone, build-cli.js fails without it) so documented deployments
keep passwordless local access.
This commit is contained in:
Nguyen Thanh Dat
2026-08-14 16:32:40 +07:00
committed by decolua
parent b04c03c6b5
commit 92259214db
12 changed files with 403 additions and 23 deletions

View File

@@ -216,7 +216,9 @@ function buildCliPackage() {
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
console.log("✅ Copied custom-server.js\n");
} else {
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
console.error(" custom-server.js not found — without it no request can be proven local,");
console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1.");
process.exit(1);
}
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.

View File

@@ -1,9 +1,18 @@
const http = require("http");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const { pathToFileURL } = require("url");
const origCreate = http.createServer.bind(http);
// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client.
// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching
// header even though the env var is inherited by child processes. Named like x-9r-cli-token
// so the request-detail header sanitizer redacts it too.
const PEER_TOKEN = crypto.randomBytes(24).toString("hex");
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
let backgroundRefreshStarted = false;
function startBackgroundTokenRefreshFromCustomServer() {
@@ -57,7 +66,9 @@ http.createServer = (...args) => {
delete req.headers["x-9r-real-ip"];
delete req.headers["x-forwarded-for"];
delete req.headers["x-9r-via-proxy"];
delete req.headers["x-9r-peer-token"];
req.headers["x-9r-real-ip"] = ip;
req.headers["x-9r-peer-token"] = PEER_TOKEN;
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
return handler(req, res);
};
@@ -114,4 +125,15 @@ http.createServer = (...args) => {
return server;
};
if (require.main === module) require("./server.js");
if (require.main === module) {
const standalone = path.join(__dirname, "server.js");
if (fs.existsSync(standalone)) {
require(standalone);
} else {
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
// server in-process, so the wrapper above still sanitizes every request.
const nextBin = require.resolve("next/dist/bin/next");
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
require(nextBin);
}
}

View File

@@ -9,10 +9,10 @@
"build": "next build --webpack",
"postbuild": "node scripts/copy-standalone-assets.mjs",
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
"start": "next start --port 20127",
"start": "node custom-server.js --port 20127",
"dev:bun": "bun --bun next dev --webpack --port 20127",
"build:bun": "bun --bun next build --webpack",
"start:bun": "bun ./.next/standalone/server.js",
"start:bun": "bun ./.next/standalone/custom-server.js",
"cli:pack": "npm --prefix cli run pack:cli",
"cli:publish": "npm --prefix cli run publish:cli"
},

View File

@@ -29,6 +29,14 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr
cpSync(publicSource, publicDestination, { recursive: true, force: true });
console.log(`[standalone-assets] Copied public assets to ${publicDestination}`);
}
// Without it beside server.js the standalone build serves requests unsanitized.
const serverWrapperSource = resolve(projectRoot, "custom-server.js");
const serverWrapperDestination = resolve(standaloneDir, "custom-server.js");
if (existsSync(serverWrapperSource)) {
cpSync(serverWrapperSource, serverWrapperDestination, { force: true });
console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`);
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) {

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getSettings, validateApiKey } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer";
const CLI_TOKEN_HEADER = "x-9r-cli-token";
const CLI_TOKEN_SALT = "9r-cli-auth";
@@ -87,24 +88,40 @@ const LOCAL_ONLY_PATHS = [
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
// Accepts a Host header, a URL hostname or a raw socket address. Splitting on the first
// colon only works for IPv4 and would reduce every IPv6 form to "", so a dual-stack
// listener handing back ::ffff:127.0.0.1 would not read as loopback.
function isLoopbackHostname(h) {
if (!h) return false;
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
let name = String(h).trim().toLowerCase();
if (name.startsWith("[")) {
const end = name.indexOf("]");
if (end === -1) return false;
name = name.slice(1, end);
} else if (name.indexOf(":") !== -1 && name.indexOf(":") === name.lastIndexOf(":")) {
name = name.slice(0, name.indexOf(":"));
}
if (name.startsWith("::ffff:")) name = name.slice(7);
return LOOPBACK_HOSTS.has(name);
}
function isLoopbackPeer(request) {
if (hasTrustedPeerHeaders(request)) {
return isLoopbackHostname(request.headers.get("x-9r-real-ip"));
}
// Bare `next dev` forks its server, so the wrapper never loads and no peer address
// reaches us. Host is spoofable, so this stays confined to development.
if (process.env.NODE_ENV === "development") {
return isLoopbackHostname(request.headers.get("host"));
}
return false;
}
export function isLocalRequest(request) {
// Stamped by custom-server.js when forwarding headers exist: request came through
// a reverse proxy, so the loopback socket is the proxy hop, not the end-user.
if (request.headers.get("x-9r-via-proxy")) return false;
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) {
if (!isLoopbackHostname(realIp)) return false;
} else if (!isLoopbackHostname(request.headers.get("host"))) {
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
return false;
}
if (!isLoopbackPeer(request)) return false;
const origin = request.headers.get("origin");
if (origin) {
try {

View File

@@ -1,4 +1,5 @@
// In-memory progressive lockout for dashboard login. Resets on process restart.
import { hasTrustedPeerHeaders } from "./trustedPeer.js";
const MAX_FAILS_BEFORE_LOCK = 5;
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
@@ -46,9 +47,12 @@ export function recordSuccess(ip) {
}
export function getClientIp(request) {
// Trusted: set from TCP socket by custom-server.js (client cannot spoof).
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) return realIp;
// Trusted only when custom-server.js proves it stamped the header from the TCP socket;
// otherwise a client could rotate the value to escape its own lockout bucket.
if (hasTrustedPeerHeaders(request)) {
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) return realIp;
}
// Behind a trusted reverse proxy that overwrites XFF with the real client IP.
if (process.env.TRUST_PROXY === "true") {
const xff = request.headers.get("x-forwarded-for");

View File

@@ -0,0 +1,7 @@
// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket.
// It proves that by echoing the per-process secret it generated at boot, which a client
// cannot guess. Without the proof the header is just attacker-supplied input.
export function hasTrustedPeerHeaders(request) {
const token = process.env.NINEROUTER_PEER_TOKEN;
return Boolean(token) && request.headers.get("x-9r-peer-token") === token;
}

View File

@@ -68,6 +68,8 @@ function sanitizeHeaders(headers) {
return sanitized;
}
export const __test__ = { sanitizeHeaders };
function generateDetailId(model) {
const timestamp = new Date().toISOString();
const random = Math.random().toString(36).substring(2, 8);

View File

@@ -0,0 +1,86 @@
// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createRequire } from "node:module";
import http from "node:http";
import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
const require = createRequire(import.meta.url);
let server;
let baseUrl;
let seenHeaders;
beforeAll(async () => {
require("../../custom-server.js");
server = http.createServer((req, res) => {
seenHeaders = req.headers;
res.end("ok");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
async function get(headers = {}) {
await fetch(baseUrl, { headers });
return seenHeaders;
}
describe("custom-server peer header sanitizing", () => {
it("generates a peer trust token at boot", () => {
expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
});
it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
});
it("stamps the trust token so downstream can tell the wrapper ran", async () => {
const headers = await get();
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
});
it("drops a client-supplied peer trust token", async () => {
const headers = await get({ "x-9r-peer-token": "forged-token" });
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
});
it("drops a client-supplied x-9r-via-proxy marker", async () => {
const headers = await get({ "x-9r-via-proxy": "1" });
expect(headers["x-9r-via-proxy"]).toBeUndefined();
});
it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
expect(headers["x-9r-via-proxy"]).toBe("1");
expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
expect(headers["x-forwarded-for"]).toBeUndefined();
});
// chat.js snapshots every client header into the request detail. Anything that grants
// access must not survive into a record the dashboard renders and cloud sync uploads.
it("keeps the peer token out of persisted request details", () => {
const sanitized = requestDetails.sanitizeHeaders({
"x-9r-peer-token": "secret",
"x-9r-cli-token": "secret",
"authorization": "Bearer sk-x",
"x-9r-real-ip": "127.0.0.1",
});
expect(sanitized["x-9r-peer-token"]).toBeUndefined();
expect(sanitized["x-9r-cli-token"]).toBeUndefined();
expect(sanitized["authorization"]).toBeUndefined();
expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
});
});

View File

@@ -35,6 +35,8 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
const PEER_TOKEN = "peer-token-fixture";
function request(pathname, headers = {}) {
const normalizedHeaders = new Headers(headers);
return {
@@ -45,9 +47,16 @@ function request(pathname, headers = {}) {
};
}
// A request that actually came through custom-server.js: peer IP stamped from the TCP
// socket and proven by the per-process secret.
function localRequest(pathname, headers = {}) {
return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
}
describe("dashboard guard public LLM API access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -55,14 +64,14 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback public LLM API without API key", async () => {
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
const response = await proxy(request("/v1/chat/completions", {
const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost",
"x-9r-real-ip": "10.204.111.34",
}));
@@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback peer IP regardless of Host", async () => {
const response = await proxy(request("/v1/chat/completions", {
const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
}));
@@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback rewritten public LLM API without API key", async () => {
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
@@ -191,6 +200,7 @@ describe("dashboard guard public LLM API access", () => {
describe("dashboard guard local-only access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@@ -207,7 +217,7 @@ describe("dashboard guard local-only access", () => {
});
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
const response = await proxy(request("/api/mcp/filesystem/sse", {
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@@ -219,7 +229,7 @@ describe("dashboard guard local-only access", () => {
it("allows local-only route on loopback when requireLogin=false", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@@ -240,7 +250,7 @@ describe("dashboard guard local-only access", () => {
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://evil.example.com",
}));

View File

@@ -0,0 +1,211 @@
// GHSA-pjm4-8fpg-f9p6 (#3294): `next start` leaves custom-server.js out of the request
// path, so x-9r-real-ip arrives straight from the client and a remote caller can claim to
// be loopback. Host is spoofable the same way, so it cannot be the production fallback.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mocks = vi.hoisted(() => ({
nextResponse: Symbol("next"),
jsonResponse: vi.fn((body, init) => ({ status: init?.status || 200, body })),
getSettings: vi.fn(),
validateApiKey: vi.fn(),
getConsistentMachineId: vi.fn(),
verifyDashboardAuthToken: vi.fn(),
}));
vi.mock("next/server", () => ({
NextResponse: {
next: vi.fn(() => mocks.nextResponse),
json: mocks.jsonResponse,
redirect: vi.fn((url) => ({ status: 307, url })),
},
}));
vi.mock("@/lib/localDb", () => ({
getSettings: mocks.getSettings,
validateApiKey: mocks.validateApiKey,
}));
vi.mock("@/shared/utils/machineId", () => ({
getConsistentMachineId: mocks.getConsistentMachineId,
}));
vi.mock("@/lib/auth/dashboardSession", () => ({
verifyDashboardAuthToken: mocks.verifyDashboardAuthToken,
}));
const { proxy } = await import("../../src/dashboardGuard.js");
const { getClientIp } = await import("../../src/lib/auth/loginLimiter.js");
const PEER_TOKEN = "peer-token-fixture";
function request(pathname, headers = {}) {
return {
nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
headers: new Headers(headers),
cookies: { get: vi.fn(() => undefined) },
url: `http://localhost${pathname}`,
};
}
const originalNodeEnv = process.env.NODE_ENV;
describe("peer header trust", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
process.env.NODE_ENV = "production";
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
mocks.verifyDashboardAuthToken.mockResolvedValue(false);
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
delete process.env.NINEROUTER_PEER_TOKEN;
});
it("rejects a spoofed loopback peer IP that carries no trust proof", async () => {
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
}));
expect(response.status).toBe(401);
expect(response.body.error).toBe("API key required for remote API access");
});
it("rejects a spoofed loopback peer IP carrying a wrong trust token", async () => {
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": "guessed-token",
}));
expect(response.status).toBe(401);
});
it("rejects a spoofed loopback Host in production", async () => {
const response = await proxy(request("/api/v1/models", { host: "localhost" }));
expect(response.status).toBe(401);
});
it("rejects a spoofed loopback peer IP when the wrapper never booted", async () => {
delete process.env.NINEROUTER_PEER_TOKEN;
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": "any-token",
}));
expect(response.status).toBe(401);
});
it("keeps serving a genuinely local request stamped by the wrapper", async () => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
// A dual-stack listener reports loopback as ::ffff:127.0.0.1, which the old
// split-on-first-colon check reduced to "".
it.each(["::ffff:127.0.0.1", "::1", "[::1]", "127.0.0.1", "::FFFF:127.0.0.1"])(
"treats %s as a loopback peer",
async (peerIp) => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": peerIp,
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response).toBe(mocks.nextResponse);
}
);
it.each(["::ffff:10.204.111.34", "2001:db8::1", "[2001:db8::1]", "10.204.111.34"])(
"refuses %s as a peer",
async (peerIp) => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": peerIp,
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response.status).toBe(401);
}
);
it("still refuses a stamped non-loopback peer IP", async () => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": "10.204.111.34",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response.status).toBe(401);
});
it("blocks spoofed local-only routes that would otherwise spawn processes", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/mcp/filesystem/sse", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
}));
expect(response.status).toBe(403);
expect(response.body.error).toBe("Local only: CLI token required");
});
it("accepts the legacy Host fallback only in development", async () => {
process.env.NODE_ENV = "development";
const response = await proxy(request("/api/v1/models", { host: "localhost:20127" }));
expect(response).toBe(mocks.nextResponse);
});
});
describe("login limiter client IP", () => {
beforeEach(() => {
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
delete process.env.TRUST_PROXY;
});
afterEach(() => {
delete process.env.NINEROUTER_PEER_TOKEN;
delete process.env.TRUST_PROXY;
});
it("buckets spoofed peer IPs together so lockout cannot be rotated away", () => {
const first = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "1.1.1.1" }));
const second = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "2.2.2.2" }));
expect(first).toBe("unknown");
expect(second).toBe("unknown");
});
it("keys on the stamped peer IP when the wrapper proved it", () => {
const ip = getClientIp(request("/api/auth/login", {
"x-9r-real-ip": "203.0.113.9",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(ip).toBe("203.0.113.9");
});
it("still honours TRUST_PROXY for operators fronting 9router with a reverse proxy", () => {
process.env.TRUST_PROXY = "true";
const ip = getClientIp(request("/api/auth/login", { "x-forwarded-for": "198.51.100.7, 10.0.0.1" }));
expect(ip).toBe("198.51.100.7");
});
});

View File

@@ -37,6 +37,17 @@ describe("standalone build assets", () => {
.toBe("static asset");
});
// Without the wrapper beside server.js nothing can prove a request is local.
it("copies the request-sanitizing server wrapper into the standalone output", () => {
const projectRoot = createBuildFixture(".next");
writeFileSync(join(projectRoot, "custom-server.js"), "wrapper");
copyStandaloneAssets({ projectRoot, distDir: ".next" });
expect(readFileSync(join(projectRoot, ".next", "standalone", "custom-server.js"), "utf8"))
.toBe("wrapper");
});
it("does not modify workspace-traced CLI builds", () => {
const projectRoot = createBuildFixture(".next-cli-build");
const previousMode = process.env.NEXT_TRACING_ROOT_MODE;