@pix3/agent-bridge 0.2.0 → 0.2.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/src/index.ts CHANGED
@@ -1,350 +1,350 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Pix3AgentBridge — a LOCAL, personal bridge between the pix3 editor's in-editor agent and the LLM
4
- * providers that a browser cannot reach directly.
5
- *
6
- * It does two jobs, both on 127.0.0.1:
7
- *
8
- * 1. Agent-SDK lane (`POST /v1/messages`, `GET /v1/models`): serves the Anthropic Messages wire
9
- * shape from a real Claude Agent SDK (Claude Code / Pro/MAX subscription) session — no API key,
10
- * usage draws from the subscription. This is the original bridge behaviour (see sessions.ts).
11
- *
12
- * 2. Provider proxy lane (`ALL /providers/:id/*`, `GET /v1/providers`): a credential-injecting
13
- * reverse proxy for metered providers (OpenAI, Anthropic API, OpenCode Zen, custom
14
- * OpenAI-compatible endpoints). The editor sends requests with only the pairing token; the
15
- * bridge injects the provider key it stores in `~/.pix3/agent-bridge.json` and forwards to the
16
- * fixed upstream. Keys never enter the browser. Providers are managed with the CLI:
17
- *
18
- * pix3-agent-bridge provider add openai --key sk-...
19
- * pix3-agent-bridge provider list
20
- *
21
- * Usage:
22
- * npx @pix3/agent-bridge (requires a `claude login`-ed Claude Code for lane 1)
23
- * npx @pix3/agent-bridge provider add openai --key sk-...
24
- * Options: --port <n> (default 8484), --origin <url> (repeatable, extra allowed origins)
25
- *
26
- * Security model (defense in depth for a localhost service):
27
- * - binds to 127.0.0.1 only;
28
- * - pairing token (generated once, stored in ~/.pix3/agent-bridge.json, printed on start) must
29
- * accompany every API request (x-api-key / Authorization) — blocks other local processes/pages;
30
- * - browser `Origin` allowlist (editor.pix3.dev / cloud.pix3.dev + local dev) — anything else 403;
31
- * - `Host` header must be localhost — blocks DNS-rebinding;
32
- * - the proxy lane never takes the upstream host from the client (fixed per provider) → no SSRF,
33
- * and forwards only content-type + the injected key → the pairing token never leaks upstream;
34
- * - the Agent-SDK session runs with zero built-in tools — the model can only call pix3 editor
35
- * tools, never this machine's shell or filesystem.
36
- */
37
-
38
- import http from 'node:http';
39
- import type { IncomingMessage, ServerResponse } from 'node:http';
40
-
41
- import { SessionManager } from './sessions.ts';
42
- import { HttpError, parseMessagesRequest } from './wire.ts';
43
- import { loadConfig, RESERVED_PROVIDER_IDS, type BridgeConfig } from './config.ts';
44
- import { runProviderCommand, usage } from './cli.ts';
45
- import { forwardToProvider } from './proxy.ts';
46
-
47
- const MAX_BODY_BYTES = 64 * 1024 * 1024;
48
-
49
- /** Discovery entry for the intrinsic Agent-SDK (subscription) lane — not part of the provider table. */
50
- const AGENT_SDK_PROVIDER = {
51
- id: RESERVED_PROVIDER_IDS[0],
52
- label: 'Claude Code (MAX)',
53
- kind: 'agent-sdk' as const,
54
- };
55
-
56
- function modelEntry(
57
- id: string,
58
- label: string,
59
- description: string,
60
- maxOutputTokens: number,
61
- contextWindow: number
62
- ) {
63
- return {
64
- id,
65
- label,
66
- description,
67
- capabilities: {
68
- supportsTools: true,
69
- supportsImages: true,
70
- supportsSystemPrompt: true,
71
- maxOutputTokens,
72
- contextWindow,
73
- },
74
- pricing: { inputPer1M: 0, outputPer1M: 0 },
75
- };
76
- }
77
-
78
- /** Static catalog served to the Agent-SDK lane's `GET /v1/models` — subscription models, $0 marginal cost. */
79
- const AGENT_SDK_MODELS = [
80
- modelEntry('claude-fable-5', 'Claude Fable 5 (MAX)', 'Most capable — via Claude Code subscription.', 32000, 1_000_000),
81
- modelEntry('claude-opus-4-8', 'Claude Opus 4.8 (MAX)', 'Highly capable — via Claude Code subscription.', 32000, 1_000_000),
82
- modelEntry('claude-sonnet-5', 'Claude Sonnet 5 (MAX)', 'Balanced speed and quality — via Claude Code subscription.', 32000, 1_000_000),
83
- modelEntry('claude-haiku-4-5', 'Claude Haiku 4.5 (MAX)', 'Fastest — via Claude Code subscription.', 16000, 200_000),
84
- ];
85
-
86
- const log = (line: string): void => {
87
- console.log(`${new Date().toISOString().slice(11, 19)} ${line}`);
88
- };
89
-
90
- const hostAllowed = (req: IncomingMessage): boolean => {
91
- const host = (req.headers.host ?? '').toLowerCase();
92
- return (
93
- host.startsWith('localhost:') ||
94
- host.startsWith('127.0.0.1:') ||
95
- host === 'localhost' ||
96
- host === '127.0.0.1'
97
- );
98
- };
99
-
100
- const readBody = (req: IncomingMessage): Promise<Buffer> =>
101
- new Promise((resolve, reject) => {
102
- const chunks: Buffer[] = [];
103
- let size = 0;
104
- req.on('data', (chunk: Buffer) => {
105
- size += chunk.length;
106
- if (size > MAX_BODY_BYTES) {
107
- reject(new HttpError(413, 'Request body too large.'));
108
- req.destroy();
109
- return;
110
- }
111
- chunks.push(chunk);
112
- });
113
- req.on('end', () => resolve(Buffer.concat(chunks)));
114
- req.on('error', reject);
115
- });
116
-
117
- const startServer = (config: BridgeConfig): void => {
118
- const manager = new SessionManager(log);
119
-
120
- const withCors = (
121
- corsOrigin: string | null,
122
- extra: Record<string, string> = {}
123
- ): Record<string, string> => {
124
- const headers: Record<string, string> = { 'Cache-Control': 'no-store', ...extra };
125
- if (corsOrigin) {
126
- headers['Access-Control-Allow-Origin'] = corsOrigin;
127
- headers['Vary'] = 'Origin';
128
- }
129
- return headers;
130
- };
131
-
132
- const sendJson = (
133
- res: ServerResponse,
134
- status: number,
135
- body: unknown,
136
- corsOrigin: string | null
137
- ): void => {
138
- res.writeHead(status, withCors(corsOrigin, { 'Content-Type': 'application/json; charset=utf-8' }));
139
- res.end(JSON.stringify(body));
140
- };
141
-
142
- const sendError = (
143
- res: ServerResponse,
144
- status: number,
145
- message: string,
146
- corsOrigin: string | null
147
- ): void => {
148
- sendJson(res, status, { type: 'error', error: { type: 'invalid_request_error', message } }, corsOrigin);
149
- };
150
-
151
- const server = http.createServer(async (req, res) => {
152
- const rawUrl = req.url ?? '/';
153
- const qIndex = rawUrl.indexOf('?');
154
- const pathname = qIndex >= 0 ? rawUrl.slice(0, qIndex) : rawUrl;
155
- const query = qIndex >= 0 ? rawUrl.slice(qIndex) : '';
156
- const origin = typeof req.headers.origin === 'string' ? req.headers.origin : null;
157
-
158
- if (!hostAllowed(req)) {
159
- sendError(res, 403, 'Forbidden host.', null);
160
- return;
161
- }
162
- if (origin && !config.origins.includes(origin)) {
163
- log(`403 rejected origin ${origin}`);
164
- sendError(res, 403, 'Origin not allowed.', null);
165
- return;
166
- }
167
-
168
- // CORS preflight (no auth — browsers strip credentials from preflights).
169
- if (req.method === 'OPTIONS') {
170
- const headers: Record<string, string> = {
171
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
172
- 'Access-Control-Allow-Headers':
173
- (req.headers['access-control-request-headers'] as string | undefined) ??
174
- 'content-type, x-api-key, authorization, x-pix3-bridge-token, anthropic-version, anthropic-dangerous-direct-browser-access',
175
- 'Access-Control-Max-Age': '86400',
176
- };
177
- if (origin) {
178
- headers['Access-Control-Allow-Origin'] = origin;
179
- headers['Vary'] = 'Origin';
180
- }
181
- // Chrome Private Network Access / Local Network Access preflight opt-in.
182
- if (req.headers['access-control-request-private-network'] === 'true') {
183
- headers['Access-Control-Allow-Private-Network'] = 'true';
184
- }
185
- res.writeHead(204, headers);
186
- res.end();
187
- return;
188
- }
189
-
190
- if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) {
191
- sendJson(
192
- res,
193
- 200,
194
- {
195
- ok: true,
196
- name: 'pix3-agent-bridge',
197
- hint: 'Paste the pairing token from the bridge console into pix3 (Settings → AI Agent).',
198
- },
199
- origin
200
- );
201
- return;
202
- }
203
-
204
- // Everything below requires the pairing token (dedicated header, x-api-key, or Authorization).
205
- const auth =
206
- (req.headers['x-pix3-bridge-token'] as string | undefined) ??
207
- (req.headers['x-api-key'] as string | undefined) ??
208
- req.headers.authorization?.replace(/^Bearer\s+/i, '');
209
- if (auth !== config.token) {
210
- sendError(res, 401, 'Invalid or missing bridge pairing token.', origin);
211
- return;
212
- }
213
-
214
- // Discovery: which providers this bridge can serve right now (enabled + keyed) + the SDK lane.
215
- if (req.method === 'GET' && pathname === '/v1/providers') {
216
- const providers = Object.entries(config.providers)
217
- .filter(([, p]) => p.enabled && p.apiKey)
218
- .map(([id, p]) => ({ id, label: p.label, kind: p.kind, enabled: true }));
219
- sendJson(res, 200, { providers: [...providers, AGENT_SDK_PROVIDER] }, origin);
220
- return;
221
- }
222
-
223
- // Provider proxy lane: /providers/:id/<rest> → {provider.baseUrl}/<rest>
224
- if (pathname.startsWith('/providers/')) {
225
- const rest = pathname.slice('/providers/'.length);
226
- const slash = rest.indexOf('/');
227
- const providerId = slash >= 0 ? rest.slice(0, slash) : rest;
228
- const restPath = slash >= 0 ? rest.slice(slash) : '/';
229
- const provider = config.providers[providerId];
230
- if (!provider || !provider.enabled) {
231
- sendError(res, 404, `Provider "${providerId}" is not configured or is disabled.`, origin);
232
- return;
233
- }
234
- if (!provider.apiKey) {
235
- sendError(
236
- res,
237
- 400,
238
- `Provider "${providerId}" has no API key. Run: pix3-agent-bridge provider set-key ${providerId} <key>`,
239
- origin
240
- );
241
- return;
242
- }
243
- try {
244
- const body = req.method === 'GET' || req.method === 'HEAD' ? null : await readBody(req);
245
- const result = await forwardToProvider(provider, {
246
- method: req.method ?? 'GET',
247
- restPath,
248
- query,
249
- body,
250
- });
251
- res.writeHead(result.status, withCors(origin, { 'Content-Type': result.contentType }));
252
- res.end(result.body);
253
- } catch (error) {
254
- if (error instanceof HttpError) {
255
- sendError(res, error.status, error.message, origin);
256
- } else {
257
- log(`proxy error (${providerId}): ${error instanceof Error ? error.message : String(error)}`);
258
- sendError(res, 502, `Upstream request to "${providerId}" failed.`, origin);
259
- }
260
- }
261
- return;
262
- }
263
-
264
- // Agent-SDK lane.
265
- if (req.method === 'GET' && pathname === '/v1/models') {
266
- sendJson(res, 200, { models: AGENT_SDK_MODELS }, origin);
267
- return;
268
- }
269
-
270
- if (req.method === 'POST' && pathname === '/v1/messages') {
271
- const abort = new AbortController();
272
- res.on('close', () => {
273
- if (!res.writableEnded) abort.abort();
274
- });
275
- try {
276
- const body = (await readBody(req)).toString('utf8');
277
- const request = parseMessagesRequest(JSON.parse(body));
278
- const response = await manager.handle(request, abort.signal);
279
- sendJson(res, response.status, response.body, origin);
280
- } catch (error) {
281
- if (res.writableEnded || abort.signal.aborted) return;
282
- if (error instanceof HttpError) {
283
- sendError(res, error.status === 499 ? 400 : error.status, error.message, origin);
284
- } else if (error instanceof SyntaxError) {
285
- sendError(res, 400, 'Request body is not valid JSON.', origin);
286
- } else {
287
- log(`500: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
288
- sendError(res, 500, error instanceof Error ? error.message : 'Internal bridge error.', origin);
289
- }
290
- }
291
- return;
292
- }
293
-
294
- sendError(res, 404, `No route: ${req.method} ${pathname}`, origin);
295
- });
296
-
297
- // Long model turns must not be killed by Node's default 5-minute request timeout.
298
- server.requestTimeout = 0;
299
- server.headersTimeout = 60_000;
300
-
301
- server.listen(config.port, '127.0.0.1', () => {
302
- const enabled = Object.entries(config.providers)
303
- .filter(([, p]) => p.enabled && p.apiKey)
304
- .map(([id]) => id);
305
- console.log('');
306
- console.log(` Pix3AgentBridge listening on http://127.0.0.1:${config.port}`);
307
- console.log(` Pairing token: ${config.token}`);
308
- console.log(` Allowed origins: ${config.origins.join(', ')}`);
309
- console.log(
310
- ` Proxy providers: ${enabled.length > 0 ? enabled.join(', ') : '(none — add one: pix3-agent-bridge provider add openai --key sk-...)'}`
311
- );
312
- console.log('');
313
- console.log(' In pix3: Settings → AI Agent → paste the pairing token; advanced providers appear when enabled here.');
314
- console.log(' Agent-SDK (MAX) lane auth comes from your Claude Code login (`claude login`).');
315
- console.log('');
316
- });
317
-
318
- const shutdown = (): void => {
319
- log('shutting down');
320
- manager.closeAll('bridge shutting down');
321
- server.close(() => process.exit(0));
322
- setTimeout(() => process.exit(0), 2000).unref();
323
- };
324
- process.on('SIGINT', shutdown);
325
- process.on('SIGTERM', shutdown);
326
- };
327
-
328
- const main = (): void => {
329
- const argv = process.argv.slice(2);
330
- const command = argv[0];
331
-
332
- if (command === 'provider') {
333
- runProviderCommand(argv.slice(1));
334
- return;
335
- }
336
- if (command === 'help' || command === '--help' || command === '-h') {
337
- usage();
338
- return;
339
- }
340
-
341
- const config = loadConfig();
342
- // Serve-time flag overrides (not persisted): --port, --origin (repeatable).
343
- for (let i = 0; i < argv.length; i += 1) {
344
- if (argv[i] === '--port' && argv[i + 1]) config.port = Number(argv[i + 1]);
345
- if (argv[i] === '--origin' && argv[i + 1]) config.origins.push(argv[i + 1]);
346
- }
347
- startServer(config);
348
- };
349
-
350
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pix3AgentBridge — a LOCAL, personal bridge between the pix3 editor's in-editor agent and the LLM
4
+ * providers that a browser cannot reach directly.
5
+ *
6
+ * It does two jobs, both on 127.0.0.1:
7
+ *
8
+ * 1. Agent-SDK lane (`POST /v1/messages`, `GET /v1/models`): serves the Anthropic Messages wire
9
+ * shape from a real Claude Agent SDK (Claude Code / Pro/MAX subscription) session — no API key,
10
+ * usage draws from the subscription. This is the original bridge behaviour (see sessions.ts).
11
+ *
12
+ * 2. Provider proxy lane (`ALL /providers/:id/*`, `GET /v1/providers`): a credential-injecting
13
+ * reverse proxy for metered providers (OpenAI, Anthropic API, OpenCode Zen, custom
14
+ * OpenAI-compatible endpoints). The editor sends requests with only the pairing token; the
15
+ * bridge injects the provider key it stores in `~/.pix3/agent-bridge.json` and forwards to the
16
+ * fixed upstream. Keys never enter the browser. Providers are managed with the CLI:
17
+ *
18
+ * pix3-agent-bridge provider add openai --key sk-...
19
+ * pix3-agent-bridge provider list
20
+ *
21
+ * Usage:
22
+ * npx @pix3/agent-bridge (requires a `claude login`-ed Claude Code for lane 1)
23
+ * npx @pix3/agent-bridge provider add openai --key sk-...
24
+ * Options: --port <n> (default 8484), --origin <url> (repeatable, extra allowed origins)
25
+ *
26
+ * Security model (defense in depth for a localhost service):
27
+ * - binds to 127.0.0.1 only;
28
+ * - pairing token (generated once, stored in ~/.pix3/agent-bridge.json, printed on start) must
29
+ * accompany every API request (x-api-key / Authorization) — blocks other local processes/pages;
30
+ * - browser `Origin` allowlist (editor.pix3.dev / cloud.pix3.dev + local dev) — anything else 403;
31
+ * - `Host` header must be localhost — blocks DNS-rebinding;
32
+ * - the proxy lane never takes the upstream host from the client (fixed per provider) → no SSRF,
33
+ * and forwards only content-type + the injected key → the pairing token never leaks upstream;
34
+ * - the Agent-SDK session runs with zero built-in tools — the model can only call pix3 editor
35
+ * tools, never this machine's shell or filesystem.
36
+ */
37
+
38
+ import http from 'node:http';
39
+ import type { IncomingMessage, ServerResponse } from 'node:http';
40
+
41
+ import { SessionManager } from './sessions.ts';
42
+ import { HttpError, parseMessagesRequest } from './wire.ts';
43
+ import { loadConfig, RESERVED_PROVIDER_IDS, type BridgeConfig } from './config.ts';
44
+ import { runProviderCommand, usage } from './cli.ts';
45
+ import { forwardToProvider } from './proxy.ts';
46
+
47
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
48
+
49
+ /** Discovery entry for the intrinsic Agent-SDK (subscription) lane — not part of the provider table. */
50
+ const AGENT_SDK_PROVIDER = {
51
+ id: RESERVED_PROVIDER_IDS[0],
52
+ label: 'Claude Code (MAX)',
53
+ kind: 'agent-sdk' as const,
54
+ };
55
+
56
+ function modelEntry(
57
+ id: string,
58
+ label: string,
59
+ description: string,
60
+ maxOutputTokens: number,
61
+ contextWindow: number
62
+ ) {
63
+ return {
64
+ id,
65
+ label,
66
+ description,
67
+ capabilities: {
68
+ supportsTools: true,
69
+ supportsImages: true,
70
+ supportsSystemPrompt: true,
71
+ maxOutputTokens,
72
+ contextWindow,
73
+ },
74
+ pricing: { inputPer1M: 0, outputPer1M: 0 },
75
+ };
76
+ }
77
+
78
+ /** Static catalog served to the Agent-SDK lane's `GET /v1/models` — subscription models, $0 marginal cost. */
79
+ const AGENT_SDK_MODELS = [
80
+ modelEntry('claude-fable-5', 'Claude Fable 5 (MAX)', 'Most capable — via Claude Code subscription.', 32000, 1_000_000),
81
+ modelEntry('claude-opus-4-8', 'Claude Opus 4.8 (MAX)', 'Highly capable — via Claude Code subscription.', 32000, 1_000_000),
82
+ modelEntry('claude-sonnet-5', 'Claude Sonnet 5 (MAX)', 'Balanced speed and quality — via Claude Code subscription.', 32000, 1_000_000),
83
+ modelEntry('claude-haiku-4-5', 'Claude Haiku 4.5 (MAX)', 'Fastest — via Claude Code subscription.', 16000, 200_000),
84
+ ];
85
+
86
+ const log = (line: string): void => {
87
+ console.log(`${new Date().toISOString().slice(11, 19)} ${line}`);
88
+ };
89
+
90
+ const hostAllowed = (req: IncomingMessage): boolean => {
91
+ const host = (req.headers.host ?? '').toLowerCase();
92
+ return (
93
+ host.startsWith('localhost:') ||
94
+ host.startsWith('127.0.0.1:') ||
95
+ host === 'localhost' ||
96
+ host === '127.0.0.1'
97
+ );
98
+ };
99
+
100
+ const readBody = (req: IncomingMessage): Promise<Buffer> =>
101
+ new Promise((resolve, reject) => {
102
+ const chunks: Buffer[] = [];
103
+ let size = 0;
104
+ req.on('data', (chunk: Buffer) => {
105
+ size += chunk.length;
106
+ if (size > MAX_BODY_BYTES) {
107
+ reject(new HttpError(413, 'Request body too large.'));
108
+ req.destroy();
109
+ return;
110
+ }
111
+ chunks.push(chunk);
112
+ });
113
+ req.on('end', () => resolve(Buffer.concat(chunks)));
114
+ req.on('error', reject);
115
+ });
116
+
117
+ const startServer = (config: BridgeConfig): void => {
118
+ const manager = new SessionManager(log);
119
+
120
+ const withCors = (
121
+ corsOrigin: string | null,
122
+ extra: Record<string, string> = {}
123
+ ): Record<string, string> => {
124
+ const headers: Record<string, string> = { 'Cache-Control': 'no-store', ...extra };
125
+ if (corsOrigin) {
126
+ headers['Access-Control-Allow-Origin'] = corsOrigin;
127
+ headers['Vary'] = 'Origin';
128
+ }
129
+ return headers;
130
+ };
131
+
132
+ const sendJson = (
133
+ res: ServerResponse,
134
+ status: number,
135
+ body: unknown,
136
+ corsOrigin: string | null
137
+ ): void => {
138
+ res.writeHead(status, withCors(corsOrigin, { 'Content-Type': 'application/json; charset=utf-8' }));
139
+ res.end(JSON.stringify(body));
140
+ };
141
+
142
+ const sendError = (
143
+ res: ServerResponse,
144
+ status: number,
145
+ message: string,
146
+ corsOrigin: string | null
147
+ ): void => {
148
+ sendJson(res, status, { type: 'error', error: { type: 'invalid_request_error', message } }, corsOrigin);
149
+ };
150
+
151
+ const server = http.createServer(async (req, res) => {
152
+ const rawUrl = req.url ?? '/';
153
+ const qIndex = rawUrl.indexOf('?');
154
+ const pathname = qIndex >= 0 ? rawUrl.slice(0, qIndex) : rawUrl;
155
+ const query = qIndex >= 0 ? rawUrl.slice(qIndex) : '';
156
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : null;
157
+
158
+ if (!hostAllowed(req)) {
159
+ sendError(res, 403, 'Forbidden host.', null);
160
+ return;
161
+ }
162
+ if (origin && !config.origins.includes(origin)) {
163
+ log(`403 rejected origin ${origin}`);
164
+ sendError(res, 403, 'Origin not allowed.', null);
165
+ return;
166
+ }
167
+
168
+ // CORS preflight (no auth — browsers strip credentials from preflights).
169
+ if (req.method === 'OPTIONS') {
170
+ const headers: Record<string, string> = {
171
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
172
+ 'Access-Control-Allow-Headers':
173
+ (req.headers['access-control-request-headers'] as string | undefined) ??
174
+ 'content-type, x-api-key, authorization, x-pix3-bridge-token, anthropic-version, anthropic-dangerous-direct-browser-access',
175
+ 'Access-Control-Max-Age': '86400',
176
+ };
177
+ if (origin) {
178
+ headers['Access-Control-Allow-Origin'] = origin;
179
+ headers['Vary'] = 'Origin';
180
+ }
181
+ // Chrome Private Network Access / Local Network Access preflight opt-in.
182
+ if (req.headers['access-control-request-private-network'] === 'true') {
183
+ headers['Access-Control-Allow-Private-Network'] = 'true';
184
+ }
185
+ res.writeHead(204, headers);
186
+ res.end();
187
+ return;
188
+ }
189
+
190
+ if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) {
191
+ sendJson(
192
+ res,
193
+ 200,
194
+ {
195
+ ok: true,
196
+ name: 'pix3-agent-bridge',
197
+ hint: 'Paste the pairing token from the bridge console into pix3 (Settings → AI Agent).',
198
+ },
199
+ origin
200
+ );
201
+ return;
202
+ }
203
+
204
+ // Everything below requires the pairing token (dedicated header, x-api-key, or Authorization).
205
+ const auth =
206
+ (req.headers['x-pix3-bridge-token'] as string | undefined) ??
207
+ (req.headers['x-api-key'] as string | undefined) ??
208
+ req.headers.authorization?.replace(/^Bearer\s+/i, '');
209
+ if (auth !== config.token) {
210
+ sendError(res, 401, 'Invalid or missing bridge pairing token.', origin);
211
+ return;
212
+ }
213
+
214
+ // Discovery: which providers this bridge can serve right now (enabled + keyed) + the SDK lane.
215
+ if (req.method === 'GET' && pathname === '/v1/providers') {
216
+ const providers = Object.entries(config.providers)
217
+ .filter(([, p]) => p.enabled && p.apiKey)
218
+ .map(([id, p]) => ({ id, label: p.label, kind: p.kind, enabled: true }));
219
+ sendJson(res, 200, { providers: [...providers, AGENT_SDK_PROVIDER] }, origin);
220
+ return;
221
+ }
222
+
223
+ // Provider proxy lane: /providers/:id/<rest> → {provider.baseUrl}/<rest>
224
+ if (pathname.startsWith('/providers/')) {
225
+ const rest = pathname.slice('/providers/'.length);
226
+ const slash = rest.indexOf('/');
227
+ const providerId = slash >= 0 ? rest.slice(0, slash) : rest;
228
+ const restPath = slash >= 0 ? rest.slice(slash) : '/';
229
+ const provider = config.providers[providerId];
230
+ if (!provider || !provider.enabled) {
231
+ sendError(res, 404, `Provider "${providerId}" is not configured or is disabled.`, origin);
232
+ return;
233
+ }
234
+ if (!provider.apiKey) {
235
+ sendError(
236
+ res,
237
+ 400,
238
+ `Provider "${providerId}" has no API key. Run: pix3-agent-bridge provider set-key ${providerId} <key>`,
239
+ origin
240
+ );
241
+ return;
242
+ }
243
+ try {
244
+ const body = req.method === 'GET' || req.method === 'HEAD' ? null : await readBody(req);
245
+ const result = await forwardToProvider(provider, {
246
+ method: req.method ?? 'GET',
247
+ restPath,
248
+ query,
249
+ body,
250
+ });
251
+ res.writeHead(result.status, withCors(origin, { 'Content-Type': result.contentType }));
252
+ res.end(result.body);
253
+ } catch (error) {
254
+ if (error instanceof HttpError) {
255
+ sendError(res, error.status, error.message, origin);
256
+ } else {
257
+ log(`proxy error (${providerId}): ${error instanceof Error ? error.message : String(error)}`);
258
+ sendError(res, 502, `Upstream request to "${providerId}" failed.`, origin);
259
+ }
260
+ }
261
+ return;
262
+ }
263
+
264
+ // Agent-SDK lane.
265
+ if (req.method === 'GET' && pathname === '/v1/models') {
266
+ sendJson(res, 200, { models: AGENT_SDK_MODELS }, origin);
267
+ return;
268
+ }
269
+
270
+ if (req.method === 'POST' && pathname === '/v1/messages') {
271
+ const abort = new AbortController();
272
+ res.on('close', () => {
273
+ if (!res.writableEnded) abort.abort();
274
+ });
275
+ try {
276
+ const body = (await readBody(req)).toString('utf8');
277
+ const request = parseMessagesRequest(JSON.parse(body));
278
+ const response = await manager.handle(request, abort.signal);
279
+ sendJson(res, response.status, response.body, origin);
280
+ } catch (error) {
281
+ if (res.writableEnded || abort.signal.aborted) return;
282
+ if (error instanceof HttpError) {
283
+ sendError(res, error.status === 499 ? 400 : error.status, error.message, origin);
284
+ } else if (error instanceof SyntaxError) {
285
+ sendError(res, 400, 'Request body is not valid JSON.', origin);
286
+ } else {
287
+ log(`500: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
288
+ sendError(res, 500, error instanceof Error ? error.message : 'Internal bridge error.', origin);
289
+ }
290
+ }
291
+ return;
292
+ }
293
+
294
+ sendError(res, 404, `No route: ${req.method} ${pathname}`, origin);
295
+ });
296
+
297
+ // Long model turns must not be killed by Node's default 5-minute request timeout.
298
+ server.requestTimeout = 0;
299
+ server.headersTimeout = 60_000;
300
+
301
+ server.listen(config.port, '127.0.0.1', () => {
302
+ const enabled = Object.entries(config.providers)
303
+ .filter(([, p]) => p.enabled && p.apiKey)
304
+ .map(([id]) => id);
305
+ console.log('');
306
+ console.log(` Pix3AgentBridge listening on http://127.0.0.1:${config.port}`);
307
+ console.log(` Pairing token: ${config.token}`);
308
+ console.log(` Allowed origins: ${config.origins.join(', ')}`);
309
+ console.log(
310
+ ` Proxy providers: ${enabled.length > 0 ? enabled.join(', ') : '(none — add one: pix3-agent-bridge provider add openai --key sk-...)'}`
311
+ );
312
+ console.log('');
313
+ console.log(' In pix3: Settings → AI Agent → paste the pairing token; advanced providers appear when enabled here.');
314
+ console.log(' Agent-SDK (MAX) lane auth comes from your Claude Code login (`claude login`).');
315
+ console.log('');
316
+ });
317
+
318
+ const shutdown = (): void => {
319
+ log('shutting down');
320
+ manager.closeAll('bridge shutting down');
321
+ server.close(() => process.exit(0));
322
+ setTimeout(() => process.exit(0), 2000).unref();
323
+ };
324
+ process.on('SIGINT', shutdown);
325
+ process.on('SIGTERM', shutdown);
326
+ };
327
+
328
+ const main = (): void => {
329
+ const argv = process.argv.slice(2);
330
+ const command = argv[0];
331
+
332
+ if (command === 'provider') {
333
+ runProviderCommand(argv.slice(1));
334
+ return;
335
+ }
336
+ if (command === 'help' || command === '--help' || command === '-h') {
337
+ usage();
338
+ return;
339
+ }
340
+
341
+ const config = loadConfig();
342
+ // Serve-time flag overrides (not persisted): --port, --origin (repeatable).
343
+ for (let i = 0; i < argv.length; i += 1) {
344
+ if (argv[i] === '--port' && argv[i + 1]) config.port = Number(argv[i + 1]);
345
+ if (argv[i] === '--origin' && argv[i + 1]) config.origins.push(argv[i + 1]);
346
+ }
347
+ startServer(config);
348
+ };
349
+
350
+ main();