Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,68 @@ export function browserWorkerUrl(value?: string): string | undefined {
// writes never re-fire here: they are dispatched outside the model loop through
// reviewed, idempotency-keyed actions.
export const MODEL_MAX_RETRIES = 2;

export function parsePort(raw: string | undefined): number {
const port = Number(raw ?? 8787);
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error("PORT must be an integer between 1 and 65535");
return port;
}

export function parsePublicUrl(raw: string | undefined, port: number): string {
const value = (raw ?? `http://localhost:${port}`).trim().replace(/\/+$/, "");
if (!value) throw new Error("PUBLIC_API_URL must be a valid URL");
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error("PUBLIC_API_URL must be a valid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
throw new Error("PUBLIC_API_URL must use http or https");
// Downstream consumers concatenate this value as a base string
// (Auth.sign does `${publicUrl}${path}?owner=...`, callback is
// `${publicUrl}/api/google/callback`), so credentials, queries,
// fragments, and subpaths would produce malformed links or leak secrets.
if (parsed.username || parsed.password)
throw new Error("PUBLIC_API_URL must not contain credentials");
if (parsed.search || parsed.hash)
throw new Error("PUBLIC_API_URL must not contain a query string or fragment");
if (parsed.pathname !== "/" && parsed.pathname !== "")
throw new Error("PUBLIC_API_URL must not contain a subpath");
return parsed.origin;
}

export function parseAllowedOrigins(raw: string | undefined): string[] {
const entries = (raw ?? "http://localhost:8081,http://127.0.0.1:8081")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const origins: string[] = [];
for (const entry of entries) {
let parsed: URL;
try {
parsed = new URL(entry);
} catch {
throw new Error(`ALLOWED_ORIGINS contains an invalid origin: ${entry}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
throw new Error(`ALLOWED_ORIGINS contains an invalid origin: ${entry}`);
const origin = parsed.origin;
if (!origins.includes(origin)) origins.push(origin);
}
return origins;
}

export function isValidEncryptionKey(key: string): boolean {
try {
const bytes = Buffer.from(key, "base64");
return bytes.length === 32 && bytes.toString("base64") === key;
} catch {
return false;
}
}

export function readConfig(): Config {
const mode = process.env.WORKSPACE_MODE ?? "sample";
if (mode !== "sample" && mode !== "live")
Expand Down Expand Up @@ -131,8 +193,8 @@ export function readConfig(): Config {
"COMPUTER_PROVIDER=e2b-desktop requires a unique COMPUTER_DEPLOYMENT_ID, e.g. from `openssl rand -hex 12`",
);
}
const port = Number(process.env.PORT ?? 8787);
const publicUrl = process.env.PUBLIC_API_URL ?? `http://localhost:${port}`;
const port = parsePort(process.env.PORT);
const publicUrl = parsePublicUrl(process.env.PUBLIC_API_URL, port);
const config: Config = {
mode,
port,
Expand Down Expand Up @@ -163,16 +225,17 @@ export function readConfig(): Config {
computerProvider,
computerE2bTemplate: process.env.COMPUTER_E2B_TEMPLATE?.trim() || "desktop",
e2bApiKey,
allowedOrigins: (
process.env.ALLOWED_ORIGINS ?? "http://localhost:8081,http://127.0.0.1:8081"
).split(","),
allowedOrigins: parseAllowedOrigins(process.env.ALLOWED_ORIGINS),
// Only trust X-Forwarded-For/X-Real-IP when the deployment is known to sit
// behind a proxy that sets them; otherwise a direct caller can spoof them.
trustProxy: process.env.TRUST_PROXY === "true",
};
if (
mode === "live" &&
(!config.accessKey || config.accessKey.length < 24 || !config.encryptionKey)
(!config.accessKey ||
config.accessKey.length < 24 ||
!config.encryptionKey ||
!isValidEncryptionKey(config.encryptionKey))
)
throw new Error(
"Live mode requires OPENMUSE_ACCESS_KEY (24+ characters) and TOKEN_ENCRYPTION_KEY (32-byte base64)",
Expand Down
133 changes: 133 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { test } from "node:test";
import {
assertApiDeploymentConfig,
Expand Down Expand Up @@ -151,3 +152,135 @@ test("computer provider defaults to Docker and e2b-desktop requires a server-sid
}
}
});

const configEnvKeys = [
"WORKSPACE_MODE",
"AGENT_BACKEND",
"PORT",
"PUBLIC_API_URL",
"HOST",
"DATA_DIR",
"OPENMUSE_ACCESS_KEY",
"TOKEN_ENCRYPTION_KEY",
"CPK_INTELLIGENCE_API_KEY",
"ALLOWED_ORIGINS",
"JEV_MODE",
"TYPESAFE_API_KEY",
"COMPUTER_PROVIDER",
"COMPUTER_ENABLED",
"E2B_API_KEY",
"COMPUTER_DEPLOYMENT_ID",
] as const;

function withEnv(env: Record<string, string | undefined>, run: () => void): void {
const saved = new Map<string, string | undefined>();
for (const key of configEnvKeys) {
saved.set(key, process.env[key]);
const value = env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
run();
} finally {
for (const key of configEnvKeys) {
const value = saved.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}

function sampleEnv(extra: Record<string, string | undefined> = {}): Record<string, string> {
return {
WORKSPACE_MODE: "sample",
AGENT_BACKEND: "sample",
CPK_INTELLIGENCE_API_KEY: "test-project-key-never-sent",
...extra,
} as Record<string, string>;
}

test("readConfig rejects out-of-range or non-numeric ports", () => {
for (const port of ["abc", "", "0", "-1", "65536", "99999", "8.5", "NaN"]) {
withEnv(sampleEnv({ PORT: port }), () => {
assert.throws(() => readConfig(), { message: /PORT must be an integer between 1 and 65535/ });
});
}
withEnv(sampleEnv({ PORT: "8790" }), () => {
assert.equal(readConfig().port, 8790);
});
});

test("readConfig normalizes the public URL and derives callback links from it", () => {
withEnv(sampleEnv({ PUBLIC_API_URL: "https://example.com/" }), () => {
const config = readConfig();
assert.equal(config.publicUrl, "https://example.com");
assert.equal(config.googleRedirectUri, "https://example.com/api/google/callback");
});
withEnv(sampleEnv({ PUBLIC_API_URL: "https://example.com:8443/" }), () => {
const config = readConfig();
assert.equal(config.publicUrl, "https://example.com:8443");
});
for (const url of ["not a url", "ftp://example.com", "example.com"]) {
withEnv(sampleEnv({ PUBLIC_API_URL: url }), () => {
assert.throws(() => readConfig(), { message: /PUBLIC_API_URL/ });
});
}
});

test("readConfig rejects credentials, queries, fragments, and subpaths in the public URL", () => {
for (const url of [
"https://user@example.com",
"https://user:pass@example.com",
"https://example.com?x=1",
"https://example.com#fragment",
"https://example.com/subpath",
"https://example.com/subpath/",
]) {
withEnv(sampleEnv({ PUBLIC_API_URL: url }), () => {
assert.throws(() => readConfig(), { message: /PUBLIC_API_URL/ });
});
}
});

test("readConfig trims, filters, and deduplicates allowed origins", () => {
withEnv(
sampleEnv({ ALLOWED_ORIGINS: "https://a.example, https://b.example ,,https://a.example/" }),
() => {
assert.deepEqual(readConfig().allowedOrigins, ["https://a.example", "https://b.example"]);
},
);
withEnv(sampleEnv({ ALLOWED_ORIGINS: "not a url" }), () => {
assert.throws(() => readConfig(), { message: /ALLOWED_ORIGINS/ });
});
});

test("readConfig rejects live deployments without a 32-byte encryption key", () => {
const accessKey = "a".repeat(24);
withEnv(
sampleEnv({
WORKSPACE_MODE: "live",
AGENT_BACKEND: "model",
OPENMUSE_ACCESS_KEY: accessKey,
TOKEN_ENCRYPTION_KEY: "too-short",
}),
() => {
assert.throws(() => readConfig(), {
message: /TOKEN_ENCRYPTION_KEY.*32-byte base64/,
});
},
);
withEnv(
sampleEnv({
WORKSPACE_MODE: "live",
AGENT_BACKEND: "model",
OPENMUSE_ACCESS_KEY: accessKey,
TOKEN_ENCRYPTION_KEY: randomBytes(32).toString("base64"),
}),
() => {
const config = readConfig();
assert.equal(config.mode, "live");
assert.equal(config.accessKey, accessKey);
},
);
});
Loading