mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
1037 lines
36 KiB
TypeScript
1037 lines
36 KiB
TypeScript
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 { 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 AuthMode = "none" | "client" | "host";
|
|
|
|
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 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);
|
|
if (request.method === "OPTIONS") {
|
|
return withCors(new Response(null, { status: 204 }), request, config);
|
|
}
|
|
|
|
const route = matchRoute(routes, request.method, url.pathname);
|
|
if (!route) {
|
|
return withCors(jsonResponse({ code: "not_found", message: "Not found" }, 404), request, config);
|
|
}
|
|
|
|
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 withCors(response, request, config);
|
|
} catch (error) {
|
|
const apiError = error instanceof ApiError
|
|
? error
|
|
: new ApiError(500, "internal_error", "Unexpected server error");
|
|
return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
|
|
}
|
|
},
|
|
};
|
|
|
|
(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 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");
|
|
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", "/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];
|
|
const items = active ? [serializeWorkspace(active)] : [];
|
|
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() : "";
|
|
logOwpenbotDebug("telegram-token:request", {
|
|
workspaceId: workspace.id,
|
|
actor: ctx.actor?.type ?? "unknown",
|
|
hasToken: Boolean(token),
|
|
});
|
|
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);
|
|
logOwpenbotDebug("telegram-token:updated", { workspaceId: workspace.id });
|
|
|
|
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, "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, "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(),
|
|
});
|
|
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(),
|
|
});
|
|
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;
|
|
}
|
|
}
|
|
|
|
async function updateOwpenbotTelegramToken(token: string): Promise<Record<string, unknown>> {
|
|
const port = resolveOwpenbotHealthPort();
|
|
const url = `http://127.0.0.1:${port}/config/telegram-token`;
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
} catch (error) {
|
|
throw new ApiError(502, "owpenbot_unreachable", "Owpenbot health server is unavailable", {
|
|
error: String(error),
|
|
});
|
|
}
|
|
|
|
const text = await response.text();
|
|
const parsed = parseJsonResponse(text);
|
|
|
|
if (!response.ok) {
|
|
const detail = typeof parsed === "object" && parsed && "error" in parsed
|
|
? String((parsed as Record<string, unknown>).error)
|
|
: "Owpenbot request failed";
|
|
throw new ApiError(response.status, "owpenbot_request_failed", detail, {
|
|
status: response.status,
|
|
body: parsed,
|
|
});
|
|
}
|
|
|
|
if (parsed && typeof parsed === "object") {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|