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.
Files changed (2) hide show
  1. package/bridge.js +297 -103
  2. package/package.json +1 -1
package/bridge.js CHANGED
@@ -1,31 +1,32 @@
1
1
  /**
2
- * Agentic Factory Bridge — Local bridge between Atos Agentic Factory and OpenCode CLI.
2
+ * OpenCode Bridge — Local bridge between Atos Agentic Factory and OpenCode CLI.
3
3
  *
4
- * This lightweight Express server runs on the user's machine (port 3001)
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
- * npm install -g agentic-factory-bridge
21
- * agentic-factory-bridge start
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: 'agentic-factory-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({ error: 'Missing required fields: executionId, prompt' });
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
- .status(400)
372
- .json({ error: 'Missing required field: token (JWT for backend callback)' });
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
- res.json({ message: 'Execution started', executionId, status: 'running' });
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 — Download manifest from marketplace API and install agent locally.
667
- * Body: { name: string, marketplaceUrl?: string, agentId: 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
- // Fetch manifest from marketplace API
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
- const manifest = await new Promise((resolve, reject) => {
691
- const urlObj = new URL(manifestUrl);
692
- const client = urlObj.protocol === 'https:' ? https : http;
693
- const request = client.get(manifestUrl, { timeout: 15000 }, (response) => {
694
- if (response.statusCode !== 200) {
695
- reject(new Error(`API returned ${response.statusCode}`));
696
- return;
697
- }
698
- let data = '';
699
- response.on('data', (chunk) => {
700
- data += chunk;
701
- });
702
- response.on('end', () => {
703
- try {
704
- resolve(JSON.parse(data));
705
- } catch (e) {
706
- reject(new Error('Invalid JSON response from API'));
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
- request.on('error', reject);
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 from existing metadata or request body
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
- const apiUrl =
789
- req.body.marketplaceUrl ||
790
- process.env.MARKETPLACE_API_URL ||
791
- 'https://atos-agentic-factory-qzwe.onrender.com/api';
792
- const manifestUrl = `${apiUrl}/agents/${encodeURIComponent(agentId)}/opencode-manifest`;
793
-
794
- const manifest = await new Promise((resolve, reject) => {
795
- const urlObj = new URL(manifestUrl);
796
- const client = urlObj.protocol === 'https:' ? https : http;
797
- const request = client.get(manifestUrl, { timeout: 15000 }, (response) => {
798
- if (response.statusCode !== 200) {
799
- reject(new Error(`API returned ${response.statusCode}`));
800
- return;
801
- }
802
- let data = '';
803
- response.on('data', (chunk) => {
804
- data += chunk;
805
- });
806
- response.on('end', () => {
807
- try {
808
- resolve(JSON.parse(data));
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
- request.on('error', reject);
815
- request.on('timeout', () => {
816
- request.destroy();
817
- reject(new Error('Request timeout'));
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({ installed: true, version: versionOut.trim() || 'unknown', path: cmdPath });
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) resolve({ success: true, stdout, stderr });
922
- else
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) resolve({ stdout, stderr });
1008
- else
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(` | Agentic Factory Bridge v${BRIDGE_VERSION.padEnd(19)}|`);
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());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-factory-bridge",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Local bridge for Atos Agentic Factory — connects the marketplace to OpenCode CLI on your machine",
5
5
  "main": "bridge.js",
6
6
  "bin": {