musecode 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/.env.example +31 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/codex-mcp.mjs +64 -0
- package/codex-sessions.mjs +153 -0
- package/config.example.json +19 -0
- package/desktop-mcp.mjs +64 -0
- package/lib.mjs +225 -0
- package/live.mjs +95 -0
- package/package.json +30 -0
- package/run-server.ps1 +9 -0
- package/server.mjs +1131 -0
- package/sessions.mjs +102 -0
- package/start.ps1 +45 -0
- package/threads.mjs +59 -0
package/server.mjs
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
// HTTP bridge that Meta Muse talks to. Exposes the same tools two ways:
|
|
2
|
+
// POST /mcp MCP over streamable HTTP (JSON responses)
|
|
3
|
+
// /api/* + /openapi.json plain REST, for Muse's OpenAPI-based custom integrations
|
|
4
|
+
// Security layers, outermost first:
|
|
5
|
+
// 1. Listens on 127.0.0.1 only; reachable from outside solely through the Cloudflare tunnel.
|
|
6
|
+
// 2. Optional caller-IP allowlist (config.allowedIps).
|
|
7
|
+
// 3. Bearer token with constant-time compare and lockout after repeated failures.
|
|
8
|
+
// 4. Every Claude task waits for human approval: a push notification links to /approve/<id>/<code>,
|
|
9
|
+
// where <code> is an HMAC only sent to your phone, never to Muse. So neither a leaked token nor
|
|
10
|
+
// a prompt injection against Muse can run anything on this PC without you tapping Approve.
|
|
11
|
+
// 5. Audit log of every task, approval, and auth failure in data/audit.log.
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import net from "node:net";
|
|
19
|
+
import {
|
|
20
|
+
loadConfig, createMcpHandler, postMessage, readMessages, loadTasks, saveTasks, audit, ROOT, DATA, TO_DESKTOP, TO_MUSE,
|
|
21
|
+
} from "./lib.mjs";
|
|
22
|
+
import { listSessions, getSession, readSession, fileFor } from "./sessions.mjs";
|
|
23
|
+
import { deliverLive, watchReply } from "./live.mjs";
|
|
24
|
+
import { appendMessage, readThread, getMeta, setMeta, listThreads, sideForkIds } from "./threads.mjs";
|
|
25
|
+
import { listCodexSessions, readCodexSession, runCodexSessionTurn } from "./codex-sessions.mjs";
|
|
26
|
+
|
|
27
|
+
const cfg = loadConfig();
|
|
28
|
+
if (cfg.publicUrl) cfg.publicUrl = cfg.publicUrl.replace(/\/+$/, "");
|
|
29
|
+
|
|
30
|
+
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
31
|
+
const MAX_UPLOAD_BODY_BYTES = Math.ceil(MAX_FILE_BYTES * 4 / 3) + 64 * 1024;
|
|
32
|
+
const FILE_DROP = path.resolve(
|
|
33
|
+
process.env.BRIDGE_FILE_DROP || path.join(process.env.LOCALAPPDATA || os.homedir(), "ClaudeDesktopBridge", "shared-files"),
|
|
34
|
+
);
|
|
35
|
+
const fileDropRelative = path.relative(ROOT, FILE_DROP);
|
|
36
|
+
if (fileDropRelative === "" || (!fileDropRelative.startsWith("..") && !path.isAbsolute(fileDropRelative))) {
|
|
37
|
+
throw new Error("BRIDGE_FILE_DROP must be outside the bridge source directory");
|
|
38
|
+
}
|
|
39
|
+
fs.mkdirSync(FILE_DROP, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const fileDataPath = (id) => path.join(FILE_DROP, `${id}.bin`);
|
|
42
|
+
const fileMetaPath = (id) => path.join(FILE_DROP, `${id}.json`);
|
|
43
|
+
|
|
44
|
+
function validFileId(id) {
|
|
45
|
+
return typeof id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fileMetadata(id) {
|
|
49
|
+
if (!validFileId(id)) return null;
|
|
50
|
+
try {
|
|
51
|
+
const meta = JSON.parse(fs.readFileSync(fileMetaPath(id), "utf8"));
|
|
52
|
+
return fs.existsSync(fileDataPath(id)) ? meta : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function listSharedFiles() {
|
|
59
|
+
return fs.readdirSync(FILE_DROP, { withFileTypes: true })
|
|
60
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
61
|
+
.map((entry) => fileMetadata(entry.name.slice(0, -5)))
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function storeSharedFile({ name, content_base64, content_type }) {
|
|
67
|
+
if (typeof name !== "string" || !name.trim()) throw new Error("name is required");
|
|
68
|
+
if (name.length > 255 || name !== path.basename(name) || /[\0-\x1f\x7f]/.test(name) || !name.isWellFormed()) {
|
|
69
|
+
throw new Error("name must be a valid file name (not a path), at most 255 characters");
|
|
70
|
+
}
|
|
71
|
+
if (typeof content_base64 !== "string") throw new Error("content_base64 is required");
|
|
72
|
+
if (content_base64.length > Math.ceil(MAX_FILE_BYTES / 3) * 4) throw Object.assign(new Error("File exceeds 25 MB limit"), { statusCode: 413 });
|
|
73
|
+
const padding = content_base64.endsWith("==") ? 2 : content_base64.endsWith("=") ? 1 : 0;
|
|
74
|
+
const unpadded = padding ? content_base64.slice(0, -padding) : content_base64;
|
|
75
|
+
if (content_base64.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(content_base64) || unpadded.includes("=")) {
|
|
76
|
+
throw new Error("content_base64 is not valid base64");
|
|
77
|
+
}
|
|
78
|
+
const content = Buffer.from(content_base64, "base64");
|
|
79
|
+
if (content.length > MAX_FILE_BYTES) throw Object.assign(new Error("File exceeds 25 MB limit"), { statusCode: 413 });
|
|
80
|
+
const normalizedType = typeof content_type === "string" && content_type.trim() ? content_type.trim() : "application/octet-stream";
|
|
81
|
+
if (normalizedType.length > 255 || !/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*(?:\s*;\s*[^\r\n]+)?$/.test(normalizedType)) {
|
|
82
|
+
throw new Error("content_type must be a valid media type");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const file_id = crypto.randomUUID();
|
|
86
|
+
const meta = {
|
|
87
|
+
file_id,
|
|
88
|
+
name,
|
|
89
|
+
size: content.length,
|
|
90
|
+
created_at: new Date().toISOString(),
|
|
91
|
+
content_type: normalizedType,
|
|
92
|
+
};
|
|
93
|
+
fs.writeFileSync(fileDataPath(file_id), content, { flag: "wx" });
|
|
94
|
+
try {
|
|
95
|
+
fs.writeFileSync(fileMetaPath(file_id), JSON.stringify(meta, null, 2) + "\n", { flag: "wx" });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
fs.rmSync(fileDataPath(file_id), { force: true });
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
return meta;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveCodexPath(configured) {
|
|
104
|
+
if (configured && fs.existsSync(configured)) return configured;
|
|
105
|
+
const binRoot = path.join(process.env.LOCALAPPDATA || "", "OpenAI", "Codex", "bin");
|
|
106
|
+
if (fs.existsSync(binRoot)) {
|
|
107
|
+
const candidates = fs.readdirSync(binRoot, { withFileTypes: true })
|
|
108
|
+
.filter((entry) => entry.isDirectory())
|
|
109
|
+
.map((entry) => path.join(binRoot, entry.name, "codex.exe"))
|
|
110
|
+
.filter((candidate) => fs.existsSync(candidate))
|
|
111
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
112
|
+
if (candidates.length) return candidates[0];
|
|
113
|
+
}
|
|
114
|
+
return configured || "codex.exe";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const CODEX_PATH = resolveCodexPath(cfg.codexPath);
|
|
118
|
+
const LOCAL_HOSTS = new Set([`127.0.0.1:${cfg.port}`, `localhost:${cfg.port}`]);
|
|
119
|
+
const allowedHosts = new Set(LOCAL_HOSTS);
|
|
120
|
+
if (cfg.publicUrl) allowedHosts.add(new URL(cfg.publicUrl).host);
|
|
121
|
+
const tasks = loadTasks();
|
|
122
|
+
for (const t of Object.values(tasks)) {
|
|
123
|
+
if (t.status === "running") t.status = "interrupted";
|
|
124
|
+
if (t.status === "awaiting_approval") t.status = "expired";
|
|
125
|
+
}
|
|
126
|
+
saveTasks(tasks);
|
|
127
|
+
|
|
128
|
+
const MAX_RUNNING = 2;
|
|
129
|
+
const MAX_PENDING = 5;
|
|
130
|
+
const waiters = new Map(); // taskId -> [resolve], woken whenever the task changes state
|
|
131
|
+
|
|
132
|
+
function update(task, fields) {
|
|
133
|
+
Object.assign(task, fields);
|
|
134
|
+
saveTasks(tasks);
|
|
135
|
+
for (const resolve of waiters.get(task.id) || []) resolve();
|
|
136
|
+
waiters.delete(task.id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- Approval ----
|
|
140
|
+
|
|
141
|
+
const approvalCode = (id) => crypto.createHmac("sha256", cfg.approvalSecret).update(id).digest("base64url");
|
|
142
|
+
|
|
143
|
+
function codeMatches(id, code) {
|
|
144
|
+
const want = Buffer.from(approvalCode(id));
|
|
145
|
+
const got = Buffer.from(String(code || ""));
|
|
146
|
+
return got.length === want.length && crypto.timingSafeEqual(got, want);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function notifyPhone(task, baseUrl) {
|
|
150
|
+
const link = `${cfg.publicUrl || baseUrl}/approve/${task.id}/${approvalCode(task.id)}`;
|
|
151
|
+
console.log(`\n>>> Approval needed for task ${task.id}: ${link}\n`);
|
|
152
|
+
try {
|
|
153
|
+
// The prompt itself is not sent to ntfy; you read it on the approval page served by this bridge.
|
|
154
|
+
const res = await fetch(`${cfg.ntfyServer}/${cfg.ntfyTopic}`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
Title: `Muse wants ${task.agent === "codex" ? "Codex" : "Claude"} to run a task`,
|
|
158
|
+
Priority: "high",
|
|
159
|
+
Tags: "robot",
|
|
160
|
+
Click: link,
|
|
161
|
+
Actions: `view, Review, ${link}, clear=true`,
|
|
162
|
+
},
|
|
163
|
+
body: `Task ${task.id}. Tap to review the prompt and approve or deny. Expires in ${cfg.approvalTimeoutMinutes} min.`,
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) console.error(`ntfy responded ${res.status}`);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.error(`Could not send push notification: ${e.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- Tasks ----
|
|
172
|
+
|
|
173
|
+
function createTask(prompt, sessionId, baseUrl) {
|
|
174
|
+
const pending = Object.values(tasks).filter((t) => t.status === "awaiting_approval").length;
|
|
175
|
+
if (pending >= MAX_PENDING) throw new Error("Too many tasks awaiting approval.");
|
|
176
|
+
|
|
177
|
+
// Any of the user's sessions can be continued, in that session's own project folder.
|
|
178
|
+
let cwd = cfg.workdir;
|
|
179
|
+
if (sessionId) {
|
|
180
|
+
const session = getSession(sessionId);
|
|
181
|
+
if (!session) throw new Error("Unknown session_id. Use list_sessions to find one.");
|
|
182
|
+
if (!fs.existsSync(session.project_folder)) throw new Error(`Session's folder no longer exists: ${session.project_folder}`);
|
|
183
|
+
// Live deliveries can stack (Desktop feeds them in order); only headless resumes must not overlap.
|
|
184
|
+
if (Object.values(tasks).some((t) => t.status === "running" && t.mode === "resume" && t.sessionId === sessionId)) {
|
|
185
|
+
throw new Error("A task is already running in that session; wait for it with get_task.");
|
|
186
|
+
}
|
|
187
|
+
// Always continue the live session itself (never a fork), in its own project folder.
|
|
188
|
+
cwd = session.project_folder;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const id = crypto.randomUUID();
|
|
192
|
+
const task = {
|
|
193
|
+
id, prompt, status: "awaiting_approval", createdAt: new Date().toISOString(),
|
|
194
|
+
sessionId: sessionId || null, cwd,
|
|
195
|
+
};
|
|
196
|
+
tasks[id] = task;
|
|
197
|
+
saveTasks(tasks);
|
|
198
|
+
audit("task_created", { id, sessionId, prompt });
|
|
199
|
+
|
|
200
|
+
if (!cfg.requireApproval) {
|
|
201
|
+
runTask(task);
|
|
202
|
+
} else {
|
|
203
|
+
notifyPhone(task, baseUrl);
|
|
204
|
+
setTimeout(() => {
|
|
205
|
+
if (task.status === "awaiting_approval") { update(task, { status: "expired" }); audit("task_expired", { id }); }
|
|
206
|
+
}, cfg.approvalTimeoutMinutes * 60_000);
|
|
207
|
+
}
|
|
208
|
+
return task;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function runTask(task) {
|
|
212
|
+
const running = Object.values(tasks).filter((t) => t.status === "running").length;
|
|
213
|
+
if (running >= MAX_RUNNING) {
|
|
214
|
+
update(task, { status: "failed", error: `${running} tasks already running; send it again later.` });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
update(task, { status: "running", startedAt: new Date().toISOString() });
|
|
218
|
+
|
|
219
|
+
const session = task.sessionId && getSession(task.sessionId);
|
|
220
|
+
if (session?.desktop_title) runLive(task, session);
|
|
221
|
+
else runResume(task);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Session open in Claude Desktop: deliver into the live window, then read the reply from its transcript.
|
|
225
|
+
async function runLive(task, session) {
|
|
226
|
+
const marker = `muse task ${task.id.slice(0, 8)}`;
|
|
227
|
+
const message = `[Message from Meta Muse (AI agent), not from the user]\n\n${task.prompt}\n\n(from Meta Muse · ${marker})`;
|
|
228
|
+
const delivery = await deliverLive({
|
|
229
|
+
claudePath: cfg.claudePath, cwd: cfg.workdir, title: session.desktop_title, message,
|
|
230
|
+
permissionMode: session.permission_mode || "auto",
|
|
231
|
+
});
|
|
232
|
+
audit("live_delivery", { id: task.id, sessionId: task.sessionId, ...delivery });
|
|
233
|
+
|
|
234
|
+
if (delivery.status !== "delivered" && delivery.status !== "queued") {
|
|
235
|
+
// Not open in Desktop (or delivery failed): continue the session directly instead.
|
|
236
|
+
console.log(`task ${task.id}: live delivery ${delivery.status} (${delivery.detail}); resuming instead`);
|
|
237
|
+
return runResume(task);
|
|
238
|
+
}
|
|
239
|
+
update(task, { mode: "live", delivery: delivery.status });
|
|
240
|
+
|
|
241
|
+
const out = await watchReply({
|
|
242
|
+
file: fileFor(task.sessionId), marker, timeoutMs: cfg.taskTimeoutMinutes * 60_000,
|
|
243
|
+
onUpdate: ({ text }) => { if (text && text !== task.partial) update(task, { partial: text.slice(-10_000) }); },
|
|
244
|
+
});
|
|
245
|
+
const finishedAt = new Date().toISOString();
|
|
246
|
+
if (out.status === "done") update(task, { status: "done", finishedAt, result: out.text.slice(-10_000), partial: undefined });
|
|
247
|
+
else update(task, { status: "failed", finishedAt, error: out.seen ? "Timed out waiting for the reply to finish." : "Message was delivered but never showed up in the session." });
|
|
248
|
+
audit("task_finished", { id: task.id, status: task.status, mode: "live" });
|
|
249
|
+
console.log(`${finishedAt} task ${task.id} ${task.status} (live)`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Session not open in Desktop (or a fresh conversation): run it headless with the CLI.
|
|
253
|
+
function runResume(task) {
|
|
254
|
+
update(task, { mode: task.sessionId ? "resume" : "new" });
|
|
255
|
+
const args = ["-p", "--output-format", "json", "--permission-mode", cfg.permissionMode];
|
|
256
|
+
if (task.sessionId) args.push("--resume", task.sessionId);
|
|
257
|
+
if (cfg.allowedTools?.length) args.push("--allowedTools", cfg.allowedTools.join(","));
|
|
258
|
+
if (cfg.model) args.push("--model", cfg.model);
|
|
259
|
+
|
|
260
|
+
// Prompt goes over stdin so nothing from the phone is ever parsed as a command-line argument.
|
|
261
|
+
const child = spawn(cfg.claudePath, args, { cwd: task.cwd || cfg.workdir, windowsHide: true, env: process.env });
|
|
262
|
+
let stdout = "", stderr = "";
|
|
263
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
264
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
265
|
+
child.stdin.end(task.prompt);
|
|
266
|
+
|
|
267
|
+
const timer = setTimeout(() => child.kill(), cfg.taskTimeoutMinutes * 60_000);
|
|
268
|
+
child.on("error", (e) => { clearTimeout(timer); update(task, { status: "failed", error: e.message }); });
|
|
269
|
+
child.on("close", (code) => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
if (task.status !== "running") return;
|
|
272
|
+
const finishedAt = new Date().toISOString();
|
|
273
|
+
try {
|
|
274
|
+
const out = JSON.parse(stdout);
|
|
275
|
+
update(task, {
|
|
276
|
+
status: out.is_error ? "failed" : "done", finishedAt,
|
|
277
|
+
result: out.result, sessionId: out.session_id, costUsd: out.total_cost_usd,
|
|
278
|
+
});
|
|
279
|
+
} catch {
|
|
280
|
+
update(task, { status: "failed", finishedAt, error: (stderr || stdout || `exit code ${code}`).slice(-2000) });
|
|
281
|
+
}
|
|
282
|
+
audit("task_finished", { id: task.id, status: task.status });
|
|
283
|
+
console.log(`${finishedAt} task ${task.id} ${task.status}`);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Codex tasks use the same durable task/polling store as Claude tasks. New tasks create a
|
|
288
|
+
// persisted Codex thread; passing that returned session_id resumes it with its full context.
|
|
289
|
+
function createCodexTask(prompt, sessionId, baseUrl) {
|
|
290
|
+
const pending = Object.values(tasks).filter((t) => t.status === "awaiting_approval").length;
|
|
291
|
+
if (pending >= MAX_PENDING) throw new Error("Too many tasks awaiting approval.");
|
|
292
|
+
if (sessionId && !/^[0-9a-f-]{36}$/i.test(sessionId)) throw new Error("Invalid Codex session_id.");
|
|
293
|
+
|
|
294
|
+
const id = crypto.randomUUID();
|
|
295
|
+
const task = {
|
|
296
|
+
id, prompt, agent: "codex", status: "awaiting_approval", createdAt: new Date().toISOString(),
|
|
297
|
+
sessionId: sessionId || null, cwd: cfg.workdir,
|
|
298
|
+
};
|
|
299
|
+
tasks[id] = task;
|
|
300
|
+
saveTasks(tasks);
|
|
301
|
+
audit("task_created", { id, agent: "codex", sessionId, prompt });
|
|
302
|
+
|
|
303
|
+
if (!cfg.requireApproval) {
|
|
304
|
+
runCodex(task);
|
|
305
|
+
} else {
|
|
306
|
+
notifyPhone(task, baseUrl);
|
|
307
|
+
setTimeout(() => {
|
|
308
|
+
if (task.status === "awaiting_approval") { update(task, { status: "expired" }); audit("task_expired", { id }); }
|
|
309
|
+
}, cfg.approvalTimeoutMinutes * 60_000);
|
|
310
|
+
}
|
|
311
|
+
return task;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function runCodex(task) {
|
|
315
|
+
const running = Object.values(tasks).filter((t) => t.status === "running").length;
|
|
316
|
+
if (running >= MAX_RUNNING) {
|
|
317
|
+
update(task, { status: "failed", error: `${running} tasks already running; send it again later.` });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
update(task, { status: "running", startedAt: new Date().toISOString(), mode: task.sessionId ? "codex-resume" : "codex-new" });
|
|
321
|
+
|
|
322
|
+
if (task.sessionId) {
|
|
323
|
+
try {
|
|
324
|
+
const out = await runCodexSessionTurn(CODEX_PATH, task.sessionId, task.prompt, {
|
|
325
|
+
cwd: task.cwd || cfg.workdir,
|
|
326
|
+
timeoutMs: cfg.taskTimeoutMinutes * 60_000,
|
|
327
|
+
onUpdate: (text) => { if (text && text !== task.partial) update(task, { partial: text.slice(-10_000) }); },
|
|
328
|
+
});
|
|
329
|
+
const finishedAt = new Date().toISOString();
|
|
330
|
+
update(task, {
|
|
331
|
+
status: "done", finishedAt, result: out.text.slice(-10_000), partial: undefined,
|
|
332
|
+
mode: "codex-desktop", turnId: out.turnId,
|
|
333
|
+
});
|
|
334
|
+
audit("task_finished", { id: task.id, agent: "codex", status: task.status, mode: "codex-desktop" });
|
|
335
|
+
console.log(`${finishedAt} task ${task.id} ${task.status} (codex desktop)`);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
const finishedAt = new Date().toISOString();
|
|
338
|
+
update(task, { status: "failed", finishedAt, error: String(e?.message || e).slice(-2_000) });
|
|
339
|
+
audit("task_finished", { id: task.id, agent: "codex", status: task.status, mode: "codex-desktop" });
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const resultDir = path.join(DATA, "codex-results");
|
|
345
|
+
fs.mkdirSync(resultDir, { recursive: true });
|
|
346
|
+
const resultFile = path.join(resultDir, `${task.id}.txt`);
|
|
347
|
+
const common = ["--json", "--output-last-message", resultFile, "--skip-git-repo-check"];
|
|
348
|
+
const args = task.sessionId
|
|
349
|
+
? ["exec", "--approve-for-me", "resume", ...common, task.sessionId, "-"]
|
|
350
|
+
: ["exec", "--approve-for-me", ...common, "--cd", task.cwd || cfg.workdir, "-"];
|
|
351
|
+
|
|
352
|
+
const child = spawn(CODEX_PATH, args, {
|
|
353
|
+
cwd: task.cwd || cfg.workdir, windowsHide: true, env: process.env,
|
|
354
|
+
});
|
|
355
|
+
let stdout = "", stderr = "";
|
|
356
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
357
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
358
|
+
child.stdin.end(task.prompt);
|
|
359
|
+
|
|
360
|
+
const timer = setTimeout(() => child.kill(), cfg.taskTimeoutMinutes * 60_000);
|
|
361
|
+
child.on("error", (e) => { clearTimeout(timer); update(task, { status: "failed", error: e.message }); });
|
|
362
|
+
child.on("close", (code) => {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
if (task.status !== "running") return;
|
|
365
|
+
const finishedAt = new Date().toISOString();
|
|
366
|
+
let sessionId = task.sessionId;
|
|
367
|
+
for (const line of stdout.split("\n")) {
|
|
368
|
+
try {
|
|
369
|
+
const event = JSON.parse(line);
|
|
370
|
+
if (event.type === "thread.started" && event.thread_id) sessionId = event.thread_id;
|
|
371
|
+
} catch {}
|
|
372
|
+
}
|
|
373
|
+
const result = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8").trim() : "";
|
|
374
|
+
fs.rmSync(resultFile, { force: true });
|
|
375
|
+
if (code === 0 && result) {
|
|
376
|
+
update(task, { status: "done", finishedAt, result: result.slice(-10_000), sessionId });
|
|
377
|
+
} else {
|
|
378
|
+
update(task, { status: "failed", finishedAt, sessionId, error: (stderr || stdout || `exit code ${code}`).slice(-2000) });
|
|
379
|
+
}
|
|
380
|
+
audit("task_finished", { id: task.id, agent: "codex", status: task.status });
|
|
381
|
+
console.log(`${finishedAt} task ${task.id} ${task.status} (codex)`);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---- Side threads: Muse <-> a read-only fork of a session, outside the user's own conversation ----
|
|
386
|
+
|
|
387
|
+
const SIDE_TOOLS = "Read,Glob,Grep";
|
|
388
|
+
const ROLE_LABEL = {
|
|
389
|
+
muse: "Meta Muse",
|
|
390
|
+
"side-claude": "Side Claude",
|
|
391
|
+
"desktop-claude": "Desktop Claude",
|
|
392
|
+
"desktop-codex": "Desktop Codex",
|
|
393
|
+
user: "User",
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
function createSideTask(sessionId, text) {
|
|
397
|
+
if (sideForkIds().has(sessionId)) throw new Error("That is a side-thread fork; use the original session's id.");
|
|
398
|
+
const session = getSession(sessionId);
|
|
399
|
+
if (!session) throw new Error("Unknown session_id. Use list_sessions to find one.");
|
|
400
|
+
if (!fs.existsSync(session.project_folder)) throw new Error(`Session's folder no longer exists: ${session.project_folder}`);
|
|
401
|
+
if (Object.values(tasks).some((t) => t.status === "running" && t.mode === "side" && t.sessionId === sessionId)) {
|
|
402
|
+
throw new Error("The side agent is still answering the previous message; poll get_task, then send again.");
|
|
403
|
+
}
|
|
404
|
+
appendMessage(sessionId, "muse", text);
|
|
405
|
+
setMeta(sessionId, { title: session.title });
|
|
406
|
+
|
|
407
|
+
const id = crypto.randomUUID();
|
|
408
|
+
const now = new Date().toISOString();
|
|
409
|
+
const task = { id, prompt: text, status: "running", mode: "side", sessionId, cwd: session.project_folder, createdAt: now, startedAt: now };
|
|
410
|
+
tasks[id] = task;
|
|
411
|
+
saveTasks(tasks);
|
|
412
|
+
audit("side_message", { id, sessionId, text });
|
|
413
|
+
runSide(task);
|
|
414
|
+
return task;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function runSide(task) {
|
|
418
|
+
const meta = getMeta(task.sessionId);
|
|
419
|
+
// Everything posted since the side agent last spoke (by Muse, the user, or Desktop Claude), labeled by author.
|
|
420
|
+
const pending = readThread(task.sessionId, { since: meta.lastSeenId }).filter((m) => m.role !== "side-claude");
|
|
421
|
+
const prompt = [
|
|
422
|
+
"[SIDE CHANNEL — separate from the user's main conversation]",
|
|
423
|
+
"You are the side agent for this session: a read-only fork that answers messages in a side thread between Meta Muse",
|
|
424
|
+
"(an AI agent working for the user) and this PC session. Nothing you write here reaches the user's main conversation, and",
|
|
425
|
+
"the main session keeps working independently. You can read files (Read, Glob, Grep) but cannot change anything;",
|
|
426
|
+
"if real work is needed, tell Muse to send it to the main session with ask_claude.",
|
|
427
|
+
"Messages are labeled by author. Reply to them as the side agent.",
|
|
428
|
+
"",
|
|
429
|
+
...pending.map((m) => `[${ROLE_LABEL[m.role]} · ${m.at}]\n${m.text}\n`),
|
|
430
|
+
].join("\n");
|
|
431
|
+
|
|
432
|
+
const args = ["-p", "--output-format", "json", "--permission-mode", "dontAsk", "--tools", SIDE_TOOLS, "--allowedTools", SIDE_TOOLS];
|
|
433
|
+
// First message forks the session; later ones continue that same fork, so the side thread builds context.
|
|
434
|
+
if (meta.forkSessionId) args.push("--resume", meta.forkSessionId);
|
|
435
|
+
else args.push("--resume", task.sessionId, "--fork-session");
|
|
436
|
+
if (cfg.model) args.push("--model", cfg.model);
|
|
437
|
+
|
|
438
|
+
const child = spawn(cfg.claudePath, args, { cwd: task.cwd, windowsHide: true, env: process.env });
|
|
439
|
+
let stdout = "", stderr = "";
|
|
440
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
441
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
442
|
+
child.stdin.end(prompt);
|
|
443
|
+
|
|
444
|
+
const timer = setTimeout(() => child.kill(), cfg.taskTimeoutMinutes * 60_000);
|
|
445
|
+
child.on("error", (e) => { clearTimeout(timer); update(task, { status: "failed", error: e.message }); });
|
|
446
|
+
child.on("close", (code) => {
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
if (task.status !== "running") return;
|
|
449
|
+
const finishedAt = new Date().toISOString();
|
|
450
|
+
try {
|
|
451
|
+
const out = JSON.parse(stdout);
|
|
452
|
+
if (out.is_error) throw new Error(out.result);
|
|
453
|
+
setMeta(task.sessionId, { forkSessionId: out.session_id, lastSeenId: pending[pending.length - 1]?.id || meta.lastSeenId });
|
|
454
|
+
appendMessage(task.sessionId, "side-claude", out.result, { task_id: task.id });
|
|
455
|
+
update(task, { status: "done", finishedAt, result: out.result });
|
|
456
|
+
} catch (e) {
|
|
457
|
+
update(task, { status: "failed", finishedAt, error: (e.message || stderr || `exit code ${code}`).slice(-2000) });
|
|
458
|
+
}
|
|
459
|
+
audit("task_finished", { id: task.id, status: task.status, mode: "side" });
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const FINAL = new Set(["done", "failed", "denied", "expired", "interrupted"]);
|
|
464
|
+
|
|
465
|
+
async function waitFor(task, seconds) {
|
|
466
|
+
if (FINAL.has(task.status) || !seconds) return task;
|
|
467
|
+
const deadline = Date.now() + Math.min(seconds, 110) * 1000;
|
|
468
|
+
while (!FINAL.has(task.status) && Date.now() < deadline) {
|
|
469
|
+
await Promise.race([
|
|
470
|
+
new Promise((r) => waiters.set(task.id, [...(waiters.get(task.id) || []), r])),
|
|
471
|
+
new Promise((r) => setTimeout(r, deadline - Date.now())),
|
|
472
|
+
]);
|
|
473
|
+
}
|
|
474
|
+
return task;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const HINTS = {
|
|
478
|
+
awaiting_approval: "Waiting for the user to approve this task on their phone. Tell them to check the notification, then call get_task.",
|
|
479
|
+
running: "Claude is working. Call get_task with this task_id to check again.",
|
|
480
|
+
denied: "The user denied this task.",
|
|
481
|
+
expired: "Nobody approved this task in time.",
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const view = (t) => ({
|
|
485
|
+
task_id: t.id, agent: t.agent || "claude", status: t.status, result: t.result, error: t.error, session_id: t.sessionId,
|
|
486
|
+
// live = delivered into the open Claude Desktop window; resume = continued headless; new = fresh conversation.
|
|
487
|
+
mode: t.mode, reply_so_far: t.status === "running" ? t.partial : undefined,
|
|
488
|
+
created_at: t.createdAt, finished_at: t.finishedAt, hint: HINTS[t.status],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ---- Tools (shared by MCP and REST) ----
|
|
492
|
+
|
|
493
|
+
const tools = [
|
|
494
|
+
{
|
|
495
|
+
name: "ask_codex",
|
|
496
|
+
description:
|
|
497
|
+
"Send a task or question to Codex running on the user's Windows PC. " +
|
|
498
|
+
(cfg.requireApproval ? "The user must approve each task on their phone before it runs. " : "") +
|
|
499
|
+
"Waits up to wait_seconds; if unfinished, returns a task_id to poll with get_task. " +
|
|
500
|
+
"Without session_id it starts a fresh persistent Codex thread in the bridge workspace. " +
|
|
501
|
+
"Pass a thread id from list_codex_sessions to continue a real Codex Desktop task with its full context.",
|
|
502
|
+
inputSchema: {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {
|
|
505
|
+
prompt: { type: "string", description: "What Codex should do or answer." },
|
|
506
|
+
session_id: { type: "string", description: "Optional Codex thread id returned by an earlier ask_codex call." },
|
|
507
|
+
wait_seconds: { type: "number", description: "How long to wait for the answer (0-110). Default 90." },
|
|
508
|
+
},
|
|
509
|
+
required: ["prompt"],
|
|
510
|
+
},
|
|
511
|
+
run: async ({ prompt, session_id, wait_seconds = 90 }, ctx) => {
|
|
512
|
+
if (!prompt?.trim()) throw new Error("prompt is required");
|
|
513
|
+
if (prompt.length > 50_000) throw new Error("prompt too long");
|
|
514
|
+
return view(await waitFor(createCodexTask(prompt, session_id, ctx.baseUrl), wait_seconds));
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
name: "list_codex_sessions",
|
|
519
|
+
description:
|
|
520
|
+
"List the user's real Codex Desktop and CLI tasks, most recent first. Use a returned session_id with ask_codex " +
|
|
521
|
+
"to deliver directly into that existing task instead of starting a detached run.",
|
|
522
|
+
inputSchema: {
|
|
523
|
+
type: "object",
|
|
524
|
+
properties: { limit: { type: "number", description: "Max results (default 20, max 100)." } },
|
|
525
|
+
},
|
|
526
|
+
run: async ({ limit = 20 }) => {
|
|
527
|
+
const rows = await listCodexSessions(CODEX_PATH, { cwd: cfg.workdir, limit: Math.max(1, Math.min(100, limit)) });
|
|
528
|
+
const sessions = rows.map((s) => ({
|
|
529
|
+
session_id: s.id || s.sessionId,
|
|
530
|
+
title: s.name || s.preview,
|
|
531
|
+
preview: s.preview,
|
|
532
|
+
project_folder: s.cwd,
|
|
533
|
+
last_activity: s.recencyAt || s.updatedAt,
|
|
534
|
+
status: s.status,
|
|
535
|
+
source: s.source,
|
|
536
|
+
originator: s.originator,
|
|
537
|
+
}));
|
|
538
|
+
return sessions.length ? { sessions } : { sessions: [], note: "No Codex sessions found." };
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
name: "read_codex_session",
|
|
543
|
+
description: "Read a Codex task, including its persisted turns and items, by session_id.",
|
|
544
|
+
inputSchema: {
|
|
545
|
+
type: "object",
|
|
546
|
+
properties: { session_id: { type: "string" } },
|
|
547
|
+
required: ["session_id"],
|
|
548
|
+
},
|
|
549
|
+
run: async ({ session_id }) => {
|
|
550
|
+
if (!/^[0-9a-f-]{36}$/i.test(session_id || "")) throw new Error("Invalid Codex session_id.");
|
|
551
|
+
return readCodexSession(CODEX_PATH, session_id, { cwd: cfg.workdir });
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: "ask_claude",
|
|
556
|
+
description:
|
|
557
|
+
"Send a task or question to Claude Code running on the user's Windows PC, with full access to that PC. " +
|
|
558
|
+
(cfg.requireApproval ? "The user must approve each task on their phone before it runs. " : "") +
|
|
559
|
+
"Waits up to wait_seconds; if not finished, " +
|
|
560
|
+
"returns a task_id to poll with get_task. To continue one of the user's existing Claude sessions (from Claude Desktop " +
|
|
561
|
+
"or the terminal), pass its session_id from list_sessions; Claude picks up with that session's full context, in its project folder. " +
|
|
562
|
+
"If that session is open in Claude Desktop, the message is delivered live into the Desktop window (it queues behind any " +
|
|
563
|
+
"work in progress) and the reply is read back; get_task shows reply_so_far while it works. " +
|
|
564
|
+
"Without session_id, a fresh conversation starts.",
|
|
565
|
+
inputSchema: {
|
|
566
|
+
type: "object",
|
|
567
|
+
properties: {
|
|
568
|
+
prompt: { type: "string", description: "What Claude should do or answer." },
|
|
569
|
+
session_id: { type: "string", description: "Optional. An existing session to continue (from list_sessions or a previous answer)." },
|
|
570
|
+
wait_seconds: { type: "number", description: "How long to wait for the answer (0-110). Default 90." },
|
|
571
|
+
},
|
|
572
|
+
required: ["prompt"],
|
|
573
|
+
},
|
|
574
|
+
run: async ({ prompt, session_id, wait_seconds = 90 }, ctx) => {
|
|
575
|
+
if (!prompt?.trim()) throw new Error("prompt is required");
|
|
576
|
+
if (prompt.length > 50_000) throw new Error("prompt too long");
|
|
577
|
+
return view(await waitFor(createTask(prompt, session_id, ctx.baseUrl), wait_seconds));
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: "list_sessions",
|
|
582
|
+
description:
|
|
583
|
+
"List the user's existing Claude sessions on their PC (Claude Desktop Code tab and terminal), most recent first, " +
|
|
584
|
+
"with title, project folder, last activity, and whether it's open in Desktop right now.",
|
|
585
|
+
inputSchema: {
|
|
586
|
+
type: "object",
|
|
587
|
+
properties: {
|
|
588
|
+
query: { type: "string", description: "Optional. Filter by words in the title, folder, or last prompt." },
|
|
589
|
+
limit: { type: "number", description: "Max results (default 20, max 100)." },
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
run: async ({ query, limit }) => {
|
|
593
|
+
const sessions = listSessions({ query, limit, hide: sideForkIds() });
|
|
594
|
+
return sessions.length ? { sessions } : { sessions: [], note: "No matching sessions." };
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "read_session",
|
|
599
|
+
description: "Read the recent conversation (user and Claude text, without tool output) of one of the user's Claude sessions.",
|
|
600
|
+
inputSchema: {
|
|
601
|
+
type: "object",
|
|
602
|
+
properties: {
|
|
603
|
+
session_id: { type: "string" },
|
|
604
|
+
last: { type: "number", description: "How many recent turns to return (default 20, max 100)." },
|
|
605
|
+
},
|
|
606
|
+
required: ["session_id"],
|
|
607
|
+
},
|
|
608
|
+
run: async ({ session_id, last = 20 }) => {
|
|
609
|
+
const s = readSession(session_id, last);
|
|
610
|
+
if (!s) throw new Error("Unknown session_id. Use list_sessions to find one.");
|
|
611
|
+
return s;
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: "side_thread_send",
|
|
616
|
+
description:
|
|
617
|
+
"Send a message into the SIDE THREAD for one of the user's sessions: an agent-to-agent channel that never appears in " +
|
|
618
|
+
"the user's own conversation. A side agent (a read-only fork of that session, with its full context) answers. " +
|
|
619
|
+
"Use this for questions, status checks and coordination; use ask_claude when the main session should actually do work. " +
|
|
620
|
+
"Waits up to wait_seconds for the reply; otherwise poll get_task.",
|
|
621
|
+
inputSchema: {
|
|
622
|
+
type: "object",
|
|
623
|
+
properties: {
|
|
624
|
+
session_id: { type: "string", description: "The session whose side thread to post in (from list_sessions)." },
|
|
625
|
+
text: { type: "string" },
|
|
626
|
+
wait_seconds: { type: "number", description: "0-110. Default 90." },
|
|
627
|
+
},
|
|
628
|
+
required: ["session_id", "text"],
|
|
629
|
+
},
|
|
630
|
+
run: async ({ session_id, text, wait_seconds = 90 }) => {
|
|
631
|
+
if (!text?.trim()) throw new Error("text is required");
|
|
632
|
+
if (text.length > 20_000) throw new Error("text too long");
|
|
633
|
+
return view(await waitFor(createSideTask(session_id, text), wait_seconds));
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "side_thread_read",
|
|
638
|
+
description:
|
|
639
|
+
"Read a session's side thread. Every message is labeled: muse (you), side-claude (the side agent), " +
|
|
640
|
+
"desktop-claude (the main session's Claude), desktop-codex (the main Codex session), or user. " +
|
|
641
|
+
"Omit session_id to list all side threads.",
|
|
642
|
+
inputSchema: {
|
|
643
|
+
type: "object",
|
|
644
|
+
properties: {
|
|
645
|
+
session_id: { type: "string" },
|
|
646
|
+
since: { type: "string", description: "Optional. Only messages after this message id or ISO timestamp." },
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
run: async ({ session_id, since }) => {
|
|
650
|
+
if (!session_id) return { threads: listThreads() };
|
|
651
|
+
return { session_id, messages: readThread(session_id, { since }).slice(-100) };
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: "get_task",
|
|
656
|
+
description: "Check a task started with ask_claude, ask_codex, or side_thread_send. Optionally wait for it to finish.",
|
|
657
|
+
inputSchema: {
|
|
658
|
+
type: "object",
|
|
659
|
+
properties: { task_id: { type: "string" }, wait_seconds: { type: "number", description: "0-110. Default 30." } },
|
|
660
|
+
required: ["task_id"],
|
|
661
|
+
},
|
|
662
|
+
run: async ({ task_id, wait_seconds = 30 }) => {
|
|
663
|
+
const t = tasks[task_id];
|
|
664
|
+
if (!t) throw new Error(`No task ${task_id}`);
|
|
665
|
+
return view(await waitFor(t, wait_seconds));
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: "list_tasks",
|
|
670
|
+
description: "List the 20 most recent Claude and Codex tasks with their status.",
|
|
671
|
+
inputSchema: { type: "object", properties: {} },
|
|
672
|
+
run: async () =>
|
|
673
|
+
Object.values(tasks).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 20)
|
|
674
|
+
.map((t) => ({ task_id: t.id, agent: t.agent || "claude", status: t.status, prompt: t.prompt.slice(0, 120), created_at: t.createdAt })),
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
name: "send_to_desktop",
|
|
678
|
+
description:
|
|
679
|
+
"Leave a message in the desktop-agent inbox on the user's PC. Messages beginning FOR CODEX are also " +
|
|
680
|
+
"dispatched immediately into the most recent real Codex Desktop task; the response includes codex_task.task_id for polling.",
|
|
681
|
+
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
|
|
682
|
+
run: async ({ text }, ctx) => {
|
|
683
|
+
if (!text?.trim()) throw new Error("text is required");
|
|
684
|
+
if (text.length > 20_000) throw new Error("text too long");
|
|
685
|
+
const sent = postMessage(TO_DESKTOP, text, "muse");
|
|
686
|
+
if (!/^\s*FOR CODEX\b/i.test(text)) return { sent };
|
|
687
|
+
|
|
688
|
+
const prompt = [
|
|
689
|
+
"[Immediate notification from Meta Muse, an AI agent working with the user]",
|
|
690
|
+
"This message was routed to you by the Muse bridge because it begins FOR CODEX.",
|
|
691
|
+
"Treat it as a communication turn. Reply to the message, but do not infer authorization for unrelated system changes.",
|
|
692
|
+
"",
|
|
693
|
+
text,
|
|
694
|
+
].join("\n");
|
|
695
|
+
const sessions = await listCodexSessions(CODEX_PATH, { cwd: cfg.workdir, limit: 50 });
|
|
696
|
+
const desktop = sessions.find((s) => s.originator === "Codex Desktop" || s.source === "vscode") || sessions[0];
|
|
697
|
+
if (!desktop) throw new Error("No existing Codex session is available. Open Codex Desktop once, then retry.");
|
|
698
|
+
const codexSessionId = desktop.id || desktop.sessionId;
|
|
699
|
+
const codexTask = createCodexTask(prompt, codexSessionId, ctx.baseUrl);
|
|
700
|
+
const acknowledged = postMessage(
|
|
701
|
+
TO_MUSE,
|
|
702
|
+
`FOR MUSE — received your FOR CODEX message. Delivered to Codex Desktop task ${codexSessionId} as bridge task ${codexTask.id}; poll it with get_task.`,
|
|
703
|
+
"codex-desktop",
|
|
704
|
+
);
|
|
705
|
+
audit("codex_message_dispatched", { messageId: sent.id, taskId: codexTask.id, sessionId: codexSessionId });
|
|
706
|
+
return { sent, acknowledged, codex_task: view(codexTask) };
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
name: "read_desktop_messages",
|
|
711
|
+
description: "Read messages Claude Desktop has sent to Muse. Returns unread messages and marks them read.",
|
|
712
|
+
inputSchema: { type: "object", properties: { include_read: { type: "boolean", description: "Return the last 50 instead of only unread." } } },
|
|
713
|
+
run: async ({ include_read }) => {
|
|
714
|
+
const messages = readMessages(TO_MUSE, { unreadOnly: !include_read });
|
|
715
|
+
return messages.length ? { messages } : { messages: [], note: "No new messages from Claude Desktop." };
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
];
|
|
719
|
+
|
|
720
|
+
const handleMcp = createMcpHandler({
|
|
721
|
+
name: "desktop-agent-bridge",
|
|
722
|
+
version: "1.4.0",
|
|
723
|
+
instructions:
|
|
724
|
+
"Bridge to Claude and Codex on the user's PC. Use ask_claude or ask_codex to hand an agent a task" +
|
|
725
|
+
(cfg.requireApproval ? "; the user approves it on their phone, " : ", ") +
|
|
726
|
+
"then poll get_task until it is done. Use list_sessions / read_session for Claude and list_codex_sessions / " +
|
|
727
|
+
"read_codex_session for Codex. Passing a session_id continues the actual desktop task. FOR CODEX messages sent through " +
|
|
728
|
+
"send_to_desktop are acknowledged immediately and routed to the most recent Codex Desktop task.",
|
|
729
|
+
tools,
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// ---- OpenAPI description of the REST routes ----
|
|
733
|
+
|
|
734
|
+
function openapi(baseUrl) {
|
|
735
|
+
const paths = {};
|
|
736
|
+
for (const t of tools) {
|
|
737
|
+
paths[`/api/${t.name}`] = {
|
|
738
|
+
post: {
|
|
739
|
+
operationId: t.name,
|
|
740
|
+
summary: t.description,
|
|
741
|
+
requestBody: { required: true, content: { "application/json": { schema: t.inputSchema } } },
|
|
742
|
+
responses: { 200: { description: "OK", content: { "application/json": { schema: { type: "object" } } } } },
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
const fileSchema = {
|
|
747
|
+
type: "object",
|
|
748
|
+
properties: {
|
|
749
|
+
file_id: { type: "string", format: "uuid" },
|
|
750
|
+
name: { type: "string" },
|
|
751
|
+
size: { type: "integer", minimum: 0, maximum: MAX_FILE_BYTES },
|
|
752
|
+
created_at: { type: "string", format: "date-time" },
|
|
753
|
+
content_type: { type: "string" },
|
|
754
|
+
},
|
|
755
|
+
required: ["file_id", "name", "size", "created_at", "content_type"],
|
|
756
|
+
};
|
|
757
|
+
paths["/api/upload_file"] = {
|
|
758
|
+
post: {
|
|
759
|
+
operationId: "upload_file",
|
|
760
|
+
summary: "Upload a file to the shared file drop (25 MB maximum).",
|
|
761
|
+
requestBody: {
|
|
762
|
+
required: true,
|
|
763
|
+
content: {
|
|
764
|
+
"application/json": {
|
|
765
|
+
schema: {
|
|
766
|
+
type: "object",
|
|
767
|
+
properties: {
|
|
768
|
+
name: { type: "string", description: "File name only; paths are rejected." },
|
|
769
|
+
content_base64: { type: "string", format: "byte" },
|
|
770
|
+
content_type: { type: "string", default: "application/octet-stream" },
|
|
771
|
+
},
|
|
772
|
+
required: ["name", "content_base64"],
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
responses: { 201: { description: "Uploaded", content: { "application/json": { schema: fileSchema } } }, 413: { description: "File too large" } },
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
paths["/api/download_file"] = {
|
|
781
|
+
get: {
|
|
782
|
+
operationId: "download_file",
|
|
783
|
+
summary: "Download a shared file.",
|
|
784
|
+
parameters: [{ name: "file_id", in: "query", required: true, schema: { type: "string", format: "uuid" } }],
|
|
785
|
+
responses: {
|
|
786
|
+
200: { description: "File contents", headers: { "Content-Disposition": { schema: { type: "string" } } }, content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } } },
|
|
787
|
+
404: { description: "File not found" },
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
paths["/api/list_files"] = {
|
|
792
|
+
get: {
|
|
793
|
+
operationId: "list_files",
|
|
794
|
+
summary: "List files in the shared file drop.",
|
|
795
|
+
responses: { 200: { description: "Stored files", content: { "application/json": { schema: { type: "object", properties: { files: { type: "array", items: fileSchema } }, required: ["files"] } } } } },
|
|
796
|
+
},
|
|
797
|
+
};
|
|
798
|
+
paths["/api/delete_file"] = {
|
|
799
|
+
delete: {
|
|
800
|
+
operationId: "delete_file",
|
|
801
|
+
summary: "Delete a shared file.",
|
|
802
|
+
parameters: [{ name: "file_id", in: "query", required: true, schema: { type: "string", format: "uuid" } }],
|
|
803
|
+
responses: { 200: { description: "Deleted", content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, file_id: { type: "string", format: "uuid" } }, required: ["deleted", "file_id"] } } } }, 404: { description: "File not found" } },
|
|
804
|
+
},
|
|
805
|
+
};
|
|
806
|
+
return {
|
|
807
|
+
openapi: "3.1.0",
|
|
808
|
+
info: { title: "Claude Desktop Bridge", version: "1.3.0", description: "Lets Meta Muse send tasks, messages, and files to agents on the user's PC." },
|
|
809
|
+
servers: [{ url: cfg.publicUrl || baseUrl }],
|
|
810
|
+
components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } },
|
|
811
|
+
security: [{ bearer: [] }],
|
|
812
|
+
paths,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ---- Approval page ----
|
|
817
|
+
|
|
818
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
819
|
+
|
|
820
|
+
function approvalPage(task, message) {
|
|
821
|
+
const pending = task.status === "awaiting_approval";
|
|
822
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
823
|
+
<meta name="referrer" content="no-referrer"><title>Approve Claude task</title>
|
|
824
|
+
<style>
|
|
825
|
+
:root{color-scheme:light dark;--bg:#fff;--fg:#1a1a1a;--muted:#666;--box:#f4f4f5;--ok:#15803d;--no:#b91c1c}
|
|
826
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111;--fg:#eee;--muted:#999;--box:#1d1d20}}
|
|
827
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif}
|
|
828
|
+
main{max-width:640px;margin:0 auto;padding:24px 16px}
|
|
829
|
+
h1{font-size:20px;margin:0 0 4px}.meta{color:var(--muted);font-size:14px;margin-bottom:16px}
|
|
830
|
+
pre{background:var(--box);padding:14px;border-radius:10px;white-space:pre-wrap;word-break:break-word;max-height:55vh;overflow:auto}
|
|
831
|
+
.row{display:flex;gap:12px;margin-top:20px}button{flex:1;font-size:17px;padding:14px;border:0;border-radius:10px;color:#fff;cursor:pointer}
|
|
832
|
+
.ok{background:var(--ok)}.no{background:var(--no)}.status{font-weight:600;margin-top:16px}
|
|
833
|
+
</style></head><body><main>
|
|
834
|
+
<h1>Muse wants Claude to run this</h1>
|
|
835
|
+
<div class="meta">Task ${esc(task.id.slice(0, 8))} · ${esc(task.createdAt)}${task.sessionId ? ` · continues session ${esc(task.sessionId.slice(0, 8))}` : ""}<br>
|
|
836
|
+
Claude will run with ${esc(cfg.permissionMode === "bypassPermissions" ? "full access to this PC" : `permission mode ${cfg.permissionMode}`)}.</div>
|
|
837
|
+
<pre>${esc(task.prompt)}</pre>
|
|
838
|
+
${message ? `<div class="status">${esc(message)}</div>` : ""}
|
|
839
|
+
${pending ? `<form method="post" class="row">
|
|
840
|
+
<button class="no" name="decision" value="deny">Deny</button>
|
|
841
|
+
<button class="ok" name="decision" value="approve">Approve</button></form>` : `<div class="status">Status: ${esc(task.status)}</div>`}
|
|
842
|
+
</main></body></html>`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function handleApproval(req, res, id, code, ip) {
|
|
846
|
+
const task = tasks[id];
|
|
847
|
+
const html = (status, body) => {
|
|
848
|
+
res.writeHead(status, {
|
|
849
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
850
|
+
"Cache-Control": "no-store",
|
|
851
|
+
"Referrer-Policy": "no-referrer",
|
|
852
|
+
"X-Frame-Options": "DENY",
|
|
853
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
|
|
854
|
+
});
|
|
855
|
+
res.end(body);
|
|
856
|
+
};
|
|
857
|
+
if (!task || !codeMatches(id, code)) {
|
|
858
|
+
audit("approval_bad_code", { id, ip });
|
|
859
|
+
return html(404, "<!doctype html><title>Not found</title>Not found");
|
|
860
|
+
}
|
|
861
|
+
// GET only shows the prompt (so link previews can't approve anything); POST records the decision.
|
|
862
|
+
if (req.method === "GET") return html(200, approvalPage(task));
|
|
863
|
+
if (req.method !== "POST") return html(405, "Method not allowed");
|
|
864
|
+
|
|
865
|
+
const decision = new URLSearchParams(await readBody(req, true)).get("decision");
|
|
866
|
+
if (task.status !== "awaiting_approval") return html(200, approvalPage(task, "Already decided."));
|
|
867
|
+
if (decision === "approve") {
|
|
868
|
+
audit("task_approved", { id, ip });
|
|
869
|
+
runTask(task);
|
|
870
|
+
return html(200, approvalPage(task, "Approved. Claude is running it now."));
|
|
871
|
+
}
|
|
872
|
+
if (decision === "deny") {
|
|
873
|
+
audit("task_denied", { id, ip });
|
|
874
|
+
update(task, { status: "denied" });
|
|
875
|
+
return html(200, approvalPage(task, "Denied."));
|
|
876
|
+
}
|
|
877
|
+
return html(400, approvalPage(task, "Unknown decision."));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ---- Side-thread viewer (local only) ----
|
|
881
|
+
|
|
882
|
+
const VIEWER_CSS = `
|
|
883
|
+
:root{color-scheme:light dark;--bg:#fafafa;--fg:#1a1a1a;--muted:#6b6b6b;--card:#fff;--line:#e5e5e5;
|
|
884
|
+
--muse:#2563eb;--side:#7c3aed;--desk:#0f766e;--user:#b45309}
|
|
885
|
+
@media (prefers-color-scheme:dark){:root{--bg:#111;--fg:#eee;--muted:#9a9a9a;--card:#1b1b1d;--line:#2c2c2f;
|
|
886
|
+
--muse:#60a5fa;--side:#a78bfa;--desk:#2dd4bf;--user:#fbbf24}}
|
|
887
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}
|
|
888
|
+
main{max-width:760px;margin:0 auto;padding:24px 16px}a{color:inherit}
|
|
889
|
+
h1{font-size:20px;margin:0 0 4px}.sub{color:var(--muted);font-size:13px;margin-bottom:18px}
|
|
890
|
+
.row{display:block;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--card);margin-bottom:8px;text-decoration:none}
|
|
891
|
+
.msg{border-left:3px solid var(--c);background:var(--card);border-radius:8px;padding:10px 14px;margin-bottom:10px}
|
|
892
|
+
.who{font-weight:600;color:var(--c);font-size:13px}.at{color:var(--muted);font-size:12px;margin-left:8px;font-weight:400}
|
|
893
|
+
.text{white-space:pre-wrap;word-break:break-word;margin-top:4px}
|
|
894
|
+
.muse{--c:var(--muse)}.side-claude{--c:var(--side)}.desktop-claude{--c:var(--desk)}.desktop-codex{--c:var(--desk)}.user{--c:var(--user)}
|
|
895
|
+
.legend span{margin-right:12px;font-size:13px;color:var(--c)}
|
|
896
|
+
form{display:flex;gap:8px;margin-top:16px}textarea{flex:1;min-height:60px;font:inherit;padding:8px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--fg)}
|
|
897
|
+
button{font:inherit;padding:8px 16px;border:0;border-radius:8px;background:var(--user);color:#fff;cursor:pointer}`;
|
|
898
|
+
|
|
899
|
+
function viewerPage(title, body, script = "") {
|
|
900
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
901
|
+
<title>${esc(title)}</title><style>${VIEWER_CSS}</style></head><body><main>${body}</main>${script}</body></html>`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const renderMsg = (m) =>
|
|
905
|
+
`<div class="msg ${esc(m.role)}"><div class="who">${esc(ROLE_LABEL[m.role] || m.role)}<span class="at">${esc(new Date(m.at).toLocaleString())}</span></div><div class="text">${esc(m.text)}</div></div>`;
|
|
906
|
+
|
|
907
|
+
async function handleViewer(req, res, url) {
|
|
908
|
+
const html = (status, body) => {
|
|
909
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Frame-Options": "DENY" });
|
|
910
|
+
res.end(body);
|
|
911
|
+
};
|
|
912
|
+
const legend = `<div class="legend">${Object.entries(ROLE_LABEL).map(([r, l]) => `<span class="${r}">■ ${l}</span>`).join("")}</div>`;
|
|
913
|
+
|
|
914
|
+
if (url.pathname === "/threads") {
|
|
915
|
+
const rows = listThreads().map((t) =>
|
|
916
|
+
`<a class="row" href="/threads/${t.session_id}"><b>${esc(t.title || t.session_id)}</b><div class="sub" style="margin:2px 0 0">${t.messages} messages · last from ${esc(ROLE_LABEL[t.last_role] || "—")} · ${esc(t.last_at ? new Date(t.last_at).toLocaleString() : "")}</div></a>`).join("");
|
|
917
|
+
return html(200, viewerPage("Side threads", `<h1>Side threads</h1><div class="sub">Agent-to-agent channels between Meta Muse and your PC sessions, kept out of your own conversations.</div>${legend}<br>${rows || "<p>No side threads yet.</p>"}`));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const m = url.pathname.match(/^\/threads\/([0-9a-f-]{36})(\.json)?$/i);
|
|
921
|
+
if (!m) return html(404, "Not found");
|
|
922
|
+
const sessionId = m[1];
|
|
923
|
+
|
|
924
|
+
if (req.method === "POST") {
|
|
925
|
+
// Only the viewer page itself may post (blocks other websites from posting via your browser).
|
|
926
|
+
const origin = String(req.headers.origin || "");
|
|
927
|
+
if (![...LOCAL_HOSTS].some((h) => origin === `http://${h}`)) return html(403, "Forbidden");
|
|
928
|
+
const text = new URLSearchParams(await readBody(req, true)).get("text")?.trim();
|
|
929
|
+
if (text) { appendMessage(sessionId, "user", text.slice(0, 20_000)); audit("side_message_user", { sessionId }); }
|
|
930
|
+
res.writeHead(303, { Location: `/threads/${sessionId}` });
|
|
931
|
+
return res.end();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const msgs = readThread(sessionId);
|
|
935
|
+
if (m[2]) return send(res, 200, { html: msgs.map(renderMsg).join(""), count: msgs.length });
|
|
936
|
+
const title = getMeta(sessionId).title || sessionId;
|
|
937
|
+
const script = `<script>
|
|
938
|
+
let n=${msgs.length};setInterval(async()=>{try{const r=await fetch(location.pathname+".json");const d=await r.json();
|
|
939
|
+
if(d.count!==n){n=d.count;document.getElementById("msgs").innerHTML=d.html;window.scrollTo(0,document.body.scrollHeight);}}catch{}},4000);
|
|
940
|
+
window.scrollTo(0,document.body.scrollHeight);</script>`;
|
|
941
|
+
return html(200, viewerPage(`Side thread: ${title}`,
|
|
942
|
+
`<a href="/threads" class="sub">← all side threads</a><h1>${esc(title)}</h1><div class="sub">Side thread · separate from your own conversation in this session · updates live</div>${legend}<br>
|
|
943
|
+
<div id="msgs">${msgs.map(renderMsg).join("") || "<p>No messages yet.</p>"}</div>
|
|
944
|
+
<form method="post"><textarea name="text" placeholder="Post to this side thread as the user…"></textarea><button>Post as user</button></form>`, script));
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---- HTTP plumbing ----
|
|
948
|
+
|
|
949
|
+
const send = (res, status, body, headers = {}) => {
|
|
950
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
951
|
+
res.end(body === undefined ? "" : JSON.stringify(body));
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
// No lockout: a 256-bit token can't be brute-forced, and a global lockout would let internet
|
|
955
|
+
// scanners lock Muse out. Only real token attempts (an Authorization header) are audited, not scanner noise.
|
|
956
|
+
function authorized(req, ip) {
|
|
957
|
+
const got = Buffer.from((req.headers.authorization || "").replace(/^Bearer\s+/i, ""));
|
|
958
|
+
const want = Buffer.from(cfg.token);
|
|
959
|
+
const ok = got.length === want.length && crypto.timingSafeEqual(got, want);
|
|
960
|
+
if (!ok && req.headers.authorization) audit("auth_failed", { ip });
|
|
961
|
+
return ok;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function readBody(req, raw = false, maxBytes = 1_000_000) {
|
|
965
|
+
let data = "";
|
|
966
|
+
let size = 0;
|
|
967
|
+
for await (const chunk of req) {
|
|
968
|
+
size += chunk.length;
|
|
969
|
+
if (size > maxBytes) throw Object.assign(new Error("Body too large"), { statusCode: 413 });
|
|
970
|
+
data += chunk;
|
|
971
|
+
}
|
|
972
|
+
if (raw) return data;
|
|
973
|
+
return data ? JSON.parse(data) : {};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// IP allowlist (config.allowedIps: CIDRs or single IPs; empty = allow any IP).
|
|
977
|
+
// The server only listens on 127.0.0.1, so every request arrives via cloudflared, and the real
|
|
978
|
+
// caller's address is in CF-Connecting-IP, which Cloudflare's edge sets and clients cannot override.
|
|
979
|
+
const allowList = new net.BlockList();
|
|
980
|
+
for (const entry of cfg.allowedIps || []) {
|
|
981
|
+
const [addr, bits] = entry.split("/");
|
|
982
|
+
const type = net.isIPv6(addr) ? "ipv6" : "ipv4";
|
|
983
|
+
if (bits) allowList.addSubnet(addr, Number(bits), type);
|
|
984
|
+
else allowList.addAddress(addr, type);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (cfg.allowedIps?.length && !cfg.ipHeader) {
|
|
988
|
+
console.error("allowedIps is set but ipHeader is null: the caller's IP can't be verified behind this tunnel. Clear allowedIps or set ipHeader.");
|
|
989
|
+
process.exit(1);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function clientIp(req) {
|
|
993
|
+
const trusted = cfg.ipHeader && req.headers[cfg.ipHeader.toLowerCase()];
|
|
994
|
+
if (trusted) return String(trusted).replace(/^::ffff:/, "");
|
|
995
|
+
// No trusted header: show what the proxy forwarded, marked unverified (for logs only, never for access checks).
|
|
996
|
+
const fwd = req.headers["x-forwarded-for"];
|
|
997
|
+
return fwd ? `${String(fwd).split(",").pop().trim()}(unverified)` : String(req.socket.remoteAddress || "").replace(/^::ffff:/, "");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function ipAllowed(ip) {
|
|
1001
|
+
if (!cfg.allowedIps?.length) return true;
|
|
1002
|
+
if (!net.isIP(ip)) return false;
|
|
1003
|
+
return allowList.check(ip, net.isIPv6(ip) ? "ipv6" : "ipv4");
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const server = http.createServer(async (req, res) => {
|
|
1007
|
+
const url = new URL(req.url, "http://x");
|
|
1008
|
+
const proto = req.headers["x-forwarded-proto"] || "http";
|
|
1009
|
+
const baseUrl = `${proto}://${req.headers.host}`;
|
|
1010
|
+
const ip = clientIp(req);
|
|
1011
|
+
const log = (s) => console.log(`${new Date().toISOString()} ${ip} ${req.method} ${url.pathname.replace(/^(\/approve\/[^/]+)\/.*/, "$1/***")} ${s}`);
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
// Only the tunnel's hostname (or localhost for testing) is accepted. Blocks spoofed Host headers
|
|
1015
|
+
// and DNS-rebinding attempts from web pages in a local browser.
|
|
1016
|
+
if (cfg.publicUrl && !allowedHosts.has(String(req.headers.host || "").toLowerCase())) {
|
|
1017
|
+
log(`421 bad host ${req.headers.host}`);
|
|
1018
|
+
return send(res, 421, { error: "Misdirected request" });
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (url.pathname === "/threads" || url.pathname.startsWith("/threads/")) {
|
|
1022
|
+
// Local-only: through the tunnel the Host is the public name, so this 404s for everyone outside the PC.
|
|
1023
|
+
if (!LOCAL_HOSTS.has(String(req.headers.host || "").toLowerCase())) return send(res, 404, { error: "Not found" });
|
|
1024
|
+
return await handleViewer(req, res, url);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const approval = url.pathname.match(/^\/approve\/([\w-]+)\/([\w-]+)$/);
|
|
1028
|
+
if (approval) {
|
|
1029
|
+
log("approval page");
|
|
1030
|
+
return await handleApproval(req, res, approval[1], approval[2], ip);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (!ipAllowed(ip)) {
|
|
1034
|
+
log("403 ip not allowed");
|
|
1035
|
+
return send(res, 403, { error: "Forbidden" });
|
|
1036
|
+
}
|
|
1037
|
+
if (url.pathname === "/health") return send(res, 200, { ok: true });
|
|
1038
|
+
if (url.pathname === "/openapi.json") return send(res, 200, openapi(baseUrl));
|
|
1039
|
+
|
|
1040
|
+
if (!authorized(req, ip)) {
|
|
1041
|
+
log("401");
|
|
1042
|
+
return send(res, 401, { error: "Missing or wrong bearer token" }, { "WWW-Authenticate": "Bearer" });
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (url.pathname === "/api/upload_file") {
|
|
1046
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST" }, { Allow: "POST" });
|
|
1047
|
+
try {
|
|
1048
|
+
const meta = storeSharedFile(await readBody(req, false, MAX_UPLOAD_BODY_BYTES));
|
|
1049
|
+
audit("file_uploaded", { fileId: meta.file_id, name: meta.name, size: meta.size, ip });
|
|
1050
|
+
log(`upload_file ${meta.file_id} ${meta.size} bytes`);
|
|
1051
|
+
return send(res, 201, meta);
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
return send(res, e.statusCode || 400, { error: e.message });
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (url.pathname === "/api/download_file") {
|
|
1058
|
+
if (req.method !== "GET") return send(res, 405, { error: "Use GET" }, { Allow: "GET" });
|
|
1059
|
+
const meta = fileMetadata(url.searchParams.get("file_id"));
|
|
1060
|
+
if (!meta) return send(res, 404, { error: "File not found" });
|
|
1061
|
+
const content = fs.readFileSync(fileDataPath(meta.file_id));
|
|
1062
|
+
const fallbackName = meta.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
1063
|
+
const encodedName = encodeURIComponent(meta.name).replace(/['()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
1064
|
+
audit("file_downloaded", { fileId: meta.file_id, name: meta.name, size: meta.size, ip });
|
|
1065
|
+
log(`download_file ${meta.file_id}`);
|
|
1066
|
+
res.writeHead(200, {
|
|
1067
|
+
"Content-Type": meta.content_type,
|
|
1068
|
+
"Content-Length": content.length,
|
|
1069
|
+
"Content-Disposition": `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodedName}`,
|
|
1070
|
+
"Cache-Control": "no-store",
|
|
1071
|
+
"X-Content-Type-Options": "nosniff",
|
|
1072
|
+
});
|
|
1073
|
+
return res.end(content);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (url.pathname === "/api/list_files") {
|
|
1077
|
+
if (req.method !== "GET") return send(res, 405, { error: "Use GET" }, { Allow: "GET" });
|
|
1078
|
+
log("list_files");
|
|
1079
|
+
return send(res, 200, { files: listSharedFiles() });
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (url.pathname === "/api/delete_file") {
|
|
1083
|
+
if (req.method !== "DELETE") return send(res, 405, { error: "Use DELETE" }, { Allow: "DELETE" });
|
|
1084
|
+
const meta = fileMetadata(url.searchParams.get("file_id"));
|
|
1085
|
+
if (!meta) return send(res, 404, { error: "File not found" });
|
|
1086
|
+
fs.rmSync(fileDataPath(meta.file_id), { force: true });
|
|
1087
|
+
fs.rmSync(fileMetaPath(meta.file_id), { force: true });
|
|
1088
|
+
audit("file_deleted", { fileId: meta.file_id, name: meta.name, size: meta.size, ip });
|
|
1089
|
+
log(`delete_file ${meta.file_id}`);
|
|
1090
|
+
return send(res, 200, { deleted: true, file_id: meta.file_id });
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const ctx = { baseUrl, ip };
|
|
1094
|
+
if (url.pathname === "/mcp") {
|
|
1095
|
+
if (req.method === "GET") return send(res, 405, { error: "SSE stream not supported; POST JSON-RPC instead" }, { Allow: "POST" });
|
|
1096
|
+
if (req.method === "DELETE") return send(res, 200, {});
|
|
1097
|
+
if (req.method !== "POST") return send(res, 405, { error: "Use POST" });
|
|
1098
|
+
const body = await readBody(req);
|
|
1099
|
+
const batch = Array.isArray(body) ? body : [body];
|
|
1100
|
+
const replies = (await Promise.all(batch.map((m) => handleMcp(m, ctx)))).filter(Boolean);
|
|
1101
|
+
log(batch.map((m) => m.method + (m.params?.name ? `:${m.params.name}` : "")).join(","));
|
|
1102
|
+
const headers = batch.some((m) => m.method === "initialize") ? { "Mcp-Session-Id": crypto.randomUUID() } : {};
|
|
1103
|
+
if (!replies.length) return send(res, 202, undefined, headers);
|
|
1104
|
+
return send(res, 200, Array.isArray(body) ? replies : replies[0], headers);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const m = url.pathname.match(/^\/api\/(\w+)$/);
|
|
1108
|
+
const tool = m && tools.find((t) => t.name === m[1]);
|
|
1109
|
+
if (tool && req.method === "POST") {
|
|
1110
|
+
log(tool.name);
|
|
1111
|
+
try {
|
|
1112
|
+
return send(res, 200, await tool.run(await readBody(req), ctx));
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
return send(res, 400, { error: e.message });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return send(res, 404, { error: "Not found" });
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
log(`500 ${e.message}`);
|
|
1120
|
+
return send(res, 500, { error: "Internal error" });
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
server.listen(cfg.port, "127.0.0.1", () => {
|
|
1125
|
+
console.log(`Claude bridge listening on http://127.0.0.1:${cfg.port}`);
|
|
1126
|
+
console.log(`Workspace: ${cfg.workdir}`);
|
|
1127
|
+
console.log(`Permission mode: ${cfg.permissionMode} Approval required: ${cfg.requireApproval}`);
|
|
1128
|
+
console.log(`Approval notifications: subscribe to "${cfg.ntfyTopic}" on ${cfg.ntfyServer} in the ntfy app`);
|
|
1129
|
+
console.log(`Public URL: ${cfg.publicUrl || "(not pinned)"}`);
|
|
1130
|
+
console.log(`IP allowlist: ${cfg.allowedIps?.length ? cfg.allowedIps.join(", ") : "(off)"}`);
|
|
1131
|
+
});
|