@yemi33/minions 0.1.2086 → 0.1.2087

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/dashboard.js +164 -0
  2. package/package.json +1 -1
package/dashboard.js CHANGED
@@ -2357,6 +2357,163 @@ function getStatusJson() {
2357
2357
  return _statusCacheJson;
2358
2358
  }
2359
2359
 
2360
+ // ── /state/<path> — raw state-file passthrough ────────────────────────────
2361
+ // Static-file handler that serves files from under MINIONS_DIR directly,
2362
+ // bypassing the /api/status assembled-snapshot cache. Per-page polls hit
2363
+ // this instead of /api/status so a stale outer cache cannot hold a fresh
2364
+ // disk write hostage. ETag = mtimeMs + size → 304 when unchanged.
2365
+ //
2366
+ // Safety:
2367
+ // 1. shared.sanitizePath rejects null bytes / .. / absolute paths and
2368
+ // asserts the resolved target stays under MINIONS_DIR.
2369
+ // 2. Top-level dir must be in STATE_READ_ALLOWED_DIRS — no exposing
2370
+ // config.json, .install-id, source code, .gitignored secrets.
2371
+ // 3. STATE_READ_DENIED_PATTERNS deny-lists sensitive files INSIDE
2372
+ // allowed dirs (SQLite db, .backup sidecars, live-output.log,
2373
+ // session.json — any of which leak credentials or runtime state).
2374
+ // 4. Symlinks are rejected outright (no follow → realpath round-trip).
2375
+ // 5. Directory paths return a JSON listing [{name,size,mtimeMs,isDir}],
2376
+ // one level deep — needed for inbox/ and notes/inbox/ enumeration.
2377
+ const STATE_READ_ALLOWED_DIRS = new Set([
2378
+ 'projects', 'engine', 'prd', 'notes', 'plans',
2379
+ 'pipelines', 'knowledge', 'agents', 'watches.d',
2380
+ ]);
2381
+ const STATE_READ_DENIED_PATTERNS = [
2382
+ /(^|[\\/])state\.db($|-wal$|-shm$)/i,
2383
+ /\.backup$/i,
2384
+ /(^|[\\/])live-output\.log$/i,
2385
+ /(^|[\\/])session\.json$/i,
2386
+ /(^|[\\/])\.env($|\.)/i,
2387
+ ];
2388
+ function _isStateReadPathDenied(rel) {
2389
+ return STATE_READ_DENIED_PATTERNS.some(re => re.test(rel));
2390
+ }
2391
+ function _stateReadContentType(ext) {
2392
+ switch (ext.toLowerCase()) {
2393
+ case '.json': return 'application/json; charset=utf-8';
2394
+ case '.md': return 'text/markdown; charset=utf-8';
2395
+ case '.txt': case '.log': return 'text/plain; charset=utf-8';
2396
+ default: return 'application/octet-stream';
2397
+ }
2398
+ }
2399
+ function handleStateRead(req, res) {
2400
+ const urlPath = req.url.split('?')[0];
2401
+ const rel = decodeURIComponent(urlPath.slice('/state/'.length));
2402
+ if (!rel) {
2403
+ res.statusCode = 400;
2404
+ res.setHeader('Content-Type', 'application/json');
2405
+ res.end(JSON.stringify({ error: 'path required' }));
2406
+ return;
2407
+ }
2408
+ // Top-level allowlist — first path segment must match.
2409
+ const top = rel.split(/[\\/]/)[0];
2410
+ if (!STATE_READ_ALLOWED_DIRS.has(top)) {
2411
+ res.statusCode = 403;
2412
+ res.setHeader('Content-Type', 'application/json');
2413
+ res.end(JSON.stringify({ error: 'forbidden directory' }));
2414
+ return;
2415
+ }
2416
+ if (_isStateReadPathDenied(rel)) {
2417
+ res.statusCode = 403;
2418
+ res.setHeader('Content-Type', 'application/json');
2419
+ res.end(JSON.stringify({ error: 'forbidden file' }));
2420
+ return;
2421
+ }
2422
+ let resolved;
2423
+ try {
2424
+ resolved = shared.sanitizePath(rel, MINIONS_DIR);
2425
+ } catch (e) {
2426
+ res.statusCode = 403;
2427
+ res.setHeader('Content-Type', 'application/json');
2428
+ res.end(JSON.stringify({ error: e.message }));
2429
+ return;
2430
+ }
2431
+ let lstat;
2432
+ try { lstat = fs.lstatSync(resolved); }
2433
+ catch {
2434
+ res.statusCode = 404;
2435
+ res.setHeader('Content-Type', 'application/json');
2436
+ res.end(JSON.stringify({ error: 'not found' }));
2437
+ return;
2438
+ }
2439
+ // Reject symlinks outright — even if they point inside MINIONS_DIR, they
2440
+ // can be swapped after the allowlist check (TOCTOU) to escape. Engine
2441
+ // never writes symlinks into state dirs in normal operation.
2442
+ if (lstat.isSymbolicLink()) {
2443
+ res.statusCode = 403;
2444
+ res.setHeader('Content-Type', 'application/json');
2445
+ res.end(JSON.stringify({ error: 'symlinks not served' }));
2446
+ return;
2447
+ }
2448
+ if (lstat.isDirectory()) {
2449
+ // One-level directory listing for enumeration (inbox, archive, etc.).
2450
+ // Per-entry stat lives inside the inner try-catch so a single bad entry
2451
+ // doesn't fail the whole listing — bad entry just gets dropped.
2452
+ const etag = '"dir-' + Math.floor(lstat.mtimeMs) + '"';
2453
+ res.setHeader('ETag', etag);
2454
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2455
+ if (req.headers['if-none-match'] === etag) {
2456
+ res.statusCode = 304;
2457
+ res.end();
2458
+ return;
2459
+ }
2460
+ let entries;
2461
+ try {
2462
+ entries = fs.readdirSync(resolved).map(name => {
2463
+ try {
2464
+ const s = fs.lstatSync(path.join(resolved, name));
2465
+ if (s.isSymbolicLink()) return null;
2466
+ return {
2467
+ name,
2468
+ size: s.size,
2469
+ mtimeMs: Math.floor(s.mtimeMs),
2470
+ isDir: s.isDirectory(),
2471
+ };
2472
+ } catch { return null; }
2473
+ }).filter(Boolean);
2474
+ } catch (e) {
2475
+ res.statusCode = 500;
2476
+ res.setHeader('Content-Type', 'application/json');
2477
+ res.end(JSON.stringify({ error: e.message }));
2478
+ return;
2479
+ }
2480
+ res.statusCode = 200;
2481
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
2482
+ res.end(JSON.stringify({ path: rel.replace(/\\/g, '/'), entries }));
2483
+ return;
2484
+ }
2485
+ // File path: mtime+size ETag, stream the bytes.
2486
+ const etag = '"' + Math.floor(lstat.mtimeMs) + '-' + lstat.size + '"';
2487
+ res.setHeader('ETag', etag);
2488
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2489
+ if (req.headers['if-none-match'] === etag) {
2490
+ res.statusCode = 304;
2491
+ res.end();
2492
+ return;
2493
+ }
2494
+ res.statusCode = 200;
2495
+ res.setHeader('Content-Type', _stateReadContentType(path.extname(resolved)));
2496
+ try {
2497
+ const stream = fs.createReadStream(resolved);
2498
+ stream.on('error', (err) => {
2499
+ if (!res.headersSent) {
2500
+ res.statusCode = 500;
2501
+ res.setHeader('Content-Type', 'application/json');
2502
+ res.end(JSON.stringify({ error: err.message }));
2503
+ } else if (!res.writableEnded) {
2504
+ res.end();
2505
+ }
2506
+ });
2507
+ stream.pipe(res);
2508
+ } catch (e) {
2509
+ if (!res.headersSent) {
2510
+ res.statusCode = 500;
2511
+ res.setHeader('Content-Type', 'application/json');
2512
+ res.end(JSON.stringify({ error: e.message }));
2513
+ }
2514
+ }
2515
+ }
2516
+
2360
2517
  // Top-level /api/status request handler (W-mpehsyhv0017085a). Extracted from
2361
2518
  // the inline route handler so unit tests can call it with mock req/res and so
2362
2519
  // production routing has a single source of truth. The inline route delegates
@@ -10633,6 +10790,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10633
10790
 
10634
10791
  // Status & health
10635
10792
  { method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
10793
+ // Raw state-file passthrough (no /api/ prefix — this serves files, not API
10794
+ // responses). Allowlisted top-level dirs only; mtime+size ETag → 304.
10795
+ { method: 'GET', path: /^\/state\/.+/, desc: 'Serve a raw state file (or directory listing) from MINIONS_DIR with mtime/size ETag', handler: handleStateRead },
10636
10796
  { method: 'GET', path: '/api/browser-presence', desc: 'Whether a dashboard browser tab was recently active', handler: (req, res) => {
10637
10797
  return jsonReply(res, 200, _getDashboardBrowserPresence(), req);
10638
10798
  }},
@@ -11579,6 +11739,10 @@ module.exports = {
11579
11739
  refreshStatusAsync,
11580
11740
  handleStatus: _handleStatusRequest,
11581
11741
  invalidateStatusCache,
11742
+ // Raw state-file passthrough — exported for direct unit testing.
11743
+ handleStateRead,
11744
+ STATE_READ_ALLOWED_DIRS,
11745
+ STATE_READ_DENIED_PATTERNS,
11582
11746
  _getStatusCacheVersion,
11583
11747
  _setStatusRefreshHook,
11584
11748
  _resetStatusCacheForTesting,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2086",
3
+ "version": "0.1.2087",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"