@cluesmith/codev 2.0.0-rc.64 → 2.0.0-rc.69
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/dist/assets/{index-DZuzzh0T.js → index-CG7nUttd.js} +22 -22
- package/dashboard/dist/assets/index-CG7nUttd.js.map +1 -0
- package/dashboard/dist/index.html +1 -1
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +4 -1
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/architect.d.ts.map +1 -1
- package/dist/agent-farm/commands/architect.js +4 -6
- package/dist/agent-farm/commands/architect.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +54 -6
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/tower-cloud.d.ts +1 -0
- package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -1
- package/dist/agent-farm/commands/tower-cloud.js +9 -8
- package/dist/agent-farm/commands/tower-cloud.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +18 -0
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/lib/cloud-config.d.ts +13 -0
- package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -1
- package/dist/agent-farm/lib/cloud-config.js +38 -1
- package/dist/agent-farm/lib/cloud-config.js.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.js +15 -6
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +166 -138
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/utils/session.d.ts +22 -0
- package/dist/agent-farm/utils/session.d.ts.map +1 -1
- package/dist/agent-farm/utils/session.js +45 -0
- package/dist/agent-farm/utils/session.js.map +1 -1
- package/dist/commands/consult/index.d.ts +10 -2
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +133 -37
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +96 -52
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/porch/index.d.ts +4 -0
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +40 -11
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/state.d.ts +18 -0
- package/dist/commands/porch/state.d.ts.map +1 -1
- package/dist/commands/porch/state.js +41 -2
- package/dist/commands/porch/state.js.map +1 -1
- package/package.json +2 -1
- package/skeleton/protocols/bugfix/builder-prompt.md +3 -3
- package/skeleton/protocols/bugfix/prompts/pr.md +8 -4
- package/skeleton/protocols/bugfix/protocol.json +2 -32
- package/skeleton/protocols/experiment/builder-prompt.md +1 -1
- package/skeleton/protocols/maintain/builder-prompt.md +1 -1
- package/skeleton/protocols/spir/builder-prompt.md +1 -1
- package/skeleton/protocols/tick/builder-prompt.md +1 -1
- package/skeleton/protocols/tick/protocol.json +1 -1
- package/skeleton/roles/builder.md +9 -8
- package/templates/tower.html +275 -41
- package/dashboard/dist/assets/index-DZuzzh0T.js.map +0 -1
|
@@ -21,6 +21,7 @@ import { TerminalManager } from '../../terminal/pty-manager.js';
|
|
|
21
21
|
import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
|
|
22
22
|
import { TunnelClient } from '../lib/tunnel-client.js';
|
|
23
23
|
import { readCloudConfig, getCloudConfigPath, maskApiKey } from '../lib/cloud-config.js';
|
|
24
|
+
import { parseTmuxSessionName } from '../utils/session.js';
|
|
24
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
26
|
const __dirname = path.dirname(__filename);
|
|
26
27
|
// Default port for tower dashboard
|
|
@@ -456,14 +457,16 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
|
|
|
456
457
|
killTmuxSession(sessionName);
|
|
457
458
|
}
|
|
458
459
|
try {
|
|
459
|
-
// Use spawnSync with array args to avoid shell injection via project paths
|
|
460
|
+
// Use spawnSync with array args to avoid shell injection via project paths.
|
|
461
|
+
// Wrap command with `env -u CLAUDECODE` to prevent Claude from detecting
|
|
462
|
+
// a nested session when the Tower was started from within Claude Code.
|
|
460
463
|
const tmuxArgs = [
|
|
461
464
|
'new-session', '-d',
|
|
462
465
|
'-s', sessionName,
|
|
463
466
|
'-c', cwd,
|
|
464
467
|
'-x', String(cols),
|
|
465
468
|
'-y', String(rows),
|
|
466
|
-
command, ...args,
|
|
469
|
+
'env', '-u', 'CLAUDECODE', command, ...args,
|
|
467
470
|
];
|
|
468
471
|
const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
|
|
469
472
|
if (result.status !== 0) {
|
|
@@ -476,7 +479,17 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
|
|
|
476
479
|
// events during layout settling. Default tmux behavior (size to smallest
|
|
477
480
|
// client) is more stable since we only have one client per session.
|
|
478
481
|
spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
|
|
479
|
-
|
|
482
|
+
// Mouse OFF — xterm.js in the browser handles selection and Cmd+C/Cmd+V
|
|
483
|
+
// clipboard. tmux mouse mode conflicts (auto-copy on selection, intercepts
|
|
484
|
+
// click/drag). See codev/resources/terminal-tmux.md.
|
|
485
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'off'], { stdio: 'ignore' });
|
|
486
|
+
// Alternate screen OFF — without this, tmux puts xterm.js into alternate
|
|
487
|
+
// buffer which has no scrollback. xterm.js then translates wheel events
|
|
488
|
+
// to arrow keys (cycling command history). With alternate-screen off,
|
|
489
|
+
// tmux writes to the normal buffer and xterm.js native scroll works.
|
|
490
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'alternate-screen', 'off'], { stdio: 'ignore' });
|
|
491
|
+
// Unset CLAUDECODE so spawned Claude processes don't detect a nested session
|
|
492
|
+
spawnSync('tmux', ['set-environment', '-t', sessionName, '-u', 'CLAUDECODE'], { stdio: 'ignore' });
|
|
480
493
|
return sessionName;
|
|
481
494
|
}
|
|
482
495
|
catch (err) {
|
|
@@ -522,36 +535,14 @@ function killTmuxSession(sessionName) {
|
|
|
522
535
|
// Session may have already died
|
|
523
536
|
}
|
|
524
537
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
*
|
|
529
|
-
* Examples:
|
|
530
|
-
* "architect-codev-public" → { type: 'architect', projectBasename: 'codev-public', roleId: null }
|
|
531
|
-
* "builder-codevos_ai-0001" → { type: 'builder', projectBasename: 'codevos_ai', roleId: '0001' }
|
|
532
|
-
* "shell-codev-public-shell-1" → { type: 'shell', projectBasename: 'codev-public', roleId: 'shell-1' }
|
|
533
|
-
*/
|
|
534
|
-
function parseTmuxSessionName(name) {
|
|
535
|
-
// architect-{basename}
|
|
536
|
-
const architectMatch = name.match(/^architect-(.+)$/);
|
|
537
|
-
if (architectMatch) {
|
|
538
|
-
return { type: 'architect', projectBasename: architectMatch[1], roleId: null };
|
|
539
|
-
}
|
|
540
|
-
// builder-{basename}-{specId} — specId is always the last segment (digits like "0001")
|
|
541
|
-
const builderMatch = name.match(/^builder-(.+)-(\d{4,})$/);
|
|
542
|
-
if (builderMatch) {
|
|
543
|
-
return { type: 'builder', projectBasename: builderMatch[1], roleId: builderMatch[2] };
|
|
544
|
-
}
|
|
545
|
-
// shell-{basename}-{shellId} — shellId is "shell-N" (last two segments)
|
|
546
|
-
const shellMatch = name.match(/^shell-(.+)-(shell-\d+)$/);
|
|
547
|
-
if (shellMatch) {
|
|
548
|
-
return { type: 'shell', projectBasename: shellMatch[1], roleId: shellMatch[2] };
|
|
549
|
-
}
|
|
550
|
-
return null;
|
|
551
|
-
}
|
|
538
|
+
// ============================================================================
|
|
539
|
+
// Tmux-First Discovery (tmux is source of truth for existence)
|
|
540
|
+
// ============================================================================
|
|
552
541
|
/**
|
|
553
542
|
* List all tmux sessions that match codev naming conventions.
|
|
554
543
|
* Returns an array of { tmuxName, parsed } for each matching session.
|
|
544
|
+
* Sessions with recognized prefixes (architect-, builder-, shell-) but
|
|
545
|
+
* unparseable ID formats are included with parsed: null for SQLite lookup.
|
|
555
546
|
*/
|
|
556
547
|
// Cache for listCodevTmuxSessions — avoid shelling out on every dashboard poll
|
|
557
548
|
let _tmuxListCache = [];
|
|
@@ -573,6 +564,10 @@ function listCodevTmuxSessions(bypassCache = false) {
|
|
|
573
564
|
if (parsed) {
|
|
574
565
|
codevSessions.push({ tmuxName: name, parsed });
|
|
575
566
|
}
|
|
567
|
+
else if (/^(?:architect|builder|shell)-/.test(name)) {
|
|
568
|
+
// Recognized codev prefix but unparseable ID format — include for SQLite lookup
|
|
569
|
+
codevSessions.push({ tmuxName: name, parsed: null });
|
|
570
|
+
}
|
|
576
571
|
}
|
|
577
572
|
_tmuxListCache = codevSessions;
|
|
578
573
|
_tmuxListCacheTime = now;
|
|
@@ -655,11 +650,11 @@ async function reconcileTerminalSessions() {
|
|
|
655
650
|
const dbRow = findSqliteRowForTmuxSession(tmuxName);
|
|
656
651
|
matchedTmuxNames.add(tmuxName);
|
|
657
652
|
// Determine metadata — prefer SQLite, fall back to parsed name
|
|
658
|
-
const projectPath = dbRow?.project_path || resolveProjectPathFromBasename(parsed.projectBasename);
|
|
659
|
-
const type = dbRow?.type || parsed
|
|
660
|
-
const roleId = dbRow?.role_id
|
|
661
|
-
if (!projectPath) {
|
|
662
|
-
log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
|
|
653
|
+
const projectPath = dbRow?.project_path || (parsed && resolveProjectPathFromBasename(parsed.projectBasename));
|
|
654
|
+
const type = (dbRow?.type || parsed?.type);
|
|
655
|
+
const roleId = dbRow?.role_id ?? parsed?.roleId ?? null;
|
|
656
|
+
if (!projectPath || !type) {
|
|
657
|
+
log('WARN', `Cannot resolve ${!projectPath ? 'project path' : 'type'} for tmux session "${tmuxName}"${parsed ? ` (basename: ${parsed.projectBasename})` : ''} — skipping`);
|
|
663
658
|
continue;
|
|
664
659
|
}
|
|
665
660
|
// Skip sessions whose project path doesn't exist on disk or is in a
|
|
@@ -703,6 +698,10 @@ async function reconcileTerminalSessions() {
|
|
|
703
698
|
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
|
|
704
699
|
}
|
|
705
700
|
saveTerminalSession(newSession.id, projectPath, type, roleId, newSession.pid, tmuxName);
|
|
701
|
+
registerKnownProject(projectPath);
|
|
702
|
+
// Ensure correct tmux options on reconnected sessions
|
|
703
|
+
spawnSync('tmux', ['set-option', '-t', tmuxName, 'mouse', 'off'], { stdio: 'ignore' });
|
|
704
|
+
spawnSync('tmux', ['set-option', '-t', tmuxName, 'alternate-screen', 'off'], { stdio: 'ignore' });
|
|
706
705
|
if (dbRow) {
|
|
707
706
|
log('INFO', `Reconnected tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)})`);
|
|
708
707
|
reconnected++;
|
|
@@ -935,11 +934,39 @@ if (isNaN(port) || port < 1 || port > 65535) {
|
|
|
935
934
|
}
|
|
936
935
|
log('INFO', `Tower server starting on port ${port}`);
|
|
937
936
|
/**
|
|
938
|
-
*
|
|
937
|
+
* Register a project in the known_projects table so it persists across restarts
|
|
938
|
+
* even when all terminal sessions are gone.
|
|
939
|
+
*/
|
|
940
|
+
function registerKnownProject(projectPath) {
|
|
941
|
+
try {
|
|
942
|
+
const db = getGlobalDb();
|
|
943
|
+
db.prepare(`
|
|
944
|
+
INSERT INTO known_projects (project_path, name, last_launched_at)
|
|
945
|
+
VALUES (?, ?, datetime('now'))
|
|
946
|
+
ON CONFLICT(project_path) DO UPDATE SET last_launched_at = datetime('now')
|
|
947
|
+
`).run(projectPath, path.basename(projectPath));
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
// Table may not exist yet (pre-migration)
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Get all known project paths from known_projects, terminal_sessions, and in-memory cache
|
|
939
955
|
*/
|
|
940
956
|
function getKnownProjectPaths() {
|
|
941
957
|
const projectPaths = new Set();
|
|
942
|
-
// From
|
|
958
|
+
// From known_projects table (persists even after all terminals are killed)
|
|
959
|
+
try {
|
|
960
|
+
const db = getGlobalDb();
|
|
961
|
+
const projects = db.prepare('SELECT project_path FROM known_projects').all();
|
|
962
|
+
for (const p of projects) {
|
|
963
|
+
projectPaths.add(p.project_path);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
// Table may not exist yet
|
|
968
|
+
}
|
|
969
|
+
// From terminal_sessions table (catches any missed by known_projects)
|
|
943
970
|
try {
|
|
944
971
|
const db = getGlobalDb();
|
|
945
972
|
const sessions = db.prepare('SELECT DISTINCT project_path FROM terminal_sessions').all();
|
|
@@ -1141,6 +1168,9 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
1141
1168
|
const projectBasename = sanitizeTmuxSessionName(path.basename(normalizedPath));
|
|
1142
1169
|
const liveTmux = listCodevTmuxSessions();
|
|
1143
1170
|
for (const { tmuxName, parsed } of liveTmux) {
|
|
1171
|
+
// Skip sessions we couldn't fully parse (no projectBasename to match)
|
|
1172
|
+
if (!parsed)
|
|
1173
|
+
continue;
|
|
1144
1174
|
// Only process sessions whose sanitized project basename matches
|
|
1145
1175
|
if (parsed.projectBasename !== projectBasename)
|
|
1146
1176
|
continue;
|
|
@@ -1151,9 +1181,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
1151
1181
|
if (alreadyRegistered)
|
|
1152
1182
|
continue;
|
|
1153
1183
|
// Orphaned tmux session — reconnect it.
|
|
1154
|
-
// Skip architect sessions: launchInstance handles
|
|
1155
|
-
// and has its own exit handler for auto-restart. Reconnecting here races with
|
|
1156
|
-
// the restart logic and can attach to a dead tmux session.
|
|
1184
|
+
// Skip architect sessions: launchInstance handles creation/reconnection.
|
|
1157
1185
|
if (parsed.type === 'architect')
|
|
1158
1186
|
continue;
|
|
1159
1187
|
try {
|
|
@@ -1351,6 +1379,8 @@ async function launchInstance(projectPath) {
|
|
|
1351
1379
|
try {
|
|
1352
1380
|
// Ensure project has port allocation
|
|
1353
1381
|
const resolvedPath = fs.realpathSync(projectPath);
|
|
1382
|
+
// Persist in known_projects so the project survives terminal cleanup
|
|
1383
|
+
registerKnownProject(resolvedPath);
|
|
1354
1384
|
// Initialize project terminal entry
|
|
1355
1385
|
const entry = getProjectTerminalsEntry(resolvedPath);
|
|
1356
1386
|
// Create architect terminal if not already present
|
|
@@ -1390,12 +1420,17 @@ async function launchInstance(projectPath) {
|
|
|
1390
1420
|
log('INFO', `Reconnecting to existing tmux session "${sanitizedTmuxName}" for architect`);
|
|
1391
1421
|
}
|
|
1392
1422
|
else {
|
|
1393
|
-
|
|
1423
|
+
// Wrap architect in a restart loop inside tmux so it auto-restarts
|
|
1424
|
+
// when the user exits Claude Code (e.g., /exit). The loop runs
|
|
1425
|
+
// inside tmux itself, independent of Tower's node-pty exit handler.
|
|
1426
|
+
const innerCmd = [cmd, ...cmdArgs].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
|
|
1427
|
+
const loopCmd = `while true; do ${innerCmd}; echo "Architect exited. Restarting in 2s..."; sleep 2; done`;
|
|
1428
|
+
const createdName = createTmuxSession(tmuxName, 'sh', ['-c', loopCmd], projectPath, 200, 50);
|
|
1394
1429
|
if (createdName) {
|
|
1395
1430
|
cmd = 'tmux';
|
|
1396
1431
|
cmdArgs = ['attach-session', '-t', createdName];
|
|
1397
1432
|
activeTmuxSession = createdName;
|
|
1398
|
-
log('INFO', `Created tmux session "${createdName}" for architect`);
|
|
1433
|
+
log('INFO', `Created tmux session "${createdName}" for architect (with restart loop)`);
|
|
1399
1434
|
}
|
|
1400
1435
|
}
|
|
1401
1436
|
}
|
|
@@ -1409,49 +1444,17 @@ async function launchInstance(projectPath) {
|
|
|
1409
1444
|
entry.architect = session.id;
|
|
1410
1445
|
// TICK-001: Save to SQLite for persistence (with tmux session name)
|
|
1411
1446
|
saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
|
|
1412
|
-
//
|
|
1447
|
+
// Clean up cache/SQLite when the node-pty session exits.
|
|
1448
|
+
// Restart is handled by the while-true loop inside tmux (not here).
|
|
1413
1449
|
const ptySession = manager.getSession(session.id);
|
|
1414
1450
|
if (ptySession) {
|
|
1415
|
-
const startedAt = Date.now();
|
|
1416
1451
|
ptySession.on('exit', () => {
|
|
1417
|
-
// Re-read entry from the Map — getTerminalsForProject() periodically
|
|
1418
|
-
// replaces the Map entry with a fresh object, so the `entry` captured
|
|
1419
|
-
// in the closure may be stale.
|
|
1420
1452
|
const currentEntry = getProjectTerminalsEntry(resolvedPath);
|
|
1421
1453
|
if (currentEntry.architect === session.id) {
|
|
1422
1454
|
currentEntry.architect = undefined;
|
|
1423
1455
|
}
|
|
1424
1456
|
deleteTerminalSession(session.id);
|
|
1425
|
-
|
|
1426
|
-
// The node-pty process is `tmux attach` — it exits on disconnect
|
|
1427
|
-
// timeout, but the tmux session (and the architect process inside
|
|
1428
|
-
// it) may still be running. Only kill tmux if the inner process
|
|
1429
|
-
// has also exited (e.g., user typed "exit" or process crashed).
|
|
1430
|
-
const tmuxAlive = activeTmuxSession && tmuxSessionExists(activeTmuxSession);
|
|
1431
|
-
if (activeTmuxSession && !tmuxAlive) {
|
|
1432
|
-
log('INFO', `Tmux session "${activeTmuxSession}" already gone for ${projectPath}`);
|
|
1433
|
-
}
|
|
1434
|
-
else if (tmuxAlive) {
|
|
1435
|
-
log('INFO', `Tmux session "${activeTmuxSession}" still alive for ${projectPath}, preserving for reconnect`);
|
|
1436
|
-
}
|
|
1437
|
-
// Only restart if the architect ran for at least 5s (prevents crash loops)
|
|
1438
|
-
const uptime = Date.now() - startedAt;
|
|
1439
|
-
if (uptime < 5000) {
|
|
1440
|
-
log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
|
|
1441
|
-
return;
|
|
1442
|
-
}
|
|
1443
|
-
// Kill the stale tmux session so launchInstance creates a fresh one
|
|
1444
|
-
// instead of reconnecting to the dead session.
|
|
1445
|
-
if (activeTmuxSession && tmuxSessionExists(activeTmuxSession)) {
|
|
1446
|
-
killTmuxSession(activeTmuxSession);
|
|
1447
|
-
log('INFO', `Killed stale tmux session "${activeTmuxSession}" before restart`);
|
|
1448
|
-
}
|
|
1449
|
-
log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
|
|
1450
|
-
setTimeout(() => {
|
|
1451
|
-
launchInstance(projectPath).catch((err) => {
|
|
1452
|
-
log('WARN', `Failed to restart architect for ${projectPath}: ${err.message}`);
|
|
1453
|
-
});
|
|
1454
|
-
}, 2000);
|
|
1457
|
+
log('INFO', `Architect pty exited for ${projectPath} (tmux loop handles restart)`);
|
|
1455
1458
|
});
|
|
1456
1459
|
}
|
|
1457
1460
|
log('INFO', `Created architect terminal for project: ${projectPath}`);
|
|
@@ -1598,6 +1601,73 @@ function serveStaticFile(filePath, res) {
|
|
|
1598
1601
|
return false;
|
|
1599
1602
|
}
|
|
1600
1603
|
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Handle tunnel management endpoints (Spec 0097 Phase 4).
|
|
1606
|
+
* Extracted so both /api/tunnel/* and /project/<encoded>/api/tunnel/* can use it.
|
|
1607
|
+
*/
|
|
1608
|
+
async function handleTunnelEndpoint(req, res, tunnelSub) {
|
|
1609
|
+
// POST connect
|
|
1610
|
+
if (req.method === 'POST' && tunnelSub === 'connect') {
|
|
1611
|
+
try {
|
|
1612
|
+
const config = readCloudConfig();
|
|
1613
|
+
if (!config) {
|
|
1614
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1615
|
+
res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (tunnelClient)
|
|
1619
|
+
tunnelClient.resetCircuitBreaker();
|
|
1620
|
+
const client = await connectTunnel(config);
|
|
1621
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1622
|
+
res.end(JSON.stringify({ success: true, state: client.getState() }));
|
|
1623
|
+
}
|
|
1624
|
+
catch (err) {
|
|
1625
|
+
log('ERROR', `Tunnel connect failed: ${err.message}`);
|
|
1626
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1627
|
+
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
1628
|
+
}
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
// POST disconnect
|
|
1632
|
+
if (req.method === 'POST' && tunnelSub === 'disconnect') {
|
|
1633
|
+
if (tunnelClient) {
|
|
1634
|
+
tunnelClient.disconnect();
|
|
1635
|
+
tunnelClient = null;
|
|
1636
|
+
}
|
|
1637
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1638
|
+
res.end(JSON.stringify({ success: true }));
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
// GET status
|
|
1642
|
+
if (req.method === 'GET' && tunnelSub === 'status') {
|
|
1643
|
+
let config = null;
|
|
1644
|
+
try {
|
|
1645
|
+
config = readCloudConfig();
|
|
1646
|
+
}
|
|
1647
|
+
catch {
|
|
1648
|
+
// Config file may be corrupted — treat as unregistered
|
|
1649
|
+
}
|
|
1650
|
+
const state = tunnelClient?.getState() ?? 'disconnected';
|
|
1651
|
+
const uptime = tunnelClient?.getUptime() ?? null;
|
|
1652
|
+
const response = {
|
|
1653
|
+
registered: config !== null,
|
|
1654
|
+
state,
|
|
1655
|
+
uptime,
|
|
1656
|
+
};
|
|
1657
|
+
if (config) {
|
|
1658
|
+
response.towerId = config.tower_id;
|
|
1659
|
+
response.towerName = config.tower_name;
|
|
1660
|
+
response.serverUrl = config.server_url;
|
|
1661
|
+
response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
|
|
1662
|
+
}
|
|
1663
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1664
|
+
res.end(JSON.stringify(response));
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
// Unknown tunnel endpoint
|
|
1668
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1669
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1670
|
+
}
|
|
1601
1671
|
// Create server
|
|
1602
1672
|
const server = http.createServer(async (req, res) => {
|
|
1603
1673
|
// Security: Validate Host and Origin headers
|
|
@@ -1606,13 +1676,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1606
1676
|
res.end('Forbidden');
|
|
1607
1677
|
return;
|
|
1608
1678
|
}
|
|
1609
|
-
// CORS headers
|
|
1679
|
+
// CORS headers — allow localhost and tunnel proxy origins
|
|
1610
1680
|
const origin = req.headers.origin;
|
|
1611
|
-
if (origin && (origin.startsWith('http://localhost:') ||
|
|
1681
|
+
if (origin && (origin.startsWith('http://localhost:') ||
|
|
1682
|
+
origin.startsWith('http://127.0.0.1:') ||
|
|
1683
|
+
origin.startsWith('https://'))) {
|
|
1612
1684
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1613
1685
|
}
|
|
1614
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
1615
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
1686
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
1687
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
1616
1688
|
res.setHeader('Cache-Control', 'no-store');
|
|
1617
1689
|
if (req.method === 'OPTIONS') {
|
|
1618
1690
|
res.writeHead(200);
|
|
@@ -1641,64 +1713,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1641
1713
|
}
|
|
1642
1714
|
// =========================================================================
|
|
1643
1715
|
// Tunnel Management Endpoints (Spec 0097 Phase 4)
|
|
1716
|
+
// Also reachable from /project/<encoded>/api/tunnel/* (see project router)
|
|
1644
1717
|
// =========================================================================
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
const config = readCloudConfig();
|
|
1649
|
-
if (!config) {
|
|
1650
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1651
|
-
res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
|
|
1652
|
-
return;
|
|
1653
|
-
}
|
|
1654
|
-
// Reset circuit breaker if in auth_failed state
|
|
1655
|
-
if (tunnelClient)
|
|
1656
|
-
tunnelClient.resetCircuitBreaker();
|
|
1657
|
-
const client = await connectTunnel(config);
|
|
1658
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1659
|
-
res.end(JSON.stringify({ success: true, state: client.getState() }));
|
|
1660
|
-
}
|
|
1661
|
-
catch (err) {
|
|
1662
|
-
log('ERROR', `Tunnel connect failed: ${err.message}`);
|
|
1663
|
-
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1664
|
-
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
1665
|
-
}
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
// POST /api/tunnel/disconnect — Disconnect tunnel from codevos.ai
|
|
1669
|
-
if (req.method === 'POST' && url.pathname === '/api/tunnel/disconnect') {
|
|
1670
|
-
if (tunnelClient) {
|
|
1671
|
-
tunnelClient.disconnect();
|
|
1672
|
-
tunnelClient = null;
|
|
1673
|
-
}
|
|
1674
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1675
|
-
res.end(JSON.stringify({ success: true }));
|
|
1676
|
-
return;
|
|
1677
|
-
}
|
|
1678
|
-
// GET /api/tunnel/status — Return tunnel connection status
|
|
1679
|
-
if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
|
|
1680
|
-
let config = null;
|
|
1681
|
-
try {
|
|
1682
|
-
config = readCloudConfig();
|
|
1683
|
-
}
|
|
1684
|
-
catch {
|
|
1685
|
-
// Config file may be corrupted — treat as unregistered
|
|
1686
|
-
}
|
|
1687
|
-
const state = tunnelClient?.getState() ?? 'disconnected';
|
|
1688
|
-
const uptime = tunnelClient?.getUptime() ?? null;
|
|
1689
|
-
const response = {
|
|
1690
|
-
registered: config !== null,
|
|
1691
|
-
state,
|
|
1692
|
-
uptime,
|
|
1693
|
-
};
|
|
1694
|
-
if (config) {
|
|
1695
|
-
response.towerId = config.tower_id;
|
|
1696
|
-
response.towerName = config.tower_name;
|
|
1697
|
-
response.serverUrl = config.server_url;
|
|
1698
|
-
response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
|
|
1699
|
-
}
|
|
1700
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1701
|
-
res.end(JSON.stringify(response));
|
|
1718
|
+
if (url.pathname.startsWith('/api/tunnel/')) {
|
|
1719
|
+
const tunnelSub = url.pathname.slice('/api/tunnel/'.length);
|
|
1720
|
+
await handleTunnelEndpoint(req, res, tunnelSub);
|
|
1702
1721
|
return;
|
|
1703
1722
|
}
|
|
1704
1723
|
// API: List all projects (Spec 0090 Phase 1)
|
|
@@ -2167,6 +2186,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
2167
2186
|
// Phase 4 (Spec 0090): Tower handles everything directly
|
|
2168
2187
|
const isApiCall = subPath.startsWith('api/') || subPath === 'api';
|
|
2169
2188
|
const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
|
|
2189
|
+
// Tunnel endpoints are tower-level, not project-scoped, but the React
|
|
2190
|
+
// dashboard uses relative paths (./api/tunnel/...) which resolve to
|
|
2191
|
+
// /project/<encoded>/api/tunnel/... in project context. Handle here by
|
|
2192
|
+
// extracting the tunnel sub-path and dispatching to handleTunnelEndpoint().
|
|
2193
|
+
if (subPath.startsWith('api/tunnel/')) {
|
|
2194
|
+
const tunnelSub = subPath.slice('api/tunnel/'.length); // e.g. "status", "connect", "disconnect"
|
|
2195
|
+
await handleTunnelEndpoint(req, res, tunnelSub);
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2170
2198
|
// GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
|
|
2171
2199
|
if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
|
|
2172
2200
|
const relPath = url.searchParams.get('path');
|