@lifeaitools/clauth 1.30.24 → 1.30.26

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.
@@ -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"));
@@ -677,6 +685,9 @@ 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}
688
+ #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}
689
+ .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}
690
+ .write-unlock-cancel:hover{border-color:#64748b;color:#cbd5e1}
680
691
  /* ── Main view ── */
681
692
  #main-view{display:none;padding:2rem}
682
693
  .header{display:flex;align-items:center;gap:10px;margin-bottom:1.5rem;flex-wrap:wrap}
@@ -951,6 +962,24 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
951
962
  </div>
952
963
  </div>
953
964
 
965
+ <!-- Write-unlock modal (replaces window.prompt(), which silently no-ops in
966
+ embedded/webview browser contexts and after a browser suppresses repeated
967
+ native dialogs) -->
968
+ <div id="write-unlock-overlay">
969
+ <div class="lock-card">
970
+ <div class="lock-icon">🔒</div>
971
+ <div class="lock-title">Enable writes</div>
972
+ <div class="lock-sub">Enter your vault password to enable saving changes (30-minute write session)</div>
973
+ <form onsubmit="submitWriteUnlock();return false;" autocomplete="on">
974
+ <input type="text" name="username" value="clauth" autocomplete="username" style="display:none">
975
+ <input class="lock-input" id="write-unlock-input" type="password" placeholder="••••••••••••" autocomplete="current-password">
976
+ <button class="btn-unlock" id="write-unlock-btn" type="submit">Unlock Writes</button>
977
+ </form>
978
+ <div class="lock-err" id="write-unlock-err"></div>
979
+ <button type="button" class="write-unlock-cancel" onclick="closeWriteUnlockModal()">Cancel</button>
980
+ </div>
981
+ </div>
982
+
954
983
  <!-- ── Main view (shown after unlock) ──────── -->
955
984
  <div id="main-view">
956
985
  <div id="upgrade-banner" style="display:none" class="upgrade-banner">
@@ -1166,12 +1195,6 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
1166
1195
  <div id="supervisor-status" class="supervisor-sub">Loading supervisor state…</div>
1167
1196
  <div id="supervisor-feedback" class="supervisor-feedback" role="status"></div>
1168
1197
  <div class="supervisor-grid" style="margin-top:10px">
1169
- <div class="supervisor-card">
1170
- <h4>Plugins</h4>
1171
- <div class="supervisor-kpi" id="supervisor-plugin-count">—</div>
1172
- <div class="supervisor-meta" id="supervisor-plugin-meta">awaiting data</div>
1173
- <div class="supervisor-list" id="supervisor-plugins" style="margin-top:8px"></div>
1174
- </div>
1175
1198
  <div class="supervisor-card">
1176
1199
  <h4>Surfaces</h4>
1177
1200
  <div class="supervisor-kpi" id="supervisor-surface-count">—</div>
@@ -1231,11 +1254,16 @@ function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null)
1231
1254
  <span id="service-search-count" class="service-search-count"></span>
1232
1255
  </div>
1233
1256
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
1234
- <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
1257
+ <div class="footer" id="originFooter">checking origin… · 10-strike lockout</div>
1235
1258
  </div>
1236
1259
 
1237
1260
  <script>
1238
- const BASE = "http://127.0.0.1:${port}";
1261
+ const BASE = location.origin;
1262
+ (function reportOrigin() {
1263
+ const isLocal = /^(127\\.0\\.0\\.1|localhost|\\[::1\\])$/.test(location.hostname);
1264
+ const el = document.getElementById("originFooter");
1265
+ if (el) el.textContent = (isLocal ? "LOCAL" : "REMOTE") + " · " + BASE + " · 10-strike lockout";
1266
+ })();
1239
1267
 
1240
1268
  const SERVICE_HINTS = {
1241
1269
  "neo4j": "neo4j+s://username:password@instance.databases.neo4j.io",
@@ -1494,21 +1522,16 @@ async function loadSupervisorCockpit() {
1494
1522
  const status = document.getElementById("supervisor-status");
1495
1523
  if (status) status.textContent = "Loading supervisor state…";
1496
1524
  try {
1497
- const [health, plugins, surfaces, logs] = await Promise.all([
1525
+ const [health, surfaces, logs] = await Promise.all([
1498
1526
  supervisorJson("/health"),
1499
- supervisorJson("/v1/plugins"),
1500
1527
  supervisorJson("/v1/surfaces"),
1501
1528
  supervisorJson("/v1/logs?limit=40"),
1502
1529
  ]);
1503
- const pluginRows = plugins.plugins || [];
1504
1530
  const surfaceRows = surfaces.surfaces || [];
1505
- document.getElementById("supervisor-plugin-count").textContent = String(pluginRows.length);
1506
1531
  document.getElementById("supervisor-surface-count").textContent = String(surfaceRows.length);
1507
1532
  document.getElementById("supervisor-operation-count").textContent = String((logs.operations || []).length);
1508
- document.getElementById("supervisor-plugin-meta").textContent = "current " + (pluginRows.filter(p => p.state === "current").length) + " · awaiting " + (pluginRows.filter(p => p.state === "awaiting_enable").length);
1509
1533
  document.getElementById("supervisor-surface-meta").textContent = "pm2 home: " + (health.pm2_home || "—");
1510
1534
  document.getElementById("supervisor-log-path").textContent = logs.log_path || "events.jsonl";
1511
- document.getElementById("supervisor-plugins").innerHTML = pluginRows.length ? pluginRows.map(renderSupervisorPlugin).join("") : '<div class="supervisor-sub">No plugins discovered.</div>';
1512
1535
  document.getElementById("supervisor-surfaces").innerHTML = surfaceRows.length ? surfaceRows.map(renderSupervisorSurface).join("") : '<div class="supervisor-sub">No surfaces declared.</div>';
1513
1536
  document.getElementById("supervisor-events").textContent = (logs.events || []).slice(-20).reverse().map(e => {
1514
1537
  const label = e.kind === "operation" ? (e.action + " " + JSON.stringify(e.target || {})) : (e.kind + " " + (e.plugin_id || ""));
@@ -1521,14 +1544,6 @@ async function loadSupervisorCockpit() {
1521
1544
  }
1522
1545
  }
1523
1546
 
1524
- function renderSupervisorPlugin(plugin) {
1525
- const stateKind = plugin.state === "current" ? "ok" : (plugin.state === "manifest_invalid" || plugin.state === "missing" ? "bad" : "warn");
1526
- return '<div class="supervisor-row"><div class="supervisor-row-top"><span class="supervisor-name">' + htmlEscape(plugin.id) + '</span>' + supervisorBadge(plugin.state || "unknown", stateKind) + '</div>' +
1527
- '<div class="supervisor-meta">' + htmlEscape(plugin.publisher || "unknown") + ' · ' + htmlEscape(plugin.version || "—") + ' · ' + htmlEscape(plugin.source || "—") + '</div>' +
1528
- '<div class="supervisor-meta">' + htmlEscape((plugin.manifest_hash || "").slice(0, 12)) + '</div>' +
1529
- '<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>';
1530
- }
1531
-
1532
1547
  function renderSupervisorSurface(surface) {
1533
1548
  const ownerKind = surface.lifecycle_owner === "clauth" ? "ok" : (surface.lifecycle_owner === "plugin" ? "warn" : "");
1534
1549
  const stateKind = surface.state === "current" || surface.status === "healthy" ? "ok" : (surface.state === "unavailable" ? "bad" : "warn");
@@ -1546,14 +1561,6 @@ async function rescanSupervisorPlugins() {
1546
1561
  } catch (err) { supervisorFeedback("Plugin rescan failed: " + (err.message || err), true); }
1547
1562
  }
1548
1563
 
1549
- async function runSupervisorPlugin(id, action) {
1550
- try {
1551
- const receipt = await supervisorJson("/v1/plugins/" + encodeURIComponent(id) + "/" + encodeURIComponent(action), { method: "POST", headers: writeHeaders() });
1552
- supervisorFeedback("Plugin " + action + " receipt " + (receipt.operationId || "recorded") + " · " + ((receipt.resulting_state && receipt.resulting_state.state) || "completed"), false);
1553
- await loadSupervisorCockpit();
1554
- } catch (err) { supervisorFeedback("Plugin " + action + " failed: " + (err.message || err), true); }
1555
- }
1556
-
1557
1564
  async function runSupervisorSurface(id, action) {
1558
1565
  try {
1559
1566
  const receipt = await supervisorJson("/v1/surfaces/" + encodeURIComponent(id) + "/actions", { method: "POST", headers: writeHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ action }) });
@@ -1644,21 +1651,51 @@ async function lockVault() {
1644
1651
  // ── Unlock writes (re-establish write scope without locking) ──
1645
1652
  // Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
1646
1653
  // unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
1647
- async function unlockWrites() {
1648
- if (writeToken && !confirm("Writes are already unlocked this session. Re-unlock?")) return;
1649
- const pw = prompt("Enter your vault password to enable saving changes (10-minute write session):");
1650
- if (!pw) return;
1654
+ function unlockWrites() {
1655
+ openWriteUnlockModal();
1656
+ }
1657
+
1658
+ function openWriteUnlockModal() {
1659
+ const overlay = document.getElementById("write-unlock-overlay");
1660
+ const input = document.getElementById("write-unlock-input");
1661
+ const err = document.getElementById("write-unlock-err");
1662
+ if (err) err.textContent = "";
1663
+ if (input) { input.value = ""; input.className = "lock-input"; }
1664
+ if (overlay) overlay.style.display = "flex";
1665
+ if (input) setTimeout(() => input.focus(), 50);
1666
+ }
1667
+
1668
+ function closeWriteUnlockModal() {
1669
+ const overlay = document.getElementById("write-unlock-overlay");
1670
+ if (overlay) overlay.style.display = "none";
1671
+ }
1672
+
1673
+ async function submitWriteUnlock() {
1674
+ const input = document.getElementById("write-unlock-input");
1675
+ const btn = document.getElementById("write-unlock-btn");
1676
+ const err = document.getElementById("write-unlock-err");
1677
+ const pw = input ? input.value : "";
1678
+ if (!pw) { if (err) err.textContent = "Password is required."; return; }
1679
+ if (btn) { btn.disabled = true; btn.textContent = "Verifying..."; }
1651
1680
  try {
1652
1681
  const r = await fetch(BASE + "/auth", {
1653
1682
  method: "POST",
1654
1683
  headers: { "Content-Type": "application/json" },
1655
1684
  body: JSON.stringify({ password: pw }),
1656
1685
  }).then(r => r.json());
1657
- if (r.error) { alert("Unlock failed: " + r.error); return; }
1686
+ if (r.error) {
1687
+ if (input) { input.className = "lock-input error"; setTimeout(() => input.className = "lock-input", 600); }
1688
+ if (err) err.textContent = "Invalid: " + (r.error || "Invalid password");
1689
+ return;
1690
+ }
1658
1691
  writeToken = r.write_token || null;
1659
1692
  refreshWriteLockUi();
1660
- alert(writeToken ? "Writes unlocked for 10 minutes." : "Unlock did not return a write token.");
1661
- } catch (e) { alert("Unlock error: " + (e.message || e)); }
1693
+ closeWriteUnlockModal();
1694
+ } catch (e) {
1695
+ if (err) err.textContent = "Unlock error: " + (e.message || e);
1696
+ } finally {
1697
+ if (btn) { btn.disabled = false; btn.textContent = "Unlock Writes"; }
1698
+ }
1662
1699
  }
1663
1700
 
1664
1701
  // Reflect write-lock state on the button so it is obvious when a save will fail.
@@ -4027,6 +4064,170 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4027
4064
  });
4028
4065
  const isSupervisorPort = port === getSupervisorPort();
4029
4066
  const supervisorTestNoToken = process.env.CLAUTH_SUPERVISOR_TEST_NO_TOKEN === "1";
4067
+ const opsAdapter = createPm2Adapter(pm2);
4068
+ const executePm2 = createSerializedExecutor();
4069
+ const opsPolicy = createOperationPolicy({
4070
+ enabled: String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
4071
+ applications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_APPLICATIONS || "{}"); } catch { return {}; } })(),
4072
+ adminEnabled: String(process.env.CLAUTH_OPS_ADMIN_ENABLED || "").split(",").map((item) => item.trim()).filter(Boolean),
4073
+ adminApplications: (() => { try { return JSON.parse(process.env.CLAUTH_OPS_ADMIN_APPLICATIONS || "{}"); } catch { return {}; } })(),
4074
+ allowHostWide: process.env.CLAUTH_OPS_ALLOW_HOST_WIDE === "1",
4075
+ });
4076
+ const opsJobs = createJobStore({
4077
+ filePath: process.env.CLAUTH_OPS_JOB_STORE_PATH || path.join(getSupervisorDir(), "ops-jobs.json"),
4078
+ });
4079
+
4080
+ async function getLoopbackSecret(service) {
4081
+ const response = await fetch(`http://127.0.0.1:${LIVE_PORT}/v/${encodeURIComponent(service)}`, { signal: AbortSignal.timeout(5000) });
4082
+ if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
4083
+ const value = (await response.text()).trim();
4084
+ if (!value) throw new Error(`${service} is empty`);
4085
+ return value;
4086
+ }
4087
+ const coolify = createCoolifyAdapter({
4088
+ baseUrl: process.env.CLAUTH_COOLIFY_BASE_URL || "https://deploy.regendevcorp.com",
4089
+ getToken: () => getLoopbackSecret("coolify-api"),
4090
+ });
4091
+ const deployments = (() => { try { return parseDeploymentRegistry(process.env.CLAUTH_OPS_DEPLOYMENTS || "{}"); } catch { return {}; } })();
4092
+ const deploymentAdapter = createDeploymentAdapter({
4093
+ deployments,
4094
+ reload: async (target) => {
4095
+ await executePm2(async () => {
4096
+ await opsAdapter.connect();
4097
+ try { await opsAdapter.execute("reload", { target, options: { updateEnv: true } }); } finally { await opsAdapter.disconnect(); }
4098
+ });
4099
+ },
4100
+ });
4101
+
4102
+ /**
4103
+ * Record an ops failure's upstream message to the LOCAL log only.
4104
+ *
4105
+ * job-store's sanitizer deliberately drops free-form `error` text so an
4106
+ * upstream message cannot carry a credential into the persisted job file or
4107
+ * the API response. That protection left every failure with an empty detail,
4108
+ * so jobs reported `failed` with no reason at all. Jobs now carry an
4109
+ * enumerated `code`; the underlying message goes here, to the same
4110
+ * operator-only log as the rest of the daemon's diagnostics.
4111
+ */
4112
+ function logOpsFailure(kind, operation, error) {
4113
+ const message = String(error?.message || error || "unknown");
4114
+ try {
4115
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [OPS ${kind}/${operation}] ${message}\n`);
4116
+ } catch {}
4117
+ }
4118
+
4119
+ async function opsBearerRole(req) {
4120
+ const header = req.headers.authorization;
4121
+ const supplied = Array.isArray(header) ? header[0] : header;
4122
+ if (!supplied || !String(supplied).startsWith("Bearer ")) return null;
4123
+ const actual = String(supplied).slice(7).trim();
4124
+ let admin; let agent;
4125
+ try { admin = await getLoopbackSecret(process.env.CLAUTH_OPS_ADMIN_TOKEN_SERVICE || "vultr-ops-admin-token"); } catch {}
4126
+ try { agent = await getLoopbackSecret(process.env.CLAUTH_OPS_AGENT_TOKEN_SERVICE || "vultr-ops-api-token"); } catch {}
4127
+ if (admin && agent && admin === agent) return null;
4128
+ for (const [role, expected] of [["admin", admin], ["agent", agent]]) {
4129
+ if (!expected) continue;
4130
+ const a = Buffer.from(actual); const b = Buffer.from(expected);
4131
+ if (a.length === b.length && crypto.timingSafeEqual(a, b)) return role;
4132
+ }
4133
+ return null;
4134
+ }
4135
+
4136
+ async function requireOpsBearer(req, res) {
4137
+ const role = await opsBearerRole(req);
4138
+ if (role) { req._opsRole = role; return true; }
4139
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
4140
+ res.end(JSON.stringify({ error: "ops_bearer_required" }));
4141
+ return false;
4142
+ }
4143
+
4144
+ function submitOpsJob(operation, input, role = "agent") {
4145
+ const authorization = opsPolicy.authorize(operation, input, role);
4146
+ const job = opsJobs.create({ kind: "pm2", operation, target: input.target || input.name || null });
4147
+ if (!authorization.ok) {
4148
+ return opsJobs.event(job.id, "rejected", { code: authorization.code });
4149
+ }
4150
+ void (async () => {
4151
+ opsJobs.event(job.id, "running");
4152
+ try {
4153
+ const result = await executePm2(async () => {
4154
+ await opsAdapter.connect();
4155
+ try { return await opsAdapter.execute(operation, input); } finally { await opsAdapter.disconnect(); }
4156
+ });
4157
+ opsJobs.event(job.id, "succeeded", operationReceipt(operation, result, authorization.allowed_targets || []));
4158
+ } catch (error) {
4159
+ // `error` alone is dropped by job-store's sanitizer (it refuses
4160
+ // free-form upstream text so a credential cannot ride along), which
4161
+ // left every failure with an empty detail. Emit an enumerated code so
4162
+ // the failure has a reason; keep the message for the local log only.
4163
+ logOpsFailure("pm2", operation, error);
4164
+ opsJobs.event(job.id, "failed", { code: "pm2_operation_failed" });
4165
+ }
4166
+ })();
4167
+ return opsJobs.get(job.id);
4168
+ }
4169
+
4170
+ function operationReceipt(operation, result, allowedTargets) {
4171
+ if (["list", "describe", "logs"].includes(operation)) {
4172
+ const processes = Array.isArray(result)
4173
+ ? result.filter((process) => allowedTargets.includes("*") || allowedTargets.includes(process?.name))
4174
+ : [];
4175
+ return { processes };
4176
+ }
4177
+ if (operation === "ping") return { status: "connected" };
4178
+ return { status: "completed" };
4179
+ }
4180
+
4181
+ function submitPromotionJob(applicationUuid) {
4182
+ const job = opsJobs.create({ kind: "coolify", operation: "promote", target: applicationUuid });
4183
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("coolify_promote");
4184
+ const allowlist = (() => { try { return JSON.parse(process.env.CLAUTH_COOLIFY_PROMOTE_UUIDS || "[]"); } catch { return []; } })();
4185
+ if (!enabled || !Array.isArray(allowlist) || !allowlist.includes(applicationUuid)) {
4186
+ return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
4187
+ }
4188
+ void (async () => {
4189
+ opsJobs.event(job.id, "running");
4190
+ try {
4191
+ const deployment = await coolify.promote(applicationUuid);
4192
+ // Coolify answers with a `deployments` ARRAY, not a flat object — see
4193
+ // deploymentUuidFrom. Reading the flat field alone marked the job failed
4194
+ // while the deployment was actually running.
4195
+ const deploymentUuid = deploymentUuidFrom(deployment);
4196
+ if (!deploymentUuid) {
4197
+ // The deploy request itself SUCCEEDED (no throw); only the UUID was
4198
+ // unreadable, so the deployment may well be RUNNING. The code says so
4199
+ // explicitly rather than a bare "failed", because a plain failure
4200
+ // invites a retry and a duplicate production deploy.
4201
+ //
4202
+ // An enumerated code, not a free-form error: job-store's sanitizer
4203
+ // drops `error` on purpose to keep upstream text (and any credential
4204
+ // inside it) out of the persisted job.
4205
+ return opsJobs.event(job.id, "failed", { code: "coolify_deploy_accepted_uuid_unreadable" });
4206
+ }
4207
+ opsJobs.event(job.id, "waiting", { deployment_uuid: deploymentUuid });
4208
+ const terminal = await coolify.poll(deploymentUuid, { attempts: Number(process.env.CLAUTH_COOLIFY_POLL_ATTEMPTS || 60), delay: () => new Promise((resolve) => setTimeout(resolve, 5000)) });
4209
+ opsJobs.event(job.id, terminal.state === "succeeded" ? "succeeded" : terminal.state, { deployment_uuid: deploymentUuid, status: terminal.deployment?.status || null });
4210
+ } catch (error) {
4211
+ // The throw may have happened AFTER Coolify accepted the deploy (e.g.
4212
+ // the poll lost the network), so this is not proof nothing shipped.
4213
+ logOpsFailure("coolify", "promote", error);
4214
+ opsJobs.event(job.id, "failed", { code: "coolify_promote_failed" });
4215
+ }
4216
+ })();
4217
+ return opsJobs.get(job.id);
4218
+ }
4219
+
4220
+ function submitDeploymentJob(application, ref) {
4221
+ const job = opsJobs.create({ kind: "deployment", operation: "deploy", target: application });
4222
+ const enabled = String(process.env.CLAUTH_OPS_ENABLED || "").split(",").map((item) => item.trim()).includes("deploy");
4223
+ if (!enabled || !deployments[application]) return opsJobs.event(job.id, "rejected", { code: "service_not_available" });
4224
+ void (async () => {
4225
+ opsJobs.event(job.id, "running");
4226
+ try { opsJobs.event(job.id, "building"); opsJobs.event(job.id, "succeeded", { result: await deploymentAdapter.deploy({ application, ref }) }); }
4227
+ catch (error) { logOpsFailure("deployment", "deploy", error); opsJobs.event(job.id, "failed", { code: "deployment_failed" }); }
4228
+ })();
4229
+ return opsJobs.get(job.id);
4230
+ }
4030
4231
 
4031
4232
  function hasSupervisorWrite(req) {
4032
4233
  if (validateWriteToken(req, writeSession)) return true;
@@ -4623,7 +4824,104 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4623
4824
  }
4624
4825
 
4625
4826
  if (method === "GET" && reqPath === "/health") {
4626
- return ok(res, { ...supervisorHealth(), vault_locked: !password, clauth_version: VERSION });
4827
+ return ok(res, { ...supervisorHealth(), listening_port: port, process_id: process.pid, vault_locked: !password, clauth_version: VERSION });
4828
+ }
4829
+
4830
+ // Bearer-gated remote operations surface. It remains loopback-only at this
4831
+ // layer; ingress/tunnel policy decides whether it is reachable remotely.
4832
+ if (method === "GET" && reqPath === "/v1/ops/catalog") {
4833
+ if (!await requireOpsBearer(req, res)) return;
4834
+ return ok(res, { schema: "clauth.ops.v1", operations: PM2_OPERATION_CATALOG });
4835
+ }
4836
+
4837
+ if (method === "GET" && reqPath === "/v1/ops/processes") {
4838
+ if (!await requireOpsBearer(req, res)) return;
4839
+ const job = submitOpsJob("list", {}, req._opsRole);
4840
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4841
+ return res.end(JSON.stringify(job));
4842
+ }
4843
+
4844
+ const opsProcessMatch = reqPath.match(/^\/v1\/ops\/processes\/([^/]+)$/);
4845
+ if (method === "GET" && opsProcessMatch) {
4846
+ if (!await requireOpsBearer(req, res)) return;
4847
+ const job = submitOpsJob("describe", { target: decodeURIComponent(opsProcessMatch[1]) }, req._opsRole);
4848
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4849
+ return res.end(JSON.stringify(job));
4850
+ }
4851
+
4852
+ if (method === "POST" && reqPath === "/v1/ops/operations") {
4853
+ if (!await requireOpsBearer(req, res)) return;
4854
+ let body;
4855
+ try { body = await readBody(req); } catch {
4856
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4857
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4858
+ }
4859
+ const operation = String(body?.operation || "");
4860
+ if (!PM2_OPERATION_CATALOG[operation]) {
4861
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4862
+ return res.end(JSON.stringify({ error: "unknown_operation" }));
4863
+ }
4864
+ const job = submitOpsJob(operation, body?.input && typeof body.input === "object" ? body.input : {}, req._opsRole);
4865
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4866
+ return res.end(JSON.stringify(job));
4867
+ }
4868
+
4869
+ if (method === "POST" && reqPath === "/v1/ops/promotions") {
4870
+ if (!await requireOpsBearer(req, res)) return;
4871
+ let body;
4872
+ try { body = await readBody(req); } catch {
4873
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4874
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4875
+ }
4876
+ const applicationUuid = String(body?.application_uuid || "").trim();
4877
+ if (!applicationUuid) {
4878
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4879
+ return res.end(JSON.stringify({ error: "application_uuid_required" }));
4880
+ }
4881
+ const job = submitPromotionJob(applicationUuid);
4882
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4883
+ return res.end(JSON.stringify(job));
4884
+ }
4885
+
4886
+ if (method === "POST" && reqPath === "/v1/ops/deployments") {
4887
+ if (!await requireOpsBearer(req, res)) return;
4888
+ let body;
4889
+ try { body = await readBody(req); } catch {
4890
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4891
+ return res.end(JSON.stringify({ error: "invalid_json" }));
4892
+ }
4893
+ const application = String(body?.application || "").trim();
4894
+ if (!application) {
4895
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
4896
+ return res.end(JSON.stringify({ error: "application_required" }));
4897
+ }
4898
+ const job = submitDeploymentJob(application, body?.ref ? String(body.ref) : undefined);
4899
+ res.writeHead(job.phase === "rejected" ? 403 : 202, { "Content-Type": "application/json", ...CORS });
4900
+ return res.end(JSON.stringify(job));
4901
+ }
4902
+
4903
+ const opsJobMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)$/);
4904
+ if (method === "GET" && opsJobMatch) {
4905
+ if (!await requireOpsBearer(req, res)) return;
4906
+ const job = opsJobs.get(decodeURIComponent(opsJobMatch[1]));
4907
+ res.writeHead(job ? 200 : 404, { "Content-Type": "application/json", ...CORS });
4908
+ return res.end(JSON.stringify(job || { error: "job_not_found" }));
4909
+ }
4910
+
4911
+ const opsJobEventsMatch = reqPath.match(/^\/v1\/ops\/jobs\/([^/]+)\/events$/);
4912
+ if (method === "GET" && opsJobEventsMatch) {
4913
+ if (!await requireOpsBearer(req, res)) return;
4914
+ const jobId = decodeURIComponent(opsJobEventsMatch[1]);
4915
+ if (!opsJobs.get(jobId)) {
4916
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS });
4917
+ return res.end(JSON.stringify({ error: "job_not_found" }));
4918
+ }
4919
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...CORS });
4920
+ const unsubscribe = opsJobs.subscribe(jobId, (job) => {
4921
+ if (!res.writableEnded) res.write(`event: job\ndata: ${JSON.stringify(job)}\n\n`);
4922
+ });
4923
+ req.on("close", unsubscribe);
4924
+ return;
4627
4925
  }
4628
4926
 
4629
4927
  if (method === "GET" && reqPath === "/v1/plugins") {
@@ -11343,6 +11641,41 @@ const MCP_TOOLS = [
11343
11641
  additionalProperties: false,
11344
11642
  },
11345
11643
  },
11644
+ {
11645
+ name: "clauth_ops_catalog",
11646
+ description: "Return the Vultr deployment-control API catalog. Availability is still decided on Vultr.",
11647
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
11648
+ },
11649
+ {
11650
+ name: "clauth_ops_processes",
11651
+ description: "Submit a scoped PM2 process-status query to the Vultr control plane. Only server-approved applications are returned.",
11652
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
11653
+ },
11654
+ {
11655
+ name: "clauth_ops_describe",
11656
+ description: "Submit a scoped PM2 status query for one server-approved application.",
11657
+ inputSchema: { type: "object", properties: { application: { type: "string" } }, required: ["application"], additionalProperties: false },
11658
+ },
11659
+ {
11660
+ name: "clauth_ops_deploy",
11661
+ description: "Submit one manifest-scoped development deployment to Vultr. The server chooses the repository, build, PM2 process, and allowed ref.",
11662
+ inputSchema: { type: "object", properties: { application: { type: "string" }, ref: { type: "string" } }, required: ["application"], additionalProperties: false },
11663
+ },
11664
+ {
11665
+ name: "clauth_ops_promote",
11666
+ description: "Submit one server-allowlisted Coolify promotion and return its asynchronous job receipt.",
11667
+ inputSchema: { type: "object", properties: { application_uuid: { type: "string" } }, required: ["application_uuid"], additionalProperties: false },
11668
+ },
11669
+ {
11670
+ name: "clauth_ops_job",
11671
+ description: "Read the terminal or in-progress receipt for a deployment-control job.",
11672
+ inputSchema: { type: "object", properties: { job_id: { type: "string" } }, required: ["job_id"], additionalProperties: false },
11673
+ },
11674
+ {
11675
+ name: "clauth_ops_run",
11676
+ description: "Submit one PM2 operation through the Vultr control plane. The remote agent/admin profile decides whether the operation and target are available.",
11677
+ inputSchema: { type: "object", properties: { operation: { type: "string" }, input: { type: "object" } }, required: ["operation"], additionalProperties: false },
11678
+ },
11346
11679
  ];
11347
11680
 
11348
11681
  const MCP_WRITE_TOOL_NAMES = new Set([
@@ -11350,6 +11683,9 @@ const MCP_WRITE_TOOL_NAMES = new Set([
11350
11683
  "clauth_disable",
11351
11684
  "clauth_set_project",
11352
11685
  "clauth_generate_token",
11686
+ "clauth_ops_deploy",
11687
+ "clauth_ops_promote",
11688
+ "clauth_ops_run",
11353
11689
  ]);
11354
11690
 
11355
11691
  function filterMcpToolsForWriteMode(tools) {
@@ -11383,6 +11719,24 @@ function mcpError(text) {
11383
11719
  return { content: [{ type: "text", text }], isError: true };
11384
11720
  }
11385
11721
 
11722
+ async function callOpsFromMcp(vault, method, requestPath, body, { write = false } = {}) {
11723
+ 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.");
11724
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
11725
+ const endpoint = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
11726
+ if (!endpoint) return mcpError("service_not_available");
11727
+ try {
11728
+ const url = new URL(endpoint);
11729
+ if (url.protocol !== "https:") return mcpError("service_not_available");
11730
+ const service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token";
11731
+ const credential = await vaultRetrieveValue(vault, service);
11732
+ if (credential.error || !credential.value) return mcpError("service_not_available");
11733
+ const payload = await requestOps({ endpoint: url.toString().replace(/\/$/, ""), token: String(credential.value), method, path: requestPath, body });
11734
+ return mcpResult(JSON.stringify(payload, null, 2));
11735
+ } catch {
11736
+ return mcpError("service_not_available");
11737
+ }
11738
+ }
11739
+
11386
11740
  // Windows cmd.exe doesn't support single quotes — use bash for gws JSON args
11387
11741
  const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
11388
11742
 
@@ -11393,6 +11747,13 @@ async function handleMcpTool(vault, name, args) {
11393
11747
  };
11394
11748
 
11395
11749
  switch (name) {
11750
+ case "clauth_ops_catalog": return callOpsFromMcp(vault, "GET", "/v1/ops/catalog");
11751
+ case "clauth_ops_processes": return callOpsFromMcp(vault, "GET", "/v1/ops/processes");
11752
+ case "clauth_ops_describe": return callOpsFromMcp(vault, "GET", `/v1/ops/processes/${encodeURIComponent(args.application || "")}`);
11753
+ case "clauth_ops_deploy": return callOpsFromMcp(vault, "POST", "/v1/ops/deployments", { application: args.application, ...(args.ref ? { ref: args.ref } : {}) }, { write: true });
11754
+ case "clauth_ops_promote": return callOpsFromMcp(vault, "POST", "/v1/ops/promotions", { application_uuid: args.application_uuid }, { write: true });
11755
+ case "clauth_ops_job": return callOpsFromMcp(vault, "GET", `/v1/ops/jobs/${encodeURIComponent(args.job_id || "")}`);
11756
+ 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 });
11396
11757
  case "clauth_ping": {
11397
11758
  return mcpResult(
11398
11759
  vault.password
@@ -131,7 +131,7 @@ export async function runWatchdog(action, opts = {}) {
131
131
  console.log("Usage: clauth watchdog restart <service-id>");
132
132
  return;
133
133
  }
134
- const result = restartWatchdogService(serviceId);
134
+ const result = await restartWatchdogService(serviceId);
135
135
  console.log(JSON.stringify(result, null, 2));
136
136
  return;
137
137
  }
package/cli/index.js CHANGED
@@ -150,6 +150,8 @@ import { runInstall } from './commands/install.js';
150
150
  import { runUninstall } from './commands/uninstall.js';
151
151
  import { runScrub } from './commands/scrub.js';
152
152
  import { runServe } from './commands/serve.js';
153
+ import { runOps } from './commands/ops.js';
154
+ import { runOpsInstall } from './commands/ops-install.js';
153
155
  import { runCodevelop } from './commands/codevelop.js';
154
156
  import { runNpm, runPublish } from './commands/npm.js';
155
157
 
@@ -1050,4 +1052,22 @@ Examples:
1050
1052
  await runServe({ ...opts, action: resolvedAction });
1051
1053
  });
1052
1054
 
1055
+ program
1056
+ .command("ops <action>")
1057
+ .description("Call the bearer-authenticated PM2 and Coolify operations control plane")
1058
+ .option("--endpoint <url>", "HTTPS control-plane endpoint (or CLAUTH_OPS_ENDPOINT)")
1059
+ .option("--target <name>", "PM2 process name or id")
1060
+ .option("--script <path>", "PM2 script path for start")
1061
+ .option("--instances <n>", "PM2 scale target")
1062
+ .option("--operation <name>", "PM2 operation for run")
1063
+ .option("--args-json <json>", "JSON positional arguments for a raw pm2_* operation")
1064
+ .option("--options-json <json>", "JSON PM2 options for a typed operation")
1065
+ .option("--application <uuid>", "registered Coolify application UUID for promote")
1066
+ .option("--ref <name>", "registered Git ref for deploy")
1067
+ .option("--job <id>", "job id for status lookup")
1068
+ .option("--config <path>", "server-side JSON policy for ops install")
1069
+ .option("--dry-run", "validate and print ops install configuration without changing PM2")
1070
+ .addHelpText("after", `\nActions: catalog | list | describe | run | deploy | promote | job | install\n\nInstall: clauth ops install --config /etc/clauth/ops-control-plane.json\nThe installer creates or updates a PM2-managed local control plane, then proves /health and the bearer gate without reading a token.\n\nThe bearer is retrieved only from local clauth service vultr-ops-api-token and is never printed.\n`)
1071
+ .action(async (action, opts) => { if (action === "install") await runOpsInstall(opts); else await runOps(action, opts); });
1072
+
1053
1073
  program.parse(process.argv);