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/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# OpenWork Server
|
|
2
|
+
|
|
3
|
+
Filesystem-backed API for OpenWork remote clients. This package provides the OpenWork server layer described in `packages/app/pr/openwork-server.md` and is intentionally independent from the desktop app.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g openwork-server
|
|
9
|
+
openwork-server --workspace /path/to/workspace --approval auto
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or from source:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm --filter openwork-server dev -- \
|
|
16
|
+
--workspace /path/to/workspace \
|
|
17
|
+
--approval auto
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The server logs the client token and host token on boot when they are auto-generated.
|
|
21
|
+
|
|
22
|
+
## Config file
|
|
23
|
+
|
|
24
|
+
Defaults to `~/.config/openwork/server.json` (override with `OPENWORK_SERVER_CONFIG` or `--config`).
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"host": "127.0.0.1",
|
|
29
|
+
"port": 8787,
|
|
30
|
+
"approval": { "mode": "manual", "timeoutMs": 30000 },
|
|
31
|
+
"workspaces": [
|
|
32
|
+
{
|
|
33
|
+
"path": "/Users/susan/Finance",
|
|
34
|
+
"name": "Finance",
|
|
35
|
+
"workspaceType": "local",
|
|
36
|
+
"baseUrl": "http://127.0.0.1:4096",
|
|
37
|
+
"directory": "/Users/susan/Finance"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"corsOrigins": ["http://localhost:5173"]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Environment variables
|
|
45
|
+
|
|
46
|
+
- `OPENWORK_SERVER_CONFIG` path to config JSON
|
|
47
|
+
- `OPENWORK_HOST` / `OPENWORK_PORT`
|
|
48
|
+
- `OPENWORK_TOKEN` client bearer token
|
|
49
|
+
- `OPENWORK_HOST_TOKEN` host approval token
|
|
50
|
+
- `OPENWORK_APPROVAL_MODE` (`manual` | `auto`)
|
|
51
|
+
- `OPENWORK_APPROVAL_TIMEOUT_MS`
|
|
52
|
+
- `OPENWORK_WORKSPACES` (JSON array or comma-separated list of paths)
|
|
53
|
+
- `OPENWORK_CORS_ORIGINS` (comma-separated list or `*`)
|
|
54
|
+
- `OPENWORK_OPENCODE_BASE_URL`
|
|
55
|
+
- `OPENWORK_OPENCODE_DIRECTORY`
|
|
56
|
+
- `OPENWORK_OPENCODE_USERNAME`
|
|
57
|
+
- `OPENWORK_OPENCODE_PASSWORD`
|
|
58
|
+
|
|
59
|
+
## Endpoints (initial)
|
|
60
|
+
|
|
61
|
+
- `GET /health`
|
|
62
|
+
- `GET /capabilities`
|
|
63
|
+
- `GET /workspaces`
|
|
64
|
+
- `GET /workspace/:id/config`
|
|
65
|
+
- `PATCH /workspace/:id/config`
|
|
66
|
+
- `GET /workspace/:id/plugins`
|
|
67
|
+
- `POST /workspace/:id/plugins`
|
|
68
|
+
- `DELETE /workspace/:id/plugins/:name`
|
|
69
|
+
- `GET /workspace/:id/skills`
|
|
70
|
+
- `POST /workspace/:id/skills`
|
|
71
|
+
- `GET /workspace/:id/mcp`
|
|
72
|
+
- `POST /workspace/:id/mcp`
|
|
73
|
+
- `DELETE /workspace/:id/mcp/:name`
|
|
74
|
+
- `GET /workspace/:id/commands`
|
|
75
|
+
- `POST /workspace/:id/commands`
|
|
76
|
+
- `DELETE /workspace/:id/commands/:name`
|
|
77
|
+
- `GET /workspace/:id/audit`
|
|
78
|
+
- `GET /workspace/:id/export`
|
|
79
|
+
- `POST /workspace/:id/import`
|
|
80
|
+
|
|
81
|
+
## Approvals
|
|
82
|
+
|
|
83
|
+
All writes are gated by host approval. Host APIs require `X-OpenWork-Host-Token`:
|
|
84
|
+
|
|
85
|
+
- `GET /approvals`
|
|
86
|
+
- `POST /approvals/:id` with `{ "reply": "allow" | "deny" }`
|
|
87
|
+
|
|
88
|
+
Set `OPENWORK_APPROVAL_MODE=auto` to auto-approve during local development.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { shortId } from "./utils.js";
|
|
2
|
+
export class ApprovalService {
|
|
3
|
+
config;
|
|
4
|
+
pending = new Map();
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
}
|
|
8
|
+
list() {
|
|
9
|
+
return Array.from(this.pending.values()).map((entry) => entry.request);
|
|
10
|
+
}
|
|
11
|
+
async requestApproval(input) {
|
|
12
|
+
if (this.config.mode === "auto") {
|
|
13
|
+
return { id: "auto", allowed: true };
|
|
14
|
+
}
|
|
15
|
+
const id = shortId();
|
|
16
|
+
const request = {
|
|
17
|
+
...input,
|
|
18
|
+
id,
|
|
19
|
+
createdAt: Date.now(),
|
|
20
|
+
};
|
|
21
|
+
const result = await new Promise((resolve) => {
|
|
22
|
+
const timeout = setTimeout(() => {
|
|
23
|
+
this.pending.delete(id);
|
|
24
|
+
resolve({ id, allowed: false, reason: "timeout" });
|
|
25
|
+
}, this.config.timeoutMs);
|
|
26
|
+
this.pending.set(id, { request, resolve, timeout });
|
|
27
|
+
});
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
respond(id, reply) {
|
|
31
|
+
const pending = this.pending.get(id);
|
|
32
|
+
if (!pending)
|
|
33
|
+
return null;
|
|
34
|
+
if (pending.timeout)
|
|
35
|
+
clearTimeout(pending.timeout);
|
|
36
|
+
this.pending.delete(id);
|
|
37
|
+
const result = {
|
|
38
|
+
id,
|
|
39
|
+
allowed: reply === "allow",
|
|
40
|
+
reason: reply === "allow" ? undefined : "denied",
|
|
41
|
+
};
|
|
42
|
+
pending.resolve(result);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
3
|
+
import { ensureDir, exists } from "./utils.js";
|
|
4
|
+
export function auditLogPath(workspaceRoot) {
|
|
5
|
+
return join(workspaceRoot, ".opencode", "openwork", "audit.jsonl");
|
|
6
|
+
}
|
|
7
|
+
export async function recordAudit(workspaceRoot, entry) {
|
|
8
|
+
const path = auditLogPath(workspaceRoot);
|
|
9
|
+
await ensureDir(dirname(path));
|
|
10
|
+
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
|
11
|
+
}
|
|
12
|
+
export async function readLastAudit(workspaceRoot) {
|
|
13
|
+
const path = auditLogPath(workspaceRoot);
|
|
14
|
+
if (!(await exists(path)))
|
|
15
|
+
return null;
|
|
16
|
+
const content = await readFile(path, "utf8");
|
|
17
|
+
const lines = content.trim().split("\n");
|
|
18
|
+
const last = lines[lines.length - 1];
|
|
19
|
+
if (!last)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(last);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function readAuditEntries(workspaceRoot, limit = 50) {
|
|
29
|
+
const path = auditLogPath(workspaceRoot);
|
|
30
|
+
if (!(await exists(path)))
|
|
31
|
+
return [];
|
|
32
|
+
const content = await readFile(path, "utf8");
|
|
33
|
+
const rawLines = content.trim().split("\n").filter(Boolean);
|
|
34
|
+
if (!rawLines.length)
|
|
35
|
+
return [];
|
|
36
|
+
const slice = rawLines.slice(-Math.max(1, limit));
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (let i = slice.length - 1; i >= 0; i -= 1) {
|
|
39
|
+
try {
|
|
40
|
+
entries.push(JSON.parse(slice[i]));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// ignore malformed entry
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return entries;
|
|
47
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
|
|
3
|
+
import { startServer } from "./server.js";
|
|
4
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
5
|
+
if (args.help) {
|
|
6
|
+
printHelp();
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
const config = await resolveServerConfig(args);
|
|
10
|
+
const server = startServer(config);
|
|
11
|
+
const url = `http://${config.host}:${server.port}`;
|
|
12
|
+
console.log(`OpenWork server listening on ${url}`);
|
|
13
|
+
if (config.tokenSource === "generated") {
|
|
14
|
+
console.log(`Client token: ${config.token}`);
|
|
15
|
+
}
|
|
16
|
+
if (config.hostTokenSource === "generated") {
|
|
17
|
+
console.log(`Host token: ${config.hostToken}`);
|
|
18
|
+
}
|
|
19
|
+
if (config.workspaces.length === 0) {
|
|
20
|
+
console.log("No workspaces configured. Add --workspace or update server.json.");
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
console.log(`Workspaces: ${config.workspaces.length}`);
|
|
24
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, rm, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
|
|
5
|
+
import { exists } from "./utils.js";
|
|
6
|
+
import { projectCommandsDir } from "./workspace-files.js";
|
|
7
|
+
import { validateCommandName, sanitizeCommandName } from "./validators.js";
|
|
8
|
+
import { ApiError } from "./errors.js";
|
|
9
|
+
async function listCommandsInDir(dir, scope) {
|
|
10
|
+
if (!(await exists(dir)))
|
|
11
|
+
return [];
|
|
12
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
13
|
+
const items = [];
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (!entry.isFile())
|
|
16
|
+
continue;
|
|
17
|
+
if (!entry.name.endsWith(".md"))
|
|
18
|
+
continue;
|
|
19
|
+
const filePath = join(dir, entry.name);
|
|
20
|
+
const content = await readFile(filePath, "utf8");
|
|
21
|
+
const { data, body } = parseFrontmatter(content);
|
|
22
|
+
const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
|
|
23
|
+
try {
|
|
24
|
+
validateCommandName(name);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
items.push({
|
|
30
|
+
name,
|
|
31
|
+
description: typeof data.description === "string" ? data.description : undefined,
|
|
32
|
+
template: body.trim(),
|
|
33
|
+
agent: typeof data.agent === "string" ? data.agent : undefined,
|
|
34
|
+
model: typeof data.model === "string" ? data.model : null,
|
|
35
|
+
subtask: typeof data.subtask === "boolean" ? data.subtask : undefined,
|
|
36
|
+
scope,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return items;
|
|
40
|
+
}
|
|
41
|
+
export async function listCommands(workspaceRoot, scope) {
|
|
42
|
+
if (scope === "global") {
|
|
43
|
+
const dir = join(homedir(), ".config", "opencode", "commands");
|
|
44
|
+
return listCommandsInDir(dir, "global");
|
|
45
|
+
}
|
|
46
|
+
return listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
|
|
47
|
+
}
|
|
48
|
+
export async function upsertCommand(workspaceRoot, payload) {
|
|
49
|
+
if (!payload.template || payload.template.trim().length === 0) {
|
|
50
|
+
throw new ApiError(400, "invalid_command_template", "Command template is required");
|
|
51
|
+
}
|
|
52
|
+
const sanitized = sanitizeCommandName(payload.name);
|
|
53
|
+
validateCommandName(sanitized);
|
|
54
|
+
const frontmatter = buildFrontmatter({
|
|
55
|
+
name: sanitized,
|
|
56
|
+
description: payload.description,
|
|
57
|
+
agent: payload.agent,
|
|
58
|
+
model: payload.model ?? null,
|
|
59
|
+
subtask: payload.subtask ?? false,
|
|
60
|
+
});
|
|
61
|
+
const content = frontmatter + "\n" + payload.template.trim() + "\n";
|
|
62
|
+
const dir = projectCommandsDir(workspaceRoot);
|
|
63
|
+
await mkdir(dir, { recursive: true });
|
|
64
|
+
const path = join(dir, `${sanitized}.md`);
|
|
65
|
+
await writeFile(path, content, "utf8");
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
export async function deleteCommand(workspaceRoot, name) {
|
|
69
|
+
const sanitized = sanitizeCommandName(name);
|
|
70
|
+
validateCommandName(sanitized);
|
|
71
|
+
const path = join(projectCommandsDir(workspaceRoot), `${sanitized}.md`);
|
|
72
|
+
await rm(path, { force: true });
|
|
73
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { buildWorkspaceInfos } from "./workspaces.js";
|
|
4
|
+
import { parseList, readJsonFile, shortId } from "./utils.js";
|
|
5
|
+
const DEFAULT_PORT = 8787;
|
|
6
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
8
|
+
export function parseCliArgs(argv) {
|
|
9
|
+
const args = { workspaces: [] };
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
const value = argv[index];
|
|
12
|
+
if (!value)
|
|
13
|
+
continue;
|
|
14
|
+
if (value === "--help" || value === "-h") {
|
|
15
|
+
args.help = true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (value === "--config") {
|
|
19
|
+
args.configPath = argv[index + 1];
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (value === "--host") {
|
|
24
|
+
args.host = argv[index + 1];
|
|
25
|
+
index += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (value === "--port") {
|
|
29
|
+
const port = Number(argv[index + 1]);
|
|
30
|
+
if (!Number.isNaN(port))
|
|
31
|
+
args.port = port;
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (value === "--token") {
|
|
36
|
+
args.token = argv[index + 1];
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (value === "--host-token") {
|
|
41
|
+
args.hostToken = argv[index + 1];
|
|
42
|
+
index += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (value === "--approval") {
|
|
46
|
+
const mode = argv[index + 1];
|
|
47
|
+
if (mode === "manual" || mode === "auto")
|
|
48
|
+
args.approvalMode = mode;
|
|
49
|
+
index += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (value === "--approval-timeout") {
|
|
53
|
+
const timeout = Number(argv[index + 1]);
|
|
54
|
+
if (!Number.isNaN(timeout))
|
|
55
|
+
args.approvalTimeoutMs = timeout;
|
|
56
|
+
index += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (value === "--opencode-base-url") {
|
|
60
|
+
args.opencodeBaseUrl = argv[index + 1];
|
|
61
|
+
index += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (value === "--opencode-directory") {
|
|
65
|
+
args.opencodeDirectory = argv[index + 1];
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (value === "--opencode-username") {
|
|
70
|
+
args.opencodeUsername = argv[index + 1];
|
|
71
|
+
index += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (value === "--opencode-password") {
|
|
75
|
+
args.opencodePassword = argv[index + 1];
|
|
76
|
+
index += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (value === "--workspace") {
|
|
80
|
+
const path = argv[index + 1];
|
|
81
|
+
if (path)
|
|
82
|
+
args.workspaces.push(path);
|
|
83
|
+
index += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (value === "--cors") {
|
|
87
|
+
args.corsOrigins = parseList(argv[index + 1]);
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (value === "--read-only") {
|
|
92
|
+
args.readOnly = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return args;
|
|
97
|
+
}
|
|
98
|
+
export function printHelp() {
|
|
99
|
+
const message = [
|
|
100
|
+
"openwork-server",
|
|
101
|
+
"",
|
|
102
|
+
"Options:",
|
|
103
|
+
" --config <path> Path to server.json",
|
|
104
|
+
" --host <host> Hostname (default 127.0.0.1)",
|
|
105
|
+
" --port <port> Port (default 8787)",
|
|
106
|
+
" --token <token> Client bearer token",
|
|
107
|
+
" --host-token <token> Host approval token",
|
|
108
|
+
" --approval <mode> manual | auto",
|
|
109
|
+
" --approval-timeout <ms> Approval timeout",
|
|
110
|
+
" --opencode-base-url <url> OpenCode base URL to share",
|
|
111
|
+
" --opencode-directory <path> OpenCode workspace directory to share",
|
|
112
|
+
" --opencode-username <user> OpenCode server username",
|
|
113
|
+
" --opencode-password <pass> OpenCode server password",
|
|
114
|
+
" --workspace <path> Workspace root (repeatable)",
|
|
115
|
+
" --cors <origins> Comma-separated origins or *",
|
|
116
|
+
" --read-only Disable writes",
|
|
117
|
+
].join("\n");
|
|
118
|
+
console.log(message);
|
|
119
|
+
}
|
|
120
|
+
async function loadFileConfig(configPath) {
|
|
121
|
+
const parsed = await readJsonFile(configPath);
|
|
122
|
+
return parsed ?? {};
|
|
123
|
+
}
|
|
124
|
+
export async function resolveServerConfig(cli) {
|
|
125
|
+
const envConfigPath = process.env.OPENWORK_SERVER_CONFIG;
|
|
126
|
+
const configPath = cli.configPath ?? envConfigPath ?? resolve(homedir(), ".config", "openwork", "server.json");
|
|
127
|
+
const fileConfig = await loadFileConfig(configPath);
|
|
128
|
+
const configDir = dirname(configPath);
|
|
129
|
+
const envWorkspaces = parseList(process.env.OPENWORK_WORKSPACES);
|
|
130
|
+
const workspaceConfigs = cli.workspaces.length > 0
|
|
131
|
+
? cli.workspaces.map((path) => ({ path }))
|
|
132
|
+
: envWorkspaces.length > 0
|
|
133
|
+
? envWorkspaces.map((path) => ({ path }))
|
|
134
|
+
: fileConfig.workspaces ?? [];
|
|
135
|
+
const envOpencodeBaseUrl = process.env.OPENWORK_OPENCODE_BASE_URL;
|
|
136
|
+
const envOpencodeDirectory = process.env.OPENWORK_OPENCODE_DIRECTORY;
|
|
137
|
+
const envOpencodeUsername = process.env.OPENWORK_OPENCODE_USERNAME;
|
|
138
|
+
const envOpencodePassword = process.env.OPENWORK_OPENCODE_PASSWORD;
|
|
139
|
+
const opencodeBaseUrl = cli.opencodeBaseUrl ?? envOpencodeBaseUrl;
|
|
140
|
+
const opencodeDirectory = cli.opencodeDirectory ?? envOpencodeDirectory;
|
|
141
|
+
const opencodeUsername = cli.opencodeUsername ?? envOpencodeUsername ?? fileConfig.opencodeUsername;
|
|
142
|
+
const opencodePassword = cli.opencodePassword ?? envOpencodePassword ?? fileConfig.opencodePassword;
|
|
143
|
+
if (workspaceConfigs.length > 0 && (opencodeBaseUrl || opencodeDirectory || opencodeUsername || opencodePassword)) {
|
|
144
|
+
workspaceConfigs[0] = {
|
|
145
|
+
...workspaceConfigs[0],
|
|
146
|
+
baseUrl: opencodeBaseUrl ?? workspaceConfigs[0].baseUrl,
|
|
147
|
+
directory: opencodeDirectory ?? workspaceConfigs[0].directory,
|
|
148
|
+
opencodeUsername: opencodeUsername ?? workspaceConfigs[0].opencodeUsername,
|
|
149
|
+
opencodePassword: opencodePassword ?? workspaceConfigs[0].opencodePassword,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const workspaces = buildWorkspaceInfos(workspaceConfigs, configDir);
|
|
153
|
+
const tokenFromEnv = process.env.OPENWORK_TOKEN;
|
|
154
|
+
const hostTokenFromEnv = process.env.OPENWORK_HOST_TOKEN;
|
|
155
|
+
const token = cli.token ?? tokenFromEnv ?? fileConfig.token ?? shortId();
|
|
156
|
+
const hostToken = cli.hostToken ?? hostTokenFromEnv ?? fileConfig.hostToken ?? shortId();
|
|
157
|
+
const tokenSource = cli.token
|
|
158
|
+
? "cli"
|
|
159
|
+
: tokenFromEnv
|
|
160
|
+
? "env"
|
|
161
|
+
: fileConfig.token
|
|
162
|
+
? "file"
|
|
163
|
+
: "generated";
|
|
164
|
+
const hostTokenSource = cli.hostToken
|
|
165
|
+
? "cli"
|
|
166
|
+
: hostTokenFromEnv
|
|
167
|
+
? "env"
|
|
168
|
+
: fileConfig.hostToken
|
|
169
|
+
? "file"
|
|
170
|
+
: "generated";
|
|
171
|
+
const approvalMode = cli.approvalMode ??
|
|
172
|
+
process.env.OPENWORK_APPROVAL_MODE ??
|
|
173
|
+
fileConfig.approval?.mode ??
|
|
174
|
+
"manual";
|
|
175
|
+
const approvalTimeoutMs = cli.approvalTimeoutMs ??
|
|
176
|
+
(process.env.OPENWORK_APPROVAL_TIMEOUT_MS ? Number(process.env.OPENWORK_APPROVAL_TIMEOUT_MS) : undefined) ??
|
|
177
|
+
fileConfig.approval?.timeoutMs ??
|
|
178
|
+
DEFAULT_TIMEOUT_MS;
|
|
179
|
+
const approval = {
|
|
180
|
+
mode: approvalMode === "auto" ? "auto" : "manual",
|
|
181
|
+
timeoutMs: Number.isNaN(approvalTimeoutMs) ? DEFAULT_TIMEOUT_MS : approvalTimeoutMs,
|
|
182
|
+
};
|
|
183
|
+
const envCorsOrigins = process.env.OPENWORK_CORS_ORIGINS;
|
|
184
|
+
const parsedEnvCors = envCorsOrigins ? parseList(envCorsOrigins) : null;
|
|
185
|
+
const corsOrigins = cli.corsOrigins ?? parsedEnvCors ?? fileConfig.corsOrigins ?? ["*"];
|
|
186
|
+
const envReadOnly = process.env.OPENWORK_READONLY;
|
|
187
|
+
const parsedReadOnly = envReadOnly
|
|
188
|
+
? ["true", "1", "yes"].includes(envReadOnly.toLowerCase())
|
|
189
|
+
: undefined;
|
|
190
|
+
const readOnly = cli.readOnly ?? parsedReadOnly ?? fileConfig.readOnly ?? false;
|
|
191
|
+
const authorizedRoots = fileConfig.authorizedRoots?.length
|
|
192
|
+
? fileConfig.authorizedRoots.map((root) => resolve(configDir, root))
|
|
193
|
+
: workspaces.map((workspace) => workspace.path);
|
|
194
|
+
const host = cli.host ?? process.env.OPENWORK_HOST ?? fileConfig.host ?? DEFAULT_HOST;
|
|
195
|
+
const port = cli.port ?? (process.env.OPENWORK_PORT ? Number(process.env.OPENWORK_PORT) : undefined) ?? fileConfig.port ?? DEFAULT_PORT;
|
|
196
|
+
return {
|
|
197
|
+
host,
|
|
198
|
+
port: Number.isNaN(port) ? DEFAULT_PORT : port,
|
|
199
|
+
token,
|
|
200
|
+
hostToken,
|
|
201
|
+
approval,
|
|
202
|
+
corsOrigins,
|
|
203
|
+
workspaces,
|
|
204
|
+
authorizedRoots,
|
|
205
|
+
readOnly,
|
|
206
|
+
startedAt: Date.now(),
|
|
207
|
+
tokenSource,
|
|
208
|
+
hostTokenSource,
|
|
209
|
+
};
|
|
210
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(status, code, message, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.details = details;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function formatError(err) {
|
|
13
|
+
return {
|
|
14
|
+
code: err.code,
|
|
15
|
+
message: err.message,
|
|
16
|
+
details: err.details,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parse, stringify } from "yaml";
|
|
2
|
+
export function parseFrontmatter(content) {
|
|
3
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
4
|
+
if (!match) {
|
|
5
|
+
return { data: {}, body: content };
|
|
6
|
+
}
|
|
7
|
+
const raw = match[1] ?? "";
|
|
8
|
+
const data = parse(raw) ?? {};
|
|
9
|
+
const body = content.slice(match[0].length);
|
|
10
|
+
return { data, body };
|
|
11
|
+
}
|
|
12
|
+
export function buildFrontmatter(data) {
|
|
13
|
+
const yaml = stringify(data).trimEnd();
|
|
14
|
+
return `---\n${yaml}\n---\n`;
|
|
15
|
+
}
|
package/dist/jsonc.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { ApiError } from "./errors.js";
|
|
5
|
+
import { ensureDir, exists } from "./utils.js";
|
|
6
|
+
export async function readJsoncFile(path, fallback) {
|
|
7
|
+
if (!(await exists(path))) {
|
|
8
|
+
return { data: fallback, raw: "" };
|
|
9
|
+
}
|
|
10
|
+
const raw = await readFile(path, "utf8");
|
|
11
|
+
const errors = [];
|
|
12
|
+
const data = parse(raw, errors, { allowTrailingComma: true });
|
|
13
|
+
if (errors.length > 0) {
|
|
14
|
+
const details = errors.map((error) => ({
|
|
15
|
+
code: printParseErrorCode(error.error),
|
|
16
|
+
offset: error.offset,
|
|
17
|
+
length: error.length,
|
|
18
|
+
}));
|
|
19
|
+
throw new ApiError(422, "invalid_jsonc", "Failed to parse JSONC", details);
|
|
20
|
+
}
|
|
21
|
+
return { data, raw };
|
|
22
|
+
}
|
|
23
|
+
export async function updateJsoncTopLevel(path, updates) {
|
|
24
|
+
const hasFile = await exists(path);
|
|
25
|
+
if (!hasFile) {
|
|
26
|
+
await ensureDir(dirname(path));
|
|
27
|
+
const content = JSON.stringify(updates, null, 2) + "\n";
|
|
28
|
+
await writeFile(path, content, "utf8");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let content = await readFile(path, "utf8");
|
|
32
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
|
|
33
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
34
|
+
const edits = modify(content, [key], value, { formattingOptions });
|
|
35
|
+
content = applyEdits(content, edits);
|
|
36
|
+
}
|
|
37
|
+
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf8");
|
|
38
|
+
}
|
|
39
|
+
export async function writeJsoncFile(path, value) {
|
|
40
|
+
await ensureDir(dirname(path));
|
|
41
|
+
const content = JSON.stringify(value, null, 2) + "\n";
|
|
42
|
+
await writeFile(path, content, "utf8");
|
|
43
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { minimatch } from "minimatch";
|
|
2
|
+
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
|
|
3
|
+
import { opencodeConfigPath } from "./workspace-files.js";
|
|
4
|
+
import { validateMcpConfig, validateMcpName } from "./validators.js";
|
|
5
|
+
function getMcpConfig(config) {
|
|
6
|
+
const mcp = config.mcp;
|
|
7
|
+
if (!mcp || typeof mcp !== "object")
|
|
8
|
+
return {};
|
|
9
|
+
return mcp;
|
|
10
|
+
}
|
|
11
|
+
function getDeniedToolPatterns(config) {
|
|
12
|
+
const tools = config.tools;
|
|
13
|
+
if (!tools || typeof tools !== "object")
|
|
14
|
+
return [];
|
|
15
|
+
const deny = tools.deny;
|
|
16
|
+
if (!Array.isArray(deny))
|
|
17
|
+
return [];
|
|
18
|
+
return deny.filter((item) => typeof item === "string");
|
|
19
|
+
}
|
|
20
|
+
function isMcpDisabledByTools(config, name) {
|
|
21
|
+
const patterns = getDeniedToolPatterns(config);
|
|
22
|
+
if (patterns.length === 0)
|
|
23
|
+
return false;
|
|
24
|
+
const candidates = [`mcp.${name}`, `mcp.${name}.*`, `mcp:${name}`, `mcp:${name}:*`, "mcp.*", "mcp:*"];
|
|
25
|
+
return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern)));
|
|
26
|
+
}
|
|
27
|
+
export async function listMcp(workspaceRoot) {
|
|
28
|
+
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {});
|
|
29
|
+
const mcpMap = getMcpConfig(config);
|
|
30
|
+
return Object.entries(mcpMap).map(([name, entry]) => ({
|
|
31
|
+
name,
|
|
32
|
+
config: entry,
|
|
33
|
+
source: "config.project",
|
|
34
|
+
disabledByTools: isMcpDisabledByTools(config, name) || undefined,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
export async function addMcp(workspaceRoot, name, config) {
|
|
38
|
+
validateMcpName(name);
|
|
39
|
+
validateMcpConfig(config);
|
|
40
|
+
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {});
|
|
41
|
+
const mcpMap = getMcpConfig(data);
|
|
42
|
+
mcpMap[name] = config;
|
|
43
|
+
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
|
|
44
|
+
}
|
|
45
|
+
export async function removeMcp(workspaceRoot, name) {
|
|
46
|
+
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {});
|
|
47
|
+
const mcpMap = getMcpConfig(data);
|
|
48
|
+
delete mcpMap[name];
|
|
49
|
+
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
|
|
50
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
3
|
+
import { ApiError } from "./errors.js";
|
|
4
|
+
export function assertAbsolute(path) {
|
|
5
|
+
if (!isAbsolute(path)) {
|
|
6
|
+
throw new ApiError(400, "invalid_path", "Path must be absolute");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function resolveWithinRoot(root, ...segments) {
|
|
10
|
+
const resolvedRoot = await realpath(root);
|
|
11
|
+
const candidate = resolve(resolvedRoot, ...segments);
|
|
12
|
+
const resolvedCandidate = await realpath(candidate).catch(() => candidate);
|
|
13
|
+
if (resolvedCandidate === resolvedRoot)
|
|
14
|
+
return candidate;
|
|
15
|
+
if (!resolvedCandidate.startsWith(resolvedRoot + sep)) {
|
|
16
|
+
throw new ApiError(400, "path_escape", "Path escapes workspace root");
|
|
17
|
+
}
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|