Files
openwork/packages/server/src/server.ts
ben 265ed971b5 fix(owpenbot): make token saves fast and resilient (#488)
* fix(owpenbot): make token saves fast and resilient

Persist Telegram/Slack tokens even when owpenbot is offline, bound adapter restarts to avoid long hangs, and refresh UI status with apply warnings.

* chore: update Cargo.lock

Keep desktop Cargo.lock in sync with the crate version so CI cargo --locked passes.
2026-02-06 21:18:45 -08:00

1659 lines
56 KiB
TypeScript

import { readFile, writeFile, rm, rename } from "node:fs/promises";
import { homedir, hostname } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor, ReloadReason, ReloadTrigger } from "./types.js";
import { ApprovalService } from "./approvals.js";
import { addPlugin, listPlugins, normalizePluginSpec, removePlugin } from "./plugins.js";
import { addMcp, listMcp, removeMcp } from "./mcp.js";
import { listSkills, upsertSkill } from "./skills.js";
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
import { deleteScheduledJob, listScheduledJobs, resolveScheduledJob } from "./scheduler.js";
import { ApiError, formatError } from "./errors.js";
import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
import { recordAudit, readAuditEntries, readLastAudit } from "./audit.js";
import { ReloadEventStore } from "./events.js";
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 pkg from "../package.json" with { type: "json" };
const SERVER_VERSION = pkg.version;
type LogLevel = "info" | "warn" | "error";
type LogAttributes = Record<string, unknown>;
type ServerLogger = {
log: (level: LogLevel, message: string, attributes?: LogAttributes) => void;
};
const LOG_LEVEL_NUMBERS: Record<LogLevel, number> = {
info: 9,
warn: 13,
error: 17,
};
function toUnixNano(): string {
return (BigInt(Date.now()) * 1_000_000n).toString();
}
export function createServerLogger(config: ServerConfig): ServerLogger {
const runId = process.env.OPENWRK_RUN_ID ?? process.env.OPENWORK_RUN_ID ?? shortId();
const host = hostname().trim();
const resource: Record<string, string> = {
"service.name": "openwork-server",
"service.version": SERVER_VERSION,
"service.instance.id": runId,
};
if (host) {
resource["host.name"] = host;
}
const baseAttributes: LogAttributes = {
"run.id": runId,
"process.pid": process.pid,
};
const emit = (level: LogLevel, message: string, attributes?: LogAttributes) => {
const merged = { ...baseAttributes, ...(attributes ?? {}) };
if (config.logFormat === "json") {
const record = {
timeUnixNano: toUnixNano(),
severityText: level.toUpperCase(),
severityNumber: LOG_LEVEL_NUMBERS[level],
body: message,
attributes: merged,
resource,
};
process.stdout.write(`${JSON.stringify(record)}\n`);
return;
}
process.stdout.write(`${message}\n`);
};
return { log: emit };
}
function logRequest(input: {
logger: ServerLogger;
request: Request;
response: Response;
durationMs: number;
authMode: AuthMode;
proxyBaseUrl?: string;
error?: string;
}) {
const { logger, request, response, durationMs, authMode, proxyBaseUrl, error } = input;
const status = response.status;
const level: LogLevel = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
const url = new URL(request.url);
const method = request.method.toUpperCase();
const message = `${method} ${url.pathname} ${status} ${durationMs}ms${proxyBaseUrl ? " (opencode)" : ""}`;
const attributes: LogAttributes = {
method,
path: url.pathname,
status,
durationMs,
auth: authMode,
};
if (proxyBaseUrl) {
attributes["opencode.base_url"] = proxyBaseUrl;
}
if (error) {
attributes.error = error;
}
logger.log(level, message, attributes);
}
type AuthMode = "none" | "client" | "host";
function parseWorkspaceMount(pathname: string): { workspaceId: string; restPath: string } | null {
if (!pathname.startsWith("/w/")) return null;
const remainder = pathname.slice(3);
if (!remainder) return null;
const slash = remainder.indexOf("/");
if (slash === -1) {
return { workspaceId: decodeURIComponent(remainder), restPath: "/" };
}
const workspaceId = remainder.slice(0, slash);
const restPath = remainder.slice(slash) || "/";
if (!workspaceId.trim()) return null;
return { workspaceId: decodeURIComponent(workspaceId), restPath };
}
interface Route {
method: string;
regex: RegExp;
keys: string[];
auth: AuthMode;
handler: (ctx: RequestContext) => Promise<Response>;
}
interface RequestContext {
request: Request;
url: URL;
params: Record<string, string>;
config: ServerConfig;
approvals: ApprovalService;
reloadEvents: ReloadEventStore;
actor?: Actor;
}
export function startServer(config: ServerConfig) {
const approvals = new ApprovalService(config.approval);
const reloadEvents = new ReloadEventStore();
const routes = createRoutes(config, approvals);
const logger = createServerLogger(config);
const serverOptions: {
hostname: string;
port: number;
fetch: (request: Request) => Response | Promise<Response>;
} = {
hostname: config.host,
port: config.port,
fetch: async (request: Request) => {
const url = new URL(request.url);
const startedAt = Date.now();
let authMode: AuthMode = "none";
let proxyBaseUrl: string | undefined;
let errorMessage: string | undefined;
const finalize = (response: Response) => {
const wrapped = withCors(response, request, config);
if (config.logRequests) {
logRequest({
logger,
request,
response: wrapped,
durationMs: Date.now() - startedAt,
authMode,
proxyBaseUrl,
error: errorMessage,
});
}
return wrapped;
};
if (request.method === "OPTIONS") {
return finalize(new Response(null, { status: 204 }));
}
const mount = parseWorkspaceMount(url.pathname);
if (mount && (mount.restPath === "/opencode" || mount.restPath.startsWith("/opencode/"))) {
authMode = "client";
try {
requireClient(request, config);
const workspace = await resolveWorkspace(config, mount.workspaceId);
proxyBaseUrl = workspace.baseUrl?.trim() || undefined;
const response = await proxyOpencodeRequest({ request, url, workspace, proxyPath: mount.restPath });
return finalize(response);
} catch (error) {
const apiError = error instanceof ApiError
? error
: new ApiError(500, "internal_error", "Unexpected server error");
errorMessage = apiError.message;
return finalize(jsonResponse(formatError(apiError), apiError.status));
}
}
// Allow clients to use a mounted base URL (e.g. http://host:8787/w/<id>) while
// still calling the existing /workspace/:id/* API surface.
// Example: baseUrl + "/workspace/<id>/plugins" => "/w/<id>/workspace/<id>/plugins".
// We strip the mount prefix and route-match on the rest path.
//
// Important: when using a mounted base URL, enforce that the nested /workspace/:id
// matches the mount workspace id to preserve the "single-workspace" mental model.
if (mount && mount.restPath.startsWith("/workspace/")) {
const match = mount.restPath.match(/^\/workspace\/([^/]+)/);
const nestedId = match?.[1] ? decodeURIComponent(match[1]) : null;
if (nestedId && nestedId !== mount.workspaceId) {
errorMessage = "not_found";
return finalize(jsonResponse({ code: "not_found", message: "Not found" }, 404));
}
url.pathname = mount.restPath;
}
if (url.pathname === "/opencode" || url.pathname.startsWith("/opencode/")) {
authMode = "client";
proxyBaseUrl = config.workspaces[0]?.baseUrl?.trim() || undefined;
try {
requireClient(request, config);
const response = await proxyOpencodeRequest({ request, url, workspace: config.workspaces[0] });
return finalize(response);
} catch (error) {
const apiError = error instanceof ApiError
? error
: new ApiError(500, "internal_error", "Unexpected server error");
errorMessage = apiError.message;
return finalize(jsonResponse(formatError(apiError), apiError.status));
}
}
const route = matchRoute(routes, request.method, url.pathname);
if (!route) {
errorMessage = "not_found";
return finalize(jsonResponse({ code: "not_found", message: "Not found" }, 404));
}
authMode = route.auth;
try {
const actor = route.auth === "host" ? requireHost(request, config) : route.auth === "client" ? requireClient(request, config) : undefined;
const response = await route.handler({
request,
url,
params: route.params,
config,
approvals,
reloadEvents,
actor,
});
return finalize(response);
} catch (error) {
const apiError = error instanceof ApiError
? error
: new ApiError(500, "internal_error", "Unexpected server error");
errorMessage = apiError.message;
return finalize(jsonResponse(formatError(apiError), apiError.status));
}
},
};
(serverOptions as { idleTimeout?: number }).idleTimeout = 120;
const server = Bun.serve(serverOptions);
return server;
}
function matchRoute(routes: Route[], method: string, path: string) {
for (const route of routes) {
if (route.method !== method) continue;
const match = path.match(route.regex);
if (!match) continue;
const params: Record<string, string> = {};
route.keys.forEach((key, index) => {
params[key] = decodeURIComponent(match[index + 1]);
});
return { ...route, params };
}
return null;
}
function addRoute(routes: Route[], method: string, path: string, auth: AuthMode, handler: Route["handler"]) {
const keys: string[] = [];
const regex = pathToRegex(path, keys);
routes.push({ method, regex, keys, auth, handler });
}
function pathToRegex(path: string, keys: string[]): RegExp {
const pattern = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
keys.push(key);
return "([^/]+)";
});
return new RegExp(`^${pattern}$`);
}
function buildOpencodeProxyUrl(baseUrl: string, path: string, search: string) {
const target = new URL(baseUrl);
const trimmedPath = path.replace(/^\/opencode/, "");
target.pathname = trimmedPath.startsWith("/") ? trimmedPath : `/${trimmedPath}`;
target.search = search;
return target.toString();
}
async function proxyOpencodeRequest(input: {
request: Request;
url: URL;
workspace?: WorkspaceInfo;
proxyPath?: string;
}) {
const workspace = input.workspace;
const baseUrl = workspace?.baseUrl?.trim() ?? "";
if (!baseUrl) {
throw new ApiError(400, "opencode_unconfigured", "OpenCode base URL is missing for this workspace");
}
const proxyPath = input.proxyPath ?? input.url.pathname;
const targetUrl = buildOpencodeProxyUrl(baseUrl, proxyPath, input.url.search);
const headers = new Headers(input.request.headers);
headers.delete("authorization");
headers.delete("host");
headers.delete("origin");
const directory = workspace ? resolveOpencodeDirectory(workspace) : null;
if (directory && !headers.has("x-opencode-directory")) {
headers.set("x-opencode-directory", directory);
}
const auth = workspace ? buildOpencodeAuthHeader(workspace) : null;
if (auth) {
headers.set("Authorization", auth);
}
const method = input.request.method.toUpperCase();
const body = method === "GET" || method === "HEAD" ? undefined : input.request.body;
const response = await fetch(targetUrl, {
method,
headers,
body,
});
return response;
}
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function owpenbotDebugEnabled(): boolean {
return ["1", "true", "yes"].includes((process.env.OPENWORK_DEBUG_OWPENBOT ?? "").toLowerCase());
}
function logOwpenbotDebug(message: string, details?: Record<string, unknown>) {
if (!owpenbotDebugEnabled()) return;
const payload = details ? ` ${JSON.stringify(details)}` : "";
console.log(`[owpenbot] ${message}${payload}`);
}
function withCors(response: Response, request: Request, config: ServerConfig) {
const origin = request.headers.get("origin");
const allowedOrigins = config.corsOrigins;
let allowOrigin: string | null = null;
if (allowedOrigins.includes("*")) {
allowOrigin = "*";
} else if (origin && allowedOrigins.includes(origin)) {
allowOrigin = origin;
}
if (!allowOrigin) return response;
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", allowOrigin);
headers.set(
"Access-Control-Allow-Headers",
"Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id, X-OpenCode-Directory, X-Opencode-Directory, x-opencode-directory",
);
headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
headers.set("Vary", "Origin");
return new Response(response.body, { status: response.status, headers });
}
function requireClient(request: Request, config: ServerConfig): Actor {
const header = request.headers.get("authorization") ?? "";
const match = header.match(/^Bearer\s+(.+)$/i);
const token = match?.[1];
if (!token || token !== config.token) {
throw new ApiError(401, "unauthorized", "Invalid bearer token");
}
const clientId = request.headers.get("x-openwork-client-id") ?? undefined;
return { type: "remote", clientId, tokenHash: hashToken(token) };
}
function requireHost(request: Request, config: ServerConfig): Actor {
const token = request.headers.get("x-openwork-host-token");
if (!token || token !== config.hostToken) {
throw new ApiError(401, "unauthorized", "Invalid host token");
}
return { type: "host", tokenHash: hashToken(token) };
}
function buildCapabilities(config: ServerConfig): Capabilities {
const writeEnabled = !config.readOnly;
return {
skills: { read: true, write: writeEnabled, source: "openwork" },
plugins: { read: true, write: writeEnabled },
mcp: { read: true, write: writeEnabled },
commands: { read: true, write: writeEnabled },
config: { read: true, write: writeEnabled },
};
}
function emitReloadEvent(
reloadEvents: ReloadEventStore,
workspace: WorkspaceInfo,
reason: ReloadReason,
trigger?: ReloadTrigger,
) {
reloadEvents.record(workspace.id, reason, trigger);
}
function buildConfigTrigger(path: string): ReloadTrigger {
const name = path.split(/[\\/]/).filter(Boolean).pop();
return {
type: "config",
name: name || "opencode.json",
action: "updated",
path,
};
}
function serializeWorkspace(workspace: ServerConfig["workspaces"][number]) {
const { opencodeUsername, opencodePassword, ...rest } = workspace;
const opencodeDirectory = resolveOpencodeDirectory(workspace);
const opencode =
workspace.baseUrl || opencodeDirectory || opencodeUsername || opencodePassword
? {
baseUrl: workspace.baseUrl,
directory: opencodeDirectory ?? undefined,
username: opencodeUsername,
password: opencodePassword,
}
: undefined;
return {
...rest,
opencode,
};
}
function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[] {
const routes: Route[] = [];
addRoute(routes, "GET", "/health", "none", async () => {
return jsonResponse({ ok: true, version: SERVER_VERSION, uptimeMs: Date.now() - config.startedAt });
});
addRoute(routes, "GET", "/w/:id/health", "none", async () => {
return jsonResponse({ ok: true, version: SERVER_VERSION, uptimeMs: Date.now() - config.startedAt });
});
addRoute(routes, "GET", "/w/:id/status", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
return jsonResponse({
ok: true,
version: SERVER_VERSION,
uptimeMs: Date.now() - config.startedAt,
readOnly: config.readOnly,
approval: config.approval,
corsOrigins: config.corsOrigins,
workspaceCount: 1,
activeWorkspaceId: workspace.id,
workspace: serializeWorkspace(workspace),
authorizedRoots: config.authorizedRoots,
server: {
host: config.host,
port: config.port,
configPath: config.configPath ?? null,
},
tokenSource: {
client: config.tokenSource,
host: config.hostTokenSource,
},
});
});
addRoute(routes, "GET", "/w/:id/capabilities", "client", async () => {
return jsonResponse(buildCapabilities(config));
});
addRoute(routes, "GET", "/w/:id/workspaces", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
return jsonResponse({ items: [serializeWorkspace(workspace)], activeId: workspace.id });
});
addRoute(routes, "GET", "/status", "client", async () => {
const active = config.workspaces[0];
return jsonResponse({
ok: true,
version: SERVER_VERSION,
uptimeMs: Date.now() - config.startedAt,
readOnly: config.readOnly,
approval: config.approval,
corsOrigins: config.corsOrigins,
workspaceCount: config.workspaces.length,
activeWorkspaceId: active?.id ?? null,
workspace: active ? serializeWorkspace(active) : null,
authorizedRoots: config.authorizedRoots,
server: {
host: config.host,
port: config.port,
configPath: config.configPath ?? null,
},
tokenSource: {
client: config.tokenSource,
host: config.hostTokenSource,
},
});
});
addRoute(routes, "GET", "/capabilities", "client", async () => {
return jsonResponse(buildCapabilities(config));
});
addRoute(routes, "GET", "/workspaces", "client", async () => {
const active = config.workspaces[0] ?? null;
const items = config.workspaces.map(serializeWorkspace);
return jsonResponse({ items, activeId: active?.id ?? null });
});
addRoute(routes, "POST", "/workspaces/:id/activate", "host", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
config.workspaces = [
workspace,
...config.workspaces.filter((entry) => entry.id !== workspace.id),
];
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "host" },
action: "workspace.activate",
target: "workspace",
summary: "Switched active workspace",
timestamp: Date.now(),
});
return jsonResponse({ activeId: workspace.id, workspace: serializeWorkspace(workspace) });
});
addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const opencode = await readOpencodeConfig(workspace.path);
const openwork = await readOpenworkConfig(workspace.path);
const lastAudit = await readLastAudit(workspace.path);
return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
});
addRoute(routes, "GET", "/workspace/:id/audit", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const limitParam = ctx.url.searchParams.get("limit");
const parsed = limitParam ? Number(limitParam) : NaN;
const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
const items = await readAuditEntries(workspace.path, limit);
return jsonResponse({ items });
});
addRoute(routes, "PATCH", "/workspace/:id/config", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const opencode = body.opencode as Record<string, unknown> | undefined;
const openwork = body.openwork as Record<string, unknown> | undefined;
if (!opencode && !openwork) {
throw new ApiError(400, "invalid_payload", "opencode or openwork updates required");
}
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "config.patch",
summary: "Patch workspace config",
paths: [opencode ? opencodeConfigPath(workspace.path) : null, openwork ? openworkConfigPath(workspace.path) : null].filter(Boolean) as string[],
});
if (opencode) {
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
}
if (openwork) {
await writeOpenworkConfig(workspace.path, openwork, true);
}
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "config.patch",
target: "opencode.json",
summary: "Patched workspace config",
timestamp: Date.now(),
});
if (opencode) {
emitReloadEvent(ctx.reloadEvents, workspace, "config", buildConfigTrigger(opencodeConfigPath(workspace.path)));
}
return jsonResponse({ updatedAt: Date.now() });
});
addRoute(routes, "POST", "/workspace/:id/owpenbot/telegram-token", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const token = typeof body.token === "string" ? body.token.trim() : "";
const healthPort = normalizeHealthPort(body.healthPort);
const requestHost = ctx.url.hostname;
logOwpenbotDebug("telegram-token:request", {
workspaceId: workspace.id,
actor: ctx.actor?.type ?? "unknown",
hasToken: Boolean(token),
healthPort: healthPort ?? null,
requestHost,
});
if (!token) {
throw new ApiError(400, "token_required", "Telegram token is required");
}
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "owpenbot.telegram.set-token",
summary: "Set Telegram bot token",
paths: [resolveOwpenbotConfigPath()],
});
const result = await updateOwpenbotTelegramToken(token, healthPort, requestHost);
logOwpenbotDebug("telegram-token:updated", {
workspaceId: workspace.id,
applied: typeof result.applied === "boolean" ? result.applied : null,
applyError: typeof result.applyError === "string" ? result.applyError : null,
});
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "owpenbot.telegram.set-token",
target: "owpenbot.telegram",
summary: "Updated Telegram bot token",
timestamp: Date.now(),
});
return jsonResponse(result);
});
addRoute(routes, "POST", "/workspace/:id/owpenbot/slack-tokens", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const botToken = typeof body.botToken === "string" ? body.botToken.trim() : "";
const appToken = typeof body.appToken === "string" ? body.appToken.trim() : "";
const healthPort = normalizeHealthPort(body.healthPort);
const requestHost = ctx.url.hostname;
logOwpenbotDebug("slack-tokens:request", {
workspaceId: workspace.id,
actor: ctx.actor?.type ?? "unknown",
hasBotToken: Boolean(botToken),
hasAppToken: Boolean(appToken),
healthPort: healthPort ?? null,
requestHost,
});
if (!botToken || !appToken) {
throw new ApiError(400, "token_required", "Slack botToken and appToken are required");
}
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "owpenbot.slack.set-tokens",
summary: "Set Slack bot tokens",
paths: [resolveOwpenbotConfigPath()],
});
const result = await updateOwpenbotSlackTokens(botToken, appToken, healthPort, requestHost);
logOwpenbotDebug("slack-tokens:updated", {
workspaceId: workspace.id,
applied: typeof result.applied === "boolean" ? result.applied : null,
applyError: typeof result.applyError === "string" ? result.applyError : null,
});
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "owpenbot.slack.set-tokens",
target: "owpenbot.slack",
summary: "Updated Slack bot tokens",
timestamp: Date.now(),
});
return jsonResponse(result);
});
addRoute(routes, "GET", "/workspace/:id/events", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const sinceParam = ctx.url.searchParams.get("since");
const parsedSince = sinceParam ? Number(sinceParam) : NaN;
const since = Number.isFinite(parsedSince) ? parsedSince : undefined;
const items = ctx.reloadEvents.list(workspace.id, since);
return jsonResponse({ items, cursor: ctx.reloadEvents.cursor() });
});
addRoute(routes, "POST", "/workspace/:id/engine/reload", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "engine.reload",
summary: "Reload OpenCode engine",
paths: [opencodeConfigPath(workspace.path)],
});
await reloadOpencodeEngine(workspace);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "engine.reload",
target: "opencode.instance",
summary: "Reloaded OpenCode engine",
timestamp: Date.now(),
});
return jsonResponse({ ok: true, reloadedAt: Date.now() });
});
addRoute(routes, "GET", "/workspace/:id/plugins", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
const result = await listPlugins(workspace.path, includeGlobal);
return jsonResponse(result);
});
addRoute(routes, "POST", "/workspace/:id/plugins", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const spec = String(body.spec ?? "");
const normalized = normalizePluginSpec(spec);
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "plugins.add",
summary: `Add plugin ${spec}`,
paths: [opencodeConfigPath(workspace.path)],
});
const changed = await addPlugin(workspace.path, spec);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "plugins.add",
target: "opencode.json",
summary: `Added ${spec}`,
timestamp: Date.now(),
});
if (changed) {
emitReloadEvent(ctx.reloadEvents, workspace, "plugins", {
type: "plugin",
name: normalized,
action: "added",
});
}
const result = await listPlugins(workspace.path, false);
return jsonResponse(result);
});
addRoute(routes, "DELETE", "/workspace/:id/plugins/:name", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const name = ctx.params.name ?? "";
const normalized = normalizePluginSpec(name);
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "plugins.remove",
summary: `Remove plugin ${name}`,
paths: [opencodeConfigPath(workspace.path)],
});
const removed = await removePlugin(workspace.path, name);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "plugins.remove",
target: "opencode.json",
summary: `Removed ${name}`,
timestamp: Date.now(),
});
if (removed) {
emitReloadEvent(ctx.reloadEvents, workspace, "plugins", {
type: "plugin",
name: normalized,
action: "removed",
});
}
const result = await listPlugins(workspace.path, false);
return jsonResponse(result);
});
addRoute(routes, "GET", "/workspace/:id/skills", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
const items = await listSkills(workspace.path, includeGlobal);
return jsonResponse({ items });
});
addRoute(routes, "GET", "/workspace/:id/skills/:name", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
const name = String(ctx.params.name ?? "").trim();
if (!name) {
throw new ApiError(400, "invalid_skill_name", "Skill name is required");
}
const items = await listSkills(workspace.path, includeGlobal);
const item = items.find((skill) => skill.name === name);
if (!item) {
throw new ApiError(404, "skill_not_found", `Skill not found: ${name}`);
}
const content = await readFile(item.path, "utf8");
return jsonResponse({ item, content });
});
addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const name = String(body.name ?? "");
const content = String(body.content ?? "");
const description = body.description ? String(body.description) : undefined;
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "skills.upsert",
summary: `Upsert skill ${name}`,
paths: [join(workspace.path, ".opencode", "skills", name, "SKILL.md")],
});
const result = await upsertSkill(workspace.path, { name, content, description });
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "skills.upsert",
target: result.path,
summary: `Upserted skill ${name}`,
timestamp: Date.now(),
});
emitReloadEvent(ctx.reloadEvents, workspace, "skills", {
type: "skill",
name,
action: result.action,
path: result.path,
});
return jsonResponse({ name, path: result.path, description: description ?? "", scope: "project" });
});
addRoute(routes, "GET", "/workspace/:id/mcp", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const items = await listMcp(workspace.path);
return jsonResponse({ items });
});
addRoute(routes, "POST", "/workspace/:id/mcp", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const name = String(body.name ?? "");
const configPayload = body.config as Record<string, unknown> | undefined;
if (!configPayload) {
throw new ApiError(400, "invalid_payload", "MCP config is required");
}
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "mcp.add",
summary: `Add MCP ${name}`,
paths: [opencodeConfigPath(workspace.path)],
});
const result = await addMcp(workspace.path, name, configPayload);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "mcp.add",
target: "opencode.json",
summary: `Added MCP ${name}`,
timestamp: Date.now(),
});
emitReloadEvent(ctx.reloadEvents, workspace, "mcp", {
type: "mcp",
name,
action: result.action,
});
const items = await listMcp(workspace.path);
return jsonResponse({ items });
});
addRoute(routes, "DELETE", "/workspace/:id/mcp/:name", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const name = ctx.params.name ?? "";
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "mcp.remove",
summary: `Remove MCP ${name}`,
paths: [opencodeConfigPath(workspace.path)],
});
const removed = await removeMcp(workspace.path, name);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "mcp.remove",
target: "opencode.json",
summary: `Removed MCP ${name}`,
timestamp: Date.now(),
});
if (removed) {
emitReloadEvent(ctx.reloadEvents, workspace, "mcp", {
type: "mcp",
name,
action: "removed",
});
}
const items = await listMcp(workspace.path);
return jsonResponse({ items });
});
addRoute(routes, "GET", "/workspace/:id/commands", "client", async (ctx) => {
const scope = ctx.url.searchParams.get("scope") === "global" ? "global" : "workspace";
if (scope === "global") {
requireHost(ctx.request, config);
}
const workspace = await resolveWorkspace(config, ctx.params.id);
const items = await listCommands(workspace.path, scope);
return jsonResponse({ items });
});
addRoute(routes, "POST", "/workspace/:id/commands", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const name = String(body.name ?? "");
const template = String(body.template ?? "");
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "commands.upsert",
summary: `Upsert command ${name}`,
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
});
const path = await upsertCommand(workspace.path, {
name,
description: body.description ? String(body.description) : undefined,
template,
agent: body.agent ? String(body.agent) : undefined,
model: body.model ? String(body.model) : undefined,
subtask: typeof body.subtask === "boolean" ? body.subtask : undefined,
});
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "commands.upsert",
target: path,
summary: `Upserted command ${name}`,
timestamp: Date.now(),
});
emitReloadEvent(ctx.reloadEvents, workspace, "commands", {
type: "command",
name: sanitizeCommandName(name),
action: "updated",
path,
});
const items = await listCommands(workspace.path, "workspace");
return jsonResponse({ items });
});
addRoute(routes, "DELETE", "/workspace/:id/commands/:name", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const name = ctx.params.name ?? "";
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "commands.delete",
summary: `Delete command ${name}`,
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
});
await deleteCommand(workspace.path, name);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "commands.delete",
target: join(workspace.path, ".opencode", "commands"),
summary: `Deleted command ${name}`,
timestamp: Date.now(),
});
emitReloadEvent(ctx.reloadEvents, workspace, "commands", {
type: "command",
name: sanitizeCommandName(name),
action: "removed",
path: join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`),
});
return jsonResponse({ ok: true });
});
addRoute(routes, "GET", "/workspace/:id/scheduler/jobs", "client", async (ctx) => {
await resolveWorkspace(config, ctx.params.id);
const items = await listScheduledJobs();
return jsonResponse({ items });
});
addRoute(routes, "DELETE", "/workspace/:id/scheduler/jobs/:name", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const name = ctx.params.name ?? "";
const { job, jobFile, systemPaths } = await resolveScheduledJob(name);
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "scheduler.delete",
summary: `Delete scheduled job ${job.name}`,
paths: [jobFile, ...systemPaths],
});
await deleteScheduledJob(job);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "scheduler.delete",
target: jobFile,
summary: `Deleted scheduled job ${job.name}`,
timestamp: Date.now(),
});
return jsonResponse({ job });
});
addRoute(routes, "GET", "/workspace/:id/export", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const exportPayload = await exportWorkspace(workspace);
return jsonResponse(exportPayload);
});
addRoute(routes, "POST", "/workspace/:id/import", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "config.import",
summary: "Import workspace config",
paths: [opencodeConfigPath(workspace.path), openworkConfigPath(workspace.path)],
});
await importWorkspace(workspace, body);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "config.import",
target: "workspace",
summary: "Imported workspace config",
timestamp: Date.now(),
});
emitReloadEvent(ctx.reloadEvents, workspace, "config", buildConfigTrigger(opencodeConfigPath(workspace.path)));
return jsonResponse({ ok: true });
});
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
return jsonResponse({ items: ctx.approvals.list() });
});
addRoute(routes, "POST", "/approvals/:id", "host", async (ctx) => {
const body = await readJsonBody(ctx.request);
const reply = body.reply === "allow" ? "allow" : "deny";
const result = ctx.approvals.respond(ctx.params.id, reply);
if (!result) {
throw new ApiError(404, "approval_not_found", "Approval request not found");
}
return jsonResponse({ ok: true, allowed: result.allowed });
});
return routes;
}
async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
const workspace = config.workspaces.find((entry) => entry.id === id);
if (!workspace) {
throw new ApiError(404, "workspace_not_found", "Workspace not found");
}
const resolvedWorkspace = resolve(workspace.path);
const authorized = await isAuthorizedRoot(resolvedWorkspace, config.authorizedRoots);
if (!authorized) {
throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
}
return { ...workspace, path: resolvedWorkspace };
}
async function isAuthorizedRoot(workspacePath: string, roots: string[]): Promise<boolean> {
const resolvedWorkspace = resolve(workspacePath);
for (const root of roots) {
const resolvedRoot = resolve(root);
if (resolvedWorkspace === resolvedRoot) return true;
if (resolvedWorkspace.startsWith(resolvedRoot + sep)) return true;
}
return false;
}
function ensureWritable(config: ServerConfig): void {
if (config.readOnly) {
throw new ApiError(403, "read_only", "Server is read-only");
}
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const json = await request.json();
return json as Record<string, unknown>;
} catch {
throw new ApiError(400, "invalid_json", "Invalid JSON body");
}
}
function parseInteger(value: string | undefined): number | null {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function expandHome(value: string): string {
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
function resolveOwpenbotConfigPath(): string {
const override = process.env.OWPENBOT_CONFIG_PATH?.trim();
if (override) return expandHome(override);
const dataDir = process.env.OWPENBOT_DATA_DIR?.trim() || join(homedir(), ".openwork", "owpenbot");
return join(expandHome(dataDir), "owpenbot.json");
}
function resolveOwpenbotHealthPort(): number {
return parseInteger(process.env.OWPENBOT_HEALTH_PORT) ?? 3005;
}
function parseJsonResponse(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as unknown;
} catch {
return trimmed;
}
}
function normalizeHealthPort(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const port = Math.trunc(value);
if (port <= 0 || port > 65535) return null;
return port;
}
type OwpenbotConfigFile = Record<string, unknown> & {
version?: number;
channels?: Record<string, unknown> & {
telegram?: Record<string, unknown>;
slack?: Record<string, unknown>;
};
};
function ensurePlainObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
async function readOwpenbotConfigFile(configPath: string): Promise<OwpenbotConfigFile> {
if (!(await exists(configPath))) {
return { version: 1 };
}
let raw = "";
try {
raw = await readFile(configPath, "utf8");
} catch (error) {
throw new ApiError(500, "owpenbot_config_read_failed", "Failed to read owpenbot.json", {
path: configPath,
error: String(error),
});
}
try {
const parsed = JSON.parse(raw) as unknown;
return ensurePlainObject(parsed) as OwpenbotConfigFile;
} catch (error) {
throw new ApiError(422, "invalid_json", "Failed to parse owpenbot.json", {
path: configPath,
error: String(error),
});
}
}
async function writeOwpenbotConfigFile(configPath: string, config: OwpenbotConfigFile): Promise<void> {
await ensureDir(dirname(configPath));
const next: OwpenbotConfigFile = {
...config,
version: typeof config.version === "number" && Number.isFinite(config.version) ? config.version : 1,
};
const tmpPath = `${configPath}.tmp.${shortId()}`;
try {
await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await rename(tmpPath, configPath);
} finally {
// Best-effort cleanup if rename failed.
try {
await rm(tmpPath);
} catch {
// ignore
}
}
}
async function persistOwpenbotTelegramToken(token: string): Promise<void> {
const configPath = resolveOwpenbotConfigPath();
const current = await readOwpenbotConfigFile(configPath);
const channels = ensurePlainObject(current.channels);
const telegram = ensurePlainObject(channels.telegram);
const next: OwpenbotConfigFile = {
...current,
channels: {
...channels,
telegram: {
...telegram,
token,
enabled: true,
},
},
};
await writeOwpenbotConfigFile(configPath, next);
}
async function persistOwpenbotSlackTokens(botToken: string, appToken: string): Promise<void> {
const configPath = resolveOwpenbotConfigPath();
const current = await readOwpenbotConfigFile(configPath);
const channels = ensurePlainObject(current.channels);
const slack = ensurePlainObject(channels.slack);
const next: OwpenbotConfigFile = {
...current,
channels: {
...channels,
slack: {
...slack,
botToken,
appToken,
enabled: true,
},
},
};
await writeOwpenbotConfigFile(configPath, next);
}
type OwpenbotApplyAttempt = {
applied: boolean;
port: number;
hosts: string[];
host?: string;
status?: number;
error?: string;
body?: unknown;
};
async function tryPostOwpenbotHealth(
pathname: string,
payload: unknown,
options: { port: number; requestHost?: string | null; timeoutMs: number },
): Promise<OwpenbotApplyAttempt> {
const candidates = Array.from(
new Set(
["127.0.0.1", options.requestHost].filter(
(host): host is string => Boolean(host && host.trim()),
),
),
);
const port = options.port;
let lastError: OwpenbotApplyAttempt | null = null;
for (const host of candidates) {
const url = `http://${host}:${port}${pathname}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timer);
const text = await response.text();
const parsed = parseJsonResponse(text);
if (response.ok) {
return {
applied: true,
port,
hosts: candidates,
host,
status: response.status,
body: parsed,
};
}
const detail =
typeof parsed === "object" && parsed && "error" in parsed
? String((parsed as Record<string, unknown>).error)
: response.statusText || "Owpenbot request failed";
lastError = {
applied: false,
port,
hosts: candidates,
host,
status: response.status,
error: detail,
body: parsed,
};
} catch (error) {
clearTimeout(timer);
const message =
error instanceof Error && error.name === "AbortError"
? `Timeout after ${options.timeoutMs}ms`
: String(error);
lastError = {
applied: false,
port,
hosts: candidates,
host,
error: message,
};
}
}
return (
lastError ?? {
applied: false,
port,
hosts: candidates,
error: "Owpenbot health server is unavailable",
}
);
}
async function updateOwpenbotTelegramToken(
token: string,
healthPortOverride?: number | null,
requestHost?: string | null,
): Promise<Record<string, unknown>> {
// Always persist first so the token is saved even if owpenbot is offline.
await persistOwpenbotTelegramToken(token);
const port = healthPortOverride ?? resolveOwpenbotHealthPort();
const apply = await tryPostOwpenbotHealth(
"/config/telegram-token",
{ token },
{ port, requestHost, timeoutMs: 3_000 },
);
const response: Record<string, unknown> = {
ok: true,
persisted: true,
applied: apply.applied,
telegram: { configured: true, enabled: true },
};
// Prefer owpenbot's response payload when available.
if (apply.body && typeof apply.body === "object") {
const record = apply.body as Record<string, unknown>;
if (record.telegram && typeof record.telegram === "object") {
response.telegram = record.telegram;
}
}
// If owpenbot reports apply status, reflect it at the top-level.
let telegramStarting = false;
if (response.telegram && typeof response.telegram === "object") {
const telegram = response.telegram as Record<string, unknown>;
if (typeof telegram.applied === "boolean") {
response.applied = telegram.applied;
}
if (typeof telegram.starting === "boolean") {
telegramStarting = telegram.starting;
}
if (!response.applyError && typeof telegram.error === "string" && telegram.error.trim()) {
response.applyError = telegram.error;
}
}
if (!apply.applied) {
response.applyError = (typeof response.applyError === "string" && response.applyError.trim())
? response.applyError
: apply.error ?? "Owpenbot did not apply the update";
if (typeof apply.status === "number") response.applyStatus = apply.status;
} else if (response.applied === false && !telegramStarting && !response.applyError) {
response.applyError = "Owpenbot did not apply the update";
}
return response;
}
async function updateOwpenbotSlackTokens(
botToken: string,
appToken: string,
healthPortOverride?: number | null,
requestHost?: string | null,
): Promise<Record<string, unknown>> {
await persistOwpenbotSlackTokens(botToken, appToken);
const port = healthPortOverride ?? resolveOwpenbotHealthPort();
const apply = await tryPostOwpenbotHealth(
"/config/slack-tokens",
{ botToken, appToken },
{ port, requestHost, timeoutMs: 3_000 },
);
const response: Record<string, unknown> = {
ok: true,
persisted: true,
applied: apply.applied,
slack: { configured: true, enabled: true },
};
if (apply.body && typeof apply.body === "object") {
const record = apply.body as Record<string, unknown>;
if (record.slack && typeof record.slack === "object") {
response.slack = record.slack;
}
}
let slackStarting = false;
if (response.slack && typeof response.slack === "object") {
const slack = response.slack as Record<string, unknown>;
if (typeof slack.applied === "boolean") {
response.applied = slack.applied;
}
if (typeof slack.starting === "boolean") {
slackStarting = slack.starting;
}
if (!response.applyError && typeof slack.error === "string" && slack.error.trim()) {
response.applyError = slack.error;
}
}
if (!apply.applied) {
response.applyError = (typeof response.applyError === "string" && response.applyError.trim())
? response.applyError
: apply.error ?? "Owpenbot did not apply the update";
if (typeof apply.status === "number") response.applyStatus = apply.status;
} else if (response.applied === false && !slackStarting && !response.applyError) {
response.applyError = "Owpenbot did not apply the update";
}
return response;
}
async function readOpencodeConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
return data;
}
async function readOpenworkConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
const path = openworkConfigPath(workspaceRoot);
if (!(await exists(path))) return {};
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new ApiError(422, "invalid_json", "Failed to parse openwork.json");
}
}
function resolveOpencodeDirectory(workspace: WorkspaceInfo): string | null {
const explicit = workspace.directory?.trim() ?? "";
if (explicit) return explicit;
if (workspace.workspaceType === "local") return workspace.path;
return null;
}
function buildOpencodeReloadUrl(baseUrl: string, directory?: string | null): string {
try {
const url = new URL(baseUrl);
url.pathname = "/instance/dispose";
url.search = "";
if (directory) {
url.searchParams.set("directory", directory);
}
return url.toString();
} catch {
throw new ApiError(400, "opencode_url_invalid", "OpenCode base URL is invalid");
}
}
function buildOpencodeAuthHeader(workspace: WorkspaceInfo): string | null {
const username = workspace.opencodeUsername?.trim() ?? "";
const password = workspace.opencodePassword?.trim() ?? "";
if (!username || !password) return null;
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
function parseOpencodeErrorBody(input: string): unknown {
const trimmed = input.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as unknown;
} catch {
return trimmed;
}
}
async function reloadOpencodeEngine(workspace: WorkspaceInfo): Promise<void> {
const baseUrl = workspace.baseUrl?.trim() ?? "";
if (!baseUrl) {
throw new ApiError(400, "opencode_unconfigured", "OpenCode base URL is missing for this workspace");
}
const directory = resolveOpencodeDirectory(workspace);
const targetUrl = buildOpencodeReloadUrl(baseUrl, directory);
const headers: Record<string, string> = {};
const auth = buildOpencodeAuthHeader(workspace);
if (auth) headers.Authorization = auth;
const response = await fetch(targetUrl, { method: "POST", headers });
if (response.ok) return;
const body = parseOpencodeErrorBody(await response.text());
throw new ApiError(502, "opencode_reload_failed", "OpenCode reload failed", {
status: response.status,
body,
});
}
async function writeOpenworkConfig(workspaceRoot: string, payload: Record<string, unknown>, merge: boolean): Promise<void> {
const path = openworkConfigPath(workspaceRoot);
const next = merge ? { ...(await readOpenworkConfig(workspaceRoot)), ...payload } : payload;
await ensureDir(join(workspaceRoot, ".opencode"));
await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8");
}
async function requireApproval(
ctx: RequestContext,
input: Omit<ApprovalRequest, "id" | "createdAt" | "actor">,
): Promise<void> {
const actor = ctx.actor ?? { type: "remote" };
const result = await ctx.approvals.requestApproval({ ...input, actor });
if (!result.allowed) {
throw new ApiError(403, "write_denied", "Write request denied", {
requestId: result.id,
reason: result.reason,
});
}
}
async function exportWorkspace(workspace: WorkspaceInfo) {
const opencode = await readOpencodeConfig(workspace.path);
const openwork = await readOpenworkConfig(workspace.path);
const skills = await listSkills(workspace.path, false);
const commands = await listCommands(workspace.path, "workspace");
const skillContents = await Promise.all(
skills.map(async (skill) => ({
name: skill.name,
description: skill.description,
content: await readFile(skill.path, "utf8"),
})),
);
const commandContents = await Promise.all(
commands.map(async (command) => ({
name: command.name,
description: command.description,
template: command.template,
})),
);
return {
workspaceId: workspace.id,
exportedAt: Date.now(),
opencode,
openwork,
skills: skillContents,
commands: commandContents,
};
}
async function importWorkspace(workspace: WorkspaceInfo, payload: Record<string, unknown>): Promise<void> {
const modes = (payload.mode as Record<string, string> | undefined) ?? {};
const opencode = payload.opencode as Record<string, unknown> | undefined;
const openwork = payload.openwork as Record<string, unknown> | undefined;
const skills = (payload.skills as { name: string; content: string; description?: string }[] | undefined) ?? [];
const commands = (payload.commands as { name: string; content?: string; description?: string; template?: string; agent?: string; model?: string | null; subtask?: boolean }[] | undefined) ?? [];
if (opencode) {
if (modes.opencode === "replace") {
await writeJsoncFile(opencodeConfigPath(workspace.path), opencode);
} else {
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
}
}
if (openwork) {
if (modes.openwork === "replace") {
await writeOpenworkConfig(workspace.path, openwork, false);
} else {
await writeOpenworkConfig(workspace.path, openwork, true);
}
}
if (skills.length > 0) {
if (modes.skills === "replace") {
await rm(projectSkillsDir(workspace.path), { recursive: true, force: true });
}
for (const skill of skills) {
await upsertSkill(workspace.path, skill);
}
}
if (commands.length > 0) {
if (modes.commands === "replace") {
await rm(projectCommandsDir(workspace.path), { recursive: true, force: true });
}
for (const command of commands) {
if (command.content) {
const parsed = parseFrontmatter(command.content);
const name = command.name || (typeof parsed.data.name === "string" ? parsed.data.name : "");
const description = command.description || (typeof parsed.data.description === "string" ? parsed.data.description : undefined);
if (!name) {
throw new ApiError(400, "invalid_command", "Command name is required");
}
const template = parsed.body.trim();
await upsertCommand(workspace.path, {
name,
description,
template,
agent: typeof parsed.data.agent === "string" ? parsed.data.agent : undefined,
model: typeof parsed.data.model === "string" ? parsed.data.model : undefined,
subtask: typeof parsed.data.subtask === "boolean" ? parsed.data.subtask : undefined,
});
} else {
const name = command.name ?? "";
const template = command.template ?? "";
await upsertCommand(workspace.path, {
name,
description: command.description,
template,
agent: command.agent,
model: command.model,
subtask: command.subtask,
});
}
}
}
}