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/README.md +77 -0
- package/bin/cli.js +275 -0
- package/bridge.js +700 -0
- package/install-protocol.ps1 +73 -0
- package/opencode-handler.cmd +137 -0
- package/opencode-launch.ps1 +94 -0
- package/package.json +38 -0
- package/uninstall-protocol.ps1 +16 -0
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
|
+
});
|