@lifeaitools/clauth 1.31.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -216,6 +216,37 @@ raw secrets and no Vault secret names. Fetch `GET /v/<service>` only for the one
216
216
  exact credential needed by the next command. Never walk every service and never
217
217
  bulk-fetch all secrets.
218
218
 
219
+ ## Managed PM2 Operations Control Plane
220
+
221
+ For LIFEAI development hosts, manage an approved PM2 application through the
222
+ bearer-authenticated clauth operations control plane. Do **not** invoke PM2
223
+ directly from an agent, dashboard, or deployment script. The policy file is the
224
+ allowlist: it names permitted applications, operations, deployment definitions,
225
+ and whether host-wide actions are available.
226
+
227
+ Install or validate the local controller with its explicit policy file:
228
+
229
+ ```bash
230
+ # Linux
231
+ clauth ops install --config /etc/clauth/ops-control-plane.json --dry-run
232
+ clauth ops install --config /etc/clauth/ops-control-plane.json
233
+
234
+ # Windows PowerShell
235
+ clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json --dry-run
236
+ clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json
237
+ ```
238
+
239
+ The live install replaces only the installer-owned `clauth-deployment` PM2
240
+ process, verifies its loopback `/health` response, and checks that an unauthenticated
241
+ catalog request is rejected. It retrieves its bearer only from the local clauth
242
+ service; never paste, print, or place that bearer in a policy file.
243
+
244
+ Use `clauth ops catalog` or the corresponding scoped MCP operations tools for
245
+ approved process inspection and lifecycle actions. Before a production promotion,
246
+ retain the job receipt and verify the selected application's health route. A
247
+ passing install proves controller setup; it does not by itself prove an arbitrary
248
+ application deployment.
249
+
219
250
  ## MCP Server (v1.16.9+)
220
251
 
221
252
  clauth runs as an MCP server with five namespaced paths and safe
@@ -100,6 +100,33 @@ select vault.create_secret('new-token-here', 'CLAUTH_ADMIN_BOOTSTRAP_TOKEN', 'ro
100
100
 
101
101
  ---
102
102
 
103
+ ## Managed PM2 Operations (LIFEAI development hosts)
104
+
105
+ Use the clauth operations controller for approved PM2 lifecycle and deployment
106
+ actions. Its policy JSON is the server-side allowlist; it must define only the
107
+ applications and operations this host is permitted to expose. Do not grant
108
+ host-wide PM2 actions unless there is an explicit operational requirement.
109
+
110
+ Validate before changing the process manager, then install:
111
+
112
+ ```bash
113
+ # Linux
114
+ clauth ops install --config /etc/clauth/ops-control-plane.json --dry-run
115
+ clauth ops install --config /etc/clauth/ops-control-plane.json
116
+
117
+ # Windows PowerShell
118
+ clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json --dry-run
119
+ clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json
120
+ ```
121
+
122
+ The installer owns only the `clauth-deployment` process. It checks the local
123
+ health endpoint and verifies that the catalog remains bearer-gated. Keep the
124
+ controller bearer in clauth, not in a shell history, policy file, or PM2
125
+ environment dump. Use `clauth ops catalog` and scoped operations commands rather
126
+ than invoking PM2 directly.
127
+
128
+ ---
129
+
103
130
  ## Viewing the Audit Log
104
131
 
105
132
  ```sql
package/README.md CHANGED
@@ -164,6 +164,54 @@ description, timestamps, and `has_key`. It never returns raw secrets or Vault
164
164
  secret names. Use `GET /v/<service>` only for the one exact secret needed by the
165
165
  next command. Do not bulk-fetch every service.
166
166
 
167
+ ### PM2 deployment control-plane installer
168
+
169
+ On a managed host, store the allowed PM2 targets and deployment definitions in a
170
+ server-side JSON file, then install or update the supervised endpoint:
171
+
172
+ ```bash
173
+ # Linux
174
+ clauth ops install --config /etc/clauth/ops-control-plane.json
175
+
176
+ # Windows PowerShell
177
+ clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json
178
+ ```
179
+
180
+ The installer is idempotent on Linux and Windows: it creates or updates the
181
+ `clauth-deployment` PM2 process, probes its loopback health endpoint, and proves
182
+ that the operations catalog refuses requests without a bearer token. It does not
183
+ store or print a bearer token. Use `--dry-run` to validate the policy file before
184
+ changing PM2.
185
+
186
+ The policy is an explicit application allowlist; keep `allow_host_wide` false
187
+ unless a host-wide administrative operation has been deliberately approved. A
188
+ minimal local policy looks like:
189
+
190
+ ```json
191
+ {
192
+ "port": 53141,
193
+ "pm2_name": "clauth-deployment",
194
+ "operations": {
195
+ "agent_enabled": ["restart", "reload"],
196
+ "agent_applications": {
197
+ "restart": ["codeflow-mcp"],
198
+ "reload": ["codeflow-mcp"]
199
+ },
200
+ "allow_host_wide": false
201
+ }
202
+ }
203
+ ```
204
+
205
+ Verify the policy before installation, then verify the loopback service without
206
+ ever placing a bearer in a shell command:
207
+
208
+ ```bash
209
+ clauth ops install --config <policy-path> --dry-run
210
+ clauth ops install --config <policy-path>
211
+ curl -i http://127.0.0.1:<port>/health
212
+ curl -i http://127.0.0.1:<port>/v1/ops/catalog # must return 401 without a bearer
213
+ ```
214
+
167
215
  ---
168
216
 
169
217
  ## MCP Server — 5 Namespaces, 63 Tools
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import pm2 from "pm2";
5
+
6
+ const DEFAULT_PORT = 52439;
7
+ const DEFAULT_NAME = "clauth-deployment";
8
+ const INSTALLER_MARKER = "clauth.ops.install.v1";
9
+ const OPS_ENV_KEYS = new Set([
10
+ "CLAUTH_OPS_INSTALLER_MARKER", "CLAUTH_OPS_ENABLED", "CLAUTH_OPS_APPLICATIONS", "CLAUTH_OPS_ADMIN_ENABLED",
11
+ "CLAUTH_OPS_ADMIN_APPLICATIONS", "CLAUTH_OPS_ALLOW_HOST_WIDE", "CLAUTH_OPS_DEPLOYMENTS", "CLAUTH_COOLIFY_PROMOTE_UUIDS",
12
+ ]);
13
+ const RUNTIME_ENV_KEYS = new Set(["PATH", "Path", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "SYSTEMROOT", "ComSpec", "TMP", "TEMP"]);
14
+ const RESTORABLE_PM2_KEYS = ["node_args", "exec_mode", "instances", "namespace", "watch", "kill_retry_time", "merge_logs", "windowsHide", "stop_exit_codes"];
15
+
16
+ function requiredString(value, label) {
17
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
18
+ return value.trim();
19
+ }
20
+
21
+ function stringList(value, label) {
22
+ if (value == null) return [];
23
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim())) throw new Error(`${label} must be an array of non-empty strings`);
24
+ return value.map((item) => item.trim());
25
+ }
26
+
27
+ function applicationPolicy(value, label) {
28
+ if (value == null) return {};
29
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
30
+ return Object.fromEntries(Object.entries(value).map(([operation, targets]) => [operation, stringList(targets, `${label}.${operation}`)]));
31
+ }
32
+
33
+ /**
34
+ * An operation is only usable when it ALSO has a target allowlist.
35
+ *
36
+ * `createOperationPolicy` reads the allowlist from `applications[operation]` and
37
+ * rejects with `service_not_available` when it is empty (operation-policy.js,
38
+ * the `!allowed.length` guard). That code reads like the service is down, but
39
+ * it actually means "no targets configured" — so a config carrying only
40
+ * `agent_enabled` installs cleanly, passes the health and bearer-gate probes,
41
+ * and then rejects every authenticated call. That exact combination shipped to
42
+ * the canary and was misdiagnosed for a day as a missing credential.
43
+ *
44
+ * Read operations (`list`, `describe`, `ping`, `logs`, …) are affected too: the
45
+ * `!allowed.length` guard runs before the read/mutate distinction, so a read op
46
+ * with no allowlist is rejected just the same.
47
+ *
48
+ * Fail at install instead, naming the operations that would silently break.
49
+ */
50
+ function assertEnabledOperationsHaveTargets(enabled, applications, label) {
51
+ const missing = enabled.filter((operation) => !(applications[operation] || []).length);
52
+ if (!missing.length) return;
53
+ const applicationsLabel = label.replace("_enabled", "_applications");
54
+ throw new Error(
55
+ `${label}: ${missing.join(", ")} enabled but absent from ${applicationsLabel}. An enabled operation with no target allowlist is rejected at runtime with service_not_available. Add a target list (e.g. {"${missing[0]}": ["*"]}) or remove it from the enabled list.`,
56
+ );
57
+ }
58
+
59
+ export function loadOpsInstallConfig(configPath) {
60
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
61
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("ops installer config must be a JSON object");
62
+ const port = raw.port == null ? DEFAULT_PORT : Number(raw.port);
63
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("port must be an integer from 1024 through 65535");
64
+ const pm2Name = raw.pm2_name == null ? DEFAULT_NAME : requiredString(raw.pm2_name, "pm2_name");
65
+ const operations = raw.operations && typeof raw.operations === "object" && !Array.isArray(raw.operations) ? raw.operations : {};
66
+
67
+ const enabled = stringList(operations.agent_enabled, "operations.agent_enabled");
68
+ const applications = applicationPolicy(operations.agent_applications, "operations.agent_applications");
69
+ const adminEnabled = stringList(operations.admin_enabled, "operations.admin_enabled");
70
+ const adminApplications = applicationPolicy(operations.admin_applications, "operations.admin_applications");
71
+
72
+ assertEnabledOperationsHaveTargets(enabled, applications, "operations.agent_enabled");
73
+ assertEnabledOperationsHaveTargets(adminEnabled, adminApplications, "operations.admin_enabled");
74
+
75
+ return {
76
+ port,
77
+ pm2Name,
78
+ enabled,
79
+ applications,
80
+ adminEnabled,
81
+ adminApplications,
82
+ allowHostWide: operations.allow_host_wide === true,
83
+ deployments: raw.deployments && typeof raw.deployments === "object" && !Array.isArray(raw.deployments) ? raw.deployments : {},
84
+ coolifyPromoteUuids: stringList(raw.coolify_promote_uuids, "coolify_promote_uuids"),
85
+ };
86
+ }
87
+
88
+ export function buildOpsServiceSpec({ config, rootDir }) {
89
+ const cli = path.join(rootDir, "cli", "index.js");
90
+ const runtimeEnv = Object.fromEntries([...RUNTIME_ENV_KEYS].flatMap((key) => process.env[key] == null ? [] : [[key, process.env[key]]]));
91
+ return {
92
+ name: config.pm2Name,
93
+ script: cli,
94
+ args: `serve foreground --port ${config.port} --isolated`,
95
+ cwd: rootDir,
96
+ autorestart: true,
97
+ restart_delay: 1000,
98
+ max_restarts: 20,
99
+ // Do not inherit the invoking agent or shell environment: PM2 otherwise
100
+ // stores every inherited value in its process metadata.
101
+ // PM2 only activates filtering for a non-empty array. Exclude every
102
+ // inherited key, then explicitly add the small runtime allowlist above.
103
+ filter_env: Object.keys(process.env),
104
+ env: {
105
+ ...runtimeEnv,
106
+ CLAUTH_OPS_INSTALLER_MARKER: INSTALLER_MARKER,
107
+ CLAUTH_OPS_ENABLED: [...config.enabled, ...(Object.keys(config.deployments).length ? ["deploy"] : []), ...(config.coolifyPromoteUuids.length ? ["coolify_promote"] : [])].join(","),
108
+ CLAUTH_OPS_APPLICATIONS: JSON.stringify(config.applications),
109
+ CLAUTH_OPS_ADMIN_ENABLED: config.adminEnabled.join(","),
110
+ CLAUTH_OPS_ADMIN_APPLICATIONS: JSON.stringify(config.adminApplications),
111
+ CLAUTH_OPS_ALLOW_HOST_WIDE: config.allowHostWide ? "1" : "0",
112
+ CLAUTH_OPS_DEPLOYMENTS: JSON.stringify(config.deployments),
113
+ CLAUTH_COOLIFY_PROMOTE_UUIDS: JSON.stringify(config.coolifyPromoteUuids),
114
+ },
115
+ };
116
+ }
117
+
118
+ function pm2Call(method, ...args) {
119
+ return new Promise((resolve, reject) => pm2[method](...args, (error, value) => error ? reject(error) : resolve(value)));
120
+ }
121
+
122
+ async function probe(endpoint) {
123
+ const health = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(5000) });
124
+ const healthBody = health.ok ? await health.json() : null;
125
+ if (healthBody?.status !== "ok") throw new Error(`health probe failed at ${endpoint}`);
126
+ const catalog = await fetch(`${endpoint}/v1/ops/catalog`, { signal: AbortSignal.timeout(5000) });
127
+ if (catalog.status !== 401) throw new Error(`bearer gate probe failed: expected HTTP 401, received ${catalog.status}`);
128
+ return { health_status: health.status, bearer_gate_status: catalog.status, listening_port: healthBody.listening_port, process_id: healthBody.process_id };
129
+ }
130
+
131
+ function argsFor(entry) {
132
+ const value = entry?.pm2_env?.args ?? entry?.args ?? "";
133
+ return Array.isArray(value) ? value.join(" ") : String(value || "");
134
+ }
135
+
136
+ function ownedService(entry, spec) {
137
+ const env = entry?.pm2_env || {};
138
+ return env.CLAUTH_OPS_INSTALLER_MARKER === INSTALLER_MARKER
139
+ && path.resolve(env.pm_exec_path || "") === path.resolve(spec.script)
140
+ && path.resolve(env.pm_cwd || "") === path.resolve(spec.cwd)
141
+ && argsFor(entry) === spec.args;
142
+ }
143
+
144
+ export function previousSpec(entry) {
145
+ const env = entry.pm2_env || {};
146
+ const restored = {
147
+ name: env.name,
148
+ script: env.pm_exec_path,
149
+ args: argsFor(entry),
150
+ cwd: env.pm_cwd,
151
+ autorestart: env.autorestart !== false,
152
+ restart_delay: env.restart_delay || 1000,
153
+ max_restarts: env.max_restarts || 20,
154
+ filter_env: Object.keys(process.env),
155
+ env: Object.fromEntries(Object.entries(env).filter(([key]) => OPS_ENV_KEYS.has(key) || RUNTIME_ENV_KEYS.has(key))),
156
+ };
157
+ if (env.exec_interpreter !== undefined) restored.interpreter = env.exec_interpreter;
158
+ for (const key of RESTORABLE_PM2_KEYS) if (env[key] !== undefined) restored[key] = env[key];
159
+ return restored;
160
+ }
161
+
162
+ async function rollback(previous, name) {
163
+ try { await pm2Call("delete", name); } catch {}
164
+ if (previous) await pm2Call("start", previous);
165
+ }
166
+
167
+ export async function runOpsInstall(opts = {}) {
168
+ const configPath = path.resolve(requiredString(opts.config, "--config"));
169
+ const config = loadOpsInstallConfig(configPath);
170
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
171
+ const spec = buildOpsServiceSpec({ config, rootDir });
172
+ const endpoint = `http://127.0.0.1:${config.port}`;
173
+ if (opts.dryRun) {
174
+ process.stdout.write(`${JSON.stringify({ action: "install", dry_run: true, platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, operations: Object.keys(config.applications).sort() }, null, 2)}\n`);
175
+ return;
176
+ }
177
+ await new Promise((resolve, reject) => pm2.connect(false, (error) => error ? reject(error) : resolve()));
178
+ let result;
179
+ let previous = null;
180
+ try {
181
+ const existing = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
182
+ // PM2 rejects an inline `env` object on restart-by-name. Replacing only
183
+ // this dedicated control-plane process is the supported way to apply a
184
+ // changed server policy without touching any managed application.
185
+ if (existing && !ownedService(existing, spec)) throw new Error(`refusing to replace non-installer PM2 process: ${config.pm2Name}`);
186
+ previous = existing ? previousSpec(existing) : null;
187
+ if (existing) await pm2Call("delete", config.pm2Name);
188
+ await pm2Call("start", spec);
189
+ for (let attempt = 0; attempt < 20; attempt += 1) {
190
+ try {
191
+ result = await probe(endpoint);
192
+ const current = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
193
+ const pid = current?.pid ?? current?.pm2_env?.pm_pid;
194
+ const status = current?.pm2_env?.status ?? current?.status;
195
+ if (status !== "online" || !Number.isInteger(pid) || result.process_id !== pid || result.listening_port !== config.port) {
196
+ throw new Error("new PM2 process does not own the verified control-plane endpoint");
197
+ }
198
+ break;
199
+ } catch (error) {
200
+ if (attempt === 19) throw error;
201
+ await new Promise((resolve) => setTimeout(resolve, 250));
202
+ }
203
+ }
204
+ } catch (error) {
205
+ await rollback(previous, config.pm2Name);
206
+ throw error;
207
+ } finally {
208
+ await new Promise((resolve) => pm2.disconnect(() => resolve()));
209
+ }
210
+ process.stdout.write(`${JSON.stringify({ action: "install", platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, health_status: result.health_status, bearer_gate_status: result.bearer_gate_status }, null, 2)}\n`);
211
+ }
@@ -0,0 +1,69 @@
1
+ const DEFAULT_LOCAL_CLAUTH = "http://127.0.0.1:52437";
2
+
3
+ function endpointFrom(opts) {
4
+ const approved = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
5
+ if (!approved) throw new Error("Ops endpoint unavailable: CLAUTH_OPS_APPROVED_ORIGIN is required");
6
+ const approvedUrl = new URL(approved);
7
+ const requested = opts.endpoint ? new URL(opts.endpoint) : approvedUrl;
8
+ if (approvedUrl.protocol !== "https:" || requested.origin !== approvedUrl.origin) throw new Error("Ops endpoint is not the approved Vultr origin");
9
+ return approvedUrl.toString().replace(/\/$/, "");
10
+ }
11
+
12
+ async function bearer(service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token") {
13
+ const response = await fetch(`${process.env.CLAUTH_LOCAL_URL || DEFAULT_LOCAL_CLAUTH}/v/${encodeURIComponent(service)}`);
14
+ if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
15
+ const token = (await response.text()).trim();
16
+ if (!token) throw new Error("vultr-ops-api-token is empty");
17
+ return token;
18
+ }
19
+
20
+ export async function requestOps({ endpoint, token, method, path, body }) {
21
+ const response = await fetch(`${endpoint}${path}`, {
22
+ method,
23
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
24
+ ...(body ? { body: JSON.stringify(body) } : {}),
25
+ });
26
+ const payload = await response.json().catch(() => ({ error: "invalid_json_response" }));
27
+ if (!response.ok) throw new Error(payload.error || `ops HTTP ${response.status}`);
28
+ return payload;
29
+ }
30
+
31
+ function print(value) {
32
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
33
+ }
34
+
35
+ export async function runOps(action, opts = {}) {
36
+ const endpoint = endpointFrom(opts);
37
+ const token = await bearer(opts.tokenService);
38
+ const request = (method, path, body) => requestOps({ endpoint, token, method, path, body });
39
+ if (action === "catalog") return print(await request("GET", "/v1/ops/catalog"));
40
+ if (action === "list") return print(await request("GET", "/v1/ops/processes"));
41
+ if (action === "describe") {
42
+ if (!opts.target) throw new Error("--target is required for describe");
43
+ return print(await request("GET", `/v1/ops/processes/${encodeURIComponent(opts.target)}`));
44
+ }
45
+ if (action === "run") {
46
+ if (!opts.operation) throw new Error("--operation is required for run");
47
+ const input = {
48
+ ...(opts.target ? { target: opts.target } : {}),
49
+ ...(opts.script ? { script: opts.script } : {}),
50
+ ...(opts.instances ? { instances: Number(opts.instances) } : {}),
51
+ ...(opts.argsJson ? { args: JSON.parse(opts.argsJson) } : {}),
52
+ ...(opts.optionsJson ? { options: JSON.parse(opts.optionsJson) } : {}),
53
+ };
54
+ return print(await request("POST", "/v1/ops/operations", { operation: opts.operation, input }));
55
+ }
56
+ if (action === "promote") {
57
+ if (!opts.application) throw new Error("--application is required for promote");
58
+ return print(await request("POST", "/v1/ops/promotions", { application_uuid: opts.application }));
59
+ }
60
+ if (action === "deploy") {
61
+ if (!opts.application) throw new Error("--application is required for deploy");
62
+ return print(await request("POST", "/v1/ops/deployments", { application: opts.application, ...(opts.ref ? { ref: opts.ref } : {}) }));
63
+ }
64
+ if (action === "job") {
65
+ if (!opts.job) throw new Error("--job is required for job");
66
+ return print(await request("GET", `/v1/ops/jobs/${encodeURIComponent(opts.job)}`));
67
+ }
68
+ throw new Error(`unknown ops action: ${action}`);
69
+ }