agentic-factory-bridge 1.0.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/bridge.js ADDED
@@ -0,0 +1,700 @@
1
+ /**
2
+ * Agentic Factory Bridge — Local bridge between Atos Agentic Factory and OpenCode CLI.
3
+ *
4
+ * This lightweight Express server runs on the user's machine (port 3001)
5
+ * and acts as an intermediary:
6
+ * 1. Receives execution requests from the marketplace frontend
7
+ * 2. Spawns `opencode run --format json` with the given prompt
8
+ * 3. Reports results back to the marketplace backend via PATCH
9
+ *
10
+ * Security features:
11
+ * - CORS restricted to allowed origins
12
+ * - HMAC token authentication on all mutating endpoints
13
+ * - Input sanitization to prevent command injection
14
+ * - Rate limiting on sensitive endpoints
15
+ * - Bounded in-memory store with TTL
16
+ * - Body size limits
17
+ * - No sensitive info leaked in /health
18
+ *
19
+ * Usage:
20
+ * npm install -g agentic-factory-bridge
21
+ * agentic-factory-bridge start
22
+ *
23
+ * Environment variables:
24
+ * BRIDGE_PORT — Port to listen on (default: 3001)
25
+ * MARKETPLACE_API_URL — Marketplace backend URL (default: http://localhost:3000/api)
26
+ * OPENCODE_PATH — Path to opencode binary (default: "opencode" — must be in PATH)
27
+ * BRIDGE_SECRET — Shared secret for HMAC authentication (default: auto-generated)
28
+ * BRIDGE_CORS_ORIGIN — Allowed CORS origins, comma-separated
29
+ */
30
+
31
+ const express = require('express');
32
+ const cors = require('cors');
33
+ const crypto = require('crypto');
34
+ const { v4: uuidv4 } = require('uuid');
35
+ const { spawn } = require('child_process');
36
+ const http = require('http');
37
+ const https = require('https');
38
+ const path = require('path');
39
+ const fs = require('fs');
40
+
41
+ const BRIDGE_VERSION = require(path.join(__dirname, 'package.json')).version;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Configuration
45
+ // ---------------------------------------------------------------------------
46
+ const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
47
+ const MARKETPLACE_API_URL = process.env.MARKETPLACE_API_URL || 'http://localhost:3000/api';
48
+ const OPENCODE_PATH = process.env.OPENCODE_PATH || 'opencode';
49
+
50
+ // Security configuration
51
+ const BRIDGE_SECRET = process.env.BRIDGE_SECRET || crypto.randomBytes(32).toString('hex');
52
+ const CORS_ORIGIN =
53
+ process.env.BRIDGE_CORS_ORIGIN ||
54
+ 'http://localhost:4200,https://atos-agentic-factory.onrender.com';
55
+
56
+ // Rate limiting configuration
57
+ const RATE_LIMITS = {
58
+ execute: { windowMs: 60000, max: 10 },
59
+ install: { windowMs: 3600000, max: 2 },
60
+ register: { windowMs: 3600000, max: 3 },
61
+ };
62
+
63
+ // In-memory store limits
64
+ const MAX_EXECUTIONS = 100;
65
+ const EXECUTION_TTL_MS = 3600000;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Security: Input sanitization
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const DANGEROUS_PATTERNS = /[&|;`$<>{}()\n\r]/;
72
+ const DANGEROUS_WIN_PATTERNS = /(\^[&|<>])|(%[a-zA-Z0-9]+%)/;
73
+
74
+ function sanitizeInput(input, fieldName, maxLength = 10000) {
75
+ if (typeof input !== 'string') {
76
+ return { safe: false, cleaned: '', reason: `${fieldName} must be a string` };
77
+ }
78
+ if (input.length > maxLength) {
79
+ return {
80
+ safe: false,
81
+ cleaned: '',
82
+ reason: `${fieldName} exceeds maximum length of ${maxLength}`,
83
+ };
84
+ }
85
+ if (DANGEROUS_PATTERNS.test(input)) {
86
+ return {
87
+ safe: false,
88
+ cleaned: '',
89
+ reason: `${fieldName} contains forbidden characters (& | ; \` $ < > { } ( ))`,
90
+ };
91
+ }
92
+ if (process.platform === 'win32' && DANGEROUS_WIN_PATTERNS.test(input)) {
93
+ return {
94
+ safe: false,
95
+ cleaned: '',
96
+ reason: `${fieldName} contains forbidden Windows patterns`,
97
+ };
98
+ }
99
+ return { safe: true, cleaned: input.trim() };
100
+ }
101
+
102
+ function validateModel(model) {
103
+ if (!model) return { safe: true, cleaned: '' };
104
+ if (typeof model !== 'string') {
105
+ return { safe: false, cleaned: '', reason: 'model must be a string' };
106
+ }
107
+ if (model.length > 200) {
108
+ return { safe: false, cleaned: '', reason: 'model name too long (max 200)' };
109
+ }
110
+ if (!/^[a-zA-Z0-9_\-./]+$/.test(model)) {
111
+ return {
112
+ safe: false,
113
+ cleaned: '',
114
+ reason: 'model contains invalid characters (only a-z, 0-9, - _ . / allowed)',
115
+ };
116
+ }
117
+ return { safe: true, cleaned: model };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Security: HMAC token authentication
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function generateBridgeToken() {
125
+ const timestamp = Date.now().toString();
126
+ const hmac = crypto.createHmac('sha256', BRIDGE_SECRET).update(timestamp).digest('hex');
127
+ return `${timestamp}.${hmac}`;
128
+ }
129
+
130
+ function verifyBridgeToken(token) {
131
+ if (!token || typeof token !== 'string') return false;
132
+ const parts = token.split('.');
133
+ if (parts.length !== 2) return false;
134
+ const [timestamp, hmac] = parts;
135
+ const ts = parseInt(timestamp, 10);
136
+ if (isNaN(ts)) return false;
137
+ const now = Date.now();
138
+ if (Math.abs(now - ts) > 5 * 60 * 1000) return false;
139
+ const expected = crypto.createHmac('sha256', BRIDGE_SECRET).update(timestamp).digest('hex');
140
+ return crypto.timingSafeEqual(Buffer.from(hmac, 'hex'), Buffer.from(expected, 'hex'));
141
+ }
142
+
143
+ function requireBridgeAuth(req, res, next) {
144
+ const token = req.headers['x-bridge-token'];
145
+ if (!verifyBridgeToken(token)) {
146
+ return res.status(401).json({
147
+ error: 'Unauthorized: invalid or missing X-Bridge-Token header',
148
+ });
149
+ }
150
+ next();
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Security: Rate limiting
155
+ // ---------------------------------------------------------------------------
156
+
157
+ const rateLimitStore = new Map();
158
+
159
+ function rateLimit(key, config) {
160
+ return (req, res, next) => {
161
+ const ip = req.ip || req.connection.remoteAddress || 'unknown';
162
+ const storeKey = `${key}:${ip}`;
163
+ const now = Date.now();
164
+ let entry = rateLimitStore.get(storeKey);
165
+ if (!entry || now >= entry.resetAt) {
166
+ entry = { count: 0, resetAt: now + config.windowMs };
167
+ rateLimitStore.set(storeKey, entry);
168
+ }
169
+ entry.count++;
170
+ if (entry.count > config.max) {
171
+ const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
172
+ res.set('Retry-After', String(retryAfter));
173
+ return res.status(429).json({
174
+ error: `Too many requests. Limit: ${config.max} per ${config.windowMs / 1000}s. Retry after ${retryAfter}s.`,
175
+ });
176
+ }
177
+ next();
178
+ };
179
+ }
180
+
181
+ setInterval(() => {
182
+ const now = Date.now();
183
+ for (const [key, entry] of rateLimitStore) {
184
+ if (now >= entry.resetAt) rateLimitStore.delete(key);
185
+ }
186
+ }, 600000);
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // In-memory execution store
190
+ // ---------------------------------------------------------------------------
191
+
192
+ const executions = new Map();
193
+
194
+ function evictStaleExecutions() {
195
+ const now = Date.now();
196
+ for (const [id, exec] of executions) {
197
+ if (now - exec.startedAt.getTime() > EXECUTION_TTL_MS) {
198
+ executions.delete(id);
199
+ }
200
+ }
201
+ if (executions.size > MAX_EXECUTIONS) {
202
+ const sorted = Array.from(executions.entries()).sort(
203
+ (a, b) => a[1].startedAt.getTime() - b[1].startedAt.getTime(),
204
+ );
205
+ const toRemove = sorted.slice(0, executions.size - MAX_EXECUTIONS);
206
+ for (const [id] of toRemove) {
207
+ executions.delete(id);
208
+ }
209
+ }
210
+ }
211
+
212
+ setInterval(evictStaleExecutions, 300000);
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Helpers
216
+ // ---------------------------------------------------------------------------
217
+
218
+ function httpRequest(method, url, body, token) {
219
+ return new Promise((resolve, reject) => {
220
+ const parsed = new URL(url);
221
+ const mod = parsed.protocol === 'https:' ? https : http;
222
+ const payload = JSON.stringify(body);
223
+ const options = {
224
+ hostname: parsed.hostname,
225
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
226
+ path: parsed.pathname + parsed.search,
227
+ method,
228
+ headers: {
229
+ 'Content-Type': 'application/json',
230
+ 'Content-Length': Buffer.byteLength(payload),
231
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
232
+ },
233
+ };
234
+ const req = mod.request(options, (res) => {
235
+ let data = '';
236
+ res.on('data', (chunk) => (data += chunk));
237
+ res.on('end', () => {
238
+ try {
239
+ resolve({ status: res.statusCode, body: JSON.parse(data) });
240
+ } catch {
241
+ resolve({ status: res.statusCode, body: data });
242
+ }
243
+ });
244
+ });
245
+ req.on('error', reject);
246
+ req.write(payload);
247
+ req.end();
248
+ });
249
+ }
250
+
251
+ async function reportToBackend(executionId, update, token) {
252
+ const url = `${MARKETPLACE_API_URL}/opencode/executions/${executionId}`;
253
+ try {
254
+ const result = await httpRequest('PATCH', url, update, token);
255
+ console.log(`[bridge] Reported to backend: ${url} -> ${result.status}`);
256
+ } catch (err) {
257
+ console.error(`[bridge] Failed to report to backend: ${err.message}`);
258
+ }
259
+ }
260
+
261
+ function runOpenCode(prompt, model) {
262
+ return new Promise((resolve, reject) => {
263
+ const args = ['run', prompt, '--format', 'json'];
264
+ if (model) {
265
+ args.push('--model', model);
266
+ }
267
+ console.log(`[bridge] Spawning: ${OPENCODE_PATH} ${args.join(' ')}`);
268
+ const proc = spawn(OPENCODE_PATH, args, {
269
+ stdio: ['pipe', 'pipe', 'pipe'],
270
+ timeout: 300000,
271
+ shell: false,
272
+ windowsHide: true,
273
+ });
274
+ let stdout = '';
275
+ let stderr = '';
276
+ proc.stdout.on('data', (data) => {
277
+ stdout += data.toString();
278
+ });
279
+ proc.stderr.on('data', (data) => {
280
+ stderr += data.toString();
281
+ });
282
+ proc.on('close', (code) => {
283
+ if (code === 0) {
284
+ resolve({ stdout, stderr, code });
285
+ } else {
286
+ reject(new Error(`OpenCode exited with code ${code}: ${stderr || stdout}`));
287
+ }
288
+ });
289
+ proc.on('error', (err) => {
290
+ reject(new Error(`Failed to spawn OpenCode: ${err.message}`));
291
+ });
292
+ });
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Express app
297
+ // ---------------------------------------------------------------------------
298
+ const app = express();
299
+
300
+ const allowedOrigins = CORS_ORIGIN.split(',').map((o) => o.trim());
301
+ app.use(
302
+ cors({
303
+ origin: (origin, callback) => {
304
+ if (!origin) return callback(null, true);
305
+ if (allowedOrigins.includes(origin)) return callback(null, true);
306
+ callback(new Error(`CORS: origin ${origin} not allowed`));
307
+ },
308
+ credentials: true,
309
+ }),
310
+ );
311
+
312
+ app.use(express.json({ limit: '1mb' }));
313
+
314
+ app.use((_req, res, next) => {
315
+ res.set('X-Content-Type-Options', 'nosniff');
316
+ res.set('X-Frame-Options', 'DENY');
317
+ res.set('Cache-Control', 'no-store');
318
+ next();
319
+ });
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Public endpoints
323
+ // ---------------------------------------------------------------------------
324
+
325
+ app.get('/health', (_req, res) => {
326
+ res.json({
327
+ status: 'ok',
328
+ bridge: 'agentic-factory-bridge',
329
+ version: BRIDGE_VERSION,
330
+ uptime: Math.floor(process.uptime()),
331
+ activeExecutions: executions.size,
332
+ });
333
+ });
334
+
335
+ app.get('/bridge-token', (_req, res) => {
336
+ const token = generateBridgeToken();
337
+ res.json({ token, expiresIn: 300 });
338
+ });
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Protected endpoints
342
+ // ---------------------------------------------------------------------------
343
+
344
+ app.post(
345
+ '/execute',
346
+ requireBridgeAuth,
347
+ rateLimit('execute', RATE_LIMITS.execute),
348
+ async (req, res) => {
349
+ const { executionId, prompt, agentId, model, token } = req.body;
350
+
351
+ if (!executionId || !prompt) {
352
+ return res.status(400).json({ error: 'Missing required fields: executionId, prompt' });
353
+ }
354
+ if (!token) {
355
+ return res
356
+ .status(400)
357
+ .json({ error: 'Missing required field: token (JWT for backend callback)' });
358
+ }
359
+ if (
360
+ typeof executionId !== 'string' ||
361
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(executionId)
362
+ ) {
363
+ return res.status(400).json({ error: 'executionId must be a valid UUID' });
364
+ }
365
+
366
+ const promptCheck = sanitizeInput(prompt, 'prompt');
367
+ if (!promptCheck.safe) {
368
+ return res.status(400).json({ error: promptCheck.reason });
369
+ }
370
+ const modelCheck = validateModel(model);
371
+ if (!modelCheck.safe) {
372
+ return res.status(400).json({ error: modelCheck.reason });
373
+ }
374
+
375
+ evictStaleExecutions();
376
+
377
+ const execution = {
378
+ id: executionId,
379
+ status: 'running',
380
+ startedAt: new Date(),
381
+ prompt: promptCheck.cleaned,
382
+ agentId,
383
+ model: modelCheck.cleaned || undefined,
384
+ logs: [],
385
+ };
386
+ executions.set(executionId, execution);
387
+
388
+ res.json({ message: 'Execution started', executionId, status: 'running' });
389
+
390
+ const startTime = Date.now();
391
+ try {
392
+ execution.logs.push(`[${new Date().toISOString()}] Starting OpenCode CLI...`);
393
+ const result = await runOpenCode(promptCheck.cleaned, modelCheck.cleaned || undefined);
394
+ const executionTime = Date.now() - startTime;
395
+ execution.status = 'completed';
396
+ execution.logs.push(`[${new Date().toISOString()}] Completed in ${executionTime}ms`);
397
+
398
+ let response = result.stdout;
399
+ let tokensUsed = null;
400
+ let cost = null;
401
+ try {
402
+ const parsed = JSON.parse(result.stdout);
403
+ response = parsed.response || parsed.output || parsed.result || result.stdout;
404
+ tokensUsed = parsed.tokens || parsed.tokensUsed || null;
405
+ cost = parsed.cost || null;
406
+ } catch {
407
+ execution.logs.push(`[${new Date().toISOString()}] Output is not JSON, using raw text`);
408
+ }
409
+
410
+ execution.response = response;
411
+ await reportToBackend(
412
+ executionId,
413
+ {
414
+ status: 'completed',
415
+ response: typeof response === 'string' ? response : JSON.stringify(response),
416
+ executionTime,
417
+ tokensUsed,
418
+ cost,
419
+ logs: execution.logs,
420
+ bridgeVersion: BRIDGE_VERSION,
421
+ },
422
+ token,
423
+ );
424
+ } catch (err) {
425
+ const executionTime = Date.now() - startTime;
426
+ execution.status = 'failed';
427
+ execution.error = err.message;
428
+ execution.logs.push(`[${new Date().toISOString()}] Error: ${err.message}`);
429
+ await reportToBackend(
430
+ executionId,
431
+ {
432
+ status: 'failed',
433
+ errorMessage: err.message,
434
+ executionTime,
435
+ logs: execution.logs,
436
+ bridgeVersion: BRIDGE_VERSION,
437
+ },
438
+ token,
439
+ );
440
+ }
441
+ },
442
+ );
443
+
444
+ app.get('/status/:executionId', requireBridgeAuth, (req, res) => {
445
+ const execution = executions.get(req.params.executionId);
446
+ if (!execution) {
447
+ return res.status(404).json({ error: 'Execution not found' });
448
+ }
449
+ res.json({
450
+ id: execution.id,
451
+ status: execution.status,
452
+ startedAt: execution.startedAt,
453
+ prompt: execution.prompt,
454
+ response: execution.response,
455
+ error: execution.error,
456
+ logs: execution.logs,
457
+ });
458
+ });
459
+
460
+ app.get('/executions', requireBridgeAuth, (_req, res) => {
461
+ const list = Array.from(executions.values()).map((e) => ({
462
+ id: e.id,
463
+ status: e.status,
464
+ startedAt: e.startedAt,
465
+ prompt: e.prompt.substring(0, 100) + (e.prompt.length > 100 ? '...' : ''),
466
+ agentId: e.agentId,
467
+ }));
468
+ res.json(list);
469
+ });
470
+
471
+ app.delete('/executions', requireBridgeAuth, (_req, res) => {
472
+ const count = executions.size;
473
+ executions.clear();
474
+ res.json({ message: `Cleared ${count} executions` });
475
+ });
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Smart Detection
479
+ // ---------------------------------------------------------------------------
480
+
481
+ function checkCommandExists(command) {
482
+ return new Promise((resolve) => {
483
+ const isWin = process.platform === 'win32';
484
+ const checkCmd = isWin ? 'where' : 'which';
485
+ const proc = spawn(checkCmd, [command], {
486
+ stdio: ['pipe', 'pipe', 'pipe'],
487
+ timeout: 10000,
488
+ shell: false,
489
+ });
490
+ let stdout = '';
491
+ proc.stdout.on('data', (data) => {
492
+ stdout += data.toString();
493
+ });
494
+ proc.on('close', (code) => {
495
+ if (code === 0 && stdout.trim()) {
496
+ const cmdPath = stdout.trim().split('\n')[0].trim();
497
+ const versionProc = spawn(command, ['--version'], {
498
+ stdio: ['pipe', 'pipe', 'pipe'],
499
+ timeout: 10000,
500
+ shell: false,
501
+ });
502
+ let versionOut = '';
503
+ versionProc.stdout.on('data', (d) => {
504
+ versionOut += d.toString();
505
+ });
506
+ versionProc.stderr.on('data', (d) => {
507
+ versionOut += d.toString();
508
+ });
509
+ versionProc.on('close', () => {
510
+ resolve({ installed: true, version: versionOut.trim() || 'unknown', path: cmdPath });
511
+ });
512
+ versionProc.on('error', () => {
513
+ resolve({ installed: true, version: 'unknown', path: cmdPath });
514
+ });
515
+ } else {
516
+ resolve({ installed: false, version: null, path: null });
517
+ }
518
+ });
519
+ proc.on('error', () => {
520
+ resolve({ installed: false, version: null, path: null });
521
+ });
522
+ });
523
+ }
524
+
525
+ app.get('/check-opencode', async (_req, res) => {
526
+ try {
527
+ const result = await checkCommandExists(OPENCODE_PATH);
528
+ console.log(
529
+ `[bridge] OpenCode check: installed=${result.installed}, version=${result.version}`,
530
+ );
531
+ res.json(result);
532
+ } catch (err) {
533
+ console.error(`[bridge] OpenCode check error: ${err.message}`);
534
+ res.json({ installed: false, version: null, path: null, error: err.message });
535
+ }
536
+ });
537
+
538
+ app.post(
539
+ '/install-opencode',
540
+ requireBridgeAuth,
541
+ rateLimit('install', RATE_LIMITS.install),
542
+ async (_req, res) => {
543
+ console.log('[bridge] Installing OpenCode CLI via npm install -g opencode...');
544
+ try {
545
+ await new Promise((resolve, reject) => {
546
+ const proc = spawn('npm', ['install', '-g', 'opencode'], {
547
+ stdio: ['pipe', 'pipe', 'pipe'],
548
+ timeout: 120000,
549
+ shell: false,
550
+ });
551
+ let stdout = '';
552
+ let stderr = '';
553
+ proc.stdout.on('data', (data) => {
554
+ stdout += data.toString();
555
+ console.log(`[bridge] npm install stdout: ${data.toString().trim()}`);
556
+ });
557
+ proc.stderr.on('data', (data) => {
558
+ stderr += data.toString();
559
+ });
560
+ proc.on('close', (code) => {
561
+ if (code === 0) resolve({ success: true, stdout, stderr });
562
+ else
563
+ reject(
564
+ new Error(`npm install -g opencode failed with code ${code}: ${stderr || stdout}`),
565
+ );
566
+ });
567
+ proc.on('error', (err) => {
568
+ reject(new Error(`Failed to spawn npm: ${err.message}`));
569
+ });
570
+ });
571
+
572
+ const check = await checkCommandExists(OPENCODE_PATH);
573
+ res.json({
574
+ success: true,
575
+ message: 'OpenCode CLI installed successfully',
576
+ version: check.version || 'unknown',
577
+ path: check.path || null,
578
+ });
579
+ } catch (err) {
580
+ console.error(`[bridge] OpenCode install error: ${err.message}`);
581
+ res.status(500).json({
582
+ success: false,
583
+ message: `Installation failed: ${err.message}`,
584
+ version: null,
585
+ path: null,
586
+ });
587
+ }
588
+ },
589
+ );
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // Protocol Registration
593
+ // ---------------------------------------------------------------------------
594
+
595
+ app.post(
596
+ '/register-protocol',
597
+ requireBridgeAuth,
598
+ rateLimit('register', RATE_LIMITS.register),
599
+ async (_req, res) => {
600
+ const isWin = process.platform === 'win32';
601
+ if (!isWin) {
602
+ return res.status(400).json({
603
+ success: false,
604
+ message: 'Protocol registration is only supported on Windows.',
605
+ });
606
+ }
607
+
608
+ const scriptPath = path.join(__dirname, 'install-protocol.ps1');
609
+ const handlerPath = path.join(__dirname, 'opencode-handler.cmd');
610
+
611
+ if (!fs.existsSync(scriptPath)) {
612
+ return res.status(404).json({
613
+ success: false,
614
+ message: `install-protocol.ps1 not found in ${__dirname}`,
615
+ });
616
+ }
617
+ if (!fs.existsSync(handlerPath)) {
618
+ return res.status(404).json({
619
+ success: false,
620
+ message: `opencode-handler.cmd not found in ${__dirname}`,
621
+ });
622
+ }
623
+
624
+ console.log('[bridge] Registering opencode:// protocol via install-protocol.ps1...');
625
+
626
+ try {
627
+ await new Promise((resolve, reject) => {
628
+ const proc = spawn(
629
+ 'powershell',
630
+ ['-ExecutionPolicy', 'Bypass', '-NonInteractive', '-File', scriptPath],
631
+ {
632
+ stdio: ['pipe', 'pipe', 'pipe'],
633
+ timeout: 30000,
634
+ shell: false,
635
+ },
636
+ );
637
+ let stdout = '';
638
+ let stderr = '';
639
+ proc.stdout.on('data', (data) => {
640
+ stdout += data.toString();
641
+ console.log(`[bridge] ps1 stdout: ${data.toString().trim()}`);
642
+ });
643
+ proc.stderr.on('data', (data) => {
644
+ stderr += data.toString();
645
+ });
646
+ proc.on('close', (code) => {
647
+ if (code === 0) resolve({ stdout, stderr });
648
+ else
649
+ reject(new Error(`install-protocol.ps1 exited with code ${code}: ${stderr || stdout}`));
650
+ });
651
+ proc.on('error', (err) => {
652
+ reject(new Error(`Failed to spawn PowerShell: ${err.message}`));
653
+ });
654
+ });
655
+
656
+ res.json({
657
+ success: true,
658
+ message: 'Protocol opencode:// registered successfully. Please restart your browser.',
659
+ });
660
+ } catch (err) {
661
+ console.error(`[bridge] Protocol registration error: ${err.message}`);
662
+ res.status(500).json({
663
+ success: false,
664
+ message: `Registration failed: ${err.message}`,
665
+ });
666
+ }
667
+ },
668
+ );
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // Start server
672
+ // ---------------------------------------------------------------------------
673
+ app.listen(PORT, '127.0.0.1', () => {
674
+ const isAutoSecret = !process.env.BRIDGE_SECRET;
675
+ console.log('');
676
+ console.log(' +----------------------------------------------+');
677
+ console.log(' | |');
678
+ console.log(` | Agentic Factory Bridge v${BRIDGE_VERSION.padEnd(19)}|`);
679
+ console.log(` | Listening on http://127.0.0.1:${PORT} |`);
680
+ console.log(' | |');
681
+ const origins = CORS_ORIGIN.split(',').map((o) => o.trim());
682
+ origins.forEach((o, i) => {
683
+ const label = i === 0 ? 'CORS: ' : ' ';
684
+ console.log(` | ${label}${o.padEnd(33).substring(0, 33)}|`);
685
+ });
686
+ console.log(' | |');
687
+ if (isAutoSecret) {
688
+ console.log(' | [!] Using auto-generated bridge secret |');
689
+ console.log(' | Set BRIDGE_SECRET env var for fixed key |');
690
+ } else {
691
+ console.log(' | [OK] Using configured BRIDGE_SECRET |');
692
+ }
693
+ console.log(' | |');
694
+ console.log(' +----------------------------------------------+');
695
+ console.log('');
696
+ if (isAutoSecret) {
697
+ console.log(` Bridge secret: ${BRIDGE_SECRET.substring(0, 16)}...`);
698
+ console.log('');
699
+ }
700
+ });