@lifeaitools/clauth 1.31.1 → 2.0.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/.clauth-skill/SKILL.md +31 -0
- package/.clauth-skill/references/operator-guide.md +27 -0
- package/README.md +48 -0
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/serve.js +1241 -1676
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +93 -1
- package/cli/ops/coolify-adapter.js +80 -0
- package/cli/ops/deployment-adapter.js +63 -0
- package/cli/ops/job-store.js +116 -0
- package/cli/ops/operation-policy.js +51 -0
- package/cli/ops/pm2-adapter.js +128 -0
- package/cli/ops/serialized-executor.js +9 -0
- package/cli/supervisor-registry.js +112 -6
- package/cli/supervisor-registry.test.js +151 -0
- package/cli/watchdog-registry.js +30 -2
- package/cli/watchdog-registry.test.js +28 -5
- package/package.json +3 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
package/cli/commands/serve.js
CHANGED
|
@@ -14,7 +14,7 @@ import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
|
|
|
14
14
|
import * as api from "../api.js";
|
|
15
15
|
import chalk from "chalk";
|
|
16
16
|
import ora from "ora";
|
|
17
|
-
import { execFileSync, execSync as execSyncTop } from "child_process";
|
|
17
|
+
import { execFileSync, execSync as execSyncTop, spawn } from "child_process";
|
|
18
18
|
import Conf from "conf";
|
|
19
19
|
import { getConfOptions } from "../conf-path.js";
|
|
20
20
|
import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp } from "node:fs/promises";
|
|
@@ -30,6 +30,25 @@ import {
|
|
|
30
30
|
readWatchdogEvents,
|
|
31
31
|
restartWatchdogService,
|
|
32
32
|
} from "../watchdog-registry.js";
|
|
33
|
+
import {
|
|
34
|
+
discoverPlugins,
|
|
35
|
+
getSupervisorDir,
|
|
36
|
+
getSupervisorPort,
|
|
37
|
+
loadSupervisorState,
|
|
38
|
+
listPlugins,
|
|
39
|
+
listRoutes,
|
|
40
|
+
listSurfaces,
|
|
41
|
+
listTunnels,
|
|
42
|
+
readSupervisorEvents,
|
|
43
|
+
addTunnelRoute,
|
|
44
|
+
removeTunnelRoute,
|
|
45
|
+
reconcileSurfaceHealth,
|
|
46
|
+
runPluginAction,
|
|
47
|
+
runSurfaceAction,
|
|
48
|
+
setPluginEnabled,
|
|
49
|
+
supervisorHealth,
|
|
50
|
+
operation,
|
|
51
|
+
} from "../supervisor-registry.js";
|
|
33
52
|
import {
|
|
34
53
|
AgentPool,
|
|
35
54
|
DelegationLane,
|
|
@@ -40,6 +59,14 @@ import {
|
|
|
40
59
|
DEFAULT_BOOTSTRAP,
|
|
41
60
|
} from "./agent-pool.js";
|
|
42
61
|
import { AgentCron, cronEnabled, nextRun } from "./agent-cron.js";
|
|
62
|
+
import pm2 from "pm2";
|
|
63
|
+
import { createPm2Adapter, PM2_OPERATION_CATALOG } from "../ops/pm2-adapter.js";
|
|
64
|
+
import { createOperationPolicy } from "../ops/operation-policy.js";
|
|
65
|
+
import { createJobStore } from "../ops/job-store.js";
|
|
66
|
+
import { createCoolifyAdapter, deploymentUuidFrom } from "../ops/coolify-adapter.js";
|
|
67
|
+
import { createDeploymentAdapter, parseDeploymentRegistry } from "../ops/deployment-adapter.js";
|
|
68
|
+
import { createSerializedExecutor } from "../ops/serialized-executor.js";
|
|
69
|
+
import { requestOps } from "./ops.js";
|
|
43
70
|
|
|
44
71
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
45
72
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
|
@@ -463,6 +490,7 @@ function createRotationEngine(password, machineHash, logFile) {
|
|
|
463
490
|
|
|
464
491
|
const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
|
|
465
492
|
const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
|
|
493
|
+
const SUPERVISOR_PID_FILE = path.join(os.tmpdir(), "clauth-supervisor.pid");
|
|
466
494
|
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
467
495
|
const LIVE_PORT = 52437;
|
|
468
496
|
const STAGED_PORT = 52438;
|
|
@@ -487,6 +515,74 @@ function validateWriteToken(req, writeSession) {
|
|
|
487
515
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
488
516
|
}
|
|
489
517
|
|
|
518
|
+
export function isLoopbackAddress(remote) {
|
|
519
|
+
return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function summarizeSupervisorTarget(target) {
|
|
523
|
+
if (!target || typeof target !== "object") return null;
|
|
524
|
+
return {
|
|
525
|
+
plugin_id: typeof target.plugin_id === "string" ? target.plugin_id : undefined,
|
|
526
|
+
surface_id: typeof target.surface_id === "string" ? target.surface_id : undefined,
|
|
527
|
+
tunnel_id: typeof target.tunnel_id === "string" ? target.tunnel_id : undefined,
|
|
528
|
+
route_id: typeof target.route_id === "string" ? target.route_id : undefined,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function summarizeSupervisorState(value) {
|
|
533
|
+
if (!value || typeof value !== "object") return null;
|
|
534
|
+
return {
|
|
535
|
+
ok: typeof value.ok === "boolean" ? value.ok : undefined,
|
|
536
|
+
state: typeof value.state === "string" ? value.state : undefined,
|
|
537
|
+
reason: typeof value.reason === "string" ? value.reason : undefined,
|
|
538
|
+
status: typeof value.status === "number" ? value.status : undefined,
|
|
539
|
+
private: typeof value.private === "boolean" ? value.private : undefined,
|
|
540
|
+
public_route: typeof value.public_route === "boolean" ? value.public_route : undefined,
|
|
541
|
+
evidence: Array.isArray(value.evidence) ? value.evidence.map((item) => String(item).slice(0, 200)).slice(0, 5) : [],
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function supervisorLogDto(event) {
|
|
546
|
+
if (!event || typeof event !== "object") return { kind: "unknown" };
|
|
547
|
+
if (event.kind === "operation") {
|
|
548
|
+
return {
|
|
549
|
+
ts: event.ts || event.created_at,
|
|
550
|
+
kind: "operation",
|
|
551
|
+
operationId: event.operationId,
|
|
552
|
+
actor: event.actor,
|
|
553
|
+
action: event.action,
|
|
554
|
+
target: summarizeSupervisorTarget(event.target),
|
|
555
|
+
resulting_state: summarizeSupervisorState(event.resulting_state),
|
|
556
|
+
completed_at: event.completed_at,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
ts: event.ts,
|
|
561
|
+
kind: event.kind,
|
|
562
|
+
plugin_id: event.plugin_id,
|
|
563
|
+
source: event.source,
|
|
564
|
+
state: event.state,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function supervisorOperationDto(receipt) {
|
|
569
|
+
return {
|
|
570
|
+
operationId: receipt.operationId,
|
|
571
|
+
actor: receipt.actor,
|
|
572
|
+
action: receipt.action,
|
|
573
|
+
target: summarizeSupervisorTarget(receipt.target),
|
|
574
|
+
resulting_state: summarizeSupervisorState(receipt.resulting_state),
|
|
575
|
+
evidence: Array.isArray(receipt.evidence) ? receipt.evidence.map((item) => String(item).slice(0, 200)).slice(0, 5) : [],
|
|
576
|
+
created_at: receipt.created_at,
|
|
577
|
+
completed_at: receipt.completed_at,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function supervisorRequiresWriteToken(port = getSupervisorPort(), env = process.env) {
|
|
582
|
+
if (port !== getSupervisorPort()) return true;
|
|
583
|
+
return env.CLAUTH_SUPERVISOR_REQUIRE_WRITE_TOKEN === "1";
|
|
584
|
+
}
|
|
585
|
+
|
|
490
586
|
// ── PID helpers ──────────────────────────────────────────────
|
|
491
587
|
function readPid() {
|
|
492
588
|
try {
|
|
@@ -512,6 +608,18 @@ function writeStagedPid(pid, port) {
|
|
|
512
608
|
fs.writeFileSync(STAGED_PID_FILE, `${pid}:${port}`, "utf8");
|
|
513
609
|
}
|
|
514
610
|
|
|
611
|
+
function readSupervisorPid() {
|
|
612
|
+
try {
|
|
613
|
+
const raw = fs.readFileSync(SUPERVISOR_PID_FILE, "utf8").trim();
|
|
614
|
+
const [pid, port] = raw.split(":");
|
|
615
|
+
return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
|
|
616
|
+
} catch { return null; }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function writeSupervisorPid(pid, port) {
|
|
620
|
+
fs.writeFileSync(SUPERVISOR_PID_FILE, `${pid}:${port}`, "utf8");
|
|
621
|
+
}
|
|
622
|
+
|
|
515
623
|
function removeStagedPid() {
|
|
516
624
|
try { fs.unlinkSync(STAGED_PID_FILE); } catch {}
|
|
517
625
|
}
|
|
@@ -520,6 +628,10 @@ function removePid() {
|
|
|
520
628
|
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
521
629
|
}
|
|
522
630
|
|
|
631
|
+
function removeSupervisorPid() {
|
|
632
|
+
try { fs.unlinkSync(SUPERVISOR_PID_FILE); } catch {}
|
|
633
|
+
}
|
|
634
|
+
|
|
523
635
|
function isProcessAlive(pid) {
|
|
524
636
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
525
637
|
}
|
|
@@ -574,6 +686,9 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
574
686
|
.btn-unlock:hover{background:#2563eb}
|
|
575
687
|
.btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
|
|
576
688
|
.lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
|
|
689
|
+
#write-unlock-overlay{display:none;position:fixed;inset:0;background:rgba(2,6,23,.72);z-index:9999;align-items:center;justify-content:center;padding:2rem}
|
|
690
|
+
.write-unlock-cancel{width:100%;background:transparent;color:#94a3b8;border:1px solid #334155;border-radius:8px;padding:8px;font-size:.85rem;cursor:pointer;margin-top:.6rem;transition:border-color .15s}
|
|
691
|
+
.write-unlock-cancel:hover{border-color:#64748b;color:#cbd5e1}
|
|
577
692
|
/* ── Main view ── */
|
|
578
693
|
#main-view{display:none;padding:2rem}
|
|
579
694
|
.header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
|
|
@@ -658,15 +773,13 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
658
773
|
.btn-ccandme{background:linear-gradient(135deg,#1a1a2e,#16213e);color:#a78bfa;border:1px solid #4c1d95;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:600;transition:all .15s;white-space:nowrap}
|
|
659
774
|
.btn-ccandme:hover{background:linear-gradient(135deg,#2d1b69,#1e1b4b);border-color:#7c3aed;color:#c4b5fd;transform:translateY(-1px)}
|
|
660
775
|
.btn-ccandme:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
|
661
|
-
.btn-tintin{background:linear-gradient(135deg,#103c2f,#0f2f3f);color:#86efac;border:1px solid #166534;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:700;transition:all .15s;white-space:nowrap}
|
|
662
|
-
.btn-tintin:hover{background:linear-gradient(135deg,#14532d,#155e75);border-color:#22c55e;color:#bbf7d0;transform:translateY(-1px)}
|
|
663
|
-
.btn-tintin:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
|
664
776
|
.btn-claude{background:linear-gradient(135deg,#d97706,#f59e0b);color:#0a0f1a;padding:8px 18px;font-size:.85rem;border-radius:7px;border:none;cursor:pointer;font-weight:700;letter-spacing:.3px;transition:all .15s;white-space:nowrap}
|
|
665
777
|
.btn-claude:hover{filter:brightness(1.1);transform:translateY(-1px)}
|
|
666
778
|
.btn-claude:disabled{opacity:.4;cursor:not-allowed;transform:none;filter:none}
|
|
667
779
|
.btn-tunnel-stop{background:#1e293b;color:#f87171;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
|
|
668
780
|
.btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
|
|
669
781
|
.tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
|
|
782
|
+
.dash-panel{margin-bottom:1.25rem}
|
|
670
783
|
.build-panel{background:#0f1a2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
671
784
|
.build-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
|
672
785
|
.build-dot.idle{background:#64748b}
|
|
@@ -677,25 +790,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
677
790
|
.build-label{font-size:.85rem;color:#94a3b8;flex:1}
|
|
678
791
|
.build-label strong{color:#e2e8f0}
|
|
679
792
|
.build-meta{font-family:'Courier New',monospace;font-size:.75rem;color:#64748b}
|
|
680
|
-
.tintin-tab{position:fixed;top:50%;right:0;transform:translateY(-50%);z-index:81;writing-mode:vertical-rl;text-orientation:mixed;background:linear-gradient(180deg,#103c2f,#0f2f3f);color:#86efac;border:1px solid #166534;border-right:none;border-radius:8px 0 0 8px;padding:14px 6px;font-size:.78rem;font-weight:700;cursor:pointer;letter-spacing:.5px;transition:all .2s}
|
|
681
|
-
.tintin-tab:hover{background:linear-gradient(180deg,#14532d,#155e75);color:#bbf7d0;padding-right:10px}
|
|
682
|
-
.tintin-tab.active{background:#081c1a;border-color:#22c55e;color:#bbf7d0}
|
|
683
|
-
.tintin-panel{position:fixed;top:0;right:-440px;bottom:0;z-index:80;width:min(420px,calc(100vw - 24px));background:#081c1a;border-left:1px solid #14532d;padding:0;box-shadow:-22px 0 60px rgba(0,0,0,.42);display:flex;flex-direction:column;transition:right .25s ease}
|
|
684
|
-
.tintin-panel.open{right:0}
|
|
685
|
-
.tintin-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:.8rem}
|
|
686
|
-
.tintin-title{font-size:.95rem;font-weight:700;color:#dcfce7}
|
|
687
|
-
.tintin-sub{font-size:.78rem;color:#6ee7b7;margin-top:2px}
|
|
688
|
-
.tintin-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
|
|
689
|
-
.tintin-field{display:flex;flex-direction:column;gap:4px}
|
|
690
|
-
.tintin-field label{font-size:.72rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;font-weight:700}
|
|
691
|
-
.tintin-input,.tintin-textarea{background:#031312;border:1px solid #14532d;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:8px 10px;outline:none;transition:border-color .15s}
|
|
692
|
-
.tintin-input:focus,.tintin-textarea:focus{border-color:#22c55e}
|
|
693
|
-
.tintin-textarea{width:100%;min-height:92px;resize:vertical;line-height:1.45;margin-bottom:10px}
|
|
694
|
-
.tintin-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
695
|
-
.tintin-status{font-family:'Courier New',monospace;font-size:.78rem;color:#94a3b8}
|
|
696
|
-
.tintin-result{display:none;margin-top:12px;background:#020b0a;border:1px solid #134e4a;border-radius:8px;padding:10px;white-space:pre-wrap;word-break:break-word;font-family:'Courier New',monospace;font-size:.82rem;color:#bbf7d0;max-height:240px;overflow:auto}
|
|
697
|
-
.tintin-result.open{display:block}
|
|
698
|
-
@media (max-width:720px){.tintin-panel{width:100vw}.tintin-grid{grid-template-columns:1fr}}
|
|
699
793
|
.mcp-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
|
700
794
|
.mcp-label{font-size:.72rem;color:#64748b;min-width:80px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
|
701
795
|
.mcp-val{flex:1;font-family:'Courier New',monospace;font-size:.82rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:6px 10px;word-break:break-all;user-select:all}
|
|
@@ -805,6 +899,36 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
805
899
|
.wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
|
|
806
900
|
.wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
|
|
807
901
|
.wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
|
|
902
|
+
.supervisor-panel{margin:14px 16px;border:1px solid #233249;background:linear-gradient(135deg,rgba(15,23,42,.96),rgba(10,15,26,.98));border-radius:12px;padding:14px}
|
|
903
|
+
.supervisor-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px}
|
|
904
|
+
.supervisor-title{font-size:.95rem;font-weight:700;color:#e2e8f0}
|
|
905
|
+
.supervisor-sub{font-size:.76rem;color:#94a3b8;margin-top:3px;line-height:1.35}
|
|
906
|
+
.supervisor-actions{display:flex;gap:8px;flex-wrap:wrap}
|
|
907
|
+
.supervisor-action{background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:6px;padding:6px 10px;font-size:.75rem;cursor:pointer}
|
|
908
|
+
.supervisor-action:hover{border-color:#38bdf8;color:#e0f2fe}
|
|
909
|
+
.supervisor-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
|
|
910
|
+
.supervisor-card{border:1px solid #1e293b;background:rgba(2,6,23,.52);border-radius:10px;padding:10px;min-width:0}
|
|
911
|
+
.supervisor-card h4{margin:0 0 8px;color:#cbd5e1;font-size:.75rem;letter-spacing:.08em;text-transform:uppercase}
|
|
912
|
+
.supervisor-kpi{font-size:1.35rem;font-weight:700;color:#f8fafc}
|
|
913
|
+
.supervisor-meta{font-size:.72rem;color:#94a3b8;font-family:'Courier New',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
914
|
+
.supervisor-pill{display:inline-flex;align-items:center;border-radius:999px;padding:2px 7px;margin:2px 4px 2px 0;font-size:.68rem;border:1px solid #334155;color:#cbd5e1;background:#0f172a}
|
|
915
|
+
.supervisor-pill.ok{border-color:rgba(74,222,128,.35);color:#86efac;background:rgba(34,197,94,.08)}
|
|
916
|
+
.supervisor-pill.warn{border-color:rgba(250,204,21,.35);color:#fde68a;background:rgba(250,204,21,.08)}
|
|
917
|
+
.supervisor-pill.bad{border-color:rgba(248,113,113,.35);color:#fecaca;background:rgba(248,113,113,.08)}
|
|
918
|
+
.supervisor-list{display:flex;flex-direction:column;gap:8px;max-height:320px;overflow:auto}
|
|
919
|
+
.supervisor-row{border:1px solid #1e293b;border-radius:8px;padding:6px 8px;background:rgba(15,23,42,.45);cursor:pointer}
|
|
920
|
+
.supervisor-row:hover{border-color:#334155}
|
|
921
|
+
.supervisor-row.selected{border-color:#38bdf8;background:rgba(56,189,248,.08)}
|
|
922
|
+
.supervisor-row.readonly{cursor:default}
|
|
923
|
+
.supervisor-row-top{display:flex;justify-content:space-between;gap:8px;align-items:center}
|
|
924
|
+
.supervisor-name{font-size:.82rem;color:#e2e8f0;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
925
|
+
.supervisor-row-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}
|
|
926
|
+
.supervisor-cmdbar{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:8px;padding:8px;border:1px solid #1e293b;border-radius:8px;background:rgba(2,6,23,.4);min-height:36px}
|
|
927
|
+
.supervisor-cmdbar-empty{font-size:.74rem;color:#64748b}
|
|
928
|
+
.supervisor-cmdbar-name{font-size:.78rem;color:#e2e8f0;font-weight:600;margin-right:4px}
|
|
929
|
+
.supervisor-log{font-family:'Courier New',monospace;font-size:.7rem;color:#94a3b8;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow:auto}
|
|
930
|
+
.supervisor-feedback{margin-top:8px;padding:7px 9px;border-radius:6px;font-family:'Courier New',monospace;font-size:.72rem;color:#86efac;background:rgba(34,197,94,.08);border:1px solid rgba(74,222,128,.22);display:none}
|
|
931
|
+
.supervisor-feedback.bad{color:#fecaca;background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25)}
|
|
808
932
|
</style>
|
|
809
933
|
</head>
|
|
810
934
|
<body>
|
|
@@ -824,15 +948,27 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
824
948
|
</div>
|
|
825
949
|
</div>
|
|
826
950
|
|
|
951
|
+
<!-- Write-unlock modal (replaces window.prompt(), which silently no-ops in
|
|
952
|
+
embedded/webview browser contexts and after a browser suppresses repeated
|
|
953
|
+
native dialogs) -->
|
|
954
|
+
<div id="write-unlock-overlay">
|
|
955
|
+
<div class="lock-card">
|
|
956
|
+
<div class="lock-icon">🔒</div>
|
|
957
|
+
<div class="lock-title">Enable writes</div>
|
|
958
|
+
<div class="lock-sub">Enter your vault password to enable saving changes (30-minute write session)</div>
|
|
959
|
+
<form onsubmit="submitWriteUnlock();return false;" autocomplete="on">
|
|
960
|
+
<input type="text" name="username" value="clauth" autocomplete="username" style="display:none">
|
|
961
|
+
<input class="lock-input" id="write-unlock-input" type="password" placeholder="••••••••••••" autocomplete="current-password">
|
|
962
|
+
<button class="btn-unlock" id="write-unlock-btn" type="submit">Unlock Writes</button>
|
|
963
|
+
</form>
|
|
964
|
+
<div class="lock-err" id="write-unlock-err"></div>
|
|
965
|
+
<button type="button" class="write-unlock-cancel" onclick="closeWriteUnlockModal()">Cancel</button>
|
|
966
|
+
</div>
|
|
967
|
+
</div>
|
|
968
|
+
|
|
827
969
|
<!-- ── Main view (shown after unlock) ──────── -->
|
|
828
970
|
<div id="main-view">
|
|
829
|
-
<
|
|
830
|
-
<div class="upgrade-content">
|
|
831
|
-
<strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
|
|
832
|
-
<span id="upgrade-details"></span>
|
|
833
|
-
<button onclick="dismissUpgrade()">Dismiss ✕</button>
|
|
834
|
-
</div>
|
|
835
|
-
</div>
|
|
971
|
+
<section class="dash-panel" data-panel="title-status">
|
|
836
972
|
<div class="header">
|
|
837
973
|
<div class="dot" id="dot"></div>
|
|
838
974
|
<h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span>${isStaged ? `<span style="font-size:0.5em;background:#b45309;color:#fef3c7;border-radius:4px;padding:2px 10px;margin-left:12px;font-weight:600;letter-spacing:.5px">STAGED</span>` : ""}</h1>
|
|
@@ -851,6 +987,16 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
851
987
|
<div>Services: <span id="s-services">${whitelist ? whitelist.join(", ") : "all"}</span></div>
|
|
852
988
|
<div>Failures: <span id="s-fails">—</span></div>
|
|
853
989
|
</div>
|
|
990
|
+
</section>
|
|
991
|
+
|
|
992
|
+
<section class="dash-panel" data-panel="status-area">
|
|
993
|
+
<div id="upgrade-banner" style="display:none" class="upgrade-banner">
|
|
994
|
+
<div class="upgrade-content">
|
|
995
|
+
<strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
|
|
996
|
+
<span id="upgrade-details"></span>
|
|
997
|
+
<button onclick="dismissUpgrade()">Dismiss ✕</button>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
854
1000
|
<div class="build-panel" id="build-panel">
|
|
855
1001
|
<div class="build-dot idle" id="build-dot"></div>
|
|
856
1002
|
<div class="build-label" id="build-label">
|
|
@@ -859,8 +1005,9 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
859
1005
|
<span id="build-sha" style="font-family:'Courier New',monospace;font-size:.72rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:2px 8px;letter-spacing:.3px"></span>
|
|
860
1006
|
<div class="build-meta" id="build-meta" style="width:100%;margin-top:4px"></div>
|
|
861
1007
|
</div>
|
|
1008
|
+
</section>
|
|
862
1009
|
|
|
863
|
-
<div class="toolbar">
|
|
1010
|
+
<div class="toolbar" data-panel="menu-bar">
|
|
864
1011
|
<button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
|
|
865
1012
|
<button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
|
|
866
1013
|
<button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
|
|
@@ -952,7 +1099,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
952
1099
|
</form>
|
|
953
1100
|
</div>
|
|
954
1101
|
|
|
955
|
-
<div class="tunnel-panel" id="tunnel-panel">
|
|
1102
|
+
<div class="tunnel-panel" id="tunnel-panel" data-panel="tunnel-bar">
|
|
956
1103
|
<!-- not_configured -->
|
|
957
1104
|
<div class="tunnel-state not-configured" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
958
1105
|
<div class="tunnel-dot off"></div>
|
|
@@ -993,40 +1140,38 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
993
1140
|
</div>
|
|
994
1141
|
</div>
|
|
995
1142
|
|
|
996
|
-
<
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
<div class="
|
|
1001
|
-
|
|
1143
|
+
<section class="supervisor-panel" id="supervisor-panel" data-panel="surfaces-log">
|
|
1144
|
+
<div class="supervisor-head">
|
|
1145
|
+
<div>
|
|
1146
|
+
<div class="supervisor-title">Local Software Factory Supervisor</div>
|
|
1147
|
+
<div class="supervisor-sub">clauth owns local LIFEAI plugin discovery, candidate tests, clauth-owned surface operations, receipts, and supervisor event logs. Dev Center should call this surface instead of PM2 directly.</div>
|
|
1148
|
+
</div>
|
|
1149
|
+
<div class="supervisor-actions">
|
|
1150
|
+
<button class="supervisor-action" onclick="loadSupervisorCockpit()">Refresh</button>
|
|
1151
|
+
<button class="supervisor-action" onclick="rescanSupervisorPlugins()">Rescan plugins</button>
|
|
1002
1152
|
</div>
|
|
1003
|
-
<div class="tintin-sub">claude-sonnet-4-6 · clauth CLI dispatch</div>
|
|
1004
|
-
<div style="margin-top:6px"><a href="/tintin/settings/ui" target="_blank" rel="noopener" style="font-size:.72rem;color:#86efac;text-decoration:none;border:1px solid #14532d;padding:2px 6px;border-radius:4px">Agent settings</a></div>
|
|
1005
|
-
</div>
|
|
1006
|
-
<div id="tintin-messages" style="flex:1;min-height:0;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:10px">
|
|
1007
|
-
<div style="font-size:.78rem;color:#6ee7b7;font-style:italic">Ask anything. Runs a Claude CLI agent with full clauth + repo context.</div>
|
|
1008
1153
|
</div>
|
|
1009
|
-
<div
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
<div class="
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
</div>
|
|
1154
|
+
<div id="supervisor-status" class="supervisor-sub">Loading supervisor state…</div>
|
|
1155
|
+
<div id="supervisor-feedback" class="supervisor-feedback" role="status"></div>
|
|
1156
|
+
<div class="supervisor-grid" style="margin-top:10px">
|
|
1157
|
+
<div class="supervisor-card">
|
|
1158
|
+
<h4>Surfaces</h4>
|
|
1159
|
+
<div class="supervisor-kpi" id="supervisor-surface-count">—</div>
|
|
1160
|
+
<div class="supervisor-meta" id="supervisor-surface-meta">destination + owner matrix</div>
|
|
1161
|
+
<div class="supervisor-cmdbar" id="supervisor-cmdbar"><span class="supervisor-cmdbar-empty">Select a surface below to act on it.</span></div>
|
|
1162
|
+
<div class="supervisor-list" id="supervisor-surfaces" style="margin-top:8px"></div>
|
|
1019
1163
|
</div>
|
|
1020
|
-
<
|
|
1021
|
-
|
|
1022
|
-
<
|
|
1023
|
-
<
|
|
1164
|
+
<div class="supervisor-card">
|
|
1165
|
+
<h4>Operations + log</h4>
|
|
1166
|
+
<div class="supervisor-kpi" id="supervisor-operation-count">—</div>
|
|
1167
|
+
<div class="supervisor-meta" id="supervisor-log-path">events.jsonl</div>
|
|
1168
|
+
<div class="supervisor-log" id="supervisor-events" style="margin-top:8px">No events loaded.</div>
|
|
1024
1169
|
</div>
|
|
1025
1170
|
</div>
|
|
1026
|
-
</
|
|
1171
|
+
</section>
|
|
1027
1172
|
|
|
1028
1173
|
|
|
1029
|
-
<div class="tunnel-panel" id="webdav-panel" style="flex-direction:column;align-items:stretch;gap:8px">
|
|
1174
|
+
<div class="tunnel-panel" id="webdav-panel" data-panel="webdav-mounts" style="flex-direction:column;align-items:stretch;gap:8px">
|
|
1030
1175
|
<div style="display:flex;align-items:center;gap:10px;width:100%">
|
|
1031
1176
|
<div class="tunnel-dot off" id="webdav-dot"></div>
|
|
1032
1177
|
<strong style="color:#e2e8f0;font-size:.88rem">WebDAV Mounts</strong>
|
|
@@ -1062,6 +1207,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
1062
1207
|
<div class="wizard-foot" id="wizard-foot"></div>
|
|
1063
1208
|
</div>
|
|
1064
1209
|
|
|
1210
|
+
<section class="dash-panel" data-panel="search-credentials">
|
|
1065
1211
|
<div id="project-tabs" class="project-tabs" style="display:none"></div>
|
|
1066
1212
|
<div id="service-search" class="service-search">
|
|
1067
1213
|
<span class="service-search-label">Search</span>
|
|
@@ -1069,11 +1215,17 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
1069
1215
|
<span id="service-search-count" class="service-search-count"></span>
|
|
1070
1216
|
</div>
|
|
1071
1217
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
1072
|
-
|
|
1218
|
+
</section>
|
|
1219
|
+
<div class="footer" id="originFooter" data-panel="footer">checking origin… · 10-strike lockout</div>
|
|
1073
1220
|
</div>
|
|
1074
1221
|
|
|
1075
1222
|
<script>
|
|
1076
|
-
const BASE =
|
|
1223
|
+
const BASE = location.origin;
|
|
1224
|
+
(function reportOrigin() {
|
|
1225
|
+
const isLocal = /^(127\\.0\\.0\\.1|localhost|\\[::1\\])$/.test(location.hostname);
|
|
1226
|
+
const el = document.getElementById("originFooter");
|
|
1227
|
+
if (el) el.textContent = (isLocal ? "LOCAL" : "REMOTE") + " · " + BASE + " · 10-strike lockout";
|
|
1228
|
+
})();
|
|
1077
1229
|
|
|
1078
1230
|
const SERVICE_HINTS = {
|
|
1079
1231
|
"neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
|
|
@@ -1305,9 +1457,173 @@ function showMain(ping) {
|
|
|
1305
1457
|
pollTunnel();
|
|
1306
1458
|
loadWebdavMounts();
|
|
1307
1459
|
updateBuildStatus();
|
|
1460
|
+
loadSupervisorCockpit();
|
|
1461
|
+
startSupervisorLogTail();
|
|
1308
1462
|
refreshWriteLockUi();
|
|
1309
1463
|
}
|
|
1310
1464
|
|
|
1465
|
+
let supervisorLogTailStarted = false;
|
|
1466
|
+
function startSupervisorLogTail() {
|
|
1467
|
+
if (supervisorLogTailStarted) return; // showMain() can run more than once per page load
|
|
1468
|
+
supervisorLogTailStarted = true;
|
|
1469
|
+
setInterval(loadSupervisorCockpit, 3000);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function supervisorBadge(text, kind) {
|
|
1473
|
+
return '<span class="supervisor-pill ' + (kind || '') + '">' + htmlEscape(text) + '</span>';
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function supervisorJson(path, options) {
|
|
1477
|
+
const response = await fetch(BASE + path, { cache: "no-store", ...(options || {}) });
|
|
1478
|
+
const data = await response.json().catch(() => ({}));
|
|
1479
|
+
if (!response.ok || data.error) throw new Error(data.error || ("HTTP " + response.status));
|
|
1480
|
+
return data;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function supervisorFeedback(text, bad) {
|
|
1484
|
+
const el = document.getElementById("supervisor-feedback");
|
|
1485
|
+
if (!el) return;
|
|
1486
|
+
el.textContent = text || "";
|
|
1487
|
+
el.className = "supervisor-feedback" + (bad ? " bad" : "");
|
|
1488
|
+
el.style.display = text ? "block" : "none";
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const SUPERVISOR_SURFACE_ACTIONS = ["start", "stop", "restart", "reconcile", "test", "promote", "rollback"];
|
|
1492
|
+
let selectedSupervisorSurface = null; // { id, name } | null
|
|
1493
|
+
let lastSupervisorSurfaceRows = [];
|
|
1494
|
+
|
|
1495
|
+
const CLAUTH_SELF_PSEUDO_SURFACE = {
|
|
1496
|
+
__pseudo: true,
|
|
1497
|
+
id: "clauth:mcp-sse",
|
|
1498
|
+
plugin_id: "clauth",
|
|
1499
|
+
name: "clauth (self)",
|
|
1500
|
+
lifecycle_owner: "external",
|
|
1501
|
+
destination: "self",
|
|
1502
|
+
state: "self_managed",
|
|
1503
|
+
port: null,
|
|
1504
|
+
health: null,
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
async function loadSupervisorCockpit() {
|
|
1508
|
+
const status = document.getElementById("supervisor-status");
|
|
1509
|
+
if (status) status.textContent = "Loading supervisor state…";
|
|
1510
|
+
try {
|
|
1511
|
+
const [health, surfaces, logs] = await Promise.all([
|
|
1512
|
+
supervisorJson("/health"),
|
|
1513
|
+
supervisorJson("/v1/surfaces"),
|
|
1514
|
+
supervisorJson("/v1/logs?limit=40"),
|
|
1515
|
+
]);
|
|
1516
|
+
const surfaceRows = [CLAUTH_SELF_PSEUDO_SURFACE, ...(surfaces.surfaces || [])];
|
|
1517
|
+
lastSupervisorSurfaceRows = surfaceRows;
|
|
1518
|
+
if (selectedSupervisorSurface && !surfaceRows.some(s => (s.plugin_id + ":" + s.id) === selectedSupervisorSurface.id)) {
|
|
1519
|
+
selectedSupervisorSurface = null;
|
|
1520
|
+
}
|
|
1521
|
+
document.getElementById("supervisor-surface-count").textContent = String(surfaceRows.length - 1);
|
|
1522
|
+
document.getElementById("supervisor-operation-count").textContent = String((logs.operations || []).length);
|
|
1523
|
+
document.getElementById("supervisor-surface-meta").textContent = "pm2 home: " + (health.pm2_home || "—");
|
|
1524
|
+
document.getElementById("supervisor-log-path").textContent = logs.log_path || "events.jsonl";
|
|
1525
|
+
document.getElementById("supervisor-surfaces").innerHTML = surfaceRows.map(renderSupervisorSurface).join("");
|
|
1526
|
+
renderSupervisorCmdbar();
|
|
1527
|
+
const logEl = document.getElementById("supervisor-events");
|
|
1528
|
+
// Newest-at-bottom, like a real tail -- and only auto-scroll if the
|
|
1529
|
+
// reader was already at the bottom, so scrolling up to read history
|
|
1530
|
+
// during a poll tick doesn't get yanked back down.
|
|
1531
|
+
const wasAtBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 12;
|
|
1532
|
+
logEl.textContent = (logs.events || []).slice(-40).map(e => {
|
|
1533
|
+
const label = e.kind === "operation" ? (e.action + " " + JSON.stringify(e.target || {})) : (e.kind + " " + (e.plugin_id || ""));
|
|
1534
|
+
return (e.ts || e.created_at || "") + " " + label;
|
|
1535
|
+
}).join("\\n") || "No events yet.";
|
|
1536
|
+
if (wasAtBottom) logEl.scrollTop = logEl.scrollHeight;
|
|
1537
|
+
if (status) status.innerHTML = supervisorBadge("supervisor " + (health.status === "ok" ? "healthy" : (health.status || "unknown")), health.status === "ok" ? "ok" : "warn") + supervisorBadge("vault " + (health.vault_locked ? "locked" : "unlocked"), health.vault_locked ? "warn" : "ok") + supervisorBadge("v" + (health.clauth_version || "${VERSION}"), "");
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
if (status) status.innerHTML = supervisorBadge("supervisor unreachable", "bad") + " " + htmlEscape(err.message || err);
|
|
1540
|
+
supervisorFeedback("Supervisor unavailable — actions are disabled until the local control plane returns.", true);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Derives an "Open UI" target for a surface without storing one on disk —
|
|
1545
|
+
// a real localhost port wins, else the first externally-routable URL.
|
|
1546
|
+
function openUiUrlForSurface(surface) {
|
|
1547
|
+
if (surface.port && surface.port !== "auto") return "http://127.0.0.1:" + surface.port + "/";
|
|
1548
|
+
const external = (surface.routes || []).find(r => r.kind === "external" && r.url);
|
|
1549
|
+
return external ? external.url : null;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function selectSupervisorSurface(compositeId, name) {
|
|
1553
|
+
selectedSupervisorSurface = selectedSupervisorSurface && selectedSupervisorSurface.id === compositeId
|
|
1554
|
+
? null // clicking the already-selected card deselects it
|
|
1555
|
+
: { id: compositeId, name };
|
|
1556
|
+
document.querySelectorAll(".supervisor-row[data-surface-id]").forEach(row => {
|
|
1557
|
+
row.classList.toggle("selected", row.dataset.surfaceId === (selectedSupervisorSurface && selectedSupervisorSurface.id));
|
|
1558
|
+
});
|
|
1559
|
+
renderSupervisorCmdbar();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function renderSupervisorCmdbar() {
|
|
1563
|
+
const bar = document.getElementById("supervisor-cmdbar");
|
|
1564
|
+
if (!bar) return;
|
|
1565
|
+
if (!selectedSupervisorSurface) {
|
|
1566
|
+
bar.innerHTML = '<span class="supervisor-cmdbar-empty">Select a surface below to act on it.</span>';
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
const { id, name } = selectedSupervisorSurface;
|
|
1570
|
+
bar.innerHTML = '<span class="supervisor-cmdbar-name">' + htmlEscape(name) + '</span>' +
|
|
1571
|
+
SUPERVISOR_SURFACE_ACTIONS.map(a => '<button class="supervisor-action" data-supervisor-action="' + a + '" onclick="runSupervisorSurface(' + jsArg(id) + ',' + jsArg(a) + ')">' + a + '</button>').join("");
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function renderSupervisorSurface(surface) {
|
|
1575
|
+
const compositeId = surface.plugin_id + ":" + surface.id;
|
|
1576
|
+
const ownerKind = surface.lifecycle_owner === "clauth" ? "ok" : (surface.lifecycle_owner === "plugin" ? "warn" : "");
|
|
1577
|
+
const stateKind = surface.state === "current" || surface.status === "healthy" ? "ok" : (surface.state === "unavailable" ? "bad" : "warn");
|
|
1578
|
+
const openUrl = surface.__pseudo ? null : openUiUrlForSurface(surface);
|
|
1579
|
+
const isSelected = selectedSupervisorSurface && selectedSupervisorSurface.id === compositeId;
|
|
1580
|
+
const rowClass = "supervisor-row" + (surface.__pseudo ? " readonly" : "") + (isSelected ? " selected" : "");
|
|
1581
|
+
const onclick = surface.__pseudo ? "" : ' onclick="selectSupervisorSurface(' + jsArg(compositeId) + ',' + jsArg(surface.name || compositeId) + ')"';
|
|
1582
|
+
const openBtn = surface.__pseudo
|
|
1583
|
+
? '<button class="supervisor-action" onclick="event.stopPropagation();openClauthSelfMcp()" title="Open clauth\\'s own MCP endpoint">Open</button>'
|
|
1584
|
+
: (openUrl ? '<button class="supervisor-action" onclick="event.stopPropagation();window.open(' + jsArg(openUrl) + ',\\'_blank\\')" title="Open this surface\\'s UI">Open UI</button>' : "");
|
|
1585
|
+
return '<div class="' + rowClass + '" data-surface-id="' + htmlEscape(compositeId) + '"' + onclick + '>' +
|
|
1586
|
+
'<div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.name || compositeId) + '</span>' +
|
|
1587
|
+
supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(surface.state || surface.status || "unknown", stateKind) +
|
|
1588
|
+
openBtn +
|
|
1589
|
+
'</div>' +
|
|
1590
|
+
'<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
|
|
1591
|
+
'<div class="supervisor-meta">' + htmlEscape(surface.health || surface.health_url || "no health") + '</div>' +
|
|
1592
|
+
'</div>';
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
async function rescanSupervisorPlugins() {
|
|
1596
|
+
try {
|
|
1597
|
+
await supervisorJson("/v1/plugins/rescan", { method: "POST", headers: writeHeaders() });
|
|
1598
|
+
supervisorFeedback("Plugin rescan completed.", false);
|
|
1599
|
+
await loadSupervisorCockpit();
|
|
1600
|
+
} catch (err) { supervisorFeedback("Plugin rescan failed: " + (err.message || err), true); }
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async function runSupervisorSurface(id, action) {
|
|
1604
|
+
try {
|
|
1605
|
+
const receipt = await supervisorJson("/v1/surfaces/" + encodeURIComponent(id) + "/actions", { method: "POST", headers: writeHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ action }) });
|
|
1606
|
+
const result = receipt.resulting_state || {};
|
|
1607
|
+
supervisorFeedback("Surface " + action + " receipt " + (receipt.operationId || "recorded") + " · " + (result.state || "completed"), result.ok === false);
|
|
1608
|
+
await loadSupervisorCockpit();
|
|
1609
|
+
} catch (err) { supervisorFeedback("Surface " + action + " failed: " + (err.message || err), true); }
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// clauth doesn't manage itself as a plugin, so its own MCP endpoint has no
|
|
1613
|
+
// entry in the real surfaces registry — this reads the vault value directly
|
|
1614
|
+
// (never hardcoded) rather than storing a synthetic surfaces row.
|
|
1615
|
+
async function openClauthSelfMcp() {
|
|
1616
|
+
try {
|
|
1617
|
+
const res = await fetch(BASE + "/v/mcp-clauth-endpoint");
|
|
1618
|
+
const text = (await res.text()).trim();
|
|
1619
|
+
if (!res.ok) {
|
|
1620
|
+
supervisorFeedback("Could not read clauth's own MCP endpoint: " + text, true);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
window.open(text, "_blank");
|
|
1624
|
+
} catch (err) { supervisorFeedback("Could not read clauth's own MCP endpoint: " + (err.message || err), true); }
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1311
1627
|
// ── Unlock ──────────────────────────────────
|
|
1312
1628
|
async function unlock() {
|
|
1313
1629
|
const input = document.getElementById("lock-input");
|
|
@@ -1389,21 +1705,51 @@ async function lockVault() {
|
|
|
1389
1705
|
// ── Unlock writes (re-establish write scope without locking) ──
|
|
1390
1706
|
// Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
|
|
1391
1707
|
// unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1708
|
+
function unlockWrites() {
|
|
1709
|
+
openWriteUnlockModal();
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function openWriteUnlockModal() {
|
|
1713
|
+
const overlay = document.getElementById("write-unlock-overlay");
|
|
1714
|
+
const input = document.getElementById("write-unlock-input");
|
|
1715
|
+
const err = document.getElementById("write-unlock-err");
|
|
1716
|
+
if (err) err.textContent = "";
|
|
1717
|
+
if (input) { input.value = ""; input.className = "lock-input"; }
|
|
1718
|
+
if (overlay) overlay.style.display = "flex";
|
|
1719
|
+
if (input) setTimeout(() => input.focus(), 50);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function closeWriteUnlockModal() {
|
|
1723
|
+
const overlay = document.getElementById("write-unlock-overlay");
|
|
1724
|
+
if (overlay) overlay.style.display = "none";
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
async function submitWriteUnlock() {
|
|
1728
|
+
const input = document.getElementById("write-unlock-input");
|
|
1729
|
+
const btn = document.getElementById("write-unlock-btn");
|
|
1730
|
+
const err = document.getElementById("write-unlock-err");
|
|
1731
|
+
const pw = input ? input.value : "";
|
|
1732
|
+
if (!pw) { if (err) err.textContent = "Password is required."; return; }
|
|
1733
|
+
if (btn) { btn.disabled = true; btn.textContent = "Verifying..."; }
|
|
1396
1734
|
try {
|
|
1397
1735
|
const r = await fetch(BASE + "/auth", {
|
|
1398
1736
|
method: "POST",
|
|
1399
1737
|
headers: { "Content-Type": "application/json" },
|
|
1400
1738
|
body: JSON.stringify({ password: pw }),
|
|
1401
1739
|
}).then(r => r.json());
|
|
1402
|
-
if (r.error) {
|
|
1740
|
+
if (r.error) {
|
|
1741
|
+
if (input) { input.className = "lock-input error"; setTimeout(() => input.className = "lock-input", 600); }
|
|
1742
|
+
if (err) err.textContent = "Invalid: " + (r.error || "Invalid password");
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1403
1745
|
writeToken = r.write_token || null;
|
|
1404
1746
|
refreshWriteLockUi();
|
|
1405
|
-
|
|
1406
|
-
} catch (e) {
|
|
1747
|
+
closeWriteUnlockModal();
|
|
1748
|
+
} catch (e) {
|
|
1749
|
+
if (err) err.textContent = "Unlock error: " + (e.message || e);
|
|
1750
|
+
} finally {
|
|
1751
|
+
if (btn) { btn.disabled = false; btn.textContent = "Unlock Writes"; }
|
|
1752
|
+
}
|
|
1407
1753
|
}
|
|
1408
1754
|
|
|
1409
1755
|
// Reflect write-lock state on the button so it is obvious when a save will fail.
|
|
@@ -1451,124 +1797,6 @@ async function launchCCandMe() {
|
|
|
1451
1797
|
}
|
|
1452
1798
|
}
|
|
1453
1799
|
|
|
1454
|
-
// ── TinTin local CLI dispatch sidebar ──
|
|
1455
|
-
let tintinPollTimer = null;
|
|
1456
|
-
|
|
1457
|
-
function toggleTinTinPanel() {
|
|
1458
|
-
const panel = document.getElementById("tintin-panel");
|
|
1459
|
-
const tab = document.getElementById("tintin-tab");
|
|
1460
|
-
if (!panel) return;
|
|
1461
|
-
panel.classList.toggle("open");
|
|
1462
|
-
if (tab) tab.classList.toggle("active");
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
function fillTinTinSmoke() {
|
|
1466
|
-
const prompt = document.getElementById("tintin-prompt");
|
|
1467
|
-
const app = document.getElementById("tintin-app");
|
|
1468
|
-
if (app) app.value = "clauth-dashboard";
|
|
1469
|
-
if (prompt) prompt.value = "Reply exactly: tintin-dashboard-ok";
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
function setTinTinStatus(text) {
|
|
1473
|
-
const el = document.getElementById("tintin-status");
|
|
1474
|
-
if (el) el.textContent = text || "";
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
function appendTinTinMessage(role, text) {
|
|
1478
|
-
const container = document.getElementById("tintin-messages");
|
|
1479
|
-
if (!container || !text) return;
|
|
1480
|
-
const bubble = document.createElement("div");
|
|
1481
|
-
bubble.style.cssText = role === "user"
|
|
1482
|
-
? "background:#14532d;border:1px solid #166534;border-radius:8px;padding:8px 10px;font-size:.82rem;color:#bbf7d0;white-space:pre-wrap;word-break:break-word;align-self:flex-end;max-width:90%"
|
|
1483
|
-
: "background:#0a1f1a;border:1px solid #134e4a;border-radius:8px;padding:8px 10px;font-size:.82rem;color:#86efac;font-family:'Courier New',monospace;white-space:pre-wrap;word-break:break-word;max-width:95%;max-height:300px;overflow:auto";
|
|
1484
|
-
bubble.textContent = text;
|
|
1485
|
-
container.appendChild(bubble);
|
|
1486
|
-
container.scrollTop = container.scrollHeight;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
function setTinTinResult(text, open) {
|
|
1490
|
-
if (open && text) appendTinTinMessage("assistant", text);
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
async function sendTinTinMessage() {
|
|
1494
|
-
const sendBtn = document.getElementById("tintin-send");
|
|
1495
|
-
const promptEl = document.getElementById("tintin-prompt");
|
|
1496
|
-
const appEl = document.getElementById("tintin-app");
|
|
1497
|
-
const cwdEl = document.getElementById("tintin-cwd");
|
|
1498
|
-
const prompt = (promptEl && promptEl.value || "").trim();
|
|
1499
|
-
const appSlug = (appEl && appEl.value || "clauth-dashboard").trim() || "clauth-dashboard";
|
|
1500
|
-
const cwd = (cwdEl && cwdEl.value || "").trim();
|
|
1501
|
-
if (!prompt) {
|
|
1502
|
-
setTinTinStatus("prompt required");
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
if (tintinPollTimer) {
|
|
1506
|
-
clearTimeout(tintinPollTimer);
|
|
1507
|
-
tintinPollTimer = null;
|
|
1508
|
-
}
|
|
1509
|
-
const jobId = "tintin-" + Date.now();
|
|
1510
|
-
const body = {
|
|
1511
|
-
prompt,
|
|
1512
|
-
job_id: jobId,
|
|
1513
|
-
cwd,
|
|
1514
|
-
agent_context: {
|
|
1515
|
-
app: { slug: appSlug, route: "/clauth-dashboard", origin: window.location.origin },
|
|
1516
|
-
repo: { root: cwd, cwd },
|
|
1517
|
-
runtime: { agent: "clauth-cli", requested_by: "clauth-dashboard", model: "claude-sonnet-4-6" },
|
|
1518
|
-
task: { intent: "general_chat", thread_id: jobId }
|
|
1519
|
-
}
|
|
1520
|
-
};
|
|
1521
|
-
|
|
1522
|
-
appendTinTinMessage("user", prompt);
|
|
1523
|
-
if (promptEl) promptEl.value = "";
|
|
1524
|
-
if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = "Dispatching..."; }
|
|
1525
|
-
setTinTinStatus("dispatching");
|
|
1526
|
-
try {
|
|
1527
|
-
const response = await fetch(BASE + "/tintin/dispatch", {
|
|
1528
|
-
method: "POST",
|
|
1529
|
-
headers: { "Content-Type": "application/json" },
|
|
1530
|
-
body: JSON.stringify(body)
|
|
1531
|
-
});
|
|
1532
|
-
const result = await response.json();
|
|
1533
|
-
if (!response.ok || result.error) {
|
|
1534
|
-
setTinTinStatus("dispatch failed");
|
|
1535
|
-
setTinTinResult(JSON.stringify(result, null, 2), true);
|
|
1536
|
-
return;
|
|
1537
|
-
}
|
|
1538
|
-
setTinTinStatus("spawned pid " + result.pid + " - polling");
|
|
1539
|
-
pollTinTinJob(jobId, 0);
|
|
1540
|
-
} catch (err) {
|
|
1541
|
-
setTinTinStatus("dispatch error");
|
|
1542
|
-
setTinTinResult(err && err.message ? err.message : String(err), true);
|
|
1543
|
-
} finally {
|
|
1544
|
-
if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = "Dispatch"; }
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
async function pollTinTinJob(jobId, attempt) {
|
|
1549
|
-
try {
|
|
1550
|
-
const response = await fetch(BASE + "/tintin/dispatch/" + encodeURIComponent(jobId), { cache: "no-store" });
|
|
1551
|
-
const job = await response.json();
|
|
1552
|
-
if (!response.ok || job.error) {
|
|
1553
|
-
setTinTinStatus("job lookup failed");
|
|
1554
|
-
setTinTinResult(JSON.stringify(job, null, 2), true);
|
|
1555
|
-
return;
|
|
1556
|
-
}
|
|
1557
|
-
if (job.status === "running" && attempt < 60) {
|
|
1558
|
-
setTinTinStatus("running pid " + (job.pid || "?") + " - " + (attempt + 1));
|
|
1559
|
-
tintinPollTimer = setTimeout(function() { pollTinTinJob(jobId, attempt + 1); }, 1500);
|
|
1560
|
-
return;
|
|
1561
|
-
}
|
|
1562
|
-
setTinTinStatus(job.status === "completed" ? "done" : job.status);
|
|
1563
|
-
const out = (job.stdout || "").trim();
|
|
1564
|
-
if (out) appendTinTinMessage("assistant", out);
|
|
1565
|
-
if (job.stderr && job.stderr.trim()) appendTinTinMessage("assistant", "stderr: " + job.stderr.trim());
|
|
1566
|
-
} catch (err) {
|
|
1567
|
-
setTinTinStatus("poll error");
|
|
1568
|
-
setTinTinResult(err && err.message ? err.message : String(err), true);
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
1800
|
// ── Restart daemon (keeps boot.key — vault stays unlocked) ──
|
|
1573
1801
|
async function restartDaemon() {
|
|
1574
1802
|
if (!confirm("Restart the daemon?\\n\\nThe vault will stay unlocked (boot.key kept).")) return;
|
|
@@ -1640,8 +1868,16 @@ function switchProjectTab(key) {
|
|
|
1640
1868
|
renderServiceGrid(allServices);
|
|
1641
1869
|
}
|
|
1642
1870
|
|
|
1871
|
+
const MCP_SURFACE_DUPLICATE_SERVICES = new Set([
|
|
1872
|
+
"mcp-clauth-endpoint",
|
|
1873
|
+
"mcp-fs-endpoint",
|
|
1874
|
+
"mcp-regen-media-endpoint",
|
|
1875
|
+
"mcp-web-research-endpoint",
|
|
1876
|
+
]);
|
|
1877
|
+
|
|
1643
1878
|
function renderServiceGrid(services) {
|
|
1644
1879
|
const grid = document.getElementById("grid");
|
|
1880
|
+
services = services.filter(s => !MCP_SURFACE_DUPLICATE_SERVICES.has(s.name));
|
|
1645
1881
|
let filtered = services;
|
|
1646
1882
|
if (activeProjectTab === "unassigned") {
|
|
1647
1883
|
filtered = services.filter(s => !s.project);
|
|
@@ -3034,11 +3270,6 @@ setInterval(async () => {
|
|
|
3034
3270
|
}, 5000);
|
|
3035
3271
|
|
|
3036
3272
|
boot();
|
|
3037
|
-
|
|
3038
|
-
const tintinSidebarScript = document.createElement("script");
|
|
3039
|
-
tintinSidebarScript.src = "/tintin/sidebar.user.js";
|
|
3040
|
-
tintinSidebarScript.defer = true;
|
|
3041
|
-
document.body.appendChild(tintinSidebarScript);
|
|
3042
3273
|
</script>
|
|
3043
3274
|
</body>
|
|
3044
3275
|
</html>`;
|
|
@@ -3061,619 +3292,64 @@ function clauthConfigDir() {
|
|
|
3061
3292
|
return path.join(os.homedir(), ".config", "clauth");
|
|
3062
3293
|
}
|
|
3063
3294
|
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
agents: {
|
|
3079
|
-
claude: {
|
|
3080
|
-
runtime: "claude",
|
|
3081
|
-
model: process.env.CLAUTH_TINTIN_CLAUDE_MODEL || "claude-sonnet-4-6",
|
|
3082
|
-
command: process.env.CLAUDE_BIN || "claude",
|
|
3083
|
-
enabled: true,
|
|
3084
|
-
},
|
|
3085
|
-
codex: {
|
|
3086
|
-
runtime: "codex",
|
|
3087
|
-
model: process.env.CLAUTH_TINTIN_CODEX_MODEL || "gpt-5-codex",
|
|
3088
|
-
command: process.env.CODEX_BIN || "codex",
|
|
3089
|
-
enabled: true,
|
|
3090
|
-
},
|
|
3091
|
-
},
|
|
3092
|
-
},
|
|
3093
|
-
sidebar_settings: {
|
|
3094
|
-
enabled: true,
|
|
3095
|
-
default_app_slug: "tintin-console",
|
|
3096
|
-
session_ttl_hours: 24,
|
|
3097
|
-
event_history_limit: 200,
|
|
3098
|
-
},
|
|
3099
|
-
dispatch_settings: {
|
|
3100
|
-
enabled: true,
|
|
3101
|
-
default_agent: "claude",
|
|
3102
|
-
default_model: process.env.CLAUTH_TINTIN_CLAUDE_MODEL || "claude-sonnet-4-6",
|
|
3103
|
-
max_concurrent_workers: 2,
|
|
3104
|
-
},
|
|
3105
|
-
codevelop_settings: {
|
|
3106
|
-
enabled: true,
|
|
3107
|
-
default_repo: process.env.CLAUTH_TINTIN_REPO_ROOT || "C:\\Dev\\regen-root",
|
|
3108
|
-
message_retention_limit: 500,
|
|
3109
|
-
require_peer_join: true,
|
|
3110
|
-
},
|
|
3111
|
-
agent_sessions: [],
|
|
3112
|
-
apps: [
|
|
3113
|
-
{
|
|
3114
|
-
slug: "rdc-marketing-engine",
|
|
3115
|
-
origins: [
|
|
3116
|
-
"http://localhost:3000",
|
|
3117
|
-
"http://127.0.0.1:3000",
|
|
3118
|
-
"https://app.regendevcorp.com",
|
|
3119
|
-
"https://rdc-marketing-engine.dev.regendevcorp.com",
|
|
3120
|
-
],
|
|
3121
|
-
manifest_url: "https://app.regendevcorp.com/.well-known/monkey.json",
|
|
3122
|
-
cwd: "C:\\Dev\\regen-root",
|
|
3123
|
-
capabilities: ["general_chat", "skill_request", "handoff"],
|
|
3124
|
-
},
|
|
3125
|
-
],
|
|
3126
|
-
};
|
|
3127
|
-
}
|
|
3128
|
-
|
|
3129
|
-
function mergeTinTinAgentSettings(input = {}) {
|
|
3130
|
-
const defaults = defaultTinTinConfig().agent_settings;
|
|
3131
|
-
const incoming = input && typeof input === "object" ? input : {};
|
|
3132
|
-
return {
|
|
3133
|
-
...defaults,
|
|
3134
|
-
...incoming,
|
|
3135
|
-
agents: {
|
|
3136
|
-
...defaults.agents,
|
|
3137
|
-
...(incoming.agents && typeof incoming.agents === "object" ? incoming.agents : {}),
|
|
3138
|
-
},
|
|
3139
|
-
};
|
|
3140
|
-
}
|
|
3141
|
-
|
|
3142
|
-
function mergeTinTinSectionSettings(sectionName, input = {}) {
|
|
3143
|
-
const defaults = defaultTinTinConfig()[sectionName] || {};
|
|
3144
|
-
const incoming = input && typeof input === "object" ? input : {};
|
|
3145
|
-
return { ...defaults, ...incoming };
|
|
3146
|
-
}
|
|
3147
|
-
|
|
3148
|
-
function normalizeTinTinConfig(input = {}) {
|
|
3149
|
-
const defaults = defaultTinTinConfig();
|
|
3150
|
-
return {
|
|
3151
|
-
schema_version: Math.max(Number(input.schema_version || 0), defaults.schema_version),
|
|
3152
|
-
trust_mode: typeof input.trust_mode === "string" ? input.trust_mode : defaults.trust_mode,
|
|
3153
|
-
agent_settings: mergeTinTinAgentSettings(input.agent_settings),
|
|
3154
|
-
sidebar_settings: mergeTinTinSectionSettings("sidebar_settings", input.sidebar_settings),
|
|
3155
|
-
dispatch_settings: mergeTinTinSectionSettings("dispatch_settings", input.dispatch_settings),
|
|
3156
|
-
codevelop_settings: mergeTinTinSectionSettings("codevelop_settings", input.codevelop_settings),
|
|
3157
|
-
agent_sessions: Array.isArray(input.agent_sessions) ? input.agent_sessions : defaults.agent_sessions,
|
|
3158
|
-
apps: Array.isArray(input.apps) ? input.apps : defaults.apps,
|
|
3159
|
-
};
|
|
3160
|
-
}
|
|
3161
|
-
|
|
3162
|
-
function loadTinTinConfig() {
|
|
3163
|
-
const configPath = tintinConfigPath();
|
|
3164
|
-
try {
|
|
3165
|
-
if (!fs.existsSync(configPath)) return { ...normalizeTinTinConfig(), path: configPath, exists: false };
|
|
3166
|
-
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
3167
|
-
return {
|
|
3168
|
-
...normalizeTinTinConfig(parsed),
|
|
3169
|
-
path: configPath,
|
|
3170
|
-
exists: true,
|
|
3171
|
-
};
|
|
3172
|
-
} catch (err) {
|
|
3173
|
-
return {
|
|
3174
|
-
...normalizeTinTinConfig(),
|
|
3175
|
-
path: configPath,
|
|
3176
|
-
exists: false,
|
|
3177
|
-
error: err.message,
|
|
3178
|
-
};
|
|
3295
|
+
// ── call_agent noauth-host guard (Gate B, bearer-gated tunnel) ───────────────
|
|
3296
|
+
// Pure decision function so the policy is testable without a daemon. Decides
|
|
3297
|
+
// whether a /call-agent request may proceed.
|
|
3298
|
+
// - Browser Origin → reject (CSRF), unchanged, regardless of host/token.
|
|
3299
|
+
// - Local (127.0.0.1, noAuthHost=false) → allow without a token, as today.
|
|
3300
|
+
// - Noauth tunnel host (clauth.regendevcorp.com etc.) → allow ONLY when the
|
|
3301
|
+
// request carries `Authorization: Bearer <expectedToken>` and the token is
|
|
3302
|
+
// a non-empty exact match. Missing/wrong bearer → reject (unchanged
|
|
3303
|
+
// unauthenticated behaviour). If no expectedToken is configured server-side,
|
|
3304
|
+
// the noauth host stays fully closed (cannot be unlocked by any bearer).
|
|
3305
|
+
// Returns { allow: true } or { allow: false, status, error }.
|
|
3306
|
+
export function evaluateCallAgentGuard({ origin, noAuthHost, authHeader, expectedToken }) {
|
|
3307
|
+
if (origin) {
|
|
3308
|
+
return { allow: false, status: 403, error: "call_agent_rejects_browser_origin", origin };
|
|
3179
3309
|
}
|
|
3310
|
+
if (!noAuthHost) {
|
|
3311
|
+
return { allow: true };
|
|
3312
|
+
}
|
|
3313
|
+
// Noauth tunnel host: require a valid bearer token.
|
|
3314
|
+
const presented = typeof authHeader === "string"
|
|
3315
|
+
? (authHeader.match(/^Bearer\s+(.+)$/i)?.[1] || "").trim()
|
|
3316
|
+
: "";
|
|
3317
|
+
if (expectedToken && presented && presented === expectedToken) {
|
|
3318
|
+
return { allow: true };
|
|
3319
|
+
}
|
|
3320
|
+
return { allow: false, status: 403, error: "call_agent_not_available_on_noauth_host" };
|
|
3180
3321
|
}
|
|
3181
3322
|
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
3186
|
-
fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
3187
|
-
return { ...next, path: configPath, exists: true };
|
|
3188
|
-
}
|
|
3189
|
-
|
|
3190
|
-
function slugPart(value, fallback = "session") {
|
|
3191
|
-
return String(value || fallback).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || fallback;
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
function assertInside(parent, child) {
|
|
3195
|
-
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
3196
|
-
return rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
3197
|
-
}
|
|
3323
|
+
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
3324
|
+
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
|
|
3325
|
+
mcpHttpBaseUrl = `http://127.0.0.1:${port}`;
|
|
3198
3326
|
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
return execSyncTop("git rev-parse --show-toplevel", {
|
|
3202
|
-
cwd: repoRoot,
|
|
3203
|
-
encoding: "utf8",
|
|
3204
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3205
|
-
windowsHide: true,
|
|
3206
|
-
}).trim();
|
|
3207
|
-
} catch {
|
|
3208
|
-
return null;
|
|
3209
|
-
}
|
|
3210
|
-
}
|
|
3327
|
+
// tunnelHostname may be updated at runtime (fetched from DB after unlock)
|
|
3328
|
+
let tunnelHostname = tunnelHostnameInit;
|
|
3211
3329
|
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
const repoRoot = path.resolve(String(input.repo_root || settings.default_repo_root || settings.cwd || process.cwd()));
|
|
3221
|
-
const top = gitTopLevel(repoRoot);
|
|
3222
|
-
if (!top) return { ok: false, error: "not_git_repo", repo_root: repoRoot };
|
|
3223
|
-
|
|
3224
|
-
const isolation = String(input.isolation || settings.isolation || "worktree");
|
|
3225
|
-
const baseBranch = slugPart(input.base_branch || settings.base_branch || "develop", "develop");
|
|
3226
|
-
let cwd = top;
|
|
3227
|
-
let branch = null;
|
|
3228
|
-
let worktreePath = null;
|
|
3229
|
-
let worktreeCreated = false;
|
|
3230
|
-
const commands = [];
|
|
3231
|
-
|
|
3232
|
-
if (isolation === "worktree") {
|
|
3233
|
-
const worktreeRoot = path.resolve(String(input.worktree_root || settings.worktree_root || path.join(path.dirname(top), `${path.basename(top)}.wt`)));
|
|
3234
|
-
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
3235
|
-
worktreePath = path.join(worktreeRoot, `tintin-${agentKey}-${sessionId}`);
|
|
3236
|
-
if (!assertInside(worktreeRoot, worktreePath)) return { ok: false, error: "worktree_path_escape", worktree_root: worktreeRoot, worktree_path: worktreePath };
|
|
3237
|
-
branch = `wt/tintin/${agentKey}/${sessionId}`;
|
|
3238
|
-
if (!fs.existsSync(worktreePath)) {
|
|
3239
|
-
execSyncTop(`git worktree add -b ${JSON.stringify(branch)} ${JSON.stringify(worktreePath)} ${JSON.stringify(baseBranch)}`, {
|
|
3240
|
-
cwd: top,
|
|
3241
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
3242
|
-
timeout: 60_000,
|
|
3243
|
-
windowsHide: true,
|
|
3244
|
-
});
|
|
3245
|
-
worktreeCreated = true;
|
|
3246
|
-
commands.push(`git worktree add -b ${branch} ${worktreePath} ${baseBranch}`);
|
|
3330
|
+
// Ensure Windows system tools are reachable (bash shells may lack these on PATH)
|
|
3331
|
+
if (os.platform() === "win32") {
|
|
3332
|
+
const sys32 = "C:\\Windows\\System32";
|
|
3333
|
+
if (!process.env.PATH?.includes(sys32 + "\\Wbem")) {
|
|
3334
|
+
process.env.PATH = (process.env.PATH || "") + ";" + sys32 + "\\Wbem";
|
|
3335
|
+
}
|
|
3336
|
+
if (!process.env.PATH?.includes(sys32 + ";") && !process.env.PATH?.endsWith(sys32)) {
|
|
3337
|
+
process.env.PATH = (process.env.PATH || "") + ";" + sys32;
|
|
3247
3338
|
}
|
|
3248
|
-
cwd = worktreePath;
|
|
3249
|
-
} else if (isolation !== "cwd") {
|
|
3250
|
-
return { ok: false, error: "unsupported_isolation", isolation };
|
|
3251
3339
|
}
|
|
3340
|
+
const MAX_FAILS = 10;
|
|
3341
|
+
let failCount = 0;
|
|
3342
|
+
const MAX_AUTH_FAILS = 10;
|
|
3343
|
+
let authFailCount = 0;
|
|
3252
3344
|
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
const
|
|
3256
|
-
const
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
const
|
|
3261
|
-
ok: true,
|
|
3262
|
-
agent_session_id: sessionId,
|
|
3263
|
-
agent: agentKey,
|
|
3264
|
-
runtime,
|
|
3265
|
-
model,
|
|
3266
|
-
isolation,
|
|
3267
|
-
repo_root: top,
|
|
3268
|
-
cwd,
|
|
3269
|
-
worktree_root: isolation === "worktree" ? path.dirname(worktreePath) : null,
|
|
3270
|
-
worktree_path: worktreePath,
|
|
3271
|
-
worktree_created: worktreeCreated,
|
|
3272
|
-
branch,
|
|
3273
|
-
base_branch: baseBranch,
|
|
3274
|
-
launch_mode: settings.launch_mode || "setup_only",
|
|
3275
|
-
launch_command: launchCommand,
|
|
3276
|
-
commands,
|
|
3277
|
-
};
|
|
3278
|
-
const sessionRecord = {
|
|
3279
|
-
agent_session_id: result.agent_session_id,
|
|
3280
|
-
agent: result.agent,
|
|
3281
|
-
runtime: result.runtime,
|
|
3282
|
-
model: result.model,
|
|
3283
|
-
isolation: result.isolation,
|
|
3284
|
-
repo_root: result.repo_root,
|
|
3285
|
-
cwd: result.cwd,
|
|
3286
|
-
worktree_path: result.worktree_path,
|
|
3287
|
-
branch: result.branch,
|
|
3288
|
-
base_branch: result.base_branch,
|
|
3289
|
-
launch_mode: result.launch_mode,
|
|
3290
|
-
created_at: new Date().toISOString(),
|
|
3291
|
-
};
|
|
3292
|
-
const sessions = [sessionRecord, ...(Array.isArray(config.agent_sessions) ? config.agent_sessions : [])
|
|
3293
|
-
.filter((item) => item && item.agent_session_id !== sessionId)]
|
|
3294
|
-
.slice(0, 100);
|
|
3295
|
-
saveTinTinConfig({ ...config, agent_sessions: sessions });
|
|
3296
|
-
return result;
|
|
3297
|
-
}
|
|
3298
|
-
|
|
3299
|
-
function isLoopbackOrigin(origin) {
|
|
3300
|
-
if (!origin) return false;
|
|
3301
|
-
try {
|
|
3302
|
-
const parsed = new URL(origin);
|
|
3303
|
-
return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
|
|
3304
|
-
} catch {
|
|
3305
|
-
return false;
|
|
3306
|
-
}
|
|
3307
|
-
}
|
|
3308
|
-
|
|
3309
|
-
function tintinAppForOrigin(config, origin) {
|
|
3310
|
-
if (!origin) return null;
|
|
3311
|
-
return (config.apps || []).find((app) => (
|
|
3312
|
-
Array.isArray(app.origins) && app.origins.some((allowed) => allowed === origin || allowed === "*")
|
|
3313
|
-
)) || null;
|
|
3314
|
-
}
|
|
3315
|
-
|
|
3316
|
-
function checkTinTinBrowserAccess(req) {
|
|
3317
|
-
const config = loadTinTinConfig();
|
|
3318
|
-
const origin = req.headers.origin || "";
|
|
3319
|
-
const trustMode = config.trust_mode || "open-local";
|
|
3320
|
-
const matchedApp = tintinAppForOrigin(config, origin);
|
|
3321
|
-
|
|
3322
|
-
if (trustMode === "disabled") {
|
|
3323
|
-
return { allowed: false, config, reason: "monkey_disabled" };
|
|
3324
|
-
}
|
|
3325
|
-
if (!origin) {
|
|
3326
|
-
return { allowed: true, config, app: null, reason: "non_browser_local" };
|
|
3327
|
-
}
|
|
3328
|
-
if (matchedApp) {
|
|
3329
|
-
return { allowed: true, config, app: matchedApp, reason: "configured_origin" };
|
|
3330
|
-
}
|
|
3331
|
-
if (trustMode === "open-local" && isLoopbackOrigin(origin)) {
|
|
3332
|
-
return { allowed: true, config, app: null, reason: "open_local_loopback" };
|
|
3333
|
-
}
|
|
3334
|
-
return { allowed: false, config, reason: "origin_not_allowed", origin };
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
function publicTinTinConfig(config) {
|
|
3338
|
-
return {
|
|
3339
|
-
schema_version: config.schema_version || 1,
|
|
3340
|
-
trust_mode: config.trust_mode || "open-local",
|
|
3341
|
-
config_path: config.path,
|
|
3342
|
-
config_exists: !!config.exists,
|
|
3343
|
-
config_error: config.error || null,
|
|
3344
|
-
agent_settings: mergeTinTinAgentSettings(config.agent_settings),
|
|
3345
|
-
sidebar_settings: mergeTinTinSectionSettings("sidebar_settings", config.sidebar_settings),
|
|
3346
|
-
dispatch_settings: mergeTinTinSectionSettings("dispatch_settings", config.dispatch_settings),
|
|
3347
|
-
codevelop_settings: mergeTinTinSectionSettings("codevelop_settings", config.codevelop_settings),
|
|
3348
|
-
agent_sessions: Array.isArray(config.agent_sessions) ? config.agent_sessions : [],
|
|
3349
|
-
apps: (config.apps || []).map((app) => ({
|
|
3350
|
-
slug: app.slug,
|
|
3351
|
-
origins: app.origins || [],
|
|
3352
|
-
manifest_url: app.manifest_url || null,
|
|
3353
|
-
cwd: app.cwd || null,
|
|
3354
|
-
capabilities: app.capabilities || [],
|
|
3355
|
-
})),
|
|
3356
|
-
};
|
|
3357
|
-
}
|
|
3358
|
-
|
|
3359
|
-
function tintinSettingsHtml() {
|
|
3360
|
-
return `<!DOCTYPE html>
|
|
3361
|
-
<html lang="en">
|
|
3362
|
-
<head>
|
|
3363
|
-
<meta charset="utf-8">
|
|
3364
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3365
|
-
<title>clauth TinTin Settings</title>
|
|
3366
|
-
<style>
|
|
3367
|
-
body{margin:0;background:#07100e;color:#d9fbe8;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
|
|
3368
|
-
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #14352f;background:#091814}
|
|
3369
|
-
h1{font-size:20px;margin:0}.sub{font-size:12px;color:#8dd8c2;margin-top:3px}.pill{display:inline-flex;align-items:center;border:1px solid #14532d;color:#86efac;padding:2px 7px;font-size:11px;margin-top:7px}
|
|
3370
|
-
main{display:grid;grid-template-columns:minmax(300px,430px) 1fr;gap:14px;padding:14px}
|
|
3371
|
-
.stack{display:grid;gap:14px}.wide{display:grid;gap:14px}
|
|
3372
|
-
section{border:1px solid #14352f;background:#091814;padding:14px}
|
|
3373
|
-
h2{font-size:15px;margin:0 0 10px;color:#dcfce7}
|
|
3374
|
-
label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#8dd8c2;font-weight:700;margin:10px 0 4px}
|
|
3375
|
-
input,select,textarea{box-sizing:border-box;width:100%;border:1px solid #1d4d43;background:#031312;color:#d9fbe8;padding:8px 9px;font:13px ui-monospace,SFMono-Regular,Consolas,monospace;outline:none}
|
|
3376
|
-
textarea{min-height:170px;resize:vertical}.short{min-height:92px}.tiny{min-height:58px}
|
|
3377
|
-
.row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
|
3378
|
-
button,a.btn{border:1px solid #2d6a5e;background:#08231f;color:#b8ffe1;padding:7px 10px;font-size:12px;font-weight:700;text-decoration:none;cursor:pointer}
|
|
3379
|
-
button:hover,a.btn:hover{background:#0f2a24}.danger{border-color:#5f3b3b;color:#fecaca;background:#241010}
|
|
3380
|
-
pre{white-space:pre-wrap;word-break:break-word;border:1px solid #0d2a25;background:#020b09;color:#c8f9df;padding:10px;max-height:460px;overflow:auto;font-size:12px}
|
|
3381
|
-
.status{font:12px ui-monospace,SFMono-Regular,Consolas,monospace;color:#86efac}.hint{font-size:12px;color:#7dd3c0;line-height:1.4}.check{display:flex;align-items:center;gap:8px;margin-top:10px;color:#d9fbe8;font-size:13px}.check input{width:auto}
|
|
3382
|
-
@media(max-width:900px){main{grid-template-columns:1fr}.row{grid-template-columns:1fr}}
|
|
3383
|
-
</style>
|
|
3384
|
-
</head>
|
|
3385
|
-
<body>
|
|
3386
|
-
<header>
|
|
3387
|
-
<div><h1>TinTin Settings</h1><div class="sub">Persistent clauth control plane for agent setup, app sidebars, blackboard dispatch, and co-develop relay.</div><div class="pill">GET/PUT /tintin/settings</div></div>
|
|
3388
|
-
<a class="btn" href="/">Vault Dashboard</a>
|
|
3389
|
-
</header>
|
|
3390
|
-
<main>
|
|
3391
|
-
<div class="stack">
|
|
3392
|
-
<section>
|
|
3393
|
-
<h2>Agent Setup</h2>
|
|
3394
|
-
<div class="hint">Creates a proper isolated cwd for Claude or Codex. Worktree setup is explicit and returned before any supervisor launches a process.</div>
|
|
3395
|
-
<div class="row">
|
|
3396
|
-
<div><label>agent</label><select id="agent"><option value="claude">claude</option><option value="codex">codex</option></select></div>
|
|
3397
|
-
<div><label>isolation</label><select id="isolation"><option value="worktree">worktree</option><option value="cwd">cwd</option></select></div>
|
|
3398
|
-
</div>
|
|
3399
|
-
<label>repo root</label><input id="repoRoot">
|
|
3400
|
-
<label>worktree root</label><input id="worktreeRoot">
|
|
3401
|
-
<div class="row">
|
|
3402
|
-
<div><label>base branch</label><input id="baseBranch"></div>
|
|
3403
|
-
<div><label>session id</label><input id="sessionId" placeholder="auto if blank"></div>
|
|
3404
|
-
</div>
|
|
3405
|
-
<div class="actions">
|
|
3406
|
-
<button onclick="loadSettings()" title="GET /tintin/settings">Load</button>
|
|
3407
|
-
<button onclick="saveSettings()" title="PUT /tintin/settings">Save</button>
|
|
3408
|
-
<button onclick="setupAgent()" title="POST /tintin/agent-sessions">Setup Agent</button>
|
|
3409
|
-
</div>
|
|
3410
|
-
<p class="status" id="status">idle</p>
|
|
3411
|
-
</section>
|
|
3412
|
-
<section>
|
|
3413
|
-
<h2>Stored Agent Sessions</h2>
|
|
3414
|
-
<pre id="agentSessions">No sessions loaded.</pre>
|
|
3415
|
-
</section>
|
|
3416
|
-
<section>
|
|
3417
|
-
<h2>Return</h2>
|
|
3418
|
-
<pre id="result">No return yet.</pre>
|
|
3419
|
-
</section>
|
|
3420
|
-
</div>
|
|
3421
|
-
<div class="wide">
|
|
3422
|
-
<section>
|
|
3423
|
-
<h2>Agent Defaults</h2>
|
|
3424
|
-
<div class="row">
|
|
3425
|
-
<div><label>Claude model</label><input id="claudeModel"></div>
|
|
3426
|
-
<div><label>Codex model</label><input id="codexModel"></div>
|
|
3427
|
-
</div>
|
|
3428
|
-
<div class="row">
|
|
3429
|
-
<div><label>Claude command</label><input id="claudeCommand"></div>
|
|
3430
|
-
<div><label>Codex command</label><input id="codexCommand"></div>
|
|
3431
|
-
</div>
|
|
3432
|
-
<div class="row">
|
|
3433
|
-
<label class="check"><input type="checkbox" id="claudeEnabled"> Claude enabled</label>
|
|
3434
|
-
<label class="check"><input type="checkbox" id="codexEnabled"> Codex enabled</label>
|
|
3435
|
-
</div>
|
|
3436
|
-
</section>
|
|
3437
|
-
<section>
|
|
3438
|
-
<h2>Sidebar Apps</h2>
|
|
3439
|
-
<div class="row">
|
|
3440
|
-
<div><label>trust mode</label><select id="trustMode"><option value="open-local">open-local</option><option value="configured-origins">configured-origins</option><option value="disabled">disabled</option></select></div>
|
|
3441
|
-
<div><label>default app slug</label><input id="defaultAppSlug"></div>
|
|
3442
|
-
</div>
|
|
3443
|
-
<div class="row">
|
|
3444
|
-
<div><label>session ttl hours</label><input id="sessionTtlHours" type="number" min="1" step="1"></div>
|
|
3445
|
-
<div><label>event history limit</label><input id="eventHistoryLimit" type="number" min="1" step="1"></div>
|
|
3446
|
-
</div>
|
|
3447
|
-
<label class="check"><input type="checkbox" id="sidebarEnabled"> Sidebar sessions enabled</label>
|
|
3448
|
-
<label>apps JSON</label><textarea class="short" id="appsJson" spellcheck="false"></textarea>
|
|
3449
|
-
</section>
|
|
3450
|
-
<section>
|
|
3451
|
-
<h2>Blackboard Dispatch</h2>
|
|
3452
|
-
<div class="row">
|
|
3453
|
-
<div><label>default agent</label><select id="dispatchDefaultAgent"><option value="claude">claude</option><option value="codex">codex</option></select></div>
|
|
3454
|
-
<div><label>default model</label><input id="dispatchDefaultModel"></div>
|
|
3455
|
-
</div>
|
|
3456
|
-
<div class="row">
|
|
3457
|
-
<div><label>max workers</label><input id="dispatchMaxWorkers" type="number" min="1" step="1"></div>
|
|
3458
|
-
<label class="check"><input type="checkbox" id="dispatchEnabled"> Dispatch enabled</label>
|
|
3459
|
-
</div>
|
|
3460
|
-
</section>
|
|
3461
|
-
<section>
|
|
3462
|
-
<h2>Co-develop Relay</h2>
|
|
3463
|
-
<div class="row">
|
|
3464
|
-
<div><label>default repo</label><input id="codevelopDefaultRepo"></div>
|
|
3465
|
-
<div><label>message retention</label><input id="codevelopRetention" type="number" min="1" step="1"></div>
|
|
3466
|
-
</div>
|
|
3467
|
-
<div class="row">
|
|
3468
|
-
<label class="check"><input type="checkbox" id="codevelopEnabled"> Co-develop enabled</label>
|
|
3469
|
-
<label class="check"><input type="checkbox" id="codevelopRequirePeer"> Require peer join</label>
|
|
3470
|
-
</div>
|
|
3471
|
-
</section>
|
|
3472
|
-
<section>
|
|
3473
|
-
<h2>Settings JSON</h2>
|
|
3474
|
-
<textarea id="settingsJson" spellcheck="false"></textarea>
|
|
3475
|
-
</section>
|
|
3476
|
-
</div>
|
|
3477
|
-
</main>
|
|
3478
|
-
<script>
|
|
3479
|
-
const BASE = location.origin;
|
|
3480
|
-
let currentSettings = {};
|
|
3481
|
-
function setStatus(text){ document.getElementById("status").textContent = text; }
|
|
3482
|
-
function show(value){ document.getElementById("result").textContent = typeof value === "string" ? value : JSON.stringify(value,null,2); }
|
|
3483
|
-
function num(id, fallback){ const n = Number(document.getElementById(id).value); return Number.isFinite(n) ? n : fallback; }
|
|
3484
|
-
function applySettings(data){
|
|
3485
|
-
const cfg = data.agent_settings ? data : data.config || data;
|
|
3486
|
-
currentSettings = cfg;
|
|
3487
|
-
const s = cfg.agent_settings || {};
|
|
3488
|
-
const side = cfg.sidebar_settings || {};
|
|
3489
|
-
const dispatch = cfg.dispatch_settings || {};
|
|
3490
|
-
const codevelop = cfg.codevelop_settings || {};
|
|
3491
|
-
const agents = s.agents || {};
|
|
3492
|
-
const claude = agents.claude || {};
|
|
3493
|
-
const codex = agents.codex || {};
|
|
3494
|
-
document.getElementById("trustMode").value = cfg.trust_mode || "open-local";
|
|
3495
|
-
document.getElementById("repoRoot").value = s.default_repo_root || "";
|
|
3496
|
-
document.getElementById("worktreeRoot").value = s.worktree_root || "";
|
|
3497
|
-
document.getElementById("baseBranch").value = s.base_branch || "develop";
|
|
3498
|
-
document.getElementById("isolation").value = s.isolation || "worktree";
|
|
3499
|
-
document.getElementById("claudeModel").value = claude.model || "";
|
|
3500
|
-
document.getElementById("codexModel").value = codex.model || "";
|
|
3501
|
-
document.getElementById("claudeCommand").value = claude.command || "claude";
|
|
3502
|
-
document.getElementById("codexCommand").value = codex.command || "codex";
|
|
3503
|
-
document.getElementById("claudeEnabled").checked = claude.enabled !== false;
|
|
3504
|
-
document.getElementById("codexEnabled").checked = codex.enabled !== false;
|
|
3505
|
-
document.getElementById("sidebarEnabled").checked = side.enabled !== false;
|
|
3506
|
-
document.getElementById("defaultAppSlug").value = side.default_app_slug || "";
|
|
3507
|
-
document.getElementById("sessionTtlHours").value = side.session_ttl_hours || 24;
|
|
3508
|
-
document.getElementById("eventHistoryLimit").value = side.event_history_limit || 200;
|
|
3509
|
-
document.getElementById("appsJson").value = JSON.stringify(cfg.apps || [],null,2);
|
|
3510
|
-
document.getElementById("dispatchEnabled").checked = dispatch.enabled !== false;
|
|
3511
|
-
document.getElementById("dispatchDefaultAgent").value = dispatch.default_agent || "claude";
|
|
3512
|
-
document.getElementById("dispatchDefaultModel").value = dispatch.default_model || "";
|
|
3513
|
-
document.getElementById("dispatchMaxWorkers").value = dispatch.max_concurrent_workers || 2;
|
|
3514
|
-
document.getElementById("codevelopEnabled").checked = codevelop.enabled !== false;
|
|
3515
|
-
document.getElementById("codevelopDefaultRepo").value = codevelop.default_repo || "";
|
|
3516
|
-
document.getElementById("codevelopRetention").value = codevelop.message_retention_limit || 500;
|
|
3517
|
-
document.getElementById("codevelopRequirePeer").checked = codevelop.require_peer_join !== false;
|
|
3518
|
-
document.getElementById("agentSessions").textContent = JSON.stringify(cfg.agent_sessions || [],null,2);
|
|
3519
|
-
document.getElementById("settingsJson").value = JSON.stringify(cfg,null,2);
|
|
3520
|
-
}
|
|
3521
|
-
function collectSettings(){
|
|
3522
|
-
let apps;
|
|
3523
|
-
try { apps = JSON.parse(document.getElementById("appsJson").value || "[]"); }
|
|
3524
|
-
catch(e){ throw new Error("apps JSON: " + e.message); }
|
|
3525
|
-
const parsed = JSON.parse(document.getElementById("settingsJson").value || "{}");
|
|
3526
|
-
return {
|
|
3527
|
-
...parsed,
|
|
3528
|
-
trust_mode: document.getElementById("trustMode").value,
|
|
3529
|
-
agent_settings: {
|
|
3530
|
-
...(parsed.agent_settings || {}),
|
|
3531
|
-
default_repo_root: document.getElementById("repoRoot").value,
|
|
3532
|
-
worktree_root: document.getElementById("worktreeRoot").value,
|
|
3533
|
-
base_branch: document.getElementById("baseBranch").value,
|
|
3534
|
-
isolation: document.getElementById("isolation").value,
|
|
3535
|
-
agents: {
|
|
3536
|
-
...((parsed.agent_settings || {}).agents || {}),
|
|
3537
|
-
claude: {
|
|
3538
|
-
...(((parsed.agent_settings || {}).agents || {}).claude || {}),
|
|
3539
|
-
runtime: "claude",
|
|
3540
|
-
model: document.getElementById("claudeModel").value,
|
|
3541
|
-
command: document.getElementById("claudeCommand").value,
|
|
3542
|
-
enabled: document.getElementById("claudeEnabled").checked
|
|
3543
|
-
},
|
|
3544
|
-
codex: {
|
|
3545
|
-
...(((parsed.agent_settings || {}).agents || {}).codex || {}),
|
|
3546
|
-
runtime: "codex",
|
|
3547
|
-
model: document.getElementById("codexModel").value,
|
|
3548
|
-
command: document.getElementById("codexCommand").value,
|
|
3549
|
-
enabled: document.getElementById("codexEnabled").checked
|
|
3550
|
-
}
|
|
3551
|
-
}
|
|
3552
|
-
},
|
|
3553
|
-
sidebar_settings: {
|
|
3554
|
-
...(parsed.sidebar_settings || {}),
|
|
3555
|
-
enabled: document.getElementById("sidebarEnabled").checked,
|
|
3556
|
-
default_app_slug: document.getElementById("defaultAppSlug").value,
|
|
3557
|
-
session_ttl_hours: num("sessionTtlHours", 24),
|
|
3558
|
-
event_history_limit: num("eventHistoryLimit", 200)
|
|
3559
|
-
},
|
|
3560
|
-
dispatch_settings: {
|
|
3561
|
-
...(parsed.dispatch_settings || {}),
|
|
3562
|
-
enabled: document.getElementById("dispatchEnabled").checked,
|
|
3563
|
-
default_agent: document.getElementById("dispatchDefaultAgent").value,
|
|
3564
|
-
default_model: document.getElementById("dispatchDefaultModel").value,
|
|
3565
|
-
max_concurrent_workers: num("dispatchMaxWorkers", 2)
|
|
3566
|
-
},
|
|
3567
|
-
codevelop_settings: {
|
|
3568
|
-
...(parsed.codevelop_settings || {}),
|
|
3569
|
-
enabled: document.getElementById("codevelopEnabled").checked,
|
|
3570
|
-
default_repo: document.getElementById("codevelopDefaultRepo").value,
|
|
3571
|
-
message_retention_limit: num("codevelopRetention", 500),
|
|
3572
|
-
require_peer_join: document.getElementById("codevelopRequirePeer").checked
|
|
3573
|
-
},
|
|
3574
|
-
agent_sessions: Array.isArray(parsed.agent_sessions) ? parsed.agent_sessions : (currentSettings.agent_sessions || []),
|
|
3575
|
-
apps
|
|
3576
|
-
};
|
|
3577
|
-
}
|
|
3578
|
-
async function loadSettings(){
|
|
3579
|
-
setStatus("GET /tintin/settings");
|
|
3580
|
-
const r = await fetch(BASE + "/tintin/settings", { cache:"no-store" });
|
|
3581
|
-
const data = await r.json();
|
|
3582
|
-
applySettings(data);
|
|
3583
|
-
show(data);
|
|
3584
|
-
setStatus(r.ok ? "loaded" : "load failed");
|
|
3585
|
-
}
|
|
3586
|
-
async function saveSettings(){
|
|
3587
|
-
setStatus("PUT /tintin/settings");
|
|
3588
|
-
let body;
|
|
3589
|
-
try { body = collectSettings(); }
|
|
3590
|
-
catch(e){ setStatus("invalid JSON"); show(e.message); return; }
|
|
3591
|
-
const r = await fetch(BASE + "/tintin/settings", { method:"PUT", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(body) });
|
|
3592
|
-
const data = await r.json();
|
|
3593
|
-
applySettings(data);
|
|
3594
|
-
show(data);
|
|
3595
|
-
setStatus(r.ok ? "saved" : "save failed");
|
|
3596
|
-
}
|
|
3597
|
-
async function setupAgent(){
|
|
3598
|
-
const body = {
|
|
3599
|
-
agent: document.getElementById("agent").value,
|
|
3600
|
-
isolation: document.getElementById("isolation").value,
|
|
3601
|
-
repo_root: document.getElementById("repoRoot").value,
|
|
3602
|
-
worktree_root: document.getElementById("worktreeRoot").value,
|
|
3603
|
-
base_branch: document.getElementById("baseBranch").value,
|
|
3604
|
-
session_id: document.getElementById("sessionId").value || undefined
|
|
3605
|
-
};
|
|
3606
|
-
setStatus("POST /tintin/agent-sessions");
|
|
3607
|
-
const r = await fetch(BASE + "/tintin/agent-sessions", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(body) });
|
|
3608
|
-
const data = await r.json();
|
|
3609
|
-
show(data);
|
|
3610
|
-
setStatus(r.ok && data.ok ? "agent setup ready" : "agent setup failed");
|
|
3611
|
-
if (r.ok && data.ok) loadSettings().catch(function(){});
|
|
3612
|
-
}
|
|
3613
|
-
loadSettings().catch(err => { setStatus("load failed"); show(err.message || String(err)); });
|
|
3614
|
-
</script>
|
|
3615
|
-
</body>
|
|
3616
|
-
</html>`;
|
|
3617
|
-
}
|
|
3618
|
-
|
|
3619
|
-
// ── call_agent noauth-host guard (Gate B, bearer-gated tunnel) ───────────────
|
|
3620
|
-
// Pure decision function so the policy is testable without a daemon. Decides
|
|
3621
|
-
// whether a /call-agent request may proceed.
|
|
3622
|
-
// - Browser Origin → reject (CSRF), unchanged, regardless of host/token.
|
|
3623
|
-
// - Local (127.0.0.1, noAuthHost=false) → allow without a token, as today.
|
|
3624
|
-
// - Noauth tunnel host (clauth.regendevcorp.com etc.) → allow ONLY when the
|
|
3625
|
-
// request carries `Authorization: Bearer <expectedToken>` and the token is
|
|
3626
|
-
// a non-empty exact match. Missing/wrong bearer → reject (unchanged
|
|
3627
|
-
// unauthenticated behaviour). If no expectedToken is configured server-side,
|
|
3628
|
-
// the noauth host stays fully closed (cannot be unlocked by any bearer).
|
|
3629
|
-
// Returns { allow: true } or { allow: false, status, error }.
|
|
3630
|
-
export function evaluateCallAgentGuard({ origin, noAuthHost, authHeader, expectedToken }) {
|
|
3631
|
-
if (origin) {
|
|
3632
|
-
return { allow: false, status: 403, error: "call_agent_rejects_browser_origin", origin };
|
|
3633
|
-
}
|
|
3634
|
-
if (!noAuthHost) {
|
|
3635
|
-
return { allow: true };
|
|
3636
|
-
}
|
|
3637
|
-
// Noauth tunnel host: require a valid bearer token.
|
|
3638
|
-
const presented = typeof authHeader === "string"
|
|
3639
|
-
? (authHeader.match(/^Bearer\s+(.+)$/i)?.[1] || "").trim()
|
|
3640
|
-
: "";
|
|
3641
|
-
if (expectedToken && presented && presented === expectedToken) {
|
|
3642
|
-
return { allow: true };
|
|
3643
|
-
}
|
|
3644
|
-
return { allow: false, status: 403, error: "call_agent_not_available_on_noauth_host" };
|
|
3645
|
-
}
|
|
3646
|
-
|
|
3647
|
-
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
3648
|
-
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
|
|
3649
|
-
mcpHttpBaseUrl = `http://127.0.0.1:${port}`;
|
|
3650
|
-
|
|
3651
|
-
// tunnelHostname may be updated at runtime (fetched from DB after unlock)
|
|
3652
|
-
let tunnelHostname = tunnelHostnameInit;
|
|
3653
|
-
|
|
3654
|
-
// Ensure Windows system tools are reachable (bash shells may lack these on PATH)
|
|
3655
|
-
if (os.platform() === "win32") {
|
|
3656
|
-
const sys32 = "C:\\Windows\\System32";
|
|
3657
|
-
if (!process.env.PATH?.includes(sys32 + "\\Wbem")) {
|
|
3658
|
-
process.env.PATH = (process.env.PATH || "") + ";" + sys32 + "\\Wbem";
|
|
3659
|
-
}
|
|
3660
|
-
if (!process.env.PATH?.includes(sys32 + ";") && !process.env.PATH?.endsWith(sys32)) {
|
|
3661
|
-
process.env.PATH = (process.env.PATH || "") + ";" + sys32;
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
const MAX_FAILS = 10;
|
|
3665
|
-
let failCount = 0;
|
|
3666
|
-
const MAX_AUTH_FAILS = 10;
|
|
3667
|
-
let authFailCount = 0;
|
|
3668
|
-
|
|
3669
|
-
// Per-IP unknown-service strike counter — separate from auth failure budget.
|
|
3670
|
-
// Caller typos / stale memory should NOT burn the 10-strike lockout.
|
|
3671
|
-
const unknownServiceStrikes = new Map(); // ip → count
|
|
3672
|
-
const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
|
|
3673
|
-
let authHardLocked = false;
|
|
3674
|
-
let password = initPassword || null; // null = locked; set via POST /auth
|
|
3675
|
-
let writeSession = null; // null until explicit password auth grants write scope
|
|
3676
|
-
const machineHash = getMachineHash();
|
|
3345
|
+
// Per-IP unknown-service strike counter — separate from auth failure budget.
|
|
3346
|
+
// Caller typos / stale memory should NOT burn the 10-strike lockout.
|
|
3347
|
+
const unknownServiceStrikes = new Map(); // ip → count
|
|
3348
|
+
const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
|
|
3349
|
+
let authHardLocked = false;
|
|
3350
|
+
let password = initPassword || null; // null = locked; set via POST /auth
|
|
3351
|
+
let writeSession = null; // null until explicit password auth grants write scope
|
|
3352
|
+
const machineHash = getMachineHash();
|
|
3677
3353
|
|
|
3678
3354
|
// ── call_agent bearer token resolver (noauth-host gate) ────────────────────
|
|
3679
3355
|
// The shared secret that lets a noauth tunnel host (clauth.regendevcorp.com)
|
|
@@ -3770,6 +3446,187 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3770
3446
|
});
|
|
3771
3447
|
},
|
|
3772
3448
|
});
|
|
3449
|
+
const isSupervisorPort = port === getSupervisorPort();
|
|
3450
|
+
const supervisorTestNoToken = process.env.CLAUTH_SUPERVISOR_TEST_NO_TOKEN === "1";
|
|
3451
|
+
const opsAdapter = createPm2Adapter(pm2);
|
|
3452
|
+
const executePm2 = createSerializedExecutor();
|
|
3453
|
+
const opsPolicy = createOperationPolicy({
|
|
3454
|
+
enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
3455
|
+
applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
3456
|
+
adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
3457
|
+
adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
3458
|
+
allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
|
|
3459
|
+
});
|
|
3460
|
+
const opsJobs = createJobStore({
|
|
3461
|
+
filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
|
|
3462
|
+
});
|
|
3463
|
+
|
|
3464
|
+
async function getLoopbackSecret(service) {
|
|
3465
|
+
const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
|
|
3466
|
+
if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
|
|
3467
|
+
const value = (await response.text()).trim();
|
|
3468
|
+
if (!value) throw new Error(`${service} is empty`);
|
|
3469
|
+
return value;
|
|
3470
|
+
}
|
|
3471
|
+
const coolify = createCoolifyAdapter({
|
|
3472
|
+
baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
|
|
3473
|
+
getToken: () => getLoopbackSecret("coolify-api"),
|
|
3474
|
+
});
|
|
3475
|
+
const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
|
|
3476
|
+
const deploymentAdapter = createDeploymentAdapter({
|
|
3477
|
+
deployments,
|
|
3478
|
+
reload: async (target) => {
|
|
3479
|
+
await executePm2(async () => {
|
|
3480
|
+
await opsAdapter.connect();
|
|
3481
|
+
try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
|
|
3482
|
+
});
|
|
3483
|
+
},
|
|
3484
|
+
});
|
|
3485
|
+
|
|
3486
|
+
/**
|
|
3487
|
+
* Record an ops failure's upstream message to the LOCAL log only.
|
|
3488
|
+
*
|
|
3489
|
+
* job-store's sanitizer deliberately drops free-form `error` text so an
|
|
3490
|
+
* upstream message cannot carry a credential into the persisted job file or
|
|
3491
|
+
* the API response. That protection left every failure with an empty detail,
|
|
3492
|
+
* so jobs reported `failed` with no reason at all. Jobs now carry an
|
|
3493
|
+
* enumerated `code`; the underlying message goes here, to the same
|
|
3494
|
+
* operator-only log as the rest of the daemon's diagnostics.
|
|
3495
|
+
*/
|
|
3496
|
+
function logOpsFailure(kind, operation, error) {
|
|
3497
|
+
const message = String(error?.message || error || "unknown");
|
|
3498
|
+
try {
|
|
3499
|
+
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
|
|
3500
|
+
} catch {}
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
async function opsBearerRole(req) {
|
|
3504
|
+
const header = req.headers.authorization;
|
|
3505
|
+
const supplied = Array.isArray(header) ? header[0] : header;
|
|
3506
|
+
if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
|
|
3507
|
+
const actual = String(supplied).slice(7).trim();
|
|
3508
|
+
let admin; let agent;
|
|
3509
|
+
try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
|
|
3510
|
+
try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
|
|
3511
|
+
if (admin && agent && admin === agent) return null;
|
|
3512
|
+
for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
|
|
3513
|
+
if (!expected) continue;
|
|
3514
|
+
const a = Buffer.from(actual); const b = Buffer.from(expected);
|
|
3515
|
+
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
|
|
3516
|
+
}
|
|
3517
|
+
return null;
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
async function requireOpsBearer(req, res) {
|
|
3521
|
+
const role = await opsBearerRole(req);
|
|
3522
|
+
if (role) { req._opsRole = role; return true; }
|
|
3523
|
+
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
3524
|
+
res.end(JSON.stringify({ error: "ops_bearer_required" }));
|
|
3525
|
+
return false;
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
function submitOpsJob(operation, input, role = "agent") {
|
|
3529
|
+
const authorization = opsPolicy.authorize(operation, input, role);
|
|
3530
|
+
const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
|
|
3531
|
+
if (!authorization.ok) {
|
|
3532
|
+
return opsJobs.event(job.id, "rejected", { code: authorization.code });
|
|
3533
|
+
}
|
|
3534
|
+
void (async () => {
|
|
3535
|
+
opsJobs.event(job.id, "running");
|
|
3536
|
+
try {
|
|
3537
|
+
const result = await executePm2(async () => {
|
|
3538
|
+
await opsAdapter.connect();
|
|
3539
|
+
try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
|
|
3540
|
+
});
|
|
3541
|
+
opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
|
|
3542
|
+
} catch (error) {
|
|
3543
|
+
// `error` alone is dropped by job-store's sanitizer (it refuses
|
|
3544
|
+
// free-form upstream text so a credential cannot ride along), which
|
|
3545
|
+
// left every failure with an empty detail. Emit an enumerated code so
|
|
3546
|
+
// the failure has a reason; keep the message for the local log only.
|
|
3547
|
+
logOpsFailure("pm2", operation, error);
|
|
3548
|
+
opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
|
|
3549
|
+
}
|
|
3550
|
+
})();
|
|
3551
|
+
return opsJobs.get(job.id);
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
function operationReceipt(operation, result, allowedTargets) {
|
|
3555
|
+
if (["list", "describe", "logs"].includes(operation)) {
|
|
3556
|
+
const processes = Array.isArray(result)
|
|
3557
|
+
? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
|
|
3558
|
+
: [];
|
|
3559
|
+
return { processes };
|
|
3560
|
+
}
|
|
3561
|
+
if (operation === "ping") return { status: "connected" };
|
|
3562
|
+
return { status: "completed" };
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
function submitPromotionJob(applicationUuid) {
|
|
3566
|
+
const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
|
|
3567
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
|
|
3568
|
+
const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
|
|
3569
|
+
if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
|
|
3570
|
+
return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
3571
|
+
}
|
|
3572
|
+
void (async () => {
|
|
3573
|
+
opsJobs.event(job.id, "running");
|
|
3574
|
+
try {
|
|
3575
|
+
const deployment = await coolify.promote(applicationUuid);
|
|
3576
|
+
// Coolify answers with a `deployments` ARRAY, not a flat object — see
|
|
3577
|
+
// deploymentUuidFrom. Reading the flat field alone marked the job failed
|
|
3578
|
+
// while the deployment was actually running.
|
|
3579
|
+
const deploymentUuid = deploymentUuidFrom(deployment);
|
|
3580
|
+
if (!deploymentUuid) {
|
|
3581
|
+
// The deploy request itself SUCCEEDED (no throw); only the UUID was
|
|
3582
|
+
// unreadable, so the deployment may well be RUNNING. The code says so
|
|
3583
|
+
// explicitly rather than a bare "failed", because a plain failure
|
|
3584
|
+
// invites a retry and a duplicate production deploy.
|
|
3585
|
+
//
|
|
3586
|
+
// An enumerated code, not a free-form error: job-store's sanitizer
|
|
3587
|
+
// drops `error` on purpose to keep upstream text (and any credential
|
|
3588
|
+
// inside it) out of the persisted job.
|
|
3589
|
+
return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
|
|
3590
|
+
}
|
|
3591
|
+
opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
|
|
3592
|
+
const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
|
|
3593
|
+
opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
|
|
3594
|
+
} catch (error) {
|
|
3595
|
+
// The throw may have happened AFTER Coolify accepted the deploy (e.g.
|
|
3596
|
+
// the poll lost the network), so this is not proof nothing shipped.
|
|
3597
|
+
logOpsFailure("coolify", "promote", error);
|
|
3598
|
+
opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
|
|
3599
|
+
}
|
|
3600
|
+
})();
|
|
3601
|
+
return opsJobs.get(job.id);
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
function submitDeploymentJob(application, ref) {
|
|
3605
|
+
const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
|
|
3606
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
|
|
3607
|
+
if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
3608
|
+
void (async () => {
|
|
3609
|
+
opsJobs.event(job.id, "running");
|
|
3610
|
+
try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
|
|
3611
|
+
catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
|
|
3612
|
+
})();
|
|
3613
|
+
return opsJobs.get(job.id);
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
function hasSupervisorWrite(req) {
|
|
3617
|
+
if (validateWriteToken(req, writeSession)) return true;
|
|
3618
|
+
if (!isSupervisorPort) return false;
|
|
3619
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
|
3620
|
+
return supervisorTestNoToken || !supervisorRequiresWriteToken(port);
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
function rejectSupervisorWrite(res) {
|
|
3624
|
+
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
3625
|
+
return res.end(JSON.stringify({
|
|
3626
|
+
error: "write_token_required",
|
|
3627
|
+
hint: "Unlock clauth writes, unset CLAUTH_SUPERVISOR_REQUIRE_WRITE_TOKEN for localhost supervisor operations, or set CLAUTH_SUPERVISOR_TEST_NO_TOKEN=1 for temporary localhost-only supervisor tests.",
|
|
3628
|
+
}));
|
|
3629
|
+
}
|
|
3773
3630
|
|
|
3774
3631
|
// ── MCP SSE session tracking ──────────────────────────────
|
|
3775
3632
|
const sseSessions = new Map(); // sessionId → { res, initialized }
|
|
@@ -4310,7 +4167,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4310
4167
|
|
|
4311
4168
|
const server = http.createServer(async (req, res) => {
|
|
4312
4169
|
const remote = req.socket.remoteAddress;
|
|
4313
|
-
const isLocal = remote
|
|
4170
|
+
const isLocal = isLoopbackAddress(remote);
|
|
4314
4171
|
|
|
4315
4172
|
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
4316
4173
|
const reqPath = url.pathname;
|
|
@@ -4350,6 +4207,205 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4350
4207
|
return res.end(JSON.stringify(result));
|
|
4351
4208
|
}
|
|
4352
4209
|
|
|
4210
|
+
if (method === "GET" && reqPath === "/health") {
|
|
4211
|
+
return ok(res, { ...supervisorHealth(), listening_port: port, process_id: process.pid, vault_locked: !password, clauth_version: VERSION });
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
// Bearer-gated remote operations surface. It remains loopback-only at this
|
|
4215
|
+
// layer; ingress/tunnel policy decides whether it is reachable remotely.
|
|
4216
|
+
if (method === "GET" && reqPath === "/v1/ops/catalog") {
|
|
4217
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4218
|
+
return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
if (method === "GET" && reqPath === "/v1/ops/processes") {
|
|
4222
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4223
|
+
const job = submitOpsJob("list", {}, req._opsRole);
|
|
4224
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4225
|
+
return res.end(JSON.stringify(job));
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
|
|
4229
|
+
if (method === "GET" && opsProcessMatch) {
|
|
4230
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4231
|
+
const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
|
|
4232
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4233
|
+
return res.end(JSON.stringify(job));
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
if (method === "POST" && reqPath === "/v1/ops/operations") {
|
|
4237
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4238
|
+
let body;
|
|
4239
|
+
try { body = await readBody(req); } catch {
|
|
4240
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4241
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4242
|
+
}
|
|
4243
|
+
const operation = String(body?.operation || "");
|
|
4244
|
+
if (!PM2_OPERATION_CATALOG[operation]) {
|
|
4245
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4246
|
+
return res.end(JSON.stringify({ error: "unknown_operation" }));
|
|
4247
|
+
}
|
|
4248
|
+
const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
|
|
4249
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4250
|
+
return res.end(JSON.stringify(job));
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
if (method === "POST" && reqPath === "/v1/ops/promotions") {
|
|
4254
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4255
|
+
let body;
|
|
4256
|
+
try { body = await readBody(req); } catch {
|
|
4257
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4258
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4259
|
+
}
|
|
4260
|
+
const applicationUuid = String(body?.application_uuid || "").trim();
|
|
4261
|
+
if (!applicationUuid) {
|
|
4262
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4263
|
+
return res.end(JSON.stringify({ error: "application_uuid_required" }));
|
|
4264
|
+
}
|
|
4265
|
+
const job = submitPromotionJob(applicationUuid);
|
|
4266
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4267
|
+
return res.end(JSON.stringify(job));
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
if (method === "POST" && reqPath === "/v1/ops/deployments") {
|
|
4271
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4272
|
+
let body;
|
|
4273
|
+
try { body = await readBody(req); } catch {
|
|
4274
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4275
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4276
|
+
}
|
|
4277
|
+
const application = String(body?.application || "").trim();
|
|
4278
|
+
if (!application) {
|
|
4279
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4280
|
+
return res.end(JSON.stringify({ error: "application_required" }));
|
|
4281
|
+
}
|
|
4282
|
+
const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
|
|
4283
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4284
|
+
return res.end(JSON.stringify(job));
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
|
|
4288
|
+
if (method === "GET" && opsJobMatch) {
|
|
4289
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4290
|
+
const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
|
|
4291
|
+
res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
|
|
4292
|
+
return res.end(JSON.stringify(job || { error: "job_not_found" }));
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
|
|
4296
|
+
if (method === "GET" && opsJobEventsMatch) {
|
|
4297
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4298
|
+
const jobId = decodeURIComponent(opsJobEventsMatch[1]);
|
|
4299
|
+
if (!opsJobs.get(jobId)) {
|
|
4300
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
4301
|
+
return res.end(JSON.stringify({ error: "job_not_found" }));
|
|
4302
|
+
}
|
|
4303
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
|
|
4304
|
+
const unsubscribe = opsJobs.subscribe(jobId, (job) => {
|
|
4305
|
+
if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
|
|
4306
|
+
});
|
|
4307
|
+
req.on("close", unsubscribe);
|
|
4308
|
+
return;
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
if (method === "GET" && reqPath === "/v1/plugins") {
|
|
4312
|
+
return ok(res, { plugins: listPlugins() });
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
if (method === "POST" && reqPath === "/v1/plugins/rescan") {
|
|
4316
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4317
|
+
return ok(res, discoverPlugins());
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
const pluginEnableMatch = reqPath.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable|test|promote)$/);
|
|
4321
|
+
if (method === "POST" && pluginEnableMatch) {
|
|
4322
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4323
|
+
const pluginId = decodeURIComponent(pluginEnableMatch[1]);
|
|
4324
|
+
const op = pluginEnableMatch[2];
|
|
4325
|
+
const result = op === "enable"
|
|
4326
|
+
? setPluginEnabled(pluginId, true)
|
|
4327
|
+
: op === "disable"
|
|
4328
|
+
? setPluginEnabled(pluginId, false)
|
|
4329
|
+
: runPluginAction(pluginId, op);
|
|
4330
|
+
res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...CORS });
|
|
4331
|
+
return res.end(JSON.stringify(result));
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
if (method === "GET" && reqPath === "/v1/surfaces") {
|
|
4335
|
+
return ok(res, { surfaces: listSurfaces() });
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
const surfaceActionMatch = reqPath.match(/^\/v1\/surfaces\/([^/]+)\/actions$/);
|
|
4339
|
+
if (method === "POST" && surfaceActionMatch) {
|
|
4340
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4341
|
+
let body;
|
|
4342
|
+
try { body = await readBody(req); } catch {
|
|
4343
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4344
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
4345
|
+
}
|
|
4346
|
+
const result = runSurfaceAction(decodeURIComponent(surfaceActionMatch[1]), body?.action || "reconcile");
|
|
4347
|
+
res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...CORS });
|
|
4348
|
+
return res.end(JSON.stringify(result));
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
if (method === "GET" && reqPath === "/v1/routes") {
|
|
4352
|
+
return ok(res, { routes: listRoutes() });
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
if (method === "GET" && reqPath === "/v1/tunnels") {
|
|
4356
|
+
return ok(res, { tunnels: listTunnels() });
|
|
4357
|
+
}
|
|
4358
|
+
|
|
4359
|
+
if (method === "GET" && reqPath === "/v1/logs") {
|
|
4360
|
+
const limit = Number(url.searchParams.get("limit") || 100);
|
|
4361
|
+
const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 500)) : 100;
|
|
4362
|
+
const state = loadSupervisorState();
|
|
4363
|
+
return ok(res, {
|
|
4364
|
+
schema: "clauth.supervisor.logs.v1",
|
|
4365
|
+
log_path: path.join(getSupervisorDir(), "events.jsonl"),
|
|
4366
|
+
events: readSupervisorEvents(boundedLimit).map(supervisorLogDto),
|
|
4367
|
+
operations: (state.operations || []).slice(0, boundedLimit).map(supervisorOperationDto),
|
|
4368
|
+
});
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
const tunnelRoutesMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes$/);
|
|
4372
|
+
if (method === "POST" && tunnelRoutesMatch) {
|
|
4373
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4374
|
+
let body;
|
|
4375
|
+
try { body = await readBody(req); } catch {
|
|
4376
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4377
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
4378
|
+
}
|
|
4379
|
+
return ok(res, addTunnelRoute(decodeURIComponent(tunnelRoutesMatch[1]), body, "localhost"));
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
const tunnelRouteDeleteMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes\/([^/]+)$/);
|
|
4383
|
+
if (method === "DELETE" && tunnelRouteDeleteMatch) {
|
|
4384
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4385
|
+
return ok(res, removeTunnelRoute(decodeURIComponent(tunnelRouteDeleteMatch[1]), decodeURIComponent(tunnelRouteDeleteMatch[2]), "localhost"));
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
if (method === "GET" && reqPath.startsWith("/v1/operations/")) {
|
|
4389
|
+
const id = decodeURIComponent(reqPath.split("/").pop());
|
|
4390
|
+
const operation = (supervisorHealth(), readSupervisorEvents(500)).find((event) => event.operationId === id);
|
|
4391
|
+
res.writeHead(operation ? 200 : 404, { "Content-Type": "application/json", ...CORS });
|
|
4392
|
+
return res.end(JSON.stringify(operation ? supervisorLogDto(operation) : { error: "operation_not_found" }));
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
if (method === "GET" && reqPath === "/v1/events") {
|
|
4396
|
+
res.writeHead(200, {
|
|
4397
|
+
"Content-Type": "text/event-stream",
|
|
4398
|
+
"Cache-Control": "no-cache",
|
|
4399
|
+
"Connection": "keep-alive",
|
|
4400
|
+
...CORS,
|
|
4401
|
+
});
|
|
4402
|
+
for (const event of readSupervisorEvents(Number(url.searchParams.get("limit") || 100))) {
|
|
4403
|
+
res.write(`event: supervisor\ndata: ${JSON.stringify(supervisorLogDto(event))}\n\n`);
|
|
4404
|
+
}
|
|
4405
|
+
res.end();
|
|
4406
|
+
return;
|
|
4407
|
+
}
|
|
4408
|
+
|
|
4353
4409
|
// ── Hosts that bypass OAuth (fresh domains for claude.ai compatibility) ──
|
|
4354
4410
|
const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
|
|
4355
4411
|
const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
@@ -4419,6 +4475,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4419
4475
|
saveClients(oauthClients);
|
|
4420
4476
|
const logMsg = `[${new Date().toISOString()}] OAuth: registered public client ${clientId} (${client.client_name})\n`;
|
|
4421
4477
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
4478
|
+
operation("oauth.client_register", { client_id: clientId, client_name: client.client_name }, null, { ok: true });
|
|
4422
4479
|
res.writeHead(201, { "Content-Type": "application/json", "Cache-Control": "no-store", ...CORS });
|
|
4423
4480
|
return res.end(JSON.stringify(client));
|
|
4424
4481
|
}
|
|
@@ -4542,6 +4599,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4542
4599
|
|
|
4543
4600
|
const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
|
|
4544
4601
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
4602
|
+
operation("oauth.token_issue", { client_id: stored.client_id }, null, { ok: true });
|
|
4545
4603
|
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...CORS });
|
|
4546
4604
|
return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "mcp:tools", expires_in: 86400 }));
|
|
4547
4605
|
}
|
|
@@ -4552,7 +4610,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4552
4610
|
function toolsForPath(p) {
|
|
4553
4611
|
const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
|
|
4554
4612
|
if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
|
|
4555
|
-
if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "
|
|
4613
|
+
if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "call_agent" || t.name.startsWith("terminal_") || t.name.startsWith("channel_") || t.name.startsWith("handoff_"));
|
|
4556
4614
|
if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
|
|
4557
4615
|
if (p === "/chitchat") return tools.filter(t => t.name.startsWith("chitchat_"));
|
|
4558
4616
|
if (p === "/codevelop") return tools.filter(t => t.name.startsWith("codevelop_"));
|
|
@@ -4858,8 +4916,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4858
4916
|
return res.end(JSON.stringify({ error: "name and path are required" }));
|
|
4859
4917
|
}
|
|
4860
4918
|
const result = webdavService.addMount(name.trim(), mountPath.trim());
|
|
4861
|
-
if (result.error) {
|
|
4919
|
+
if (result.error) {
|
|
4920
|
+
operation("webdav.mount_add", { name: name.trim() }, null, { ok: false, error: result.error });
|
|
4921
|
+
res.writeHead(409, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result));
|
|
4922
|
+
}
|
|
4862
4923
|
webdavService.shutdown();
|
|
4924
|
+
operation("webdav.mount_add", { name: name.trim(), path: mountPath.trim() }, null, { ok: true });
|
|
4863
4925
|
return ok(res, result);
|
|
4864
4926
|
}
|
|
4865
4927
|
|
|
@@ -4867,8 +4929,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4867
4929
|
if (writeGuard(req, res)) return;
|
|
4868
4930
|
const mountName = decodeURIComponent(reqPath.slice("/webdav/mounts/".length));
|
|
4869
4931
|
const result = webdavService.removeMount(mountName);
|
|
4870
|
-
if (result.error) {
|
|
4932
|
+
if (result.error) {
|
|
4933
|
+
operation("webdav.mount_remove", { name: mountName }, null, { ok: false, error: result.error });
|
|
4934
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result));
|
|
4935
|
+
}
|
|
4871
4936
|
webdavService.shutdown();
|
|
4937
|
+
operation("webdav.mount_remove", { name: mountName }, null, { ok: true });
|
|
4872
4938
|
return ok(res, result);
|
|
4873
4939
|
}
|
|
4874
4940
|
|
|
@@ -4882,6 +4948,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4882
4948
|
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
4883
4949
|
}
|
|
4884
4950
|
const result = startCodevelopSession(body || {});
|
|
4951
|
+
operation("codevelop.start", { session_id: result?.session_id }, null, { ok: !result?.error, error: result?.error });
|
|
4885
4952
|
return ok(res, result);
|
|
4886
4953
|
}
|
|
4887
4954
|
|
|
@@ -4893,12 +4960,19 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4893
4960
|
}
|
|
4894
4961
|
const result = joinCodevelopSession(body || {});
|
|
4895
4962
|
if (result.error) {
|
|
4963
|
+
operation("codevelop.join", { session_id: body?.session_id }, null, { ok: false, error: result.error });
|
|
4896
4964
|
res.writeHead(result.error === "not_found" ? 404 : 400, { "Content-Type": "application/json", ...CORS });
|
|
4897
4965
|
return res.end(JSON.stringify(result));
|
|
4898
4966
|
}
|
|
4967
|
+
operation("codevelop.join", { session_id: body?.session_id }, null, { ok: true });
|
|
4899
4968
|
return ok(res, result);
|
|
4900
4969
|
}
|
|
4901
4970
|
|
|
4971
|
+
// codevelop/send is a genuine mutation (adds a message) but excluded from
|
|
4972
|
+
// per-route logging by design — a live peer-to-peer relay can send at a
|
|
4973
|
+
// frequency that would bloat events.jsonl (same risk noted in the audit-
|
|
4974
|
+
// logging retrofit plan for codevelop/poll). Session lifecycle
|
|
4975
|
+
// (start/join/stop) is logged; message throughput is not.
|
|
4902
4976
|
if (method === "POST" && reqPath === "/codevelop/send") {
|
|
4903
4977
|
let body;
|
|
4904
4978
|
try { body = await readBody(req); } catch {
|
|
@@ -4982,9 +5056,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4982
5056
|
}
|
|
4983
5057
|
const result = stopCodevelopSession(body?.session_id);
|
|
4984
5058
|
if (result.error) {
|
|
5059
|
+
operation("codevelop.stop", { session_id: body?.session_id }, null, { ok: false, error: result.error });
|
|
4985
5060
|
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
4986
5061
|
return res.end(JSON.stringify(result));
|
|
4987
5062
|
}
|
|
5063
|
+
operation("codevelop.stop", { session_id: body?.session_id }, null, { ok: true });
|
|
4988
5064
|
return ok(res, result);
|
|
4989
5065
|
}
|
|
4990
5066
|
|
|
@@ -5172,6 +5248,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5172
5248
|
saveTokens(oauthTokens);
|
|
5173
5249
|
const logMsg = `[${new Date().toISOString()}] OAuth: rolled credentials — all clients and tokens invalidated\n`;
|
|
5174
5250
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
5251
|
+
operation("oauth.roll_creds", {}, null, { ok: true });
|
|
5175
5252
|
return ok(res, { clients_cleared: true, tokens_invalidated: true });
|
|
5176
5253
|
}
|
|
5177
5254
|
|
|
@@ -5185,10 +5262,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5185
5262
|
}
|
|
5186
5263
|
if (body.action === "stop") {
|
|
5187
5264
|
stopTunnel();
|
|
5265
|
+
operation("tunnel.stop", {}, null, { ok: true });
|
|
5188
5266
|
return ok(res, { status: tunnelStatus, running: false });
|
|
5189
5267
|
}
|
|
5190
5268
|
// start
|
|
5191
5269
|
await startTunnel();
|
|
5270
|
+
operation("tunnel.start", {}, null, { ok: !!tunnelProc, url: tunnelUrl, error: tunnelError });
|
|
5192
5271
|
return ok(res, { status: tunnelStatus, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
|
|
5193
5272
|
}
|
|
5194
5273
|
|
|
@@ -5198,531 +5277,88 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5198
5277
|
if (tunnelProc) return ok(res, { status: tunnelStatus, message: "already running" });
|
|
5199
5278
|
tunnelStatus = "starting";
|
|
5200
5279
|
startTunnel().catch(() => {});
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
// POST /tunnel/stop — explicit stop endpoint
|
|
5205
|
-
if (method === "POST" && reqPath === "/tunnel/stop") {
|
|
5206
|
-
if (lockedGuard(res)) return;
|
|
5207
|
-
stopTunnel();
|
|
5208
|
-
return ok(res, { status: tunnelStatus });
|
|
5209
|
-
}
|
|
5210
|
-
|
|
5211
|
-
// POST /launch-ccandme — open a new CCandMe WezTerm window without killing existing sessions
|
|
5212
|
-
if (method === "POST" && reqPath === "/launch-ccandme") {
|
|
5213
|
-
if (lockedGuard(res)) return;
|
|
5214
|
-
try {
|
|
5215
|
-
const { spawn } = await import("child_process");
|
|
5216
|
-
const { existsSync, readFileSync, writeFileSync } = await import("fs");
|
|
5217
|
-
|
|
5218
|
-
// Locate WezTerm
|
|
5219
|
-
const wezCandidates = [
|
|
5220
|
-
"C:/Program Files/WezTerm/wezterm.exe",
|
|
5221
|
-
"C:/Program Files (x86)/WezTerm/wezterm.exe",
|
|
5222
|
-
process.env.WEZTERM_EXE,
|
|
5223
|
-
].filter(Boolean);
|
|
5224
|
-
const wezExe = wezCandidates.find(existsSync) || "wezterm";
|
|
5225
|
-
|
|
5226
|
-
// Render the lua config from the CCandMe template (skip the kill-existing step)
|
|
5227
|
-
const CCANDME_DIR = "C:/Dev/CCandMe";
|
|
5228
|
-
const WORK_DIR = "C:/Dev/regen-root";
|
|
5229
|
-
const templatePath = path.join(CCANDME_DIR, "templates", "wezterm.lua");
|
|
5230
|
-
const luaOutPath = path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
|
|
5231
|
-
|
|
5232
|
-
if (existsSync(templatePath)) {
|
|
5233
|
-
const lua = readFileSync(templatePath, "utf8")
|
|
5234
|
-
.replaceAll("__SUPERVISOR_DIR__", CCANDME_DIR.replace(/\//g, "\\\\"))
|
|
5235
|
-
.replaceAll("__WORK_DIR__", WORK_DIR.replace(/\//g, "\\\\"))
|
|
5236
|
-
.replaceAll("__WORKSPACE__", "ccandme")
|
|
5237
|
-
.replaceAll("__CLAUDE_CMD__", "claude")
|
|
5238
|
-
.replaceAll("__CODEX_CMD__", "codex")
|
|
5239
|
-
.replaceAll("__PACKAGE_ROOT__", CCANDME_DIR.replace(/\//g, "\\\\"));
|
|
5240
|
-
writeFileSync(luaOutPath, lua, "utf8");
|
|
5241
|
-
}
|
|
5242
|
-
|
|
5243
|
-
// Launch WezTerm with the CCandMe config — no kill of existing sessions
|
|
5244
|
-
const luaArg = existsSync(luaOutPath) ? luaOutPath : path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
|
|
5245
|
-
const child = spawn(wezExe, ["--config-file", luaArg, "start"], {
|
|
5246
|
-
detached: true,
|
|
5247
|
-
stdio: "ignore",
|
|
5248
|
-
});
|
|
5249
|
-
child.unref();
|
|
5250
|
-
return ok(res, { ok: true, message: "CCandMe launched" });
|
|
5251
|
-
} catch (err) {
|
|
5252
|
-
res.writeHead(500, { "Content-Type": "application/json", ...CORS });
|
|
5253
|
-
return res.end(JSON.stringify({ error: err.message }));
|
|
5254
|
-
}
|
|
5255
|
-
}
|
|
5256
|
-
|
|
5257
|
-
// POST /restart — spawn fresh process then exit (keeps boot.key, vault stays unlocked)
|
|
5258
|
-
if (method === "POST" && reqPath === "/restart") {
|
|
5259
|
-
ok(res, { ok: true, message: "restarting" });
|
|
5260
|
-
const { spawn } = await import("child_process");
|
|
5261
|
-
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
5262
|
-
const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
|
|
5263
|
-
if (password) childArgs.push("--pw", password);
|
|
5264
|
-
if (whitelist) childArgs.push("--services", whitelist.join(","));
|
|
5265
|
-
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
5266
|
-
const out = fs.openSync(LOG_FILE, "a");
|
|
5267
|
-
const child = spawn(process.execPath, childArgs, {
|
|
5268
|
-
detached: true,
|
|
5269
|
-
stdio: ["ignore", out, out],
|
|
5270
|
-
env: { ...process.env, __CLAUTH_DAEMON: "1" },
|
|
5271
|
-
windowsHide: true,
|
|
5272
|
-
});
|
|
5273
|
-
child.unref();
|
|
5274
|
-
stopTunnel();
|
|
5275
|
-
removePid();
|
|
5276
|
-
setTimeout(() => process.exit(0), 300);
|
|
5277
|
-
return;
|
|
5278
|
-
}
|
|
5279
|
-
|
|
5280
|
-
// GET /tintin/manifest — app manifest for the TinTin sidebar loader.
|
|
5281
|
-
// GET /monkey/manifest — backward-compat alias.
|
|
5282
|
-
if (method === "GET" && (reqPath === "/tintin/manifest" || reqPath === "/monkey/manifest")) {
|
|
5283
|
-
return ok(res, {
|
|
5284
|
-
schema_version: 1,
|
|
5285
|
-
app: {
|
|
5286
|
-
slug: "clauth-dashboard",
|
|
5287
|
-
name: "clauth Dashboard",
|
|
5288
|
-
},
|
|
5289
|
-
load: {
|
|
5290
|
-
mode: "external-sidebar",
|
|
5291
|
-
requires_compile_in: false,
|
|
5292
|
-
recommended_loader: "chrome-extension-or-tampermonkey",
|
|
5293
|
-
userscript_url: "/tintin/sidebar.user.js",
|
|
5294
|
-
},
|
|
5295
|
-
local_clauth: {
|
|
5296
|
-
base_url: `http://127.0.0.1:${port}`,
|
|
5297
|
-
capabilities_endpoint: "/tintin/capabilities",
|
|
5298
|
-
session_endpoint: "/tintin/sessions",
|
|
5299
|
-
transport: "tintin",
|
|
5300
|
-
},
|
|
5301
|
-
repo: {
|
|
5302
|
-
root_hint: "C:\\Dev\\regen-root",
|
|
5303
|
-
cwd_hint: "C:\\Dev\\regen-root",
|
|
5304
|
-
},
|
|
5305
|
-
capabilities: ["general_chat", "skill_request", "handoff", "dashboard_test"],
|
|
5306
|
-
agent_context: {
|
|
5307
|
-
app: { slug: "clauth-dashboard" },
|
|
5308
|
-
runtime: { requested_by: "clauth-dashboard-monkey" },
|
|
5309
|
-
},
|
|
5310
|
-
});
|
|
5311
|
-
}
|
|
5312
|
-
|
|
5313
|
-
// GET /tintin/sidebar.user.js — shared app-side TinTin sidebar loader.
|
|
5314
|
-
// GET /monkey/sidebar.user.js — backward-compat alias.
|
|
5315
|
-
if (method === "GET" && (reqPath === "/tintin/sidebar.user.js" || reqPath === "/monkey/sidebar.user.js")) {
|
|
5316
|
-
const candidates = [
|
|
5317
|
-
process.env.CLAUTH_MONKEY_SIDEBAR_SCRIPT,
|
|
5318
|
-
"C:\\Dev\\regen-root\\scripts\\monkey-sidebar.user.js",
|
|
5319
|
-
path.resolve(process.cwd(), "..", "regen-root", "scripts", "monkey-sidebar.user.js"),
|
|
5320
|
-
].filter(Boolean);
|
|
5321
|
-
const scriptPath = candidates.find((candidate) => {
|
|
5322
|
-
try { return fs.existsSync(candidate); } catch { return false; }
|
|
5323
|
-
});
|
|
5324
|
-
if (!scriptPath) {
|
|
5325
|
-
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
5326
|
-
return res.end(JSON.stringify({ error: "monkey_sidebar_script_not_found" }));
|
|
5327
|
-
}
|
|
5328
|
-
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", ...CORS });
|
|
5329
|
-
return res.end(fs.readFileSync(scriptPath, "utf8"));
|
|
5330
|
-
}
|
|
5331
|
-
|
|
5332
|
-
// GET /tintin/capabilities — browser-safe local TinTin sidebar capability probe.
|
|
5333
|
-
// GET /monkey/capabilities — backward-compat alias.
|
|
5334
|
-
if (method === "GET" && (reqPath === "/tintin/capabilities" || reqPath === "/monkey/capabilities")) {
|
|
5335
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5336
|
-
if (!access.allowed) {
|
|
5337
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5338
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5339
|
-
}
|
|
5340
|
-
return ok(res, {
|
|
5341
|
-
ok: true,
|
|
5342
|
-
status: password ? "available" : "locked",
|
|
5343
|
-
locked: !password,
|
|
5344
|
-
trust_mode: access.config.trust_mode || "open-local",
|
|
5345
|
-
app: access.app?.slug || null,
|
|
5346
|
-
config: publicTinTinConfig(access.config),
|
|
5347
|
-
transports: ["dispatch", "sse", "polling", "result"],
|
|
5348
|
-
endpoints: {
|
|
5349
|
-
sessions: "/tintin/sessions",
|
|
5350
|
-
events: "/tintin/sessions/:session_id/events",
|
|
5351
|
-
messages: "/tintin/sessions/:session_id/messages",
|
|
5352
|
-
result: "/tintin/messages/:message_id/result",
|
|
5353
|
-
},
|
|
5354
|
-
});
|
|
5355
|
-
}
|
|
5356
|
-
|
|
5357
|
-
// GET /tintin/config — local operator view of the app allowlist/config.
|
|
5358
|
-
// GET /monkey/config — backward-compat alias.
|
|
5359
|
-
if (method === "GET" && (reqPath === "/tintin/config" || reqPath === "/monkey/config")) {
|
|
5360
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5361
|
-
if (!access.allowed) {
|
|
5362
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5363
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5364
|
-
}
|
|
5365
|
-
return ok(res, { ok: true, ...publicTinTinConfig(access.config) });
|
|
5366
|
-
}
|
|
5367
|
-
|
|
5368
|
-
if (method === "GET" && reqPath === "/tintin/settings/ui") {
|
|
5369
|
-
res.writeHead(200, { "Content-Type": "text/html", ...CORS });
|
|
5370
|
-
return res.end(tintinSettingsHtml());
|
|
5371
|
-
}
|
|
5372
|
-
|
|
5373
|
-
if (method === "GET" && (reqPath === "/tintin/settings" || reqPath === "/monkey/settings")) {
|
|
5374
|
-
const config = loadTinTinConfig();
|
|
5375
|
-
return ok(res, { ok: true, ...publicTinTinConfig(config) });
|
|
5376
|
-
}
|
|
5377
|
-
|
|
5378
|
-
if ((method === "PUT" || method === "POST") && (reqPath === "/tintin/settings" || reqPath === "/monkey/settings")) {
|
|
5379
|
-
let body;
|
|
5380
|
-
try { body = await readBody(req); } catch {
|
|
5381
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5382
|
-
return res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
5383
|
-
}
|
|
5384
|
-
try {
|
|
5385
|
-
const saved = saveTinTinConfig(body || {});
|
|
5386
|
-
return ok(res, { ok: true, ...publicTinTinConfig(saved) });
|
|
5387
|
-
} catch (err) {
|
|
5388
|
-
res.writeHead(500, { "Content-Type": "application/json", ...CORS });
|
|
5389
|
-
return res.end(JSON.stringify({ ok: false, error: "settings_save_failed", message: err.message }));
|
|
5390
|
-
}
|
|
5391
|
-
}
|
|
5392
|
-
|
|
5393
|
-
if (method === "POST" && (reqPath === "/tintin/agent-sessions" || reqPath === "/monkey/agent-sessions")) {
|
|
5394
|
-
let body;
|
|
5395
|
-
try { body = await readBody(req); } catch {
|
|
5396
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5397
|
-
return res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
|
|
5398
|
-
}
|
|
5399
|
-
try {
|
|
5400
|
-
const result = createTinTinAgentSession(body || {});
|
|
5401
|
-
if (!result.ok) {
|
|
5402
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5403
|
-
return res.end(JSON.stringify(result));
|
|
5404
|
-
}
|
|
5405
|
-
return ok(res, result);
|
|
5406
|
-
} catch (err) {
|
|
5407
|
-
res.writeHead(500, { "Content-Type": "application/json", ...CORS });
|
|
5408
|
-
return res.end(JSON.stringify({ ok: false, error: "agent_session_setup_failed", message: err.message }));
|
|
5409
|
-
}
|
|
5410
|
-
}
|
|
5411
|
-
|
|
5412
|
-
// POST /tintin/sessions — create or attach to a local CLI-backed TinTin session.
|
|
5413
|
-
// POST /monkey/sessions — backward-compat alias.
|
|
5414
|
-
if (method === "POST" && (reqPath === "/tintin/sessions" || reqPath === "/monkey/sessions")) {
|
|
5415
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5416
|
-
if (!access.allowed) {
|
|
5417
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5418
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5419
|
-
}
|
|
5420
|
-
let body;
|
|
5421
|
-
try { body = await readBody(req); } catch {
|
|
5422
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5423
|
-
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
5424
|
-
}
|
|
5425
|
-
const session = createTinTinSession(body || {});
|
|
5426
|
-
pushTinTinEvent(session.id, {
|
|
5427
|
-
source: "clauth",
|
|
5428
|
-
type: "status",
|
|
5429
|
-
content: "session ready",
|
|
5430
|
-
payload: { status: "ready" },
|
|
5431
|
-
});
|
|
5432
|
-
return ok(res, {
|
|
5433
|
-
ok: true,
|
|
5434
|
-
session_id: session.id,
|
|
5435
|
-
status: session.status,
|
|
5436
|
-
agent_context: session.agent_context,
|
|
5437
|
-
last_seq: session.seq,
|
|
5438
|
-
});
|
|
5439
|
-
}
|
|
5440
|
-
|
|
5441
|
-
// GET /tintin/sessions — list in-memory local TinTin sessions.
|
|
5442
|
-
// GET /monkey/sessions — backward-compat alias.
|
|
5443
|
-
if (method === "GET" && (reqPath === "/tintin/sessions" || reqPath === "/monkey/sessions")) {
|
|
5444
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5445
|
-
if (!access.allowed) {
|
|
5446
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5447
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5448
|
-
}
|
|
5449
|
-
return ok(res, {
|
|
5450
|
-
ok: true,
|
|
5451
|
-
sessions: [...tintinSessions.values()].map((session) => ({
|
|
5452
|
-
session_id: session.id,
|
|
5453
|
-
status: session.status,
|
|
5454
|
-
created_at: session.created_at,
|
|
5455
|
-
updated_at: session.updated_at,
|
|
5456
|
-
last_seq: session.seq,
|
|
5457
|
-
agent_context: session.agent_context,
|
|
5458
|
-
})),
|
|
5459
|
-
});
|
|
5460
|
-
}
|
|
5461
|
-
|
|
5462
|
-
const tintinSessionMatch = reqPath.match(/^\/(?:tintin|monkey)\/sessions\/([^/]+)$/);
|
|
5463
|
-
if (method === "GET" && tintinSessionMatch) {
|
|
5464
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5465
|
-
if (!access.allowed) {
|
|
5466
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5467
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5468
|
-
}
|
|
5469
|
-
const sessionId = decodeURIComponent(tintinSessionMatch[1]);
|
|
5470
|
-
const session = getTinTinSession(sessionId);
|
|
5471
|
-
if (!session) {
|
|
5472
|
-
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
5473
|
-
return res.end(JSON.stringify({ error: "not_found", session_id: sessionId }));
|
|
5474
|
-
}
|
|
5475
|
-
return ok(res, {
|
|
5476
|
-
ok: true,
|
|
5477
|
-
session_id: session.id,
|
|
5478
|
-
status: session.status,
|
|
5479
|
-
created_at: session.created_at,
|
|
5480
|
-
updated_at: session.updated_at,
|
|
5481
|
-
last_seq: session.seq,
|
|
5482
|
-
agent_context: session.agent_context,
|
|
5483
|
-
});
|
|
5484
|
-
}
|
|
5485
|
-
|
|
5486
|
-
const tintinMessagesMatch = reqPath.match(/^\/(?:tintin|monkey)\/sessions\/([^/]+)\/messages$/);
|
|
5487
|
-
if (method === "GET" && tintinMessagesMatch) {
|
|
5488
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5489
|
-
if (!access.allowed) {
|
|
5490
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5491
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5492
|
-
}
|
|
5493
|
-
const sessionId = decodeURIComponent(tintinMessagesMatch[1]);
|
|
5494
|
-
const afterSeq = Number(url.searchParams.get("after_seq") || url.searchParams.get("after") || 0);
|
|
5495
|
-
const events = listTinTinEvents(sessionId, Number.isFinite(afterSeq) ? afterSeq : 0);
|
|
5496
|
-
if (!events) {
|
|
5497
|
-
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
5498
|
-
return res.end(JSON.stringify({ error: "not_found", session_id: sessionId }));
|
|
5499
|
-
}
|
|
5500
|
-
return ok(res, { ok: true, session_id: sessionId, events });
|
|
5501
|
-
}
|
|
5502
|
-
|
|
5503
|
-
if (method === "POST" && tintinMessagesMatch) {
|
|
5504
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5505
|
-
if (!access.allowed) {
|
|
5506
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5507
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5508
|
-
}
|
|
5509
|
-
if (!password) {
|
|
5510
|
-
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
5511
|
-
return res.end(JSON.stringify({ error: "Vault is locked", locked: true }));
|
|
5512
|
-
}
|
|
5513
|
-
const sessionId = decodeURIComponent(tintinMessagesMatch[1]);
|
|
5514
|
-
let session = getTinTinSession(sessionId);
|
|
5515
|
-
let body;
|
|
5516
|
-
try { body = await readBody(req); } catch {
|
|
5517
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5518
|
-
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
5519
|
-
}
|
|
5520
|
-
if (!session) session = createTinTinSession({ ...(body || {}), session_id: sessionId });
|
|
5521
|
-
const content = String(body?.content || body?.prompt || "").trim();
|
|
5522
|
-
if (!content) {
|
|
5523
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5524
|
-
return res.end(JSON.stringify({ error: "content required" }));
|
|
5525
|
-
}
|
|
5526
|
-
|
|
5527
|
-
const messageId = body.message_id || makeTinTinId("msg");
|
|
5528
|
-
const jobId = body.job_id || messageId;
|
|
5529
|
-
const agentContext = normalizeAgentContext(body.agent_context || {
|
|
5530
|
-
...session.agent_context,
|
|
5531
|
-
task: {
|
|
5532
|
-
...(session.agent_context?.task || {}),
|
|
5533
|
-
intent: body.skill ? "skill_request" : "general_chat",
|
|
5534
|
-
thread_id: sessionId,
|
|
5535
|
-
},
|
|
5536
|
-
});
|
|
5537
|
-
pushTinTinEvent(sessionId, {
|
|
5538
|
-
source: "sidebar",
|
|
5539
|
-
type: "message",
|
|
5540
|
-
role: "user",
|
|
5541
|
-
message_id: messageId,
|
|
5542
|
-
job_id: jobId,
|
|
5543
|
-
content,
|
|
5544
|
-
payload: { skill: body.skill || null },
|
|
5545
|
-
});
|
|
5546
|
-
|
|
5547
|
-
const prompt = buildTinTinPrompt({
|
|
5548
|
-
prompt: body.skill ? `Skill: ${body.skill}\n\n${content}` : content,
|
|
5549
|
-
job_id: jobId,
|
|
5550
|
-
agent_context: agentContext,
|
|
5551
|
-
});
|
|
5552
|
-
const dispatchCwd = resolveDispatchCwd(body.cwd || session.cwd, agentContext);
|
|
5553
|
-
const result = spawnClaudeTask(prompt, jobId, dispatchCwd, agentContext);
|
|
5554
|
-
if (result.error) {
|
|
5555
|
-
pushTinTinEvent(sessionId, {
|
|
5556
|
-
source: "clauth",
|
|
5557
|
-
type: "error",
|
|
5558
|
-
role: "assistant",
|
|
5559
|
-
message_id: messageId,
|
|
5560
|
-
job_id: jobId,
|
|
5561
|
-
content: result.message || result.error,
|
|
5562
|
-
payload: { code: result.error, retryable: result.error === "concurrency_limit" },
|
|
5563
|
-
});
|
|
5564
|
-
res.writeHead(503, { "Content-Type": "application/json", ...CORS });
|
|
5565
|
-
return res.end(JSON.stringify(result));
|
|
5566
|
-
}
|
|
5567
|
-
|
|
5568
|
-
tintinMessageIndex.set(messageId, { session_id: sessionId, job_id: jobId });
|
|
5569
|
-
pushTinTinEvent(sessionId, {
|
|
5570
|
-
source: "clauth",
|
|
5571
|
-
type: "status",
|
|
5572
|
-
role: "assistant",
|
|
5573
|
-
message_id: messageId,
|
|
5574
|
-
job_id: jobId,
|
|
5575
|
-
content: "spawned",
|
|
5576
|
-
payload: result,
|
|
5577
|
-
});
|
|
5578
|
-
startTinTinMessageMonitor(sessionId, messageId, jobId);
|
|
5579
|
-
return ok(res, {
|
|
5580
|
-
ok: true,
|
|
5581
|
-
session_id: sessionId,
|
|
5582
|
-
message_id: messageId,
|
|
5583
|
-
job_id: jobId,
|
|
5584
|
-
status: "spawned",
|
|
5585
|
-
dispatch: result,
|
|
5586
|
-
});
|
|
5587
|
-
}
|
|
5588
|
-
|
|
5589
|
-
const tintinEventsMatch = reqPath.match(/^\/(?:tintin|monkey)\/sessions\/([^/]+)\/events$/);
|
|
5590
|
-
if (method === "GET" && tintinEventsMatch) {
|
|
5591
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5592
|
-
if (!access.allowed) {
|
|
5593
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5594
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5595
|
-
}
|
|
5596
|
-
const sessionId = decodeURIComponent(tintinEventsMatch[1]);
|
|
5597
|
-
const afterSeq = Number(url.searchParams.get("after_seq") || url.searchParams.get("after") || 0);
|
|
5598
|
-
const session = getTinTinSession(sessionId);
|
|
5599
|
-
if (!session) {
|
|
5600
|
-
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
5601
|
-
return res.end(JSON.stringify({ error: "not_found", session_id: sessionId }));
|
|
5602
|
-
}
|
|
5603
|
-
|
|
5604
|
-
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "Connection": "keep-alive", ...CORS });
|
|
5605
|
-
const send = (event) => {
|
|
5606
|
-
const eventType = event.type || "message";
|
|
5607
|
-
res.write(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
5608
|
-
};
|
|
5609
|
-
res.write(`event: ready\ndata: ${JSON.stringify({ ok: true, session_id: sessionId, last_seq: session.seq })}\n\n`);
|
|
5610
|
-
for (const event of listTinTinEvents(sessionId, Number.isFinite(afterSeq) ? afterSeq : 0) || []) send(event);
|
|
5611
|
-
const unsubscribe = subscribeTinTinEvents(sessionId, send);
|
|
5612
|
-
const heartbeat = setInterval(() => {
|
|
5613
|
-
try { res.write(`event: heartbeat\ndata: ${JSON.stringify({ session_id: sessionId, at: new Date().toISOString() })}\n\n`); }
|
|
5614
|
-
catch { clearInterval(heartbeat); unsubscribe(); }
|
|
5615
|
-
}, 15000);
|
|
5616
|
-
req.on("close", () => {
|
|
5617
|
-
clearInterval(heartbeat);
|
|
5618
|
-
unsubscribe();
|
|
5619
|
-
});
|
|
5620
|
-
return;
|
|
5621
|
-
}
|
|
5622
|
-
|
|
5623
|
-
const tintinResultMatch = reqPath.match(/^\/(?:tintin|monkey)\/messages\/([^/]+)\/result$/);
|
|
5624
|
-
if (method === "GET" && tintinResultMatch) {
|
|
5625
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5626
|
-
if (!access.allowed) {
|
|
5627
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5628
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5629
|
-
}
|
|
5630
|
-
const messageId = decodeURIComponent(tintinResultMatch[1]);
|
|
5631
|
-
const ref = tintinMessageIndex.get(messageId);
|
|
5632
|
-
if (!ref) {
|
|
5633
|
-
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
5634
|
-
return res.end(JSON.stringify({ error: "not_found", message_id: messageId }));
|
|
5635
|
-
}
|
|
5636
|
-
const job = tintinJobs.get(ref.job_id);
|
|
5637
|
-
const session = getTinTinSession(ref.session_id);
|
|
5638
|
-
return ok(res, {
|
|
5639
|
-
ok: true,
|
|
5640
|
-
message_id: messageId,
|
|
5641
|
-
session_id: ref.session_id,
|
|
5642
|
-
job_id: ref.job_id,
|
|
5643
|
-
status: job?.status || "unknown",
|
|
5644
|
-
stdout: job?.stdout || "",
|
|
5645
|
-
stderr: job?.stderr || "",
|
|
5646
|
-
events: session?.events.filter((event) => event.message_id === messageId) || [],
|
|
5647
|
-
});
|
|
5648
|
-
}
|
|
5649
|
-
|
|
5650
|
-
// POST /tintin/dispatch — browser-safe, config-checked CLI worker spawn.
|
|
5651
|
-
// POST /monkey/dispatch, /dispatch, and /monkey-dispatch remain legacy backward-compat aliases.
|
|
5652
|
-
if (method === "POST" && (reqPath === "/tintin/dispatch" || reqPath === "/dispatch" || reqPath === "/monkey-dispatch" || reqPath === "/monkey/dispatch")) {
|
|
5653
|
-
if (reqPath === "/tintin/dispatch" || reqPath === "/monkey/dispatch") {
|
|
5654
|
-
const access = checkTinTinBrowserAccess(req);
|
|
5655
|
-
if (!access.allowed) {
|
|
5656
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5657
|
-
return res.end(JSON.stringify({ ok: false, error: access.reason, origin: access.origin || null }));
|
|
5658
|
-
}
|
|
5659
|
-
} else if (req.headers.origin) {
|
|
5660
|
-
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
5661
|
-
return res.end(JSON.stringify({ ok: false, error: "legacy_dispatch_rejects_browser_origin", use: "/tintin/dispatch" }));
|
|
5662
|
-
}
|
|
5663
|
-
let body = "";
|
|
5664
|
-
req.on("data", d => body += d);
|
|
5665
|
-
req.on("end", () => {
|
|
5666
|
-
try {
|
|
5667
|
-
const { prompt, job_id, cwd, agent_context } = JSON.parse(body || "{}");
|
|
5668
|
-
if (!prompt && !job_id) {
|
|
5669
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5670
|
-
return res.end(JSON.stringify({ error: "prompt required" }));
|
|
5671
|
-
}
|
|
5672
|
-
const normalizedContext = normalizeAgentContext(agent_context);
|
|
5673
|
-
const wrappedPrompt = buildTinTinPrompt({ prompt, job_id, agent_context: normalizedContext });
|
|
5674
|
-
const dispatchCwd = resolveDispatchCwd(cwd, normalizedContext);
|
|
5675
|
-
const result = spawnClaudeTask(wrappedPrompt, job_id || "untracked", dispatchCwd, normalizedContext);
|
|
5676
|
-
const status = result.error ? 503 : 200;
|
|
5677
|
-
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5678
|
-
res.end(JSON.stringify({ ...result, context: normalizedContext }));
|
|
5679
|
-
} catch {
|
|
5680
|
-
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5681
|
-
res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
5682
|
-
}
|
|
5683
|
-
});
|
|
5684
|
-
return;
|
|
5280
|
+
operation("tunnel.start", {}, null, { ok: true, status: "starting" });
|
|
5281
|
+
return ok(res, { status: "starting" });
|
|
5685
5282
|
}
|
|
5686
5283
|
|
|
5687
|
-
// POST /
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
if (!job) {
|
|
5694
|
-
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
5695
|
-
return res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
|
5696
|
-
}
|
|
5697
|
-
if (job.pid && job.status === "running") {
|
|
5698
|
-
try { process.kill(job.pid); } catch {}
|
|
5699
|
-
job.status = "killed";
|
|
5700
|
-
job.completed_at = new Date().toISOString();
|
|
5701
|
-
}
|
|
5702
|
-
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
5703
|
-
return res.end(JSON.stringify({ ok: true, status: job.status, jobId }));
|
|
5284
|
+
// POST /tunnel/stop — explicit stop endpoint
|
|
5285
|
+
if (method === "POST" && reqPath === "/tunnel/stop") {
|
|
5286
|
+
if (lockedGuard(res)) return;
|
|
5287
|
+
stopTunnel();
|
|
5288
|
+
operation("tunnel.stop", {}, null, { ok: true });
|
|
5289
|
+
return ok(res, { status: tunnelStatus });
|
|
5704
5290
|
}
|
|
5705
5291
|
|
|
5706
|
-
|
|
5707
|
-
if (method === "
|
|
5708
|
-
if (
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5292
|
+
// POST /launch-ccandme — open a new CCandMe WezTerm window without killing existing sessions
|
|
5293
|
+
if (method === "POST" && reqPath === "/launch-ccandme") {
|
|
5294
|
+
if (lockedGuard(res)) return;
|
|
5295
|
+
try {
|
|
5296
|
+
const { spawn } = await import("child_process");
|
|
5297
|
+
const { existsSync, readFileSync, writeFileSync } = await import("fs");
|
|
5298
|
+
|
|
5299
|
+
// Locate WezTerm
|
|
5300
|
+
const wezCandidates = [
|
|
5301
|
+
"C:/Program Files/WezTerm/wezterm.exe",
|
|
5302
|
+
"C:/Program Files (x86)/WezTerm/wezterm.exe",
|
|
5303
|
+
process.env.WEZTERM_EXE,
|
|
5304
|
+
].filter(Boolean);
|
|
5305
|
+
const wezExe = wezCandidates.find(existsSync) || "wezterm";
|
|
5306
|
+
|
|
5307
|
+
// Render the lua config from the CCandMe template (skip the kill-existing step)
|
|
5308
|
+
const CCANDME_DIR = "C:/Dev/CCandMe";
|
|
5309
|
+
const WORK_DIR = "C:/Dev/regen-root";
|
|
5310
|
+
const templatePath = path.join(CCANDME_DIR, "templates", "wezterm.lua");
|
|
5311
|
+
const luaOutPath = path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
|
|
5312
|
+
|
|
5313
|
+
if (existsSync(templatePath)) {
|
|
5314
|
+
const lua = readFileSync(templatePath, "utf8")
|
|
5315
|
+
.replaceAll("__SUPERVISOR_DIR__", CCANDME_DIR.replace(/\//g, "\\\\"))
|
|
5316
|
+
.replaceAll("__WORK_DIR__", WORK_DIR.replace(/\//g, "\\\\"))
|
|
5317
|
+
.replaceAll("__WORKSPACE__", "ccandme")
|
|
5318
|
+
.replaceAll("__CLAUDE_CMD__", "claude")
|
|
5319
|
+
.replaceAll("__CODEX_CMD__", "codex")
|
|
5320
|
+
.replaceAll("__PACKAGE_ROOT__", CCANDME_DIR.replace(/\//g, "\\\\"));
|
|
5321
|
+
writeFileSync(luaOutPath, lua, "utf8");
|
|
5713
5322
|
}
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5323
|
+
|
|
5324
|
+
// Launch WezTerm with the CCandMe config — no kill of existing sessions
|
|
5325
|
+
const luaArg = existsSync(luaOutPath) ? luaOutPath : path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
|
|
5326
|
+
const child = spawn(wezExe, ["--config-file", luaArg, "start"], {
|
|
5327
|
+
detached: true,
|
|
5328
|
+
stdio: "ignore",
|
|
5329
|
+
});
|
|
5330
|
+
child.unref();
|
|
5331
|
+
operation("ccandme.launch", {}, null, { ok: true });
|
|
5332
|
+
return ok(res, { ok: true, message: "CCandMe launched" });
|
|
5333
|
+
} catch (err) {
|
|
5334
|
+
operation("ccandme.launch", {}, null, { ok: false, error: err.message });
|
|
5335
|
+
res.writeHead(500, { "Content-Type": "application/json", ...CORS });
|
|
5336
|
+
return res.end(JSON.stringify({ error: err.message }));
|
|
5723
5337
|
}
|
|
5724
|
-
|
|
5725
|
-
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
// POST /restart — spawn fresh process then exit (keeps boot.key, vault stays unlocked)
|
|
5341
|
+
if (method === "POST" && reqPath === "/restart") {
|
|
5342
|
+
operation("daemon.restart_requested", { port }, null, { ok: true });
|
|
5343
|
+
ok(res, { ok: true, message: "restarting" });
|
|
5344
|
+
const { spawn } = await import("child_process");
|
|
5345
|
+
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
5346
|
+
const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
|
|
5347
|
+
if (password) childArgs.push("--pw", password);
|
|
5348
|
+
if (whitelist) childArgs.push("--services", whitelist.join(","));
|
|
5349
|
+
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
5350
|
+
const out = fs.openSync(LOG_FILE, "a");
|
|
5351
|
+
const child = spawn(process.execPath, childArgs, {
|
|
5352
|
+
detached: true,
|
|
5353
|
+
stdio: ["ignore", out, out],
|
|
5354
|
+
env: { ...process.env, __CLAUTH_DAEMON: "1" },
|
|
5355
|
+
windowsHide: true,
|
|
5356
|
+
});
|
|
5357
|
+
child.unref();
|
|
5358
|
+
stopTunnel();
|
|
5359
|
+
removePid();
|
|
5360
|
+
setTimeout(() => process.exit(0), 300);
|
|
5361
|
+
return;
|
|
5726
5362
|
}
|
|
5727
5363
|
|
|
5728
5364
|
// ── call_agent (Gate B) ───────────────────────────────────────────────────
|
|
@@ -5863,6 +5499,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5863
5499
|
try {
|
|
5864
5500
|
const { name } = JSON.parse(body || "{}");
|
|
5865
5501
|
const result = await startChitchatSession(name || "collab");
|
|
5502
|
+
operation("chitchat.start", { name: name || "collab" }, null, { ok: !result?.error, session_id: result?.session_id, error: result?.error });
|
|
5866
5503
|
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
5867
5504
|
res.end(JSON.stringify(result));
|
|
5868
5505
|
} catch (e) {
|
|
@@ -5873,14 +5510,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5873
5510
|
return;
|
|
5874
5511
|
}
|
|
5875
5512
|
|
|
5876
|
-
// POST /handoff
|
|
5877
|
-
if (method === "POST" &&
|
|
5513
|
+
// POST /handoff — visible local handoff from claude.ai or a sidebar.
|
|
5514
|
+
if (method === "POST" && reqPath === "/handoff") {
|
|
5878
5515
|
let body;
|
|
5879
5516
|
try { body = await readBody(req); } catch {
|
|
5880
5517
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5881
5518
|
return res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
5882
5519
|
}
|
|
5883
5520
|
const result = await startHandoffSession(body || {});
|
|
5521
|
+
operation("handoff.start", {}, null, { ok: !result.error, error: result.error });
|
|
5884
5522
|
const status = result.error ? 503 : 200;
|
|
5885
5523
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5886
5524
|
return res.end(JSON.stringify(result));
|
|
@@ -5898,6 +5536,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5898
5536
|
return res.end(JSON.stringify({ error: "session_id and message required" }));
|
|
5899
5537
|
}
|
|
5900
5538
|
const result = sendChitchatMessage(session_id, message);
|
|
5539
|
+
operation("chitchat.send", { session_id }, null, { ok: !result.error, error: result.error });
|
|
5901
5540
|
const status = result.error === 'not_found' ? 404 : result.error ? 400 : 200;
|
|
5902
5541
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5903
5542
|
res.end(JSON.stringify(result));
|
|
@@ -5922,6 +5561,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5922
5561
|
}
|
|
5923
5562
|
const tier = knowledge_tier || 'db_only';
|
|
5924
5563
|
const result = startTerminalSession(name, tier, context_md || null);
|
|
5564
|
+
operation("terminal.start", { name, tier }, null, { ok: !result.error, error: result.error });
|
|
5925
5565
|
const status = result.error ? 503 : 200;
|
|
5926
5566
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5927
5567
|
res.end(JSON.stringify(result));
|
|
@@ -5945,6 +5585,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5945
5585
|
return res.end(JSON.stringify({ error: "session_id and message required" }));
|
|
5946
5586
|
}
|
|
5947
5587
|
const result = sendTerminalMessage(session_id, message);
|
|
5588
|
+
operation("terminal.send", { session_id }, null, { ok: !result.error, error: result.error });
|
|
5948
5589
|
const status = result.error === 'session_busy' ? 409 : result.error ? 404 : 200;
|
|
5949
5590
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5950
5591
|
res.end(JSON.stringify(result));
|
|
@@ -5998,6 +5639,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5998
5639
|
return res.end(JSON.stringify({ error: "session_id required" }));
|
|
5999
5640
|
}
|
|
6000
5641
|
const result = stopTerminalSession(session_id);
|
|
5642
|
+
operation("terminal.stop", { session_id }, null, { ok: !result.error, error: result.error });
|
|
6001
5643
|
const status = result.error ? 404 : 200;
|
|
6002
5644
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
6003
5645
|
res.end(JSON.stringify(result));
|
|
@@ -6057,6 +5699,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6057
5699
|
channelEvents.push(entry);
|
|
6058
5700
|
if (channelEvents.length > MAX_CHANNEL_EVENTS) channelEvents.shift();
|
|
6059
5701
|
console.log(`[channel] queued event ${eventId} type=${event}`);
|
|
5702
|
+
operation("channel.event", { event, resource, repository }, null, { ok: true, event_id: eventId });
|
|
6060
5703
|
return ok(res, { received: true, event_id: eventId });
|
|
6061
5704
|
} catch {
|
|
6062
5705
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
@@ -6069,6 +5712,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6069
5712
|
// GET|POST /shutdown (for daemon stop — programmatic, keeps boot.key)
|
|
6070
5713
|
// Accept POST as well — older scripts and curl default to POST
|
|
6071
5714
|
if ((method === "GET" || method === "POST") && reqPath === "/shutdown") {
|
|
5715
|
+
operation("daemon.shutdown", { port }, null, { ok: true });
|
|
6072
5716
|
stopTunnel();
|
|
6073
5717
|
ok(res, { ok: true, message: "shutting down" });
|
|
6074
5718
|
removePid();
|
|
@@ -6078,6 +5722,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6078
5722
|
|
|
6079
5723
|
// POST /shutdown-ui (user-initiated stop — clears boot.key so password is required on restart)
|
|
6080
5724
|
if (method === "POST" && reqPath === "/shutdown-ui") {
|
|
5725
|
+
operation("daemon.shutdown_ui", { port }, null, { ok: true });
|
|
6081
5726
|
stopTunnel();
|
|
6082
5727
|
// Clear boot.key so watchdog can't auto-unlock on restart
|
|
6083
5728
|
const bootKeyPath = getBootKeyPath();
|
|
@@ -6159,10 +5804,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6159
5804
|
if (promoted) {
|
|
6160
5805
|
const okLog = `[${new Date().toISOString()}] Make-live: promoted to live on port ${LIVE_PORT}\n`;
|
|
6161
5806
|
try { fs.appendFileSync(LOG_FILE, okLog); } catch {}
|
|
5807
|
+
operation("daemon.make_live", { from_port: port, live_port: LIVE_PORT }, null, { ok: true });
|
|
6162
5808
|
ok(res, { ok: true, message: "promoted to live", live_port: LIVE_PORT });
|
|
6163
5809
|
} else {
|
|
6164
5810
|
const failLog = `[${new Date().toISOString()}] Make-live: new daemon failed to start on port ${LIVE_PORT}\n`;
|
|
6165
5811
|
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
5812
|
+
operation("daemon.make_live", { from_port: port, live_port: LIVE_PORT }, null, { ok: false, error: "new daemon failed to start" });
|
|
6166
5813
|
ok(res, { ok: false, error: "New daemon failed to start on live port — check log" });
|
|
6167
5814
|
}
|
|
6168
5815
|
|
|
@@ -6501,6 +6148,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6501
6148
|
tunnelStatus = "starting";
|
|
6502
6149
|
startTunnel().catch(() => {});
|
|
6503
6150
|
}
|
|
6151
|
+
operation("vault.unlock", {}, null, { ok: true });
|
|
6504
6152
|
return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
6505
6153
|
} catch (authErr) {
|
|
6506
6154
|
const msg = authErr.message || "";
|
|
@@ -6526,6 +6174,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6526
6174
|
// No strike, no hard-lock — the verdict was never rendered.
|
|
6527
6175
|
const failLog = `[${new Date().toISOString()}] [BACKEND ${backendKind}] vault backend unreachable, no auth strike — ${detail}\n`;
|
|
6528
6176
|
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
6177
|
+
operation("vault.unlock", {}, null, { ok: false, backend_error: true, kind: backendKind });
|
|
6529
6178
|
res.writeHead(503, { "Content-Type": "application/json", ...CORS });
|
|
6530
6179
|
return res.end(JSON.stringify({
|
|
6531
6180
|
error: friendly,
|
|
@@ -6550,6 +6199,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6550
6199
|
authHardLocked = true;
|
|
6551
6200
|
const lockLog = `[${new Date().toISOString()}] Server rejected with terminal verdict${reasonSuffix} — hard-locking locally to stop strike accrual; recover via the runbook (unlock machine + re-seal boot.key)\n`;
|
|
6552
6201
|
try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
|
|
6202
|
+
operation("vault.unlock", {}, null, { ok: false, terminal: true, reason: serverReason });
|
|
6553
6203
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
6554
6204
|
return res.end(JSON.stringify({ error: "Vault rejected credentials — recovery required", reason: serverReason, hard_locked: true, terminal: true }));
|
|
6555
6205
|
}
|
|
@@ -6557,9 +6207,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6557
6207
|
authHardLocked = true;
|
|
6558
6208
|
const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
|
|
6559
6209
|
try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
|
|
6210
|
+
operation("vault.unlock", {}, null, { ok: false, hard_locked: true, reason: serverReason });
|
|
6560
6211
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
6561
6212
|
return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", reason: serverReason, hard_locked: true }));
|
|
6562
6213
|
}
|
|
6214
|
+
operation("vault.unlock", {}, null, { ok: false, reason: serverReason, failures_remaining: authRemaining });
|
|
6563
6215
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
6564
6216
|
return res.end(JSON.stringify({ error: "Invalid password", reason: serverReason, failures_remaining: authRemaining }));
|
|
6565
6217
|
}
|
|
@@ -6573,6 +6225,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6573
6225
|
stopTunnel();
|
|
6574
6226
|
const logLine = `[${new Date().toISOString()}] Vault locked\n`;
|
|
6575
6227
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
6228
|
+
operation("vault.lock", {}, null, { ok: true });
|
|
6576
6229
|
return ok(res, { ok: true, locked: true, hard_locked: authHardLocked });
|
|
6577
6230
|
}
|
|
6578
6231
|
|
|
@@ -6594,10 +6247,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6594
6247
|
try {
|
|
6595
6248
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
6596
6249
|
const result = await api.updateService(password, machineHash, token, timestamp, service, { name: newName, label: newName });
|
|
6597
|
-
if (result.error)
|
|
6250
|
+
if (result.error) {
|
|
6251
|
+
operation("service.rename", { service, new_name: newName }, null, { ok: false, error: result.error });
|
|
6252
|
+
return strike(res, 502, result.error);
|
|
6253
|
+
}
|
|
6598
6254
|
invalidateServiceStatusCache(machineHash);
|
|
6255
|
+
operation("service.rename", { service, new_name: newName }, null, { ok: true });
|
|
6599
6256
|
return ok(res, { ok: true, old_name: service, new_name: newName });
|
|
6600
6257
|
} catch (err) {
|
|
6258
|
+
operation("service.rename", { service, new_name: newName }, null, { ok: false, error: err.message });
|
|
6601
6259
|
return strike(res, 502, err.message);
|
|
6602
6260
|
}
|
|
6603
6261
|
}
|
|
@@ -6610,10 +6268,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6610
6268
|
try {
|
|
6611
6269
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
6612
6270
|
const result = await api.removeService(password, machineHash, token, timestamp, service, `CONFIRM REMOVE ${service.toUpperCase()}`);
|
|
6613
|
-
if (result.error)
|
|
6271
|
+
if (result.error) {
|
|
6272
|
+
operation("service.delete", { service }, null, { ok: false, error: result.error });
|
|
6273
|
+
return strike(res, 502, result.error);
|
|
6274
|
+
}
|
|
6614
6275
|
invalidateServiceStatusCache(machineHash);
|
|
6276
|
+
operation("service.delete", { service }, null, { ok: true });
|
|
6615
6277
|
return ok(res, { ok: true, deleted: service });
|
|
6616
6278
|
} catch (err) {
|
|
6279
|
+
operation("service.delete", { service }, null, { ok: false, error: err.message });
|
|
6617
6280
|
return strike(res, 502, err.message);
|
|
6618
6281
|
}
|
|
6619
6282
|
}
|
|
@@ -6639,10 +6302,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6639
6302
|
try {
|
|
6640
6303
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
6641
6304
|
const result = await api.enable(password, machineHash, token, timestamp, service, enabled);
|
|
6642
|
-
if (result.error)
|
|
6305
|
+
if (result.error) {
|
|
6306
|
+
operation("service.toggle", { service, enabled }, null, { ok: false, error: result.error });
|
|
6307
|
+
return strike(res, 502, result.error);
|
|
6308
|
+
}
|
|
6643
6309
|
invalidateServiceStatusCache(machineHash);
|
|
6310
|
+
operation("service.toggle", { service, enabled }, null, { ok: true });
|
|
6644
6311
|
return ok(res, { ok: true, service, enabled });
|
|
6645
6312
|
} catch (err) {
|
|
6313
|
+
operation("service.toggle", { service, enabled }, null, { ok: false, error: err.message });
|
|
6646
6314
|
return strike(res, 502, err.message);
|
|
6647
6315
|
}
|
|
6648
6316
|
}
|
|
@@ -6729,6 +6397,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6729
6397
|
return res.end(JSON.stringify({ error: "Service name required" }));
|
|
6730
6398
|
}
|
|
6731
6399
|
const result = await rotationEngine.rotateService(service);
|
|
6400
|
+
operation("service.rotate", { service }, null, { ok: !result?.error, error: result?.error });
|
|
6732
6401
|
return ok(res, result);
|
|
6733
6402
|
}
|
|
6734
6403
|
|
|
@@ -6742,6 +6411,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6742
6411
|
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
6743
6412
|
}
|
|
6744
6413
|
rotationEngine.setExpiry(service, body.expires_at, body.rotation_days);
|
|
6414
|
+
operation("service.set_expiry", { service }, null, { ok: true, expires_at: body.expires_at, rotation_days: body.rotation_days });
|
|
6745
6415
|
return ok(res, { ok: true, service, expires_at: body.expires_at });
|
|
6746
6416
|
}
|
|
6747
6417
|
|
|
@@ -6925,8 +6595,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6925
6595
|
});
|
|
6926
6596
|
}
|
|
6927
6597
|
|
|
6598
|
+
operation("tunnel.setup.cf_token", {}, null, { ok: true, accountId, accountName });
|
|
6928
6599
|
return ok(res, { ok: true, accountId, accountName });
|
|
6929
6600
|
} catch (err) {
|
|
6601
|
+
operation("tunnel.setup.cf_token", {}, null, { ok: false, error: err.message });
|
|
6930
6602
|
return strike(res, 502, err.message);
|
|
6931
6603
|
}
|
|
6932
6604
|
}
|
|
@@ -6997,8 +6669,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6997
6669
|
tunnelHostname = hostname;
|
|
6998
6670
|
tunnelUrl = `https://${hostname}`;
|
|
6999
6671
|
|
|
6672
|
+
operation("tunnel.setup.cf_save", { hostname, tunnelId }, null, { ok: true });
|
|
7000
6673
|
return ok(res, { ok: true, hostname });
|
|
7001
6674
|
} catch (err) {
|
|
6675
|
+
operation("tunnel.setup.cf_save", { hostname }, null, { ok: false, error: err.message });
|
|
7002
6676
|
return strike(res, 502, err.message);
|
|
7003
6677
|
}
|
|
7004
6678
|
}
|
|
@@ -7078,8 +6752,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7078
6752
|
tunnelHostname = hostname;
|
|
7079
6753
|
tunnelUrl = `https://${hostname}`;
|
|
7080
6754
|
|
|
6755
|
+
operation("tunnel.setup.cf_create_api", { hostname, tunnelId }, null, { ok: true });
|
|
7081
6756
|
return ok(res, { ok: true, tunnelId, hostname });
|
|
7082
6757
|
} catch (err) {
|
|
6758
|
+
operation("tunnel.setup.cf_create_api", { hostname }, null, { ok: false, error: err.message });
|
|
7083
6759
|
return strike(res, 502, err.message);
|
|
7084
6760
|
}
|
|
7085
6761
|
}
|
|
@@ -7120,9 +6796,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7120
6796
|
const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"], windowsHide: true });
|
|
7121
6797
|
proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
7122
6798
|
proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
7123
|
-
proc.on("close", code => {
|
|
6799
|
+
proc.on("close", code => {
|
|
6800
|
+
operation("tunnel.setup.cf_login", {}, null, { ok: code === 0, exit_code: code });
|
|
6801
|
+
sendEvt({ done: true, code }); res.end();
|
|
6802
|
+
});
|
|
7124
6803
|
req.on("close", () => { try { proc.kill(); } catch {} });
|
|
7125
6804
|
} catch (err) {
|
|
6805
|
+
operation("tunnel.setup.cf_login", {}, null, { ok: false, error: err.message });
|
|
7126
6806
|
sendEvt({ done: true, code: 1, error: err.message });
|
|
7127
6807
|
res.end();
|
|
7128
6808
|
}
|
|
@@ -7194,9 +6874,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7194
6874
|
tunnelHostname = hostname;
|
|
7195
6875
|
tunnelUrl = `https://${hostname}`;
|
|
7196
6876
|
|
|
6877
|
+
operation("tunnel.setup.cf_create", { name, hostname, tunnelId }, null, { ok: true });
|
|
7197
6878
|
sendEvt({ done: true, tunnelId, hostname });
|
|
7198
6879
|
res.end();
|
|
7199
6880
|
} catch (err) {
|
|
6881
|
+
operation("tunnel.setup.cf_create", { name, hostname }, null, { ok: false, error: err.message });
|
|
7200
6882
|
sendEvt({ error: err.message, done: true });
|
|
7201
6883
|
res.end();
|
|
7202
6884
|
}
|
|
@@ -7228,8 +6910,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7228
6910
|
writeSession = makeWriteToken();
|
|
7229
6911
|
const logLine = `[${new Date().toISOString()}] Password changed\n`;
|
|
7230
6912
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
6913
|
+
operation("vault.change_password", {}, null, { ok: true });
|
|
7231
6914
|
return ok(res, { ok: true, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
7232
6915
|
} catch (err) {
|
|
6916
|
+
operation("vault.change_password", {}, null, { ok: false, error: err.message });
|
|
7233
6917
|
res.writeHead(502, { "Content-Type": "application/json", ...CORS });
|
|
7234
6918
|
return res.end(JSON.stringify({ error: err.message }));
|
|
7235
6919
|
}
|
|
@@ -7257,11 +6941,17 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7257
6941
|
}
|
|
7258
6942
|
|
|
7259
6943
|
try {
|
|
6944
|
+
// NEVER log `value` — it is the secret being written.
|
|
7260
6945
|
const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password, machineHash, service, value, logFile: LOG_FILE });
|
|
7261
|
-
if (result.error)
|
|
6946
|
+
if (result.error) {
|
|
6947
|
+
operation("service.set", { service }, null, { ok: false, error: result.error });
|
|
6948
|
+
return strike(res, 502, result.error);
|
|
6949
|
+
}
|
|
7262
6950
|
invalidateServiceStatusCache(machineHash);
|
|
6951
|
+
operation("service.set", { service }, null, { ok: true, normalized });
|
|
7263
6952
|
return ok(res, { ok: true, service, recovery_snapshot: snapshot?.ok ? true : false, normalized });
|
|
7264
6953
|
} catch (err) {
|
|
6954
|
+
operation("service.set", { service }, null, { ok: false, error: err.message });
|
|
7265
6955
|
return strike(res, 502, err.message);
|
|
7266
6956
|
}
|
|
7267
6957
|
}
|
|
@@ -7290,11 +6980,17 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7290
6980
|
try {
|
|
7291
6981
|
const randomHex = crypto.randomBytes(32).toString("hex");
|
|
7292
6982
|
const token = `${prefix}${randomHex}`;
|
|
6983
|
+
// NEVER log `token` — it is the secret being generated/stored.
|
|
7293
6984
|
const { result, snapshot } = await writeCredentialWithRecovery({ password, machineHash, service, value: token, logFile: LOG_FILE, normalize: false });
|
|
7294
|
-
if (result.error)
|
|
6985
|
+
if (result.error) {
|
|
6986
|
+
operation("service.generate_token", { service }, null, { ok: false, error: result.error });
|
|
6987
|
+
return strike(res, 502, result.error);
|
|
6988
|
+
}
|
|
7295
6989
|
invalidateServiceStatusCache(machineHash);
|
|
6990
|
+
operation("service.generate_token", { service }, null, { ok: true });
|
|
7296
6991
|
return ok(res, { token, service, stored: true, recovery_snapshot: snapshot?.ok ? true : false });
|
|
7297
6992
|
} catch (err) {
|
|
6993
|
+
operation("service.generate_token", { service }, null, { ok: false, error: err.message });
|
|
7298
6994
|
return strike(res, 502, err.message);
|
|
7299
6995
|
}
|
|
7300
6996
|
}
|
|
@@ -7324,10 +7020,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7324
7020
|
try {
|
|
7325
7021
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
7326
7022
|
const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "", project || undefined);
|
|
7327
|
-
if (result.error)
|
|
7023
|
+
if (result.error) {
|
|
7024
|
+
operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: false, error: result.error });
|
|
7025
|
+
return strike(res, 502, result.error);
|
|
7026
|
+
}
|
|
7328
7027
|
invalidateServiceStatusCache(machineHash);
|
|
7028
|
+
operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: true, key_type: type });
|
|
7329
7029
|
return ok(res, { ok: true, service: name.trim().toLowerCase() });
|
|
7330
7030
|
} catch (err) {
|
|
7031
|
+
operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: false, error: err.message });
|
|
7331
7032
|
return strike(res, 502, err.message);
|
|
7332
7033
|
}
|
|
7333
7034
|
}
|
|
@@ -7361,10 +7062,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7361
7062
|
try {
|
|
7362
7063
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
7363
7064
|
const result = await api.updateService(password, machineHash, token, timestamp, service.toLowerCase(), updates);
|
|
7364
|
-
if (result.error)
|
|
7065
|
+
if (result.error) {
|
|
7066
|
+
operation("service.update", { name: service.toLowerCase() }, null, { ok: false, error: result.error });
|
|
7067
|
+
return strike(res, 502, result.error);
|
|
7068
|
+
}
|
|
7365
7069
|
invalidateServiceStatusCache(machineHash);
|
|
7070
|
+
operation("service.update", { name: service.toLowerCase() }, null, { ok: true, fields: Object.keys(updates) });
|
|
7366
7071
|
return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
|
|
7367
7072
|
} catch (err) {
|
|
7073
|
+
operation("service.update", { name: service.toLowerCase() }, null, { ok: false, error: err.message });
|
|
7368
7074
|
return strike(res, 502, err.message);
|
|
7369
7075
|
}
|
|
7370
7076
|
}
|
|
@@ -7415,11 +7121,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7415
7121
|
try {
|
|
7416
7122
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
7417
7123
|
const result = await api.createEnrollment(password, machineHash, token, timestamp, label, ttlMinutes);
|
|
7418
|
-
if (result.error)
|
|
7124
|
+
if (result.error) {
|
|
7125
|
+
operation("machine.enroll", { label, target }, null, { ok: false, error: result.error });
|
|
7126
|
+
return strike(res, 502, result.error);
|
|
7127
|
+
}
|
|
7419
7128
|
const localConfig = new Conf(getConfOptions());
|
|
7420
7129
|
const supabaseUrl = localConfig.get("supabase_url") || process.env.CLAUTH_SUPABASE_URL || "";
|
|
7421
7130
|
const anonKey = localConfig.get("supabase_anon_key") || process.env.CLAUTH_SUPABASE_ANON_KEY || "";
|
|
7131
|
+
// NEVER log `enrollment_code` — it is a one-time credential.
|
|
7422
7132
|
const scriptPath = writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode: result.enrollment_code, label, target });
|
|
7133
|
+
operation("machine.enroll", { label, target }, null, { ok: true, expires_at: result.expires_at });
|
|
7423
7134
|
return ok(res, {
|
|
7424
7135
|
ok: true,
|
|
7425
7136
|
enrollment_code: result.enrollment_code,
|
|
@@ -7427,6 +7138,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7427
7138
|
script_path: scriptPath,
|
|
7428
7139
|
});
|
|
7429
7140
|
} catch (err) {
|
|
7141
|
+
operation("machine.enroll", { label, target }, null, { ok: false, error: err.message });
|
|
7430
7142
|
return strike(res, 502, err.message);
|
|
7431
7143
|
}
|
|
7432
7144
|
}
|
|
@@ -7465,6 +7177,27 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7465
7177
|
});
|
|
7466
7178
|
}
|
|
7467
7179
|
|
|
7180
|
+
// The localhost supervisor is the only process allowed to repair clauth-owned
|
|
7181
|
+
// local surfaces. Keep this loop out of the vault/staged instances and make
|
|
7182
|
+
// the cadence configurable for deterministic tests.
|
|
7183
|
+
if (port === getSupervisorPort() && process.env.CLAUTH_SUPERVISOR_HEALTH_RECONCILE !== "0") {
|
|
7184
|
+
const configuredInterval = Number(process.env.CLAUTH_SUPERVISOR_HEALTH_INTERVAL_MS || 10000);
|
|
7185
|
+
const intervalMs = Number.isFinite(configuredInterval) ? Math.max(1000, Math.min(configuredInterval, 300000)) : 10000;
|
|
7186
|
+
let healthReconcileInFlight = false;
|
|
7187
|
+
const runHealthReconcile = () => {
|
|
7188
|
+
if (healthReconcileInFlight) return;
|
|
7189
|
+
healthReconcileInFlight = true;
|
|
7190
|
+
reconcileSurfaceHealth().catch((err) => {
|
|
7191
|
+
try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] supervisor health reconcile failed: ${err.message}\n`); } catch {}
|
|
7192
|
+
}).finally(() => { healthReconcileInFlight = false; });
|
|
7193
|
+
};
|
|
7194
|
+
const healthTimer = setInterval(runHealthReconcile, intervalMs);
|
|
7195
|
+
healthTimer.unref?.();
|
|
7196
|
+
server.__supervisorHealthTimer = healthTimer;
|
|
7197
|
+
server.on("close", () => clearInterval(healthTimer));
|
|
7198
|
+
setImmediate(runHealthReconcile);
|
|
7199
|
+
}
|
|
7200
|
+
|
|
7468
7201
|
return server;
|
|
7469
7202
|
}
|
|
7470
7203
|
|
|
@@ -7480,6 +7213,61 @@ async function verifyAuth(password) {
|
|
|
7480
7213
|
}
|
|
7481
7214
|
}
|
|
7482
7215
|
|
|
7216
|
+
async function supervisorResponds(port = getSupervisorPort()) {
|
|
7217
|
+
try {
|
|
7218
|
+
const resp = await fetch(`http://127.0.0.1:${port}/health`);
|
|
7219
|
+
return resp.ok;
|
|
7220
|
+
} catch {
|
|
7221
|
+
return false;
|
|
7222
|
+
}
|
|
7223
|
+
}
|
|
7224
|
+
|
|
7225
|
+
async function ensureSupervisorStarted(cliEntry) {
|
|
7226
|
+
const port = getSupervisorPort();
|
|
7227
|
+
const existing = readSupervisorPid();
|
|
7228
|
+
if (existing && isProcessAlive(existing.pid) && await supervisorResponds(existing.port)) {
|
|
7229
|
+
return { started: false, pid: existing.pid, port: existing.port, state: "already_running" };
|
|
7230
|
+
}
|
|
7231
|
+
if (existing && !isProcessAlive(existing.pid)) removeSupervisorPid();
|
|
7232
|
+
if (await supervisorResponds(port)) {
|
|
7233
|
+
return { started: false, pid: existing?.pid || null, port, state: "port_already_live" };
|
|
7234
|
+
}
|
|
7235
|
+
|
|
7236
|
+
const out = fs.openSync(LOG_FILE, "a");
|
|
7237
|
+
const child = spawn(process.execPath, [cliEntry, "serve", "supervisor", "--port", String(port)], {
|
|
7238
|
+
detached: true,
|
|
7239
|
+
stdio: ["ignore", out, out],
|
|
7240
|
+
env: { ...process.env, __CLAUTH_SUPERVISOR_DAEMON: "1" },
|
|
7241
|
+
});
|
|
7242
|
+
child.unref();
|
|
7243
|
+
writeSupervisorPid(child.pid, port);
|
|
7244
|
+
|
|
7245
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
7246
|
+
await new Promise(r => setTimeout(r, 500));
|
|
7247
|
+
if (await supervisorResponds(port)) {
|
|
7248
|
+
return { started: true, pid: child.pid, port, state: "started" };
|
|
7249
|
+
}
|
|
7250
|
+
}
|
|
7251
|
+
return { started: true, pid: child.pid, port, state: "start_unverified" };
|
|
7252
|
+
}
|
|
7253
|
+
|
|
7254
|
+
async function stopSupervisorSibling() {
|
|
7255
|
+
const info = readSupervisorPid();
|
|
7256
|
+
if (!info) return null;
|
|
7257
|
+
if (!isProcessAlive(info.pid)) {
|
|
7258
|
+
removeSupervisorPid();
|
|
7259
|
+
return { stopped: false, pid: info.pid, port: info.port, state: "stale" };
|
|
7260
|
+
}
|
|
7261
|
+
try {
|
|
7262
|
+
process.kill(info.pid, "SIGTERM");
|
|
7263
|
+
await new Promise(r => setTimeout(r, 300));
|
|
7264
|
+
removeSupervisorPid();
|
|
7265
|
+
return { stopped: true, pid: info.pid, port: info.port, state: "stopped" };
|
|
7266
|
+
} catch (err) {
|
|
7267
|
+
return { stopped: false, pid: info.pid, port: info.port, state: "stop_failed", error: err.message };
|
|
7268
|
+
}
|
|
7269
|
+
}
|
|
7270
|
+
|
|
7483
7271
|
async function actionStart(opts) {
|
|
7484
7272
|
if (opts.isolated) {
|
|
7485
7273
|
return actionForeground(opts);
|
|
@@ -7681,6 +7469,11 @@ async function actionStart(opts) {
|
|
|
7681
7469
|
console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
|
|
7682
7470
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
7683
7471
|
console.log(chalk.gray(` Log: ${LOG_FILE}`));
|
|
7472
|
+
if (!isStaged) {
|
|
7473
|
+
const supervisor = await ensureSupervisorStarted(cliEntry);
|
|
7474
|
+
const verb = supervisor.started ? "started" : "available";
|
|
7475
|
+
console.log(chalk.gray(` Supervisor: ${verb} on 127.0.0.1:${supervisor.port}${supervisor.pid ? ` (PID ${supervisor.pid})` : ""}`));
|
|
7476
|
+
}
|
|
7684
7477
|
if (isStaged) {
|
|
7685
7478
|
console.log(chalk.yellow(`\n ⚡ Staged on port ${port} — open dashboard to verify, then click "Make Live"`));
|
|
7686
7479
|
} else if (!password) {
|
|
@@ -7698,6 +7491,7 @@ async function actionStart(opts) {
|
|
|
7698
7491
|
async function actionStop() {
|
|
7699
7492
|
const info = readPid();
|
|
7700
7493
|
if (!info) {
|
|
7494
|
+
await stopSupervisorSibling();
|
|
7701
7495
|
console.log(chalk.yellow("\n No clauth serve PID file found — not running.\n"));
|
|
7702
7496
|
return;
|
|
7703
7497
|
}
|
|
@@ -7705,6 +7499,7 @@ async function actionStop() {
|
|
|
7705
7499
|
if (!isProcessAlive(info.pid)) {
|
|
7706
7500
|
console.log(chalk.yellow(`\n PID ${info.pid} is not running (stale PID file). Cleaning up.\n`));
|
|
7707
7501
|
removePid();
|
|
7502
|
+
await stopSupervisorSibling();
|
|
7708
7503
|
return;
|
|
7709
7504
|
}
|
|
7710
7505
|
|
|
@@ -7715,6 +7510,7 @@ async function actionStop() {
|
|
|
7715
7510
|
await new Promise(r => setTimeout(r, 300));
|
|
7716
7511
|
console.log(chalk.green(`\n 🛑 clauth serve stopped (was PID ${info.pid}, port ${info.port})\n`));
|
|
7717
7512
|
removePid();
|
|
7513
|
+
await stopSupervisorSibling();
|
|
7718
7514
|
return;
|
|
7719
7515
|
}
|
|
7720
7516
|
} catch {}
|
|
@@ -7728,6 +7524,7 @@ async function actionStop() {
|
|
|
7728
7524
|
console.log(chalk.yellow(`\n Could not kill PID ${info.pid}: ${err.message}\n`));
|
|
7729
7525
|
}
|
|
7730
7526
|
removePid();
|
|
7527
|
+
await stopSupervisorSibling();
|
|
7731
7528
|
}
|
|
7732
7529
|
|
|
7733
7530
|
async function actionPing() {
|
|
@@ -7772,7 +7569,9 @@ async function actionRestart(opts) {
|
|
|
7772
7569
|
async function actionForeground(opts) {
|
|
7773
7570
|
const port = parseInt(opts.port || "52437", 10);
|
|
7774
7571
|
const isolated = !!opts.isolated;
|
|
7775
|
-
const
|
|
7572
|
+
const containerPassword = process.env.CLAUTH_MASTER_PASSWORD || process.env["clauth-master-password"] || null;
|
|
7573
|
+
const password = isolated ? null : (opts.pw || containerPassword);
|
|
7574
|
+
const bindHost = process.env.CLAUTH_BIND_HOST || "127.0.0.1";
|
|
7776
7575
|
const tunnelHostname = opts.tunnel || null;
|
|
7777
7576
|
const whitelist = opts.services
|
|
7778
7577
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
@@ -7798,14 +7597,40 @@ async function actionForeground(opts) {
|
|
|
7798
7597
|
console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
|
|
7799
7598
|
}
|
|
7800
7599
|
|
|
7801
|
-
console.log(chalk.gray(` Port:
|
|
7600
|
+
console.log(chalk.gray(` Port: ${bindHost}:${port}`));
|
|
7802
7601
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
7803
7602
|
console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
|
|
7804
7603
|
|
|
7604
|
+
// Plugin discovery previously only ran under `serve supervisor` or an
|
|
7605
|
+
// on-demand rescan — normal `serve start`/`foreground` boot never ran it,
|
|
7606
|
+
// so a stale/never-discovered plugin list could sit unnoticed until
|
|
7607
|
+
// someone happened to hit rescan. Discovery touching disk must never be
|
|
7608
|
+
// allowed to crash daemon boot, hence the try/catch.
|
|
7609
|
+
let discoveryResult = null;
|
|
7610
|
+
let discoveryError = null;
|
|
7611
|
+
try {
|
|
7612
|
+
discoveryResult = discoverPlugins();
|
|
7613
|
+
} catch (err) {
|
|
7614
|
+
discoveryError = err;
|
|
7615
|
+
console.log(chalk.yellow(` ⚠ plugin discovery failed at boot: ${err.message}`));
|
|
7616
|
+
}
|
|
7617
|
+
|
|
7805
7618
|
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
7806
|
-
server.listen(port,
|
|
7619
|
+
server.listen(port, bindHost, () => {
|
|
7807
7620
|
if (!isolated) writePid(process.pid, port);
|
|
7808
|
-
|
|
7621
|
+
try {
|
|
7622
|
+
operation("daemon.started", { port, isolated }, null, { ok: true, version: VERSION }, "system");
|
|
7623
|
+
operation(
|
|
7624
|
+
"daemon.plugin_discovery",
|
|
7625
|
+
{ port },
|
|
7626
|
+
null,
|
|
7627
|
+
discoveryError
|
|
7628
|
+
? { ok: false, error: discoveryError.message }
|
|
7629
|
+
: { ok: true, plugin_count: (discoveryResult?.plugins || discoveryResult || []).length },
|
|
7630
|
+
"system",
|
|
7631
|
+
);
|
|
7632
|
+
} catch { /* startup logging must never block the daemon from serving */ }
|
|
7633
|
+
console.log(chalk.green(` clauth serve → http://${bindHost}:${port}`));
|
|
7809
7634
|
if (tunnelHostname) {
|
|
7810
7635
|
console.log(chalk.cyan(` Tunnel: https://${tunnelHostname}/sse`));
|
|
7811
7636
|
console.log("");
|
|
@@ -7845,7 +7670,7 @@ async function actionForeground(opts) {
|
|
|
7845
7670
|
import { createInterface } from "readline";
|
|
7846
7671
|
import { execSync, spawn as spawnProc, spawnSync } from "child_process";
|
|
7847
7672
|
|
|
7848
|
-
// ──
|
|
7673
|
+
// ── Shared headless Claude CLI worker helpers (used by call-agent, terminal, codevelop) ───
|
|
7849
7674
|
function findClaudeBinary() {
|
|
7850
7675
|
const candidates = [
|
|
7851
7676
|
process.env.CLAUDE_BIN,
|
|
@@ -8336,7 +8161,7 @@ async function runCallAgent(args = {}) {
|
|
|
8336
8161
|
const persistMeta = { prompt, skill_slug: args.skill_slug, model, agent_context: args.agent_context };
|
|
8337
8162
|
|
|
8338
8163
|
if (mode === "async") {
|
|
8339
|
-
const jobId =
|
|
8164
|
+
const jobId = `call-${crypto.randomUUID()}`;
|
|
8340
8165
|
// Persist the queued row FIRST so the { jobId } we return is recoverable
|
|
8341
8166
|
// even if the daemon restarts before the worker settles.
|
|
8342
8167
|
await persistCallAgentDispatch({ jobId, ...persistMeta });
|
|
@@ -8360,307 +8185,6 @@ async function runCallAgent(args = {}) {
|
|
|
8360
8185
|
return { ok: true, package: rec.package, jobId: rec.jobId, model: rec.model, ms: rec.ms };
|
|
8361
8186
|
}
|
|
8362
8187
|
|
|
8363
|
-
const tintinJobs = new Map();
|
|
8364
|
-
const MAX_TINTIN_JOBS = 100;
|
|
8365
|
-
const tintinSessions = new Map();
|
|
8366
|
-
const tintinMessageIndex = new Map();
|
|
8367
|
-
const tintinEventStreams = new Map();
|
|
8368
|
-
const tintinMessageMonitors = new Map();
|
|
8369
|
-
const MAX_TINTIN_SESSION_EVENTS = 500;
|
|
8370
|
-
|
|
8371
|
-
function normalizeAgentContext(input = {}) {
|
|
8372
|
-
const context = input && typeof input === "object" ? input : {};
|
|
8373
|
-
const app = context.app && typeof context.app === "object" ? context.app : {};
|
|
8374
|
-
const repo = context.repo && typeof context.repo === "object" ? context.repo : {};
|
|
8375
|
-
const runtime = context.runtime && typeof context.runtime === "object" ? context.runtime : {};
|
|
8376
|
-
const task = context.task && typeof context.task === "object" ? context.task : {};
|
|
8377
|
-
|
|
8378
|
-
return {
|
|
8379
|
-
app: {
|
|
8380
|
-
slug: String(app.slug || context.app_slug || "unknown-app"),
|
|
8381
|
-
route: String(app.route || context.route || "/"),
|
|
8382
|
-
origin: String(app.origin || context.origin || "unknown-origin"),
|
|
8383
|
-
},
|
|
8384
|
-
repo: {
|
|
8385
|
-
root: String(repo.root || context.repo_root || ""),
|
|
8386
|
-
cwd: String(repo.cwd || context.cwd || ""),
|
|
8387
|
-
},
|
|
8388
|
-
runtime: {
|
|
8389
|
-
agent: String(runtime.agent || context.agent || "clauth-cli"),
|
|
8390
|
-
requested_by: String(runtime.requested_by || context.requested_by || "tintin"),
|
|
8391
|
-
},
|
|
8392
|
-
task: {
|
|
8393
|
-
intent: String(task.intent || context.intent || "tintin-dispatch"),
|
|
8394
|
-
thread_id: task.thread_id || context.thread_id || null,
|
|
8395
|
-
},
|
|
8396
|
-
};
|
|
8397
|
-
}
|
|
8398
|
-
|
|
8399
|
-
function makeTinTinId(prefix) {
|
|
8400
|
-
return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
8401
|
-
}
|
|
8402
|
-
|
|
8403
|
-
function createTinTinSession(input = {}) {
|
|
8404
|
-
const sessionId = input.session_id || input.thread_id || makeTinTinId("ms");
|
|
8405
|
-
const agentContext = normalizeAgentContext(input.agent_context || {
|
|
8406
|
-
app: {
|
|
8407
|
-
slug: input.app_id || input.app_slug || "unknown-app",
|
|
8408
|
-
route: input.route || "/",
|
|
8409
|
-
origin: input.origin || "unknown-origin",
|
|
8410
|
-
},
|
|
8411
|
-
repo: {
|
|
8412
|
-
root: input.repo_root || "",
|
|
8413
|
-
cwd: input.cwd || "",
|
|
8414
|
-
},
|
|
8415
|
-
runtime: {
|
|
8416
|
-
agent: "clauth-cli",
|
|
8417
|
-
requested_by: "tintin-sidebar",
|
|
8418
|
-
},
|
|
8419
|
-
task: {
|
|
8420
|
-
intent: input.intent || "general_chat",
|
|
8421
|
-
thread_id: sessionId,
|
|
8422
|
-
},
|
|
8423
|
-
});
|
|
8424
|
-
const existing = tintinSessions.get(sessionId);
|
|
8425
|
-
if (existing) {
|
|
8426
|
-
existing.agent_context = agentContext;
|
|
8427
|
-
existing.updated_at = new Date().toISOString();
|
|
8428
|
-
tintinSessions.set(sessionId, existing);
|
|
8429
|
-
return existing;
|
|
8430
|
-
}
|
|
8431
|
-
const session = {
|
|
8432
|
-
id: sessionId,
|
|
8433
|
-
status: "ready",
|
|
8434
|
-
created_at: new Date().toISOString(),
|
|
8435
|
-
updated_at: new Date().toISOString(),
|
|
8436
|
-
seq: 0,
|
|
8437
|
-
agent_context: agentContext,
|
|
8438
|
-
cwd: input.cwd || agentContext.repo.cwd || agentContext.repo.root || "",
|
|
8439
|
-
events: [],
|
|
8440
|
-
};
|
|
8441
|
-
tintinSessions.set(sessionId, session);
|
|
8442
|
-
return session;
|
|
8443
|
-
}
|
|
8444
|
-
|
|
8445
|
-
function getTinTinSession(sessionId) {
|
|
8446
|
-
return tintinSessions.get(sessionId);
|
|
8447
|
-
}
|
|
8448
|
-
|
|
8449
|
-
function pushTinTinEvent(sessionId, event) {
|
|
8450
|
-
const session = getTinTinSession(sessionId);
|
|
8451
|
-
if (!session) return null;
|
|
8452
|
-
session.seq += 1;
|
|
8453
|
-
session.updated_at = new Date().toISOString();
|
|
8454
|
-
const fullEvent = {
|
|
8455
|
-
id: event.id || makeTinTinId("mev"),
|
|
8456
|
-
session_id: sessionId,
|
|
8457
|
-
seq: session.seq,
|
|
8458
|
-
source: event.source || "clauth",
|
|
8459
|
-
type: event.type || "status",
|
|
8460
|
-
role: event.role,
|
|
8461
|
-
message_id: event.message_id,
|
|
8462
|
-
job_id: event.job_id,
|
|
8463
|
-
content: event.content,
|
|
8464
|
-
payload: event.payload || {},
|
|
8465
|
-
created_at: new Date().toISOString(),
|
|
8466
|
-
};
|
|
8467
|
-
session.events.push(fullEvent);
|
|
8468
|
-
session.events = session.events.slice(-MAX_TINTIN_SESSION_EVENTS);
|
|
8469
|
-
tintinSessions.set(sessionId, session);
|
|
8470
|
-
const streams = tintinEventStreams.get(sessionId);
|
|
8471
|
-
if (streams) {
|
|
8472
|
-
for (const send of [...streams]) {
|
|
8473
|
-
try {
|
|
8474
|
-
send(fullEvent);
|
|
8475
|
-
} catch {
|
|
8476
|
-
streams.delete(send);
|
|
8477
|
-
}
|
|
8478
|
-
}
|
|
8479
|
-
}
|
|
8480
|
-
return fullEvent;
|
|
8481
|
-
}
|
|
8482
|
-
|
|
8483
|
-
function listTinTinEvents(sessionId, afterSeq = 0) {
|
|
8484
|
-
const session = getTinTinSession(sessionId);
|
|
8485
|
-
if (!session) return null;
|
|
8486
|
-
return session.events.filter((event) => event.seq > afterSeq);
|
|
8487
|
-
}
|
|
8488
|
-
|
|
8489
|
-
function subscribeTinTinEvents(sessionId, send) {
|
|
8490
|
-
const streams = tintinEventStreams.get(sessionId) || new Set();
|
|
8491
|
-
streams.add(send);
|
|
8492
|
-
tintinEventStreams.set(sessionId, streams);
|
|
8493
|
-
return () => {
|
|
8494
|
-
const current = tintinEventStreams.get(sessionId);
|
|
8495
|
-
if (!current) return;
|
|
8496
|
-
current.delete(send);
|
|
8497
|
-
if (current.size === 0) tintinEventStreams.delete(sessionId);
|
|
8498
|
-
};
|
|
8499
|
-
}
|
|
8500
|
-
|
|
8501
|
-
function startTinTinMessageMonitor(sessionId, messageId, jobId) {
|
|
8502
|
-
const key = `${sessionId}:${messageId}`;
|
|
8503
|
-
if (tintinMessageMonitors.has(key)) return;
|
|
8504
|
-
let lastStdoutLength = 0;
|
|
8505
|
-
const timer = setInterval(() => {
|
|
8506
|
-
const job = tintinJobs.get(jobId);
|
|
8507
|
-
if (!job) return;
|
|
8508
|
-
const stdout = job.stdout || "";
|
|
8509
|
-
if (stdout.length > lastStdoutLength) {
|
|
8510
|
-
const chunk = stdout.slice(lastStdoutLength);
|
|
8511
|
-
lastStdoutLength = stdout.length;
|
|
8512
|
-
pushTinTinEvent(sessionId, {
|
|
8513
|
-
source: "claude",
|
|
8514
|
-
type: "delta",
|
|
8515
|
-
role: "assistant",
|
|
8516
|
-
message_id: messageId,
|
|
8517
|
-
job_id: jobId,
|
|
8518
|
-
content: chunk,
|
|
8519
|
-
});
|
|
8520
|
-
}
|
|
8521
|
-
if (job.status !== "running") {
|
|
8522
|
-
clearInterval(timer);
|
|
8523
|
-
tintinMessageMonitors.delete(key);
|
|
8524
|
-
pushTinTinEvent(sessionId, {
|
|
8525
|
-
source: "clauth",
|
|
8526
|
-
type: job.status === "completed" ? "done" : "error",
|
|
8527
|
-
role: "assistant",
|
|
8528
|
-
message_id: messageId,
|
|
8529
|
-
job_id: jobId,
|
|
8530
|
-
content: job.status === "completed" ? "" : (job.stderr || job.stdout || "TinTin worker failed"),
|
|
8531
|
-
payload: {
|
|
8532
|
-
status: job.status,
|
|
8533
|
-
exit_code: job.exit_code,
|
|
8534
|
-
completed_at: job.completed_at,
|
|
8535
|
-
},
|
|
8536
|
-
});
|
|
8537
|
-
}
|
|
8538
|
-
}, 500);
|
|
8539
|
-
tintinMessageMonitors.set(key, timer);
|
|
8540
|
-
}
|
|
8541
|
-
|
|
8542
|
-
function resolveDispatchCwd(requestedCwd, agentContext = {}) {
|
|
8543
|
-
const raw = requestedCwd || agentContext?.repo?.cwd || agentContext?.repo?.root || CHITCHAT_FALLBACK_CWD;
|
|
8544
|
-
if (!raw || typeof raw !== "string") return CHITCHAT_FALLBACK_CWD;
|
|
8545
|
-
const resolved = path.resolve(raw);
|
|
8546
|
-
try {
|
|
8547
|
-
const st = fs.statSync(resolved);
|
|
8548
|
-
if (!st.isDirectory()) return CHITCHAT_FALLBACK_CWD;
|
|
8549
|
-
return resolved;
|
|
8550
|
-
} catch {
|
|
8551
|
-
return CHITCHAT_FALLBACK_CWD;
|
|
8552
|
-
}
|
|
8553
|
-
}
|
|
8554
|
-
|
|
8555
|
-
function buildTinTinPrompt({ prompt, job_id, agent_context }) {
|
|
8556
|
-
const normalized = normalizeAgentContext(agent_context);
|
|
8557
|
-
const jobInstruction = job_id
|
|
8558
|
-
? [
|
|
8559
|
-
`TinTin job id: ${job_id}`,
|
|
8560
|
-
"If the prompt does not contain all job details, use clauth at http://127.0.0.1:52437 to fetch exact credentials and query the app database/API directly.",
|
|
8561
|
-
"Do not assume agent MCP/plugin tools are available to this spawned CLI process.",
|
|
8562
|
-
].join("\n")
|
|
8563
|
-
: "";
|
|
8564
|
-
|
|
8565
|
-
const body = prompt || [
|
|
8566
|
-
"Process this TinTin job using the provided agent context.",
|
|
8567
|
-
jobInstruction,
|
|
8568
|
-
"Find the job payload from the consuming app's declared data path before making changes.",
|
|
8569
|
-
].filter(Boolean).join("\n\n");
|
|
8570
|
-
|
|
8571
|
-
return [
|
|
8572
|
-
"You are a clauth-spawned TinTin CLI agent (model: claude-sonnet-4-6).",
|
|
8573
|
-
"Agent context setup follows. Treat it as the runtime contract for this task.",
|
|
8574
|
-
"```json",
|
|
8575
|
-
JSON.stringify(normalized, null, 2),
|
|
8576
|
-
"```",
|
|
8577
|
-
"",
|
|
8578
|
-
"Execution rules:",
|
|
8579
|
-
"- You are a local CLI process, not the parent agent runtime.",
|
|
8580
|
-
"- You do not inherit Codex/Claude MCP or plugin tools.",
|
|
8581
|
-
"- Use clauth HTTP, direct HTTP APIs, repo files, or app endpoints for access.",
|
|
8582
|
-
"- Keep results tied to the supplied app, route, cwd, thread, and job id.",
|
|
8583
|
-
"",
|
|
8584
|
-
jobInstruction,
|
|
8585
|
-
"",
|
|
8586
|
-
body,
|
|
8587
|
-
].filter(Boolean).join("\n");
|
|
8588
|
-
}
|
|
8589
|
-
|
|
8590
|
-
function spawnClaudeTask(prompt, jobId, cwd, agentContext) {
|
|
8591
|
-
if (activeCliWorkers >= MAX_CLI_WORKERS) {
|
|
8592
|
-
return { error: 'concurrency_limit', message: `Max ${MAX_CLI_WORKERS} CLI workers active` };
|
|
8593
|
-
}
|
|
8594
|
-
const binary = findClaudeBinary();
|
|
8595
|
-
if (!binary) {
|
|
8596
|
-
return { error: 'binary_not_found', message: 'claude CLI not found in PATH or AppData/npm' };
|
|
8597
|
-
}
|
|
8598
|
-
|
|
8599
|
-
const resolvedCwd = resolveDispatchCwd(cwd, agentContext);
|
|
8600
|
-
activeCliWorkers++;
|
|
8601
|
-
const startedAtIso = new Date().toISOString();
|
|
8602
|
-
tintinJobs.set(jobId, {
|
|
8603
|
-
jobId,
|
|
8604
|
-
status: "running",
|
|
8605
|
-
cwd: resolvedCwd,
|
|
8606
|
-
pid: null,
|
|
8607
|
-
started_at: startedAtIso,
|
|
8608
|
-
completed_at: null,
|
|
8609
|
-
exit_code: null,
|
|
8610
|
-
stdout: "",
|
|
8611
|
-
stderr: "",
|
|
8612
|
-
context: normalizeAgentContext(agentContext),
|
|
8613
|
-
});
|
|
8614
|
-
while (tintinJobs.size > MAX_TINTIN_JOBS) {
|
|
8615
|
-
const firstKey = tintinJobs.keys().next().value;
|
|
8616
|
-
tintinJobs.delete(firstKey);
|
|
8617
|
-
}
|
|
8618
|
-
const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(binary);
|
|
8619
|
-
const command = isCmdShim ? "cmd" : binary;
|
|
8620
|
-
const args = isCmdShim
|
|
8621
|
-
? ["/d", "/s", "/c", `"${binary}"`, "-p", prompt, "--dangerously-skip-permissions"]
|
|
8622
|
-
: ["-p", prompt, "--dangerously-skip-permissions"];
|
|
8623
|
-
const proc = spawnProc(command, args, {
|
|
8624
|
-
cwd: resolvedCwd,
|
|
8625
|
-
env: process.env,
|
|
8626
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8627
|
-
shell: false,
|
|
8628
|
-
windowsHide: true,
|
|
8629
|
-
});
|
|
8630
|
-
|
|
8631
|
-
const tracked = tintinJobs.get(jobId);
|
|
8632
|
-
if (tracked) {
|
|
8633
|
-
tracked.pid = proc.pid;
|
|
8634
|
-
tintinJobs.set(jobId, tracked);
|
|
8635
|
-
}
|
|
8636
|
-
|
|
8637
|
-
const startedAt = Date.now();
|
|
8638
|
-
proc.stdout?.on("data", (chunk) => {
|
|
8639
|
-
const trackedJob = tintinJobs.get(jobId);
|
|
8640
|
-
if (!trackedJob) return;
|
|
8641
|
-
trackedJob.stdout = `${trackedJob.stdout}${chunk.toString()}`.slice(-20000);
|
|
8642
|
-
tintinJobs.set(jobId, trackedJob);
|
|
8643
|
-
});
|
|
8644
|
-
proc.stderr?.on("data", (chunk) => {
|
|
8645
|
-
const trackedJob = tintinJobs.get(jobId);
|
|
8646
|
-
if (!trackedJob) return;
|
|
8647
|
-
trackedJob.stderr = `${trackedJob.stderr}${chunk.toString()}`.slice(-20000);
|
|
8648
|
-
tintinJobs.set(jobId, trackedJob);
|
|
8649
|
-
});
|
|
8650
|
-
proc.on('close', (code) => {
|
|
8651
|
-
activeCliWorkers--;
|
|
8652
|
-
const trackedJob = tintinJobs.get(jobId);
|
|
8653
|
-
if (trackedJob) {
|
|
8654
|
-
trackedJob.status = code === 0 ? "completed" : "failed";
|
|
8655
|
-
trackedJob.exit_code = code;
|
|
8656
|
-
trackedJob.completed_at = new Date().toISOString();
|
|
8657
|
-
tintinJobs.set(jobId, trackedJob);
|
|
8658
|
-
}
|
|
8659
|
-
console.log(`[tintin] job ${jobId} exited code=${code} in ${Date.now() - startedAt}ms`);
|
|
8660
|
-
});
|
|
8661
|
-
|
|
8662
|
-
return { status: 'spawned', pid: proc.pid, jobId, cwd: resolvedCwd, activeWorkers: activeCliWorkers };
|
|
8663
|
-
}
|
|
8664
8188
|
|
|
8665
8189
|
// ── Terminal session manager ─────────────────────────────────────
|
|
8666
8190
|
// Rolling-context approach: each session stores a context string.
|
|
@@ -8678,7 +8202,7 @@ function generateSessionId() {
|
|
|
8678
8202
|
}
|
|
8679
8203
|
|
|
8680
8204
|
function defaultTerminalCwd() {
|
|
8681
|
-
const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-
|
|
8205
|
+
const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-terminal");
|
|
8682
8206
|
try {
|
|
8683
8207
|
fs.mkdirSync(dir, { recursive: true });
|
|
8684
8208
|
} catch {
|
|
@@ -8712,7 +8236,7 @@ function startTerminalSession(name, knowledge_tier, context_md, cwd, use_warm =
|
|
|
8712
8236
|
};
|
|
8713
8237
|
|
|
8714
8238
|
// Try to acquire a warm worker only when explicitly requested. The warm pool
|
|
8715
|
-
// is optimized for call_agent one-shots;
|
|
8239
|
+
// is optimized for call_agent one-shots; a terminal session needs reliable
|
|
8716
8240
|
// repeated turns more than experimental low-latency pinning.
|
|
8717
8241
|
if (use_warm) {
|
|
8718
8242
|
try {
|
|
@@ -8920,12 +8444,12 @@ function showTerminalSession(session_id) {
|
|
|
8920
8444
|
|
|
8921
8445
|
if (process.platform === "win32") {
|
|
8922
8446
|
try {
|
|
8923
|
-
const title = `
|
|
8447
|
+
const title = `clauth terminal ${session.name || session_id}`;
|
|
8924
8448
|
const lines = [
|
|
8925
8449
|
`$Host.UI.RawUI.WindowTitle = ${JSON.stringify(title)}`,
|
|
8926
8450
|
`Set-Location -LiteralPath ${JSON.stringify(session.cwd || os.homedir())}`,
|
|
8927
8451
|
"Clear-Host",
|
|
8928
|
-
`Write-Host ${JSON.stringify(`
|
|
8452
|
+
`Write-Host ${JSON.stringify(`clauth terminal session ${session_id}`)} -ForegroundColor Cyan`,
|
|
8929
8453
|
`Write-Host ${JSON.stringify(`Name: ${session.name || ""}`)}`,
|
|
8930
8454
|
`Write-Host ${JSON.stringify(`Status: ${session.status}`)}`,
|
|
8931
8455
|
`Write-Host ${JSON.stringify(`CWD: ${session.cwd || ""}`)}`,
|
|
@@ -9894,7 +9418,7 @@ function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFI
|
|
|
9894
9418
|
return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
|
|
9895
9419
|
}
|
|
9896
9420
|
|
|
9897
|
-
const MCP_TOOLS = [
|
|
9421
|
+
export const MCP_TOOLS = [
|
|
9898
9422
|
{
|
|
9899
9423
|
name: "clauth_ping",
|
|
9900
9424
|
description: "Check if the vault is locked or unlocked, show failure count",
|
|
@@ -10044,7 +9568,7 @@ const MCP_TOOLS = [
|
|
|
10044
9568
|
},
|
|
10045
9569
|
{
|
|
10046
9570
|
name: "call_agent",
|
|
10047
|
-
description: "Call a headless Haiku agent for a single prompt or skill and get the answer back. Lean one-shot through a warm pool (model default claude-haiku-4-5, ~4-5s warm). sync mode (default) blocks and returns { ok, package, jobId, model, ms }; async returns { ok, jobId } for polling.
|
|
9571
|
+
description: "Call a headless Haiku agent for a single prompt or skill and get the answer back. Lean one-shot through a warm pool (model default claude-haiku-4-5, ~4-5s warm). sync mode (default) blocks and returns { ok, package, jobId, model, ms }; async returns { ok, jobId } for polling.",
|
|
10048
9572
|
inputSchema: {
|
|
10049
9573
|
type: "object",
|
|
10050
9574
|
properties: {
|
|
@@ -10085,24 +9609,6 @@ const MCP_TOOLS = [
|
|
|
10085
9609
|
additionalProperties: false,
|
|
10086
9610
|
},
|
|
10087
9611
|
},
|
|
10088
|
-
{
|
|
10089
|
-
name: "tintin_dispatch", // formerly monkey_dispatch
|
|
10090
|
-
description: "Dispatch a TinTin job to a headless Claude Code CLI worker with an app/repo/runtime/task agent_context envelope. Max 2 concurrent workers.",
|
|
10091
|
-
inputSchema: {
|
|
10092
|
-
type: "object",
|
|
10093
|
-
properties: {
|
|
10094
|
-
prompt: { type: "string", description: "Full prompt for the CLI worker to execute. Optional when job_id is supplied." },
|
|
10095
|
-
job_id: { type: "string", description: "tintin job UUID or app job id for tracking" },
|
|
10096
|
-
cwd: { type: "string", description: "Existing local directory where the CLI worker should run" },
|
|
10097
|
-
agent_context: {
|
|
10098
|
-
type: "object",
|
|
10099
|
-
description: "App-neutral context envelope with app, repo, runtime, and task fields",
|
|
10100
|
-
additionalProperties: true,
|
|
10101
|
-
},
|
|
10102
|
-
},
|
|
10103
|
-
additionalProperties: false,
|
|
10104
|
-
},
|
|
10105
|
-
},
|
|
10106
9612
|
{
|
|
10107
9613
|
name: "handoff_start",
|
|
10108
9614
|
description: "Start a visible local Claude Code handoff session from a /handoff command and return immediately with session metadata.",
|
|
@@ -10154,7 +9660,7 @@ const MCP_TOOLS = [
|
|
|
10154
9660
|
},
|
|
10155
9661
|
{
|
|
10156
9662
|
name: "terminal_show",
|
|
10157
|
-
description: "Reveal/focus a
|
|
9663
|
+
description: "Reveal/focus a clauth terminal session on this Windows machine. If no live Claude process window exists, opens a visible session inspector at the session cwd.",
|
|
10158
9664
|
inputSchema: {
|
|
10159
9665
|
type: "object",
|
|
10160
9666
|
properties: {
|
|
@@ -10882,6 +10388,41 @@ const MCP_TOOLS = [
|
|
|
10882
10388
|
additionalProperties: false,
|
|
10883
10389
|
},
|
|
10884
10390
|
},
|
|
10391
|
+
{
|
|
10392
|
+
name: "clauth_ops_catalog",
|
|
10393
|
+
description: "Return the Vultr deployment-control API catalog. Availability is still decided on Vultr.",
|
|
10394
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
10395
|
+
},
|
|
10396
|
+
{
|
|
10397
|
+
name: "clauth_ops_processes",
|
|
10398
|
+
description: "Submit a scoped PM2 process-status query to the Vultr control plane. Only server-approved applications are returned.",
|
|
10399
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
10400
|
+
},
|
|
10401
|
+
{
|
|
10402
|
+
name: "clauth_ops_describe",
|
|
10403
|
+
description: "Submit a scoped PM2 status query for one server-approved application.",
|
|
10404
|
+
inputSchema: { type: "object", properties: { application: { type: "string" } }, required: ["application"], additionalProperties: false },
|
|
10405
|
+
},
|
|
10406
|
+
{
|
|
10407
|
+
name: "clauth_ops_deploy",
|
|
10408
|
+
description: "Submit one manifest-scoped development deployment to Vultr. The server chooses the repository, build, PM2 process, and allowed ref.",
|
|
10409
|
+
inputSchema: { type: "object", properties: { application: { type: "string" }, ref: { type: "string" } }, required: ["application"], additionalProperties: false },
|
|
10410
|
+
},
|
|
10411
|
+
{
|
|
10412
|
+
name: "clauth_ops_promote",
|
|
10413
|
+
description: "Submit one server-allowlisted Coolify promotion and return its asynchronous job receipt.",
|
|
10414
|
+
inputSchema: { type: "object", properties: { application_uuid: { type: "string" } }, required: ["application_uuid"], additionalProperties: false },
|
|
10415
|
+
},
|
|
10416
|
+
{
|
|
10417
|
+
name: "clauth_ops_job",
|
|
10418
|
+
description: "Read the terminal or in-progress receipt for a deployment-control job.",
|
|
10419
|
+
inputSchema: { type: "object", properties: { job_id: { type: "string" } }, required: ["job_id"], additionalProperties: false },
|
|
10420
|
+
},
|
|
10421
|
+
{
|
|
10422
|
+
name: "clauth_ops_run",
|
|
10423
|
+
description: "Submit one PM2 operation through the Vultr control plane. The remote agent/admin profile decides whether the operation and target are available.",
|
|
10424
|
+
inputSchema: { type: "object", properties: { operation: { type: "string" }, input: { type: "object" } }, required: ["operation"], additionalProperties: false },
|
|
10425
|
+
},
|
|
10885
10426
|
];
|
|
10886
10427
|
|
|
10887
10428
|
const MCP_WRITE_TOOL_NAMES = new Set([
|
|
@@ -10889,6 +10430,9 @@ const MCP_WRITE_TOOL_NAMES = new Set([
|
|
|
10889
10430
|
"clauth_disable",
|
|
10890
10431
|
"clauth_set_project",
|
|
10891
10432
|
"clauth_generate_token",
|
|
10433
|
+
"clauth_ops_deploy",
|
|
10434
|
+
"clauth_ops_promote",
|
|
10435
|
+
"clauth_ops_run",
|
|
10892
10436
|
]);
|
|
10893
10437
|
|
|
10894
10438
|
function filterMcpToolsForWriteMode(tools) {
|
|
@@ -10922,6 +10466,24 @@ function mcpError(text) {
|
|
|
10922
10466
|
return { content: [{ type: "text", text }], isError: true };
|
|
10923
10467
|
}
|
|
10924
10468
|
|
|
10469
|
+
async function callOpsFromMcp(vault, method, requestPath, body, { write = false } = {}) {
|
|
10470
|
+
if (write && !vault.writeEnabled) return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
|
|
10471
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
10472
|
+
const endpoint = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
|
|
10473
|
+
if (!endpoint) return mcpError("service_not_available");
|
|
10474
|
+
try {
|
|
10475
|
+
const url = new URL(endpoint);
|
|
10476
|
+
if (url.protocol !== "https:") return mcpError("service_not_available");
|
|
10477
|
+
const service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token";
|
|
10478
|
+
const credential = await vaultRetrieveValue(vault, service);
|
|
10479
|
+
if (credential.error || !credential.value) return mcpError("service_not_available");
|
|
10480
|
+
const payload = await requestOps({ endpoint: url.toString().replace(/\/$/, ""), token: String(credential.value), method, path: requestPath, body });
|
|
10481
|
+
return mcpResult(JSON.stringify(payload, null, 2));
|
|
10482
|
+
} catch {
|
|
10483
|
+
return mcpError("service_not_available");
|
|
10484
|
+
}
|
|
10485
|
+
}
|
|
10486
|
+
|
|
10925
10487
|
// Windows cmd.exe doesn't support single quotes — use bash for gws JSON args
|
|
10926
10488
|
const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
|
|
10927
10489
|
|
|
@@ -10932,6 +10494,13 @@ async function handleMcpTool(vault, name, args) {
|
|
|
10932
10494
|
};
|
|
10933
10495
|
|
|
10934
10496
|
switch (name) {
|
|
10497
|
+
case "clauth_ops_catalog": return callOpsFromMcp(vault, "GET", "/v1/ops/catalog");
|
|
10498
|
+
case "clauth_ops_processes": return callOpsFromMcp(vault, "GET", "/v1/ops/processes");
|
|
10499
|
+
case "clauth_ops_describe": return callOpsFromMcp(vault, "GET", `/v1/ops/processes/${encodeURIComponent(args.application || "")}`);
|
|
10500
|
+
case "clauth_ops_deploy": return callOpsFromMcp(vault, "POST", "/v1/ops/deployments", { application: args.application, ...(args.ref ? { ref: args.ref } : {}) }, { write: true });
|
|
10501
|
+
case "clauth_ops_promote": return callOpsFromMcp(vault, "POST", "/v1/ops/promotions", { application_uuid: args.application_uuid }, { write: true });
|
|
10502
|
+
case "clauth_ops_job": return callOpsFromMcp(vault, "GET", `/v1/ops/jobs/${encodeURIComponent(args.job_id || "")}`);
|
|
10503
|
+
case "clauth_ops_run": return callOpsFromMcp(vault, "POST", "/v1/ops/operations", { operation: args.operation, input: args.input && typeof args.input === "object" ? args.input : {} }, { write: true });
|
|
10935
10504
|
case "clauth_ping": {
|
|
10936
10505
|
return mcpResult(
|
|
10937
10506
|
vault.password
|
|
@@ -11997,19 +11566,6 @@ async function handleMcpTool(vault, name, args) {
|
|
|
11997
11566
|
return mcpResult(JSON.stringify(result));
|
|
11998
11567
|
}
|
|
11999
11568
|
|
|
12000
|
-
case "tintin_dispatch": // formerly monkey_dispatch
|
|
12001
|
-
case "monkey_dispatch": {
|
|
12002
|
-
const { prompt, job_id, cwd: requestedCwd, agent_context } = args;
|
|
12003
|
-
if (!prompt && !job_id) return mcpError("prompt required");
|
|
12004
|
-
const fallbackCwd = await resolveChitchatRoot(vault);
|
|
12005
|
-
const normalizedContext = normalizeAgentContext(agent_context);
|
|
12006
|
-
const dispatchCwd = resolveDispatchCwd(requestedCwd || fallbackCwd, normalizedContext);
|
|
12007
|
-
const wrappedPrompt = buildTinTinPrompt({ prompt, job_id, agent_context: normalizedContext });
|
|
12008
|
-
const result = spawnClaudeTask(wrappedPrompt, job_id || "untracked", dispatchCwd, normalizedContext);
|
|
12009
|
-
if (result.error) return mcpError(`${result.error}: ${result.message}`);
|
|
12010
|
-
return mcpResult(JSON.stringify({ ...result, context: normalizedContext }));
|
|
12011
|
-
}
|
|
12012
|
-
|
|
12013
11569
|
case "handoff_start": {
|
|
12014
11570
|
const result = await startHandoffSession(args || {}, vault);
|
|
12015
11571
|
if (result.error) return mcpError(`${result.error}: ${result.message}`);
|
|
@@ -13132,6 +12688,14 @@ async function actionUpgrade(opts) {
|
|
|
13132
12688
|
return actionStart(opts);
|
|
13133
12689
|
}
|
|
13134
12690
|
|
|
12691
|
+
async function actionSupervisor(opts) {
|
|
12692
|
+
opts.isolated = true;
|
|
12693
|
+
opts.port = String(opts.port || getSupervisorPort());
|
|
12694
|
+
// discoverPlugins() now runs unconditionally inside actionForeground at
|
|
12695
|
+
// every boot — calling it here too would double-run discovery.
|
|
12696
|
+
return actionForeground(opts);
|
|
12697
|
+
}
|
|
12698
|
+
|
|
13135
12699
|
export async function runServe(opts) {
|
|
13136
12700
|
const action = opts.action || "foreground";
|
|
13137
12701
|
|
|
@@ -13142,12 +12706,13 @@ export async function runServe(opts) {
|
|
|
13142
12706
|
case "ping": return actionPing();
|
|
13143
12707
|
case "foreground": return actionForeground(opts);
|
|
13144
12708
|
case "mcp": return actionMcp(opts);
|
|
12709
|
+
case "supervisor": return actionSupervisor(opts);
|
|
13145
12710
|
case "install": return actionInstall(opts);
|
|
13146
12711
|
case "uninstall": return actionUninstall();
|
|
13147
12712
|
case "upgrade": return actionUpgrade(opts);
|
|
13148
12713
|
default:
|
|
13149
12714
|
console.log(chalk.red(`\n Unknown serve action: ${action}`));
|
|
13150
|
-
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall | upgrade\n"));
|
|
12715
|
+
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | supervisor | install | uninstall | upgrade\n"));
|
|
13151
12716
|
process.exit(1);
|
|
13152
12717
|
}
|
|
13153
12718
|
}
|