@lifeaitools/clauth 1.31.1 → 2.0.1
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/keys-guide.md +270 -270
- package/.clauth-skill/references/operator-guide.md +27 -0
- package/README.md +48 -0
- package/cli/api.js +238 -238
- package/cli/commands/install.js +396 -396
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/serve.js +1381 -1644
- package/cli/commands/uninstall.js +164 -164
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +165 -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 +403 -6
- package/cli/supervisor-registry.test.js +496 -4
- package/cli/supervisor-ui.test.js +436 -0
- package/cli/watchdog-registry.js +30 -2
- package/cli/watchdog-registry.test.js +28 -5
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +4 -3
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
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
|
}
|
|
@@ -550,7 +662,9 @@ function openBrowser(url) {
|
|
|
550
662
|
}
|
|
551
663
|
|
|
552
664
|
// ── Dashboard HTML ───────────────────────────────────────────
|
|
553
|
-
|
|
665
|
+
// Exported so cli/supervisor-ui.test.js can extract the served dashboard script
|
|
666
|
+
// and drive its real functions, instead of asserting on source text.
|
|
667
|
+
export function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null) {
|
|
554
668
|
return `<!DOCTYPE html>
|
|
555
669
|
<html lang="en">
|
|
556
670
|
<head>
|
|
@@ -574,6 +688,9 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
574
688
|
.btn-unlock:hover{background:#2563eb}
|
|
575
689
|
.btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
|
|
576
690
|
.lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
|
|
691
|
+
#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}
|
|
692
|
+
.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}
|
|
693
|
+
.write-unlock-cancel:hover{border-color:#64748b;color:#cbd5e1}
|
|
577
694
|
/* ── Main view ── */
|
|
578
695
|
#main-view{display:none;padding:2rem}
|
|
579
696
|
.header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
|
|
@@ -658,15 +775,13 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
658
775
|
.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
776
|
.btn-ccandme:hover{background:linear-gradient(135deg,#2d1b69,#1e1b4b);border-color:#7c3aed;color:#c4b5fd;transform:translateY(-1px)}
|
|
660
777
|
.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
778
|
.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
779
|
.btn-claude:hover{filter:brightness(1.1);transform:translateY(-1px)}
|
|
666
780
|
.btn-claude:disabled{opacity:.4;cursor:not-allowed;transform:none;filter:none}
|
|
667
781
|
.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
782
|
.btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
|
|
669
783
|
.tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
|
|
784
|
+
.dash-panel{margin-bottom:1.25rem}
|
|
670
785
|
.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
786
|
.build-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
|
672
787
|
.build-dot.idle{background:#64748b}
|
|
@@ -677,25 +792,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
677
792
|
.build-label{font-size:.85rem;color:#94a3b8;flex:1}
|
|
678
793
|
.build-label strong{color:#e2e8f0}
|
|
679
794
|
.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
795
|
.mcp-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
|
700
796
|
.mcp-label{font-size:.72rem;color:#64748b;min-width:80px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
|
701
797
|
.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 +901,36 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
805
901
|
.wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
|
|
806
902
|
.wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
|
|
807
903
|
.wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
|
|
904
|
+
.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}
|
|
905
|
+
.supervisor-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px}
|
|
906
|
+
.supervisor-title{font-size:.95rem;font-weight:700;color:#e2e8f0}
|
|
907
|
+
.supervisor-sub{font-size:.76rem;color:#94a3b8;margin-top:3px;line-height:1.35}
|
|
908
|
+
.supervisor-actions{display:flex;gap:8px;flex-wrap:wrap}
|
|
909
|
+
.supervisor-action{background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:6px;padding:6px 10px;font-size:.75rem;cursor:pointer}
|
|
910
|
+
.supervisor-action:hover{border-color:#38bdf8;color:#e0f2fe}
|
|
911
|
+
.supervisor-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
|
|
912
|
+
.supervisor-card{border:1px solid #1e293b;background:rgba(2,6,23,.52);border-radius:10px;padding:10px;min-width:0}
|
|
913
|
+
.supervisor-card h4{margin:0 0 8px;color:#cbd5e1;font-size:.75rem;letter-spacing:.08em;text-transform:uppercase}
|
|
914
|
+
.supervisor-kpi{font-size:1.35rem;font-weight:700;color:#f8fafc}
|
|
915
|
+
.supervisor-meta{font-size:.72rem;color:#94a3b8;font-family:'Courier New',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
916
|
+
.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}
|
|
917
|
+
.supervisor-pill.ok{border-color:rgba(74,222,128,.35);color:#86efac;background:rgba(34,197,94,.08)}
|
|
918
|
+
.supervisor-pill.warn{border-color:rgba(250,204,21,.35);color:#fde68a;background:rgba(250,204,21,.08)}
|
|
919
|
+
.supervisor-pill.bad{border-color:rgba(248,113,113,.35);color:#fecaca;background:rgba(248,113,113,.08)}
|
|
920
|
+
.supervisor-list{display:flex;flex-direction:column;gap:8px;max-height:320px;overflow:auto}
|
|
921
|
+
.supervisor-row{border:1px solid #1e293b;border-radius:8px;padding:6px 8px;background:rgba(15,23,42,.45);cursor:pointer}
|
|
922
|
+
.supervisor-row:hover{border-color:#334155}
|
|
923
|
+
.supervisor-row.selected{border-color:#38bdf8;background:rgba(56,189,248,.08)}
|
|
924
|
+
.supervisor-row.readonly{cursor:default}
|
|
925
|
+
.supervisor-row-top{display:flex;justify-content:space-between;gap:8px;align-items:center}
|
|
926
|
+
.supervisor-name{font-size:.82rem;color:#e2e8f0;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
927
|
+
.supervisor-row-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}
|
|
928
|
+
.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}
|
|
929
|
+
.supervisor-cmdbar-empty{font-size:.74rem;color:#64748b}
|
|
930
|
+
.supervisor-cmdbar-name{font-size:.78rem;color:#e2e8f0;font-weight:600;margin-right:4px}
|
|
931
|
+
.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}
|
|
932
|
+
.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}
|
|
933
|
+
.supervisor-feedback.bad{color:#fecaca;background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25)}
|
|
808
934
|
</style>
|
|
809
935
|
</head>
|
|
810
936
|
<body>
|
|
@@ -824,15 +950,27 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
824
950
|
</div>
|
|
825
951
|
</div>
|
|
826
952
|
|
|
953
|
+
<!-- Write-unlock modal (replaces window.prompt(), which silently no-ops in
|
|
954
|
+
embedded/webview browser contexts and after a browser suppresses repeated
|
|
955
|
+
native dialogs) -->
|
|
956
|
+
<div id="write-unlock-overlay">
|
|
957
|
+
<div class="lock-card">
|
|
958
|
+
<div class="lock-icon">🔒</div>
|
|
959
|
+
<div class="lock-title">Enable writes</div>
|
|
960
|
+
<div class="lock-sub">Enter your vault password to enable saving changes (30-minute write session)</div>
|
|
961
|
+
<form onsubmit="submitWriteUnlock();return false;" autocomplete="on">
|
|
962
|
+
<input type="text" name="username" value="clauth" autocomplete="username" style="display:none">
|
|
963
|
+
<input class="lock-input" id="write-unlock-input" type="password" placeholder="••••••••••••" autocomplete="current-password">
|
|
964
|
+
<button class="btn-unlock" id="write-unlock-btn" type="submit">Unlock Writes</button>
|
|
965
|
+
</form>
|
|
966
|
+
<div class="lock-err" id="write-unlock-err"></div>
|
|
967
|
+
<button type="button" class="write-unlock-cancel" onclick="closeWriteUnlockModal()">Cancel</button>
|
|
968
|
+
</div>
|
|
969
|
+
</div>
|
|
970
|
+
|
|
827
971
|
<!-- ── Main view (shown after unlock) ──────── -->
|
|
828
972
|
<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>
|
|
973
|
+
<section class="dash-panel" data-panel="title-status">
|
|
836
974
|
<div class="header">
|
|
837
975
|
<div class="dot" id="dot"></div>
|
|
838
976
|
<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 +989,16 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
851
989
|
<div>Services: <span id="s-services">${whitelist ? whitelist.join(", ") : "all"}</span></div>
|
|
852
990
|
<div>Failures: <span id="s-fails">—</span></div>
|
|
853
991
|
</div>
|
|
992
|
+
</section>
|
|
993
|
+
|
|
994
|
+
<section class="dash-panel" data-panel="status-area">
|
|
995
|
+
<div id="upgrade-banner" style="display:none" class="upgrade-banner">
|
|
996
|
+
<div class="upgrade-content">
|
|
997
|
+
<strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
|
|
998
|
+
<span id="upgrade-details"></span>
|
|
999
|
+
<button onclick="dismissUpgrade()">Dismiss ✕</button>
|
|
1000
|
+
</div>
|
|
1001
|
+
</div>
|
|
854
1002
|
<div class="build-panel" id="build-panel">
|
|
855
1003
|
<div class="build-dot idle" id="build-dot"></div>
|
|
856
1004
|
<div class="build-label" id="build-label">
|
|
@@ -859,8 +1007,9 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
859
1007
|
<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
1008
|
<div class="build-meta" id="build-meta" style="width:100%;margin-top:4px"></div>
|
|
861
1009
|
</div>
|
|
1010
|
+
</section>
|
|
862
1011
|
|
|
863
|
-
<div class="toolbar">
|
|
1012
|
+
<div class="toolbar" data-panel="menu-bar">
|
|
864
1013
|
<button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
|
|
865
1014
|
<button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
|
|
866
1015
|
<button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
|
|
@@ -952,7 +1101,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
952
1101
|
</form>
|
|
953
1102
|
</div>
|
|
954
1103
|
|
|
955
|
-
<div class="tunnel-panel" id="tunnel-panel">
|
|
1104
|
+
<div class="tunnel-panel" id="tunnel-panel" data-panel="tunnel-bar">
|
|
956
1105
|
<!-- not_configured -->
|
|
957
1106
|
<div class="tunnel-state not-configured" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
958
1107
|
<div class="tunnel-dot off"></div>
|
|
@@ -993,40 +1142,38 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
993
1142
|
</div>
|
|
994
1143
|
</div>
|
|
995
1144
|
|
|
996
|
-
<
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
<div class="
|
|
1001
|
-
|
|
1145
|
+
<section class="supervisor-panel" id="supervisor-panel" data-panel="surfaces-log">
|
|
1146
|
+
<div class="supervisor-head">
|
|
1147
|
+
<div>
|
|
1148
|
+
<div class="supervisor-title">Local Software Factory Supervisor</div>
|
|
1149
|
+
<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>
|
|
1150
|
+
</div>
|
|
1151
|
+
<div class="supervisor-actions">
|
|
1152
|
+
<button class="supervisor-action" onclick="loadSupervisorCockpit()">Refresh</button>
|
|
1153
|
+
<button class="supervisor-action" onclick="rescanSupervisorPlugins()">Rescan plugins</button>
|
|
1002
1154
|
</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
1155
|
</div>
|
|
1009
|
-
<div
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
<div class="
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
</div>
|
|
1156
|
+
<div id="supervisor-status" class="supervisor-sub">Loading supervisor state…</div>
|
|
1157
|
+
<div id="supervisor-feedback" class="supervisor-feedback" role="status"></div>
|
|
1158
|
+
<div class="supervisor-grid" style="margin-top:10px">
|
|
1159
|
+
<div class="supervisor-card">
|
|
1160
|
+
<h4>Surfaces</h4>
|
|
1161
|
+
<div class="supervisor-kpi" id="supervisor-surface-count">—</div>
|
|
1162
|
+
<div class="supervisor-meta" id="supervisor-surface-meta">destination + owner matrix</div>
|
|
1163
|
+
<div class="supervisor-cmdbar" id="supervisor-cmdbar"><span class="supervisor-cmdbar-empty">Select a surface below to act on it.</span></div>
|
|
1164
|
+
<div class="supervisor-list" id="supervisor-surfaces" style="margin-top:8px"></div>
|
|
1019
1165
|
</div>
|
|
1020
|
-
<
|
|
1021
|
-
|
|
1022
|
-
<
|
|
1023
|
-
<
|
|
1166
|
+
<div class="supervisor-card">
|
|
1167
|
+
<h4>Operations + log</h4>
|
|
1168
|
+
<div class="supervisor-kpi" id="supervisor-operation-count">—</div>
|
|
1169
|
+
<div class="supervisor-meta" id="supervisor-log-path">events.jsonl</div>
|
|
1170
|
+
<div class="supervisor-log" id="supervisor-events" style="margin-top:8px">No events loaded.</div>
|
|
1024
1171
|
</div>
|
|
1025
1172
|
</div>
|
|
1026
|
-
</
|
|
1173
|
+
</section>
|
|
1027
1174
|
|
|
1028
1175
|
|
|
1029
|
-
<div class="tunnel-panel" id="webdav-panel" style="flex-direction:column;align-items:stretch;gap:8px">
|
|
1176
|
+
<div class="tunnel-panel" id="webdav-panel" data-panel="webdav-mounts" style="flex-direction:column;align-items:stretch;gap:8px">
|
|
1030
1177
|
<div style="display:flex;align-items:center;gap:10px;width:100%">
|
|
1031
1178
|
<div class="tunnel-dot off" id="webdav-dot"></div>
|
|
1032
1179
|
<strong style="color:#e2e8f0;font-size:.88rem">WebDAV Mounts</strong>
|
|
@@ -1062,6 +1209,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
1062
1209
|
<div class="wizard-foot" id="wizard-foot"></div>
|
|
1063
1210
|
</div>
|
|
1064
1211
|
|
|
1212
|
+
<section class="dash-panel" data-panel="search-credentials">
|
|
1065
1213
|
<div id="project-tabs" class="project-tabs" style="display:none"></div>
|
|
1066
1214
|
<div id="service-search" class="service-search">
|
|
1067
1215
|
<span class="service-search-label">Search</span>
|
|
@@ -1069,11 +1217,17 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
1069
1217
|
<span id="service-search-count" class="service-search-count"></span>
|
|
1070
1218
|
</div>
|
|
1071
1219
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
1072
|
-
|
|
1220
|
+
</section>
|
|
1221
|
+
<div class="footer" id="originFooter" data-panel="footer">checking origin… · 10-strike lockout</div>
|
|
1073
1222
|
</div>
|
|
1074
1223
|
|
|
1075
1224
|
<script>
|
|
1076
|
-
const BASE =
|
|
1225
|
+
const BASE = location.origin;
|
|
1226
|
+
(function reportOrigin() {
|
|
1227
|
+
const isLocal = /^(127\\.0\\.0\\.1|localhost|\\[::1\\])$/.test(location.hostname);
|
|
1228
|
+
const el = document.getElementById("originFooter");
|
|
1229
|
+
if (el) el.textContent = (isLocal ? "LOCAL" : "REMOTE") + " · " + BASE + " · 10-strike lockout";
|
|
1230
|
+
})();
|
|
1077
1231
|
|
|
1078
1232
|
const SERVICE_HINTS = {
|
|
1079
1233
|
"neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
|
|
@@ -1217,10 +1371,130 @@ function renderSetPanel(serviceOrName) {
|
|
|
1217
1371
|
let writeToken = ${JSON.stringify(initWriteToken)};
|
|
1218
1372
|
|
|
1219
1373
|
function writeHeaders(extra) {
|
|
1220
|
-
if (!writeToken) throw new Error("
|
|
1374
|
+
if (!writeToken) throw new Error("Writes are locked — click 🔓 Unlock Writes to enable saving.");
|
|
1221
1375
|
return { ...(extra || {}), "X-Clauth-Write-Token": writeToken };
|
|
1222
1376
|
}
|
|
1223
1377
|
|
|
1378
|
+
// ── Write-access choke point ────────────────
|
|
1379
|
+
// ONE mechanism sits in front of every write action.
|
|
1380
|
+
//
|
|
1381
|
+
// A button on this dashboard IS the human being present — they are past the
|
|
1382
|
+
// lock screen already. So a click must never demand a second password. Before
|
|
1383
|
+
// each action the page silently acquires a current write token from
|
|
1384
|
+
// POST /write-token, which succeeds whenever the vault is unlocked. The
|
|
1385
|
+
// password modal is the LOCKED-VAULT fallback only, and the common case never
|
|
1386
|
+
// reaches it.
|
|
1387
|
+
//
|
|
1388
|
+
// Acquiring per action (rather than trusting the token injected at page load)
|
|
1389
|
+
// is what makes staleness structurally impossible: the server session has a
|
|
1390
|
+
// 10-minute TTL while a dashboard tab can stay open for hours. That gap is the
|
|
1391
|
+
// actual defect behind "adding a service says I need write unlock" — the page
|
|
1392
|
+
// kept sending a token the daemon had already expired, and writeGuard answered
|
|
1393
|
+
// 403 "write token required". One loopback round-trip per click closes it.
|
|
1394
|
+
//
|
|
1395
|
+
// If the vault really is locked, the action is parked, the modal opens, and a
|
|
1396
|
+
// successful unlock re-fires it exactly once. Cancelling clears the park, so no
|
|
1397
|
+
// delayed write can fire later.
|
|
1398
|
+
//
|
|
1399
|
+
// writeHeaders() above still throws, but only as a backstop for a write path
|
|
1400
|
+
// that never went through here — WRITE_ACTIONS is asserted complete against
|
|
1401
|
+
// every writeHeaders() call site by cli/supervisor-ui.test.js.
|
|
1402
|
+
let pendingWriteAction = null;
|
|
1403
|
+
|
|
1404
|
+
// Returns true when writeToken now holds a token the daemon will accept.
|
|
1405
|
+
// False means the vault is genuinely locked (or the daemon is unreachable),
|
|
1406
|
+
// which is the only case that deserves a password prompt.
|
|
1407
|
+
async function ensureWriteAccess() {
|
|
1408
|
+
try {
|
|
1409
|
+
const r = await fetch(BASE + "/write-token", { method: "POST" }).then(res => res.json());
|
|
1410
|
+
if (r && r.write_token) {
|
|
1411
|
+
writeToken = r.write_token;
|
|
1412
|
+
refreshWriteLockUi();
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
} catch {}
|
|
1416
|
+
return false;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Every dashboard function whose body calls writeHeaders(). Adding a write
|
|
1420
|
+
// action without adding it here fails the registry-completeness test.
|
|
1421
|
+
const WRITE_ACTIONS = [
|
|
1422
|
+
"rescanSupervisorPlugins",
|
|
1423
|
+
"runSupervisorSurface",
|
|
1424
|
+
"rotateKey",
|
|
1425
|
+
"setExpiry",
|
|
1426
|
+
"saveProject",
|
|
1427
|
+
"saveLabel",
|
|
1428
|
+
"deleteService",
|
|
1429
|
+
"saveKey",
|
|
1430
|
+
"toggleService",
|
|
1431
|
+
"changePassword",
|
|
1432
|
+
"addService",
|
|
1433
|
+
"enrollMachine",
|
|
1434
|
+
"submitMount",
|
|
1435
|
+
"deleteMount",
|
|
1436
|
+
"wizSubmitCfToken",
|
|
1437
|
+
];
|
|
1438
|
+
|
|
1439
|
+
function withWriteAccess(name, fn) {
|
|
1440
|
+
// async, so every path returns a thenable — a caller doing action(x).catch(…)
|
|
1441
|
+
// must not TypeError on the no-write-access path only.
|
|
1442
|
+
const guarded = async function (...args) {
|
|
1443
|
+
if (!(await ensureWriteAccess())) {
|
|
1444
|
+
// Vault is locked. Park the WHOLE call — nothing has run yet, so a
|
|
1445
|
+
// confirm() inside the action is asked once, on the retry, rather than
|
|
1446
|
+
// before a no-op.
|
|
1447
|
+
pendingWriteAction = { name, fn, self: this, args };
|
|
1448
|
+
openWriteUnlockModal();
|
|
1449
|
+
return undefined;
|
|
1450
|
+
}
|
|
1451
|
+
return fn.apply(this, args);
|
|
1452
|
+
};
|
|
1453
|
+
guarded.__writeGuarded = true;
|
|
1454
|
+
guarded.__unguarded = fn;
|
|
1455
|
+
return guarded;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function clearPendingWriteAction() { pendingWriteAction = null; }
|
|
1459
|
+
|
|
1460
|
+
// Read-only seams so tests (and the console) can observe park state without
|
|
1461
|
+
// reaching into a module-scoped binding.
|
|
1462
|
+
function hasPendingWriteAction() { return pendingWriteAction !== null; }
|
|
1463
|
+
function pendingWriteActionName() { return pendingWriteAction ? pendingWriteAction.name : null; }
|
|
1464
|
+
|
|
1465
|
+
function takePendingWriteAction() {
|
|
1466
|
+
const pending = pendingWriteAction;
|
|
1467
|
+
pendingWriteAction = null;
|
|
1468
|
+
return pending;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function installWriteAccessGuards(scope) {
|
|
1472
|
+
for (const name of WRITE_ACTIONS) {
|
|
1473
|
+
const fn = scope[name];
|
|
1474
|
+
if (typeof fn === "function" && fn.__writeGuarded) continue;
|
|
1475
|
+
if (typeof fn !== "function") {
|
|
1476
|
+
// Fail loud, not silent. A registered name that is not a global function
|
|
1477
|
+
// is almost always a write action refactored from a function declaration
|
|
1478
|
+
// to a const/arrow binding: const/let create no global property, so
|
|
1479
|
+
// scope[name] is undefined, the guard would no-op, and the inline
|
|
1480
|
+
// onclick="" would resolve through the lexical binding straight to the
|
|
1481
|
+
// UNGUARDED function — silently restoring the write-lock defect for that
|
|
1482
|
+
// action. Skipping it here is how that regression would ship green.
|
|
1483
|
+
throw new Error(
|
|
1484
|
+
"clauth write-guard install failed: '" + name + "' is registered in WRITE_ACTIONS but is not a " +
|
|
1485
|
+
"global function. Declare it as 'function " + name + "(...)' — a const/let/arrow binding cannot be guarded."
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
scope[name] = withWriteAccess(name, fn);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Safe to run here: every dashboard function is a top-level function
|
|
1493
|
+
// declaration, so all of them are hoisted and initialised before this
|
|
1494
|
+
// statement executes. Inline onclick="" handlers resolve through the same
|
|
1495
|
+
// global properties, so they get the guarded versions.
|
|
1496
|
+
installWriteAccessGuards(typeof window !== "undefined" ? window : globalThis);
|
|
1497
|
+
|
|
1224
1498
|
async function boot() {
|
|
1225
1499
|
try {
|
|
1226
1500
|
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
@@ -1305,9 +1579,173 @@ function showMain(ping) {
|
|
|
1305
1579
|
pollTunnel();
|
|
1306
1580
|
loadWebdavMounts();
|
|
1307
1581
|
updateBuildStatus();
|
|
1582
|
+
loadSupervisorCockpit();
|
|
1583
|
+
startSupervisorLogTail();
|
|
1308
1584
|
refreshWriteLockUi();
|
|
1309
1585
|
}
|
|
1310
1586
|
|
|
1587
|
+
let supervisorLogTailStarted = false;
|
|
1588
|
+
function startSupervisorLogTail() {
|
|
1589
|
+
if (supervisorLogTailStarted) return; // showMain() can run more than once per page load
|
|
1590
|
+
supervisorLogTailStarted = true;
|
|
1591
|
+
setInterval(loadSupervisorCockpit, 3000);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function supervisorBadge(text, kind) {
|
|
1595
|
+
return '<span class="supervisor-pill ' + (kind || '') + '">' + htmlEscape(text) + '</span>';
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
async function supervisorJson(path, options) {
|
|
1599
|
+
const response = await fetch(BASE + path, { cache: "no-store", ...(options || {}) });
|
|
1600
|
+
const data = await response.json().catch(() => ({}));
|
|
1601
|
+
if (!response.ok || data.error) throw new Error(data.error || ("HTTP " + response.status));
|
|
1602
|
+
return data;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function supervisorFeedback(text, bad) {
|
|
1606
|
+
const el = document.getElementById("supervisor-feedback");
|
|
1607
|
+
if (!el) return;
|
|
1608
|
+
el.textContent = text || "";
|
|
1609
|
+
el.className = "supervisor-feedback" + (bad ? " bad" : "");
|
|
1610
|
+
el.style.display = text ? "block" : "none";
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const SUPERVISOR_SURFACE_ACTIONS = ["start", "stop", "restart", "reconcile", "test", "promote", "rollback"];
|
|
1614
|
+
let selectedSupervisorSurface = null; // { id, name } | null
|
|
1615
|
+
let lastSupervisorSurfaceRows = [];
|
|
1616
|
+
|
|
1617
|
+
const CLAUTH_SELF_PSEUDO_SURFACE = {
|
|
1618
|
+
__pseudo: true,
|
|
1619
|
+
id: "clauth:mcp-sse",
|
|
1620
|
+
plugin_id: "clauth",
|
|
1621
|
+
name: "clauth (self)",
|
|
1622
|
+
lifecycle_owner: "external",
|
|
1623
|
+
destination: "self",
|
|
1624
|
+
state: "self_managed",
|
|
1625
|
+
port: null,
|
|
1626
|
+
health: null,
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
async function loadSupervisorCockpit() {
|
|
1630
|
+
const status = document.getElementById("supervisor-status");
|
|
1631
|
+
if (status) status.textContent = "Loading supervisor state…";
|
|
1632
|
+
try {
|
|
1633
|
+
const [health, surfaces, logs] = await Promise.all([
|
|
1634
|
+
supervisorJson("/health"),
|
|
1635
|
+
supervisorJson("/v1/surfaces"),
|
|
1636
|
+
supervisorJson("/v1/logs?limit=40"),
|
|
1637
|
+
]);
|
|
1638
|
+
const surfaceRows = [CLAUTH_SELF_PSEUDO_SURFACE, ...(surfaces.surfaces || [])];
|
|
1639
|
+
lastSupervisorSurfaceRows = surfaceRows;
|
|
1640
|
+
if (selectedSupervisorSurface && !surfaceRows.some(s => (s.plugin_id + ":" + s.id) === selectedSupervisorSurface.id)) {
|
|
1641
|
+
selectedSupervisorSurface = null;
|
|
1642
|
+
}
|
|
1643
|
+
document.getElementById("supervisor-surface-count").textContent = String(surfaceRows.length - 1);
|
|
1644
|
+
document.getElementById("supervisor-operation-count").textContent = String((logs.operations || []).length);
|
|
1645
|
+
document.getElementById("supervisor-surface-meta").textContent = "pm2 home: " + (health.pm2_home || "—");
|
|
1646
|
+
document.getElementById("supervisor-log-path").textContent = logs.log_path || "events.jsonl";
|
|
1647
|
+
document.getElementById("supervisor-surfaces").innerHTML = surfaceRows.map(renderSupervisorSurface).join("");
|
|
1648
|
+
renderSupervisorCmdbar();
|
|
1649
|
+
const logEl = document.getElementById("supervisor-events");
|
|
1650
|
+
// Newest-at-bottom, like a real tail -- and only auto-scroll if the
|
|
1651
|
+
// reader was already at the bottom, so scrolling up to read history
|
|
1652
|
+
// during a poll tick doesn't get yanked back down.
|
|
1653
|
+
const wasAtBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 12;
|
|
1654
|
+
logEl.textContent = (logs.events || []).slice(-40).map(e => {
|
|
1655
|
+
const label = e.kind === "operation" ? (e.action + " " + JSON.stringify(e.target || {})) : (e.kind + " " + (e.plugin_id || ""));
|
|
1656
|
+
return (e.ts || e.created_at || "") + " " + label;
|
|
1657
|
+
}).join("\\n") || "No events yet.";
|
|
1658
|
+
if (wasAtBottom) logEl.scrollTop = logEl.scrollHeight;
|
|
1659
|
+
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}"), "");
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
if (status) status.innerHTML = supervisorBadge("supervisor unreachable", "bad") + " " + htmlEscape(err.message || err);
|
|
1662
|
+
supervisorFeedback("Supervisor unavailable — actions are disabled until the local control plane returns.", true);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Derives an "Open UI" target for a surface without storing one on disk —
|
|
1667
|
+
// a real localhost port wins, else the first externally-routable URL.
|
|
1668
|
+
function openUiUrlForSurface(surface) {
|
|
1669
|
+
if (surface.port && surface.port !== "auto") return "http://127.0.0.1:" + surface.port + "/";
|
|
1670
|
+
const external = (surface.routes || []).find(r => r.kind === "external" && r.url);
|
|
1671
|
+
return external ? external.url : null;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function selectSupervisorSurface(compositeId, name) {
|
|
1675
|
+
selectedSupervisorSurface = selectedSupervisorSurface && selectedSupervisorSurface.id === compositeId
|
|
1676
|
+
? null // clicking the already-selected card deselects it
|
|
1677
|
+
: { id: compositeId, name };
|
|
1678
|
+
document.querySelectorAll(".supervisor-row[data-surface-id]").forEach(row => {
|
|
1679
|
+
row.classList.toggle("selected", row.dataset.surfaceId === (selectedSupervisorSurface && selectedSupervisorSurface.id));
|
|
1680
|
+
});
|
|
1681
|
+
renderSupervisorCmdbar();
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function renderSupervisorCmdbar() {
|
|
1685
|
+
const bar = document.getElementById("supervisor-cmdbar");
|
|
1686
|
+
if (!bar) return;
|
|
1687
|
+
if (!selectedSupervisorSurface) {
|
|
1688
|
+
bar.innerHTML = '<span class="supervisor-cmdbar-empty">Select a surface below to act on it.</span>';
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
const { id, name } = selectedSupervisorSurface;
|
|
1692
|
+
bar.innerHTML = '<span class="supervisor-cmdbar-name">' + htmlEscape(name) + '</span>' +
|
|
1693
|
+
SUPERVISOR_SURFACE_ACTIONS.map(a => '<button class="supervisor-action" data-supervisor-action="' + a + '" onclick="runSupervisorSurface(' + jsArg(id) + ',' + jsArg(a) + ')">' + a + '</button>').join("");
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function renderSupervisorSurface(surface) {
|
|
1697
|
+
const compositeId = surface.plugin_id + ":" + surface.id;
|
|
1698
|
+
const ownerKind = surface.lifecycle_owner === "clauth" ? "ok" : (surface.lifecycle_owner === "plugin" ? "warn" : "");
|
|
1699
|
+
const stateKind = surface.state === "current" || surface.status === "healthy" ? "ok" : (surface.state === "unavailable" ? "bad" : "warn");
|
|
1700
|
+
const openUrl = surface.__pseudo ? null : openUiUrlForSurface(surface);
|
|
1701
|
+
const isSelected = selectedSupervisorSurface && selectedSupervisorSurface.id === compositeId;
|
|
1702
|
+
const rowClass = "supervisor-row" + (surface.__pseudo ? " readonly" : "") + (isSelected ? " selected" : "");
|
|
1703
|
+
const onclick = surface.__pseudo ? "" : ' onclick="selectSupervisorSurface(' + jsArg(compositeId) + ',' + jsArg(surface.name || compositeId) + ')"';
|
|
1704
|
+
const openBtn = surface.__pseudo
|
|
1705
|
+
? '<button class="supervisor-action" onclick="event.stopPropagation();openClauthSelfMcp()" title="Open clauth\\'s own MCP endpoint">Open</button>'
|
|
1706
|
+
: (openUrl ? '<button class="supervisor-action" onclick="event.stopPropagation();window.open(' + jsArg(openUrl) + ',\\'_blank\\')" title="Open this surface\\'s UI">Open UI</button>' : "");
|
|
1707
|
+
return '<div class="' + rowClass + '" data-surface-id="' + htmlEscape(compositeId) + '"' + onclick + '>' +
|
|
1708
|
+
'<div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.name || compositeId) + '</span>' +
|
|
1709
|
+
supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(surface.state || surface.status || "unknown", stateKind) +
|
|
1710
|
+
openBtn +
|
|
1711
|
+
'</div>' +
|
|
1712
|
+
'<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
|
|
1713
|
+
'<div class="supervisor-meta">' + htmlEscape(surface.health || surface.health_url || "no health") + '</div>' +
|
|
1714
|
+
'</div>';
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async function rescanSupervisorPlugins() {
|
|
1718
|
+
try {
|
|
1719
|
+
await supervisorJson("/v1/plugins/rescan", { method: "POST", headers: writeHeaders() });
|
|
1720
|
+
supervisorFeedback("Plugin rescan completed.", false);
|
|
1721
|
+
await loadSupervisorCockpit();
|
|
1722
|
+
} catch (err) { supervisorFeedback("Plugin rescan failed: " + (err.message || err), true); }
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
async function runSupervisorSurface(id, action) {
|
|
1726
|
+
try {
|
|
1727
|
+
const receipt = await supervisorJson("/v1/surfaces/" + encodeURIComponent(id) + "/actions", { method: "POST", headers: writeHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ action }) });
|
|
1728
|
+
const result = receipt.resulting_state || {};
|
|
1729
|
+
supervisorFeedback("Surface " + action + " receipt " + (receipt.operationId || "recorded") + " · " + (result.state || "completed"), result.ok === false);
|
|
1730
|
+
await loadSupervisorCockpit();
|
|
1731
|
+
} catch (err) { supervisorFeedback("Surface " + action + " failed: " + (err.message || err), true); }
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// clauth doesn't manage itself as a plugin, so its own MCP endpoint has no
|
|
1735
|
+
// entry in the real surfaces registry — this reads the vault value directly
|
|
1736
|
+
// (never hardcoded) rather than storing a synthetic surfaces row.
|
|
1737
|
+
async function openClauthSelfMcp() {
|
|
1738
|
+
try {
|
|
1739
|
+
const res = await fetch(BASE + "/v/mcp-clauth-endpoint");
|
|
1740
|
+
const text = (await res.text()).trim();
|
|
1741
|
+
if (!res.ok) {
|
|
1742
|
+
supervisorFeedback("Could not read clauth's own MCP endpoint: " + text, true);
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
window.open(text, "_blank");
|
|
1746
|
+
} catch (err) { supervisorFeedback("Could not read clauth's own MCP endpoint: " + (err.message || err), true); }
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1311
1749
|
// ── Unlock ──────────────────────────────────
|
|
1312
1750
|
async function unlock() {
|
|
1313
1751
|
const input = document.getElementById("lock-input");
|
|
@@ -1365,6 +1803,9 @@ async function unlock() {
|
|
|
1365
1803
|
}
|
|
1366
1804
|
|
|
1367
1805
|
writeToken = r.write_token || null;
|
|
1806
|
+
// Full-vault unlock is a fresh page state, not a retry of a parked write.
|
|
1807
|
+
// Kept symmetric with unlockWrites() so no path can carry a stale park.
|
|
1808
|
+
clearPendingWriteAction();
|
|
1368
1809
|
input.value = "";
|
|
1369
1810
|
const ping = await fetch(BASE + "/ping").then(r => r.json());
|
|
1370
1811
|
showMain(ping);
|
|
@@ -1389,21 +1830,68 @@ async function lockVault() {
|
|
|
1389
1830
|
// ── Unlock writes (re-establish write scope without locking) ──
|
|
1390
1831
|
// Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
|
|
1391
1832
|
// unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1833
|
+
function unlockWrites() {
|
|
1834
|
+
// Manual unlock from the toolbar — nothing is parked, so make sure a stale
|
|
1835
|
+
// park from an earlier dismissed attempt cannot ride along on this unlock.
|
|
1836
|
+
clearPendingWriteAction();
|
|
1837
|
+
openWriteUnlockModal();
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
function openWriteUnlockModal() {
|
|
1841
|
+
const overlay = document.getElementById("write-unlock-overlay");
|
|
1842
|
+
const input = document.getElementById("write-unlock-input");
|
|
1843
|
+
const err = document.getElementById("write-unlock-err");
|
|
1844
|
+
if (err) err.textContent = "";
|
|
1845
|
+
if (input) { input.value = ""; input.className = "lock-input"; }
|
|
1846
|
+
if (overlay) overlay.style.display = "flex";
|
|
1847
|
+
if (input) setTimeout(() => input.focus(), 50);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function closeWriteUnlockModal() {
|
|
1851
|
+
// Dismissing the modal must disarm the parked write. submitWriteUnlock()
|
|
1852
|
+
// takes the park before it calls this, so a successful unlock still retries.
|
|
1853
|
+
clearPendingWriteAction();
|
|
1854
|
+
const overlay = document.getElementById("write-unlock-overlay");
|
|
1855
|
+
if (overlay) overlay.style.display = "none";
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function submitWriteUnlock() {
|
|
1859
|
+
const input = document.getElementById("write-unlock-input");
|
|
1860
|
+
const btn = document.getElementById("write-unlock-btn");
|
|
1861
|
+
const err = document.getElementById("write-unlock-err");
|
|
1862
|
+
const pw = input ? input.value : "";
|
|
1863
|
+
if (!pw) { if (err) err.textContent = "Password is required."; return; }
|
|
1864
|
+
if (btn) { btn.disabled = true; btn.textContent = "Verifying..."; }
|
|
1396
1865
|
try {
|
|
1397
1866
|
const r = await fetch(BASE + "/auth", {
|
|
1398
1867
|
method: "POST",
|
|
1399
1868
|
headers: { "Content-Type": "application/json" },
|
|
1400
1869
|
body: JSON.stringify({ password: pw }),
|
|
1401
1870
|
}).then(r => r.json());
|
|
1402
|
-
if (r.error) {
|
|
1871
|
+
if (r.error) {
|
|
1872
|
+
if (input) { input.className = "lock-input error"; setTimeout(() => input.className = "lock-input", 600); }
|
|
1873
|
+
if (err) err.textContent = "Invalid: " + (r.error || "Invalid password");
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1403
1876
|
writeToken = r.write_token || null;
|
|
1877
|
+
// Take the park BEFORE closing — closeWriteUnlockModal() clears it.
|
|
1878
|
+
const pending = takePendingWriteAction();
|
|
1404
1879
|
refreshWriteLockUi();
|
|
1405
|
-
|
|
1406
|
-
|
|
1880
|
+
closeWriteUnlockModal();
|
|
1881
|
+
if (pending && writeToken) {
|
|
1882
|
+
// Re-fire the ORIGINAL unguarded action, exactly once. Using the
|
|
1883
|
+
// unguarded reference means it can never re-park itself into a loop.
|
|
1884
|
+
try {
|
|
1885
|
+
await pending.fn.apply(pending.self, pending.args);
|
|
1886
|
+
} catch (retryErr) {
|
|
1887
|
+
console.error("[clauth] write action '" + pending.name + "' failed after unlock:", retryErr);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
} catch (e) {
|
|
1891
|
+
if (err) err.textContent = "Unlock error: " + (e.message || e);
|
|
1892
|
+
} finally {
|
|
1893
|
+
if (btn) { btn.disabled = false; btn.textContent = "Unlock Writes"; }
|
|
1894
|
+
}
|
|
1407
1895
|
}
|
|
1408
1896
|
|
|
1409
1897
|
// Reflect write-lock state on the button so it is obvious when a save will fail.
|
|
@@ -1451,124 +1939,6 @@ async function launchCCandMe() {
|
|
|
1451
1939
|
}
|
|
1452
1940
|
}
|
|
1453
1941
|
|
|
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
1942
|
// ── Restart daemon (keeps boot.key — vault stays unlocked) ──
|
|
1573
1943
|
async function restartDaemon() {
|
|
1574
1944
|
if (!confirm("Restart the daemon?\\n\\nThe vault will stay unlocked (boot.key kept).")) return;
|
|
@@ -1640,8 +2010,16 @@ function switchProjectTab(key) {
|
|
|
1640
2010
|
renderServiceGrid(allServices);
|
|
1641
2011
|
}
|
|
1642
2012
|
|
|
2013
|
+
const MCP_SURFACE_DUPLICATE_SERVICES = new Set([
|
|
2014
|
+
"mcp-clauth-endpoint",
|
|
2015
|
+
"mcp-fs-endpoint",
|
|
2016
|
+
"mcp-regen-media-endpoint",
|
|
2017
|
+
"mcp-web-research-endpoint",
|
|
2018
|
+
]);
|
|
2019
|
+
|
|
1643
2020
|
function renderServiceGrid(services) {
|
|
1644
2021
|
const grid = document.getElementById("grid");
|
|
2022
|
+
services = services.filter(s => !MCP_SURFACE_DUPLICATE_SERVICES.has(s.name));
|
|
1645
2023
|
let filtered = services;
|
|
1646
2024
|
if (activeProjectTab === "unassigned") {
|
|
1647
2025
|
filtered = services.filter(s => !s.project);
|
|
@@ -3034,11 +3412,6 @@ setInterval(async () => {
|
|
|
3034
3412
|
}, 5000);
|
|
3035
3413
|
|
|
3036
3414
|
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
3415
|
</script>
|
|
3043
3416
|
</body>
|
|
3044
3417
|
</html>`;
|
|
@@ -3061,610 +3434,55 @@ function clauthConfigDir() {
|
|
|
3061
3434
|
return path.join(os.homedir(), ".config", "clauth");
|
|
3062
3435
|
}
|
|
3063
3436
|
|
|
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
|
-
};
|
|
3437
|
+
// ── call_agent noauth-host guard (Gate B, bearer-gated tunnel) ───────────────
|
|
3438
|
+
// Pure decision function so the policy is testable without a daemon. Decides
|
|
3439
|
+
// whether a /call-agent request may proceed.
|
|
3440
|
+
// - Browser Origin → reject (CSRF), unchanged, regardless of host/token.
|
|
3441
|
+
// - Local (127.0.0.1, noAuthHost=false) → allow without a token, as today.
|
|
3442
|
+
// - Noauth tunnel host (clauth.regendevcorp.com etc.) → allow ONLY when the
|
|
3443
|
+
// request carries `Authorization: Bearer <expectedToken>` and the token is
|
|
3444
|
+
// a non-empty exact match. Missing/wrong bearer → reject (unchanged
|
|
3445
|
+
// unauthenticated behaviour). If no expectedToken is configured server-side,
|
|
3446
|
+
// the noauth host stays fully closed (cannot be unlocked by any bearer).
|
|
3447
|
+
// Returns { allow: true } or { allow: false, status, error }.
|
|
3448
|
+
export function evaluateCallAgentGuard({ origin, noAuthHost, authHeader, expectedToken }) {
|
|
3449
|
+
if (origin) {
|
|
3450
|
+
return { allow: false, status: 403, error: "call_agent_rejects_browser_origin", origin };
|
|
3179
3451
|
}
|
|
3452
|
+
if (!noAuthHost) {
|
|
3453
|
+
return { allow: true };
|
|
3454
|
+
}
|
|
3455
|
+
// Noauth tunnel host: require a valid bearer token.
|
|
3456
|
+
const presented = typeof authHeader === "string"
|
|
3457
|
+
? (authHeader.match(/^Bearer\s+(.+)$/i)?.[1] || "").trim()
|
|
3458
|
+
: "";
|
|
3459
|
+
if (expectedToken && presented && presented === expectedToken) {
|
|
3460
|
+
return { allow: true };
|
|
3461
|
+
}
|
|
3462
|
+
return { allow: false, status: 403, error: "call_agent_not_available_on_noauth_host" };
|
|
3180
3463
|
}
|
|
3181
3464
|
|
|
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
|
-
}
|
|
3465
|
+
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
3466
|
+
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
|
|
3467
|
+
mcpHttpBaseUrl = `http://127.0.0.1:${port}`;
|
|
3198
3468
|
|
|
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
|
-
}
|
|
3469
|
+
// tunnelHostname may be updated at runtime (fetched from DB after unlock)
|
|
3470
|
+
let tunnelHostname = tunnelHostnameInit;
|
|
3211
3471
|
|
|
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}`);
|
|
3472
|
+
// Ensure Windows system tools are reachable (bash shells may lack these on PATH)
|
|
3473
|
+
if (os.platform() === "win32") {
|
|
3474
|
+
const sys32 = "C:\\Windows\\System32";
|
|
3475
|
+
if (!process.env.PATH?.includes(sys32 + "\\Wbem")) {
|
|
3476
|
+
process.env.PATH = (process.env.PATH || "") + ";" + sys32 + "\\Wbem";
|
|
3477
|
+
}
|
|
3478
|
+
if (!process.env.PATH?.includes(sys32 + ";") && !process.env.PATH?.endsWith(sys32)) {
|
|
3479
|
+
process.env.PATH = (process.env.PATH || "") + ";" + sys32;
|
|
3247
3480
|
}
|
|
3248
|
-
cwd = worktreePath;
|
|
3249
|
-
} else if (isolation !== "cwd") {
|
|
3250
|
-
return { ok: false, error: "unsupported_isolation", isolation };
|
|
3251
3481
|
}
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
const
|
|
3255
|
-
|
|
3256
|
-
const launchCommand = runtime === "codex"
|
|
3257
|
-
? `${command} exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -C ${JSON.stringify(cwd)} -`
|
|
3258
|
-
: `${command} -p <prompt> --dangerously-skip-permissions`;
|
|
3259
|
-
|
|
3260
|
-
const result = {
|
|
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;
|
|
3482
|
+
const MAX_FAILS = 10;
|
|
3483
|
+
let failCount = 0;
|
|
3484
|
+
const MAX_AUTH_FAILS = 10;
|
|
3485
|
+
let authFailCount = 0;
|
|
3668
3486
|
|
|
3669
3487
|
// Per-IP unknown-service strike counter — separate from auth failure budget.
|
|
3670
3488
|
// Caller typos / stale memory should NOT burn the 10-strike lockout.
|
|
@@ -3770,6 +3588,187 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3770
3588
|
});
|
|
3771
3589
|
},
|
|
3772
3590
|
});
|
|
3591
|
+
const isSupervisorPort = port === getSupervisorPort();
|
|
3592
|
+
const supervisorTestNoToken = process.env.CLAUTH_SUPERVISOR_TEST_NO_TOKEN === "1";
|
|
3593
|
+
const opsAdapter = createPm2Adapter(pm2);
|
|
3594
|
+
const executePm2 = createSerializedExecutor();
|
|
3595
|
+
const opsPolicy = createOperationPolicy({
|
|
3596
|
+
enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
3597
|
+
applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
3598
|
+
adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
3599
|
+
adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
3600
|
+
allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
|
|
3601
|
+
});
|
|
3602
|
+
const opsJobs = createJobStore({
|
|
3603
|
+
filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
|
|
3604
|
+
});
|
|
3605
|
+
|
|
3606
|
+
async function getLoopbackSecret(service) {
|
|
3607
|
+
const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
|
|
3608
|
+
if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
|
|
3609
|
+
const value = (await response.text()).trim();
|
|
3610
|
+
if (!value) throw new Error(`${service} is empty`);
|
|
3611
|
+
return value;
|
|
3612
|
+
}
|
|
3613
|
+
const coolify = createCoolifyAdapter({
|
|
3614
|
+
baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
|
|
3615
|
+
getToken: () => getLoopbackSecret("coolify-api"),
|
|
3616
|
+
});
|
|
3617
|
+
const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
|
|
3618
|
+
const deploymentAdapter = createDeploymentAdapter({
|
|
3619
|
+
deployments,
|
|
3620
|
+
reload: async (target) => {
|
|
3621
|
+
await executePm2(async () => {
|
|
3622
|
+
await opsAdapter.connect();
|
|
3623
|
+
try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
|
|
3624
|
+
});
|
|
3625
|
+
},
|
|
3626
|
+
});
|
|
3627
|
+
|
|
3628
|
+
/**
|
|
3629
|
+
* Record an ops failure's upstream message to the LOCAL log only.
|
|
3630
|
+
*
|
|
3631
|
+
* job-store's sanitizer deliberately drops free-form `error` text so an
|
|
3632
|
+
* upstream message cannot carry a credential into the persisted job file or
|
|
3633
|
+
* the API response. That protection left every failure with an empty detail,
|
|
3634
|
+
* so jobs reported `failed` with no reason at all. Jobs now carry an
|
|
3635
|
+
* enumerated `code`; the underlying message goes here, to the same
|
|
3636
|
+
* operator-only log as the rest of the daemon's diagnostics.
|
|
3637
|
+
*/
|
|
3638
|
+
function logOpsFailure(kind, operation, error) {
|
|
3639
|
+
const message = String(error?.message || error || "unknown");
|
|
3640
|
+
try {
|
|
3641
|
+
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
|
|
3642
|
+
} catch {}
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
async function opsBearerRole(req) {
|
|
3646
|
+
const header = req.headers.authorization;
|
|
3647
|
+
const supplied = Array.isArray(header) ? header[0] : header;
|
|
3648
|
+
if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
|
|
3649
|
+
const actual = String(supplied).slice(7).trim();
|
|
3650
|
+
let admin; let agent;
|
|
3651
|
+
try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
|
|
3652
|
+
try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
|
|
3653
|
+
if (admin && agent && admin === agent) return null;
|
|
3654
|
+
for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
|
|
3655
|
+
if (!expected) continue;
|
|
3656
|
+
const a = Buffer.from(actual); const b = Buffer.from(expected);
|
|
3657
|
+
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
|
|
3658
|
+
}
|
|
3659
|
+
return null;
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
async function requireOpsBearer(req, res) {
|
|
3663
|
+
const role = await opsBearerRole(req);
|
|
3664
|
+
if (role) { req._opsRole = role; return true; }
|
|
3665
|
+
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
3666
|
+
res.end(JSON.stringify({ error: "ops_bearer_required" }));
|
|
3667
|
+
return false;
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
function submitOpsJob(operation, input, role = "agent") {
|
|
3671
|
+
const authorization = opsPolicy.authorize(operation, input, role);
|
|
3672
|
+
const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
|
|
3673
|
+
if (!authorization.ok) {
|
|
3674
|
+
return opsJobs.event(job.id, "rejected", { code: authorization.code });
|
|
3675
|
+
}
|
|
3676
|
+
void (async () => {
|
|
3677
|
+
opsJobs.event(job.id, "running");
|
|
3678
|
+
try {
|
|
3679
|
+
const result = await executePm2(async () => {
|
|
3680
|
+
await opsAdapter.connect();
|
|
3681
|
+
try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
|
|
3682
|
+
});
|
|
3683
|
+
opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
|
|
3684
|
+
} catch (error) {
|
|
3685
|
+
// `error` alone is dropped by job-store's sanitizer (it refuses
|
|
3686
|
+
// free-form upstream text so a credential cannot ride along), which
|
|
3687
|
+
// left every failure with an empty detail. Emit an enumerated code so
|
|
3688
|
+
// the failure has a reason; keep the message for the local log only.
|
|
3689
|
+
logOpsFailure("pm2", operation, error);
|
|
3690
|
+
opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
|
|
3691
|
+
}
|
|
3692
|
+
})();
|
|
3693
|
+
return opsJobs.get(job.id);
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
function operationReceipt(operation, result, allowedTargets) {
|
|
3697
|
+
if (["list", "describe", "logs"].includes(operation)) {
|
|
3698
|
+
const processes = Array.isArray(result)
|
|
3699
|
+
? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
|
|
3700
|
+
: [];
|
|
3701
|
+
return { processes };
|
|
3702
|
+
}
|
|
3703
|
+
if (operation === "ping") return { status: "connected" };
|
|
3704
|
+
return { status: "completed" };
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
function submitPromotionJob(applicationUuid) {
|
|
3708
|
+
const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
|
|
3709
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
|
|
3710
|
+
const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
|
|
3711
|
+
if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
|
|
3712
|
+
return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
3713
|
+
}
|
|
3714
|
+
void (async () => {
|
|
3715
|
+
opsJobs.event(job.id, "running");
|
|
3716
|
+
try {
|
|
3717
|
+
const deployment = await coolify.promote(applicationUuid);
|
|
3718
|
+
// Coolify answers with a `deployments` ARRAY, not a flat object — see
|
|
3719
|
+
// deploymentUuidFrom. Reading the flat field alone marked the job failed
|
|
3720
|
+
// while the deployment was actually running.
|
|
3721
|
+
const deploymentUuid = deploymentUuidFrom(deployment);
|
|
3722
|
+
if (!deploymentUuid) {
|
|
3723
|
+
// The deploy request itself SUCCEEDED (no throw); only the UUID was
|
|
3724
|
+
// unreadable, so the deployment may well be RUNNING. The code says so
|
|
3725
|
+
// explicitly rather than a bare "failed", because a plain failure
|
|
3726
|
+
// invites a retry and a duplicate production deploy.
|
|
3727
|
+
//
|
|
3728
|
+
// An enumerated code, not a free-form error: job-store's sanitizer
|
|
3729
|
+
// drops `error` on purpose to keep upstream text (and any credential
|
|
3730
|
+
// inside it) out of the persisted job.
|
|
3731
|
+
return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
|
|
3732
|
+
}
|
|
3733
|
+
opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
|
|
3734
|
+
const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
|
|
3735
|
+
opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
|
|
3736
|
+
} catch (error) {
|
|
3737
|
+
// The throw may have happened AFTER Coolify accepted the deploy (e.g.
|
|
3738
|
+
// the poll lost the network), so this is not proof nothing shipped.
|
|
3739
|
+
logOpsFailure("coolify", "promote", error);
|
|
3740
|
+
opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
|
|
3741
|
+
}
|
|
3742
|
+
})();
|
|
3743
|
+
return opsJobs.get(job.id);
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
function submitDeploymentJob(application, ref) {
|
|
3747
|
+
const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
|
|
3748
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
|
|
3749
|
+
if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
3750
|
+
void (async () => {
|
|
3751
|
+
opsJobs.event(job.id, "running");
|
|
3752
|
+
try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
|
|
3753
|
+
catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
|
|
3754
|
+
})();
|
|
3755
|
+
return opsJobs.get(job.id);
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
function hasSupervisorWrite(req) {
|
|
3759
|
+
if (validateWriteToken(req, writeSession)) return true;
|
|
3760
|
+
if (!isSupervisorPort) return false;
|
|
3761
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
|
|
3762
|
+
return supervisorTestNoToken || !supervisorRequiresWriteToken(port);
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
function rejectSupervisorWrite(res) {
|
|
3766
|
+
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
3767
|
+
return res.end(JSON.stringify({
|
|
3768
|
+
error: "write_token_required",
|
|
3769
|
+
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.",
|
|
3770
|
+
}));
|
|
3771
|
+
}
|
|
3773
3772
|
|
|
3774
3773
|
// ── MCP SSE session tracking ──────────────────────────────
|
|
3775
3774
|
const sseSessions = new Map(); // sessionId → { res, initialized }
|
|
@@ -4310,7 +4309,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4310
4309
|
|
|
4311
4310
|
const server = http.createServer(async (req, res) => {
|
|
4312
4311
|
const remote = req.socket.remoteAddress;
|
|
4313
|
-
const isLocal = remote
|
|
4312
|
+
const isLocal = isLoopbackAddress(remote);
|
|
4314
4313
|
|
|
4315
4314
|
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
4316
4315
|
const reqPath = url.pathname;
|
|
@@ -4350,6 +4349,205 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4350
4349
|
return res.end(JSON.stringify(result));
|
|
4351
4350
|
}
|
|
4352
4351
|
|
|
4352
|
+
if (method === "GET" && reqPath === "/health") {
|
|
4353
|
+
return ok(res, { ...supervisorHealth(), listening_port: port, process_id: process.pid, vault_locked: !password, clauth_version: VERSION });
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
// Bearer-gated remote operations surface. It remains loopback-only at this
|
|
4357
|
+
// layer; ingress/tunnel policy decides whether it is reachable remotely.
|
|
4358
|
+
if (method === "GET" && reqPath === "/v1/ops/catalog") {
|
|
4359
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4360
|
+
return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
if (method === "GET" && reqPath === "/v1/ops/processes") {
|
|
4364
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4365
|
+
const job = submitOpsJob("list", {}, req._opsRole);
|
|
4366
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4367
|
+
return res.end(JSON.stringify(job));
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
|
|
4371
|
+
if (method === "GET" && opsProcessMatch) {
|
|
4372
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4373
|
+
const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
|
|
4374
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4375
|
+
return res.end(JSON.stringify(job));
|
|
4376
|
+
}
|
|
4377
|
+
|
|
4378
|
+
if (method === "POST" && reqPath === "/v1/ops/operations") {
|
|
4379
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4380
|
+
let body;
|
|
4381
|
+
try { body = await readBody(req); } catch {
|
|
4382
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4383
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4384
|
+
}
|
|
4385
|
+
const operation = String(body?.operation || "");
|
|
4386
|
+
if (!PM2_OPERATION_CATALOG[operation]) {
|
|
4387
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4388
|
+
return res.end(JSON.stringify({ error: "unknown_operation" }));
|
|
4389
|
+
}
|
|
4390
|
+
const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
|
|
4391
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4392
|
+
return res.end(JSON.stringify(job));
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
if (method === "POST" && reqPath === "/v1/ops/promotions") {
|
|
4396
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4397
|
+
let body;
|
|
4398
|
+
try { body = await readBody(req); } catch {
|
|
4399
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4400
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4401
|
+
}
|
|
4402
|
+
const applicationUuid = String(body?.application_uuid || "").trim();
|
|
4403
|
+
if (!applicationUuid) {
|
|
4404
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4405
|
+
return res.end(JSON.stringify({ error: "application_uuid_required" }));
|
|
4406
|
+
}
|
|
4407
|
+
const job = submitPromotionJob(applicationUuid);
|
|
4408
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4409
|
+
return res.end(JSON.stringify(job));
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
if (method === "POST" && reqPath === "/v1/ops/deployments") {
|
|
4413
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4414
|
+
let body;
|
|
4415
|
+
try { body = await readBody(req); } catch {
|
|
4416
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4417
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4418
|
+
}
|
|
4419
|
+
const application = String(body?.application || "").trim();
|
|
4420
|
+
if (!application) {
|
|
4421
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4422
|
+
return res.end(JSON.stringify({ error: "application_required" }));
|
|
4423
|
+
}
|
|
4424
|
+
const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
|
|
4425
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4426
|
+
return res.end(JSON.stringify(job));
|
|
4427
|
+
}
|
|
4428
|
+
|
|
4429
|
+
const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
|
|
4430
|
+
if (method === "GET" && opsJobMatch) {
|
|
4431
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4432
|
+
const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
|
|
4433
|
+
res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
|
|
4434
|
+
return res.end(JSON.stringify(job || { error: "job_not_found" }));
|
|
4435
|
+
}
|
|
4436
|
+
|
|
4437
|
+
const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
|
|
4438
|
+
if (method === "GET" && opsJobEventsMatch) {
|
|
4439
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4440
|
+
const jobId = decodeURIComponent(opsJobEventsMatch[1]);
|
|
4441
|
+
if (!opsJobs.get(jobId)) {
|
|
4442
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
4443
|
+
return res.end(JSON.stringify({ error: "job_not_found" }));
|
|
4444
|
+
}
|
|
4445
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
|
|
4446
|
+
const unsubscribe = opsJobs.subscribe(jobId, (job) => {
|
|
4447
|
+
if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
|
|
4448
|
+
});
|
|
4449
|
+
req.on("close", unsubscribe);
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4452
|
+
|
|
4453
|
+
if (method === "GET" && reqPath === "/v1/plugins") {
|
|
4454
|
+
return ok(res, { plugins: listPlugins() });
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
if (method === "POST" && reqPath === "/v1/plugins/rescan") {
|
|
4458
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4459
|
+
return ok(res, discoverPlugins());
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
const pluginEnableMatch = reqPath.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable|test|promote)$/);
|
|
4463
|
+
if (method === "POST" && pluginEnableMatch) {
|
|
4464
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4465
|
+
const pluginId = decodeURIComponent(pluginEnableMatch[1]);
|
|
4466
|
+
const op = pluginEnableMatch[2];
|
|
4467
|
+
const result = op === "enable"
|
|
4468
|
+
? setPluginEnabled(pluginId, true)
|
|
4469
|
+
: op === "disable"
|
|
4470
|
+
? setPluginEnabled(pluginId, false)
|
|
4471
|
+
: runPluginAction(pluginId, op);
|
|
4472
|
+
res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...CORS });
|
|
4473
|
+
return res.end(JSON.stringify(result));
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
if (method === "GET" && reqPath === "/v1/surfaces") {
|
|
4477
|
+
return ok(res, { surfaces: listSurfaces() });
|
|
4478
|
+
}
|
|
4479
|
+
|
|
4480
|
+
const surfaceActionMatch = reqPath.match(/^\/v1\/surfaces\/([^/]+)\/actions$/);
|
|
4481
|
+
if (method === "POST" && surfaceActionMatch) {
|
|
4482
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4483
|
+
let body;
|
|
4484
|
+
try { body = await readBody(req); } catch {
|
|
4485
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4486
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
4487
|
+
}
|
|
4488
|
+
const result = runSurfaceAction(decodeURIComponent(surfaceActionMatch[1]), body?.action || "reconcile");
|
|
4489
|
+
res.writeHead(result.error ? 400 : 200, { "Content-Type": "application/json", ...CORS });
|
|
4490
|
+
return res.end(JSON.stringify(result));
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4493
|
+
if (method === "GET" && reqPath === "/v1/routes") {
|
|
4494
|
+
return ok(res, { routes: listRoutes() });
|
|
4495
|
+
}
|
|
4496
|
+
|
|
4497
|
+
if (method === "GET" && reqPath === "/v1/tunnels") {
|
|
4498
|
+
return ok(res, { tunnels: listTunnels() });
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
if (method === "GET" && reqPath === "/v1/logs") {
|
|
4502
|
+
const limit = Number(url.searchParams.get("limit") || 100);
|
|
4503
|
+
const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 500)) : 100;
|
|
4504
|
+
const state = loadSupervisorState();
|
|
4505
|
+
return ok(res, {
|
|
4506
|
+
schema: "clauth.supervisor.logs.v1",
|
|
4507
|
+
log_path: path.join(getSupervisorDir(), "events.jsonl"),
|
|
4508
|
+
events: readSupervisorEvents(boundedLimit).map(supervisorLogDto),
|
|
4509
|
+
operations: (state.operations || []).slice(0, boundedLimit).map(supervisorOperationDto),
|
|
4510
|
+
});
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
const tunnelRoutesMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes$/);
|
|
4514
|
+
if (method === "POST" && tunnelRoutesMatch) {
|
|
4515
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4516
|
+
let body;
|
|
4517
|
+
try { body = await readBody(req); } catch {
|
|
4518
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4519
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
4520
|
+
}
|
|
4521
|
+
return ok(res, addTunnelRoute(decodeURIComponent(tunnelRoutesMatch[1]), body, "localhost"));
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
const tunnelRouteDeleteMatch = reqPath.match(/^\/v1\/tunnels\/([^/]+)\/routes\/([^/]+)$/);
|
|
4525
|
+
if (method === "DELETE" && tunnelRouteDeleteMatch) {
|
|
4526
|
+
if (!hasSupervisorWrite(req)) return rejectSupervisorWrite(res);
|
|
4527
|
+
return ok(res, removeTunnelRoute(decodeURIComponent(tunnelRouteDeleteMatch[1]), decodeURIComponent(tunnelRouteDeleteMatch[2]), "localhost"));
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
if (method === "GET" && reqPath.startsWith("/v1/operations/")) {
|
|
4531
|
+
const id = decodeURIComponent(reqPath.split("/").pop());
|
|
4532
|
+
const operation = (supervisorHealth(), readSupervisorEvents(500)).find((event) => event.operationId === id);
|
|
4533
|
+
res.writeHead(operation ? 200 : 404, { "Content-Type": "application/json", ...CORS });
|
|
4534
|
+
return res.end(JSON.stringify(operation ? supervisorLogDto(operation) : { error: "operation_not_found" }));
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
if (method === "GET" && reqPath === "/v1/events") {
|
|
4538
|
+
res.writeHead(200, {
|
|
4539
|
+
"Content-Type": "text/event-stream",
|
|
4540
|
+
"Cache-Control": "no-cache",
|
|
4541
|
+
"Connection": "keep-alive",
|
|
4542
|
+
...CORS,
|
|
4543
|
+
});
|
|
4544
|
+
for (const event of readSupervisorEvents(Number(url.searchParams.get("limit") || 100))) {
|
|
4545
|
+
res.write(`event: supervisor\ndata: ${JSON.stringify(supervisorLogDto(event))}\n\n`);
|
|
4546
|
+
}
|
|
4547
|
+
res.end();
|
|
4548
|
+
return;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4353
4551
|
// ── Hosts that bypass OAuth (fresh domains for claude.ai compatibility) ──
|
|
4354
4552
|
const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
|
|
4355
4553
|
const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
@@ -4419,6 +4617,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4419
4617
|
saveClients(oauthClients);
|
|
4420
4618
|
const logMsg = `[${new Date().toISOString()}] OAuth: registered public client ${clientId} (${client.client_name})\n`;
|
|
4421
4619
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
4620
|
+
operation("oauth.client_register", { client_id: clientId, client_name: client.client_name }, null, { ok: true });
|
|
4422
4621
|
res.writeHead(201, { "Content-Type": "application/json", "Cache-Control": "no-store", ...CORS });
|
|
4423
4622
|
return res.end(JSON.stringify(client));
|
|
4424
4623
|
}
|
|
@@ -4542,6 +4741,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4542
4741
|
|
|
4543
4742
|
const logMsg = `[${new Date().toISOString()}] OAuth: token issued for ${stored.client_id} (token=${accessToken.slice(0,8)}…)\n`;
|
|
4544
4743
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
4744
|
+
operation("oauth.token_issue", { client_id: stored.client_id }, null, { ok: true });
|
|
4545
4745
|
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", ...CORS });
|
|
4546
4746
|
return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", scope: "mcp:tools", expires_in: 86400 }));
|
|
4547
4747
|
}
|
|
@@ -4552,7 +4752,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4552
4752
|
function toolsForPath(p) {
|
|
4553
4753
|
const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
|
|
4554
4754
|
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 === "
|
|
4755
|
+
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
4756
|
if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
|
|
4557
4757
|
if (p === "/chitchat") return tools.filter(t => t.name.startsWith("chitchat_"));
|
|
4558
4758
|
if (p === "/codevelop") return tools.filter(t => t.name.startsWith("codevelop_"));
|
|
@@ -4858,8 +5058,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4858
5058
|
return res.end(JSON.stringify({ error: "name and path are required" }));
|
|
4859
5059
|
}
|
|
4860
5060
|
const result = webdavService.addMount(name.trim(), mountPath.trim());
|
|
4861
|
-
if (result.error) {
|
|
5061
|
+
if (result.error) {
|
|
5062
|
+
operation("webdav.mount_add", { name: name.trim() }, null, { ok: false, error: result.error });
|
|
5063
|
+
res.writeHead(409, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result));
|
|
5064
|
+
}
|
|
4862
5065
|
webdavService.shutdown();
|
|
5066
|
+
operation("webdav.mount_add", { name: name.trim(), path: mountPath.trim() }, null, { ok: true });
|
|
4863
5067
|
return ok(res, result);
|
|
4864
5068
|
}
|
|
4865
5069
|
|
|
@@ -4867,8 +5071,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4867
5071
|
if (writeGuard(req, res)) return;
|
|
4868
5072
|
const mountName = decodeURIComponent(reqPath.slice("/webdav/mounts/".length));
|
|
4869
5073
|
const result = webdavService.removeMount(mountName);
|
|
4870
|
-
if (result.error) {
|
|
5074
|
+
if (result.error) {
|
|
5075
|
+
operation("webdav.mount_remove", { name: mountName }, null, { ok: false, error: result.error });
|
|
5076
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS }); return res.end(JSON.stringify(result));
|
|
5077
|
+
}
|
|
4871
5078
|
webdavService.shutdown();
|
|
5079
|
+
operation("webdav.mount_remove", { name: mountName }, null, { ok: true });
|
|
4872
5080
|
return ok(res, result);
|
|
4873
5081
|
}
|
|
4874
5082
|
|
|
@@ -4882,6 +5090,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4882
5090
|
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
4883
5091
|
}
|
|
4884
5092
|
const result = startCodevelopSession(body || {});
|
|
5093
|
+
operation("codevelop.start", { session_id: result?.session_id }, null, { ok: !result?.error, error: result?.error });
|
|
4885
5094
|
return ok(res, result);
|
|
4886
5095
|
}
|
|
4887
5096
|
|
|
@@ -4893,12 +5102,19 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4893
5102
|
}
|
|
4894
5103
|
const result = joinCodevelopSession(body || {});
|
|
4895
5104
|
if (result.error) {
|
|
5105
|
+
operation("codevelop.join", { session_id: body?.session_id }, null, { ok: false, error: result.error });
|
|
4896
5106
|
res.writeHead(result.error === "not_found" ? 404 : 400, { "Content-Type": "application/json", ...CORS });
|
|
4897
5107
|
return res.end(JSON.stringify(result));
|
|
4898
5108
|
}
|
|
5109
|
+
operation("codevelop.join", { session_id: body?.session_id }, null, { ok: true });
|
|
4899
5110
|
return ok(res, result);
|
|
4900
5111
|
}
|
|
4901
5112
|
|
|
5113
|
+
// codevelop/send is a genuine mutation (adds a message) but excluded from
|
|
5114
|
+
// per-route logging by design — a live peer-to-peer relay can send at a
|
|
5115
|
+
// frequency that would bloat events.jsonl (same risk noted in the audit-
|
|
5116
|
+
// logging retrofit plan for codevelop/poll). Session lifecycle
|
|
5117
|
+
// (start/join/stop) is logged; message throughput is not.
|
|
4902
5118
|
if (method === "POST" && reqPath === "/codevelop/send") {
|
|
4903
5119
|
let body;
|
|
4904
5120
|
try { body = await readBody(req); } catch {
|
|
@@ -4982,9 +5198,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4982
5198
|
}
|
|
4983
5199
|
const result = stopCodevelopSession(body?.session_id);
|
|
4984
5200
|
if (result.error) {
|
|
5201
|
+
operation("codevelop.stop", { session_id: body?.session_id }, null, { ok: false, error: result.error });
|
|
4985
5202
|
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
4986
5203
|
return res.end(JSON.stringify(result));
|
|
4987
5204
|
}
|
|
5205
|
+
operation("codevelop.stop", { session_id: body?.session_id }, null, { ok: true });
|
|
4988
5206
|
return ok(res, result);
|
|
4989
5207
|
}
|
|
4990
5208
|
|
|
@@ -5172,6 +5390,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5172
5390
|
saveTokens(oauthTokens);
|
|
5173
5391
|
const logMsg = `[${new Date().toISOString()}] OAuth: rolled credentials — all clients and tokens invalidated\n`;
|
|
5174
5392
|
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
5393
|
+
operation("oauth.roll_creds", {}, null, { ok: true });
|
|
5175
5394
|
return ok(res, { clients_cleared: true, tokens_invalidated: true });
|
|
5176
5395
|
}
|
|
5177
5396
|
|
|
@@ -5185,10 +5404,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5185
5404
|
}
|
|
5186
5405
|
if (body.action === "stop") {
|
|
5187
5406
|
stopTunnel();
|
|
5407
|
+
operation("tunnel.stop", {}, null, { ok: true });
|
|
5188
5408
|
return ok(res, { status: tunnelStatus, running: false });
|
|
5189
5409
|
}
|
|
5190
5410
|
// start
|
|
5191
5411
|
await startTunnel();
|
|
5412
|
+
operation("tunnel.start", {}, null, { ok: !!tunnelProc, url: tunnelUrl, error: tunnelError });
|
|
5192
5413
|
return ok(res, { status: tunnelStatus, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
|
|
5193
5414
|
}
|
|
5194
5415
|
|
|
@@ -5198,6 +5419,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5198
5419
|
if (tunnelProc) return ok(res, { status: tunnelStatus, message: "already running" });
|
|
5199
5420
|
tunnelStatus = "starting";
|
|
5200
5421
|
startTunnel().catch(() => {});
|
|
5422
|
+
operation("tunnel.start", {}, null, { ok: true, status: "starting" });
|
|
5201
5423
|
return ok(res, { status: "starting" });
|
|
5202
5424
|
}
|
|
5203
5425
|
|
|
@@ -5205,6 +5427,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5205
5427
|
if (method === "POST" && reqPath === "/tunnel/stop") {
|
|
5206
5428
|
if (lockedGuard(res)) return;
|
|
5207
5429
|
stopTunnel();
|
|
5430
|
+
operation("tunnel.stop", {}, null, { ok: true });
|
|
5208
5431
|
return ok(res, { status: tunnelStatus });
|
|
5209
5432
|
}
|
|
5210
5433
|
|
|
@@ -5224,505 +5447,60 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5224
5447
|
const wezExe = wezCandidates.find(existsSync) || "wezterm";
|
|
5225
5448
|
|
|
5226
5449
|
// 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
|
-
}
|
|
5450
|
+
const CCANDME_DIR = "C:/Dev/CCandMe";
|
|
5451
|
+
const WORK_DIR = "C:/Dev/regen-root";
|
|
5452
|
+
const templatePath = path.join(CCANDME_DIR, "templates", "wezterm.lua");
|
|
5453
|
+
const luaOutPath = path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
|
|
5649
5454
|
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
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" }));
|
|
5455
|
+
if (existsSync(templatePath)) {
|
|
5456
|
+
const lua = readFileSync(templatePath, "utf8")
|
|
5457
|
+
.replaceAll("__SUPERVISOR_DIR__", CCANDME_DIR.replace(/\//g, "\\\\"))
|
|
5458
|
+
.replaceAll("__WORK_DIR__", WORK_DIR.replace(/\//g, "\\\\"))
|
|
5459
|
+
.replaceAll("__WORKSPACE__", "ccandme")
|
|
5460
|
+
.replaceAll("__CLAUDE_CMD__", "claude")
|
|
5461
|
+
.replaceAll("__CODEX_CMD__", "codex")
|
|
5462
|
+
.replaceAll("__PACKAGE_ROOT__", CCANDME_DIR.replace(/\//g, "\\\\"));
|
|
5463
|
+
writeFileSync(luaOutPath, lua, "utf8");
|
|
5682
5464
|
}
|
|
5683
|
-
});
|
|
5684
|
-
return;
|
|
5685
|
-
}
|
|
5686
5465
|
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
return res
|
|
5696
|
-
}
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
job.completed_at = new Date().toISOString();
|
|
5466
|
+
// Launch WezTerm with the CCandMe config — no kill of existing sessions
|
|
5467
|
+
const luaArg = existsSync(luaOutPath) ? luaOutPath : path.join(CCANDME_DIR, ".ccandme-wezterm.lua");
|
|
5468
|
+
const child = spawn(wezExe, ["--config-file", luaArg, "start"], {
|
|
5469
|
+
detached: true,
|
|
5470
|
+
stdio: "ignore",
|
|
5471
|
+
});
|
|
5472
|
+
child.unref();
|
|
5473
|
+
operation("ccandme.launch", {}, null, { ok: true });
|
|
5474
|
+
return ok(res, { ok: true, message: "CCandMe launched" });
|
|
5475
|
+
} catch (err) {
|
|
5476
|
+
operation("ccandme.launch", {}, null, { ok: false, error: err.message });
|
|
5477
|
+
res.writeHead(500, { "Content-Type": "application/json", ...CORS });
|
|
5478
|
+
return res.end(JSON.stringify({ error: err.message }));
|
|
5701
5479
|
}
|
|
5702
|
-
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
5703
|
-
return res.end(JSON.stringify({ ok: true, status: job.status, jobId }));
|
|
5704
5480
|
}
|
|
5705
5481
|
|
|
5706
|
-
|
|
5707
|
-
if (method === "
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5482
|
+
// POST /restart — spawn fresh process then exit (keeps boot.key, vault stays unlocked)
|
|
5483
|
+
if (method === "POST" && reqPath === "/restart") {
|
|
5484
|
+
operation("daemon.restart_requested", { port }, null, { ok: true });
|
|
5485
|
+
ok(res, { ok: true, message: "restarting" });
|
|
5486
|
+
const { spawn } = await import("child_process");
|
|
5487
|
+
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
5488
|
+
const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
|
|
5489
|
+
if (password) childArgs.push("--pw", password);
|
|
5490
|
+
if (whitelist) childArgs.push("--services", whitelist.join(","));
|
|
5491
|
+
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
5492
|
+
const out = fs.openSync(LOG_FILE, "a");
|
|
5493
|
+
const child = spawn(process.execPath, childArgs, {
|
|
5494
|
+
detached: true,
|
|
5495
|
+
stdio: ["ignore", out, out],
|
|
5496
|
+
env: { ...process.env, __CLAUTH_DAEMON: "1" },
|
|
5497
|
+
windowsHide: true,
|
|
5498
|
+
});
|
|
5499
|
+
child.unref();
|
|
5500
|
+
stopTunnel();
|
|
5501
|
+
removePid();
|
|
5502
|
+
setTimeout(() => process.exit(0), 300);
|
|
5503
|
+
return;
|
|
5726
5504
|
}
|
|
5727
5505
|
|
|
5728
5506
|
// ── call_agent (Gate B) ───────────────────────────────────────────────────
|
|
@@ -5863,6 +5641,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5863
5641
|
try {
|
|
5864
5642
|
const { name } = JSON.parse(body || "{}");
|
|
5865
5643
|
const result = await startChitchatSession(name || "collab");
|
|
5644
|
+
operation("chitchat.start", { name: name || "collab" }, null, { ok: !result?.error, session_id: result?.session_id, error: result?.error });
|
|
5866
5645
|
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
5867
5646
|
res.end(JSON.stringify(result));
|
|
5868
5647
|
} catch (e) {
|
|
@@ -5873,14 +5652,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5873
5652
|
return;
|
|
5874
5653
|
}
|
|
5875
5654
|
|
|
5876
|
-
// POST /handoff
|
|
5877
|
-
if (method === "POST" &&
|
|
5655
|
+
// POST /handoff — visible local handoff from claude.ai or a sidebar.
|
|
5656
|
+
if (method === "POST" && reqPath === "/handoff") {
|
|
5878
5657
|
let body;
|
|
5879
5658
|
try { body = await readBody(req); } catch {
|
|
5880
5659
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5881
5660
|
return res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
5882
5661
|
}
|
|
5883
5662
|
const result = await startHandoffSession(body || {});
|
|
5663
|
+
operation("handoff.start", {}, null, { ok: !result.error, error: result.error });
|
|
5884
5664
|
const status = result.error ? 503 : 200;
|
|
5885
5665
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5886
5666
|
return res.end(JSON.stringify(result));
|
|
@@ -5898,6 +5678,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5898
5678
|
return res.end(JSON.stringify({ error: "session_id and message required" }));
|
|
5899
5679
|
}
|
|
5900
5680
|
const result = sendChitchatMessage(session_id, message);
|
|
5681
|
+
operation("chitchat.send", { session_id }, null, { ok: !result.error, error: result.error });
|
|
5901
5682
|
const status = result.error === 'not_found' ? 404 : result.error ? 400 : 200;
|
|
5902
5683
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5903
5684
|
res.end(JSON.stringify(result));
|
|
@@ -5922,6 +5703,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5922
5703
|
}
|
|
5923
5704
|
const tier = knowledge_tier || 'db_only';
|
|
5924
5705
|
const result = startTerminalSession(name, tier, context_md || null);
|
|
5706
|
+
operation("terminal.start", { name, tier }, null, { ok: !result.error, error: result.error });
|
|
5925
5707
|
const status = result.error ? 503 : 200;
|
|
5926
5708
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5927
5709
|
res.end(JSON.stringify(result));
|
|
@@ -5945,6 +5727,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5945
5727
|
return res.end(JSON.stringify({ error: "session_id and message required" }));
|
|
5946
5728
|
}
|
|
5947
5729
|
const result = sendTerminalMessage(session_id, message);
|
|
5730
|
+
operation("terminal.send", { session_id }, null, { ok: !result.error, error: result.error });
|
|
5948
5731
|
const status = result.error === 'session_busy' ? 409 : result.error ? 404 : 200;
|
|
5949
5732
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5950
5733
|
res.end(JSON.stringify(result));
|
|
@@ -5998,6 +5781,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5998
5781
|
return res.end(JSON.stringify({ error: "session_id required" }));
|
|
5999
5782
|
}
|
|
6000
5783
|
const result = stopTerminalSession(session_id);
|
|
5784
|
+
operation("terminal.stop", { session_id }, null, { ok: !result.error, error: result.error });
|
|
6001
5785
|
const status = result.error ? 404 : 200;
|
|
6002
5786
|
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
6003
5787
|
res.end(JSON.stringify(result));
|
|
@@ -6057,6 +5841,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6057
5841
|
channelEvents.push(entry);
|
|
6058
5842
|
if (channelEvents.length > MAX_CHANNEL_EVENTS) channelEvents.shift();
|
|
6059
5843
|
console.log(`[channel] queued event ${eventId} type=${event}`);
|
|
5844
|
+
operation("channel.event", { event, resource, repository }, null, { ok: true, event_id: eventId });
|
|
6060
5845
|
return ok(res, { received: true, event_id: eventId });
|
|
6061
5846
|
} catch {
|
|
6062
5847
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
@@ -6069,6 +5854,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6069
5854
|
// GET|POST /shutdown (for daemon stop — programmatic, keeps boot.key)
|
|
6070
5855
|
// Accept POST as well — older scripts and curl default to POST
|
|
6071
5856
|
if ((method === "GET" || method === "POST") && reqPath === "/shutdown") {
|
|
5857
|
+
operation("daemon.shutdown", { port }, null, { ok: true });
|
|
6072
5858
|
stopTunnel();
|
|
6073
5859
|
ok(res, { ok: true, message: "shutting down" });
|
|
6074
5860
|
removePid();
|
|
@@ -6078,6 +5864,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6078
5864
|
|
|
6079
5865
|
// POST /shutdown-ui (user-initiated stop — clears boot.key so password is required on restart)
|
|
6080
5866
|
if (method === "POST" && reqPath === "/shutdown-ui") {
|
|
5867
|
+
operation("daemon.shutdown_ui", { port }, null, { ok: true });
|
|
6081
5868
|
stopTunnel();
|
|
6082
5869
|
// Clear boot.key so watchdog can't auto-unlock on restart
|
|
6083
5870
|
const bootKeyPath = getBootKeyPath();
|
|
@@ -6159,10 +5946,12 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6159
5946
|
if (promoted) {
|
|
6160
5947
|
const okLog = `[${new Date().toISOString()}] Make-live: promoted to live on port ${LIVE_PORT}\n`;
|
|
6161
5948
|
try { fs.appendFileSync(LOG_FILE, okLog); } catch {}
|
|
5949
|
+
operation("daemon.make_live", { from_port: port, live_port: LIVE_PORT }, null, { ok: true });
|
|
6162
5950
|
ok(res, { ok: true, message: "promoted to live", live_port: LIVE_PORT });
|
|
6163
5951
|
} else {
|
|
6164
5952
|
const failLog = `[${new Date().toISOString()}] Make-live: new daemon failed to start on port ${LIVE_PORT}\n`;
|
|
6165
5953
|
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
5954
|
+
operation("daemon.make_live", { from_port: port, live_port: LIVE_PORT }, null, { ok: false, error: "new daemon failed to start" });
|
|
6166
5955
|
ok(res, { ok: false, error: "New daemon failed to start on live port — check log" });
|
|
6167
5956
|
}
|
|
6168
5957
|
|
|
@@ -6416,6 +6205,36 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6416
6205
|
}
|
|
6417
6206
|
}
|
|
6418
6207
|
|
|
6208
|
+
// POST /write-token — hand the dashboard a current write token for an
|
|
6209
|
+
// ALREADY-UNLOCKED vault, with no second password prompt.
|
|
6210
|
+
//
|
|
6211
|
+
// A human looking at the dashboard has already passed the lock screen (or
|
|
6212
|
+
// the daemon was auto-unlocked via --pw/boot.key). Making them re-enter the
|
|
6213
|
+
// password to press a button is ceremony, not security: GET / at the route
|
|
6214
|
+
// above already embeds a write token on exactly this condition. This route
|
|
6215
|
+
// just lets the page RENEW it, which GET / could only do on a full reload.
|
|
6216
|
+
//
|
|
6217
|
+
// Why the write token still exists at all: every request reaching this
|
|
6218
|
+
// server is already loopback-only (hard 403 above), but cloudflared proxies
|
|
6219
|
+
// the public tunnel FROM localhost, so a remote request and a local one are
|
|
6220
|
+
// indistinguishable by address. The token is what a page-driven write has
|
|
6221
|
+
// and a blind remote POST does not, so the gate stays; only the prompt goes.
|
|
6222
|
+
//
|
|
6223
|
+
// Mint-or-reuse, mirroring GET /: reusing a still-valid session keeps
|
|
6224
|
+
// multiple open tabs working instead of each one invalidating the last.
|
|
6225
|
+
if (method === "POST" && reqPath === "/write-token") {
|
|
6226
|
+
if (!password) {
|
|
6227
|
+
res.writeHead(403, { "Content-Type": "application/json", ...CORS });
|
|
6228
|
+
return res.end(JSON.stringify({ error: "vault_locked", locked: true }));
|
|
6229
|
+
}
|
|
6230
|
+
if (!writeSession || Date.now() > writeSession.expiresAt) writeSession = makeWriteToken();
|
|
6231
|
+
return ok(res, {
|
|
6232
|
+
ok: true,
|
|
6233
|
+
write_token: writeSession.token,
|
|
6234
|
+
write_expires_at: new Date(writeSession.expiresAt).toISOString(),
|
|
6235
|
+
});
|
|
6236
|
+
}
|
|
6237
|
+
|
|
6419
6238
|
// POST /auth — unlock the vault with a password (verifies against Edge Function)
|
|
6420
6239
|
if (method === "POST" && reqPath === "/auth") {
|
|
6421
6240
|
let body;
|
|
@@ -6501,6 +6320,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6501
6320
|
tunnelStatus = "starting";
|
|
6502
6321
|
startTunnel().catch(() => {});
|
|
6503
6322
|
}
|
|
6323
|
+
operation("vault.unlock", {}, null, { ok: true });
|
|
6504
6324
|
return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
6505
6325
|
} catch (authErr) {
|
|
6506
6326
|
const msg = authErr.message || "";
|
|
@@ -6526,6 +6346,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6526
6346
|
// No strike, no hard-lock — the verdict was never rendered.
|
|
6527
6347
|
const failLog = `[${new Date().toISOString()}] [BACKEND ${backendKind}] vault backend unreachable, no auth strike — ${detail}\n`;
|
|
6528
6348
|
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
6349
|
+
operation("vault.unlock", {}, null, { ok: false, backend_error: true, kind: backendKind });
|
|
6529
6350
|
res.writeHead(503, { "Content-Type": "application/json", ...CORS });
|
|
6530
6351
|
return res.end(JSON.stringify({
|
|
6531
6352
|
error: friendly,
|
|
@@ -6550,6 +6371,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6550
6371
|
authHardLocked = true;
|
|
6551
6372
|
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
6373
|
try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
|
|
6374
|
+
operation("vault.unlock", {}, null, { ok: false, terminal: true, reason: serverReason });
|
|
6553
6375
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
6554
6376
|
return res.end(JSON.stringify({ error: "Vault rejected credentials — recovery required", reason: serverReason, hard_locked: true, terminal: true }));
|
|
6555
6377
|
}
|
|
@@ -6557,9 +6379,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6557
6379
|
authHardLocked = true;
|
|
6558
6380
|
const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
|
|
6559
6381
|
try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
|
|
6382
|
+
operation("vault.unlock", {}, null, { ok: false, hard_locked: true, reason: serverReason });
|
|
6560
6383
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
6561
6384
|
return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", reason: serverReason, hard_locked: true }));
|
|
6562
6385
|
}
|
|
6386
|
+
operation("vault.unlock", {}, null, { ok: false, reason: serverReason, failures_remaining: authRemaining });
|
|
6563
6387
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
6564
6388
|
return res.end(JSON.stringify({ error: "Invalid password", reason: serverReason, failures_remaining: authRemaining }));
|
|
6565
6389
|
}
|
|
@@ -6573,6 +6397,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6573
6397
|
stopTunnel();
|
|
6574
6398
|
const logLine = `[${new Date().toISOString()}] Vault locked\n`;
|
|
6575
6399
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
6400
|
+
operation("vault.lock", {}, null, { ok: true });
|
|
6576
6401
|
return ok(res, { ok: true, locked: true, hard_locked: authHardLocked });
|
|
6577
6402
|
}
|
|
6578
6403
|
|
|
@@ -6594,10 +6419,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6594
6419
|
try {
|
|
6595
6420
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
6596
6421
|
const result = await api.updateService(password, machineHash, token, timestamp, service, { name: newName, label: newName });
|
|
6597
|
-
if (result.error)
|
|
6422
|
+
if (result.error) {
|
|
6423
|
+
operation("service.rename", { service, new_name: newName }, null, { ok: false, error: result.error });
|
|
6424
|
+
return strike(res, 502, result.error);
|
|
6425
|
+
}
|
|
6598
6426
|
invalidateServiceStatusCache(machineHash);
|
|
6427
|
+
operation("service.rename", { service, new_name: newName }, null, { ok: true });
|
|
6599
6428
|
return ok(res, { ok: true, old_name: service, new_name: newName });
|
|
6600
6429
|
} catch (err) {
|
|
6430
|
+
operation("service.rename", { service, new_name: newName }, null, { ok: false, error: err.message });
|
|
6601
6431
|
return strike(res, 502, err.message);
|
|
6602
6432
|
}
|
|
6603
6433
|
}
|
|
@@ -6610,10 +6440,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6610
6440
|
try {
|
|
6611
6441
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
6612
6442
|
const result = await api.removeService(password, machineHash, token, timestamp, service, `CONFIRM REMOVE ${service.toUpperCase()}`);
|
|
6613
|
-
if (result.error)
|
|
6443
|
+
if (result.error) {
|
|
6444
|
+
operation("service.delete", { service }, null, { ok: false, error: result.error });
|
|
6445
|
+
return strike(res, 502, result.error);
|
|
6446
|
+
}
|
|
6614
6447
|
invalidateServiceStatusCache(machineHash);
|
|
6448
|
+
operation("service.delete", { service }, null, { ok: true });
|
|
6615
6449
|
return ok(res, { ok: true, deleted: service });
|
|
6616
6450
|
} catch (err) {
|
|
6451
|
+
operation("service.delete", { service }, null, { ok: false, error: err.message });
|
|
6617
6452
|
return strike(res, 502, err.message);
|
|
6618
6453
|
}
|
|
6619
6454
|
}
|
|
@@ -6639,10 +6474,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6639
6474
|
try {
|
|
6640
6475
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
6641
6476
|
const result = await api.enable(password, machineHash, token, timestamp, service, enabled);
|
|
6642
|
-
if (result.error)
|
|
6477
|
+
if (result.error) {
|
|
6478
|
+
operation("service.toggle", { service, enabled }, null, { ok: false, error: result.error });
|
|
6479
|
+
return strike(res, 502, result.error);
|
|
6480
|
+
}
|
|
6643
6481
|
invalidateServiceStatusCache(machineHash);
|
|
6482
|
+
operation("service.toggle", { service, enabled }, null, { ok: true });
|
|
6644
6483
|
return ok(res, { ok: true, service, enabled });
|
|
6645
6484
|
} catch (err) {
|
|
6485
|
+
operation("service.toggle", { service, enabled }, null, { ok: false, error: err.message });
|
|
6646
6486
|
return strike(res, 502, err.message);
|
|
6647
6487
|
}
|
|
6648
6488
|
}
|
|
@@ -6729,6 +6569,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6729
6569
|
return res.end(JSON.stringify({ error: "Service name required" }));
|
|
6730
6570
|
}
|
|
6731
6571
|
const result = await rotationEngine.rotateService(service);
|
|
6572
|
+
operation("service.rotate", { service }, null, { ok: !result?.error, error: result?.error });
|
|
6732
6573
|
return ok(res, result);
|
|
6733
6574
|
}
|
|
6734
6575
|
|
|
@@ -6742,6 +6583,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6742
6583
|
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
6743
6584
|
}
|
|
6744
6585
|
rotationEngine.setExpiry(service, body.expires_at, body.rotation_days);
|
|
6586
|
+
operation("service.set_expiry", { service }, null, { ok: true, expires_at: body.expires_at, rotation_days: body.rotation_days });
|
|
6745
6587
|
return ok(res, { ok: true, service, expires_at: body.expires_at });
|
|
6746
6588
|
}
|
|
6747
6589
|
|
|
@@ -6925,8 +6767,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6925
6767
|
});
|
|
6926
6768
|
}
|
|
6927
6769
|
|
|
6770
|
+
operation("tunnel.setup.cf_token", {}, null, { ok: true, accountId, accountName });
|
|
6928
6771
|
return ok(res, { ok: true, accountId, accountName });
|
|
6929
6772
|
} catch (err) {
|
|
6773
|
+
operation("tunnel.setup.cf_token", {}, null, { ok: false, error: err.message });
|
|
6930
6774
|
return strike(res, 502, err.message);
|
|
6931
6775
|
}
|
|
6932
6776
|
}
|
|
@@ -6997,8 +6841,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6997
6841
|
tunnelHostname = hostname;
|
|
6998
6842
|
tunnelUrl = `https://${hostname}`;
|
|
6999
6843
|
|
|
6844
|
+
operation("tunnel.setup.cf_save", { hostname, tunnelId }, null, { ok: true });
|
|
7000
6845
|
return ok(res, { ok: true, hostname });
|
|
7001
6846
|
} catch (err) {
|
|
6847
|
+
operation("tunnel.setup.cf_save", { hostname }, null, { ok: false, error: err.message });
|
|
7002
6848
|
return strike(res, 502, err.message);
|
|
7003
6849
|
}
|
|
7004
6850
|
}
|
|
@@ -7078,8 +6924,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7078
6924
|
tunnelHostname = hostname;
|
|
7079
6925
|
tunnelUrl = `https://${hostname}`;
|
|
7080
6926
|
|
|
6927
|
+
operation("tunnel.setup.cf_create_api", { hostname, tunnelId }, null, { ok: true });
|
|
7081
6928
|
return ok(res, { ok: true, tunnelId, hostname });
|
|
7082
6929
|
} catch (err) {
|
|
6930
|
+
operation("tunnel.setup.cf_create_api", { hostname }, null, { ok: false, error: err.message });
|
|
7083
6931
|
return strike(res, 502, err.message);
|
|
7084
6932
|
}
|
|
7085
6933
|
}
|
|
@@ -7120,9 +6968,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7120
6968
|
const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"], windowsHide: true });
|
|
7121
6969
|
proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
7122
6970
|
proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
7123
|
-
proc.on("close", code => {
|
|
6971
|
+
proc.on("close", code => {
|
|
6972
|
+
operation("tunnel.setup.cf_login", {}, null, { ok: code === 0, exit_code: code });
|
|
6973
|
+
sendEvt({ done: true, code }); res.end();
|
|
6974
|
+
});
|
|
7124
6975
|
req.on("close", () => { try { proc.kill(); } catch {} });
|
|
7125
6976
|
} catch (err) {
|
|
6977
|
+
operation("tunnel.setup.cf_login", {}, null, { ok: false, error: err.message });
|
|
7126
6978
|
sendEvt({ done: true, code: 1, error: err.message });
|
|
7127
6979
|
res.end();
|
|
7128
6980
|
}
|
|
@@ -7194,9 +7046,11 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7194
7046
|
tunnelHostname = hostname;
|
|
7195
7047
|
tunnelUrl = `https://${hostname}`;
|
|
7196
7048
|
|
|
7049
|
+
operation("tunnel.setup.cf_create", { name, hostname, tunnelId }, null, { ok: true });
|
|
7197
7050
|
sendEvt({ done: true, tunnelId, hostname });
|
|
7198
7051
|
res.end();
|
|
7199
7052
|
} catch (err) {
|
|
7053
|
+
operation("tunnel.setup.cf_create", { name, hostname }, null, { ok: false, error: err.message });
|
|
7200
7054
|
sendEvt({ error: err.message, done: true });
|
|
7201
7055
|
res.end();
|
|
7202
7056
|
}
|
|
@@ -7228,8 +7082,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7228
7082
|
writeSession = makeWriteToken();
|
|
7229
7083
|
const logLine = `[${new Date().toISOString()}] Password changed\n`;
|
|
7230
7084
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
7085
|
+
operation("vault.change_password", {}, null, { ok: true });
|
|
7231
7086
|
return ok(res, { ok: true, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
7232
7087
|
} catch (err) {
|
|
7088
|
+
operation("vault.change_password", {}, null, { ok: false, error: err.message });
|
|
7233
7089
|
res.writeHead(502, { "Content-Type": "application/json", ...CORS });
|
|
7234
7090
|
return res.end(JSON.stringify({ error: err.message }));
|
|
7235
7091
|
}
|
|
@@ -7257,11 +7113,17 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7257
7113
|
}
|
|
7258
7114
|
|
|
7259
7115
|
try {
|
|
7116
|
+
// NEVER log `value` — it is the secret being written.
|
|
7260
7117
|
const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password, machineHash, service, value, logFile: LOG_FILE });
|
|
7261
|
-
if (result.error)
|
|
7118
|
+
if (result.error) {
|
|
7119
|
+
operation("service.set", { service }, null, { ok: false, error: result.error });
|
|
7120
|
+
return strike(res, 502, result.error);
|
|
7121
|
+
}
|
|
7262
7122
|
invalidateServiceStatusCache(machineHash);
|
|
7123
|
+
operation("service.set", { service }, null, { ok: true, normalized });
|
|
7263
7124
|
return ok(res, { ok: true, service, recovery_snapshot: snapshot?.ok ? true : false, normalized });
|
|
7264
7125
|
} catch (err) {
|
|
7126
|
+
operation("service.set", { service }, null, { ok: false, error: err.message });
|
|
7265
7127
|
return strike(res, 502, err.message);
|
|
7266
7128
|
}
|
|
7267
7129
|
}
|
|
@@ -7290,11 +7152,17 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7290
7152
|
try {
|
|
7291
7153
|
const randomHex = crypto.randomBytes(32).toString("hex");
|
|
7292
7154
|
const token = `${prefix}${randomHex}`;
|
|
7155
|
+
// NEVER log `token` — it is the secret being generated/stored.
|
|
7293
7156
|
const { result, snapshot } = await writeCredentialWithRecovery({ password, machineHash, service, value: token, logFile: LOG_FILE, normalize: false });
|
|
7294
|
-
if (result.error)
|
|
7157
|
+
if (result.error) {
|
|
7158
|
+
operation("service.generate_token", { service }, null, { ok: false, error: result.error });
|
|
7159
|
+
return strike(res, 502, result.error);
|
|
7160
|
+
}
|
|
7295
7161
|
invalidateServiceStatusCache(machineHash);
|
|
7162
|
+
operation("service.generate_token", { service }, null, { ok: true });
|
|
7296
7163
|
return ok(res, { token, service, stored: true, recovery_snapshot: snapshot?.ok ? true : false });
|
|
7297
7164
|
} catch (err) {
|
|
7165
|
+
operation("service.generate_token", { service }, null, { ok: false, error: err.message });
|
|
7298
7166
|
return strike(res, 502, err.message);
|
|
7299
7167
|
}
|
|
7300
7168
|
}
|
|
@@ -7324,10 +7192,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7324
7192
|
try {
|
|
7325
7193
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
7326
7194
|
const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "", project || undefined);
|
|
7327
|
-
if (result.error)
|
|
7195
|
+
if (result.error) {
|
|
7196
|
+
operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: false, error: result.error });
|
|
7197
|
+
return strike(res, 502, result.error);
|
|
7198
|
+
}
|
|
7328
7199
|
invalidateServiceStatusCache(machineHash);
|
|
7200
|
+
operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: true, key_type: type });
|
|
7329
7201
|
return ok(res, { ok: true, service: name.trim().toLowerCase() });
|
|
7330
7202
|
} catch (err) {
|
|
7203
|
+
operation("service.add", { name: name.trim().toLowerCase() }, null, { ok: false, error: err.message });
|
|
7331
7204
|
return strike(res, 502, err.message);
|
|
7332
7205
|
}
|
|
7333
7206
|
}
|
|
@@ -7361,10 +7234,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7361
7234
|
try {
|
|
7362
7235
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
7363
7236
|
const result = await api.updateService(password, machineHash, token, timestamp, service.toLowerCase(), updates);
|
|
7364
|
-
if (result.error)
|
|
7237
|
+
if (result.error) {
|
|
7238
|
+
operation("service.update", { name: service.toLowerCase() }, null, { ok: false, error: result.error });
|
|
7239
|
+
return strike(res, 502, result.error);
|
|
7240
|
+
}
|
|
7365
7241
|
invalidateServiceStatusCache(machineHash);
|
|
7242
|
+
operation("service.update", { name: service.toLowerCase() }, null, { ok: true, fields: Object.keys(updates) });
|
|
7366
7243
|
return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
|
|
7367
7244
|
} catch (err) {
|
|
7245
|
+
operation("service.update", { name: service.toLowerCase() }, null, { ok: false, error: err.message });
|
|
7368
7246
|
return strike(res, 502, err.message);
|
|
7369
7247
|
}
|
|
7370
7248
|
}
|
|
@@ -7415,11 +7293,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7415
7293
|
try {
|
|
7416
7294
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
7417
7295
|
const result = await api.createEnrollment(password, machineHash, token, timestamp, label, ttlMinutes);
|
|
7418
|
-
if (result.error)
|
|
7296
|
+
if (result.error) {
|
|
7297
|
+
operation("machine.enroll", { label, target }, null, { ok: false, error: result.error });
|
|
7298
|
+
return strike(res, 502, result.error);
|
|
7299
|
+
}
|
|
7419
7300
|
const localConfig = new Conf(getConfOptions());
|
|
7420
7301
|
const supabaseUrl = localConfig.get("supabase_url") || process.env.CLAUTH_SUPABASE_URL || "";
|
|
7421
7302
|
const anonKey = localConfig.get("supabase_anon_key") || process.env.CLAUTH_SUPABASE_ANON_KEY || "";
|
|
7303
|
+
// NEVER log `enrollment_code` — it is a one-time credential.
|
|
7422
7304
|
const scriptPath = writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode: result.enrollment_code, label, target });
|
|
7305
|
+
operation("machine.enroll", { label, target }, null, { ok: true, expires_at: result.expires_at });
|
|
7423
7306
|
return ok(res, {
|
|
7424
7307
|
ok: true,
|
|
7425
7308
|
enrollment_code: result.enrollment_code,
|
|
@@ -7427,6 +7310,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7427
7310
|
script_path: scriptPath,
|
|
7428
7311
|
});
|
|
7429
7312
|
} catch (err) {
|
|
7313
|
+
operation("machine.enroll", { label, target }, null, { ok: false, error: err.message });
|
|
7430
7314
|
return strike(res, 502, err.message);
|
|
7431
7315
|
}
|
|
7432
7316
|
}
|
|
@@ -7465,6 +7349,27 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
7465
7349
|
});
|
|
7466
7350
|
}
|
|
7467
7351
|
|
|
7352
|
+
// The localhost supervisor is the only process allowed to repair clauth-owned
|
|
7353
|
+
// local surfaces. Keep this loop out of the vault/staged instances and make
|
|
7354
|
+
// the cadence configurable for deterministic tests.
|
|
7355
|
+
if (port === getSupervisorPort() && process.env.CLAUTH_SUPERVISOR_HEALTH_RECONCILE !== "0") {
|
|
7356
|
+
const configuredInterval = Number(process.env.CLAUTH_SUPERVISOR_HEALTH_INTERVAL_MS || 10000);
|
|
7357
|
+
const intervalMs = Number.isFinite(configuredInterval) ? Math.max(1000, Math.min(configuredInterval, 300000)) : 10000;
|
|
7358
|
+
let healthReconcileInFlight = false;
|
|
7359
|
+
const runHealthReconcile = () => {
|
|
7360
|
+
if (healthReconcileInFlight) return;
|
|
7361
|
+
healthReconcileInFlight = true;
|
|
7362
|
+
reconcileSurfaceHealth().catch((err) => {
|
|
7363
|
+
try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] supervisor health reconcile failed: ${err.message}\n`); } catch {}
|
|
7364
|
+
}).finally(() => { healthReconcileInFlight = false; });
|
|
7365
|
+
};
|
|
7366
|
+
const healthTimer = setInterval(runHealthReconcile, intervalMs);
|
|
7367
|
+
healthTimer.unref?.();
|
|
7368
|
+
server.__supervisorHealthTimer = healthTimer;
|
|
7369
|
+
server.on("close", () => clearInterval(healthTimer));
|
|
7370
|
+
setImmediate(runHealthReconcile);
|
|
7371
|
+
}
|
|
7372
|
+
|
|
7468
7373
|
return server;
|
|
7469
7374
|
}
|
|
7470
7375
|
|
|
@@ -7480,6 +7385,61 @@ async function verifyAuth(password) {
|
|
|
7480
7385
|
}
|
|
7481
7386
|
}
|
|
7482
7387
|
|
|
7388
|
+
async function supervisorResponds(port = getSupervisorPort()) {
|
|
7389
|
+
try {
|
|
7390
|
+
const resp = await fetch(`http://127.0.0.1:${port}/health`);
|
|
7391
|
+
return resp.ok;
|
|
7392
|
+
} catch {
|
|
7393
|
+
return false;
|
|
7394
|
+
}
|
|
7395
|
+
}
|
|
7396
|
+
|
|
7397
|
+
async function ensureSupervisorStarted(cliEntry) {
|
|
7398
|
+
const port = getSupervisorPort();
|
|
7399
|
+
const existing = readSupervisorPid();
|
|
7400
|
+
if (existing && isProcessAlive(existing.pid) && await supervisorResponds(existing.port)) {
|
|
7401
|
+
return { started: false, pid: existing.pid, port: existing.port, state: "already_running" };
|
|
7402
|
+
}
|
|
7403
|
+
if (existing && !isProcessAlive(existing.pid)) removeSupervisorPid();
|
|
7404
|
+
if (await supervisorResponds(port)) {
|
|
7405
|
+
return { started: false, pid: existing?.pid || null, port, state: "port_already_live" };
|
|
7406
|
+
}
|
|
7407
|
+
|
|
7408
|
+
const out = fs.openSync(LOG_FILE, "a");
|
|
7409
|
+
const child = spawn(process.execPath, [cliEntry, "serve", "supervisor", "--port", String(port)], {
|
|
7410
|
+
detached: true,
|
|
7411
|
+
stdio: ["ignore", out, out],
|
|
7412
|
+
env: { ...process.env, __CLAUTH_SUPERVISOR_DAEMON: "1" },
|
|
7413
|
+
});
|
|
7414
|
+
child.unref();
|
|
7415
|
+
writeSupervisorPid(child.pid, port);
|
|
7416
|
+
|
|
7417
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
7418
|
+
await new Promise(r => setTimeout(r, 500));
|
|
7419
|
+
if (await supervisorResponds(port)) {
|
|
7420
|
+
return { started: true, pid: child.pid, port, state: "started" };
|
|
7421
|
+
}
|
|
7422
|
+
}
|
|
7423
|
+
return { started: true, pid: child.pid, port, state: "start_unverified" };
|
|
7424
|
+
}
|
|
7425
|
+
|
|
7426
|
+
async function stopSupervisorSibling() {
|
|
7427
|
+
const info = readSupervisorPid();
|
|
7428
|
+
if (!info) return null;
|
|
7429
|
+
if (!isProcessAlive(info.pid)) {
|
|
7430
|
+
removeSupervisorPid();
|
|
7431
|
+
return { stopped: false, pid: info.pid, port: info.port, state: "stale" };
|
|
7432
|
+
}
|
|
7433
|
+
try {
|
|
7434
|
+
process.kill(info.pid, "SIGTERM");
|
|
7435
|
+
await new Promise(r => setTimeout(r, 300));
|
|
7436
|
+
removeSupervisorPid();
|
|
7437
|
+
return { stopped: true, pid: info.pid, port: info.port, state: "stopped" };
|
|
7438
|
+
} catch (err) {
|
|
7439
|
+
return { stopped: false, pid: info.pid, port: info.port, state: "stop_failed", error: err.message };
|
|
7440
|
+
}
|
|
7441
|
+
}
|
|
7442
|
+
|
|
7483
7443
|
async function actionStart(opts) {
|
|
7484
7444
|
if (opts.isolated) {
|
|
7485
7445
|
return actionForeground(opts);
|
|
@@ -7681,6 +7641,11 @@ async function actionStart(opts) {
|
|
|
7681
7641
|
console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
|
|
7682
7642
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
7683
7643
|
console.log(chalk.gray(` Log: ${LOG_FILE}`));
|
|
7644
|
+
if (!isStaged) {
|
|
7645
|
+
const supervisor = await ensureSupervisorStarted(cliEntry);
|
|
7646
|
+
const verb = supervisor.started ? "started" : "available";
|
|
7647
|
+
console.log(chalk.gray(` Supervisor: ${verb} on 127.0.0.1:${supervisor.port}${supervisor.pid ? ` (PID ${supervisor.pid})` : ""}`));
|
|
7648
|
+
}
|
|
7684
7649
|
if (isStaged) {
|
|
7685
7650
|
console.log(chalk.yellow(`\n ⚡ Staged on port ${port} — open dashboard to verify, then click "Make Live"`));
|
|
7686
7651
|
} else if (!password) {
|
|
@@ -7698,6 +7663,7 @@ async function actionStart(opts) {
|
|
|
7698
7663
|
async function actionStop() {
|
|
7699
7664
|
const info = readPid();
|
|
7700
7665
|
if (!info) {
|
|
7666
|
+
await stopSupervisorSibling();
|
|
7701
7667
|
console.log(chalk.yellow("\n No clauth serve PID file found — not running.\n"));
|
|
7702
7668
|
return;
|
|
7703
7669
|
}
|
|
@@ -7705,6 +7671,7 @@ async function actionStop() {
|
|
|
7705
7671
|
if (!isProcessAlive(info.pid)) {
|
|
7706
7672
|
console.log(chalk.yellow(`\n PID ${info.pid} is not running (stale PID file). Cleaning up.\n`));
|
|
7707
7673
|
removePid();
|
|
7674
|
+
await stopSupervisorSibling();
|
|
7708
7675
|
return;
|
|
7709
7676
|
}
|
|
7710
7677
|
|
|
@@ -7715,6 +7682,7 @@ async function actionStop() {
|
|
|
7715
7682
|
await new Promise(r => setTimeout(r, 300));
|
|
7716
7683
|
console.log(chalk.green(`\n 🛑 clauth serve stopped (was PID ${info.pid}, port ${info.port})\n`));
|
|
7717
7684
|
removePid();
|
|
7685
|
+
await stopSupervisorSibling();
|
|
7718
7686
|
return;
|
|
7719
7687
|
}
|
|
7720
7688
|
} catch {}
|
|
@@ -7728,6 +7696,7 @@ async function actionStop() {
|
|
|
7728
7696
|
console.log(chalk.yellow(`\n Could not kill PID ${info.pid}: ${err.message}\n`));
|
|
7729
7697
|
}
|
|
7730
7698
|
removePid();
|
|
7699
|
+
await stopSupervisorSibling();
|
|
7731
7700
|
}
|
|
7732
7701
|
|
|
7733
7702
|
async function actionPing() {
|
|
@@ -7772,7 +7741,9 @@ async function actionRestart(opts) {
|
|
|
7772
7741
|
async function actionForeground(opts) {
|
|
7773
7742
|
const port = parseInt(opts.port || "52437", 10);
|
|
7774
7743
|
const isolated = !!opts.isolated;
|
|
7775
|
-
const
|
|
7744
|
+
const containerPassword = process.env.CLAUTH_MASTER_PASSWORD || process.env["clauth-master-password"] || null;
|
|
7745
|
+
const password = isolated ? null : (opts.pw || containerPassword);
|
|
7746
|
+
const bindHost = process.env.CLAUTH_BIND_HOST || "127.0.0.1";
|
|
7776
7747
|
const tunnelHostname = opts.tunnel || null;
|
|
7777
7748
|
const whitelist = opts.services
|
|
7778
7749
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
@@ -7798,14 +7769,40 @@ async function actionForeground(opts) {
|
|
|
7798
7769
|
console.log(chalk.yellow("\n Starting in locked state — open browser to unlock"));
|
|
7799
7770
|
}
|
|
7800
7771
|
|
|
7801
|
-
console.log(chalk.gray(` Port:
|
|
7772
|
+
console.log(chalk.gray(` Port: ${bindHost}:${port}`));
|
|
7802
7773
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
7803
7774
|
console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
|
|
7804
7775
|
|
|
7776
|
+
// Plugin discovery previously only ran under `serve supervisor` or an
|
|
7777
|
+
// on-demand rescan — normal `serve start`/`foreground` boot never ran it,
|
|
7778
|
+
// so a stale/never-discovered plugin list could sit unnoticed until
|
|
7779
|
+
// someone happened to hit rescan. Discovery touching disk must never be
|
|
7780
|
+
// allowed to crash daemon boot, hence the try/catch.
|
|
7781
|
+
let discoveryResult = null;
|
|
7782
|
+
let discoveryError = null;
|
|
7783
|
+
try {
|
|
7784
|
+
discoveryResult = discoverPlugins();
|
|
7785
|
+
} catch (err) {
|
|
7786
|
+
discoveryError = err;
|
|
7787
|
+
console.log(chalk.yellow(` ⚠ plugin discovery failed at boot: ${err.message}`));
|
|
7788
|
+
}
|
|
7789
|
+
|
|
7805
7790
|
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
7806
|
-
server.listen(port,
|
|
7791
|
+
server.listen(port, bindHost, () => {
|
|
7807
7792
|
if (!isolated) writePid(process.pid, port);
|
|
7808
|
-
|
|
7793
|
+
try {
|
|
7794
|
+
operation("daemon.started", { port, isolated }, null, { ok: true, version: VERSION }, "system");
|
|
7795
|
+
operation(
|
|
7796
|
+
"daemon.plugin_discovery",
|
|
7797
|
+
{ port },
|
|
7798
|
+
null,
|
|
7799
|
+
discoveryError
|
|
7800
|
+
? { ok: false, error: discoveryError.message }
|
|
7801
|
+
: { ok: true, plugin_count: (discoveryResult?.plugins || discoveryResult || []).length },
|
|
7802
|
+
"system",
|
|
7803
|
+
);
|
|
7804
|
+
} catch { /* startup logging must never block the daemon from serving */ }
|
|
7805
|
+
console.log(chalk.green(` clauth serve → http://${bindHost}:${port}`));
|
|
7809
7806
|
if (tunnelHostname) {
|
|
7810
7807
|
console.log(chalk.cyan(` Tunnel: https://${tunnelHostname}/sse`));
|
|
7811
7808
|
console.log("");
|
|
@@ -7845,7 +7842,7 @@ async function actionForeground(opts) {
|
|
|
7845
7842
|
import { createInterface } from "readline";
|
|
7846
7843
|
import { execSync, spawn as spawnProc, spawnSync } from "child_process";
|
|
7847
7844
|
|
|
7848
|
-
// ──
|
|
7845
|
+
// ── Shared headless Claude CLI worker helpers (used by call-agent, terminal, codevelop) ───
|
|
7849
7846
|
function findClaudeBinary() {
|
|
7850
7847
|
const candidates = [
|
|
7851
7848
|
process.env.CLAUDE_BIN,
|
|
@@ -8336,7 +8333,7 @@ async function runCallAgent(args = {}) {
|
|
|
8336
8333
|
const persistMeta = { prompt, skill_slug: args.skill_slug, model, agent_context: args.agent_context };
|
|
8337
8334
|
|
|
8338
8335
|
if (mode === "async") {
|
|
8339
|
-
const jobId =
|
|
8336
|
+
const jobId = `call-${crypto.randomUUID()}`;
|
|
8340
8337
|
// Persist the queued row FIRST so the { jobId } we return is recoverable
|
|
8341
8338
|
// even if the daemon restarts before the worker settles.
|
|
8342
8339
|
await persistCallAgentDispatch({ jobId, ...persistMeta });
|
|
@@ -8360,307 +8357,6 @@ async function runCallAgent(args = {}) {
|
|
|
8360
8357
|
return { ok: true, package: rec.package, jobId: rec.jobId, model: rec.model, ms: rec.ms };
|
|
8361
8358
|
}
|
|
8362
8359
|
|
|
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
8360
|
|
|
8665
8361
|
// ── Terminal session manager ─────────────────────────────────────
|
|
8666
8362
|
// Rolling-context approach: each session stores a context string.
|
|
@@ -8678,7 +8374,7 @@ function generateSessionId() {
|
|
|
8678
8374
|
}
|
|
8679
8375
|
|
|
8680
8376
|
function defaultTerminalCwd() {
|
|
8681
|
-
const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-
|
|
8377
|
+
const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-terminal");
|
|
8682
8378
|
try {
|
|
8683
8379
|
fs.mkdirSync(dir, { recursive: true });
|
|
8684
8380
|
} catch {
|
|
@@ -8712,7 +8408,7 @@ function startTerminalSession(name, knowledge_tier, context_md, cwd, use_warm =
|
|
|
8712
8408
|
};
|
|
8713
8409
|
|
|
8714
8410
|
// Try to acquire a warm worker only when explicitly requested. The warm pool
|
|
8715
|
-
// is optimized for call_agent one-shots;
|
|
8411
|
+
// is optimized for call_agent one-shots; a terminal session needs reliable
|
|
8716
8412
|
// repeated turns more than experimental low-latency pinning.
|
|
8717
8413
|
if (use_warm) {
|
|
8718
8414
|
try {
|
|
@@ -8920,12 +8616,12 @@ function showTerminalSession(session_id) {
|
|
|
8920
8616
|
|
|
8921
8617
|
if (process.platform === "win32") {
|
|
8922
8618
|
try {
|
|
8923
|
-
const title = `
|
|
8619
|
+
const title = `clauth terminal ${session.name || session_id}`;
|
|
8924
8620
|
const lines = [
|
|
8925
8621
|
`$Host.UI.RawUI.WindowTitle = ${JSON.stringify(title)}`,
|
|
8926
8622
|
`Set-Location -LiteralPath ${JSON.stringify(session.cwd || os.homedir())}`,
|
|
8927
8623
|
"Clear-Host",
|
|
8928
|
-
`Write-Host ${JSON.stringify(`
|
|
8624
|
+
`Write-Host ${JSON.stringify(`clauth terminal session ${session_id}`)} -ForegroundColor Cyan`,
|
|
8929
8625
|
`Write-Host ${JSON.stringify(`Name: ${session.name || ""}`)}`,
|
|
8930
8626
|
`Write-Host ${JSON.stringify(`Status: ${session.status}`)}`,
|
|
8931
8627
|
`Write-Host ${JSON.stringify(`CWD: ${session.cwd || ""}`)}`,
|
|
@@ -9894,7 +9590,7 @@ function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFI
|
|
|
9894
9590
|
return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
|
|
9895
9591
|
}
|
|
9896
9592
|
|
|
9897
|
-
const MCP_TOOLS = [
|
|
9593
|
+
export const MCP_TOOLS = [
|
|
9898
9594
|
{
|
|
9899
9595
|
name: "clauth_ping",
|
|
9900
9596
|
description: "Check if the vault is locked or unlocked, show failure count",
|
|
@@ -10044,7 +9740,7 @@ const MCP_TOOLS = [
|
|
|
10044
9740
|
},
|
|
10045
9741
|
{
|
|
10046
9742
|
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.
|
|
9743
|
+
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
9744
|
inputSchema: {
|
|
10049
9745
|
type: "object",
|
|
10050
9746
|
properties: {
|
|
@@ -10085,24 +9781,6 @@ const MCP_TOOLS = [
|
|
|
10085
9781
|
additionalProperties: false,
|
|
10086
9782
|
},
|
|
10087
9783
|
},
|
|
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
9784
|
{
|
|
10107
9785
|
name: "handoff_start",
|
|
10108
9786
|
description: "Start a visible local Claude Code handoff session from a /handoff command and return immediately with session metadata.",
|
|
@@ -10154,7 +9832,7 @@ const MCP_TOOLS = [
|
|
|
10154
9832
|
},
|
|
10155
9833
|
{
|
|
10156
9834
|
name: "terminal_show",
|
|
10157
|
-
description: "Reveal/focus a
|
|
9835
|
+
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
9836
|
inputSchema: {
|
|
10159
9837
|
type: "object",
|
|
10160
9838
|
properties: {
|
|
@@ -10882,6 +10560,41 @@ const MCP_TOOLS = [
|
|
|
10882
10560
|
additionalProperties: false,
|
|
10883
10561
|
},
|
|
10884
10562
|
},
|
|
10563
|
+
{
|
|
10564
|
+
name: "clauth_ops_catalog",
|
|
10565
|
+
description: "Return the Vultr deployment-control API catalog. Availability is still decided on Vultr.",
|
|
10566
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
10567
|
+
},
|
|
10568
|
+
{
|
|
10569
|
+
name: "clauth_ops_processes",
|
|
10570
|
+
description: "Submit a scoped PM2 process-status query to the Vultr control plane. Only server-approved applications are returned.",
|
|
10571
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
10572
|
+
},
|
|
10573
|
+
{
|
|
10574
|
+
name: "clauth_ops_describe",
|
|
10575
|
+
description: "Submit a scoped PM2 status query for one server-approved application.",
|
|
10576
|
+
inputSchema: { type: "object", properties: { application: { type: "string" } }, required: ["application"], additionalProperties: false },
|
|
10577
|
+
},
|
|
10578
|
+
{
|
|
10579
|
+
name: "clauth_ops_deploy",
|
|
10580
|
+
description: "Submit one manifest-scoped development deployment to Vultr. The server chooses the repository, build, PM2 process, and allowed ref.",
|
|
10581
|
+
inputSchema: { type: "object", properties: { application: { type: "string" }, ref: { type: "string" } }, required: ["application"], additionalProperties: false },
|
|
10582
|
+
},
|
|
10583
|
+
{
|
|
10584
|
+
name: "clauth_ops_promote",
|
|
10585
|
+
description: "Submit one server-allowlisted Coolify promotion and return its asynchronous job receipt.",
|
|
10586
|
+
inputSchema: { type: "object", properties: { application_uuid: { type: "string" } }, required: ["application_uuid"], additionalProperties: false },
|
|
10587
|
+
},
|
|
10588
|
+
{
|
|
10589
|
+
name: "clauth_ops_job",
|
|
10590
|
+
description: "Read the terminal or in-progress receipt for a deployment-control job.",
|
|
10591
|
+
inputSchema: { type: "object", properties: { job_id: { type: "string" } }, required: ["job_id"], additionalProperties: false },
|
|
10592
|
+
},
|
|
10593
|
+
{
|
|
10594
|
+
name: "clauth_ops_run",
|
|
10595
|
+
description: "Submit one PM2 operation through the Vultr control plane. The remote agent/admin profile decides whether the operation and target are available.",
|
|
10596
|
+
inputSchema: { type: "object", properties: { operation: { type: "string" }, input: { type: "object" } }, required: ["operation"], additionalProperties: false },
|
|
10597
|
+
},
|
|
10885
10598
|
];
|
|
10886
10599
|
|
|
10887
10600
|
const MCP_WRITE_TOOL_NAMES = new Set([
|
|
@@ -10889,6 +10602,9 @@ const MCP_WRITE_TOOL_NAMES = new Set([
|
|
|
10889
10602
|
"clauth_disable",
|
|
10890
10603
|
"clauth_set_project",
|
|
10891
10604
|
"clauth_generate_token",
|
|
10605
|
+
"clauth_ops_deploy",
|
|
10606
|
+
"clauth_ops_promote",
|
|
10607
|
+
"clauth_ops_run",
|
|
10892
10608
|
]);
|
|
10893
10609
|
|
|
10894
10610
|
function filterMcpToolsForWriteMode(tools) {
|
|
@@ -10922,6 +10638,24 @@ function mcpError(text) {
|
|
|
10922
10638
|
return { content: [{ type: "text", text }], isError: true };
|
|
10923
10639
|
}
|
|
10924
10640
|
|
|
10641
|
+
async function callOpsFromMcp(vault, method, requestPath, body, { write = false } = {}) {
|
|
10642
|
+
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.");
|
|
10643
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
10644
|
+
const endpoint = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
|
|
10645
|
+
if (!endpoint) return mcpError("service_not_available");
|
|
10646
|
+
try {
|
|
10647
|
+
const url = new URL(endpoint);
|
|
10648
|
+
if (url.protocol !== "https:") return mcpError("service_not_available");
|
|
10649
|
+
const service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token";
|
|
10650
|
+
const credential = await vaultRetrieveValue(vault, service);
|
|
10651
|
+
if (credential.error || !credential.value) return mcpError("service_not_available");
|
|
10652
|
+
const payload = await requestOps({ endpoint: url.toString().replace(/\/$/, ""), token: String(credential.value), method, path: requestPath, body });
|
|
10653
|
+
return mcpResult(JSON.stringify(payload, null, 2));
|
|
10654
|
+
} catch {
|
|
10655
|
+
return mcpError("service_not_available");
|
|
10656
|
+
}
|
|
10657
|
+
}
|
|
10658
|
+
|
|
10925
10659
|
// Windows cmd.exe doesn't support single quotes — use bash for gws JSON args
|
|
10926
10660
|
const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
|
|
10927
10661
|
|
|
@@ -10932,6 +10666,13 @@ async function handleMcpTool(vault, name, args) {
|
|
|
10932
10666
|
};
|
|
10933
10667
|
|
|
10934
10668
|
switch (name) {
|
|
10669
|
+
case "clauth_ops_catalog": return callOpsFromMcp(vault, "GET", "/v1/ops/catalog");
|
|
10670
|
+
case "clauth_ops_processes": return callOpsFromMcp(vault, "GET", "/v1/ops/processes");
|
|
10671
|
+
case "clauth_ops_describe": return callOpsFromMcp(vault, "GET", `/v1/ops/processes/${encodeURIComponent(args.application || "")}`);
|
|
10672
|
+
case "clauth_ops_deploy": return callOpsFromMcp(vault, "POST", "/v1/ops/deployments", { application: args.application, ...(args.ref ? { ref: args.ref } : {}) }, { write: true });
|
|
10673
|
+
case "clauth_ops_promote": return callOpsFromMcp(vault, "POST", "/v1/ops/promotions", { application_uuid: args.application_uuid }, { write: true });
|
|
10674
|
+
case "clauth_ops_job": return callOpsFromMcp(vault, "GET", `/v1/ops/jobs/${encodeURIComponent(args.job_id || "")}`);
|
|
10675
|
+
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
10676
|
case "clauth_ping": {
|
|
10936
10677
|
return mcpResult(
|
|
10937
10678
|
vault.password
|
|
@@ -11997,19 +11738,6 @@ async function handleMcpTool(vault, name, args) {
|
|
|
11997
11738
|
return mcpResult(JSON.stringify(result));
|
|
11998
11739
|
}
|
|
11999
11740
|
|
|
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
11741
|
case "handoff_start": {
|
|
12014
11742
|
const result = await startHandoffSession(args || {}, vault);
|
|
12015
11743
|
if (result.error) return mcpError(`${result.error}: ${result.message}`);
|
|
@@ -13132,6 +12860,14 @@ async function actionUpgrade(opts) {
|
|
|
13132
12860
|
return actionStart(opts);
|
|
13133
12861
|
}
|
|
13134
12862
|
|
|
12863
|
+
async function actionSupervisor(opts) {
|
|
12864
|
+
opts.isolated = true;
|
|
12865
|
+
opts.port = String(opts.port || getSupervisorPort());
|
|
12866
|
+
// discoverPlugins() now runs unconditionally inside actionForeground at
|
|
12867
|
+
// every boot — calling it here too would double-run discovery.
|
|
12868
|
+
return actionForeground(opts);
|
|
12869
|
+
}
|
|
12870
|
+
|
|
13135
12871
|
export async function runServe(opts) {
|
|
13136
12872
|
const action = opts.action || "foreground";
|
|
13137
12873
|
|
|
@@ -13142,12 +12878,13 @@ export async function runServe(opts) {
|
|
|
13142
12878
|
case "ping": return actionPing();
|
|
13143
12879
|
case "foreground": return actionForeground(opts);
|
|
13144
12880
|
case "mcp": return actionMcp(opts);
|
|
12881
|
+
case "supervisor": return actionSupervisor(opts);
|
|
13145
12882
|
case "install": return actionInstall(opts);
|
|
13146
12883
|
case "uninstall": return actionUninstall();
|
|
13147
12884
|
case "upgrade": return actionUpgrade(opts);
|
|
13148
12885
|
default:
|
|
13149
12886
|
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"));
|
|
12887
|
+
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | supervisor | install | uninstall | upgrade\n"));
|
|
13151
12888
|
process.exit(1);
|
|
13152
12889
|
}
|
|
13153
12890
|
}
|