@chenacc/agentbridge 0.0.0-stage → 0.1.1
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 +44 -0
- package/LICENSE +21 -0
- package/README.md +308 -2
- package/bin/agentbridge.js +7 -0
- package/docs/RELEASING.md +47 -0
- package/package.json +65 -4
- package/public/adapter-capabilities.js +29 -0
- package/public/app.js +2211 -0
- package/public/background.jpg +0 -0
- package/public/command-id.js +16 -0
- package/public/highlight.js +125 -0
- package/public/index.html +339 -0
- package/public/markdown.js +291 -0
- package/public/styles.css +1073 -0
- package/public/transcript.js +368 -0
- package/public/workspace-navigation.js +241 -0
- package/src/adapter-registry.js +23 -0
- package/src/adapters/capabilities.js +36 -0
- package/src/adapters/claude.js +493 -0
- package/src/adapters/codex.js +529 -0
- package/src/adapters/deepseek-harness.js +413 -0
- package/src/adapters/dsh-desktop.js +322 -0
- package/src/adapters/echo.js +57 -0
- package/src/adapters/process-utils.js +125 -0
- package/src/adapters/unavailable.js +30 -0
- package/src/adapters/zcode-native.js +40 -0
- package/src/attachments.js +187 -0
- package/src/cc-switch-models.js +86 -0
- package/src/cli.js +199 -0
- package/src/config.js +195 -0
- package/src/conversations/conversation-catalog.js +283 -0
- package/src/dsh-desktop-channels.js +460 -0
- package/src/event-store.js +330 -0
- package/src/notifier.js +266 -0
- package/src/pairing.js +167 -0
- package/src/server.js +964 -0
- package/src/session-manager.js +1355 -0
- package/src/workspaces/workspace-catalog.js +196 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import QRCode from "qrcode";
|
|
7
|
+
import { loadConfig, isLoopback } from "./config.js";
|
|
8
|
+
import { EventStore } from "./event-store.js";
|
|
9
|
+
import { PairingManager } from "./pairing.js";
|
|
10
|
+
import { AdapterRegistry } from "./adapter-registry.js";
|
|
11
|
+
import { SessionManager } from "./session-manager.js";
|
|
12
|
+
import { EchoAdapter } from "./adapters/echo.js";
|
|
13
|
+
import { CodexAdapter } from "./adapters/codex.js";
|
|
14
|
+
import { ClaudeAdapter } from "./adapters/claude.js";
|
|
15
|
+
import { DeepSeekHarnessAdapter } from "./adapters/deepseek-harness.js";
|
|
16
|
+
import { DshDesktopAdapter } from "./adapters/dsh-desktop.js";
|
|
17
|
+
import { ZCodeNativeAdapter } from "./adapters/zcode-native.js";
|
|
18
|
+
import { DshDesktopChannelManager } from "./dsh-desktop-channels.js";
|
|
19
|
+
import { WorkspaceCatalog } from "./workspaces/workspace-catalog.js";
|
|
20
|
+
import { ConversationCatalog } from "./conversations/conversation-catalog.js";
|
|
21
|
+
import { Notifier } from "./notifier.js";
|
|
22
|
+
import { AttachmentStore, isImage } from "./attachments.js";
|
|
23
|
+
import { notificationLink, writeNotifyConfig } from "./config.js";
|
|
24
|
+
|
|
25
|
+
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const publicDir = path.resolve(sourceDir, "../public");
|
|
27
|
+
|
|
28
|
+
// How much of the newest event stream a fresh page receives before it asks for
|
|
29
|
+
// one conversation's history on demand.
|
|
30
|
+
const INITIAL_REPLAY_EVENTS = 500;
|
|
31
|
+
|
|
32
|
+
function json(res, statusCode, value, headers = {}) {
|
|
33
|
+
res.writeHead(statusCode, {
|
|
34
|
+
"content-type": "application/json; charset=utf-8",
|
|
35
|
+
"cache-control": "no-store",
|
|
36
|
+
...headers,
|
|
37
|
+
});
|
|
38
|
+
res.end(JSON.stringify(value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function errorResponse(res, error) {
|
|
42
|
+
const statusCode = Number.isInteger(error.statusCode) ? error.statusCode : 500;
|
|
43
|
+
const payload = { error: statusCode === 500 ? "Internal server error" : error.message };
|
|
44
|
+
if (statusCode !== 500 && typeof error.code === "string") payload.code = error.code;
|
|
45
|
+
if (statusCode !== 500 && error.diagnostics && typeof error.diagnostics === "object") payload.diagnostics = error.diagnostics;
|
|
46
|
+
json(res, statusCode, payload);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseCookies(header = "") {
|
|
50
|
+
const cookies = {};
|
|
51
|
+
for (const item of header.split(";")) {
|
|
52
|
+
const index = item.indexOf("=");
|
|
53
|
+
if (index > 0) cookies[item.slice(0, index).trim()] = decodeURIComponent(item.slice(index + 1).trim());
|
|
54
|
+
}
|
|
55
|
+
return cookies;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requestToken(req) {
|
|
59
|
+
const authorization = req.headers.authorization;
|
|
60
|
+
if (authorization?.startsWith("Bearer ")) return authorization.slice(7);
|
|
61
|
+
return parseCookies(req.headers.cookie).agentbridge_session ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Raw upload body: attachments are binary, so they get their own reader. */
|
|
65
|
+
async function readBinary(req, maxBytes) {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let size = 0;
|
|
68
|
+
for await (const chunk of req) {
|
|
69
|
+
size += chunk.length;
|
|
70
|
+
if (size > maxBytes) throw Object.assign(new Error("Request body is too large"), { statusCode: 413 });
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
}
|
|
73
|
+
return Buffer.concat(chunks);
|
|
74
|
+
}
|
|
75
|
+
async function readJson(req, maxBytes = 64 * 1024) {
|
|
76
|
+
if (!String(req.headers["content-type"] ?? "").startsWith("application/json")) {
|
|
77
|
+
throw Object.assign(new Error("Content-Type must be application/json"), { statusCode: 415 });
|
|
78
|
+
}
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let size = 0;
|
|
81
|
+
for await (const chunk of req) {
|
|
82
|
+
size += chunk.length;
|
|
83
|
+
if (size > maxBytes) throw Object.assign(new Error("Request body is too large"), { statusCode: 413 });
|
|
84
|
+
chunks.push(chunk);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
88
|
+
} catch {
|
|
89
|
+
throw Object.assign(new Error("Invalid JSON body"), { statusCode: 400 });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireSameOrigin(req) {
|
|
94
|
+
const origin = req.headers.origin;
|
|
95
|
+
if (!origin) return;
|
|
96
|
+
let originHost;
|
|
97
|
+
try { originHost = new URL(origin).host; } catch { originHost = ""; }
|
|
98
|
+
if (originHost !== req.headers.host) {
|
|
99
|
+
throw Object.assign(new Error("Cross-origin state changes are not allowed"), { statusCode: 403 });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sessionCookie(token, secure, sessionTtlMs) {
|
|
104
|
+
const maxAge = Math.max(1, Math.floor(sessionTtlMs / 1000));
|
|
105
|
+
return `agentbridge_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function accessKeyCookie(key, secure) {
|
|
109
|
+
return `agentbridge_key=${encodeURIComponent(key)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=7776000${secure ? "; Secure" : ""}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function keyMatches(presented, accessKey) {
|
|
113
|
+
if (!presented) return false;
|
|
114
|
+
const candidate = createHash("sha256").update(String(presented)).digest();
|
|
115
|
+
const expected = createHash("sha256").update(accessKey).digest();
|
|
116
|
+
return timingSafeEqual(candidate, expected);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// When AGENTBRIDGE_ACCESS_KEY is set, every non-loopback request must carry the
|
|
120
|
+
// key (pairing-URL query param, x-agentbridge-key header, or the cookie set by
|
|
121
|
+
// /api/access). Loopback traffic is exempt so local tooling keeps working.
|
|
122
|
+
export function hasAccessKey(req, url, config) {
|
|
123
|
+
if (!config.accessKey) return true;
|
|
124
|
+
if (isLoopback(req.socket.remoteAddress)) return true;
|
|
125
|
+
const presented = url.searchParams.get("key")
|
|
126
|
+
?? req.headers["x-agentbridge-key"]
|
|
127
|
+
?? parseCookies(req.headers.cookie).agentbridge_key;
|
|
128
|
+
return keyMatches(presented, config.accessKey);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function requireAccessKey(req, url, config) {
|
|
132
|
+
if (!hasAccessKey(req, url, config)) {
|
|
133
|
+
throw Object.assign(new Error("Access key required"), { statusCode: 401 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function registerLocalProject(workspaceCatalog, projects, requestedPath) {
|
|
138
|
+
const workspace = await workspaceCatalog.register({ requestedPath, source: "local" });
|
|
139
|
+
projects.set(workspace.alias, workspace.canonicalPath);
|
|
140
|
+
return {
|
|
141
|
+
id: workspace.id,
|
|
142
|
+
alias: workspace.alias,
|
|
143
|
+
name: workspace.name,
|
|
144
|
+
cwd: workspace.canonicalPath,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// KaTeX ships its own CSS and fonts. Serving them from the installed package
|
|
149
|
+
// keeps formula rendering local: nothing about this bridge is fetched from a CDN.
|
|
150
|
+
const KATEX_DIR = path.resolve(sourceDir, "../node_modules/katex/dist");
|
|
151
|
+
const KATEX_TYPES = {
|
|
152
|
+
".js": "text/javascript; charset=utf-8",
|
|
153
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
154
|
+
".css": "text/css; charset=utf-8",
|
|
155
|
+
".woff2": "font/woff2",
|
|
156
|
+
".woff": "font/woff",
|
|
157
|
+
".ttf": "font/ttf",
|
|
158
|
+
};
|
|
159
|
+
const CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data:; "
|
|
160
|
+
// KaTeX positions every glyph with an inline style attribute, so formulas need
|
|
161
|
+
// inline styles. Scripts stay restricted to same-origin files.
|
|
162
|
+
+ "style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
163
|
+
|
|
164
|
+
async function serveKatex(res, pathname) {
|
|
165
|
+
let relative;
|
|
166
|
+
try {
|
|
167
|
+
relative = decodeURIComponent(pathname.slice("/vendor/katex/".length));
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (!relative || relative.includes("..") || relative.includes("\\") || relative.includes("\0")) return false;
|
|
172
|
+
const type = KATEX_TYPES[path.extname(relative).toLowerCase()];
|
|
173
|
+
if (!type) return false;
|
|
174
|
+
const filePath = path.resolve(KATEX_DIR, relative);
|
|
175
|
+
if (!filePath.startsWith(KATEX_DIR + path.sep)) return false;
|
|
176
|
+
let content;
|
|
177
|
+
try {
|
|
178
|
+
content = await readFile(filePath);
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
res.writeHead(200, {
|
|
183
|
+
"content-type": type,
|
|
184
|
+
"cache-control": "no-store",
|
|
185
|
+
"x-content-type-options": "nosniff",
|
|
186
|
+
});
|
|
187
|
+
res.end(content);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Immutable caching for the shell's own assets.
|
|
193
|
+
*
|
|
194
|
+
* Everything used to be `no-store`, which meant a phone re-downloaded the app and
|
|
195
|
+
* the 456 KB background on every single visit — the most expensive part of opening
|
|
196
|
+
* the console over a tunnel. Each asset now carries a content hash in its URL
|
|
197
|
+
* (`/app.js?v=ab12cd34`), the HTML itself stays `no-store`, and a request whose
|
|
198
|
+
* version matches is cached for a year. Changing a file changes its hash, so there
|
|
199
|
+
* is no stale-asset failure mode: the shell always asks for the current one.
|
|
200
|
+
*/
|
|
201
|
+
const ASSET_VERSIONS = new Map();
|
|
202
|
+
// The rewritten shell is memoised, but keyed on the source files' mtime+size: editing a
|
|
203
|
+
// file under public/ must take effect on the next request, exactly as it did before
|
|
204
|
+
// versioning existed. Only a real content change pays for the rebuild.
|
|
205
|
+
const SHELL_SOURCES = [
|
|
206
|
+
"index.html", "styles.css", "app.js", "adapter-capabilities.js", "workspace-navigation.js",
|
|
207
|
+
"markdown.js", "highlight.js", "transcript.js", "command-id.js", "background.jpg",
|
|
208
|
+
];
|
|
209
|
+
let SHELL_HTML = null;
|
|
210
|
+
let SHELL_KEY = null;
|
|
211
|
+
|
|
212
|
+
async function currentShell() {
|
|
213
|
+
const parts = await Promise.all(SHELL_SOURCES.map(async (file) => {
|
|
214
|
+
const info = await stat(path.join(publicDir, file));
|
|
215
|
+
return `${file}:${info.mtimeMs}:${info.size}`;
|
|
216
|
+
}));
|
|
217
|
+
const key = parts.join("|");
|
|
218
|
+
if (SHELL_HTML && key === SHELL_KEY) return SHELL_HTML;
|
|
219
|
+
SHELL_HTML = await buildShell();
|
|
220
|
+
SHELL_KEY = key;
|
|
221
|
+
return SHELL_HTML;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function assetVersion(content) {
|
|
225
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 10);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function buildShell() {
|
|
229
|
+
const read = (file) => readFile(path.join(publicDir, file));
|
|
230
|
+
const [html, css, app, adapterCaps, navigation, markdown, highlight, transcript, commandId, background] = await Promise.all([
|
|
231
|
+
read("index.html"), read("styles.css"), read("app.js"), read("adapter-capabilities.js"),
|
|
232
|
+
read("workspace-navigation.js"), read("markdown.js"), read("highlight.js"),
|
|
233
|
+
read("transcript.js"), read("command-id.js"), read("background.jpg"),
|
|
234
|
+
]);
|
|
235
|
+
// One version for the whole JS bundle graph. Hashing each module separately would
|
|
236
|
+
// need a transitive pass (a change in markdown.js must also invalidate transcript.js,
|
|
237
|
+
// which imports it), and the modules are a few dozen KB — so one stamp is both
|
|
238
|
+
// simpler and correct. The photo gets its own, because it is the only large asset
|
|
239
|
+
// and the stylesheet can carry its version.
|
|
240
|
+
const jsBundle = Buffer.concat([app, adapterCaps, navigation, markdown, highlight, transcript, commandId]);
|
|
241
|
+
const jsVersion = assetVersion(jsBundle);
|
|
242
|
+
const photoVersion = assetVersion(background);
|
|
243
|
+
const cssText = css.toString("utf8").replace(/url\("\/background\.jpg"\)/g, `url("/background.jpg?v=${photoVersion}")`);
|
|
244
|
+
const cssVersion = assetVersion(Buffer.from(cssText, "utf8"));
|
|
245
|
+
for (const file of ["app.js", "adapter-capabilities.js", "workspace-navigation.js", "markdown.js", "highlight.js", "transcript.js", "command-id.js"]) {
|
|
246
|
+
ASSET_VERSIONS.set(`/${file}`, jsVersion);
|
|
247
|
+
}
|
|
248
|
+
ASSET_VERSIONS.set("/styles.css", cssVersion);
|
|
249
|
+
ASSET_VERSIONS.set("/background.jpg", photoVersion);
|
|
250
|
+
|
|
251
|
+
let appText = app.toString("utf8");
|
|
252
|
+
for (const file of ["adapter-capabilities.js", "workspace-navigation.js", "markdown.js", "highlight.js", "transcript.js", "command-id.js"]) {
|
|
253
|
+
appText = appText.split(`from "/${file}"`).join(`from "/${file}?v=${jsVersion}"`);
|
|
254
|
+
}
|
|
255
|
+
// transcript.js imports markdown.js and highlight.js, which app.js never mentions.
|
|
256
|
+
const transcriptText = transcript.toString("utf8")
|
|
257
|
+
.split('from "/markdown.js"').join(`from "/markdown.js?v=${jsVersion}"`)
|
|
258
|
+
.split('from "/highlight.js"').join(`from "/highlight.js?v=${jsVersion}"`);
|
|
259
|
+
|
|
260
|
+
let shell = html.toString("utf8");
|
|
261
|
+
shell = shell.split('"/styles.css"').join(`"/styles.css?v=${cssVersion}"`);
|
|
262
|
+
shell = shell.split('"/app.js"').join(`"/app.js?v=${jsVersion}"`);
|
|
263
|
+
return { shell, cssText, appText, transcriptText, jsVersion, cssVersion, photoVersion };
|
|
264
|
+
}
|
|
265
|
+
async function serveStatic(res, pathname, search) {
|
|
266
|
+
if (pathname.startsWith("/vendor/katex/")) return serveKatex(res, pathname);
|
|
267
|
+
const files = {
|
|
268
|
+
"/": ["index.html", "text/html; charset=utf-8"],
|
|
269
|
+
"/pair": ["index.html", "text/html; charset=utf-8"],
|
|
270
|
+
"/app.js": ["app.js", "text/javascript; charset=utf-8"],
|
|
271
|
+
"/adapter-capabilities.js": ["adapter-capabilities.js", "text/javascript; charset=utf-8"],
|
|
272
|
+
"/workspace-navigation.js": ["workspace-navigation.js", "text/javascript; charset=utf-8"],
|
|
273
|
+
"/markdown.js": ["markdown.js", "text/javascript; charset=utf-8"],
|
|
274
|
+
"/highlight.js": ["highlight.js", "text/javascript; charset=utf-8"],
|
|
275
|
+
"/transcript.js": ["transcript.js", "text/javascript; charset=utf-8"],
|
|
276
|
+
"/command-id.js": ["command-id.js", "text/javascript; charset=utf-8"],
|
|
277
|
+
"/styles.css": ["styles.css", "text/css; charset=utf-8"],
|
|
278
|
+
"/background.jpg": ["background.jpg", "image/jpeg"],
|
|
279
|
+
};
|
|
280
|
+
const target = files[pathname];
|
|
281
|
+
if (!target) return false;
|
|
282
|
+
const version = new URLSearchParams(search ?? "").get("v");
|
|
283
|
+
const currentVersion = ASSET_VERSIONS.get(pathname);
|
|
284
|
+
const immutable = Boolean(version && currentVersion && version === currentVersion);
|
|
285
|
+
// The stylesheet is rewritten so its own URL only changes when the photo does.
|
|
286
|
+
const shell = await currentShell();
|
|
287
|
+
const content = pathname === "/styles.css"
|
|
288
|
+
? Buffer.from(shell.cssText, "utf8")
|
|
289
|
+
: pathname === "/app.js"
|
|
290
|
+
? Buffer.from(shell.appText, "utf8")
|
|
291
|
+
: pathname === "/transcript.js"
|
|
292
|
+
? Buffer.from(shell.transcriptText, "utf8")
|
|
293
|
+
: pathname === "/" || pathname === "/pair"
|
|
294
|
+
? Buffer.from(shell.shell, "utf8")
|
|
295
|
+
: await readFile(path.join(publicDir, target[0]));
|
|
296
|
+
res.writeHead(200, {
|
|
297
|
+
"content-type": target[1],
|
|
298
|
+
"cache-control": immutable
|
|
299
|
+
? "public, max-age=31536000, immutable"
|
|
300
|
+
: "no-store",
|
|
301
|
+
"content-security-policy": CONTENT_SECURITY_POLICY,
|
|
302
|
+
"x-content-type-options": "nosniff",
|
|
303
|
+
"referrer-policy": "no-referrer",
|
|
304
|
+
});
|
|
305
|
+
res.end(content);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function createBridge(options = {}) {
|
|
310
|
+
const config = options.config ?? loadConfig();
|
|
311
|
+
const workspaceCatalog = options.workspaceCatalog ?? new WorkspaceCatalog({
|
|
312
|
+
filePath: path.join(config.dataDir, "workspaces.json"),
|
|
313
|
+
initialProjects: config.projects,
|
|
314
|
+
});
|
|
315
|
+
await workspaceCatalog.init();
|
|
316
|
+
for (const workspace of workspaceCatalog.list()) {
|
|
317
|
+
if (workspace.enabled) config.projects.set(workspace.alias, workspace.canonicalPath);
|
|
318
|
+
}
|
|
319
|
+
const eventStore = options.eventStore ?? new EventStore({ filePath: path.join(config.dataDir, "events.jsonl") });
|
|
320
|
+
await eventStore.init();
|
|
321
|
+
const conversationCatalog = options.conversationCatalog ?? new ConversationCatalog({
|
|
322
|
+
filePath: path.join(config.dataDir, "bridge-sessions.json"),
|
|
323
|
+
});
|
|
324
|
+
await conversationCatalog.init();
|
|
325
|
+
const pairing = options.pairing ?? new PairingManager({
|
|
326
|
+
...config,
|
|
327
|
+
storePath: config.dataDir ? path.join(config.dataDir, "sessions.json") : null,
|
|
328
|
+
});
|
|
329
|
+
const registry = options.registry ?? new AdapterRegistry();
|
|
330
|
+
const dshDesktopChannels = options.dshDesktopChannels ?? new DshDesktopChannelManager();
|
|
331
|
+
if (!options.registry) {
|
|
332
|
+
registry.register(new EchoAdapter());
|
|
333
|
+
registry.register(new CodexAdapter());
|
|
334
|
+
registry.register(new ClaudeAdapter());
|
|
335
|
+
registry.register(new DeepSeekHarnessAdapter());
|
|
336
|
+
registry.register(new DshDesktopAdapter({ channels: dshDesktopChannels }));
|
|
337
|
+
registry.register(new ZCodeNativeAdapter());
|
|
338
|
+
}
|
|
339
|
+
// Notifications are optional: an unset webhook keeps the notifier inert.
|
|
340
|
+
const notifier = options.notifier ?? new Notifier({
|
|
341
|
+
webhookUrl: config.notifyWebhook,
|
|
342
|
+
format: config.notifyFormat,
|
|
343
|
+
chatId: config.notifyChatId,
|
|
344
|
+
token: config.notifyToken,
|
|
345
|
+
channel: config.notifyChannel,
|
|
346
|
+
muted: config.notifyMuted,
|
|
347
|
+
linkUrl: notificationLink(config),
|
|
348
|
+
logger: console,
|
|
349
|
+
});
|
|
350
|
+
// One read of the shell per process; index.html and styles.css are rewritten from
|
|
351
|
+
// it, so a deploy changes hashes without a build step.
|
|
352
|
+
await currentShell();
|
|
353
|
+
const attachments = options.attachments ?? new AttachmentStore({ dataDir: config.dataDir });
|
|
354
|
+
await attachments.init();
|
|
355
|
+
const sessions = options.sessions ?? new SessionManager({
|
|
356
|
+
eventStore,
|
|
357
|
+
registry,
|
|
358
|
+
projects: config.projects,
|
|
359
|
+
conversationCatalog,
|
|
360
|
+
workspaceCatalog,
|
|
361
|
+
notifier,
|
|
362
|
+
attachments,
|
|
363
|
+
});
|
|
364
|
+
let currentPairing = pairing.createPairing(config.advertisedOrigin);
|
|
365
|
+
const dshQrTargets = new Map();
|
|
366
|
+
let shuttingDown = false;
|
|
367
|
+
let shutdownPromise = null;
|
|
368
|
+
|
|
369
|
+
const shutdown = () => {
|
|
370
|
+
if (shutdownPromise) return shutdownPromise;
|
|
371
|
+
shuttingDown = true;
|
|
372
|
+
shutdownPromise = (async () => {
|
|
373
|
+
await sessions.shutdown();
|
|
374
|
+
await new Promise((resolve, reject) => {
|
|
375
|
+
const forceClose = setTimeout(() => server.closeAllConnections?.(), 1_500);
|
|
376
|
+
forceClose.unref?.();
|
|
377
|
+
server.close((error) => {
|
|
378
|
+
clearTimeout(forceClose);
|
|
379
|
+
if (error) reject(error);
|
|
380
|
+
else resolve();
|
|
381
|
+
});
|
|
382
|
+
server.closeIdleConnections?.();
|
|
383
|
+
});
|
|
384
|
+
await options.onShutdown?.();
|
|
385
|
+
})();
|
|
386
|
+
return shutdownPromise;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const server = http.createServer(async (req, res) => {
|
|
390
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
391
|
+
try {
|
|
392
|
+
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
393
|
+
return json(res, shuttingDown ? 503 : 200, { ok: !shuttingDown, shuttingDown, lastSeq: eventStore.lastSeq });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (shuttingDown) throw Object.assign(new Error("AgentBridge is shutting down"), { statusCode: 503, code: "BRIDGE_SHUTTING_DOWN" });
|
|
397
|
+
|
|
398
|
+
// Key-exchange endpoint: exempt from the gate itself, but rate-worthy of
|
|
399
|
+
// the same-origin check so other sites cannot plant the key cookie.
|
|
400
|
+
if (req.method === "POST" && url.pathname === "/api/access") {
|
|
401
|
+
if (!config.accessKey) throw Object.assign(new Error("Access key gate is not enabled"), { statusCode: 404 });
|
|
402
|
+
requireSameOrigin(req);
|
|
403
|
+
const body = await readJson(req);
|
|
404
|
+
if (!keyMatches(body.key, config.accessKey)) {
|
|
405
|
+
throw Object.assign(new Error("Access key is incorrect"), { statusCode: 401 });
|
|
406
|
+
}
|
|
407
|
+
return json(res, 200, { ok: true }, { "set-cookie": accessKeyCookie(config.accessKey, config.secureCookies) });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let localMatch;
|
|
411
|
+
if (req.method === "GET" && (localMatch = url.pathname.match(/^\/api\/local\/dsh-desktop\/channels\/([^/]+)\/poll$/))) {
|
|
412
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
413
|
+
return json(res, 200, dshDesktopChannels.poll(localMatch[1], requestToken(req), url.searchParams.get("after")));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (req.method === "POST" && (localMatch = url.pathname.match(/^\/api\/local\/dsh-desktop\/channels\/([^/]+)\/events$/))) {
|
|
417
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
418
|
+
// 1 MB keeps a busy session's event batches under the cap: the companion picks
|
|
419
|
+
// batches by byte budget, and the events the phone actually displays are small.
|
|
420
|
+
const body = await readJson(req, 1024 * 1024);
|
|
421
|
+
// The integration shrinks any single event that could not share a batch, so
|
|
422
|
+
// the cap still bounds one batch instead of refusing it.
|
|
423
|
+
return json(res, 202, await dshDesktopChannels.ingest(localMatch[1], requestToken(req), body.events));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (req.method === "POST" && (localMatch = url.pathname.match(/^\/api\/local\/dsh-desktop\/channels\/([^/]+)\/interactions$/))) {
|
|
427
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
428
|
+
const body = await readJson(req, 256 * 1024);
|
|
429
|
+
return json(res, 202, await dshDesktopChannels.ingestInteractions(localMatch[1], requestToken(req), body.interactions));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (req.method === "POST" && (localMatch = url.pathname.match(/^\/api\/local\/dsh-desktop\/channels\/([^/]+)\/results$/))) {
|
|
433
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
434
|
+
const body = await readJson(req);
|
|
435
|
+
return json(res, 202, dshDesktopChannels.complete(localMatch[1], requestToken(req), String(body.requestId ?? ""), body.result));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (req.method === "GET" && (localMatch = url.pathname.match(/^\/api\/local\/dsh-desktop\/channels\/([^/]+)\/qr\.svg$/))) {
|
|
439
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
440
|
+
const target = dshQrTargets.get(localMatch[1]);
|
|
441
|
+
if (!target || target.expiresAt <= Date.now()) throw Object.assign(new Error("Pairing QR has expired"), { statusCode: 410 });
|
|
442
|
+
const svg = await QRCode.toString(target.url, { type: "svg", margin: 1, width: 320, errorCorrectionLevel: "M" });
|
|
443
|
+
res.writeHead(200, { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "no-store" });
|
|
444
|
+
return res.end(svg);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (req.method === "GET" && (localMatch = url.pathname.match(/^\/api\/local\/pairings\/([^/]+)\/qr\.svg$/))) {
|
|
448
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
449
|
+
const target = pairing.getPendingPairing(localMatch[1]);
|
|
450
|
+
const svg = await QRCode.toString(target.url, { type: "svg", margin: 1, width: 320, errorCorrectionLevel: "M" });
|
|
451
|
+
res.writeHead(200, { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "no-store" });
|
|
452
|
+
return res.end(svg);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
456
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
457
|
+
return json(res, 200, { machineName: config.machineName, pairing: currentPairing });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (req.method === "GET" && url.pathname === "/api/pairing/qr.svg") {
|
|
461
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
462
|
+
const svg = await QRCode.toString(currentPairing.url, { type: "svg", margin: 1, width: 320, errorCorrectionLevel: "M" });
|
|
463
|
+
res.writeHead(200, { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "no-store" });
|
|
464
|
+
return res.end(svg);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (req.method === "POST" && url.pathname === "/api/pairing/rotate") {
|
|
468
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
469
|
+
requireSameOrigin(req);
|
|
470
|
+
currentPairing = pairing.createPairing(config.advertisedOrigin);
|
|
471
|
+
return json(res, 201, { pairing: currentPairing });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (req.method === "POST" && url.pathname === "/api/local-session") {
|
|
475
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
476
|
+
requireSameOrigin(req);
|
|
477
|
+
const local = pairing.createSession("Desktop browser");
|
|
478
|
+
return json(res, 201, { device: { id: local.id, deviceName: local.deviceName, expiresAt: local.expiresAt } }, {
|
|
479
|
+
// The desktop dashboard is intentionally opened over loopback HTTP,
|
|
480
|
+
// even when the advertised phone origin is HTTPS (Cloudflare mode).
|
|
481
|
+
// A Secure cookie would be discarded by the local browser.
|
|
482
|
+
"set-cookie": sessionCookie(local.token, false, config.sessionTtlMs),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (req.method === "POST" && url.pathname === "/api/local/workspaces") {
|
|
487
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
488
|
+
requireSameOrigin(req);
|
|
489
|
+
const body = await readJson(req);
|
|
490
|
+
const project = await registerLocalProject(workspaceCatalog, config.projects, body.path);
|
|
491
|
+
const workspace = workspaceCatalog.listPublic().find((item) => item.id === project.id);
|
|
492
|
+
return json(res, 201, { workspace });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (req.method === "POST" && url.pathname === "/api/local/phone") {
|
|
496
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
497
|
+
requireSameOrigin(req);
|
|
498
|
+
const body = await readJson(req);
|
|
499
|
+
const project = await registerLocalProject(workspaceCatalog, config.projects, body.cwd);
|
|
500
|
+
const device = pairing.createSession("Local launcher");
|
|
501
|
+
const sessionInput = {
|
|
502
|
+
adapterId: String(body.adapterId || "claude"),
|
|
503
|
+
projectAlias: project.alias,
|
|
504
|
+
providerSessionId: body.providerSessionId,
|
|
505
|
+
title: body.title,
|
|
506
|
+
commandId: String(body.commandId || `local-${Date.now()}`),
|
|
507
|
+
device,
|
|
508
|
+
};
|
|
509
|
+
const session = body.providerSessionId
|
|
510
|
+
? await sessions.attach(sessionInput)
|
|
511
|
+
: await sessions.create(sessionInput);
|
|
512
|
+
currentPairing = pairing.createPairing(config.advertisedOrigin, { targetSessionId: session.id });
|
|
513
|
+
const address = server.address();
|
|
514
|
+
const localPort = typeof address === "object" && address ? address.port : config.port;
|
|
515
|
+
const qrImageUrl = `http://127.0.0.1:${localPort}/api/local/pairings/${encodeURIComponent(currentPairing.id)}/qr.svg`;
|
|
516
|
+
return json(res, 201, { session, pairing: currentPairing, qrImageUrl, project: { alias: project.alias } });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (req.method === "POST" && url.pathname === "/api/local/dsh-desktop/register") {
|
|
520
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
521
|
+
requireSameOrigin(req);
|
|
522
|
+
const body = await readJson(req, 1024 * 1024);
|
|
523
|
+
const providerSessionId = String(body.sessionId ?? "");
|
|
524
|
+
if (providerSessionId.length < 8 || providerSessionId.length > 200) {
|
|
525
|
+
throw Object.assign(new Error("A valid DSH session id is required"), { statusCode: 400 });
|
|
526
|
+
}
|
|
527
|
+
const project = await registerLocalProject(workspaceCatalog, config.projects, body.cwd);
|
|
528
|
+
const title = String(body.title || `${path.basename(project.cwd)} · DSH Desktop`).slice(0, 100);
|
|
529
|
+
// The DSH-side integration is a copy inside the DSH profile; a stale copy is
|
|
530
|
+
// the classic "I changed it but nothing happened" trap, so the bridge records
|
|
531
|
+
// what it was told and says so out loud.
|
|
532
|
+
const integrationVersion = Number.isSafeInteger(body.integrationVersion) ? body.integrationVersion : 0; const registration = dshDesktopChannels.register({
|
|
533
|
+
providerSessionId,
|
|
534
|
+
cwd: project.cwd,
|
|
535
|
+
title,
|
|
536
|
+
provider: String(body.provider || "").slice(0, 100),
|
|
537
|
+
model: String(body.model || "").slice(0, 160),
|
|
538
|
+
modelDirectory: body.modelDirectory,
|
|
539
|
+
integrationVersion,
|
|
540
|
+
});
|
|
541
|
+
const channel = {
|
|
542
|
+
id: registration.id,
|
|
543
|
+
token: registration.token,
|
|
544
|
+
pollPath: `/api/local/dsh-desktop/channels/${registration.id}/poll`,
|
|
545
|
+
eventsPath: `/api/local/dsh-desktop/channels/${registration.id}/events`,
|
|
546
|
+
interactionsPath: `/api/local/dsh-desktop/channels/${registration.id}/interactions`,
|
|
547
|
+
resultsPath: `/api/local/dsh-desktop/channels/${registration.id}/results`,
|
|
548
|
+
};
|
|
549
|
+
// A conversation the phone asked for: the bridge session already exists in
|
|
550
|
+
// `starting`, so registration completes that request instead of minting a
|
|
551
|
+
// second pairing. The waiting `adapter.start()` binds the provider session.
|
|
552
|
+
const bridgeSessionId = typeof body.bridgeSessionId === "string" ? body.bridgeSessionId : "";
|
|
553
|
+
if (bridgeSessionId) {
|
|
554
|
+
const settled = dshDesktopChannels.settleCreate(bridgeSessionId, {
|
|
555
|
+
providerSessionId,
|
|
556
|
+
title,
|
|
557
|
+
provider: String(body.provider || "").slice(0, 100),
|
|
558
|
+
model: String(body.model || "").slice(0, 160),
|
|
559
|
+
modelDirectory: dshDesktopChannels.metadata(providerSessionId)?.modelDirectory ?? null,
|
|
560
|
+
});
|
|
561
|
+
if (!settled) dshDesktopChannels.revoke(registration.id);
|
|
562
|
+
if (!settled) throw Object.assign(new Error("That create request is no longer waiting"), { statusCode: 409 });
|
|
563
|
+
return json(res, 201, {
|
|
564
|
+
created: true,
|
|
565
|
+
sessionId: bridgeSessionId,
|
|
566
|
+
project: { alias: project.alias, cwd: project.cwd },
|
|
567
|
+
channel,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
// A reconnect is the companion noticing the bridge forgot its channel. It
|
|
571
|
+
// must not consume a pairing code, mint a device, or take the lease away
|
|
572
|
+
// from the phone that is already driving this conversation.
|
|
573
|
+
const reconnecting = body.reconnect === true;
|
|
574
|
+
const device = reconnecting ? null : pairing.createSession("DSH Desktop /phone");
|
|
575
|
+
let session;
|
|
576
|
+
try {
|
|
577
|
+
session = await sessions.attach({
|
|
578
|
+
adapterId: "deepseek-harness-desktop",
|
|
579
|
+
projectAlias: project.alias,
|
|
580
|
+
providerSessionId,
|
|
581
|
+
title,
|
|
582
|
+
commandId: `dsh-phone-${registration.id}`,
|
|
583
|
+
device,
|
|
584
|
+
});
|
|
585
|
+
} catch (error) {
|
|
586
|
+
dshDesktopChannels.revoke(registration.id);
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
if (reconnecting) {
|
|
590
|
+
return json(res, 200, {
|
|
591
|
+
session,
|
|
592
|
+
reconnected: true,
|
|
593
|
+
project: { alias: project.alias, cwd: project.cwd },
|
|
594
|
+
channel,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
currentPairing = pairing.createPairing(config.advertisedOrigin, { targetSessionId: session.id });
|
|
598
|
+
dshQrTargets.set(registration.id, { url: currentPairing.url, expiresAt: currentPairing.expiresAt });
|
|
599
|
+
const address = server.address();
|
|
600
|
+
const localPort = typeof address === "object" && address ? address.port : config.port;
|
|
601
|
+
const qrImageUrl = `http://127.0.0.1:${localPort}/api/local/dsh-desktop/channels/${registration.id}/qr.svg`;
|
|
602
|
+
return json(res, 201, {
|
|
603
|
+
session,
|
|
604
|
+
pairing: currentPairing,
|
|
605
|
+
project: { alias: project.alias, cwd: project.cwd },
|
|
606
|
+
qrImageUrl,
|
|
607
|
+
channel,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (req.method === "POST" && url.pathname === "/api/pairings/exchange") {
|
|
612
|
+
requireAccessKey(req, url, config);
|
|
613
|
+
requireSameOrigin(req);
|
|
614
|
+
const body = await readJson(req);
|
|
615
|
+
const mobile = pairing.exchange(body.code, String(body.deviceName || "WeChat mobile").slice(0, 80));
|
|
616
|
+
if (mobile.targetSessionId) {
|
|
617
|
+
await sessions.claimControl({
|
|
618
|
+
sessionId: mobile.targetSessionId,
|
|
619
|
+
commandId: `pairing-${body.code}`,
|
|
620
|
+
device: mobile,
|
|
621
|
+
force: true,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
return json(res, 201, {
|
|
625
|
+
device: { id: mobile.id, deviceName: mobile.deviceName, expiresAt: mobile.expiresAt },
|
|
626
|
+
targetSessionId: mobile.targetSessionId,
|
|
627
|
+
}, {
|
|
628
|
+
"set-cookie": sessionCookie(mobile.token, config.secureCookies, config.sessionTtlMs),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// This is deliberately not a pairing QR. It only opens the stable app
|
|
633
|
+
// address, where an already-paired phone can use its existing cookie.
|
|
634
|
+
if (req.method === "GET" && url.pathname === "/api/return-qr.svg") {
|
|
635
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
636
|
+
const entryUrl = `${config.advertisedOrigin}/${config.accessKey ? `?key=${encodeURIComponent(config.accessKey)}` : ""}`;
|
|
637
|
+
const image = await QRCode.toString(entryUrl, { type: "svg", margin: 1, width: 320, errorCorrectionLevel: "M" });
|
|
638
|
+
res.writeHead(200, { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "no-store" });
|
|
639
|
+
return res.end(image);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// The application shell is public so a newly scanned phone can render the
|
|
643
|
+
// pairing screen before it owns a session cookie. All data APIs below this
|
|
644
|
+
// point remain authenticated (and gated by the access key when enabled).
|
|
645
|
+
if (req.method === "GET" && await serveStatic(res, url.pathname, url.search)) return;
|
|
646
|
+
|
|
647
|
+
requireAccessKey(req, url, config);
|
|
648
|
+
const device = pairing.authenticate(requestToken(req));
|
|
649
|
+
if (!device) throw Object.assign(new Error("Authentication required"), { statusCode: 401 });
|
|
650
|
+
|
|
651
|
+
if (req.method === "GET" && url.pathname === "/api/devices") {
|
|
652
|
+
return json(res, 200, { devices: pairing.listDevices(), currentDeviceId: device.id });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (req.method === "GET" && url.pathname === "/api/workspaces") {
|
|
656
|
+
return json(res, 200, {
|
|
657
|
+
revision: workspaceCatalog.revision,
|
|
658
|
+
items: workspaceCatalog.listPublic(),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (req.method === "GET" && url.pathname === "/api/conversations") {
|
|
663
|
+
const workspaceId = String(url.searchParams.get("workspaceId") ?? "");
|
|
664
|
+
const adapterId = String(url.searchParams.get("adapterId") ?? "");
|
|
665
|
+
const workspace = workspaceCatalog.get(workspaceId);
|
|
666
|
+
if (!workspace || !workspace.enabled) throw Object.assign(new Error("Workspace not found"), { statusCode: 404 });
|
|
667
|
+
const refresh = url.searchParams.get("refresh") === "1";
|
|
668
|
+
let discovery = null;
|
|
669
|
+
if (refresh) {
|
|
670
|
+
requireSameOrigin(req);
|
|
671
|
+
if (!adapterId) throw Object.assign(new Error("adapterId is required for provider discovery"), { statusCode: 400 });
|
|
672
|
+
discovery = await sessions.discover({ workspaceId: workspace.id, adapterId });
|
|
673
|
+
}
|
|
674
|
+
return json(res, 200, {
|
|
675
|
+
revision: conversationCatalog.revision,
|
|
676
|
+
items: sessions.list({ workspaceId: workspace.id, adapterId: adapterId || null }),
|
|
677
|
+
...(discovery ? { discovery: {
|
|
678
|
+
created: discovery.created,
|
|
679
|
+
updated: discovery.updated,
|
|
680
|
+
diagnostics: discovery.diagnostics,
|
|
681
|
+
} } : {}),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (req.method === "GET" && url.pathname === "/api/state") {
|
|
686
|
+
return json(res, 200, {
|
|
687
|
+
machine: { id: "local", name: config.machineName },
|
|
688
|
+
device,
|
|
689
|
+
projects: workspaceCatalog.listPublic().map((workspace) => workspace.alias),
|
|
690
|
+
workspaces: workspaceCatalog.listPublic(),
|
|
691
|
+
adapters: await registry.describe(),
|
|
692
|
+
sessions: sessions.list(),
|
|
693
|
+
lastSeq: eventStore.lastSeq,
|
|
694
|
+
notifications: { enabled: notifier.enabled, format: notifier.format, channel: notifier.channel, muted: notifier.muted },
|
|
695
|
+
desktopIntegration: dshDesktopChannels.integrationStatus(),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (req.method === "GET" && url.pathname === "/api/search") {
|
|
700
|
+
const query = String(url.searchParams.get("q") ?? "").slice(0, 200);
|
|
701
|
+
const limit = Number.parseInt(url.searchParams.get("limit") ?? "30", 10) || 30;
|
|
702
|
+
const found = await eventStore.search(query, { limit });
|
|
703
|
+
const titles = new Map(sessions.list().map((session) => [session.id, session]));
|
|
704
|
+
return json(res, 200, {
|
|
705
|
+
query,
|
|
706
|
+
truncated: found.truncated,
|
|
707
|
+
scanned: found.scanned,
|
|
708
|
+
hits: found.hits.map((hit) => {
|
|
709
|
+
const session = titles.get(hit.sessionId) ?? null;
|
|
710
|
+
return {
|
|
711
|
+
...hit,
|
|
712
|
+
sessionTitle: session?.title ?? null,
|
|
713
|
+
adapterId: session?.adapterId ?? null,
|
|
714
|
+
};
|
|
715
|
+
}),
|
|
716
|
+
}, { "cache-control": "no-store" });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// The mute switch lives on the phone, so this endpoint cannot be loopback-only.
|
|
720
|
+
// It touches exactly one thing (the `muted` flag) and persists it, so the state
|
|
721
|
+
// survives a restart and a double-clicked launcher.
|
|
722
|
+
if (req.method === "POST" && url.pathname === "/api/notifications") {
|
|
723
|
+
const body = await readJson(req);
|
|
724
|
+
if (typeof body.muted !== "boolean") throw Object.assign(new Error("muted must be true or false"), { statusCode: 400 });
|
|
725
|
+
const muted = notifier.setMuted(body.muted);
|
|
726
|
+
writeNotifyConfig(config.dataDir, { muted });
|
|
727
|
+
return json(res, 200, { notifications: { enabled: notifier.enabled, format: notifier.format, channel: notifier.channel, muted } });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (req.method === "POST" && url.pathname === "/api/recent-session") {
|
|
731
|
+
requireSameOrigin(req);
|
|
732
|
+
const body = await readJson(req);
|
|
733
|
+
const sessionId = String(body.sessionId ?? "");
|
|
734
|
+
if (!sessions.get(sessionId)) throw Object.assign(new Error("Session not found"), { statusCode: 404 });
|
|
735
|
+
pairing.setLastSession(device.id, sessionId);
|
|
736
|
+
return json(res, 200, { resumeSessionId: sessionId });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const historyMatch = req.method === "GET"
|
|
740
|
+
? url.pathname.match(/^\/api\/sessions\/([^/]+)\/history$/)
|
|
741
|
+
: null;
|
|
742
|
+
if (historyMatch) {
|
|
743
|
+
const target = sessions.get(decodeURIComponent(historyMatch[1]));
|
|
744
|
+
if (!target) throw Object.assign(new Error("Session not found"), { statusCode: 404 });
|
|
745
|
+
const limit = Number.parseInt(url.searchParams.get("limit") ?? "300", 10);
|
|
746
|
+
const beforeSeq = Number.parseInt(url.searchParams.get("before") ?? "", 10);
|
|
747
|
+
const history = await eventStore.readSessionHistory(target.id, {
|
|
748
|
+
limit,
|
|
749
|
+
beforeSeq: Number.isSafeInteger(beforeSeq) ? beforeSeq : null,
|
|
750
|
+
});
|
|
751
|
+
return json(res, 200, { sessionId: target.id, ...history }, { "cache-control": "no-store" });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
755
|
+
// A brand-new page has no cursor, and replaying the whole window made the
|
|
756
|
+
// phone wait on megabytes over the tunnel before showing anything. A
|
|
757
|
+
// fresh load gets the newest slice; older conversations are fetched per
|
|
758
|
+
// session through /api/sessions/:id/history, which is what switching
|
|
759
|
+
// conversations needs anyway. A reconnect still resumes from the
|
|
760
|
+
// browser's own Last-Event-ID.
|
|
761
|
+
const requested = Number.parseInt(url.searchParams.get("after") ?? req.headers["last-event-id"] ?? "0", 10) || 0;
|
|
762
|
+
const after = requested || Math.max(0, eventStore.lastSeq - INITIAL_REPLAY_EVENTS);
|
|
763
|
+
res.writeHead(200, {
|
|
764
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
765
|
+
"cache-control": "no-cache, no-transform",
|
|
766
|
+
connection: "keep-alive",
|
|
767
|
+
"x-accel-buffering": "no",
|
|
768
|
+
});
|
|
769
|
+
res.write(": connected\n\n");
|
|
770
|
+
const writeEvent = (event) => {
|
|
771
|
+
res.write(`id: ${event.seq}\nevent: agent-event\ndata: ${JSON.stringify(event)}\n\n`);
|
|
772
|
+
};
|
|
773
|
+
for (const event of eventStore.listAfter(after)) writeEvent(event);
|
|
774
|
+
const unsubscribe = eventStore.subscribe(writeEvent);
|
|
775
|
+
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 20_000);
|
|
776
|
+
req.on("close", () => {
|
|
777
|
+
clearInterval(heartbeat);
|
|
778
|
+
unsubscribe();
|
|
779
|
+
});
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (req.method === "POST") requireSameOrigin(req);
|
|
784
|
+
let match;
|
|
785
|
+
if (req.method === "POST" && url.pathname === "/api/local/shutdown") {
|
|
786
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
787
|
+
const active = sessions.list().filter((session) => ["starting", "running", "stopping"].includes(session.status));
|
|
788
|
+
if (active.length) {
|
|
789
|
+
throw Object.assign(new Error("仍有 Agent 任务正在运行,请先停止任务再关闭 Bridge"), {
|
|
790
|
+
statusCode: 409,
|
|
791
|
+
code: "ACTIVE_SESSIONS",
|
|
792
|
+
diagnostics: {
|
|
793
|
+
count: active.length,
|
|
794
|
+
sessions: active.map(({ id, title, status, adapterId }) => ({ id, title, status, adapterId })),
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
shuttingDown = true;
|
|
799
|
+
json(res, 202, { accepted: true, message: "AgentBridge 正在安全停止" });
|
|
800
|
+
setImmediate(() => {
|
|
801
|
+
void shutdown().catch((error) => {
|
|
802
|
+
console.error("AgentBridge safe shutdown failed", error);
|
|
803
|
+
shuttingDown = false;
|
|
804
|
+
shutdownPromise = null;
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/devices\/([^/]+)\/revoke$/))) {
|
|
810
|
+
const targetId = decodeURIComponent(match[1]);
|
|
811
|
+
if (!pairing.revoke(targetId)) throw Object.assign(new Error("Device not found"), { statusCode: 404 });
|
|
812
|
+
const headers = targetId === device.id
|
|
813
|
+
? { "set-cookie": `agentbridge_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${config.secureCookies && !isLoopback(req.socket.remoteAddress) ? "; Secure" : ""}` }
|
|
814
|
+
: {};
|
|
815
|
+
return json(res, 200, { revoked: true }, headers);
|
|
816
|
+
}
|
|
817
|
+
if (req.method === "POST" && url.pathname === "/api/local/notify-test") {
|
|
818
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
819
|
+
const result = await notifier.send("notify.test", {}, {
|
|
820
|
+
machineName: config.machineName,
|
|
821
|
+
sessionTitle: "通知链路测试",
|
|
822
|
+
adapterId: "agentbridge",
|
|
823
|
+
});
|
|
824
|
+
return json(res, result.sent ? 200 : 409, { enabled: notifier.enabled, format: notifier.format, channel: notifier.channel, muted: notifier.muted, ...result });
|
|
825
|
+
}
|
|
826
|
+
if (req.method === "POST" && url.pathname === "/api/sessions") {
|
|
827
|
+
const body = await readJson(req);
|
|
828
|
+
const session = await sessions.create({ ...body, device });
|
|
829
|
+
return json(res, 201, { session });
|
|
830
|
+
}
|
|
831
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/sessions\/([^/]+)\/attachments$/))) {
|
|
832
|
+
const session = sessions.get(decodeURIComponent(match[1]));
|
|
833
|
+
if (!session) throw Object.assign(new Error("Session not found"), { statusCode: 404 });
|
|
834
|
+
sessions.requireControlFor(session.id, device);
|
|
835
|
+
const name = decodeURIComponent(String(req.headers["x-attachment-name"] ?? "attachment"));
|
|
836
|
+
const mimeType = String(req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim();
|
|
837
|
+
// Answer an oversized upload with a real 413 instead of letting the socket
|
|
838
|
+
// reset: a phone has to be able to tell "too big" from "no connection".
|
|
839
|
+
const declared = Number.parseInt(String(req.headers["content-length"] ?? ""), 10);
|
|
840
|
+
if (Number.isSafeInteger(declared) && declared > attachments.maxBytes) {
|
|
841
|
+
await new Promise((resolve) => {
|
|
842
|
+
let drained = 0;
|
|
843
|
+
const stop = () => { req.off("data", onData); req.off("end", stop); req.off("error", stop); resolve(); };
|
|
844
|
+
const onData = (chunk) => {
|
|
845
|
+
drained += chunk.length;
|
|
846
|
+
if (drained > 32 * 1024 * 1024) { req.destroy(); stop(); }
|
|
847
|
+
};
|
|
848
|
+
req.on("data", onData);
|
|
849
|
+
req.on("end", stop);
|
|
850
|
+
req.on("error", stop);
|
|
851
|
+
});
|
|
852
|
+
throw Object.assign(new Error(`附件不能超过 ${Math.round(attachments.maxBytes / 1024 / 1024)} MB`), { statusCode: 413 });
|
|
853
|
+
}
|
|
854
|
+
const data = await readBinary(req, attachments.maxBytes);
|
|
855
|
+
const stored = await attachments.save({ sessionId: session.id, name, mimeType, data });
|
|
856
|
+
return json(res, 201, {
|
|
857
|
+
attachment: {
|
|
858
|
+
id: stored.id,
|
|
859
|
+
name: stored.name,
|
|
860
|
+
mimeType: stored.mimeType,
|
|
861
|
+
bytes: stored.bytes,
|
|
862
|
+
previewUrl: `/api/attachments/${stored.id}`,
|
|
863
|
+
isImage: isImage(stored.mimeType),
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
if (req.method === "GET" && (match = url.pathname.match(/^\/api\/attachments\/([^/]+)$/))) {
|
|
868
|
+
const entry = attachments.get(decodeURIComponent(match[1]));
|
|
869
|
+
if (!entry) throw Object.assign(new Error("Attachment not found"), { statusCode: 404 });
|
|
870
|
+
const file = await attachments.read(entry.id);
|
|
871
|
+
if (!file) throw Object.assign(new Error("Attachment file is missing"), { statusCode: 404 });
|
|
872
|
+
res.writeHead(200, {
|
|
873
|
+
"content-type": entry.mimeType || "application/octet-stream",
|
|
874
|
+
"content-length": String(file.data.length),
|
|
875
|
+
"content-security-policy": CONTENT_SECURITY_POLICY,
|
|
876
|
+
"x-content-type-options": "nosniff",
|
|
877
|
+
// An attachment is user data under a random id; it never changes, so it
|
|
878
|
+
// can be cached by the phone for the life of the conversation.
|
|
879
|
+
"cache-control": "private, max-age=86400",
|
|
880
|
+
});
|
|
881
|
+
res.end(file.data);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/))) {
|
|
885
|
+
const body = await readJson(req);
|
|
886
|
+
return json(res, 202, await sessions.send({ sessionId: match[1], ...body, device }));
|
|
887
|
+
}
|
|
888
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/sessions\/([^/]+)\/model$/))) {
|
|
889
|
+
const body = await readJson(req);
|
|
890
|
+
return json(res, 200, await sessions.selectModel({ sessionId: match[1], ...body, device }));
|
|
891
|
+
}
|
|
892
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/sessions\/([^/]+)\/queue\/cancel$/))) {
|
|
893
|
+
const body = await readJson(req);
|
|
894
|
+
return json(res, 200, await sessions.cancelQueued({ sessionId: match[1], ...body, device }));
|
|
895
|
+
}
|
|
896
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/sessions\/([^/]+)\/auto-approve$/))) {
|
|
897
|
+
const body = await readJson(req);
|
|
898
|
+
return json(res, 200, await sessions.setAutoApproval({ sessionId: match[1], ...body, device }));
|
|
899
|
+
}
|
|
900
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/))) {
|
|
901
|
+
const body = await readJson(req);
|
|
902
|
+
return json(res, 200, await sessions.stop({ sessionId: match[1], ...body, device }));
|
|
903
|
+
}
|
|
904
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/sessions\/([^/]+)\/control$/))) {
|
|
905
|
+
const body = await readJson(req);
|
|
906
|
+
return json(res, 200, { controller: await sessions.claimControl({ sessionId: match[1], ...body, device }) });
|
|
907
|
+
}
|
|
908
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/approvals\/([^/]+)$/))) {
|
|
909
|
+
const body = await readJson(req);
|
|
910
|
+
return json(res, 200, await sessions.resolveApproval({ approvalId: match[1], ...body, device }));
|
|
911
|
+
}
|
|
912
|
+
if (req.method === "POST" && (match = url.pathname.match(/^\/api\/requests\/([^/]+)$/))) {
|
|
913
|
+
const body = await readJson(req);
|
|
914
|
+
return json(res, 200, await sessions.resolveInput({ requestId: decodeURIComponent(match[1]), ...body, device }));
|
|
915
|
+
}
|
|
916
|
+
json(res, 404, { error: "Not found" });
|
|
917
|
+
} catch (error) {
|
|
918
|
+
if (!Number.isInteger(error.statusCode) || error.statusCode >= 500) console.error(error);
|
|
919
|
+
if (!res.headersSent) errorResponse(res, error);
|
|
920
|
+
else res.end();
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
server,
|
|
926
|
+
config,
|
|
927
|
+
eventStore,
|
|
928
|
+
pairing,
|
|
929
|
+
registry,
|
|
930
|
+
sessions,
|
|
931
|
+
dshDesktopChannels,
|
|
932
|
+
notifier,
|
|
933
|
+
attachments,
|
|
934
|
+
workspaceCatalog,
|
|
935
|
+
conversationCatalog,
|
|
936
|
+
getCurrentPairing: () => currentPairing,
|
|
937
|
+
shutdown,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export async function startBridge() {
|
|
942
|
+
const bridge = await createBridge();
|
|
943
|
+
await new Promise((resolve, reject) => {
|
|
944
|
+
bridge.server.once("error", reject);
|
|
945
|
+
bridge.server.listen(bridge.config.port, bridge.config.host, resolve);
|
|
946
|
+
});
|
|
947
|
+
console.log(`AgentBridge running at http://127.0.0.1:${bridge.config.port}`);
|
|
948
|
+
console.log(`WeChat scan target: ${bridge.getCurrentPairing().url}`);
|
|
949
|
+
// Silence here used to mean a QR encoding an address the phone could never reach.
|
|
950
|
+
if (new URL(bridge.config.advertisedOrigin).hostname === "127.0.0.1") {
|
|
951
|
+
console.warn(
|
|
952
|
+
"No routable LAN address found, so the QR code points at loopback and a phone cannot open it. "
|
|
953
|
+
+ "Connect the machine to Wi-Fi/Ethernet, or set AGENTBRIDGE_PUBLIC_ORIGIN to the address the phone should use.",
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
return bridge;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
960
|
+
startBridge().catch((error) => {
|
|
961
|
+
console.error(error);
|
|
962
|
+
process.exitCode = 1;
|
|
963
|
+
});
|
|
964
|
+
}
|