feat: openwork sync v0 (#280)

* docs: add openwork server PRD

* feat: wire OpenWork server remote management

* feat: add OpenWork server settings panel
This commit is contained in:
ben
2026-01-26 23:00:47 -08:00
committed by GitHub
parent 4d8333eeb0
commit 4bcbf8aae0
41 changed files with 3890 additions and 88 deletions

View File

@@ -0,0 +1,66 @@
import type { ApprovalConfig, ApprovalRequest } from "./types.js";
import { shortId } from "./utils.js";
interface ApprovalResult {
id: string;
allowed: boolean;
reason?: string;
}
interface PendingApproval {
request: ApprovalRequest;
resolve: (result: ApprovalResult) => void;
timeout?: NodeJS.Timeout;
}
export class ApprovalService {
private config: ApprovalConfig;
private pending = new Map<string, PendingApproval>();
constructor(config: ApprovalConfig) {
this.config = config;
}
list(): ApprovalRequest[] {
return Array.from(this.pending.values()).map((entry) => entry.request);
}
async requestApproval(
input: Omit<ApprovalRequest, "id" | "createdAt">,
): Promise<ApprovalResult> {
if (this.config.mode === "auto") {
return { id: "auto", allowed: true };
}
const id = shortId();
const request: ApprovalRequest = {
...input,
id,
createdAt: Date.now(),
};
const result = await new Promise<ApprovalResult>((resolve) => {
const timeout = setTimeout(() => {
this.pending.delete(id);
resolve({ id, allowed: false, reason: "timeout" });
}, this.config.timeoutMs);
this.pending.set(id, { request, resolve, timeout });
});
return result;
}
respond(id: string, reply: "allow" | "deny"): ApprovalResult | null {
const pending = this.pending.get(id);
if (!pending) return null;
if (pending.timeout) clearTimeout(pending.timeout);
this.pending.delete(id);
const result: ApprovalResult = {
id,
allowed: reply === "allow",
reason: reply === "allow" ? undefined : "denied",
};
pending.resolve(result);
return result;
}
}

View File

@@ -0,0 +1,28 @@
import { dirname, join } from "node:path";
import { appendFile, readFile } from "node:fs/promises";
import type { AuditEntry } from "./types.js";
import { ensureDir, exists } from "./utils.js";
export function auditLogPath(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "openwork", "audit.jsonl");
}
export async function recordAudit(workspaceRoot: string, entry: AuditEntry): Promise<void> {
const path = auditLogPath(workspaceRoot);
await ensureDir(dirname(path));
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
}
export async function readLastAudit(workspaceRoot: string): Promise<AuditEntry | null> {
const path = auditLogPath(workspaceRoot);
if (!(await exists(path))) return null;
const content = await readFile(path, "utf8");
const lines = content.trim().split("\n");
const last = lines[lines.length - 1];
if (!last) return null;
try {
return JSON.parse(last) as AuditEntry;
} catch {
return null;
}
}

9
packages/server/src/bun.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare const Bun: {
serve: (options: {
hostname: string;
port: number;
fetch: (request: Request) => Response | Promise<Response>;
}) => {
port: number;
};
};

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bun
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
import { startServer } from "./server.js";
const args = parseCliArgs(process.argv.slice(2));
if (args.help) {
printHelp();
process.exit(0);
}
const config = await resolveServerConfig(args);
const server = startServer(config);
const url = `http://${config.host}:${server.port}`;
console.log(`OpenWork server listening on ${url}`);
if (config.tokenSource === "generated") {
console.log(`Client token: ${config.token}`);
}
if (config.hostTokenSource === "generated") {
console.log(`Host token: ${config.hostToken}`);
}
if (config.workspaces.length === 0) {
console.log("No workspaces configured. Add --workspace or update server.json.");
} else {
console.log(`Workspaces: ${config.workspaces.length}`);
}

View File

@@ -0,0 +1,77 @@
import { readdir, readFile, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
import type { CommandItem } from "./types.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { exists } from "./utils.js";
import { projectCommandsDir } from "./workspace-files.js";
import { validateCommandName, sanitizeCommandName } from "./validators.js";
import { ApiError } from "./errors.js";
async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
const items: CommandItem[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".md")) continue;
const filePath = join(dir, entry.name);
const content = await readFile(filePath, "utf8");
const { data, body } = parseFrontmatter(content);
const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
try {
validateCommandName(name);
} catch {
continue;
}
items.push({
name,
description: typeof data.description === "string" ? data.description : undefined,
template: body.trim(),
agent: typeof data.agent === "string" ? data.agent : undefined,
model: typeof data.model === "string" ? data.model : null,
subtask: typeof data.subtask === "boolean" ? data.subtask : undefined,
scope,
});
}
return items;
}
export async function listCommands(workspaceRoot: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
if (scope === "global") {
const dir = join(homedir(), ".config", "opencode", "commands");
return listCommandsInDir(dir, "global");
}
return listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
}
export async function upsertCommand(
workspaceRoot: string,
payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
): Promise<string> {
if (!payload.template || payload.template.trim().length === 0) {
throw new ApiError(400, "invalid_command_template", "Command template is required");
}
const sanitized = sanitizeCommandName(payload.name);
validateCommandName(sanitized);
const frontmatter = buildFrontmatter({
name: sanitized,
description: payload.description,
agent: payload.agent,
model: payload.model ?? null,
subtask: payload.subtask ?? false,
});
const content = frontmatter + "\n" + payload.template.trim() + "\n";
const dir = projectCommandsDir(workspaceRoot);
await mkdir(dir, { recursive: true });
const path = join(dir, `${sanitized}.md`);
await writeFile(path, content, "utf8");
return path;
}
export async function deleteCommand(workspaceRoot: string, name: string): Promise<void> {
const sanitized = sanitizeCommandName(name);
validateCommandName(sanitized);
const path = join(projectCommandsDir(workspaceRoot), `${sanitized}.md`);
await rm(path, { force: true });
}

View File

@@ -0,0 +1,214 @@
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import type { ApprovalMode, ApprovalConfig, ServerConfig, WorkspaceConfig } from "./types.js";
import { buildWorkspaceInfos } from "./workspaces.js";
import { parseList, readJsonFile, shortId } from "./utils.js";
interface CliArgs {
configPath?: string;
host?: string;
port?: number;
token?: string;
hostToken?: string;
approvalMode?: ApprovalMode;
approvalTimeoutMs?: number;
workspaces: string[];
corsOrigins?: string[];
readOnly?: boolean;
help?: boolean;
}
interface FileConfig {
host?: string;
port?: number;
token?: string;
hostToken?: string;
approval?: Partial<ApprovalConfig>;
workspaces?: WorkspaceConfig[];
corsOrigins?: string[];
authorizedRoots?: string[];
readOnly?: boolean;
}
const DEFAULT_PORT = 8787;
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_TIMEOUT_MS = 30000;
export function parseCliArgs(argv: string[]): CliArgs {
const args: CliArgs = { workspaces: [] };
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value) continue;
if (value === "--help" || value === "-h") {
args.help = true;
continue;
}
if (value === "--config") {
args.configPath = argv[index + 1];
index += 1;
continue;
}
if (value === "--host") {
args.host = argv[index + 1];
index += 1;
continue;
}
if (value === "--port") {
const port = Number(argv[index + 1]);
if (!Number.isNaN(port)) args.port = port;
index += 1;
continue;
}
if (value === "--token") {
args.token = argv[index + 1];
index += 1;
continue;
}
if (value === "--host-token") {
args.hostToken = argv[index + 1];
index += 1;
continue;
}
if (value === "--approval") {
const mode = argv[index + 1] as ApprovalMode | undefined;
if (mode === "manual" || mode === "auto") args.approvalMode = mode;
index += 1;
continue;
}
if (value === "--approval-timeout") {
const timeout = Number(argv[index + 1]);
if (!Number.isNaN(timeout)) args.approvalTimeoutMs = timeout;
index += 1;
continue;
}
if (value === "--workspace") {
const path = argv[index + 1];
if (path) args.workspaces.push(path);
index += 1;
continue;
}
if (value === "--cors") {
args.corsOrigins = parseList(argv[index + 1]);
index += 1;
continue;
}
if (value === "--read-only") {
args.readOnly = true;
continue;
}
}
return args;
}
export function printHelp(): void {
const message = [
"openwork-server",
"",
"Options:",
" --config <path> Path to server.json",
" --host <host> Hostname (default 127.0.0.1)",
" --port <port> Port (default 8787)",
" --token <token> Client bearer token",
" --host-token <token> Host approval token",
" --approval <mode> manual | auto",
" --approval-timeout <ms> Approval timeout",
" --workspace <path> Workspace root (repeatable)",
" --cors <origins> Comma-separated origins or *",
" --read-only Disable writes",
].join("\n");
console.log(message);
}
async function loadFileConfig(configPath: string): Promise<FileConfig> {
const parsed = await readJsonFile<FileConfig>(configPath);
return parsed ?? {};
}
export async function resolveServerConfig(cli: CliArgs): Promise<ServerConfig> {
const envConfigPath = process.env.OPENWORK_SERVER_CONFIG;
const configPath = cli.configPath ?? envConfigPath ?? resolve(homedir(), ".config", "openwork", "server.json");
const fileConfig = await loadFileConfig(configPath);
const configDir = dirname(configPath);
const envWorkspaces = parseList(process.env.OPENWORK_WORKSPACES);
const workspaceConfigs: WorkspaceConfig[] =
cli.workspaces.length > 0
? cli.workspaces.map((path) => ({ path }))
: envWorkspaces.length > 0
? envWorkspaces.map((path) => ({ path }))
: fileConfig.workspaces ?? [];
const workspaces = buildWorkspaceInfos(workspaceConfigs, configDir);
const tokenFromEnv = process.env.OPENWORK_TOKEN;
const hostTokenFromEnv = process.env.OPENWORK_HOST_TOKEN;
const token = cli.token ?? tokenFromEnv ?? fileConfig.token ?? shortId();
const hostToken = cli.hostToken ?? hostTokenFromEnv ?? fileConfig.hostToken ?? shortId();
const tokenSource: ServerConfig["tokenSource"] = cli.token
? "cli"
: tokenFromEnv
? "env"
: fileConfig.token
? "file"
: "generated";
const hostTokenSource: ServerConfig["hostTokenSource"] = cli.hostToken
? "cli"
: hostTokenFromEnv
? "env"
: fileConfig.hostToken
? "file"
: "generated";
const approvalMode =
cli.approvalMode ??
(process.env.OPENWORK_APPROVAL_MODE as ApprovalMode | undefined) ??
fileConfig.approval?.mode ??
"manual";
const approvalTimeoutMs =
cli.approvalTimeoutMs ??
(process.env.OPENWORK_APPROVAL_TIMEOUT_MS ? Number(process.env.OPENWORK_APPROVAL_TIMEOUT_MS) : undefined) ??
fileConfig.approval?.timeoutMs ??
DEFAULT_TIMEOUT_MS;
const approval: ApprovalConfig = {
mode: approvalMode === "auto" ? "auto" : "manual",
timeoutMs: Number.isNaN(approvalTimeoutMs) ? DEFAULT_TIMEOUT_MS : approvalTimeoutMs,
};
const envCorsOrigins = process.env.OPENWORK_CORS_ORIGINS;
const parsedEnvCors = envCorsOrigins ? parseList(envCorsOrigins) : null;
const corsOrigins = cli.corsOrigins ?? parsedEnvCors ?? fileConfig.corsOrigins ?? ["*"];
const envReadOnly = process.env.OPENWORK_READONLY;
const parsedReadOnly = envReadOnly
? ["true", "1", "yes"].includes(envReadOnly.toLowerCase())
: undefined;
const readOnly = cli.readOnly ?? parsedReadOnly ?? fileConfig.readOnly ?? false;
const authorizedRoots =
fileConfig.authorizedRoots?.length
? fileConfig.authorizedRoots.map((root) => resolve(configDir, root))
: workspaces.map((workspace) => workspace.path);
const host = cli.host ?? process.env.OPENWORK_HOST ?? fileConfig.host ?? DEFAULT_HOST;
const port = cli.port ?? (process.env.OPENWORK_PORT ? Number(process.env.OPENWORK_PORT) : undefined) ?? fileConfig.port ?? DEFAULT_PORT;
return {
host,
port: Number.isNaN(port) ? DEFAULT_PORT : port,
token,
hostToken,
approval,
corsOrigins,
workspaces,
authorizedRoots,
readOnly,
startedAt: Date.now(),
tokenSource,
hostTokenSource,
};
}

View File

@@ -0,0 +1,22 @@
import type { ApiErrorBody } from "./types.js";
export class ApiError extends Error {
status: number;
code: string;
details?: unknown;
constructor(status: number, code: string, message: string, details?: unknown) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
export function formatError(err: ApiError): ApiErrorBody {
return {
code: err.code,
message: err.message,
details: err.details,
};
}

View File

@@ -0,0 +1,17 @@
import { parse, stringify } from "yaml";
export function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) {
return { data: {}, body: content };
}
const raw = match[1] ?? "";
const data = (parse(raw) as Record<string, unknown>) ?? {};
const body = content.slice(match[0].length);
return { data, body };
}
export function buildFrontmatter(data: Record<string, unknown>): string {
const yaml = stringify(data).trimEnd();
return `---\n${yaml}\n---\n`;
}

View File

@@ -0,0 +1,52 @@
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
import { dirname } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { ApiError } from "./errors.js";
import { ensureDir, exists } from "./utils.js";
interface ParseResult<T> {
data: T;
raw: string;
}
export async function readJsoncFile<T>(path: string, fallback: T): Promise<ParseResult<T>> {
if (!(await exists(path))) {
return { data: fallback, raw: "" };
}
const raw = await readFile(path, "utf8");
const errors: { error: number; offset: number; length: number }[] = [];
const data = parse(raw, errors, { allowTrailingComma: true }) as T;
if (errors.length > 0) {
const details = errors.map((error) => ({
code: printParseErrorCode(error.error),
offset: error.offset,
length: error.length,
}));
throw new ApiError(422, "invalid_jsonc", "Failed to parse JSONC", details);
}
return { data, raw };
}
export async function updateJsoncTopLevel(path: string, updates: Record<string, unknown>): Promise<void> {
const hasFile = await exists(path);
if (!hasFile) {
await ensureDir(dirname(path));
const content = JSON.stringify(updates, null, 2) + "\n";
await writeFile(path, content, "utf8");
return;
}
let content = await readFile(path, "utf8");
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
for (const [key, value] of Object.entries(updates)) {
const edits = modify(content, [key], value, { formattingOptions });
content = applyEdits(content, edits);
}
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf8");
}
export async function writeJsoncFile(path: string, value: unknown): Promise<void> {
await ensureDir(dirname(path));
const content = JSON.stringify(value, null, 2) + "\n";
await writeFile(path, content, "utf8");
}

View File

@@ -0,0 +1,53 @@
import { minimatch } from "minimatch";
import type { McpItem } from "./types.js";
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
import { opencodeConfigPath } from "./workspace-files.js";
import { validateMcpConfig, validateMcpName } from "./validators.js";
function getMcpConfig(config: Record<string, unknown>): Record<string, Record<string, unknown>> {
const mcp = config.mcp;
if (!mcp || typeof mcp !== "object") return {};
return mcp as Record<string, Record<string, unknown>>;
}
function getDeniedToolPatterns(config: Record<string, unknown>): string[] {
const tools = config.tools;
if (!tools || typeof tools !== "object") return [];
const deny = (tools as { deny?: unknown }).deny;
if (!Array.isArray(deny)) return [];
return deny.filter((item) => typeof item === "string") as string[];
}
function isMcpDisabledByTools(config: Record<string, unknown>, name: string): boolean {
const patterns = getDeniedToolPatterns(config);
if (patterns.length === 0) return false;
const candidates = [`mcp.${name}`, `mcp.${name}.*`, `mcp:${name}`, `mcp:${name}:*`, "mcp.*", "mcp:*"];
return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern)));
}
export async function listMcp(workspaceRoot: string): Promise<McpItem[]> {
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const mcpMap = getMcpConfig(config);
return Object.entries(mcpMap).map(([name, entry]) => ({
name,
config: entry,
source: "config.project",
disabledByTools: isMcpDisabledByTools(config, name) || undefined,
}));
}
export async function addMcp(workspaceRoot: string, name: string, config: Record<string, unknown>): Promise<void> {
validateMcpName(name);
validateMcpConfig(config);
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const mcpMap = getMcpConfig(data);
mcpMap[name] = config;
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
}
export async function removeMcp(workspaceRoot: string, name: string): Promise<void> {
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const mcpMap = getMcpConfig(data);
delete mcpMap[name];
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
}

View File

@@ -0,0 +1,20 @@
import { realpath } from "node:fs/promises";
import { isAbsolute, resolve, sep } from "node:path";
import { ApiError } from "./errors.js";
export function assertAbsolute(path: string): void {
if (!isAbsolute(path)) {
throw new ApiError(400, "invalid_path", "Path must be absolute");
}
}
export async function resolveWithinRoot(root: string, ...segments: string[]): Promise<string> {
const resolvedRoot = await realpath(root);
const candidate = resolve(resolvedRoot, ...segments);
const resolvedCandidate = await realpath(candidate).catch(() => candidate);
if (resolvedCandidate === resolvedRoot) return candidate;
if (!resolvedCandidate.startsWith(resolvedRoot + sep)) {
throw new ApiError(400, "path_escape", "Path escapes workspace root");
}
return candidate;
}

View File

@@ -0,0 +1,92 @@
import { homedir } from "node:os";
import { join, relative } from "node:path";
import { readdir } from "node:fs/promises";
import type { PluginItem } from "./types.js";
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
import { opencodeConfigPath, projectPluginsDir } from "./workspace-files.js";
import { exists } from "./utils.js";
import { validatePluginSpec } from "./validators.js";
function normalizePluginSpec(spec: string): string {
const trimmed = spec.trim();
if (trimmed.startsWith("file:") || trimmed.startsWith("http:") || trimmed.startsWith("https:") || trimmed.startsWith("git:")) {
return trimmed;
}
if (trimmed.startsWith("/")) {
return trimmed;
}
if (trimmed.startsWith("@")) {
const atIndex = trimmed.indexOf("@", 1);
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
}
const atIndex = trimmed.indexOf("@");
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
}
function pluginListFromConfig(config: Record<string, unknown>): string[] {
const plugin = config.plugin;
if (typeof plugin === "string") return [plugin];
if (Array.isArray(plugin)) return plugin.filter((item) => typeof item === "string") as string[];
return [];
}
async function listPluginFiles(dir: string, scope: "project" | "global", workspaceRoot?: string): Promise<PluginItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
const items: PluginItem[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".ts")) continue;
const absolutePath = join(dir, entry.name);
const relativePath = workspaceRoot ? relative(workspaceRoot, absolutePath) : absolutePath;
items.push({
spec: `file://${absolutePath}`,
source: scope === "project" ? "dir.project" : "dir.global",
scope,
path: relativePath,
});
}
return items;
}
export async function listPlugins(workspaceRoot: string, includeGlobal: boolean): Promise<{ items: PluginItem[]; loadOrder: string[] }> {
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const pluginSpecs = pluginListFromConfig(config);
const items: PluginItem[] = pluginSpecs.map((spec) => ({
spec,
source: "config",
scope: "project",
}));
const projectDir = projectPluginsDir(workspaceRoot);
items.push(...(await listPluginFiles(projectDir, "project", workspaceRoot)));
if (includeGlobal) {
const globalDir = join(homedir(), ".config", "opencode", "plugins");
items.push(...(await listPluginFiles(globalDir, "global")));
}
return {
items,
loadOrder: ["config.global", "config.project", "dir.global", "dir.project"],
};
}
export async function addPlugin(workspaceRoot: string, spec: string): Promise<void> {
validatePluginSpec(spec);
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const pluginSpecs = pluginListFromConfig(config);
const normalized = normalizePluginSpec(spec);
const existing = pluginSpecs.find((item) => normalizePluginSpec(item) === normalized);
if (existing) return;
pluginSpecs.push(spec);
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: pluginSpecs });
}
export async function removePlugin(workspaceRoot: string, name: string): Promise<void> {
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const pluginSpecs = pluginListFromConfig(config);
const normalized = normalizePluginSpec(name);
const filtered = pluginSpecs.filter((item) => normalizePluginSpec(item) !== normalized);
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: filtered });
}

View File

@@ -0,0 +1,647 @@
import { readFile, writeFile, rm } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor } from "./types.js";
import { ApprovalService } from "./approvals.js";
import { addPlugin, listPlugins, 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 { ApiError, formatError } from "./errors.js";
import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
import { recordAudit, readLastAudit } from "./audit.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";
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;
actor?: Actor;
}
export function startServer(config: ServerConfig) {
const approvals = new ApprovalService(config.approval);
const routes = createRoutes(config, approvals);
const server = Bun.serve({
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, 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);
}
},
});
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 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 createRoutes(config: ServerConfig, approvals: ApprovalService): Route[] {
const routes: Route[] = [];
addRoute(routes, "GET", "/health", "none", async () => {
return jsonResponse({ ok: true, version: "0.1.0", uptimeMs: Date.now() - config.startedAt });
});
addRoute(routes, "GET", "/capabilities", "client", async () => {
return jsonResponse(buildCapabilities(config));
});
addRoute(routes, "GET", "/workspaces", "client", async () => {
return jsonResponse({ items: config.workspaces });
});
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, "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(),
});
return jsonResponse({ updatedAt: 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 ?? "");
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "plugins.add",
summary: `Add plugin ${spec}`,
paths: [opencodeConfigPath(workspace.path)],
});
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(),
});
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 ?? "";
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "plugins.remove",
summary: `Remove plugin ${name}`,
paths: [opencodeConfigPath(workspace.path)],
});
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(),
});
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 path = 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: path,
summary: `Upserted skill ${name}`,
timestamp: Date.now(),
});
return jsonResponse({ name, 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)],
});
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(),
});
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)],
});
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(),
});
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/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(),
});
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");
}
}
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");
}
}
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,
});
}
}
}
}

View File

@@ -0,0 +1,117 @@
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
import type { SkillItem } from "./types.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { exists } from "./utils.js";
import { validateDescription, validateSkillName } from "./validators.js";
import { ApiError } from "./errors.js";
import { projectSkillsDir } from "./workspace-files.js";
async function findWorkspaceRoots(workspaceRoot: string): Promise<string[]> {
const roots: string[] = [];
let current = resolve(workspaceRoot);
while (true) {
roots.push(current);
const gitPath = join(current, ".git");
if (await exists(gitPath)) break;
const parent = resolve(current, "..");
if (parent === current) break;
current = parent;
}
return roots;
}
async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise<SkillItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
const items: SkillItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillPath = join(dir, entry.name, "SKILL.md");
if (!(await exists(skillPath))) continue;
const content = await readFile(skillPath, "utf8");
const { data } = parseFrontmatter(content);
const name = typeof data.name === "string" ? data.name : entry.name;
const description = typeof data.description === "string" ? data.description : "";
try {
validateSkillName(name);
validateDescription(description);
} catch {
continue;
}
if (name !== entry.name) continue;
items.push({
name,
description,
path: skillPath,
scope,
});
}
return items;
}
export async function listSkills(workspaceRoot: string, includeGlobal: boolean): Promise<SkillItem[]> {
const roots = await findWorkspaceRoots(workspaceRoot);
const items: SkillItem[] = [];
for (const root of roots) {
const opencodeDir = join(root, ".opencode", "skills");
const claudeDir = join(root, ".claude", "skills");
items.push(...(await listSkillsInDir(opencodeDir, "project")));
items.push(...(await listSkillsInDir(claudeDir, "project")));
}
if (includeGlobal) {
const globalOpenWork = join(homedir(), ".config", "opencode", "skills");
const globalClaude = join(homedir(), ".claude", "skills");
items.push(...(await listSkillsInDir(globalOpenWork, "global")));
items.push(...(await listSkillsInDir(globalClaude, "global")));
}
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.name)) return false;
seen.add(item.name);
return true;
});
}
export async function upsertSkill(
workspaceRoot: string,
payload: { name: string; content: string; description?: string },
): Promise<string> {
const name = payload.name.trim();
validateSkillName(name);
if (!payload.content) {
throw new ApiError(400, "invalid_skill_content", "Skill content is required");
}
let content = payload.content;
const { data, body } = parseFrontmatter(payload.content);
if (Object.keys(data).length > 0) {
const frontmatterName = typeof data.name === "string" ? data.name : "";
const frontmatterDescription = typeof data.description === "string" ? data.description : "";
if (frontmatterName && frontmatterName !== name) {
throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name");
}
validateDescription(frontmatterDescription || payload.description);
const nextDescription = frontmatterDescription || payload.description || "";
const frontmatter = buildFrontmatter({
...data,
name,
description: nextDescription,
});
content = frontmatter + body.replace(/^\n/, "");
} else {
validateDescription(payload.description);
const frontmatter = buildFrontmatter({ name, description: payload.description });
content = frontmatter + payload.content.replace(/^\n/, "");
}
const baseDir = projectSkillsDir(workspaceRoot);
const skillDir = join(baseDir, name);
await mkdir(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8");
return skillPath;
}

View File

@@ -0,0 +1,111 @@
export type WorkspaceType = "local" | "remote";
export type ApprovalMode = "manual" | "auto";
export interface WorkspaceConfig {
path: string;
name?: string;
workspaceType?: WorkspaceType;
baseUrl?: string;
directory?: string;
}
export interface WorkspaceInfo {
id: string;
name: string;
path: string;
workspaceType: WorkspaceType;
baseUrl?: string;
directory?: string;
}
export interface ApprovalConfig {
mode: ApprovalMode;
timeoutMs: number;
}
export interface ServerConfig {
host: string;
port: number;
token: string;
hostToken: string;
approval: ApprovalConfig;
corsOrigins: string[];
workspaces: WorkspaceInfo[];
authorizedRoots: string[];
readOnly: boolean;
startedAt: number;
tokenSource: "cli" | "env" | "file" | "generated";
hostTokenSource: "cli" | "env" | "file" | "generated";
}
export interface Capabilities {
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
plugins: { read: boolean; write: boolean };
mcp: { read: boolean; write: boolean };
commands: { read: boolean; write: boolean };
config: { read: boolean; write: boolean };
}
export interface ApiErrorBody {
code: string;
message: string;
details?: unknown;
}
export interface PluginItem {
spec: string;
source: "config" | "dir.project" | "dir.global";
scope: "project" | "global";
path?: string;
}
export interface McpItem {
name: string;
config: Record<string, unknown>;
source: "config.project" | "config.global" | "config.remote";
disabledByTools?: boolean;
}
export interface SkillItem {
name: string;
path: string;
description: string;
scope: "project" | "global";
}
export interface CommandItem {
name: string;
description?: string;
template: string;
agent?: string;
model?: string | null;
subtask?: boolean;
scope: "workspace" | "global";
}
export interface Actor {
type: "remote" | "host";
clientId?: string;
tokenHash?: string;
}
export interface ApprovalRequest {
id: string;
workspaceId: string;
action: string;
summary: string;
paths: string[];
createdAt: number;
actor: Actor;
}
export interface AuditEntry {
id: string;
workspaceId: string;
actor: Actor;
action: string;
target: string;
summary: string;
timestamp: number;
}

View File

@@ -0,0 +1,52 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, stat } from "node:fs/promises";
export async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
export async function ensureDir(path: string): Promise<void> {
await mkdir(path, { recursive: true });
}
export async function readJsonFile<T>(path: string): Promise<T | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function shortId(): string {
return randomUUID();
}
export function parseList(input: string | undefined): string[] {
if (!input) return [];
const trimmed = input.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item)).filter(Boolean);
}
} catch {
return [];
}
}
return trimmed
.split(/[,;]/)
.map((value) => value.trim())
.filter(Boolean);
}

View File

@@ -0,0 +1,59 @@
import { ApiError } from "./errors.js";
const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const COMMAND_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
const MCP_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
export function validateSkillName(name: string): void {
if (!name || name.length < 1 || name.length > 64 || !SKILL_NAME_REGEX.test(name)) {
throw new ApiError(400, "invalid_skill_name", "Skill name must be kebab-case (1-64 chars)");
}
}
export function validateDescription(description: string | undefined): void {
if (!description || description.length < 1 || description.length > 1024) {
throw new ApiError(422, "invalid_description", "Description must be 1-1024 characters");
}
}
export function validatePluginSpec(spec: string): void {
if (!spec || spec.trim().length === 0) {
throw new ApiError(400, "invalid_plugin_spec", "Plugin spec is required");
}
}
export function sanitizeCommandName(name: string): string {
const trimmed = name.trim().replace(/^\/+/, "");
return trimmed;
}
export function validateCommandName(name: string): void {
if (!name || !COMMAND_NAME_REGEX.test(name)) {
throw new ApiError(400, "invalid_command_name", "Command name must be alphanumeric with _ or -");
}
}
export function validateMcpName(name: string): void {
if (!name || name.startsWith("-") || !MCP_NAME_REGEX.test(name)) {
throw new ApiError(400, "invalid_mcp_name", "MCP name must be alphanumeric and not start with -");
}
}
export function validateMcpConfig(config: Record<string, unknown>): void {
const type = config.type;
if (type !== "local" && type !== "remote") {
throw new ApiError(400, "invalid_mcp_config", "MCP config type must be local or remote");
}
if (type === "local") {
const command = config.command;
if (!Array.isArray(command) || command.length === 0) {
throw new ApiError(400, "invalid_mcp_config", "Local MCP requires command array");
}
}
if (type === "remote") {
const url = config.url;
if (!url || typeof url !== "string") {
throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires url");
}
}
}

View File

@@ -0,0 +1,21 @@
import { join } from "node:path";
export function opencodeConfigPath(workspaceRoot: string): string {
return join(workspaceRoot, "opencode.json");
}
export function openworkConfigPath(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "openwork.json");
}
export function projectSkillsDir(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "skills");
}
export function projectCommandsDir(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "commands");
}
export function projectPluginsDir(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "plugins");
}

View File

@@ -0,0 +1,25 @@
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import type { WorkspaceConfig, WorkspaceInfo } from "./types.js";
export function workspaceIdForPath(path: string): string {
const hash = createHash("sha256").update(path).digest("hex");
return `ws_${hash.slice(0, 12)}`;
}
export function buildWorkspaceInfos(
workspaces: WorkspaceConfig[],
cwd: string,
): WorkspaceInfo[] {
return workspaces.map((workspace) => {
const resolvedPath = resolve(cwd, workspace.path);
return {
id: workspaceIdForPath(resolvedPath),
name: workspace.name ?? basename(resolvedPath),
path: resolvedPath,
workspaceType: workspace.workspaceType ?? "local",
baseUrl: workspace.baseUrl,
directory: workspace.directory,
};
});
}