local | remote",
" --openwork-server-bin Path to openwork-server binary (requires --allow-external)",
" --owpenbot-bin Path to owpenbot binary (requires --allow-external)",
" --owpenbot-health-port Health server port for owpenbot (default: 3005)",
@@ -1462,10 +1465,15 @@ async function startOpenworkServer(options: {
readOnly: boolean;
corsOrigins: string[];
opencodeBaseUrl?: string;
+ opencodeConnectUrl?: string;
opencodeDirectory?: string;
opencodeUsername?: string;
opencodePassword?: string;
owpenbotHealthPort?: number;
+ targetId?: string;
+ targetLabel?: string;
+ targetType?: "local" | "remote";
+ connectUrl?: string;
}) {
const args = [
"--host",
@@ -1495,6 +1503,9 @@ async function startOpenworkServer(options: {
if (options.opencodeBaseUrl) {
args.push("--opencode-base-url", options.opencodeBaseUrl);
}
+ if (options.opencodeConnectUrl) {
+ args.push("--opencode-connect-url", options.opencodeConnectUrl);
+ }
if (options.opencodeDirectory) {
args.push("--opencode-directory", options.opencodeDirectory);
}
@@ -1504,6 +1515,18 @@ async function startOpenworkServer(options: {
if (options.opencodePassword) {
args.push("--opencode-password", options.opencodePassword);
}
+ if (options.targetId) {
+ args.push("--target-id", options.targetId);
+ }
+ if (options.targetLabel) {
+ args.push("--target-label", options.targetLabel);
+ }
+ if (options.targetType) {
+ args.push("--target-type", options.targetType);
+ }
+ if (options.connectUrl) {
+ args.push("--connect-url", options.connectUrl);
+ }
const resolved = resolveBinCommand(options.bin);
const child = spawn(resolved.command, [...resolved.prefixArgs, ...args], {
@@ -1515,9 +1538,14 @@ async function startOpenworkServer(options: {
OPENWORK_HOST_TOKEN: options.hostToken,
...(options.owpenbotHealthPort ? { OWPENBOT_HEALTH_PORT: String(options.owpenbotHealthPort) } : {}),
...(options.opencodeBaseUrl ? { OPENWORK_OPENCODE_BASE_URL: options.opencodeBaseUrl } : {}),
+ ...(options.opencodeConnectUrl ? { OPENWORK_OPENCODE_CONNECT_URL: options.opencodeConnectUrl } : {}),
...(options.opencodeDirectory ? { OPENWORK_OPENCODE_DIRECTORY: options.opencodeDirectory } : {}),
...(options.opencodeUsername ? { OPENWORK_OPENCODE_USERNAME: options.opencodeUsername } : {}),
...(options.opencodePassword ? { OPENWORK_OPENCODE_PASSWORD: options.opencodePassword } : {}),
+ ...(options.targetId ? { OPENWORK_TARGET_ID: options.targetId } : {}),
+ ...(options.targetLabel ? { OPENWORK_TARGET_LABEL: options.targetLabel } : {}),
+ ...(options.targetType ? { OPENWORK_TARGET_TYPE: options.targetType } : {}),
+ ...(options.connectUrl ? { OPENWORK_CONNECT_URL: options.connectUrl } : {}),
},
});
@@ -2527,6 +2555,20 @@ async function runStart(args: ParsedArgs) {
const corsValue = readFlag(args.flags, "cors") ?? process.env.OPENWORK_CORS_ORIGINS ?? "*";
const corsOrigins = parseList(corsValue);
const connectHost = readFlag(args.flags, "connect-host");
+ const targetTypeRaw = readFlag(args.flags, "target-type") ?? process.env.OPENWORK_TARGET_TYPE;
+ const targetType = targetTypeRaw === "remote" ? "remote" : "local";
+ const targetIdRaw = readFlag(args.flags, "target-id") ?? process.env.OPENWORK_TARGET_ID;
+ const targetLabelRaw = readFlag(args.flags, "target-label") ?? process.env.OPENWORK_TARGET_LABEL;
+ const targetId = typeof targetIdRaw === "string" && targetIdRaw.trim().length
+ ? targetIdRaw.trim()
+ : targetType === "remote"
+ ? "tgt-remote"
+ : "tgt-local";
+ const targetLabel = typeof targetLabelRaw === "string" && targetLabelRaw.trim().length
+ ? targetLabelRaw.trim()
+ : targetType === "remote"
+ ? "Remote target"
+ : "Local (this device)";
const cliVersion = await resolveCliVersion();
const sidecar = resolveSidecarConfig(args.flags, cliVersion);
@@ -2641,11 +2683,16 @@ async function runStart(args: ParsedArgs) {
approvalTimeoutMs,
readOnly,
corsOrigins: corsOrigins.length ? corsOrigins : ["*"],
- opencodeBaseUrl: opencodeConnectUrl,
+ opencodeBaseUrl,
+ opencodeConnectUrl: opencodeConnectUrl,
opencodeDirectory: resolvedWorkspace,
opencodeUsername,
opencodePassword,
owpenbotHealthPort,
+ targetId,
+ targetLabel,
+ targetType,
+ connectUrl: openworkConnectUrl,
});
children.push({ name: "openwork-server", child: openworkChild });
openworkChild.on("exit", (code, signal) => handleExit("openwork-server", code, signal));
diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts
index a839ee94..ec3a038c 100644
--- a/packages/server/src/cli.ts
+++ b/packages/server/src/cli.ts
@@ -17,7 +17,7 @@ if (args.version) {
}
const config = await resolveServerConfig(args);
-const server = startServer(config);
+const server = await startServer(config);
const url = `http://${config.host}:${server.port}`;
console.log(`OpenWork server listening on ${url}`);
diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts
index e418e1eb..1dfb12df 100644
--- a/packages/server/src/config.ts
+++ b/packages/server/src/config.ts
@@ -13,12 +13,17 @@ interface CliArgs {
approvalMode?: ApprovalMode;
approvalTimeoutMs?: number;
opencodeBaseUrl?: string;
+ opencodeConnectUrl?: string;
opencodeDirectory?: string;
opencodeUsername?: string;
opencodePassword?: string;
workspaces: string[];
corsOrigins?: string[];
readOnly?: boolean;
+ targetId?: string;
+ targetLabel?: string;
+ targetType?: "local" | "remote";
+ connectUrl?: string;
verbose?: boolean;
version?: boolean;
help?: boolean;
@@ -36,6 +41,13 @@ interface FileConfig {
readOnly?: boolean;
opencodeUsername?: string;
opencodePassword?: string;
+ target?: {
+ id?: string;
+ label?: string;
+ type?: "local" | "remote";
+ };
+ connectUrl?: string;
+ opencodeConnectUrl?: string;
}
const DEFAULT_PORT = 8787;
@@ -102,6 +114,11 @@ export function parseCliArgs(argv: string[]): CliArgs {
index += 1;
continue;
}
+ if (value === "--opencode-connect-url") {
+ args.opencodeConnectUrl = argv[index + 1];
+ index += 1;
+ continue;
+ }
if (value === "--opencode-directory") {
args.opencodeDirectory = argv[index + 1];
index += 1;
@@ -128,6 +145,29 @@ export function parseCliArgs(argv: string[]): CliArgs {
index += 1;
continue;
}
+ if (value === "--target-id") {
+ args.targetId = argv[index + 1];
+ index += 1;
+ continue;
+ }
+ if (value === "--target-label") {
+ args.targetLabel = argv[index + 1];
+ index += 1;
+ continue;
+ }
+ if (value === "--target-type") {
+ const next = argv[index + 1] as "local" | "remote" | undefined;
+ if (next === "local" || next === "remote") {
+ args.targetType = next;
+ }
+ index += 1;
+ continue;
+ }
+ if (value === "--connect-url") {
+ args.connectUrl = argv[index + 1];
+ index += 1;
+ continue;
+ }
if (value === "--read-only") {
args.readOnly = true;
continue;
@@ -149,11 +189,16 @@ export function printHelp(): void {
" --approval manual | auto",
" --approval-timeout Approval timeout",
" --opencode-base-url OpenCode base URL to share",
+ " --opencode-connect-url OpenCode connect URL to share",
" --opencode-directory OpenCode workspace directory to share",
" --opencode-username OpenCode server username",
" --opencode-password OpenCode server password",
" --workspace Workspace root (repeatable)",
" --cors Comma-separated origins or *",
+ " --target-id Target id for descriptor",
+ " --target-label Target label for descriptor",
+ " --target-type local | remote",
+ " --connect-url OpenWork connect URL",
" --read-only Disable writes",
" --verbose Print resolved config",
" --version Show version",
@@ -181,10 +226,12 @@ export async function resolveServerConfig(cli: CliArgs): Promise {
: fileConfig.workspaces ?? [];
const envOpencodeBaseUrl = process.env.OPENWORK_OPENCODE_BASE_URL;
+ const envOpencodeConnectUrl = process.env.OPENWORK_OPENCODE_CONNECT_URL;
const envOpencodeDirectory = process.env.OPENWORK_OPENCODE_DIRECTORY;
const envOpencodeUsername = process.env.OPENWORK_OPENCODE_USERNAME;
const envOpencodePassword = process.env.OPENWORK_OPENCODE_PASSWORD;
const opencodeBaseUrl = cli.opencodeBaseUrl ?? envOpencodeBaseUrl;
+ const opencodeConnectUrl = cli.opencodeConnectUrl ?? envOpencodeConnectUrl ?? fileConfig.opencodeConnectUrl;
const opencodeDirectory = cli.opencodeDirectory ?? envOpencodeDirectory;
const opencodeUsername = cli.opencodeUsername ?? envOpencodeUsername ?? fileConfig.opencodeUsername;
const opencodePassword = cli.opencodePassword ?? envOpencodePassword ?? fileConfig.opencodePassword;
@@ -263,6 +310,18 @@ export async function resolveServerConfig(cli: CliArgs): Promise {
const host = cli.host ?? process.env.OPENWORK_HOST ?? fileConfig.host ?? DEFAULT_HOST;
const port = cli.port ?? (process.env.OPENWORK_PORT ? Number(process.env.OPENWORK_PORT) : undefined) ?? fileConfig.port ?? DEFAULT_PORT;
+ const envTargetId = process.env.OPENWORK_TARGET_ID;
+ const envTargetLabel = process.env.OPENWORK_TARGET_LABEL;
+ const envTargetType = process.env.OPENWORK_TARGET_TYPE as "local" | "remote" | undefined;
+ const targetType = cli.targetType ?? envTargetType ?? fileConfig.target?.type ?? "local";
+ const defaultTargetLabel = targetType === "remote" ? "Remote target" : "Local (this device)";
+ const target = {
+ id: cli.targetId ?? envTargetId ?? fileConfig.target?.id ?? (targetType === "remote" ? "tgt-remote" : "tgt-local"),
+ label: cli.targetLabel ?? envTargetLabel ?? fileConfig.target?.label ?? defaultTargetLabel,
+ type: targetType,
+ };
+ const connectUrl = cli.connectUrl ?? process.env.OPENWORK_CONNECT_URL ?? fileConfig.connectUrl;
+
return {
host,
port: Number.isNaN(port) ? DEFAULT_PORT : port,
@@ -277,5 +336,8 @@ export async function resolveServerConfig(cli: CliArgs): Promise {
startedAt: Date.now(),
tokenSource,
hostTokenSource,
+ target,
+ connectUrl: connectUrl?.trim() || undefined,
+ opencodeConnectUrl: opencodeConnectUrl?.trim() || undefined,
};
}
diff --git a/packages/server/src/sandboxes.ts b/packages/server/src/sandboxes.ts
new file mode 100644
index 00000000..2c726138
--- /dev/null
+++ b/packages/server/src/sandboxes.ts
@@ -0,0 +1,371 @@
+import { spawn } from "node:child_process";
+import { existsSync } from "node:fs";
+import { lstat, readdir, readFile, rm, writeFile } from "node:fs/promises";
+import { dirname, join, relative, resolve, sep } from "node:path";
+import { minimatch } from "minimatch";
+import type { SandboxInfo, SandboxStatus, TargetInfo, WorkspaceInfo } from "./types.js";
+import { ensureDir, exists, shortId } from "./utils.js";
+
+type SandboxState = {
+ version: number;
+ activeId: string;
+ sandboxes: SandboxInfo[];
+};
+
+export type SandboxStore = {
+ baseWorkspace: WorkspaceInfo;
+ target: TargetInfo;
+ root: string;
+ statePath: string;
+ state: SandboxState;
+};
+
+type SandboxCreateMode = "base" | "sandbox";
+
+type SandboxCreateOptions = {
+ name?: string | null;
+ source: { type: SandboxCreateMode; id?: string | null };
+};
+
+const DEFAULT_SANDBOX_NAME = "Sandbox";
+const SANDBOX_STATE_VERSION = 1;
+const DEFAULT_IGNORE_PATTERNS = [
+ ".git",
+ ".git/**",
+ ".openwork/sandboxes",
+ ".openwork/sandboxes/**",
+ "node_modules",
+ "node_modules/**",
+ ".DS_Store",
+];
+
+const toPosix = (value: string) => value.replace(/\\/g, "/");
+
+const normalizePattern = (raw: string) => {
+ const trimmed = raw.trim();
+ if (!trimmed || trimmed.startsWith("#")) return null;
+ const negated = trimmed.startsWith("!");
+ const body = (negated ? trimmed.slice(1) : trimmed).trim();
+ if (!body) return null;
+ let normalized = toPosix(body);
+ if (normalized.startsWith("/")) normalized = normalized.slice(1);
+ if (normalized.endsWith("/")) normalized = `${normalized}**`;
+ return negated ? `!${normalized}` : normalized;
+};
+
+const parseIgnoreFile = async (path: string) => {
+ try {
+ const raw = await readFile(path, "utf8");
+ return raw
+ .split(/\r?\n/)
+ .map(normalizePattern)
+ .filter((entry): entry is string => Boolean(entry));
+ } catch {
+ return [];
+ }
+};
+
+const buildIgnoreMatcher = async (root: string) => {
+ const sandboxIgnore = join(root, ".openwork", "sandbox-ignore");
+ const gitIgnore = join(root, ".gitignore");
+ const patterns = (await parseIgnoreFile(sandboxIgnore)).length
+ ? await parseIgnoreFile(sandboxIgnore)
+ : await parseIgnoreFile(gitIgnore);
+ const merged = [...DEFAULT_IGNORE_PATTERNS, ...patterns];
+
+ return (relPath: string) => {
+ const normalized = toPosix(relPath);
+ let ignored = false;
+ for (const pattern of merged) {
+ const negated = pattern.startsWith("!");
+ const body = negated ? pattern.slice(1) : pattern;
+ if (!body) continue;
+ if (minimatch(normalized, body, { dot: true, matchBase: true })) {
+ ignored = !negated;
+ }
+ }
+ return ignored;
+ };
+};
+
+const ensureSandboxRoot = async (baseWorkspacePath: string) => {
+ const root = join(baseWorkspacePath, ".openwork", "sandboxes");
+ await ensureDir(root);
+ return root;
+};
+
+const sandboxStatePath = (root: string) => join(root, "sandboxes.json");
+
+const nowMs = () => Date.now();
+
+const ensureBaseSandbox = (state: SandboxState, baseWorkspace: WorkspaceInfo, target: TargetInfo) => {
+ const existing = state.sandboxes.find((entry) => entry.id === baseWorkspace.id);
+ if (existing) return;
+ const createdAt = nowMs();
+ state.sandboxes.push({
+ id: baseWorkspace.id,
+ name: baseWorkspace.name || "Default",
+ targetId: target.id,
+ baseWorkspaceId: baseWorkspace.id,
+ path: baseWorkspace.path,
+ createdAt,
+ updatedAt: createdAt,
+ status: "active",
+ });
+ if (!state.activeId) state.activeId = baseWorkspace.id;
+};
+
+export const deriveSandboxStatus = (sandbox: SandboxInfo, activeId: string): SandboxStatus => {
+ if (sandbox.status === "archived") return "archived";
+ if (sandbox.id === activeId) return "active";
+ return "idle";
+};
+
+const runCommand = async (command: string, args: string[], cwd: string) => {
+ return new Promise<{ ok: boolean; stdout: string; stderr: string }>((resolve) => {
+ const child = spawn(command, args, { cwd });
+ let stdout = "";
+ let stderr = "";
+ child.stdout?.on("data", (chunk) => {
+ stdout += String(chunk ?? "");
+ });
+ child.stderr?.on("data", (chunk) => {
+ stderr += String(chunk ?? "");
+ });
+ child.on("error", (error) => {
+ stderr += String(error ?? "");
+ resolve({ ok: false, stdout, stderr });
+ });
+ child.on("exit", (code) => {
+ resolve({ ok: code === 0, stdout, stderr });
+ });
+ });
+};
+
+const resolveGitRoot = async (root: string) => {
+ const result = await runCommand("git", ["rev-parse", "--show-toplevel"], root);
+ if (!result.ok) return null;
+ const trimmed = result.stdout.trim();
+ return trimmed ? resolve(trimmed) : null;
+};
+
+const isGitClean = async (root: string) => {
+ const result = await runCommand("git", ["status", "--porcelain"], root);
+ if (!result.ok) return false;
+ return result.stdout.trim().length === 0;
+};
+
+const attemptWorktree = async (root: string, dest: string) => {
+ const result = await runCommand("git", ["worktree", "add", "--detach", dest, "HEAD"], root);
+ return result.ok;
+};
+
+const attemptClone = async (root: string, dest: string) => {
+ const result = await runCommand("git", ["clone", "--depth", "1", root, dest], root);
+ return result.ok;
+};
+
+const copyWorkspace = async (
+ sourceRoot: string,
+ source: string,
+ dest: string,
+ ignore: (rel: string) => boolean,
+) => {
+ await ensureDir(dest);
+ const entries = await readdir(source, { withFileTypes: true });
+ for (const entry of entries) {
+ const srcPath = join(source, entry.name);
+ const relPath = toPosix(relative(sourceRoot, srcPath));
+ if (ignore(relPath)) continue;
+ const destPath = join(dest, entry.name);
+ if (entry.isDirectory()) {
+ await copyWorkspace(sourceRoot, srcPath, destPath, ignore);
+ continue;
+ }
+ if (entry.isSymbolicLink()) {
+ continue;
+ }
+ if (entry.isFile()) {
+ await ensureDir(dirname(destPath));
+ await Bun.write(destPath, Bun.file(srcPath));
+ }
+ }
+};
+
+const calculateDirectorySize = async (root: string, current: string, ignore: (rel: string) => boolean) => {
+ let total = 0;
+ const entries = await readdir(current, { withFileTypes: true });
+ for (const entry of entries) {
+ const entryPath = join(current, entry.name);
+ const relPath = toPosix(relative(root, entryPath));
+ if (ignore(relPath)) continue;
+ if (entry.isDirectory()) {
+ total += await calculateDirectorySize(root, entryPath, ignore);
+ continue;
+ }
+ if (entry.isFile()) {
+ const stats = await lstat(entryPath);
+ total += stats.size;
+ }
+ }
+ return total;
+};
+
+const persistState = async (store: SandboxStore) => {
+ await ensureDir(dirname(store.statePath));
+ await writeFile(store.statePath, JSON.stringify(store.state, null, 2) + "\n", "utf8");
+};
+
+export const loadSandboxStore = async (baseWorkspace: WorkspaceInfo, target: TargetInfo): Promise => {
+ const root = await ensureSandboxRoot(baseWorkspace.path);
+ const statePath = sandboxStatePath(root);
+ const existing = (await exists(statePath)) ? await readFile(statePath, "utf8").then((raw) => JSON.parse(raw) as SandboxState).catch(() => null) : null;
+ const state: SandboxState = existing && existing.version === SANDBOX_STATE_VERSION
+ ? existing
+ : {
+ version: SANDBOX_STATE_VERSION,
+ activeId: baseWorkspace.id,
+ sandboxes: [],
+ };
+ ensureBaseSandbox(state, baseWorkspace, target);
+ if (!state.activeId) state.activeId = baseWorkspace.id;
+ const store = { baseWorkspace, target, root, statePath, state };
+ await persistState(store);
+ return store;
+};
+
+export const listSandboxes = (store: SandboxStore) => {
+ const activeId = store.state.activeId;
+ return store.state.sandboxes.map((sandbox) => ({
+ ...sandbox,
+ status: deriveSandboxStatus(sandbox, activeId),
+ }));
+};
+
+export const getSandbox = (store: SandboxStore, id: string) =>
+ store.state.sandboxes.find((sandbox) => sandbox.id === id) ?? null;
+
+export const setActiveSandbox = async (store: SandboxStore, id: string) => {
+ const sandbox = getSandbox(store, id);
+ if (!sandbox) throw new Error("Sandbox not found");
+ if (sandbox.status === "archived") throw new Error("Sandbox is archived");
+ store.state.activeId = sandbox.id;
+ sandbox.updatedAt = nowMs();
+ await persistState(store);
+ return sandbox;
+};
+
+export const archiveSandbox = async (store: SandboxStore, id: string) => {
+ const sandbox = getSandbox(store, id);
+ if (!sandbox) throw new Error("Sandbox not found");
+ if (sandbox.id === store.baseWorkspace.id) {
+ throw new Error("Base sandbox cannot be archived");
+ }
+ sandbox.status = "archived";
+ sandbox.updatedAt = nowMs();
+ await persistState(store);
+ return sandbox;
+};
+
+export const deleteSandbox = async (store: SandboxStore, id: string) => {
+ const sandbox = getSandbox(store, id);
+ if (!sandbox) throw new Error("Sandbox not found");
+ if (sandbox.id === store.baseWorkspace.id) {
+ throw new Error("Base sandbox cannot be deleted");
+ }
+ if (sandbox.path && sandbox.path.startsWith(store.root)) {
+ if (existsSync(sandbox.path)) {
+ await rm(sandbox.path, { recursive: true, force: true });
+ }
+ }
+ store.state.sandboxes = store.state.sandboxes.filter((entry) => entry.id !== sandbox.id);
+ if (store.state.activeId === sandbox.id) {
+ store.state.activeId = store.baseWorkspace.id;
+ }
+ await persistState(store);
+ return sandbox;
+};
+
+export const createSandbox = async (store: SandboxStore, options: SandboxCreateOptions) => {
+ const nameInput = options.name?.trim();
+ const name = nameInput && nameInput.length ? nameInput : `${DEFAULT_SANDBOX_NAME} ${store.state.sandboxes.length + 1}`;
+ const id = `sbx_${shortId()}`;
+ const targetPath = join(store.root, id);
+ const sourceType = options.source.type;
+ const sourceSandbox =
+ sourceType === "sandbox" && options.source.id
+ ? getSandbox(store, options.source.id)
+ : null;
+ const sourcePath = sourceSandbox?.path ?? store.baseWorkspace.path;
+ const ignore = await buildIgnoreMatcher(sourcePath);
+
+ if (sourceType === "sandbox") {
+ await copyWorkspace(sourcePath, sourcePath, targetPath, ignore);
+ } else {
+ const gitRoot = await resolveGitRoot(sourcePath);
+ const isRoot = gitRoot && resolve(gitRoot) === resolve(sourcePath);
+ if (gitRoot && isRoot && (await isGitClean(sourcePath))) {
+ const ok = await attemptWorktree(sourcePath, targetPath);
+ if (!ok) {
+ const cloneOk = await attemptClone(sourcePath, targetPath);
+ if (!cloneOk) {
+ await copyWorkspace(sourcePath, sourcePath, targetPath, ignore);
+ }
+ }
+ } else if (gitRoot && isRoot) {
+ const cloneOk = await attemptClone(sourcePath, targetPath);
+ if (!cloneOk) {
+ await copyWorkspace(sourcePath, sourcePath, targetPath, ignore);
+ }
+ } else {
+ await copyWorkspace(sourcePath, sourcePath, targetPath, ignore);
+ }
+ }
+
+ const createdAt = nowMs();
+ const record: SandboxInfo = {
+ id,
+ name,
+ targetId: store.target.id,
+ baseWorkspaceId: store.baseWorkspace.id,
+ path: targetPath,
+ createdAt,
+ updatedAt: createdAt,
+ status: "active",
+ };
+ store.state.sandboxes.push(record);
+ store.state.activeId = record.id;
+ await persistState(store);
+ return record;
+};
+
+export const readSandboxSize = async (store: SandboxStore, sandbox: SandboxInfo) => {
+ const ignore = await buildIgnoreMatcher(sandbox.path);
+ return calculateDirectorySize(sandbox.path, sandbox.path, ignore);
+};
+
+export const syncSandboxStatuses = (store: SandboxStore) => {
+ const activeId = store.state.activeId;
+ store.state.sandboxes = store.state.sandboxes.map((sandbox) => ({
+ ...sandbox,
+ status: deriveSandboxStatus(sandbox, activeId),
+ }));
+};
+
+export const ensureSandboxRootsAuthorized = (roots: string[], sandboxPaths: string[]) => {
+ const next = new Set(roots.map((root) => resolve(root)));
+ for (const path of sandboxPaths) {
+ next.add(resolve(path));
+ }
+ return Array.from(next);
+};
+
+export const resolveSandboxByPath = (store: SandboxStore, path: string) => {
+ const normalized = resolve(path);
+ return store.state.sandboxes.find((sandbox) => resolve(sandbox.path) === normalized) ?? null;
+};
+
+export const isSandboxPath = (store: SandboxStore, path: string) => {
+ const resolved = resolve(path);
+ return resolved === resolve(store.root) || resolved.startsWith(resolve(store.root) + sep);
+};
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index ab47db70..884eda45 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -1,7 +1,16 @@
import { readFile, writeFile, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve, sep } from "node:path";
-import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor, ReloadReason, ReloadTrigger } from "./types.js";
+import type {
+ ApprovalRequest,
+ Capabilities,
+ ServerConfig,
+ WorkspaceInfo,
+ Actor,
+ ReloadReason,
+ ReloadTrigger,
+ SandboxInfo,
+} from "./types.js";
import { ApprovalService } from "./approvals.js";
import { addPlugin, listPlugins, normalizePluginSpec, removePlugin } from "./plugins.js";
import { addMcp, listMcp, removeMcp } from "./mcp.js";
@@ -16,6 +25,20 @@ import { parseFrontmatter } from "./frontmatter.js";
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
import { sanitizeCommandName } from "./validators.js";
+import {
+ archiveSandbox,
+ createSandbox,
+ deleteSandbox,
+ deriveSandboxStatus,
+ ensureSandboxRootsAuthorized,
+ getSandbox,
+ listSandboxes,
+ loadSandboxStore,
+ readSandboxSize,
+ setActiveSandbox,
+ syncSandboxStatuses,
+ type SandboxStore,
+} from "./sandboxes.js";
import pkg from "../package.json" with { type: "json" };
const SERVER_VERSION = pkg.version;
@@ -38,12 +61,21 @@ interface RequestContext {
approvals: ApprovalService;
reloadEvents: ReloadEventStore;
actor?: Actor;
+ sandboxes?: SandboxStore | null;
}
-export function startServer(config: ServerConfig) {
+export async function startServer(config: ServerConfig) {
const approvals = new ApprovalService(config.approval);
const reloadEvents = new ReloadEventStore();
- const routes = createRoutes(config, approvals);
+ const baseWorkspace = config.workspaces[0] ?? null;
+ const sandboxStore = baseWorkspace
+ ? await loadSandboxStore(baseWorkspace, config.target)
+ : null;
+ if (sandboxStore) {
+ syncSandboxStatuses(sandboxStore);
+ applySandboxState(config, sandboxStore);
+ }
+ const routes = createRoutes(config, approvals, sandboxStore);
const serverOptions: {
hostname: string;
@@ -86,6 +118,7 @@ export function startServer(config: ServerConfig) {
approvals,
reloadEvents,
actor,
+ sandboxes: sandboxStore,
});
return withCors(response, request, config);
} catch (error) {
@@ -284,7 +317,7 @@ function serializeWorkspace(workspace: ServerConfig["workspaces"][number]) {
};
}
-function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[] {
+function createRoutes(config: ServerConfig, approvals: ApprovalService, sandboxStore: SandboxStore | null): Route[] {
const routes: Route[] = [];
addRoute(routes, "GET", "/health", "none", async () => {
@@ -293,6 +326,7 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[]
addRoute(routes, "GET", "/status", "client", async () => {
const active = config.workspaces[0];
+ const activeSandbox = sandboxStore ? getSandbox(sandboxStore, sandboxStore.state.activeId) : null;
return jsonResponse({
ok: true,
version: SERVER_VERSION,
@@ -303,6 +337,16 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[]
workspaceCount: config.workspaces.length,
activeWorkspaceId: active?.id ?? null,
workspace: active ? serializeWorkspace(active) : null,
+ sandboxCount: sandboxStore ? sandboxStore.state.sandboxes.length : 0,
+ activeSandboxId: activeSandbox?.id ?? null,
+ sandbox: activeSandbox
+ ? {
+ id: activeSandbox.id,
+ name: activeSandbox.name,
+ path: activeSandbox.path,
+ status: activeSandbox.status,
+ }
+ : null,
authorizedRoots: config.authorizedRoots,
server: {
host: config.host,
@@ -320,6 +364,153 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[]
return jsonResponse(buildCapabilities(config));
});
+ addRoute(routes, "GET", "/connect/active", "client", async () => {
+ const activeSandbox = sandboxStore ? getSandbox(sandboxStore, sandboxStore.state.activeId) : null;
+ const activeWorkspace = activeSandbox
+ ? ({
+ id: activeSandbox.id,
+ name: activeSandbox.name,
+ path: activeSandbox.path,
+ workspaceType: "local",
+ baseUrl: config.workspaces[0]?.baseUrl,
+ directory: activeSandbox.path,
+ opencodeUsername: config.workspaces[0]?.opencodeUsername,
+ opencodePassword: config.workspaces[0]?.opencodePassword,
+ } as WorkspaceInfo)
+ : config.workspaces[0];
+ const opencodeBaseUrl = activeWorkspace?.baseUrl?.trim() ?? "";
+ const opencodeDirectory = activeWorkspace ? resolveOpencodeDirectory(activeWorkspace) : null;
+ const opencodePort = opencodeBaseUrl ? parseUrlPort(opencodeBaseUrl) : null;
+ const openworkBaseUrl = `http://${config.host}:${config.port}`;
+ const opencodeProxyUrl = `${openworkBaseUrl.replace(/\/$/, "")}/opencode`;
+ const opencodeConnectUrl = config.opencodeConnectUrl ?? opencodeProxyUrl ?? opencodeBaseUrl;
+ const owpenbotPort = resolveOwpenbotHealthPort();
+ return jsonResponse({
+ updatedAt: Date.now(),
+ sandbox: activeSandbox
+ ? {
+ id: activeSandbox.id,
+ name: activeSandbox.name,
+ path: activeSandbox.path,
+ status: deriveSandboxStatus(activeSandbox, sandboxStore?.state.activeId),
+ }
+ : null,
+ target: config.target,
+ opencode: {
+ baseUrl: opencodeBaseUrl || undefined,
+ connectUrl: opencodeConnectUrl || undefined,
+ directory: opencodeDirectory ?? undefined,
+ username: activeWorkspace?.opencodeUsername ?? undefined,
+ password: activeWorkspace?.opencodePassword ?? undefined,
+ port: opencodePort ?? undefined,
+ },
+ openwork: {
+ baseUrl: openworkBaseUrl,
+ connectUrl: config.connectUrl ?? openworkBaseUrl,
+ token: config.token,
+ hostToken: config.hostToken,
+ port: config.port,
+ },
+ owpenbot: {
+ healthUrl: `http://127.0.0.1:${owpenbotPort}`,
+ healthPort: owpenbotPort,
+ },
+ });
+ });
+
+ addRoute(routes, "GET", "/sandboxes", "client", async () => {
+ if (!sandboxStore) {
+ throw new ApiError(400, "sandbox_unavailable", "Sandboxes unavailable without a base workspace");
+ }
+ const items = listSandboxes(sandboxStore);
+ return jsonResponse({
+ activeId: sandboxStore.state.activeId,
+ items,
+ });
+ });
+
+ addRoute(routes, "POST", "/sandboxes", "client", async (ctx) => {
+ ensureWritable(config);
+ if (!sandboxStore) {
+ throw new ApiError(400, "sandbox_unavailable", "Sandboxes unavailable without a base workspace");
+ }
+ const body = await readJsonBody(ctx.request);
+ const name = typeof body.name === "string" ? body.name.trim() : null;
+ const source = typeof body.source === "string" ? body.source : "base";
+ const fromSandboxId = typeof body.fromSandboxId === "string" ? body.fromSandboxId : null;
+ const mode = source === "sandbox" ? "sandbox" : "base";
+ const sandbox = await createSandbox(sandboxStore, {
+ name,
+ source: { type: mode, id: fromSandboxId ?? sandboxStore.state.activeId },
+ });
+ syncSandboxStatuses(sandboxStore);
+ applySandboxState(config, sandboxStore);
+ return jsonResponse({ activeId: sandboxStore.state.activeId, sandbox });
+ });
+
+ addRoute(routes, "GET", "/sandboxes/:id", "client", async (ctx) => {
+ if (!sandboxStore) {
+ throw new ApiError(400, "sandbox_unavailable", "Sandboxes unavailable without a base workspace");
+ }
+ const sandbox = getSandbox(sandboxStore, ctx.params.id);
+ if (!sandbox) {
+ throw new ApiError(404, "sandbox_not_found", "Sandbox not found");
+ }
+ const sizeBytes = await readSandboxSize(sandboxStore, sandbox);
+ return jsonResponse({
+ sandbox: {
+ ...sandbox,
+ status: deriveSandboxStatus(sandbox, sandboxStore.state.activeId),
+ sizeBytes,
+ },
+ });
+ });
+
+ addRoute(routes, "POST", "/sandboxes/:id/archive", "client", async (ctx) => {
+ ensureWritable(config);
+ if (!sandboxStore) {
+ throw new ApiError(400, "sandbox_unavailable", "Sandboxes unavailable without a base workspace");
+ }
+ try {
+ const sandbox = await archiveSandbox(sandboxStore, ctx.params.id);
+ syncSandboxStatuses(sandboxStore);
+ applySandboxState(config, sandboxStore);
+ return jsonResponse({ sandbox });
+ } catch (error) {
+ throw new ApiError(400, "sandbox_archive_failed", error instanceof Error ? error.message : "Archive failed");
+ }
+ });
+
+ addRoute(routes, "POST", "/sandboxes/:id/delete", "client", async (ctx) => {
+ ensureWritable(config);
+ if (!sandboxStore) {
+ throw new ApiError(400, "sandbox_unavailable", "Sandboxes unavailable without a base workspace");
+ }
+ try {
+ const sandbox = await deleteSandbox(sandboxStore, ctx.params.id);
+ syncSandboxStatuses(sandboxStore);
+ applySandboxState(config, sandboxStore);
+ return jsonResponse({ deleted: true, sandbox });
+ } catch (error) {
+ throw new ApiError(400, "sandbox_delete_failed", error instanceof Error ? error.message : "Delete failed");
+ }
+ });
+
+ addRoute(routes, "POST", "/sandboxes/:id/activate", "client", async (ctx) => {
+ ensureWritable(config);
+ if (!sandboxStore) {
+ throw new ApiError(400, "sandbox_unavailable", "Sandboxes unavailable without a base workspace");
+ }
+ try {
+ const sandbox = await setActiveSandbox(sandboxStore, ctx.params.id);
+ syncSandboxStatuses(sandboxStore);
+ applySandboxState(config, sandboxStore);
+ return jsonResponse({ activeId: sandboxStore.state.activeId, sandbox });
+ } catch (error) {
+ throw new ApiError(400, "sandbox_activate_failed", error instanceof Error ? error.message : "Activate failed");
+ }
+ });
+
addRoute(routes, "GET", "/workspaces", "client", async () => {
const active = config.workspaces[0];
const items = active ? [serializeWorkspace(active)] : [];
@@ -799,6 +990,55 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[]
return routes;
}
+function applySandboxState(config: ServerConfig, store: SandboxStore) {
+ const baseWorkspace = config.workspaces.find((entry) => entry.id === store.baseWorkspace.id) ?? store.baseWorkspace;
+ const sandboxEntries: WorkspaceInfo[] = store.state.sandboxes.map((sandbox) => ({
+ id: sandbox.id,
+ name: sandbox.name,
+ path: sandbox.path,
+ workspaceType: baseWorkspace.workspaceType,
+ baseUrl: baseWorkspace.baseUrl,
+ directory: sandbox.path,
+ opencodeUsername: baseWorkspace.opencodeUsername,
+ opencodePassword: baseWorkspace.opencodePassword,
+ }));
+ const byId = new Map();
+ for (const entry of config.workspaces) {
+ byId.set(entry.id, entry);
+ }
+ for (const entry of sandboxEntries) {
+ const existing = byId.get(entry.id);
+ byId.set(entry.id, { ...existing, ...entry });
+ }
+ let merged = Array.from(byId.values());
+ const activeId = store.state.activeId;
+ if (activeId) {
+ merged = [
+ ...merged.filter((entry) => entry.id === activeId),
+ ...merged.filter((entry) => entry.id !== activeId),
+ ];
+ }
+ config.workspaces = merged;
+ config.authorizedRoots = ensureSandboxRootsAuthorized(
+ config.authorizedRoots,
+ store.state.sandboxes.map((sandbox) => sandbox.path),
+ );
+}
+
+function parseUrlPort(value: string): number | null {
+ if (!value) return null;
+ try {
+ const url = new URL(value);
+ if (url.port) {
+ const port = Number(url.port);
+ return Number.isFinite(port) ? port : null;
+ }
+ return url.protocol === "https:" ? 443 : 80;
+ } catch {
+ return null;
+ }
+}
+
async function resolveWorkspace(config: ServerConfig, id: string): Promise {
const workspace = config.workspaces.find((entry) => entry.id === id);
if (!workspace) {
diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts
index df791d29..87bc322a 100644
--- a/packages/server/src/types.ts
+++ b/packages/server/src/types.ts
@@ -1,5 +1,9 @@
export type WorkspaceType = "local" | "remote";
+export type TargetType = "local" | "remote";
+
+export type SandboxStatus = "active" | "idle" | "archived";
+
export type ApprovalMode = "manual" | "auto";
export interface WorkspaceConfig {
@@ -29,6 +33,24 @@ export interface WorkspaceInfo {
};
}
+export interface TargetInfo {
+ id: string;
+ label: string;
+ type: TargetType;
+}
+
+export interface SandboxInfo {
+ id: string;
+ name: string;
+ targetId: string;
+ baseWorkspaceId: string;
+ path: string;
+ createdAt: number;
+ updatedAt: number;
+ status: SandboxStatus;
+ sizeBytes?: number;
+}
+
export interface ApprovalConfig {
mode: ApprovalMode;
timeoutMs: number;
@@ -48,6 +70,9 @@ export interface ServerConfig {
startedAt: number;
tokenSource: "cli" | "env" | "file" | "generated";
hostTokenSource: "cli" | "env" | "file" | "generated";
+ target: TargetInfo;
+ connectUrl?: string;
+ opencodeConnectUrl?: string;
}
export interface Capabilities {