@lifeaitools/clauth 1.30.23 → 1.30.25
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 +306 -275
- package/.clauth-skill/references/operator-guide.md +175 -148
- package/README.md +363 -315
- package/cli/api.classify.test.js +75 -75
- package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
- package/cli/assets/watchdog.ps1 +42 -42
- package/cli/commands/agent-cron.js +396 -396
- package/cli/commands/agent-pool.js +1962 -1962
- package/cli/commands/codevelop.js +1190 -1190
- package/cli/commands/doctor.js +302 -302
- package/cli/commands/install.js +10 -10
- package/cli/commands/invite.js +175 -175
- package/cli/commands/join.js +179 -179
- package/cli/commands/npm.js +182 -182
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/scrub.js +327 -327
- package/cli/commands/scrub.test.js +115 -115
- package/cli/commands/serve.js +381 -98
- package/cli/commands/watchdog.js +209 -209
- package/cli/conf-path.js +21 -21
- package/cli/enrollment-script.js +82 -82
- package/cli/fingerprint.js +143 -143
- package/cli/index.js +1073 -1053
- package/cli/lib/fs-git.js +282 -282
- 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/recovery.js +101 -101
- package/cli/studio-debug.js +1095 -1095
- package/cli/supervisor-registry.js +594 -589
- package/cli/supervisor-registry.test.js +397 -397
- package/cli/supervisor-ui.test.js +5 -83
- package/cli/watchdog-registry.js +237 -209
- package/cli/watchdog-registry.test.js +112 -89
- package/install.ps1 +21 -21
- package/package.json +4 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/migrations/001_clauth_schema.sql +12 -12
- package/supabase/migrations/003_clauth_config.sql +13 -13
- package/supabase/migrations/003_machine_enrollments.sql +39 -39
- package/cli/served-script-syntax.test.mjs +0 -54
package/cli/commands/serve.js
CHANGED
|
@@ -58,6 +58,14 @@ import {
|
|
|
58
58
|
DEFAULT_BOOTSTRAP,
|
|
59
59
|
} from "./agent-pool.js";
|
|
60
60
|
import { AgentCron, cronEnabled, nextRun } from "./agent-cron.js";
|
|
61
|
+
import pm2 from "pm2";
|
|
62
|
+
import { createPm2Adapter, PM2_OPERATION_CATALOG } from "../ops/pm2-adapter.js";
|
|
63
|
+
import { createOperationPolicy } from "../ops/operation-policy.js";
|
|
64
|
+
import { createJobStore } from "../ops/job-store.js";
|
|
65
|
+
import { createCoolifyAdapter, deploymentUuidFrom } from "../ops/coolify-adapter.js";
|
|
66
|
+
import { createDeploymentAdapter, parseDeploymentRegistry } from "../ops/deployment-adapter.js";
|
|
67
|
+
import { createSerializedExecutor } from "../ops/serialized-executor.js";
|
|
68
|
+
import { requestOps } from "./ops.js";
|
|
61
69
|
|
|
62
70
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
63
71
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
|
@@ -486,7 +494,7 @@ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
|
486
494
|
const LIVE_PORT = 52437;
|
|
487
495
|
const STAGED_PORT = 52438;
|
|
488
496
|
const WRITE_TOKEN_BYTES = 32;
|
|
489
|
-
const WRITE_TOKEN_TTL_MS =
|
|
497
|
+
const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
490
498
|
|
|
491
499
|
function makeWriteToken() {
|
|
492
500
|
return {
|
|
@@ -677,9 +685,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
677
685
|
.btn-unlock:hover{background:#2563eb}
|
|
678
686
|
.btn-unlock:disabled{background:#1e3a5f;color:#4a6fa5;cursor:not-allowed}
|
|
679
687
|
.lock-err{color:#f87171;font-size:.82rem;margin-top:.75rem;min-height:1.2em}
|
|
680
|
-
#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}
|
|
681
|
-
.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}
|
|
682
|
-
.write-unlock-cancel:hover{border-color:#64748b;color:#cbd5e1}
|
|
683
688
|
/* ── Main view ── */
|
|
684
689
|
#main-view{display:none;padding:2rem}
|
|
685
690
|
.header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
|
|
@@ -933,7 +938,7 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
933
938
|
.supervisor-name{font-size:.82rem;color:#e2e8f0;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
934
939
|
.supervisor-row-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}
|
|
935
940
|
.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}
|
|
936
|
-
.supervisor-feedback{margin-top:8px;padding:9px
|
|
941
|
+
.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}
|
|
937
942
|
.supervisor-feedback.bad{color:#fecaca;background:rgba(248,113,113,.08);border-color:rgba(248,113,113,.25)}
|
|
938
943
|
</style>
|
|
939
944
|
</head>
|
|
@@ -954,24 +959,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
954
959
|
</div>
|
|
955
960
|
</div>
|
|
956
961
|
|
|
957
|
-
<!-- Write-unlock modal (replaces window.prompt(), which silently no-ops in
|
|
958
|
-
embedded/webview browser contexts and after a browser suppresses repeated
|
|
959
|
-
native dialogs) -->
|
|
960
|
-
<div id="write-unlock-overlay">
|
|
961
|
-
<div class="lock-card">
|
|
962
|
-
<div class="lock-icon">🔒</div>
|
|
963
|
-
<div class="lock-title">Enable writes</div>
|
|
964
|
-
<div class="lock-sub">Enter your vault password to enable saving changes (30-minute write session)</div>
|
|
965
|
-
<form onsubmit="submitWriteUnlock();return false;" autocomplete="on">
|
|
966
|
-
<input type="text" name="username" value="clauth" autocomplete="username" style="display:none">
|
|
967
|
-
<input class="lock-input" id="write-unlock-input" type="password" placeholder="••••••••••••" autocomplete="current-password">
|
|
968
|
-
<button class="btn-unlock" id="write-unlock-btn" type="submit">Unlock Writes</button>
|
|
969
|
-
</form>
|
|
970
|
-
<div class="lock-err" id="write-unlock-err"></div>
|
|
971
|
-
<button type="button" class="write-unlock-cancel" onclick="closeWriteUnlockModal()">Cancel</button>
|
|
972
|
-
</div>
|
|
973
|
-
</div>
|
|
974
|
-
|
|
975
962
|
<!-- ── Main view (shown after unlock) ──────── -->
|
|
976
963
|
<div id="main-view">
|
|
977
964
|
<div id="upgrade-banner" style="display:none" class="upgrade-banner">
|
|
@@ -1187,6 +1174,12 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
1187
1174
|
<div id="supervisor-status" class="supervisor-sub">Loading supervisor state…</div>
|
|
1188
1175
|
<div id="supervisor-feedback" class="supervisor-feedback" role="status"></div>
|
|
1189
1176
|
<div class="supervisor-grid" style="margin-top:10px">
|
|
1177
|
+
<div class="supervisor-card">
|
|
1178
|
+
<h4>Plugins</h4>
|
|
1179
|
+
<div class="supervisor-kpi" id="supervisor-plugin-count">—</div>
|
|
1180
|
+
<div class="supervisor-meta" id="supervisor-plugin-meta">awaiting data</div>
|
|
1181
|
+
<div class="supervisor-list" id="supervisor-plugins" style="margin-top:8px"></div>
|
|
1182
|
+
</div>
|
|
1190
1183
|
<div class="supervisor-card">
|
|
1191
1184
|
<h4>Surfaces</h4>
|
|
1192
1185
|
<div class="supervisor-kpi" id="supervisor-surface-count">—</div>
|
|
@@ -1246,11 +1239,16 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
|
|
|
1246
1239
|
<span id="service-search-count" class="service-search-count"></span>
|
|
1247
1240
|
</div>
|
|
1248
1241
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
1249
|
-
<div class="footer">
|
|
1242
|
+
<div class="footer" id="originFooter">checking origin… · 10-strike lockout</div>
|
|
1250
1243
|
</div>
|
|
1251
1244
|
|
|
1252
1245
|
<script>
|
|
1253
|
-
const BASE =
|
|
1246
|
+
const BASE = location.origin;
|
|
1247
|
+
(function reportOrigin() {
|
|
1248
|
+
const isLocal = /^(127\\.0\\.0\\.1|localhost|\\[::1\\])$/.test(location.hostname);
|
|
1249
|
+
const el = document.getElementById("originFooter");
|
|
1250
|
+
if (el) el.textContent = (isLocal ? "LOCAL" : "REMOTE") + " · " + BASE + " · 10-strike lockout";
|
|
1251
|
+
})();
|
|
1254
1252
|
|
|
1255
1253
|
const SERVICE_HINTS = {
|
|
1256
1254
|
"neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
|
|
@@ -1509,16 +1507,21 @@ async function loadSupervisorCockpit() {
|
|
|
1509
1507
|
const status = document.getElementById("supervisor-status");
|
|
1510
1508
|
if (status) status.textContent = "Loading supervisor state…";
|
|
1511
1509
|
try {
|
|
1512
|
-
const [health, surfaces, logs] = await Promise.all([
|
|
1510
|
+
const [health, plugins, surfaces, logs] = await Promise.all([
|
|
1513
1511
|
supervisorJson("/health"),
|
|
1512
|
+
supervisorJson("/v1/plugins"),
|
|
1514
1513
|
supervisorJson("/v1/surfaces"),
|
|
1515
1514
|
supervisorJson("/v1/logs?limit=40"),
|
|
1516
1515
|
]);
|
|
1516
|
+
const pluginRows = plugins.plugins || [];
|
|
1517
1517
|
const surfaceRows = surfaces.surfaces || [];
|
|
1518
|
+
document.getElementById("supervisor-plugin-count").textContent = String(pluginRows.length);
|
|
1518
1519
|
document.getElementById("supervisor-surface-count").textContent = String(surfaceRows.length);
|
|
1519
1520
|
document.getElementById("supervisor-operation-count").textContent = String((logs.operations || []).length);
|
|
1521
|
+
document.getElementById("supervisor-plugin-meta").textContent = "current " + (pluginRows.filter(p => p.state === "current").length) + " · awaiting " + (pluginRows.filter(p => p.state === "awaiting_enable").length);
|
|
1520
1522
|
document.getElementById("supervisor-surface-meta").textContent = "pm2 home: " + (health.pm2_home || "—");
|
|
1521
1523
|
document.getElementById("supervisor-log-path").textContent = logs.log_path || "events.jsonl";
|
|
1524
|
+
document.getElementById("supervisor-plugins").innerHTML = pluginRows.length ? pluginRows.map(renderSupervisorPlugin).join("") : '<div class="supervisor-sub">No plugins discovered.</div>';
|
|
1522
1525
|
document.getElementById("supervisor-surfaces").innerHTML = surfaceRows.length ? surfaceRows.map(renderSupervisorSurface).join("") : '<div class="supervisor-sub">No surfaces declared.</div>';
|
|
1523
1526
|
document.getElementById("supervisor-events").textContent = (logs.events || []).slice(-20).reverse().map(e => {
|
|
1524
1527
|
const label = e.kind === "operation" ? (e.action + " " + JSON.stringify(e.target || {})) : (e.kind + " " + (e.plugin_id || ""));
|
|
@@ -1531,13 +1534,21 @@ async function loadSupervisorCockpit() {
|
|
|
1531
1534
|
}
|
|
1532
1535
|
}
|
|
1533
1536
|
|
|
1537
|
+
function renderSupervisorPlugin(plugin) {
|
|
1538
|
+
const stateKind = plugin.state === "current" ? "ok" : (plugin.state === "manifest_invalid" || plugin.state === "missing" ? "bad" : "warn");
|
|
1539
|
+
return '<div class="supervisor-row"><div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(plugin.id) + '</span>' + supervisorBadge(plugin.state || "unknown", stateKind) + '</div>' +
|
|
1540
|
+
'<div class="supervisor-meta">' + htmlEscape(plugin.publisher || "unknown") + ' · ' + htmlEscape(plugin.version || "—") + ' · ' + htmlEscape(plugin.source || "—") + '</div>' +
|
|
1541
|
+
'<div class="supervisor-meta">' + htmlEscape((plugin.manifest_hash || "").slice(0, 12)) + '</div>' +
|
|
1542
|
+
'<div class="supervisor-row-actions"><button class="supervisor-action" onclick="runSupervisorPlugin(' + jsArg(plugin.id) + ',' + jsArg(plugin.enabled ? "disable" : "enable") + ')">' + (plugin.enabled ? "Disable" : "Enable") + '</button><button class="supervisor-action" onclick="runSupervisorPlugin(' + jsArg(plugin.id) + ',' + jsArg("test") + ')">Test</button><button class="supervisor-action" onclick="runSupervisorPlugin(' + jsArg(plugin.id) + ',' + jsArg("promote") + ')">Promote</button></div></div>';
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1534
1545
|
function renderSupervisorSurface(surface) {
|
|
1535
1546
|
const ownerKind = surface.lifecycle_owner === "clauth" ? "ok" : (surface.lifecycle_owner === "plugin" ? "warn" : "");
|
|
1536
1547
|
const stateKind = surface.state === "current" || surface.status === "healthy" ? "ok" : (surface.state === "unavailable" ? "bad" : "warn");
|
|
1537
1548
|
return '<div class="supervisor-row"><div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(surface.plugin_id + ":" + surface.id) + '</span>' + supervisorBadge(surface.lifecycle_owner || "unknown", ownerKind) + supervisorBadge(surface.state || surface.status || "unknown", stateKind) + '</div>' +
|
|
1538
1549
|
'<div class="supervisor-meta">' + htmlEscape(surface.destination || "—") + ' · port ' + htmlEscape(surface.port || "—") + '</div>' +
|
|
1539
1550
|
'<div class="supervisor-meta">' + htmlEscape(surface.health || surface.health_url || "no health") + '</div>' +
|
|
1540
|
-
'<div class="supervisor-row-actions">' +
|
|
1551
|
+
'<div class="supervisor-row-actions">' + ["start","stop","restart","reconcile","test","promote","rollback"].map(a => '<button class="supervisor-action" data-supervisor-action="' + a + '" onclick="runSupervisorSurface(' + jsArg(surface.id) + ',' + jsArg(a) + ')">' + a + '</button>').join("") + '</div></div>';
|
|
1541
1552
|
}
|
|
1542
1553
|
|
|
1543
1554
|
async function rescanSupervisorPlugins() {
|
|
@@ -1548,37 +1559,21 @@ async function rescanSupervisorPlugins() {
|
|
|
1548
1559
|
} catch (err) { supervisorFeedback("Plugin rescan failed: " + (err.message || err), true); }
|
|
1549
1560
|
}
|
|
1550
1561
|
|
|
1551
|
-
async function
|
|
1552
|
-
const originalLabel = btn ? btn.textContent : null;
|
|
1553
|
-
if (btn) { btn.disabled = true; btn.textContent = "..."; btn.style.opacity = "0.6"; }
|
|
1554
|
-
supervisorFeedback(action + " " + id + ": sending...", false);
|
|
1562
|
+
async function runSupervisorPlugin(id, action) {
|
|
1555
1563
|
try {
|
|
1556
|
-
const receipt = await supervisorJson("/v1/
|
|
1557
|
-
|
|
1558
|
-
const ok = result.ok !== false;
|
|
1559
|
-
if (btn) { btn.textContent = ok ? "done" : "failed"; btn.style.background = ok ? "#14532d" : "#7f1d1d"; }
|
|
1560
|
-
supervisorFeedback(id + " " + action + " " + (ok ? "succeeded" : "failed") + " -> " + (result.state || result.reason || "completed"), !ok);
|
|
1561
|
-
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
1564
|
+
const receipt = await supervisorJson("/v1/plugins/" + encodeURIComponent(id) + "/" + encodeURIComponent(action), { method: "POST", headers: writeHeaders() });
|
|
1565
|
+
supervisorFeedback("Plugin " + action + " receipt " + (receipt.operationId || "recorded") + " · " + ((receipt.resulting_state && receipt.resulting_state.state) || "completed"), false);
|
|
1562
1566
|
await loadSupervisorCockpit();
|
|
1563
|
-
} catch (err) {
|
|
1564
|
-
if (btn) { btn.textContent = "failed"; btn.style.background = "#7f1d1d"; }
|
|
1565
|
-
supervisorFeedback(id + " " + action + " failed: " + (err.message || err), true);
|
|
1566
|
-
if (btn) { setTimeout(() => { btn.disabled = false; btn.textContent = originalLabel; btn.style.opacity = ""; btn.style.background = ""; }, 2500); }
|
|
1567
|
-
}
|
|
1567
|
+
} catch (err) { supervisorFeedback("Plugin " + action + " failed: " + (err.message || err), true); }
|
|
1568
1568
|
}
|
|
1569
1569
|
|
|
1570
|
-
async function
|
|
1571
|
-
const originalLabel = btn ? btn.textContent : null;
|
|
1572
|
-
if (btn) { btn.disabled = true; btn.textContent = "..."; btn.style.opacity = "0.6"; }
|
|
1570
|
+
async function runSupervisorSurface(id, action) {
|
|
1573
1571
|
try {
|
|
1574
|
-
const receipt = await supervisorJson("/v1/
|
|
1575
|
-
|
|
1576
|
-
|
|
1572
|
+
const receipt = await supervisorJson("/v1/surfaces/" + encodeURIComponent(id) + "/actions", { method: "POST", headers: writeHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ action }) });
|
|
1573
|
+
const result = receipt.resulting_state || {};
|
|
1574
|
+
supervisorFeedback("Surface " + action + " receipt " + (receipt.operationId || "recorded") + " · " + (result.state || "completed"), result.ok === false);
|
|
1577
1575
|
await loadSupervisorCockpit();
|
|
1578
|
-
} catch (err) {
|
|
1579
|
-
supervisorFeedback(pluginId + " " + (enable ? "enable" : "disable") + " failed: " + (err.message || err), true);
|
|
1580
|
-
if (btn) { setTimeout(() => { btn.disabled = false; btn.textContent = originalLabel; btn.style.opacity = ""; }, 2000); }
|
|
1581
|
-
}
|
|
1576
|
+
} catch (err) { supervisorFeedback("Surface " + action + " failed: " + (err.message || err), true); }
|
|
1582
1577
|
}
|
|
1583
1578
|
|
|
1584
1579
|
// ── Unlock ──────────────────────────────────
|
|
@@ -1659,60 +1654,24 @@ async function lockVault() {
|
|
|
1659
1654
|
showLockScreen(r.hard_locked || false);
|
|
1660
1655
|
}
|
|
1661
1656
|
|
|
1662
|
-
//
|
|
1657
|
+
// ── Unlock writes (re-establish write scope without locking) ──
|
|
1663
1658
|
// Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
|
|
1664
|
-
// unlock screen, so it holds no write token. POST /auth mints one (
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
// suppresses repeated dialogs for a tab ("prevent this page from creating
|
|
1670
|
-
// additional dialogs") -- both cases fail with zero visible feedback.
|
|
1671
|
-
function unlockWrites() {
|
|
1672
|
-
openWriteUnlockModal();
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
function openWriteUnlockModal() {
|
|
1676
|
-
const overlay = document.getElementById("write-unlock-overlay");
|
|
1677
|
-
const input = document.getElementById("write-unlock-input");
|
|
1678
|
-
const err = document.getElementById("write-unlock-err");
|
|
1679
|
-
if (err) err.textContent = "";
|
|
1680
|
-
if (input) { input.value = ""; input.className = "lock-input"; }
|
|
1681
|
-
if (overlay) overlay.style.display = "flex";
|
|
1682
|
-
if (input) setTimeout(() => input.focus(), 50);
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
function closeWriteUnlockModal() {
|
|
1686
|
-
const overlay = document.getElementById("write-unlock-overlay");
|
|
1687
|
-
if (overlay) overlay.style.display = "none";
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
async function submitWriteUnlock() {
|
|
1691
|
-
const input = document.getElementById("write-unlock-input");
|
|
1692
|
-
const btn = document.getElementById("write-unlock-btn");
|
|
1693
|
-
const err = document.getElementById("write-unlock-err");
|
|
1694
|
-
const pw = input ? input.value : "";
|
|
1695
|
-
if (!pw) { if (err) err.textContent = "Password is required."; return; }
|
|
1696
|
-
if (btn) { btn.disabled = true; btn.textContent = "Verifying..."; }
|
|
1659
|
+
// unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
|
|
1660
|
+
async function unlockWrites() {
|
|
1661
|
+
if (writeToken && !confirm("Writes are already unlocked this session. Re-unlock?")) return;
|
|
1662
|
+
const pw = prompt("Enter your vault password to enable saving changes (10-minute write session):");
|
|
1663
|
+
if (!pw) return;
|
|
1697
1664
|
try {
|
|
1698
1665
|
const r = await fetch(BASE + "/auth", {
|
|
1699
1666
|
method: "POST",
|
|
1700
1667
|
headers: { "Content-Type": "application/json" },
|
|
1701
1668
|
body: JSON.stringify({ password: pw }),
|
|
1702
1669
|
}).then(r => r.json());
|
|
1703
|
-
if (r.error) {
|
|
1704
|
-
if (input) { input.className = "lock-input error"; setTimeout(() => input.className = "lock-input", 600); }
|
|
1705
|
-
if (err) err.textContent = "Invalid: " + (r.error || "Invalid password");
|
|
1706
|
-
return;
|
|
1707
|
-
}
|
|
1670
|
+
if (r.error) { alert("Unlock failed: " + r.error); return; }
|
|
1708
1671
|
writeToken = r.write_token || null;
|
|
1709
1672
|
refreshWriteLockUi();
|
|
1710
|
-
|
|
1711
|
-
} catch (e) {
|
|
1712
|
-
if (err) err.textContent = "Unlock error: " + (e.message || e);
|
|
1713
|
-
} finally {
|
|
1714
|
-
if (btn) { btn.disabled = false; btn.textContent = "Unlock Writes"; }
|
|
1715
|
-
}
|
|
1673
|
+
alert(writeToken ? "Writes unlocked for 10 minutes." : "Unlock did not return a write token.");
|
|
1674
|
+
} catch (e) { alert("Unlock error: " + (e.message || e)); }
|
|
1716
1675
|
}
|
|
1717
1676
|
|
|
1718
1677
|
// Reflect write-lock state on the button so it is obvious when a save will fail.
|
|
@@ -4081,6 +4040,170 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4081
4040
|
});
|
|
4082
4041
|
const isSupervisorPort = port === getSupervisorPort();
|
|
4083
4042
|
const supervisorTestNoToken = process.env.CLAUTH_SUPERVISOR_TEST_NO_TOKEN === "1";
|
|
4043
|
+
const opsAdapter = createPm2Adapter(pm2);
|
|
4044
|
+
const executePm2 = createSerializedExecutor();
|
|
4045
|
+
const opsPolicy = createOperationPolicy({
|
|
4046
|
+
enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
4047
|
+
applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
4048
|
+
adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
|
|
4049
|
+
adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
|
|
4050
|
+
allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
|
|
4051
|
+
});
|
|
4052
|
+
const opsJobs = createJobStore({
|
|
4053
|
+
filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
|
|
4054
|
+
});
|
|
4055
|
+
|
|
4056
|
+
async function getLoopbackSecret(service) {
|
|
4057
|
+
const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
|
|
4058
|
+
if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
|
|
4059
|
+
const value = (await response.text()).trim();
|
|
4060
|
+
if (!value) throw new Error(`${service} is empty`);
|
|
4061
|
+
return value;
|
|
4062
|
+
}
|
|
4063
|
+
const coolify = createCoolifyAdapter({
|
|
4064
|
+
baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
|
|
4065
|
+
getToken: () => getLoopbackSecret("coolify-api"),
|
|
4066
|
+
});
|
|
4067
|
+
const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
|
|
4068
|
+
const deploymentAdapter = createDeploymentAdapter({
|
|
4069
|
+
deployments,
|
|
4070
|
+
reload: async (target) => {
|
|
4071
|
+
await executePm2(async () => {
|
|
4072
|
+
await opsAdapter.connect();
|
|
4073
|
+
try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
|
|
4074
|
+
});
|
|
4075
|
+
},
|
|
4076
|
+
});
|
|
4077
|
+
|
|
4078
|
+
/**
|
|
4079
|
+
* Record an ops failure's upstream message to the LOCAL log only.
|
|
4080
|
+
*
|
|
4081
|
+
* job-store's sanitizer deliberately drops free-form `error` text so an
|
|
4082
|
+
* upstream message cannot carry a credential into the persisted job file or
|
|
4083
|
+
* the API response. That protection left every failure with an empty detail,
|
|
4084
|
+
* so jobs reported `failed` with no reason at all. Jobs now carry an
|
|
4085
|
+
* enumerated `code`; the underlying message goes here, to the same
|
|
4086
|
+
* operator-only log as the rest of the daemon's diagnostics.
|
|
4087
|
+
*/
|
|
4088
|
+
function logOpsFailure(kind, operation, error) {
|
|
4089
|
+
const message = String(error?.message || error || "unknown");
|
|
4090
|
+
try {
|
|
4091
|
+
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
|
|
4092
|
+
} catch {}
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
async function opsBearerRole(req) {
|
|
4096
|
+
const header = req.headers.authorization;
|
|
4097
|
+
const supplied = Array.isArray(header) ? header[0] : header;
|
|
4098
|
+
if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
|
|
4099
|
+
const actual = String(supplied).slice(7).trim();
|
|
4100
|
+
let admin; let agent;
|
|
4101
|
+
try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
|
|
4102
|
+
try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
|
|
4103
|
+
if (admin && agent && admin === agent) return null;
|
|
4104
|
+
for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
|
|
4105
|
+
if (!expected) continue;
|
|
4106
|
+
const a = Buffer.from(actual); const b = Buffer.from(expected);
|
|
4107
|
+
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
|
|
4108
|
+
}
|
|
4109
|
+
return null;
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
async function requireOpsBearer(req, res) {
|
|
4113
|
+
const role = await opsBearerRole(req);
|
|
4114
|
+
if (role) { req._opsRole = role; return true; }
|
|
4115
|
+
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
4116
|
+
res.end(JSON.stringify({ error: "ops_bearer_required" }));
|
|
4117
|
+
return false;
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
function submitOpsJob(operation, input, role = "agent") {
|
|
4121
|
+
const authorization = opsPolicy.authorize(operation, input, role);
|
|
4122
|
+
const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
|
|
4123
|
+
if (!authorization.ok) {
|
|
4124
|
+
return opsJobs.event(job.id, "rejected", { code: authorization.code });
|
|
4125
|
+
}
|
|
4126
|
+
void (async () => {
|
|
4127
|
+
opsJobs.event(job.id, "running");
|
|
4128
|
+
try {
|
|
4129
|
+
const result = await executePm2(async () => {
|
|
4130
|
+
await opsAdapter.connect();
|
|
4131
|
+
try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
|
|
4132
|
+
});
|
|
4133
|
+
opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
|
|
4134
|
+
} catch (error) {
|
|
4135
|
+
// `error` alone is dropped by job-store's sanitizer (it refuses
|
|
4136
|
+
// free-form upstream text so a credential cannot ride along), which
|
|
4137
|
+
// left every failure with an empty detail. Emit an enumerated code so
|
|
4138
|
+
// the failure has a reason; keep the message for the local log only.
|
|
4139
|
+
logOpsFailure("pm2", operation, error);
|
|
4140
|
+
opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
|
|
4141
|
+
}
|
|
4142
|
+
})();
|
|
4143
|
+
return opsJobs.get(job.id);
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
function operationReceipt(operation, result, allowedTargets) {
|
|
4147
|
+
if (["list", "describe", "logs"].includes(operation)) {
|
|
4148
|
+
const processes = Array.isArray(result)
|
|
4149
|
+
? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
|
|
4150
|
+
: [];
|
|
4151
|
+
return { processes };
|
|
4152
|
+
}
|
|
4153
|
+
if (operation === "ping") return { status: "connected" };
|
|
4154
|
+
return { status: "completed" };
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
function submitPromotionJob(applicationUuid) {
|
|
4158
|
+
const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
|
|
4159
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
|
|
4160
|
+
const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
|
|
4161
|
+
if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
|
|
4162
|
+
return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
4163
|
+
}
|
|
4164
|
+
void (async () => {
|
|
4165
|
+
opsJobs.event(job.id, "running");
|
|
4166
|
+
try {
|
|
4167
|
+
const deployment = await coolify.promote(applicationUuid);
|
|
4168
|
+
// Coolify answers with a `deployments` ARRAY, not a flat object — see
|
|
4169
|
+
// deploymentUuidFrom. Reading the flat field alone marked the job failed
|
|
4170
|
+
// while the deployment was actually running.
|
|
4171
|
+
const deploymentUuid = deploymentUuidFrom(deployment);
|
|
4172
|
+
if (!deploymentUuid) {
|
|
4173
|
+
// The deploy request itself SUCCEEDED (no throw); only the UUID was
|
|
4174
|
+
// unreadable, so the deployment may well be RUNNING. The code says so
|
|
4175
|
+
// explicitly rather than a bare "failed", because a plain failure
|
|
4176
|
+
// invites a retry and a duplicate production deploy.
|
|
4177
|
+
//
|
|
4178
|
+
// An enumerated code, not a free-form error: job-store's sanitizer
|
|
4179
|
+
// drops `error` on purpose to keep upstream text (and any credential
|
|
4180
|
+
// inside it) out of the persisted job.
|
|
4181
|
+
return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
|
|
4182
|
+
}
|
|
4183
|
+
opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
|
|
4184
|
+
const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
|
|
4185
|
+
opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
|
|
4186
|
+
} catch (error) {
|
|
4187
|
+
// The throw may have happened AFTER Coolify accepted the deploy (e.g.
|
|
4188
|
+
// the poll lost the network), so this is not proof nothing shipped.
|
|
4189
|
+
logOpsFailure("coolify", "promote", error);
|
|
4190
|
+
opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
|
|
4191
|
+
}
|
|
4192
|
+
})();
|
|
4193
|
+
return opsJobs.get(job.id);
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
function submitDeploymentJob(application, ref) {
|
|
4197
|
+
const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
|
|
4198
|
+
const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
|
|
4199
|
+
if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
|
|
4200
|
+
void (async () => {
|
|
4201
|
+
opsJobs.event(job.id, "running");
|
|
4202
|
+
try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
|
|
4203
|
+
catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
|
|
4204
|
+
})();
|
|
4205
|
+
return opsJobs.get(job.id);
|
|
4206
|
+
}
|
|
4084
4207
|
|
|
4085
4208
|
function hasSupervisorWrite(req) {
|
|
4086
4209
|
if (validateWriteToken(req, writeSession)) return true;
|
|
@@ -4677,7 +4800,104 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4677
4800
|
}
|
|
4678
4801
|
|
|
4679
4802
|
if (method === "GET" && reqPath === "/health") {
|
|
4680
|
-
return ok(res, { ...supervisorHealth(), vault_locked: !password, clauth_version: VERSION });
|
|
4803
|
+
return ok(res, { ...supervisorHealth(), listening_port: port, process_id: process.pid, vault_locked: !password, clauth_version: VERSION });
|
|
4804
|
+
}
|
|
4805
|
+
|
|
4806
|
+
// Bearer-gated remote operations surface. It remains loopback-only at this
|
|
4807
|
+
// layer; ingress/tunnel policy decides whether it is reachable remotely.
|
|
4808
|
+
if (method === "GET" && reqPath === "/v1/ops/catalog") {
|
|
4809
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4810
|
+
return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
if (method === "GET" && reqPath === "/v1/ops/processes") {
|
|
4814
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4815
|
+
const job = submitOpsJob("list", {}, req._opsRole);
|
|
4816
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4817
|
+
return res.end(JSON.stringify(job));
|
|
4818
|
+
}
|
|
4819
|
+
|
|
4820
|
+
const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
|
|
4821
|
+
if (method === "GET" && opsProcessMatch) {
|
|
4822
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4823
|
+
const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
|
|
4824
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4825
|
+
return res.end(JSON.stringify(job));
|
|
4826
|
+
}
|
|
4827
|
+
|
|
4828
|
+
if (method === "POST" && reqPath === "/v1/ops/operations") {
|
|
4829
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4830
|
+
let body;
|
|
4831
|
+
try { body = await readBody(req); } catch {
|
|
4832
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4833
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4834
|
+
}
|
|
4835
|
+
const operation = String(body?.operation || "");
|
|
4836
|
+
if (!PM2_OPERATION_CATALOG[operation]) {
|
|
4837
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4838
|
+
return res.end(JSON.stringify({ error: "unknown_operation" }));
|
|
4839
|
+
}
|
|
4840
|
+
const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
|
|
4841
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4842
|
+
return res.end(JSON.stringify(job));
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
if (method === "POST" && reqPath === "/v1/ops/promotions") {
|
|
4846
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4847
|
+
let body;
|
|
4848
|
+
try { body = await readBody(req); } catch {
|
|
4849
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4850
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4851
|
+
}
|
|
4852
|
+
const applicationUuid = String(body?.application_uuid || "").trim();
|
|
4853
|
+
if (!applicationUuid) {
|
|
4854
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4855
|
+
return res.end(JSON.stringify({ error: "application_uuid_required" }));
|
|
4856
|
+
}
|
|
4857
|
+
const job = submitPromotionJob(applicationUuid);
|
|
4858
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4859
|
+
return res.end(JSON.stringify(job));
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4862
|
+
if (method === "POST" && reqPath === "/v1/ops/deployments") {
|
|
4863
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4864
|
+
let body;
|
|
4865
|
+
try { body = await readBody(req); } catch {
|
|
4866
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4867
|
+
return res.end(JSON.stringify({ error: "invalid_json" }));
|
|
4868
|
+
}
|
|
4869
|
+
const application = String(body?.application || "").trim();
|
|
4870
|
+
if (!application) {
|
|
4871
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
4872
|
+
return res.end(JSON.stringify({ error: "application_required" }));
|
|
4873
|
+
}
|
|
4874
|
+
const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
|
|
4875
|
+
res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
|
|
4876
|
+
return res.end(JSON.stringify(job));
|
|
4877
|
+
}
|
|
4878
|
+
|
|
4879
|
+
const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
|
|
4880
|
+
if (method === "GET" && opsJobMatch) {
|
|
4881
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4882
|
+
const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
|
|
4883
|
+
res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
|
|
4884
|
+
return res.end(JSON.stringify(job || { error: "job_not_found" }));
|
|
4885
|
+
}
|
|
4886
|
+
|
|
4887
|
+
const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
|
|
4888
|
+
if (method === "GET" && opsJobEventsMatch) {
|
|
4889
|
+
if (!await requireOpsBearer(req, res)) return;
|
|
4890
|
+
const jobId = decodeURIComponent(opsJobEventsMatch[1]);
|
|
4891
|
+
if (!opsJobs.get(jobId)) {
|
|
4892
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
4893
|
+
return res.end(JSON.stringify({ error: "job_not_found" }));
|
|
4894
|
+
}
|
|
4895
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
|
|
4896
|
+
const unsubscribe = opsJobs.subscribe(jobId, (job) => {
|
|
4897
|
+
if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
|
|
4898
|
+
});
|
|
4899
|
+
req.on("close", unsubscribe);
|
|
4900
|
+
return;
|
|
4681
4901
|
}
|
|
4682
4902
|
|
|
4683
4903
|
if (method === "GET" && reqPath === "/v1/plugins") {
|
|
@@ -11397,6 +11617,41 @@ const MCP_TOOLS = [
|
|
|
11397
11617
|
additionalProperties: false,
|
|
11398
11618
|
},
|
|
11399
11619
|
},
|
|
11620
|
+
{
|
|
11621
|
+
name: "clauth_ops_catalog",
|
|
11622
|
+
description: "Return the Vultr deployment-control API catalog. Availability is still decided on Vultr.",
|
|
11623
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
11624
|
+
},
|
|
11625
|
+
{
|
|
11626
|
+
name: "clauth_ops_processes",
|
|
11627
|
+
description: "Submit a scoped PM2 process-status query to the Vultr control plane. Only server-approved applications are returned.",
|
|
11628
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
11629
|
+
},
|
|
11630
|
+
{
|
|
11631
|
+
name: "clauth_ops_describe",
|
|
11632
|
+
description: "Submit a scoped PM2 status query for one server-approved application.",
|
|
11633
|
+
inputSchema: { type: "object", properties: { application: { type: "string" } }, required: ["application"], additionalProperties: false },
|
|
11634
|
+
},
|
|
11635
|
+
{
|
|
11636
|
+
name: "clauth_ops_deploy",
|
|
11637
|
+
description: "Submit one manifest-scoped development deployment to Vultr. The server chooses the repository, build, PM2 process, and allowed ref.",
|
|
11638
|
+
inputSchema: { type: "object", properties: { application: { type: "string" }, ref: { type: "string" } }, required: ["application"], additionalProperties: false },
|
|
11639
|
+
},
|
|
11640
|
+
{
|
|
11641
|
+
name: "clauth_ops_promote",
|
|
11642
|
+
description: "Submit one server-allowlisted Coolify promotion and return its asynchronous job receipt.",
|
|
11643
|
+
inputSchema: { type: "object", properties: { application_uuid: { type: "string" } }, required: ["application_uuid"], additionalProperties: false },
|
|
11644
|
+
},
|
|
11645
|
+
{
|
|
11646
|
+
name: "clauth_ops_job",
|
|
11647
|
+
description: "Read the terminal or in-progress receipt for a deployment-control job.",
|
|
11648
|
+
inputSchema: { type: "object", properties: { job_id: { type: "string" } }, required: ["job_id"], additionalProperties: false },
|
|
11649
|
+
},
|
|
11650
|
+
{
|
|
11651
|
+
name: "clauth_ops_run",
|
|
11652
|
+
description: "Submit one PM2 operation through the Vultr control plane. The remote agent/admin profile decides whether the operation and target are available.",
|
|
11653
|
+
inputSchema: { type: "object", properties: { operation: { type: "string" }, input: { type: "object" } }, required: ["operation"], additionalProperties: false },
|
|
11654
|
+
},
|
|
11400
11655
|
];
|
|
11401
11656
|
|
|
11402
11657
|
const MCP_WRITE_TOOL_NAMES = new Set([
|
|
@@ -11404,6 +11659,9 @@ const MCP_WRITE_TOOL_NAMES = new Set([
|
|
|
11404
11659
|
"clauth_disable",
|
|
11405
11660
|
"clauth_set_project",
|
|
11406
11661
|
"clauth_generate_token",
|
|
11662
|
+
"clauth_ops_deploy",
|
|
11663
|
+
"clauth_ops_promote",
|
|
11664
|
+
"clauth_ops_run",
|
|
11407
11665
|
]);
|
|
11408
11666
|
|
|
11409
11667
|
function filterMcpToolsForWriteMode(tools) {
|
|
@@ -11437,6 +11695,24 @@ function mcpError(text) {
|
|
|
11437
11695
|
return { content: [{ type: "text", text }], isError: true };
|
|
11438
11696
|
}
|
|
11439
11697
|
|
|
11698
|
+
async function callOpsFromMcp(vault, method, requestPath, body, { write = false } = {}) {
|
|
11699
|
+
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.");
|
|
11700
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
11701
|
+
const endpoint = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
|
|
11702
|
+
if (!endpoint) return mcpError("service_not_available");
|
|
11703
|
+
try {
|
|
11704
|
+
const url = new URL(endpoint);
|
|
11705
|
+
if (url.protocol !== "https:") return mcpError("service_not_available");
|
|
11706
|
+
const service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token";
|
|
11707
|
+
const credential = await vaultRetrieveValue(vault, service);
|
|
11708
|
+
if (credential.error || !credential.value) return mcpError("service_not_available");
|
|
11709
|
+
const payload = await requestOps({ endpoint: url.toString().replace(/\/$/, ""), token: String(credential.value), method, path: requestPath, body });
|
|
11710
|
+
return mcpResult(JSON.stringify(payload, null, 2));
|
|
11711
|
+
} catch {
|
|
11712
|
+
return mcpError("service_not_available");
|
|
11713
|
+
}
|
|
11714
|
+
}
|
|
11715
|
+
|
|
11440
11716
|
// Windows cmd.exe doesn't support single quotes — use bash for gws JSON args
|
|
11441
11717
|
const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
|
|
11442
11718
|
|
|
@@ -11447,6 +11723,13 @@ async function handleMcpTool(vault, name, args) {
|
|
|
11447
11723
|
};
|
|
11448
11724
|
|
|
11449
11725
|
switch (name) {
|
|
11726
|
+
case "clauth_ops_catalog": return callOpsFromMcp(vault, "GET", "/v1/ops/catalog");
|
|
11727
|
+
case "clauth_ops_processes": return callOpsFromMcp(vault, "GET", "/v1/ops/processes");
|
|
11728
|
+
case "clauth_ops_describe": return callOpsFromMcp(vault, "GET", `/v1/ops/processes/${encodeURIComponent(args.application || "")}`);
|
|
11729
|
+
case "clauth_ops_deploy": return callOpsFromMcp(vault, "POST", "/v1/ops/deployments", { application: args.application, ...(args.ref ? { ref: args.ref } : {}) }, { write: true });
|
|
11730
|
+
case "clauth_ops_promote": return callOpsFromMcp(vault, "POST", "/v1/ops/promotions", { application_uuid: args.application_uuid }, { write: true });
|
|
11731
|
+
case "clauth_ops_job": return callOpsFromMcp(vault, "GET", `/v1/ops/jobs/${encodeURIComponent(args.job_id || "")}`);
|
|
11732
|
+
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 });
|
|
11450
11733
|
case "clauth_ping": {
|
|
11451
11734
|
return mcpResult(
|
|
11452
11735
|
vault.password
|