Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ CPK_INTELLIGENCE_API_KEY=
# Docker must be reachable from the API and any separate task worker.
COMPUTER_ENABLED=false
COMPUTER_IMAGE=openmuse-computer:local
# Stable deployment name shared by API and worker; defaults to a hash of PUBLIC_API_URL.
# COMPUTER_DEPLOYMENT_ID=my-openmuse
# Optional deployment name shared by API and worker. When unset, a new install keeps a random
# name in DATA_DIR; an install created before that keeps a hash of PUBLIC_API_URL.
# COMPUTER_DEPLOYMENT_ID= # e.g. from `openssl rand -hex 12`
# Optional scoped Docker engine, e.g. DOCKER_CONTEXT=colima-openmuse
# Or run each owner's computer in an E2B Desktop sandbox instead of Docker
# (graphical desktop with internet access; see docs/COMPUTER.md):
Expand Down
13 changes: 9 additions & 4 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ActionService } from "./actions.ts";
import { agentConfigured, makeRuntime } from "./agent.ts";
import { createAuth } from "./auth.ts";
import { BrowserService } from "./browser.ts";
import { ComputerService, type DockerRunner } from "./computer.ts";
import { ComputerService, type DockerRunner, resolveComputerDeployment } from "./computer.ts";
import { computerRoutes } from "./computer-routes.ts";
import { assertApiDeploymentConfig, type Config } from "./config.ts";
import type { Store } from "./db.ts";
Expand All @@ -33,6 +33,7 @@ export async function createApp(
options: { docker?: DockerRunner } = {},
) {
assertApiDeploymentConfig(config);
await resolveComputerDeployment(config);
const auth = await createAuth(db, config),
files = new Files(db, config, auth),
google = new GoogleAuth(db, config),
Expand Down Expand Up @@ -131,10 +132,14 @@ export async function createApp(
);
});
app.use("/api/*", async (c, next) => {
// A signed URL grants only the requests its page makes: reads, and the console's input.
const method = c.req.method === "HEAD" ? "GET" : c.req.method;
const signedRoute =
/^\/api\/files\/[^/]+\/content$|^\/api\/browsers\/[^/]+\/(?:preview|console)$/.test(
c.req.path,
);
(method === "GET" &&
/^\/api\/files\/[^/]+\/content$|^\/api\/browsers\/[^/]+\/(?:preview|console)$/.test(
c.req.path,
)) ||
(method === "POST" && /^\/api\/browsers\/[^/]+\/console$/.test(c.req.path));
const owner =
signedRoute && c.req.query("signature")
? auth.verify(new URL(c.req.url))
Expand Down
37 changes: 21 additions & 16 deletions apps/server/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,29 @@ export class Auth {
}
export async function createAuth(db: Store, config: Config) {
await mkdir(config.dataDir, { recursive: true, mode: 0o700 });
const path = join(config.dataDir, "session-signing-key");
let key: string;
const key = await readOrCreate(join(config.dataDir, "session-signing-key"), () =>
randomBytes(32).toString("base64"),
);
return new Auth(db, config, key);
}
/** Reads a private file, or creates it without replacing another process's value. */
export async function readOrCreate(path: string, create: () => string) {
try {
key = await readFile(path, "utf8");
return await readFile(path, "utf8");
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
key = randomBytes(32).toString("base64");
const temporary = `${path}.${randomBytes(16).toString("hex")}.tmp`;
await writeFile(temporary, key, { mode: 0o600, flag: "wx" });
try {
// Publish only complete bytes, without replacing another process's key.
await link(temporary, path);
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
key = await readFile(path, "utf8");
} finally {
await unlink(temporary);
}
}
return new Auth(db, config, key);
let value = create();
const temporary = `${path}.${randomBytes(16).toString("hex")}.tmp`;
await writeFile(temporary, value, { mode: 0o600, flag: "wx" });
try {
// Publish only complete bytes, without replacing another process's value.
await link(temporary, path);
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
value = await readFile(path, "utf8");
} finally {
await unlink(temporary);
}
return value;
}
24 changes: 22 additions & 2 deletions apps/server/src/computer.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { join, posix } from "node:path";
import { z } from "zod";
import type {
ComputerCommand,
ComputerDirectory,
ComputerSnapshot,
} from "../../../packages/domain/src/computer.ts";
import { readOrCreate } from "./auth.ts";
import {
type ComputerBackend,
DesktopComputerBackend,
Expand Down Expand Up @@ -149,6 +150,25 @@ export function computerIdentity(config: Config, owner: string) {
} as Record<string, string>,
};
}
/**
* Without COMPUTER_DEPLOYMENT_ID, a new install gets a random deployment name, so two installs
* on one Docker engine never derive the same computer. An install that already has a signing
* key started before this name existed: it keeps the PUBLIC_API_URL name its workspace uses.
* Runs before createAuth, so a new install writes its name before its signing key. The key is
* checked first, so a process starting alongside sees the name whenever it sees the key.
*/
export async function resolveComputerDeployment(config: Config) {
if (config.computerDeploymentId !== undefined) return;
await mkdir(config.dataDir, { recursive: true, mode: 0o700 });
const path = join(config.dataDir, "computer-deployment-id");
const exists = (file: string) =>
access(file).then(
() => true,
() => false,
);
if ((await exists(join(config.dataDir, "session-signing-key"))) && !(await exists(path))) return;
config.computerDeploymentId = await readOrCreate(path, () => randomBytes(16).toString("hex"));
}
export function workspacePath(path: string): string {
if (
path.includes("\0") ||
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ export function readConfig(): Config {
webSearchEnabled: process.env.WEB_SEARCH_ENABLED !== "false",
computerEnabled: process.env.COMPUTER_ENABLED === "true",
computerImage: process.env.COMPUTER_IMAGE ?? "openmuse-computer:local",
computerDeploymentId: process.env.COMPUTER_DEPLOYMENT_ID,
// A blank line from .env is unset; otherwise every such install would share one name.
computerDeploymentId: process.env.COMPUTER_DEPLOYMENT_ID?.trim() || undefined,
computerProvider,
computerE2bTemplate: process.env.COMPUTER_E2B_TEMPLATE?.trim() || "desktop",
e2bApiKey,
Expand Down
37 changes: 35 additions & 2 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand All @@ -18,6 +18,7 @@ import { createSamplePdf } from "../packages/integrations/src/pdf.ts";

let db: Store,
app: Awaited<ReturnType<typeof createApp>>["app"],
auth: Auth,
agent: AgentService,
config: Config,
token: string,
Expand All @@ -37,7 +38,7 @@ before(async () => {
googleRedirectUri: "http://localhost:8787/api/google/callback",
allowedOrigins: ["http://localhost:8081"],
};
({ app, agent } = await createApp(db, config));
({ app, agent, auth } = await createApp(db, config));
const response = await app.request("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand Down Expand Up @@ -388,3 +389,35 @@ test("guided document delegation streams a rich tool result bound to its saved t
assert.equal(task?.kind, "document");
assert.equal(task?.input.messageId, "mail-fieldtrip");
});

test("signed links authorize only the requests their page makes", async () => {
const link = (path: string) => {
const url = new URL(auth.sign("local-user", path));
return `${url.pathname}${url.search}`;
};
const file = link(`/api/files/${randomUUID()}/content`);
const consoleLink = link(`/api/browsers/${randomUUID()}/console`);
// Accepted links reach the handler, which then finds no such resource.
assert.equal((await app.request(file)).status, 404);
assert.equal((await app.request(file, { method: "HEAD" })).status, 404);
assert.equal((await app.request(consoleLink)).status, 404);
assert.equal(
(
await app.request(consoleLink, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "key", key: "Enter" }),
})
).status,
404,
);
// Any other method needs a session, so a route added later cannot inherit link access.
for (const [path, method] of [
[file, "PUT"],
[file, "DELETE"],
[file, "POST"],
[consoleLink, "DELETE"],
[consoleLink, "PATCH"],
])
assert.equal((await app.request(path, { method })).status, 401, `${method} ${path}`);
});
54 changes: 53 additions & 1 deletion tests/auth-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createApp } from "../apps/server/src/app.ts";
import { createAuth } from "../apps/server/src/auth.ts";
import { computerIdentity, resolveComputerDeployment } from "../apps/server/src/computer.ts";
import type { Config } from "../apps/server/src/config.ts";
import type { Store } from "../apps/server/src/db.ts";
import { createStore, type Store } from "../apps/server/src/db.ts";

function config(dataDir: string): Config {
return {
Expand Down Expand Up @@ -47,3 +49,53 @@ test("startup preserves an existing signing key and its links", async (t) => {
assert.equal(await readFile(join(directory, "session-signing-key"), "utf8"), key);
assert.deepEqual(await readdir(directory), ["session-signing-key"]);
});

test("new installs get their own computer name, shared by concurrent first starts", async (t) => {
const first = await mkdtemp(join(tmpdir(), "openmuse-deployment-a-"));
const second = await mkdtemp(join(tmpdir(), "openmuse-deployment-b-"));
t.after(() => rm(first, { recursive: true, force: true }));
t.after(() => rm(second, { recursive: true, force: true }));
const starts = Array.from({ length: 8 }, () => config(first));
await Promise.all(starts.map(resolveComputerDeployment));
const id = await readFile(join(first, "computer-deployment-id"), "utf8");
assert.match(id, /^[0-9a-f]{32}$/);
for (const start of starts) assert.equal(start.computerDeploymentId, id);
// Same PUBLIC_API_URL on one Docker engine no longer means the same container and volume.
const other = config(second);
await resolveComputerDeployment(other);
assert.notEqual(
computerIdentity(other, "local-user").volume,
computerIdentity(starts[0], "local-user").volume,
);
assert.deepEqual(await readdir(first), ["computer-deployment-id"]);
});

test("a new install keeps its computer name after restart", async (t) => {
const directory = await mkdtemp(join(tmpdir(), "openmuse-deployment-restart-"));
t.after(() => rm(directory, { recursive: true, force: true }));
const names: (string | undefined)[] = [];
for (let start = 0; start < 2; start++) {
const db = await createStore();
t.after(() => db.close());
const started = { ...config(directory), intelligenceApiKey: "test-project-key-never-sent" };
await createApp(db, started);
names.push(started.computerDeploymentId);
}
assert.match(names[0] ?? "", /^[0-9a-f]{32}$/);
assert.equal(names[1], names[0]);
});

test("existing installs and explicit names keep their computer", async (t) => {
const directory = await mkdtemp(join(tmpdir(), "openmuse-deployment-legacy-"));
t.after(() => rm(directory, { recursive: true, force: true }));
await writeFile(join(directory, "session-signing-key"), Buffer.alloc(32, 7).toString("base64"));
const legacy = config(directory);
const before = computerIdentity(legacy, "local-user");
await resolveComputerDeployment(legacy);
assert.equal(legacy.computerDeploymentId, undefined);
assert.deepEqual(computerIdentity(legacy, "local-user"), before);
const named = { ...config(directory), computerDeploymentId: "fixture-deployment" };
await resolveComputerDeployment(named);
assert.equal(named.computerDeploymentId, "fixture-deployment");
assert.deepEqual(await readdir(directory), ["session-signing-key"]);
});
3 changes: 3 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ test("computer provider defaults to Docker and e2b-desktop requires a server-sid
process.env.CPK_INTELLIGENCE_API_KEY = "test-project-key-never-sent";
for (const key of keys.slice(0, 5)) delete process.env[key];
assert.equal(readConfig().computerProvider, "docker");
process.env.COMPUTER_DEPLOYMENT_ID = " ";
assert.equal(readConfig().computerDeploymentId, undefined);
delete process.env.COMPUTER_DEPLOYMENT_ID;
process.env.COMPUTER_PROVIDER = "k8s";
assert.throws(() => readConfig(), /COMPUTER_PROVIDER/);
process.env.COMPUTER_PROVIDER = "e2b-desktop";
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers/computer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const config: Config = {
googleRedirectUri: "http://localhost/callback",
allowedOrigins: [],
computerEnabled: true,
// Fixtures copy this config into fresh data directories, so name the deployment explicitly.
computerDeploymentId: "fixture-deployment",
};
export const ok = (stdout = ""): DockerResult => ({
stdout,
Expand Down
1 change: 1 addition & 0 deletions tests/model-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ test("CopilotKit model worker executes server tools and persists the confirmed o
googleRedirectUri: "http://localhost:8787/api/google/callback",
allowedOrigins: [],
computerEnabled: true,
computerDeploymentId: "fixture-deployment",
},
{ docker: computerFixture().runner },
);
Expand Down
Loading