@yemi33/minions 0.1.2085 → 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.
- package/dashboard.js +164 -0
- 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.
|
|
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"
|