openwork-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/dist/approvals.js +45 -0
- package/dist/audit.js +47 -0
- package/dist/cli.js +24 -0
- package/dist/commands.js +73 -0
- package/dist/config.js +210 -0
- package/dist/errors.js +18 -0
- package/dist/frontmatter.js +15 -0
- package/dist/jsonc.js +43 -0
- package/dist/mcp.js +50 -0
- package/dist/paths.js +19 -0
- package/dist/plugins.js +88 -0
- package/dist/server.js +602 -0
- package/dist/skills.js +143 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +51 -0
- package/dist/validators.js +51 -0
- package/dist/workspace-files.js +23 -0
- package/dist/workspaces.js +21 -0
- package/package.json +53 -0
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
|
|
5
|
+
import { opencodeConfigPath, projectPluginsDir } from "./workspace-files.js";
|
|
6
|
+
import { exists } from "./utils.js";
|
|
7
|
+
import { validatePluginSpec } from "./validators.js";
|
|
8
|
+
function normalizePluginSpec(spec) {
|
|
9
|
+
const trimmed = spec.trim();
|
|
10
|
+
if (trimmed.startsWith("file:") || trimmed.startsWith("http:") || trimmed.startsWith("https:") || trimmed.startsWith("git:")) {
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|
|
13
|
+
if (trimmed.startsWith("/")) {
|
|
14
|
+
return trimmed;
|
|
15
|
+
}
|
|
16
|
+
if (trimmed.startsWith("@")) {
|
|
17
|
+
const atIndex = trimmed.indexOf("@", 1);
|
|
18
|
+
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
|
|
19
|
+
}
|
|
20
|
+
const atIndex = trimmed.indexOf("@");
|
|
21
|
+
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
|
|
22
|
+
}
|
|
23
|
+
function pluginListFromConfig(config) {
|
|
24
|
+
const plugin = config.plugin;
|
|
25
|
+
if (typeof plugin === "string")
|
|
26
|
+
return [plugin];
|
|
27
|
+
if (Array.isArray(plugin))
|
|
28
|
+
return plugin.filter((item) => typeof item === "string");
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
async function listPluginFiles(dir, scope, workspaceRoot) {
|
|
32
|
+
if (!(await exists(dir)))
|
|
33
|
+
return [];
|
|
34
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
35
|
+
const items = [];
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (!entry.isFile())
|
|
38
|
+
continue;
|
|
39
|
+
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".ts"))
|
|
40
|
+
continue;
|
|
41
|
+
const absolutePath = join(dir, entry.name);
|
|
42
|
+
const relativePath = workspaceRoot ? relative(workspaceRoot, absolutePath) : absolutePath;
|
|
43
|
+
items.push({
|
|
44
|
+
spec: `file://${absolutePath}`,
|
|
45
|
+
source: scope === "project" ? "dir.project" : "dir.global",
|
|
46
|
+
scope,
|
|
47
|
+
path: relativePath,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return items;
|
|
51
|
+
}
|
|
52
|
+
export async function listPlugins(workspaceRoot, includeGlobal) {
|
|
53
|
+
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {});
|
|
54
|
+
const pluginSpecs = pluginListFromConfig(config);
|
|
55
|
+
const items = pluginSpecs.map((spec) => ({
|
|
56
|
+
spec,
|
|
57
|
+
source: "config",
|
|
58
|
+
scope: "project",
|
|
59
|
+
}));
|
|
60
|
+
const projectDir = projectPluginsDir(workspaceRoot);
|
|
61
|
+
items.push(...(await listPluginFiles(projectDir, "project", workspaceRoot)));
|
|
62
|
+
if (includeGlobal) {
|
|
63
|
+
const globalDir = join(homedir(), ".config", "opencode", "plugins");
|
|
64
|
+
items.push(...(await listPluginFiles(globalDir, "global")));
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
items,
|
|
68
|
+
loadOrder: ["config.global", "config.project", "dir.global", "dir.project"],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export async function addPlugin(workspaceRoot, spec) {
|
|
72
|
+
validatePluginSpec(spec);
|
|
73
|
+
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {});
|
|
74
|
+
const pluginSpecs = pluginListFromConfig(config);
|
|
75
|
+
const normalized = normalizePluginSpec(spec);
|
|
76
|
+
const existing = pluginSpecs.find((item) => normalizePluginSpec(item) === normalized);
|
|
77
|
+
if (existing)
|
|
78
|
+
return;
|
|
79
|
+
pluginSpecs.push(spec);
|
|
80
|
+
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: pluginSpecs });
|
|
81
|
+
}
|
|
82
|
+
export async function removePlugin(workspaceRoot, name) {
|
|
83
|
+
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {});
|
|
84
|
+
const pluginSpecs = pluginListFromConfig(config);
|
|
85
|
+
const normalized = normalizePluginSpec(name);
|
|
86
|
+
const filtered = pluginSpecs.filter((item) => normalizePluginSpec(item) !== normalized);
|
|
87
|
+
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: filtered });
|
|
88
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { readFile, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { ApprovalService } from "./approvals.js";
|
|
4
|
+
import { addPlugin, listPlugins, removePlugin } from "./plugins.js";
|
|
5
|
+
import { addMcp, listMcp, removeMcp } from "./mcp.js";
|
|
6
|
+
import { listSkills, upsertSkill } from "./skills.js";
|
|
7
|
+
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
|
|
8
|
+
import { ApiError, formatError } from "./errors.js";
|
|
9
|
+
import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
|
|
10
|
+
import { recordAudit, readAuditEntries, readLastAudit } from "./audit.js";
|
|
11
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
12
|
+
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
|
|
13
|
+
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
|
|
14
|
+
import { sanitizeCommandName } from "./validators.js";
|
|
15
|
+
export function startServer(config) {
|
|
16
|
+
const approvals = new ApprovalService(config.approval);
|
|
17
|
+
const routes = createRoutes(config, approvals);
|
|
18
|
+
const server = Bun.serve({
|
|
19
|
+
hostname: config.host,
|
|
20
|
+
port: config.port,
|
|
21
|
+
fetch: async (request) => {
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
if (request.method === "OPTIONS") {
|
|
24
|
+
return withCors(new Response(null, { status: 204 }), request, config);
|
|
25
|
+
}
|
|
26
|
+
const route = matchRoute(routes, request.method, url.pathname);
|
|
27
|
+
if (!route) {
|
|
28
|
+
return withCors(jsonResponse({ code: "not_found", message: "Not found" }, 404), request, config);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const actor = route.auth === "host" ? requireHost(request, config) : route.auth === "client" ? requireClient(request, config) : undefined;
|
|
32
|
+
const response = await route.handler({ request, url, params: route.params, config, approvals, actor });
|
|
33
|
+
return withCors(response, request, config);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const apiError = error instanceof ApiError
|
|
37
|
+
? error
|
|
38
|
+
: new ApiError(500, "internal_error", "Unexpected server error");
|
|
39
|
+
return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return server;
|
|
44
|
+
}
|
|
45
|
+
function matchRoute(routes, method, path) {
|
|
46
|
+
for (const route of routes) {
|
|
47
|
+
if (route.method !== method)
|
|
48
|
+
continue;
|
|
49
|
+
const match = path.match(route.regex);
|
|
50
|
+
if (!match)
|
|
51
|
+
continue;
|
|
52
|
+
const params = {};
|
|
53
|
+
route.keys.forEach((key, index) => {
|
|
54
|
+
params[key] = decodeURIComponent(match[index + 1]);
|
|
55
|
+
});
|
|
56
|
+
return { ...route, params };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function addRoute(routes, method, path, auth, handler) {
|
|
61
|
+
const keys = [];
|
|
62
|
+
const regex = pathToRegex(path, keys);
|
|
63
|
+
routes.push({ method, regex, keys, auth, handler });
|
|
64
|
+
}
|
|
65
|
+
function pathToRegex(path, keys) {
|
|
66
|
+
const pattern = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
|
|
67
|
+
keys.push(key);
|
|
68
|
+
return "([^/]+)";
|
|
69
|
+
});
|
|
70
|
+
return new RegExp(`^${pattern}$`);
|
|
71
|
+
}
|
|
72
|
+
function jsonResponse(data, status = 200) {
|
|
73
|
+
return new Response(JSON.stringify(data), {
|
|
74
|
+
status,
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function withCors(response, request, config) {
|
|
79
|
+
const origin = request.headers.get("origin");
|
|
80
|
+
const allowedOrigins = config.corsOrigins;
|
|
81
|
+
let allowOrigin = null;
|
|
82
|
+
if (allowedOrigins.includes("*")) {
|
|
83
|
+
allowOrigin = "*";
|
|
84
|
+
}
|
|
85
|
+
else if (origin && allowedOrigins.includes(origin)) {
|
|
86
|
+
allowOrigin = origin;
|
|
87
|
+
}
|
|
88
|
+
if (!allowOrigin)
|
|
89
|
+
return response;
|
|
90
|
+
const headers = new Headers(response.headers);
|
|
91
|
+
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
92
|
+
headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id");
|
|
93
|
+
headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
|
94
|
+
headers.set("Vary", "Origin");
|
|
95
|
+
return new Response(response.body, { status: response.status, headers });
|
|
96
|
+
}
|
|
97
|
+
function requireClient(request, config) {
|
|
98
|
+
const header = request.headers.get("authorization") ?? "";
|
|
99
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
100
|
+
const token = match?.[1];
|
|
101
|
+
if (!token || token !== config.token) {
|
|
102
|
+
throw new ApiError(401, "unauthorized", "Invalid bearer token");
|
|
103
|
+
}
|
|
104
|
+
const clientId = request.headers.get("x-openwork-client-id") ?? undefined;
|
|
105
|
+
return { type: "remote", clientId, tokenHash: hashToken(token) };
|
|
106
|
+
}
|
|
107
|
+
function requireHost(request, config) {
|
|
108
|
+
const token = request.headers.get("x-openwork-host-token");
|
|
109
|
+
if (!token || token !== config.hostToken) {
|
|
110
|
+
throw new ApiError(401, "unauthorized", "Invalid host token");
|
|
111
|
+
}
|
|
112
|
+
return { type: "host", tokenHash: hashToken(token) };
|
|
113
|
+
}
|
|
114
|
+
function buildCapabilities(config) {
|
|
115
|
+
const writeEnabled = !config.readOnly;
|
|
116
|
+
return {
|
|
117
|
+
skills: { read: true, write: writeEnabled, source: "openwork" },
|
|
118
|
+
plugins: { read: true, write: writeEnabled },
|
|
119
|
+
mcp: { read: true, write: writeEnabled },
|
|
120
|
+
commands: { read: true, write: writeEnabled },
|
|
121
|
+
config: { read: true, write: writeEnabled },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function serializeWorkspace(workspace) {
|
|
125
|
+
const { opencodeUsername, opencodePassword, ...rest } = workspace;
|
|
126
|
+
const opencode = workspace.baseUrl || workspace.directory || opencodeUsername || opencodePassword
|
|
127
|
+
? {
|
|
128
|
+
baseUrl: workspace.baseUrl,
|
|
129
|
+
directory: workspace.directory,
|
|
130
|
+
username: opencodeUsername,
|
|
131
|
+
password: opencodePassword,
|
|
132
|
+
}
|
|
133
|
+
: undefined;
|
|
134
|
+
return {
|
|
135
|
+
...rest,
|
|
136
|
+
opencode,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function createRoutes(config, approvals) {
|
|
140
|
+
const routes = [];
|
|
141
|
+
addRoute(routes, "GET", "/health", "none", async () => {
|
|
142
|
+
return jsonResponse({ ok: true, version: "0.1.0", uptimeMs: Date.now() - config.startedAt });
|
|
143
|
+
});
|
|
144
|
+
addRoute(routes, "GET", "/capabilities", "client", async () => {
|
|
145
|
+
return jsonResponse(buildCapabilities(config));
|
|
146
|
+
});
|
|
147
|
+
addRoute(routes, "GET", "/workspaces", "client", async () => {
|
|
148
|
+
const active = config.workspaces[0];
|
|
149
|
+
const items = active ? [serializeWorkspace(active)] : [];
|
|
150
|
+
return jsonResponse({ items });
|
|
151
|
+
});
|
|
152
|
+
addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => {
|
|
153
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
154
|
+
const opencode = await readOpencodeConfig(workspace.path);
|
|
155
|
+
const openwork = await readOpenworkConfig(workspace.path);
|
|
156
|
+
const lastAudit = await readLastAudit(workspace.path);
|
|
157
|
+
return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
|
|
158
|
+
});
|
|
159
|
+
addRoute(routes, "GET", "/workspace/:id/audit", "client", async (ctx) => {
|
|
160
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
161
|
+
const limitParam = ctx.url.searchParams.get("limit");
|
|
162
|
+
const parsed = limitParam ? Number(limitParam) : NaN;
|
|
163
|
+
const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
|
|
164
|
+
const items = await readAuditEntries(workspace.path, limit);
|
|
165
|
+
return jsonResponse({ items });
|
|
166
|
+
});
|
|
167
|
+
addRoute(routes, "PATCH", "/workspace/:id/config", "client", async (ctx) => {
|
|
168
|
+
ensureWritable(config);
|
|
169
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
170
|
+
const body = await readJsonBody(ctx.request);
|
|
171
|
+
const opencode = body.opencode;
|
|
172
|
+
const openwork = body.openwork;
|
|
173
|
+
if (!opencode && !openwork) {
|
|
174
|
+
throw new ApiError(400, "invalid_payload", "opencode or openwork updates required");
|
|
175
|
+
}
|
|
176
|
+
await requireApproval(ctx, {
|
|
177
|
+
workspaceId: workspace.id,
|
|
178
|
+
action: "config.patch",
|
|
179
|
+
summary: "Patch workspace config",
|
|
180
|
+
paths: [opencode ? opencodeConfigPath(workspace.path) : null, openwork ? openworkConfigPath(workspace.path) : null].filter(Boolean),
|
|
181
|
+
});
|
|
182
|
+
if (opencode) {
|
|
183
|
+
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
|
|
184
|
+
}
|
|
185
|
+
if (openwork) {
|
|
186
|
+
await writeOpenworkConfig(workspace.path, openwork, true);
|
|
187
|
+
}
|
|
188
|
+
await recordAudit(workspace.path, {
|
|
189
|
+
id: shortId(),
|
|
190
|
+
workspaceId: workspace.id,
|
|
191
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
192
|
+
action: "config.patch",
|
|
193
|
+
target: "opencode.json",
|
|
194
|
+
summary: "Patched workspace config",
|
|
195
|
+
timestamp: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
return jsonResponse({ updatedAt: Date.now() });
|
|
198
|
+
});
|
|
199
|
+
addRoute(routes, "GET", "/workspace/:id/plugins", "client", async (ctx) => {
|
|
200
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
201
|
+
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
|
|
202
|
+
const result = await listPlugins(workspace.path, includeGlobal);
|
|
203
|
+
return jsonResponse(result);
|
|
204
|
+
});
|
|
205
|
+
addRoute(routes, "POST", "/workspace/:id/plugins", "client", async (ctx) => {
|
|
206
|
+
ensureWritable(config);
|
|
207
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
208
|
+
const body = await readJsonBody(ctx.request);
|
|
209
|
+
const spec = String(body.spec ?? "");
|
|
210
|
+
await requireApproval(ctx, {
|
|
211
|
+
workspaceId: workspace.id,
|
|
212
|
+
action: "plugins.add",
|
|
213
|
+
summary: `Add plugin ${spec}`,
|
|
214
|
+
paths: [opencodeConfigPath(workspace.path)],
|
|
215
|
+
});
|
|
216
|
+
await addPlugin(workspace.path, spec);
|
|
217
|
+
await recordAudit(workspace.path, {
|
|
218
|
+
id: shortId(),
|
|
219
|
+
workspaceId: workspace.id,
|
|
220
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
221
|
+
action: "plugins.add",
|
|
222
|
+
target: "opencode.json",
|
|
223
|
+
summary: `Added ${spec}`,
|
|
224
|
+
timestamp: Date.now(),
|
|
225
|
+
});
|
|
226
|
+
const result = await listPlugins(workspace.path, false);
|
|
227
|
+
return jsonResponse(result);
|
|
228
|
+
});
|
|
229
|
+
addRoute(routes, "DELETE", "/workspace/:id/plugins/:name", "client", async (ctx) => {
|
|
230
|
+
ensureWritable(config);
|
|
231
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
232
|
+
const name = ctx.params.name ?? "";
|
|
233
|
+
await requireApproval(ctx, {
|
|
234
|
+
workspaceId: workspace.id,
|
|
235
|
+
action: "plugins.remove",
|
|
236
|
+
summary: `Remove plugin ${name}`,
|
|
237
|
+
paths: [opencodeConfigPath(workspace.path)],
|
|
238
|
+
});
|
|
239
|
+
await removePlugin(workspace.path, name);
|
|
240
|
+
await recordAudit(workspace.path, {
|
|
241
|
+
id: shortId(),
|
|
242
|
+
workspaceId: workspace.id,
|
|
243
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
244
|
+
action: "plugins.remove",
|
|
245
|
+
target: "opencode.json",
|
|
246
|
+
summary: `Removed ${name}`,
|
|
247
|
+
timestamp: Date.now(),
|
|
248
|
+
});
|
|
249
|
+
const result = await listPlugins(workspace.path, false);
|
|
250
|
+
return jsonResponse(result);
|
|
251
|
+
});
|
|
252
|
+
addRoute(routes, "GET", "/workspace/:id/skills", "client", async (ctx) => {
|
|
253
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
254
|
+
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
|
|
255
|
+
const items = await listSkills(workspace.path, includeGlobal);
|
|
256
|
+
return jsonResponse({ items });
|
|
257
|
+
});
|
|
258
|
+
addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => {
|
|
259
|
+
ensureWritable(config);
|
|
260
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
261
|
+
const body = await readJsonBody(ctx.request);
|
|
262
|
+
const name = String(body.name ?? "");
|
|
263
|
+
const content = String(body.content ?? "");
|
|
264
|
+
const description = body.description ? String(body.description) : undefined;
|
|
265
|
+
await requireApproval(ctx, {
|
|
266
|
+
workspaceId: workspace.id,
|
|
267
|
+
action: "skills.upsert",
|
|
268
|
+
summary: `Upsert skill ${name}`,
|
|
269
|
+
paths: [join(workspace.path, ".opencode", "skills", name, "SKILL.md")],
|
|
270
|
+
});
|
|
271
|
+
const path = await upsertSkill(workspace.path, { name, content, description });
|
|
272
|
+
await recordAudit(workspace.path, {
|
|
273
|
+
id: shortId(),
|
|
274
|
+
workspaceId: workspace.id,
|
|
275
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
276
|
+
action: "skills.upsert",
|
|
277
|
+
target: path,
|
|
278
|
+
summary: `Upserted skill ${name}`,
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
});
|
|
281
|
+
return jsonResponse({ name, path, description: description ?? "", scope: "project" });
|
|
282
|
+
});
|
|
283
|
+
addRoute(routes, "GET", "/workspace/:id/mcp", "client", async (ctx) => {
|
|
284
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
285
|
+
const items = await listMcp(workspace.path);
|
|
286
|
+
return jsonResponse({ items });
|
|
287
|
+
});
|
|
288
|
+
addRoute(routes, "POST", "/workspace/:id/mcp", "client", async (ctx) => {
|
|
289
|
+
ensureWritable(config);
|
|
290
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
291
|
+
const body = await readJsonBody(ctx.request);
|
|
292
|
+
const name = String(body.name ?? "");
|
|
293
|
+
const configPayload = body.config;
|
|
294
|
+
if (!configPayload) {
|
|
295
|
+
throw new ApiError(400, "invalid_payload", "MCP config is required");
|
|
296
|
+
}
|
|
297
|
+
await requireApproval(ctx, {
|
|
298
|
+
workspaceId: workspace.id,
|
|
299
|
+
action: "mcp.add",
|
|
300
|
+
summary: `Add MCP ${name}`,
|
|
301
|
+
paths: [opencodeConfigPath(workspace.path)],
|
|
302
|
+
});
|
|
303
|
+
await addMcp(workspace.path, name, configPayload);
|
|
304
|
+
await recordAudit(workspace.path, {
|
|
305
|
+
id: shortId(),
|
|
306
|
+
workspaceId: workspace.id,
|
|
307
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
308
|
+
action: "mcp.add",
|
|
309
|
+
target: "opencode.json",
|
|
310
|
+
summary: `Added MCP ${name}`,
|
|
311
|
+
timestamp: Date.now(),
|
|
312
|
+
});
|
|
313
|
+
const items = await listMcp(workspace.path);
|
|
314
|
+
return jsonResponse({ items });
|
|
315
|
+
});
|
|
316
|
+
addRoute(routes, "DELETE", "/workspace/:id/mcp/:name", "client", async (ctx) => {
|
|
317
|
+
ensureWritable(config);
|
|
318
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
319
|
+
const name = ctx.params.name ?? "";
|
|
320
|
+
await requireApproval(ctx, {
|
|
321
|
+
workspaceId: workspace.id,
|
|
322
|
+
action: "mcp.remove",
|
|
323
|
+
summary: `Remove MCP ${name}`,
|
|
324
|
+
paths: [opencodeConfigPath(workspace.path)],
|
|
325
|
+
});
|
|
326
|
+
await removeMcp(workspace.path, name);
|
|
327
|
+
await recordAudit(workspace.path, {
|
|
328
|
+
id: shortId(),
|
|
329
|
+
workspaceId: workspace.id,
|
|
330
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
331
|
+
action: "mcp.remove",
|
|
332
|
+
target: "opencode.json",
|
|
333
|
+
summary: `Removed MCP ${name}`,
|
|
334
|
+
timestamp: Date.now(),
|
|
335
|
+
});
|
|
336
|
+
const items = await listMcp(workspace.path);
|
|
337
|
+
return jsonResponse({ items });
|
|
338
|
+
});
|
|
339
|
+
addRoute(routes, "GET", "/workspace/:id/commands", "client", async (ctx) => {
|
|
340
|
+
const scope = ctx.url.searchParams.get("scope") === "global" ? "global" : "workspace";
|
|
341
|
+
if (scope === "global") {
|
|
342
|
+
requireHost(ctx.request, config);
|
|
343
|
+
}
|
|
344
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
345
|
+
const items = await listCommands(workspace.path, scope);
|
|
346
|
+
return jsonResponse({ items });
|
|
347
|
+
});
|
|
348
|
+
addRoute(routes, "POST", "/workspace/:id/commands", "client", async (ctx) => {
|
|
349
|
+
ensureWritable(config);
|
|
350
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
351
|
+
const body = await readJsonBody(ctx.request);
|
|
352
|
+
const name = String(body.name ?? "");
|
|
353
|
+
const template = String(body.template ?? "");
|
|
354
|
+
await requireApproval(ctx, {
|
|
355
|
+
workspaceId: workspace.id,
|
|
356
|
+
action: "commands.upsert",
|
|
357
|
+
summary: `Upsert command ${name}`,
|
|
358
|
+
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
|
|
359
|
+
});
|
|
360
|
+
const path = await upsertCommand(workspace.path, {
|
|
361
|
+
name,
|
|
362
|
+
description: body.description ? String(body.description) : undefined,
|
|
363
|
+
template,
|
|
364
|
+
agent: body.agent ? String(body.agent) : undefined,
|
|
365
|
+
model: body.model ? String(body.model) : undefined,
|
|
366
|
+
subtask: typeof body.subtask === "boolean" ? body.subtask : undefined,
|
|
367
|
+
});
|
|
368
|
+
await recordAudit(workspace.path, {
|
|
369
|
+
id: shortId(),
|
|
370
|
+
workspaceId: workspace.id,
|
|
371
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
372
|
+
action: "commands.upsert",
|
|
373
|
+
target: path,
|
|
374
|
+
summary: `Upserted command ${name}`,
|
|
375
|
+
timestamp: Date.now(),
|
|
376
|
+
});
|
|
377
|
+
const items = await listCommands(workspace.path, "workspace");
|
|
378
|
+
return jsonResponse({ items });
|
|
379
|
+
});
|
|
380
|
+
addRoute(routes, "DELETE", "/workspace/:id/commands/:name", "client", async (ctx) => {
|
|
381
|
+
ensureWritable(config);
|
|
382
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
383
|
+
const name = ctx.params.name ?? "";
|
|
384
|
+
await requireApproval(ctx, {
|
|
385
|
+
workspaceId: workspace.id,
|
|
386
|
+
action: "commands.delete",
|
|
387
|
+
summary: `Delete command ${name}`,
|
|
388
|
+
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
|
|
389
|
+
});
|
|
390
|
+
await deleteCommand(workspace.path, name);
|
|
391
|
+
await recordAudit(workspace.path, {
|
|
392
|
+
id: shortId(),
|
|
393
|
+
workspaceId: workspace.id,
|
|
394
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
395
|
+
action: "commands.delete",
|
|
396
|
+
target: join(workspace.path, ".opencode", "commands"),
|
|
397
|
+
summary: `Deleted command ${name}`,
|
|
398
|
+
timestamp: Date.now(),
|
|
399
|
+
});
|
|
400
|
+
return jsonResponse({ ok: true });
|
|
401
|
+
});
|
|
402
|
+
addRoute(routes, "GET", "/workspace/:id/export", "client", async (ctx) => {
|
|
403
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
404
|
+
const exportPayload = await exportWorkspace(workspace);
|
|
405
|
+
return jsonResponse(exportPayload);
|
|
406
|
+
});
|
|
407
|
+
addRoute(routes, "POST", "/workspace/:id/import", "client", async (ctx) => {
|
|
408
|
+
ensureWritable(config);
|
|
409
|
+
const workspace = await resolveWorkspace(config, ctx.params.id);
|
|
410
|
+
const body = await readJsonBody(ctx.request);
|
|
411
|
+
await requireApproval(ctx, {
|
|
412
|
+
workspaceId: workspace.id,
|
|
413
|
+
action: "config.import",
|
|
414
|
+
summary: "Import workspace config",
|
|
415
|
+
paths: [opencodeConfigPath(workspace.path), openworkConfigPath(workspace.path)],
|
|
416
|
+
});
|
|
417
|
+
await importWorkspace(workspace, body);
|
|
418
|
+
await recordAudit(workspace.path, {
|
|
419
|
+
id: shortId(),
|
|
420
|
+
workspaceId: workspace.id,
|
|
421
|
+
actor: ctx.actor ?? { type: "remote" },
|
|
422
|
+
action: "config.import",
|
|
423
|
+
target: "workspace",
|
|
424
|
+
summary: "Imported workspace config",
|
|
425
|
+
timestamp: Date.now(),
|
|
426
|
+
});
|
|
427
|
+
return jsonResponse({ ok: true });
|
|
428
|
+
});
|
|
429
|
+
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
|
|
430
|
+
return jsonResponse({ items: ctx.approvals.list() });
|
|
431
|
+
});
|
|
432
|
+
addRoute(routes, "POST", "/approvals/:id", "host", async (ctx) => {
|
|
433
|
+
const body = await readJsonBody(ctx.request);
|
|
434
|
+
const reply = body.reply === "allow" ? "allow" : "deny";
|
|
435
|
+
const result = ctx.approvals.respond(ctx.params.id, reply);
|
|
436
|
+
if (!result) {
|
|
437
|
+
throw new ApiError(404, "approval_not_found", "Approval request not found");
|
|
438
|
+
}
|
|
439
|
+
return jsonResponse({ ok: true, allowed: result.allowed });
|
|
440
|
+
});
|
|
441
|
+
return routes;
|
|
442
|
+
}
|
|
443
|
+
async function resolveWorkspace(config, id) {
|
|
444
|
+
const workspace = config.workspaces.find((entry) => entry.id === id);
|
|
445
|
+
if (!workspace) {
|
|
446
|
+
throw new ApiError(404, "workspace_not_found", "Workspace not found");
|
|
447
|
+
}
|
|
448
|
+
const resolvedWorkspace = resolve(workspace.path);
|
|
449
|
+
const authorized = await isAuthorizedRoot(resolvedWorkspace, config.authorizedRoots);
|
|
450
|
+
if (!authorized) {
|
|
451
|
+
throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
|
|
452
|
+
}
|
|
453
|
+
return { ...workspace, path: resolvedWorkspace };
|
|
454
|
+
}
|
|
455
|
+
async function isAuthorizedRoot(workspacePath, roots) {
|
|
456
|
+
const resolvedWorkspace = resolve(workspacePath);
|
|
457
|
+
for (const root of roots) {
|
|
458
|
+
const resolvedRoot = resolve(root);
|
|
459
|
+
if (resolvedWorkspace === resolvedRoot)
|
|
460
|
+
return true;
|
|
461
|
+
if (resolvedWorkspace.startsWith(resolvedRoot + sep))
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
function ensureWritable(config) {
|
|
467
|
+
if (config.readOnly) {
|
|
468
|
+
throw new ApiError(403, "read_only", "Server is read-only");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function readJsonBody(request) {
|
|
472
|
+
try {
|
|
473
|
+
const json = await request.json();
|
|
474
|
+
return json;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
throw new ApiError(400, "invalid_json", "Invalid JSON body");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async function readOpencodeConfig(workspaceRoot) {
|
|
481
|
+
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {});
|
|
482
|
+
return data;
|
|
483
|
+
}
|
|
484
|
+
async function readOpenworkConfig(workspaceRoot) {
|
|
485
|
+
const path = openworkConfigPath(workspaceRoot);
|
|
486
|
+
if (!(await exists(path)))
|
|
487
|
+
return {};
|
|
488
|
+
try {
|
|
489
|
+
const raw = await readFile(path, "utf8");
|
|
490
|
+
return JSON.parse(raw);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
throw new ApiError(422, "invalid_json", "Failed to parse openwork.json");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function writeOpenworkConfig(workspaceRoot, payload, merge) {
|
|
497
|
+
const path = openworkConfigPath(workspaceRoot);
|
|
498
|
+
const next = merge ? { ...(await readOpenworkConfig(workspaceRoot)), ...payload } : payload;
|
|
499
|
+
await ensureDir(join(workspaceRoot, ".opencode"));
|
|
500
|
+
await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
501
|
+
}
|
|
502
|
+
async function requireApproval(ctx, input) {
|
|
503
|
+
const actor = ctx.actor ?? { type: "remote" };
|
|
504
|
+
const result = await ctx.approvals.requestApproval({ ...input, actor });
|
|
505
|
+
if (!result.allowed) {
|
|
506
|
+
throw new ApiError(403, "write_denied", "Write request denied", {
|
|
507
|
+
requestId: result.id,
|
|
508
|
+
reason: result.reason,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function exportWorkspace(workspace) {
|
|
513
|
+
const opencode = await readOpencodeConfig(workspace.path);
|
|
514
|
+
const openwork = await readOpenworkConfig(workspace.path);
|
|
515
|
+
const skills = await listSkills(workspace.path, false);
|
|
516
|
+
const commands = await listCommands(workspace.path, "workspace");
|
|
517
|
+
const skillContents = await Promise.all(skills.map(async (skill) => ({
|
|
518
|
+
name: skill.name,
|
|
519
|
+
description: skill.description,
|
|
520
|
+
content: await readFile(skill.path, "utf8"),
|
|
521
|
+
})));
|
|
522
|
+
const commandContents = await Promise.all(commands.map(async (command) => ({
|
|
523
|
+
name: command.name,
|
|
524
|
+
description: command.description,
|
|
525
|
+
template: command.template,
|
|
526
|
+
})));
|
|
527
|
+
return {
|
|
528
|
+
workspaceId: workspace.id,
|
|
529
|
+
exportedAt: Date.now(),
|
|
530
|
+
opencode,
|
|
531
|
+
openwork,
|
|
532
|
+
skills: skillContents,
|
|
533
|
+
commands: commandContents,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
async function importWorkspace(workspace, payload) {
|
|
537
|
+
const modes = payload.mode ?? {};
|
|
538
|
+
const opencode = payload.opencode;
|
|
539
|
+
const openwork = payload.openwork;
|
|
540
|
+
const skills = payload.skills ?? [];
|
|
541
|
+
const commands = payload.commands ?? [];
|
|
542
|
+
if (opencode) {
|
|
543
|
+
if (modes.opencode === "replace") {
|
|
544
|
+
await writeJsoncFile(opencodeConfigPath(workspace.path), opencode);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (openwork) {
|
|
551
|
+
if (modes.openwork === "replace") {
|
|
552
|
+
await writeOpenworkConfig(workspace.path, openwork, false);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
await writeOpenworkConfig(workspace.path, openwork, true);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (skills.length > 0) {
|
|
559
|
+
if (modes.skills === "replace") {
|
|
560
|
+
await rm(projectSkillsDir(workspace.path), { recursive: true, force: true });
|
|
561
|
+
}
|
|
562
|
+
for (const skill of skills) {
|
|
563
|
+
await upsertSkill(workspace.path, skill);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (commands.length > 0) {
|
|
567
|
+
if (modes.commands === "replace") {
|
|
568
|
+
await rm(projectCommandsDir(workspace.path), { recursive: true, force: true });
|
|
569
|
+
}
|
|
570
|
+
for (const command of commands) {
|
|
571
|
+
if (command.content) {
|
|
572
|
+
const parsed = parseFrontmatter(command.content);
|
|
573
|
+
const name = command.name || (typeof parsed.data.name === "string" ? parsed.data.name : "");
|
|
574
|
+
const description = command.description || (typeof parsed.data.description === "string" ? parsed.data.description : undefined);
|
|
575
|
+
if (!name) {
|
|
576
|
+
throw new ApiError(400, "invalid_command", "Command name is required");
|
|
577
|
+
}
|
|
578
|
+
const template = parsed.body.trim();
|
|
579
|
+
await upsertCommand(workspace.path, {
|
|
580
|
+
name,
|
|
581
|
+
description,
|
|
582
|
+
template,
|
|
583
|
+
agent: typeof parsed.data.agent === "string" ? parsed.data.agent : undefined,
|
|
584
|
+
model: typeof parsed.data.model === "string" ? parsed.data.model : undefined,
|
|
585
|
+
subtask: typeof parsed.data.subtask === "boolean" ? parsed.data.subtask : undefined,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
const name = command.name ?? "";
|
|
590
|
+
const template = command.template ?? "";
|
|
591
|
+
await upsertCommand(workspace.path, {
|
|
592
|
+
name,
|
|
593
|
+
description: command.description,
|
|
594
|
+
template,
|
|
595
|
+
agent: command.agent,
|
|
596
|
+
model: command.model,
|
|
597
|
+
subtask: command.subtask,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|