mirror of
https://github.com/different-ai/openwork
synced 2026-05-15 03:26:24 +02:00
* 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.
1659 lines
56 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|