agentic-factory-bridge 1.2.0 → 1.3.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 +297 -103
- package/package.json +1 -1
package/bridge.js
CHANGED
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* OpenCode Bridge — Local bridge between Atos Agentic Factory and OpenCode CLI.
|
|
3
3
|
*
|
|
4
|
-
* This lightweight Express server runs on the
|
|
4
|
+
* This lightweight Express server runs on the developer's machine (port 3001)
|
|
5
5
|
* and acts as an intermediary:
|
|
6
6
|
* 1. Receives execution requests from the marketplace frontend
|
|
7
7
|
* 2. Spawns `opencode run --format json` with the given prompt
|
|
8
8
|
* 3. Reports results back to the marketplace backend via PATCH
|
|
9
9
|
*
|
|
10
10
|
* Security features:
|
|
11
|
-
* - CORS restricted to allowed origins
|
|
11
|
+
* - CORS restricted to allowed origins (localhost:4200 by default)
|
|
12
12
|
* - HMAC token authentication on all mutating endpoints
|
|
13
|
-
* - Input sanitization to prevent command injection
|
|
13
|
+
* - Input sanitization (prompt, model) to prevent command injection
|
|
14
14
|
* - Rate limiting on sensitive endpoints
|
|
15
15
|
* - Bounded in-memory store with TTL
|
|
16
16
|
* - Body size limits
|
|
17
17
|
* - No sensitive info leaked in /health
|
|
18
18
|
*
|
|
19
19
|
* Usage:
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* cd opencode-bridge
|
|
21
|
+
* npm install
|
|
22
|
+
* npm start
|
|
22
23
|
*
|
|
23
24
|
* Environment variables:
|
|
24
25
|
* BRIDGE_PORT — Port to listen on (default: 3001)
|
|
25
26
|
* MARKETPLACE_API_URL — Marketplace backend URL (default: http://localhost:3000/api)
|
|
26
27
|
* OPENCODE_PATH — Path to opencode binary (default: "opencode" — must be in PATH)
|
|
27
28
|
* BRIDGE_SECRET — Shared secret for HMAC authentication (default: auto-generated)
|
|
28
|
-
* BRIDGE_CORS_ORIGIN — Allowed CORS origins, comma-separated
|
|
29
|
+
* BRIDGE_CORS_ORIGIN — Allowed CORS origins, comma-separated (default: http://localhost:4200,https://atos-agentic-factory.onrender.com,https://atos-agentic-factory-qzwe.onrender.com)
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
const express = require('express');
|
|
@@ -37,14 +38,13 @@ const https = require('https');
|
|
|
37
38
|
const path = require('path');
|
|
38
39
|
const fs = require('fs');
|
|
39
40
|
|
|
40
|
-
const BRIDGE_VERSION = require(path.join(__dirname, 'package.json')).version;
|
|
41
|
-
|
|
42
41
|
// ---------------------------------------------------------------------------
|
|
43
42
|
// Configuration
|
|
44
43
|
// ---------------------------------------------------------------------------
|
|
45
44
|
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
|
|
46
45
|
const MARKETPLACE_API_URL = process.env.MARKETPLACE_API_URL || 'http://localhost:3000/api';
|
|
47
46
|
const OPENCODE_PATH = process.env.OPENCODE_PATH || 'opencode';
|
|
47
|
+
const BRIDGE_VERSION = require(path.join(__dirname, 'package.json')).version;
|
|
48
48
|
|
|
49
49
|
// Security configuration
|
|
50
50
|
const BRIDGE_SECRET = process.env.BRIDGE_SECRET || crypto.randomBytes(32).toString('hex');
|
|
@@ -54,23 +54,31 @@ const CORS_ORIGIN =
|
|
|
54
54
|
|
|
55
55
|
// Rate limiting configuration
|
|
56
56
|
const RATE_LIMITS = {
|
|
57
|
-
execute: { windowMs: 60000, max: 10 },
|
|
58
|
-
install: { windowMs: 3600000, max: 2 },
|
|
59
|
-
register: { windowMs: 3600000, max: 3 },
|
|
60
|
-
update: { windowMs: 3600000, max: 3 },
|
|
57
|
+
execute: { windowMs: 60000, max: 10 }, // 10 executions per minute
|
|
58
|
+
install: { windowMs: 3600000, max: 2 }, // 2 installs per hour
|
|
59
|
+
register: { windowMs: 3600000, max: 3 }, // 3 registrations per hour
|
|
60
|
+
update: { windowMs: 3600000, max: 3 }, // 3 updates per hour
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
// In-memory store limits
|
|
64
64
|
const MAX_EXECUTIONS = 100;
|
|
65
|
-
const EXECUTION_TTL_MS = 3600000;
|
|
65
|
+
const EXECUTION_TTL_MS = 3600000; // 1 hour
|
|
66
66
|
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
68
|
// Security: Input sanitization
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Dangerous shell characters/patterns that could enable command injection.
|
|
73
|
+
* Blocks: & | ; ` $() ${ } < > \n \r and common Windows cmd injection patterns.
|
|
74
|
+
*/
|
|
71
75
|
const DANGEROUS_PATTERNS = /[&|;`$<>{}()\n\r]/;
|
|
72
76
|
const DANGEROUS_WIN_PATTERNS = /(\^[&|<>])|(%[a-zA-Z0-9]+%)/;
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Sanitizes a string input to prevent command injection.
|
|
80
|
+
* Returns { safe: boolean, cleaned: string, reason?: string }
|
|
81
|
+
*/
|
|
74
82
|
function sanitizeInput(input, fieldName, maxLength = 10000) {
|
|
75
83
|
if (typeof input !== 'string') {
|
|
76
84
|
return { safe: false, cleaned: '', reason: `${fieldName} must be a string` };
|
|
@@ -99,6 +107,10 @@ function sanitizeInput(input, fieldName, maxLength = 10000) {
|
|
|
99
107
|
return { safe: true, cleaned: input.trim() };
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Validates a model string (e.g. "anthropic/claude-sonnet-4-20250514").
|
|
112
|
+
* Only allows alphanumeric, hyphens, dots, underscores, and forward slashes.
|
|
113
|
+
*/
|
|
102
114
|
function validateModel(model) {
|
|
103
115
|
if (!model) return { safe: true, cleaned: '' };
|
|
104
116
|
if (typeof model !== 'string') {
|
|
@@ -121,6 +133,13 @@ function validateModel(model) {
|
|
|
121
133
|
// Security: HMAC token authentication
|
|
122
134
|
// ---------------------------------------------------------------------------
|
|
123
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Generates an HMAC-SHA256 token from a timestamp and the bridge secret.
|
|
138
|
+
* The frontend must send this token in the X-Bridge-Token header.
|
|
139
|
+
*
|
|
140
|
+
* Token format: <timestamp>.<hmac>
|
|
141
|
+
* The timestamp is validated to be within 5 minutes of the current time.
|
|
142
|
+
*/
|
|
124
143
|
function generateBridgeToken() {
|
|
125
144
|
const timestamp = Date.now().toString();
|
|
126
145
|
const hmac = crypto.createHmac('sha256', BRIDGE_SECRET).update(timestamp).digest('hex');
|
|
@@ -129,17 +148,27 @@ function generateBridgeToken() {
|
|
|
129
148
|
|
|
130
149
|
function verifyBridgeToken(token) {
|
|
131
150
|
if (!token || typeof token !== 'string') return false;
|
|
151
|
+
|
|
132
152
|
const parts = token.split('.');
|
|
133
153
|
if (parts.length !== 2) return false;
|
|
154
|
+
|
|
134
155
|
const [timestamp, hmac] = parts;
|
|
135
156
|
const ts = parseInt(timestamp, 10);
|
|
136
157
|
if (isNaN(ts)) return false;
|
|
158
|
+
|
|
159
|
+
// Check timestamp is within 5 minutes
|
|
137
160
|
const now = Date.now();
|
|
138
161
|
if (Math.abs(now - ts) > 5 * 60 * 1000) return false;
|
|
162
|
+
|
|
163
|
+
// Verify HMAC
|
|
139
164
|
const expected = crypto.createHmac('sha256', BRIDGE_SECRET).update(timestamp).digest('hex');
|
|
140
165
|
return crypto.timingSafeEqual(Buffer.from(hmac, 'hex'), Buffer.from(expected, 'hex'));
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Express middleware that requires a valid bridge token on mutating endpoints.
|
|
170
|
+
* The token is sent in the X-Bridge-Token header.
|
|
171
|
+
*/
|
|
143
172
|
function requireBridgeAuth(req, res, next) {
|
|
144
173
|
const token = req.headers['x-bridge-token'];
|
|
145
174
|
if (!verifyBridgeToken(token)) {
|
|
@@ -151,21 +180,24 @@ function requireBridgeAuth(req, res, next) {
|
|
|
151
180
|
}
|
|
152
181
|
|
|
153
182
|
// ---------------------------------------------------------------------------
|
|
154
|
-
// Security: Rate limiting
|
|
183
|
+
// Security: Rate limiting (simple in-memory)
|
|
155
184
|
// ---------------------------------------------------------------------------
|
|
156
185
|
|
|
186
|
+
/** @type {Map<string, { count: number, resetAt: number }>} */
|
|
157
187
|
const rateLimitStore = new Map();
|
|
158
188
|
|
|
159
|
-
function rateLimit(key, config) {
|
|
189
|
+
function rateLimit(key, config = { windowMs: 60000, max: 10 }) {
|
|
160
190
|
return (req, res, next) => {
|
|
161
191
|
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
|
162
192
|
const storeKey = `${key}:${ip}`;
|
|
163
193
|
const now = Date.now();
|
|
164
194
|
let entry = rateLimitStore.get(storeKey);
|
|
195
|
+
|
|
165
196
|
if (!entry || now >= entry.resetAt) {
|
|
166
197
|
entry = { count: 0, resetAt: now + config.windowMs };
|
|
167
198
|
rateLimitStore.set(storeKey, entry);
|
|
168
199
|
}
|
|
200
|
+
|
|
169
201
|
entry.count++;
|
|
170
202
|
if (entry.count > config.max) {
|
|
171
203
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
|
@@ -178,6 +210,7 @@ function rateLimit(key, config) {
|
|
|
178
210
|
};
|
|
179
211
|
}
|
|
180
212
|
|
|
213
|
+
// Cleanup stale rate limit entries every 10 minutes
|
|
181
214
|
setInterval(() => {
|
|
182
215
|
const now = Date.now();
|
|
183
216
|
for (const [key, entry] of rateLimitStore) {
|
|
@@ -186,18 +219,24 @@ setInterval(() => {
|
|
|
186
219
|
}, 600000);
|
|
187
220
|
|
|
188
221
|
// ---------------------------------------------------------------------------
|
|
189
|
-
// In-memory execution store
|
|
222
|
+
// In-memory execution store (bounded with TTL)
|
|
190
223
|
// ---------------------------------------------------------------------------
|
|
191
224
|
|
|
225
|
+
/** @type {Map<string, { id: string, status: string, startedAt: Date, prompt: string, agentId?: string, model?: string, response?: string, error?: string, logs: string[] }>} */
|
|
192
226
|
const executions = new Map();
|
|
193
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Evicts old executions to stay within MAX_EXECUTIONS and EXECUTION_TTL_MS.
|
|
230
|
+
*/
|
|
194
231
|
function evictStaleExecutions() {
|
|
195
232
|
const now = Date.now();
|
|
233
|
+
// First: evict expired entries
|
|
196
234
|
for (const [id, exec] of executions) {
|
|
197
235
|
if (now - exec.startedAt.getTime() > EXECUTION_TTL_MS) {
|
|
198
236
|
executions.delete(id);
|
|
199
237
|
}
|
|
200
238
|
}
|
|
239
|
+
// Then: if still over limit, evict oldest
|
|
201
240
|
if (executions.size > MAX_EXECUTIONS) {
|
|
202
241
|
const sorted = Array.from(executions.entries()).sort(
|
|
203
242
|
(a, b) => a[1].startedAt.getTime() - b[1].startedAt.getTime(),
|
|
@@ -209,12 +248,16 @@ function evictStaleExecutions() {
|
|
|
209
248
|
}
|
|
210
249
|
}
|
|
211
250
|
|
|
251
|
+
// Run eviction every 5 minutes
|
|
212
252
|
setInterval(evictStaleExecutions, 300000);
|
|
213
253
|
|
|
214
254
|
// ---------------------------------------------------------------------------
|
|
215
255
|
// Helpers
|
|
216
256
|
// ---------------------------------------------------------------------------
|
|
217
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Makes an HTTP(S) request with JSON body.
|
|
260
|
+
*/
|
|
218
261
|
function httpRequest(method, url, body, token) {
|
|
219
262
|
return new Promise((resolve, reject) => {
|
|
220
263
|
const parsed = new URL(url);
|
|
@@ -248,6 +291,9 @@ function httpRequest(method, url, body, token) {
|
|
|
248
291
|
});
|
|
249
292
|
}
|
|
250
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Reports execution result back to the marketplace backend.
|
|
296
|
+
*/
|
|
251
297
|
async function reportToBackend(executionId, update, token) {
|
|
252
298
|
const url = `${MARKETPLACE_API_URL}/opencode/executions/${executionId}`;
|
|
253
299
|
try {
|
|
@@ -258,27 +304,37 @@ async function reportToBackend(executionId, update, token) {
|
|
|
258
304
|
}
|
|
259
305
|
}
|
|
260
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Spawns the OpenCode CLI and returns a promise that resolves with the output.
|
|
309
|
+
* SECURITY: shell is always false to prevent command injection.
|
|
310
|
+
*/
|
|
261
311
|
function runOpenCode(prompt, model) {
|
|
262
312
|
return new Promise((resolve, reject) => {
|
|
263
313
|
const args = ['run', prompt, '--format', 'json'];
|
|
264
314
|
if (model) {
|
|
265
315
|
args.push('--model', model);
|
|
266
316
|
}
|
|
317
|
+
|
|
267
318
|
console.log(`[bridge] Spawning: ${OPENCODE_PATH} ${args.join(' ')}`);
|
|
319
|
+
|
|
268
320
|
const proc = spawn(OPENCODE_PATH, args, {
|
|
269
321
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
270
|
-
timeout: 300000,
|
|
271
|
-
shell: false,
|
|
322
|
+
timeout: 300000, // 5 minutes max
|
|
323
|
+
shell: false, // SECURITY: never use shell to prevent injection
|
|
272
324
|
windowsHide: true,
|
|
273
325
|
});
|
|
326
|
+
|
|
274
327
|
let stdout = '';
|
|
275
328
|
let stderr = '';
|
|
329
|
+
|
|
276
330
|
proc.stdout.on('data', (data) => {
|
|
277
331
|
stdout += data.toString();
|
|
278
332
|
});
|
|
333
|
+
|
|
279
334
|
proc.stderr.on('data', (data) => {
|
|
280
335
|
stderr += data.toString();
|
|
281
336
|
});
|
|
337
|
+
|
|
282
338
|
proc.on('close', (code) => {
|
|
283
339
|
if (code === 0) {
|
|
284
340
|
resolve({ stdout, stderr, code });
|
|
@@ -286,6 +342,7 @@ function runOpenCode(prompt, model) {
|
|
|
286
342
|
reject(new Error(`OpenCode exited with code ${code}: ${stderr || stdout}`));
|
|
287
343
|
}
|
|
288
344
|
});
|
|
345
|
+
|
|
289
346
|
proc.on('error', (err) => {
|
|
290
347
|
reject(new Error(`Failed to spawn OpenCode: ${err.message}`));
|
|
291
348
|
});
|
|
@@ -297,6 +354,7 @@ function runOpenCode(prompt, model) {
|
|
|
297
354
|
// ---------------------------------------------------------------------------
|
|
298
355
|
const app = express();
|
|
299
356
|
|
|
357
|
+
// SECURITY: Restrict CORS to known origins only
|
|
300
358
|
const allowedOrigins = CORS_ORIGIN.split(',').map((o) => o.trim());
|
|
301
359
|
|
|
302
360
|
// Private Network Access (Chrome 94+) — handle preflight BEFORE cors middleware
|
|
@@ -310,6 +368,7 @@ app.use((req, res, next) => {
|
|
|
310
368
|
app.use(
|
|
311
369
|
cors({
|
|
312
370
|
origin: (origin, callback) => {
|
|
371
|
+
// Allow requests with no origin (curl, server-to-server)
|
|
313
372
|
if (!origin) return callback(null, true);
|
|
314
373
|
if (allowedOrigins.includes(origin)) return callback(null, true);
|
|
315
374
|
callback(new Error(`CORS: origin ${origin} not allowed`));
|
|
@@ -324,8 +383,10 @@ app.use((_req, res, next) => {
|
|
|
324
383
|
next();
|
|
325
384
|
});
|
|
326
385
|
|
|
386
|
+
// SECURITY: Limit body size to 1MB
|
|
327
387
|
app.use(express.json({ limit: '1mb' }));
|
|
328
388
|
|
|
389
|
+
// Security headers
|
|
329
390
|
app.use((_req, res, next) => {
|
|
330
391
|
res.set('X-Content-Type-Options', 'nosniff');
|
|
331
392
|
res.set('X-Frame-Options', 'DENY');
|
|
@@ -334,28 +395,47 @@ app.use((_req, res, next) => {
|
|
|
334
395
|
});
|
|
335
396
|
|
|
336
397
|
// ---------------------------------------------------------------------------
|
|
337
|
-
// Public endpoints
|
|
398
|
+
// Public endpoints (no auth required)
|
|
338
399
|
// ---------------------------------------------------------------------------
|
|
339
400
|
|
|
401
|
+
/**
|
|
402
|
+
* GET /health — Health check for the bridge.
|
|
403
|
+
* SECURITY: No sensitive information exposed.
|
|
404
|
+
*/
|
|
340
405
|
app.get('/health', (_req, res) => {
|
|
341
406
|
res.json({
|
|
342
407
|
status: 'ok',
|
|
343
|
-
bridge: '
|
|
408
|
+
bridge: 'opencode-bridge',
|
|
344
409
|
version: BRIDGE_VERSION,
|
|
345
410
|
uptime: Math.floor(process.uptime()),
|
|
346
411
|
activeExecutions: executions.size,
|
|
347
412
|
});
|
|
348
413
|
});
|
|
349
414
|
|
|
415
|
+
/**
|
|
416
|
+
* GET /bridge-token — Get a fresh HMAC token for authenticating requests.
|
|
417
|
+
* This endpoint is public so the frontend can get a token before making requests.
|
|
418
|
+
* The token is time-limited (5 minutes) and HMAC-signed with the bridge secret.
|
|
419
|
+
*/
|
|
350
420
|
app.get('/bridge-token', (_req, res) => {
|
|
351
421
|
const token = generateBridgeToken();
|
|
352
422
|
res.json({ token, expiresIn: 300 });
|
|
353
423
|
});
|
|
354
424
|
|
|
355
425
|
// ---------------------------------------------------------------------------
|
|
356
|
-
// Protected endpoints
|
|
426
|
+
// Protected endpoints (require X-Bridge-Token header)
|
|
357
427
|
// ---------------------------------------------------------------------------
|
|
358
428
|
|
|
429
|
+
/**
|
|
430
|
+
* POST /execute — Launch an OpenCode execution.
|
|
431
|
+
*
|
|
432
|
+
* Body:
|
|
433
|
+
* - executionId: string — ID of the execution record in the marketplace DB
|
|
434
|
+
* - prompt: string — The prompt to send to OpenCode
|
|
435
|
+
* - agentId?: string — Agent ID (for context)
|
|
436
|
+
* - model?: string — Model override (e.g. "anthropic/claude-sonnet-4-20250514")
|
|
437
|
+
* - token: string — JWT token for reporting back to the marketplace backend
|
|
438
|
+
*/
|
|
359
439
|
app.post(
|
|
360
440
|
'/execute',
|
|
361
441
|
requireBridgeAuth,
|
|
@@ -364,13 +444,18 @@ app.post(
|
|
|
364
444
|
const { executionId, prompt, agentId, model, token } = req.body;
|
|
365
445
|
|
|
366
446
|
if (!executionId || !prompt) {
|
|
367
|
-
return res.status(400).json({
|
|
447
|
+
return res.status(400).json({
|
|
448
|
+
error: 'Missing required fields: executionId, prompt',
|
|
449
|
+
});
|
|
368
450
|
}
|
|
451
|
+
|
|
369
452
|
if (!token) {
|
|
370
|
-
return res
|
|
371
|
-
|
|
372
|
-
|
|
453
|
+
return res.status(400).json({
|
|
454
|
+
error: 'Missing required field: token (JWT for backend callback)',
|
|
455
|
+
});
|
|
373
456
|
}
|
|
457
|
+
|
|
458
|
+
// SECURITY: Validate executionId format (UUID)
|
|
374
459
|
if (
|
|
375
460
|
typeof executionId !== 'string' ||
|
|
376
461
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(executionId)
|
|
@@ -378,17 +463,22 @@ app.post(
|
|
|
378
463
|
return res.status(400).json({ error: 'executionId must be a valid UUID' });
|
|
379
464
|
}
|
|
380
465
|
|
|
466
|
+
// SECURITY: Sanitize prompt
|
|
381
467
|
const promptCheck = sanitizeInput(prompt, 'prompt');
|
|
382
468
|
if (!promptCheck.safe) {
|
|
383
469
|
return res.status(400).json({ error: promptCheck.reason });
|
|
384
470
|
}
|
|
471
|
+
|
|
472
|
+
// SECURITY: Validate model
|
|
385
473
|
const modelCheck = validateModel(model);
|
|
386
474
|
if (!modelCheck.safe) {
|
|
387
475
|
return res.status(400).json({ error: modelCheck.reason });
|
|
388
476
|
}
|
|
389
477
|
|
|
478
|
+
// Evict stale executions before adding new one
|
|
390
479
|
evictStaleExecutions();
|
|
391
480
|
|
|
481
|
+
// Store execution locally
|
|
392
482
|
const execution = {
|
|
393
483
|
id: executionId,
|
|
394
484
|
status: 'running',
|
|
@@ -400,29 +490,43 @@ app.post(
|
|
|
400
490
|
};
|
|
401
491
|
executions.set(executionId, execution);
|
|
402
492
|
|
|
403
|
-
|
|
493
|
+
// Respond immediately — execution happens in background
|
|
494
|
+
res.json({
|
|
495
|
+
message: 'Execution started',
|
|
496
|
+
executionId,
|
|
497
|
+
status: 'running',
|
|
498
|
+
});
|
|
404
499
|
|
|
500
|
+
// Run OpenCode in background
|
|
405
501
|
const startTime = Date.now();
|
|
502
|
+
|
|
406
503
|
try {
|
|
407
504
|
execution.logs.push(`[${new Date().toISOString()}] Starting OpenCode CLI...`);
|
|
505
|
+
|
|
408
506
|
const result = await runOpenCode(promptCheck.cleaned, modelCheck.cleaned || undefined);
|
|
409
507
|
const executionTime = Date.now() - startTime;
|
|
508
|
+
|
|
410
509
|
execution.status = 'completed';
|
|
411
510
|
execution.logs.push(`[${new Date().toISOString()}] Completed in ${executionTime}ms`);
|
|
412
511
|
|
|
512
|
+
// Parse the JSON output from OpenCode
|
|
413
513
|
let response = result.stdout;
|
|
414
514
|
let tokensUsed = null;
|
|
415
515
|
let cost = null;
|
|
516
|
+
|
|
416
517
|
try {
|
|
417
518
|
const parsed = JSON.parse(result.stdout);
|
|
418
519
|
response = parsed.response || parsed.output || parsed.result || result.stdout;
|
|
419
520
|
tokensUsed = parsed.tokens || parsed.tokensUsed || null;
|
|
420
521
|
cost = parsed.cost || null;
|
|
421
522
|
} catch {
|
|
523
|
+
// stdout might not be valid JSON — use as-is
|
|
422
524
|
execution.logs.push(`[${new Date().toISOString()}] Output is not JSON, using raw text`);
|
|
423
525
|
}
|
|
424
526
|
|
|
425
527
|
execution.response = response;
|
|
528
|
+
|
|
529
|
+
// Report success to marketplace backend
|
|
426
530
|
await reportToBackend(
|
|
427
531
|
executionId,
|
|
428
532
|
{
|
|
@@ -441,6 +545,8 @@ app.post(
|
|
|
441
545
|
execution.status = 'failed';
|
|
442
546
|
execution.error = err.message;
|
|
443
547
|
execution.logs.push(`[${new Date().toISOString()}] Error: ${err.message}`);
|
|
548
|
+
|
|
549
|
+
// Report failure to marketplace backend
|
|
444
550
|
await reportToBackend(
|
|
445
551
|
executionId,
|
|
446
552
|
{
|
|
@@ -456,6 +562,9 @@ app.post(
|
|
|
456
562
|
},
|
|
457
563
|
);
|
|
458
564
|
|
|
565
|
+
/**
|
|
566
|
+
* GET /status/:executionId — Check execution status.
|
|
567
|
+
*/
|
|
459
568
|
app.get('/status/:executionId', requireBridgeAuth, (req, res) => {
|
|
460
569
|
const execution = executions.get(req.params.executionId);
|
|
461
570
|
if (!execution) {
|
|
@@ -472,6 +581,9 @@ app.get('/status/:executionId', requireBridgeAuth, (req, res) => {
|
|
|
472
581
|
});
|
|
473
582
|
});
|
|
474
583
|
|
|
584
|
+
/**
|
|
585
|
+
* GET /executions — List all tracked executions.
|
|
586
|
+
*/
|
|
475
587
|
app.get('/executions', requireBridgeAuth, (_req, res) => {
|
|
476
588
|
const list = Array.from(executions.values()).map((e) => ({
|
|
477
589
|
id: e.id,
|
|
@@ -483,6 +595,9 @@ app.get('/executions', requireBridgeAuth, (_req, res) => {
|
|
|
483
595
|
res.json(list);
|
|
484
596
|
});
|
|
485
597
|
|
|
598
|
+
/**
|
|
599
|
+
* DELETE /executions — Clear all tracked executions from memory.
|
|
600
|
+
*/
|
|
486
601
|
app.delete('/executions', requireBridgeAuth, (_req, res) => {
|
|
487
602
|
const count = executions.size;
|
|
488
603
|
executions.clear();
|
|
@@ -663,56 +778,67 @@ app.get('/agents/:name/check', (req, res) => {
|
|
|
663
778
|
});
|
|
664
779
|
|
|
665
780
|
/**
|
|
666
|
-
* POST /agents/install —
|
|
667
|
-
* Body: { name: string,
|
|
781
|
+
* POST /agents/install — Install agent locally from inline data or by fetching from marketplace API.
|
|
782
|
+
* Body: { name: string, agentId: string, agentData?: object, marketplaceUrl?: string }
|
|
783
|
+
*
|
|
784
|
+
* If agentData is provided, it is used directly (no API call needed).
|
|
785
|
+
* Otherwise, falls back to fetching the manifest from the marketplace API.
|
|
668
786
|
*/
|
|
669
787
|
app.post(
|
|
670
788
|
'/agents/install',
|
|
671
789
|
requireBridgeAuth,
|
|
672
|
-
rateLimit('install'),
|
|
790
|
+
rateLimit('install', RATE_LIMITS.install),
|
|
673
791
|
express.json(),
|
|
674
792
|
async (req, res) => {
|
|
675
|
-
const { agentId } = req.body;
|
|
793
|
+
const { agentId, agentData } = req.body;
|
|
676
794
|
let { name } = req.body;
|
|
677
795
|
|
|
678
|
-
if (!agentId) {
|
|
679
|
-
return res.status(400).json({ error: 'agentId is required' });
|
|
796
|
+
if (!agentId && !agentData) {
|
|
797
|
+
return res.status(400).json({ error: 'agentId or agentData is required' });
|
|
680
798
|
}
|
|
681
799
|
|
|
682
800
|
try {
|
|
683
|
-
|
|
684
|
-
const apiUrl =
|
|
685
|
-
req.body.marketplaceUrl ||
|
|
686
|
-
process.env.MARKETPLACE_API_URL ||
|
|
687
|
-
'https://atos-agentic-factory-qzwe.onrender.com/api';
|
|
688
|
-
const manifestUrl = `${apiUrl}/agents/${encodeURIComponent(agentId)}/opencode-manifest`;
|
|
801
|
+
let manifest;
|
|
689
802
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
803
|
+
if (agentData && typeof agentData === 'object') {
|
|
804
|
+
// Use inline agent data directly — no API fetch needed
|
|
805
|
+
console.log(`[Agent Hub] Using inline agent data for "${agentData.name || name}"`);
|
|
806
|
+
manifest = agentData;
|
|
807
|
+
} else {
|
|
808
|
+
// Fallback: fetch manifest from marketplace API
|
|
809
|
+
const apiUrl =
|
|
810
|
+
req.body.marketplaceUrl ||
|
|
811
|
+
process.env.MARKETPLACE_API_URL ||
|
|
812
|
+
'https://atos-agentic-factory-qzwe.onrender.com/api';
|
|
813
|
+
const manifestUrl = `${apiUrl}/agents/${encodeURIComponent(agentId)}/opencode-manifest`;
|
|
814
|
+
|
|
815
|
+
manifest = await new Promise((resolve, reject) => {
|
|
816
|
+
const urlObj = new URL(manifestUrl);
|
|
817
|
+
const client = urlObj.protocol === 'https:' ? https : http;
|
|
818
|
+
const request = client.get(manifestUrl, { timeout: 15000 }, (response) => {
|
|
819
|
+
if (response.statusCode !== 200) {
|
|
820
|
+
reject(new Error(`API returned ${response.statusCode}`));
|
|
821
|
+
return;
|
|
707
822
|
}
|
|
823
|
+
let data = '';
|
|
824
|
+
response.on('data', (chunk) => {
|
|
825
|
+
data += chunk;
|
|
826
|
+
});
|
|
827
|
+
response.on('end', () => {
|
|
828
|
+
try {
|
|
829
|
+
resolve(JSON.parse(data));
|
|
830
|
+
} catch (e) {
|
|
831
|
+
reject(new Error('Invalid JSON response from API'));
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
request.on('error', reject);
|
|
836
|
+
request.on('timeout', () => {
|
|
837
|
+
request.destroy();
|
|
838
|
+
reject(new Error('Request timeout'));
|
|
708
839
|
});
|
|
709
840
|
});
|
|
710
|
-
|
|
711
|
-
request.on('timeout', () => {
|
|
712
|
-
request.destroy();
|
|
713
|
-
reject(new Error('Request timeout'));
|
|
714
|
-
});
|
|
715
|
-
});
|
|
841
|
+
}
|
|
716
842
|
|
|
717
843
|
// Use name from manifest if not provided
|
|
718
844
|
const agentName = sanitizeAgentName(name || manifest.name);
|
|
@@ -720,6 +846,11 @@ app.post(
|
|
|
720
846
|
return res.status(400).json({ error: 'Invalid agent name' });
|
|
721
847
|
}
|
|
722
848
|
|
|
849
|
+
// Ensure sourceUrl is set for sync tracking
|
|
850
|
+
if (!manifest.sourceUrl && agentId) {
|
|
851
|
+
manifest.sourceUrl = `/agents/${agentId}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
723
854
|
// Generate .md file content
|
|
724
855
|
const mdContent = generateAgentMd(manifest);
|
|
725
856
|
|
|
@@ -766,6 +897,10 @@ app.delete('/agents/:name', requireBridgeAuth, (req, res) => {
|
|
|
766
897
|
|
|
767
898
|
/**
|
|
768
899
|
* PUT /agents/:name/update — Re-download and update an existing agent.
|
|
900
|
+
* Body: { agentId: string, agentData?: object, marketplaceUrl?: string }
|
|
901
|
+
*
|
|
902
|
+
* If agentData is provided, it is used directly (no API call needed).
|
|
903
|
+
* Otherwise, falls back to fetching the manifest from the marketplace API.
|
|
769
904
|
*/
|
|
770
905
|
app.put('/agents/:name/update', requireBridgeAuth, async (req, res) => {
|
|
771
906
|
const name = sanitizeAgentName(req.params.name);
|
|
@@ -778,45 +913,59 @@ app.put('/agents/:name/update', requireBridgeAuth, async (req, res) => {
|
|
|
778
913
|
return res.status(404).json({ error: `Agent "${name}" not found locally` });
|
|
779
914
|
}
|
|
780
915
|
|
|
781
|
-
// Get agentId
|
|
782
|
-
const { agentId } = req.body || {};
|
|
783
|
-
if (!agentId) {
|
|
784
|
-
return res.status(400).json({ error: 'agentId is required for update' });
|
|
916
|
+
// Get agentId and optional agentData from request body
|
|
917
|
+
const { agentId, agentData } = req.body || {};
|
|
918
|
+
if (!agentId && !agentData) {
|
|
919
|
+
return res.status(400).json({ error: 'agentId or agentData is required for update' });
|
|
785
920
|
}
|
|
786
921
|
|
|
787
922
|
try {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
})
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
} catch (e) {
|
|
810
|
-
reject(new Error('Invalid JSON response from API'));
|
|
923
|
+
let manifest;
|
|
924
|
+
|
|
925
|
+
if (agentData && typeof agentData === 'object') {
|
|
926
|
+
// Use inline agent data directly
|
|
927
|
+
console.log(`[Agent Hub] Using inline agent data for update "${name}"`);
|
|
928
|
+
manifest = agentData;
|
|
929
|
+
} else {
|
|
930
|
+
// Fallback: fetch from API
|
|
931
|
+
const apiUrl =
|
|
932
|
+
req.body.marketplaceUrl ||
|
|
933
|
+
process.env.MARKETPLACE_API_URL ||
|
|
934
|
+
'https://atos-agentic-factory-qzwe.onrender.com/api';
|
|
935
|
+
const manifestUrl = `${apiUrl}/agents/${encodeURIComponent(agentId)}/opencode-manifest`;
|
|
936
|
+
|
|
937
|
+
manifest = await new Promise((resolve, reject) => {
|
|
938
|
+
const urlObj = new URL(manifestUrl);
|
|
939
|
+
const client = urlObj.protocol === 'https:' ? https : http;
|
|
940
|
+
const request = client.get(manifestUrl, { timeout: 15000 }, (response) => {
|
|
941
|
+
if (response.statusCode !== 200) {
|
|
942
|
+
reject(new Error(`API returned ${response.statusCode}`));
|
|
943
|
+
return;
|
|
811
944
|
}
|
|
945
|
+
let data = '';
|
|
946
|
+
response.on('data', (chunk) => {
|
|
947
|
+
data += chunk;
|
|
948
|
+
});
|
|
949
|
+
response.on('end', () => {
|
|
950
|
+
try {
|
|
951
|
+
resolve(JSON.parse(data));
|
|
952
|
+
} catch (e) {
|
|
953
|
+
reject(new Error('Invalid JSON response from API'));
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
request.on('error', reject);
|
|
958
|
+
request.on('timeout', () => {
|
|
959
|
+
request.destroy();
|
|
960
|
+
reject(new Error('Request timeout'));
|
|
812
961
|
});
|
|
813
962
|
});
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
}
|
|
819
|
-
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Ensure sourceUrl is set for sync tracking
|
|
966
|
+
if (!manifest.sourceUrl && agentId) {
|
|
967
|
+
manifest.sourceUrl = `/agents/${agentId}`;
|
|
968
|
+
}
|
|
820
969
|
|
|
821
970
|
const mdContent = generateAgentMd(manifest);
|
|
822
971
|
fs.writeFileSync(filePath, mdContent, 'utf8');
|
|
@@ -838,22 +987,30 @@ app.put('/agents/:name/update', requireBridgeAuth, async (req, res) => {
|
|
|
838
987
|
// Smart Detection
|
|
839
988
|
// ---------------------------------------------------------------------------
|
|
840
989
|
|
|
990
|
+
/**
|
|
991
|
+
* Checks if a command exists on the system PATH.
|
|
992
|
+
* SECURITY: Only checks the configured OPENCODE_PATH, not arbitrary commands.
|
|
993
|
+
*/
|
|
841
994
|
function checkCommandExists(command) {
|
|
842
995
|
return new Promise((resolve) => {
|
|
843
996
|
const isWin = process.platform === 'win32';
|
|
844
997
|
const checkCmd = isWin ? 'where' : 'which';
|
|
998
|
+
|
|
845
999
|
const proc = spawn(checkCmd, [command], {
|
|
846
1000
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
847
1001
|
timeout: 10000,
|
|
848
1002
|
shell: false,
|
|
849
1003
|
});
|
|
1004
|
+
|
|
850
1005
|
let stdout = '';
|
|
851
1006
|
proc.stdout.on('data', (data) => {
|
|
852
1007
|
stdout += data.toString();
|
|
853
1008
|
});
|
|
1009
|
+
|
|
854
1010
|
proc.on('close', (code) => {
|
|
855
1011
|
if (code === 0 && stdout.trim()) {
|
|
856
1012
|
const cmdPath = stdout.trim().split('\n')[0].trim();
|
|
1013
|
+
// Now get version
|
|
857
1014
|
const versionProc = spawn(command, ['--version'], {
|
|
858
1015
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
859
1016
|
timeout: 10000,
|
|
@@ -867,7 +1024,11 @@ function checkCommandExists(command) {
|
|
|
867
1024
|
versionOut += d.toString();
|
|
868
1025
|
});
|
|
869
1026
|
versionProc.on('close', () => {
|
|
870
|
-
resolve({
|
|
1027
|
+
resolve({
|
|
1028
|
+
installed: true,
|
|
1029
|
+
version: versionOut.trim() || 'unknown',
|
|
1030
|
+
path: cmdPath,
|
|
1031
|
+
});
|
|
871
1032
|
});
|
|
872
1033
|
versionProc.on('error', () => {
|
|
873
1034
|
resolve({ installed: true, version: 'unknown', path: cmdPath });
|
|
@@ -876,12 +1037,16 @@ function checkCommandExists(command) {
|
|
|
876
1037
|
resolve({ installed: false, version: null, path: null });
|
|
877
1038
|
}
|
|
878
1039
|
});
|
|
1040
|
+
|
|
879
1041
|
proc.on('error', () => {
|
|
880
1042
|
resolve({ installed: false, version: null, path: null });
|
|
881
1043
|
});
|
|
882
1044
|
});
|
|
883
1045
|
}
|
|
884
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* GET /check-opencode — Check if OpenCode CLI is installed on the system.
|
|
1049
|
+
*/
|
|
885
1050
|
app.get('/check-opencode', async (_req, res) => {
|
|
886
1051
|
try {
|
|
887
1052
|
const result = await checkCommandExists(OPENCODE_PATH);
|
|
@@ -895,41 +1060,55 @@ app.get('/check-opencode', async (_req, res) => {
|
|
|
895
1060
|
}
|
|
896
1061
|
});
|
|
897
1062
|
|
|
1063
|
+
/**
|
|
1064
|
+
* POST /install-opencode — Install OpenCode CLI via npm install -g opencode-ai.
|
|
1065
|
+
* SECURITY: Rate-limited, auth required, fixed command (no user input in spawn args).
|
|
1066
|
+
*/
|
|
898
1067
|
app.post(
|
|
899
1068
|
'/install-opencode',
|
|
900
1069
|
requireBridgeAuth,
|
|
901
1070
|
rateLimit('install', RATE_LIMITS.install),
|
|
902
1071
|
async (_req, res) => {
|
|
903
1072
|
console.log('[bridge] Installing OpenCode CLI via npm install -g opencode-ai...');
|
|
1073
|
+
|
|
904
1074
|
try {
|
|
905
1075
|
await new Promise((resolve, reject) => {
|
|
906
1076
|
const proc = spawn('npm', ['install', '-g', 'opencode-ai'], {
|
|
907
1077
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
908
|
-
timeout: 120000,
|
|
1078
|
+
timeout: 120000, // 2 minutes max
|
|
909
1079
|
shell: true,
|
|
910
1080
|
});
|
|
1081
|
+
|
|
911
1082
|
let stdout = '';
|
|
912
1083
|
let stderr = '';
|
|
1084
|
+
|
|
913
1085
|
proc.stdout.on('data', (data) => {
|
|
914
1086
|
stdout += data.toString();
|
|
915
1087
|
console.log(`[bridge] npm install stdout: ${data.toString().trim()}`);
|
|
916
1088
|
});
|
|
1089
|
+
|
|
917
1090
|
proc.stderr.on('data', (data) => {
|
|
918
1091
|
stderr += data.toString();
|
|
919
1092
|
});
|
|
1093
|
+
|
|
920
1094
|
proc.on('close', (code) => {
|
|
921
|
-
if (code === 0)
|
|
922
|
-
|
|
1095
|
+
if (code === 0) {
|
|
1096
|
+
resolve({ success: true, stdout, stderr });
|
|
1097
|
+
} else {
|
|
923
1098
|
reject(
|
|
924
1099
|
new Error(`npm install -g opencode-ai failed with code ${code}: ${stderr || stdout}`),
|
|
925
1100
|
);
|
|
1101
|
+
}
|
|
926
1102
|
});
|
|
1103
|
+
|
|
927
1104
|
proc.on('error', (err) => {
|
|
928
1105
|
reject(new Error(`Failed to spawn npm: ${err.message}`));
|
|
929
1106
|
});
|
|
930
1107
|
});
|
|
931
1108
|
|
|
1109
|
+
// Verify installation
|
|
932
1110
|
const check = await checkCommandExists(OPENCODE_PATH);
|
|
1111
|
+
|
|
933
1112
|
res.json({
|
|
934
1113
|
success: true,
|
|
935
1114
|
message: 'OpenCode CLI installed successfully',
|
|
@@ -952,12 +1131,17 @@ app.post(
|
|
|
952
1131
|
// Protocol Registration
|
|
953
1132
|
// ---------------------------------------------------------------------------
|
|
954
1133
|
|
|
1134
|
+
/**
|
|
1135
|
+
* POST /register-protocol — Register the opencode:// protocol handler on Windows.
|
|
1136
|
+
* SECURITY: Rate-limited, auth required, only executes the known script.
|
|
1137
|
+
*/
|
|
955
1138
|
app.post(
|
|
956
1139
|
'/register-protocol',
|
|
957
1140
|
requireBridgeAuth,
|
|
958
1141
|
rateLimit('register', RATE_LIMITS.register),
|
|
959
1142
|
async (_req, res) => {
|
|
960
1143
|
const isWin = process.platform === 'win32';
|
|
1144
|
+
|
|
961
1145
|
if (!isWin) {
|
|
962
1146
|
return res.status(400).json({
|
|
963
1147
|
success: false,
|
|
@@ -968,6 +1152,7 @@ app.post(
|
|
|
968
1152
|
const scriptPath = path.join(__dirname, 'install-protocol.ps1');
|
|
969
1153
|
const handlerPath = path.join(__dirname, 'opencode-handler.cmd');
|
|
970
1154
|
|
|
1155
|
+
// Check that required files exist
|
|
971
1156
|
if (!fs.existsSync(scriptPath)) {
|
|
972
1157
|
return res.status(404).json({
|
|
973
1158
|
success: false,
|
|
@@ -994,20 +1179,27 @@ app.post(
|
|
|
994
1179
|
shell: false,
|
|
995
1180
|
},
|
|
996
1181
|
);
|
|
1182
|
+
|
|
997
1183
|
let stdout = '';
|
|
998
1184
|
let stderr = '';
|
|
1185
|
+
|
|
999
1186
|
proc.stdout.on('data', (data) => {
|
|
1000
1187
|
stdout += data.toString();
|
|
1001
1188
|
console.log(`[bridge] ps1 stdout: ${data.toString().trim()}`);
|
|
1002
1189
|
});
|
|
1190
|
+
|
|
1003
1191
|
proc.stderr.on('data', (data) => {
|
|
1004
1192
|
stderr += data.toString();
|
|
1005
1193
|
});
|
|
1194
|
+
|
|
1006
1195
|
proc.on('close', (code) => {
|
|
1007
|
-
if (code === 0)
|
|
1008
|
-
|
|
1196
|
+
if (code === 0) {
|
|
1197
|
+
resolve({ stdout, stderr });
|
|
1198
|
+
} else {
|
|
1009
1199
|
reject(new Error(`install-protocol.ps1 exited with code ${code}: ${stderr || stdout}`));
|
|
1200
|
+
}
|
|
1010
1201
|
});
|
|
1202
|
+
|
|
1011
1203
|
proc.on('error', (err) => {
|
|
1012
1204
|
reject(new Error(`Failed to spawn PowerShell: ${err.message}`));
|
|
1013
1205
|
});
|
|
@@ -1158,14 +1350,16 @@ app.post(
|
|
|
1158
1350
|
);
|
|
1159
1351
|
|
|
1160
1352
|
// ---------------------------------------------------------------------------
|
|
1161
|
-
// Start server
|
|
1353
|
+
// Start server — listen only on loopback (127.0.0.1)
|
|
1162
1354
|
// ---------------------------------------------------------------------------
|
|
1163
1355
|
app.listen(PORT, '127.0.0.1', () => {
|
|
1164
1356
|
const isAutoSecret = !process.env.BRIDGE_SECRET;
|
|
1165
1357
|
console.log('');
|
|
1166
1358
|
console.log(' +----------------------------------------------+');
|
|
1167
1359
|
console.log(' | |');
|
|
1168
|
-
console.log(
|
|
1360
|
+
console.log(
|
|
1361
|
+
` | OpenCode Bridge v${BRIDGE_VERSION} (secured)${' '.repeat(Math.max(0, 14 - BRIDGE_VERSION.length))}|`,
|
|
1362
|
+
);
|
|
1169
1363
|
console.log(` | Listening on http://127.0.0.1:${PORT} |`);
|
|
1170
1364
|
console.log(' | |');
|
|
1171
1365
|
const origins = CORS_ORIGIN.split(',').map((o) => o.trim());
|