@lifeaitools/clauth 2.1.1 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/cli/api.js +238 -238
- package/cli/commands/install.js +413 -396
- package/cli/commands/login.test.js +49 -1
- package/cli/commands/ops-install.js +11 -10
- package/cli/commands/ops.test.js +270 -0
- package/cli/commands/scrub.test.js +70 -0
- package/cli/commands/serve.js +2613 -11219
- package/cli/commands/uninstall.js +164 -164
- package/cli/dashboard/dashboard.js +592 -0
- package/cli/dashboard/global.css +65 -0
- package/cli/dashboard/panel-element.js +79 -0
- package/cli/dashboard/panels/build-status-panel.css +12 -0
- package/cli/dashboard/panels/build-status-panel.js +98 -0
- package/cli/dashboard/panels/panel-registry.js +58 -0
- package/cli/dashboard/panels/services-panel.css +95 -0
- package/cli/dashboard/panels/services-panel.js +872 -0
- package/cli/dashboard/panels/surfaces-panel.css +59 -0
- package/cli/dashboard/panels/surfaces-panel.js +474 -0
- package/cli/dashboard/panels/toolbar-panel.css +42 -0
- package/cli/dashboard/panels/toolbar-panel.js +374 -0
- package/cli/dashboard/panels/tunnel-panel.css +61 -0
- package/cli/dashboard/panels/tunnel-panel.js +688 -0
- package/cli/dashboard/panels/webdav-panel.css +38 -0
- package/cli/dashboard/panels/webdav-panel.js +166 -0
- package/cli/fingerprint.test.js +95 -0
- package/cli/http/components/callagent-terminal-component.js +310 -0
- package/cli/http/components/dashboard-component.js +123 -0
- package/cli/http/components/inbox-component.js +41 -0
- package/cli/http/components/mcp-transport-component.js +275 -0
- package/cli/http/components/oauth-component.js +207 -0
- package/cli/http/components/service-crud-component.js +288 -0
- package/cli/http/components/supervisor-component.js +104 -0
- package/cli/http/components/system-component.js +184 -0
- package/cli/http/components/tunnel-component.js +508 -0
- package/cli/http/components/vault-auth-component.js +264 -0
- package/cli/http/components/watchdog-component.js +28 -0
- package/cli/http/request-utils.js +21 -0
- package/cli/http/servers/ops-server.js +335 -0
- package/cli/index.js +5 -91
- package/cli/lib/fs-git.test.js +467 -0
- package/cli/mcp/providers/call-agent-provider.js +30 -0
- package/cli/mcp/providers/clauth-core-provider.js +461 -0
- package/cli/mcp/providers/ops-provider.js +42 -0
- package/cli/mcp/providers/terminal-provider.js +81 -0
- package/cli/mcp/tool-registry.js +48 -0
- package/cli/mcp/tool-schemas.js +822 -0
- package/cli/mcp/tool-schemas.test.js +49 -0
- package/cli/ops/pm2-preflight.js +54 -0
- package/cli/recovery.js +6 -0
- package/cli/recovery.test.js +179 -0
- package/cli/services/agent-pool-instance.js +38 -0
- package/cli/services/claude-binary.js +29 -0
- package/cli/services/delegation-lane-instance.js +35 -0
- package/cli/services/delegation-lane-instance.test.js +37 -0
- package/cli/services/file-io.js +38 -0
- package/cli/services/file-io.test.js +10 -0
- package/cli/services/fs-mount.js +66 -0
- package/cli/services/fs-mount.test.js +14 -0
- package/cli/services/fs-upload-sessions.js +20 -0
- package/cli/services/fs-upload-sessions.test.js +14 -0
- package/cli/services/git-exec.js +37 -0
- package/cli/services/git-exec.test.js +41 -0
- package/cli/services/git-import-guard.js +28 -0
- package/cli/services/git-import-guard.test.js +18 -0
- package/cli/services/service-status.js +191 -0
- package/cli/services/service-status.test.js +39 -0
- package/cli/services/terminal-dispatch.js +135 -0
- package/cli/services/terminal-dispatch.test.js +9 -0
- package/cli/services/terminal-registry.js +39 -0
- package/cli/services/terminal-registry.test.js +31 -0
- package/cli/services/terminal-sessions.js +100 -0
- package/cli/services/terminal-sessions.test.js +26 -0
- package/cli/services/terminal-window.js +106 -0
- package/cli/services/vault-read.js +14 -0
- package/cli/services/vault-read.test.js +35 -0
- package/cli/supervisor-registry.js +116 -0
- package/cli/supervisor-registry.test.js +165 -0
- package/cli/supervisor-ui.test.js +230 -30
- package/cli/webdav-config.js +84 -0
- package/cli/webdav-config.test.js +79 -0
- package/cli/webdav-obscure.js +23 -0
- package/cli/webdav-obscure.test.js +41 -0
- package/cli/webdav-rclone-discovery.js +44 -0
- package/cli/webdav-service.js +30 -118
- package/cli/webdav-service.test.js +107 -0
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +2 -2
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
- package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
- package/cli/commands/codevelop.js +0 -1190
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import test from "node:test";
|
|
4
4
|
|
|
5
|
-
import { LOGIN_TARGETS, listTargets, resolveEnvRoot, resolveInvocation } from "./login.js";
|
|
5
|
+
import { LOGIN_TARGETS, listTargets, resolveEnvRoot, resolveInvocation, findBash, printTargetList } from "./login.js";
|
|
6
6
|
|
|
7
7
|
test("listTargets exposes vultr with its clauth service + default host", () => {
|
|
8
8
|
const targets = listTargets();
|
|
@@ -71,3 +71,51 @@ test("resolveInvocation only switches to the native Windows engine when explicit
|
|
|
71
71
|
assert.match(inv.args[2], /ssh-clauth\.ps1$/);
|
|
72
72
|
assert.deepEqual(inv.args.slice(3, 5), ["-Service", "vultr-dev-ssh"]);
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
test("findBash returns the first probe-successful candidate", () => {
|
|
76
|
+
const calls = [];
|
|
77
|
+
const probe = (bin) => { calls.push(bin); return { status: bin === "bash" ? 0 : 1 }; };
|
|
78
|
+
assert.equal(findBash("linux", probe), "bash");
|
|
79
|
+
assert.deepEqual(calls, ["bash"], "posix has exactly one candidate — bash itself");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("findBash on win32 tries Git Bash's absolute path before the bare 'bash' shim", () => {
|
|
83
|
+
const calls = [];
|
|
84
|
+
const probe = (bin) => { calls.push(bin); return { status: bin.includes("Git") ? 0 : 1 }; };
|
|
85
|
+
const result = findBash("win32", probe);
|
|
86
|
+
// Written via String.raw: a formatter previously collapsed a literal
|
|
87
|
+
// "\\P"/"\\G"/"\\b" double-backslash down to a single backslash, and "\b" in
|
|
88
|
+
// a normal JS string is the BACKSPACE control character, not "backslash b" —
|
|
89
|
+
// the assertion then compared a real path against a string full of control
|
|
90
|
+
// bytes. String.raw is immune to that class of silent corruption.
|
|
91
|
+
const expected = String.raw`C:\Program Files\Git\bin\bash.exe`;
|
|
92
|
+
assert.equal(result, expected);
|
|
93
|
+
assert.deepEqual(calls, [expected], "must not probe the fallback once the first candidate succeeds");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("findBash falls back to the bare 'bash' shim when Git Bash's absolute path fails", () => {
|
|
97
|
+
const probe = (bin) => (bin === "bash" ? { status: 0 } : { status: 1 });
|
|
98
|
+
assert.equal(findBash("win32", probe), "bash");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("findBash returns 'bash' as the last resort even when every probe throws", () => {
|
|
102
|
+
const probe = () => { throw new Error("spawnSync ENOENT"); };
|
|
103
|
+
assert.equal(findBash("win32", probe), "bash");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("findBash treats a non-zero exit status as a failed probe, not a crash", () => {
|
|
107
|
+
const probe = () => ({ status: 127 });
|
|
108
|
+
assert.equal(findBash("linux", probe), "bash", "every candidate reported non-zero, so the final fallback must still be returned, not undefined");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("printTargetList prints every registered target's name, host, and label", () => {
|
|
112
|
+
const lines = [];
|
|
113
|
+
printTargetList((line) => lines.push(line));
|
|
114
|
+
const text = lines.join("\n");
|
|
115
|
+
for (const t of listTargets()) {
|
|
116
|
+
assert.ok(text.includes(t.name), `missing target name ${t.name}`);
|
|
117
|
+
assert.ok(text.includes(t.host), `missing host for ${t.name}`);
|
|
118
|
+
assert.ok(text.includes(t.label), `missing label for ${t.name}`);
|
|
119
|
+
}
|
|
120
|
+
assert.match(text, /clauth login vultr/, "usage examples must be present");
|
|
121
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import
|
|
4
|
+
import { ensurePm2 } from "../ops/pm2-preflight.js";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_PORT = 52439;
|
|
7
7
|
const DEFAULT_NAME = "clauth-deployment";
|
|
@@ -115,7 +115,7 @@ export function buildOpsServiceSpec({ config, rootDir }) {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
function pm2Call(method, ...args) {
|
|
118
|
+
function pm2Call(pm2, method, ...args) {
|
|
119
119
|
return new Promise((resolve, reject) => pm2[method](...args, (error, value) => error ? reject(error) : resolve(value)));
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -159,9 +159,9 @@ export function previousSpec(entry) {
|
|
|
159
159
|
return restored;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
async function rollback(previous, name) {
|
|
163
|
-
try { await pm2Call("delete", name); } catch {}
|
|
164
|
-
if (previous) await pm2Call("start", previous);
|
|
162
|
+
async function rollback(pm2, previous, name) {
|
|
163
|
+
try { await pm2Call(pm2, "delete", name); } catch {}
|
|
164
|
+
if (previous) await pm2Call(pm2, "start", previous);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
export async function runOpsInstall(opts = {}) {
|
|
@@ -174,22 +174,23 @@ export async function runOpsInstall(opts = {}) {
|
|
|
174
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
175
|
return;
|
|
176
176
|
}
|
|
177
|
+
const pm2 = await ensurePm2();
|
|
177
178
|
await new Promise((resolve, reject) => pm2.connect(false, (error) => error ? reject(error) : resolve()));
|
|
178
179
|
let result;
|
|
179
180
|
let previous = null;
|
|
180
181
|
try {
|
|
181
|
-
const existing = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
182
|
+
const existing = (await pm2Call(pm2, "list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
182
183
|
// PM2 rejects an inline `env` object on restart-by-name. Replacing only
|
|
183
184
|
// this dedicated control-plane process is the supported way to apply a
|
|
184
185
|
// changed server policy without touching any managed application.
|
|
185
186
|
if (existing && !ownedService(existing, spec)) throw new Error(`refusing to replace non-installer PM2 process: ${config.pm2Name}`);
|
|
186
187
|
previous = existing ? previousSpec(existing) : null;
|
|
187
|
-
if (existing) await pm2Call("delete", config.pm2Name);
|
|
188
|
-
await pm2Call("start", spec);
|
|
188
|
+
if (existing) await pm2Call(pm2, "delete", config.pm2Name);
|
|
189
|
+
await pm2Call(pm2, "start", spec);
|
|
189
190
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
190
191
|
try {
|
|
191
192
|
result = await probe(endpoint);
|
|
192
|
-
const current = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
193
|
+
const current = (await pm2Call(pm2, "list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
193
194
|
const pid = current?.pid ?? current?.pm2_env?.pm_pid;
|
|
194
195
|
const status = current?.pm2_env?.status ?? current?.status;
|
|
195
196
|
if (status !== "online" || !Number.isInteger(pid) || result.process_id !== pid || result.listening_port !== config.port) {
|
|
@@ -202,7 +203,7 @@ export async function runOpsInstall(opts = {}) {
|
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
} catch (error) {
|
|
205
|
-
await rollback(previous, config.pm2Name);
|
|
206
|
+
await rollback(pm2, previous, config.pm2Name);
|
|
206
207
|
throw error;
|
|
207
208
|
} finally {
|
|
208
209
|
await new Promise((resolve) => pm2.disconnect(() => resolve()));
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { runOps, requestOps } from "./ops.js";
|
|
5
|
+
|
|
6
|
+
const APPROVED = "https://ops.example.regendevcorp.com";
|
|
7
|
+
|
|
8
|
+
function withEnv(vars, fn) {
|
|
9
|
+
const old = {};
|
|
10
|
+
// `process.env.X = undefined` does NOT delete a var — it coerces to the
|
|
11
|
+
// literal string "undefined", which endpointFrom() then feeds to `new URL()`
|
|
12
|
+
// and gets a URL-parse error instead of the "is required" guard. A var must
|
|
13
|
+
// be deleted, never assigned undefined, to simulate "not set".
|
|
14
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
15
|
+
old[k] = process.env[k];
|
|
16
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
17
|
+
}
|
|
18
|
+
return (async () => {
|
|
19
|
+
try { return await fn(); } finally {
|
|
20
|
+
for (const [k, v] of Object.entries(old)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function withMockFetch(handler, fn) {
|
|
26
|
+
const old = globalThis.fetch;
|
|
27
|
+
globalThis.fetch = handler;
|
|
28
|
+
return (async () => { try { return await fn(); } finally { globalThis.fetch = old; } })();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jsonRes(status, body) {
|
|
32
|
+
return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textRes(status, text) {
|
|
36
|
+
return { ok: status >= 200 && status < 300, status, text: async () => text };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function captureStdout(fn) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
const orig = process.stdout.write;
|
|
42
|
+
process.stdout.write = (chunk) => { chunks.push(chunk); return true; };
|
|
43
|
+
return (async () => {
|
|
44
|
+
try { await fn(); } finally { process.stdout.write = orig; }
|
|
45
|
+
return chunks.join("");
|
|
46
|
+
})();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── requestOps: the raw HTTP layer ──────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
test("requestOps sends a bearer + JSON body and returns the parsed payload", async () => {
|
|
52
|
+
let seen = null;
|
|
53
|
+
await withMockFetch(
|
|
54
|
+
async (url, opts) => { seen = { url: String(url), opts }; return jsonRes(200, { ok: true, echoed: JSON.parse(opts.body) }); },
|
|
55
|
+
async () => {
|
|
56
|
+
const result = await requestOps({ endpoint: "https://ops.example", token: "tok-123", method: "POST", path: "/v1/ops/deployments", body: { application: "app-1" } });
|
|
57
|
+
assert.equal(result.echoed.application, "app-1");
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
assert.equal(seen.url, "https://ops.example/v1/ops/deployments");
|
|
61
|
+
assert.equal(seen.opts.headers.Authorization, "Bearer tok-123");
|
|
62
|
+
assert.equal(seen.opts.headers["Content-Type"], "application/json");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("requestOps omits Content-Type on a bodyless GET", async () => {
|
|
66
|
+
let seen = null;
|
|
67
|
+
await withMockFetch(
|
|
68
|
+
async (url, opts) => { seen = opts; return jsonRes(200, { ok: true }); },
|
|
69
|
+
async () => requestOps({ endpoint: "https://ops.example", token: "t", method: "GET", path: "/v1/ops/catalog" }),
|
|
70
|
+
);
|
|
71
|
+
assert.equal(seen.headers["Content-Type"], undefined);
|
|
72
|
+
assert.equal(seen.body, undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("requestOps throws the server's error string on a non-2xx response", async () => {
|
|
76
|
+
await withMockFetch(
|
|
77
|
+
async () => jsonRes(403, { error: "forbidden_target" }),
|
|
78
|
+
async () => {
|
|
79
|
+
await assert.rejects(
|
|
80
|
+
() => requestOps({ endpoint: "https://ops.example", token: "t", method: "GET", path: "/v1/ops/list" }),
|
|
81
|
+
/forbidden_target/,
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("requestOps reports invalid_json_response rather than throwing when the error body is not JSON", async () => {
|
|
88
|
+
// .json().catch(() => ({ error: "invalid_json_response" })) means a non-JSON
|
|
89
|
+
// error body still produces a payload with an `error` field — which
|
|
90
|
+
// `payload.error || ...` then picks over the HTTP-status fallback string.
|
|
91
|
+
await withMockFetch(
|
|
92
|
+
async () => ({ ok: false, status: 502, json: async () => { throw new Error("not json"); } }),
|
|
93
|
+
async () => {
|
|
94
|
+
await assert.rejects(
|
|
95
|
+
() => requestOps({ endpoint: "https://ops.example", token: "t", method: "GET", path: "/x" }),
|
|
96
|
+
/invalid_json_response/,
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── runOps: endpoint approval + bearer retrieval + action routing ──────────
|
|
103
|
+
|
|
104
|
+
test("runOps refuses when CLAUTH_OPS_APPROVED_ORIGIN is not set — fails closed, not open", async () => {
|
|
105
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: undefined }, async () => {
|
|
106
|
+
await assert.rejects(() => runOps("catalog", {}), /CLAUTH_OPS_APPROVED_ORIGIN is required/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("runOps refuses a caller-supplied endpoint that is not the approved origin", async () => {
|
|
111
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => runOps("catalog", { endpoint: "https://evil.example.com" }),
|
|
114
|
+
/not the approved Vultr origin/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("runOps refuses an approved origin that is not https — this is the control-plane bearer, never plaintext", async () => {
|
|
120
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: "http://ops.example.regendevcorp.com" }, async () => {
|
|
121
|
+
await assert.rejects(() => runOps("catalog", {}), /not the approved Vultr origin/);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("runOps fetches the bearer from the LOCAL clauth vault, never inline, and sends it to the approved origin", async () => {
|
|
126
|
+
const calls = [];
|
|
127
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
128
|
+
await withMockFetch(
|
|
129
|
+
async (url) => {
|
|
130
|
+
calls.push(String(url));
|
|
131
|
+
if (String(url).includes("/v/vultr-ops-api-token")) return textRes(200, " secret-bearer-value \n");
|
|
132
|
+
return jsonRes(200, { catalog: [] });
|
|
133
|
+
},
|
|
134
|
+
async () => {
|
|
135
|
+
const out = await captureStdout(() => runOps("catalog", {}));
|
|
136
|
+
assert.match(out, /"catalog"/);
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
assert.ok(calls.some((u) => u.includes("127.0.0.1:52437/v/vultr-ops-api-token")), "must fetch from the local clauth daemon");
|
|
141
|
+
assert.ok(calls.some((u) => u.startsWith(APPROVED)), "must call the approved origin, not local clauth, for the actual op");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("runOps refuses when the vault has no bearer for the requested service", async () => {
|
|
145
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
146
|
+
await withMockFetch(
|
|
147
|
+
async () => textRes(404, ""),
|
|
148
|
+
async () => {
|
|
149
|
+
await assert.rejects(() => runOps("catalog", {}), /vultr-ops-api-token unavailable/);
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("runOps refuses when the vault returns an empty token", async () => {
|
|
156
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
157
|
+
await withMockFetch(
|
|
158
|
+
async () => textRes(200, " "),
|
|
159
|
+
async () => {
|
|
160
|
+
await assert.rejects(() => runOps("catalog", {}), /is empty/);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const actionFixtures = [
|
|
167
|
+
["list", {}, "GET", "/v1/ops/processes"],
|
|
168
|
+
["describe", { target: "dev-center" }, "GET", "/v1/ops/processes/dev-center"],
|
|
169
|
+
["job", { job: "job-42" }, "GET", "/v1/ops/jobs/job-42"],
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
for (const [action, opts, method, path] of actionFixtures) {
|
|
173
|
+
test(`runOps "${action}" issues ${method} ${path}`, async () => {
|
|
174
|
+
let seen = null;
|
|
175
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
176
|
+
await withMockFetch(
|
|
177
|
+
async (url, fetchOpts) => {
|
|
178
|
+
if (String(url).includes("/v/")) return textRes(200, "tok");
|
|
179
|
+
seen = { url: String(url), method: fetchOpts.method };
|
|
180
|
+
return jsonRes(200, { ok: true });
|
|
181
|
+
},
|
|
182
|
+
async () => { await captureStdout(() => runOps(action, opts)); },
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
assert.equal(seen.method, method);
|
|
186
|
+
assert.equal(seen.url, `${APPROVED}${path}`);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test('runOps "describe" requires --target', async () => {
|
|
191
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
192
|
+
await withMockFetch(
|
|
193
|
+
async (url) => (String(url).includes("/v/") ? textRes(200, "tok") : jsonRes(200, {})),
|
|
194
|
+
async () => { await assert.rejects(() => runOps("describe", {}), /--target is required/); },
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('runOps "run" builds the operation input from target/script/instances/argsJson/optionsJson', async () => {
|
|
200
|
+
let body = null;
|
|
201
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
202
|
+
await withMockFetch(
|
|
203
|
+
async (url, opts) => {
|
|
204
|
+
if (String(url).includes("/v/")) return textRes(200, "tok");
|
|
205
|
+
body = JSON.parse(opts.body);
|
|
206
|
+
return jsonRes(200, { ok: true });
|
|
207
|
+
},
|
|
208
|
+
async () => {
|
|
209
|
+
await captureStdout(() => runOps("run", {
|
|
210
|
+
operation: "restart", target: "dev-center", instances: "2",
|
|
211
|
+
argsJson: '["--force"]', optionsJson: '{"wait":true}',
|
|
212
|
+
}));
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
assert.equal(body.operation, "restart");
|
|
217
|
+
assert.equal(body.input.target, "dev-center");
|
|
218
|
+
assert.equal(body.input.instances, 2, "instances must be coerced to a number");
|
|
219
|
+
assert.deepEqual(body.input.args, ["--force"]);
|
|
220
|
+
assert.deepEqual(body.input.options, { wait: true });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('runOps "run" requires --operation', async () => {
|
|
224
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
225
|
+
await withMockFetch(
|
|
226
|
+
async (url) => (String(url).includes("/v/") ? textRes(200, "tok") : jsonRes(200, {})),
|
|
227
|
+
async () => { await assert.rejects(() => runOps("run", {}), /--operation is required/); },
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('runOps "promote" and "deploy" require --application', async () => {
|
|
233
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
234
|
+
await withMockFetch(
|
|
235
|
+
async (url) => (String(url).includes("/v/") ? textRes(200, "tok") : jsonRes(200, {})),
|
|
236
|
+
async () => {
|
|
237
|
+
await assert.rejects(() => runOps("promote", {}), /--application is required for promote/);
|
|
238
|
+
await assert.rejects(() => runOps("deploy", {}), /--application is required for deploy/);
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('runOps "deploy" includes ref only when supplied', async () => {
|
|
245
|
+
const bodies = [];
|
|
246
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
247
|
+
await withMockFetch(
|
|
248
|
+
async (url, opts) => {
|
|
249
|
+
if (String(url).includes("/v/")) return textRes(200, "tok");
|
|
250
|
+
bodies.push(JSON.parse(opts.body));
|
|
251
|
+
return jsonRes(200, { ok: true });
|
|
252
|
+
},
|
|
253
|
+
async () => {
|
|
254
|
+
await captureStdout(() => runOps("deploy", { application: "app-uuid" }));
|
|
255
|
+
await captureStdout(() => runOps("deploy", { application: "app-uuid", ref: "abc123" }));
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
assert.equal("ref" in bodies[0], false, "ref must be omitted, not sent as undefined, when not supplied");
|
|
260
|
+
assert.equal(bodies[1].ref, "abc123");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("runOps rejects an unknown action", async () => {
|
|
264
|
+
await withEnv({ CLAUTH_OPS_APPROVED_ORIGIN: APPROVED }, async () => {
|
|
265
|
+
await withMockFetch(
|
|
266
|
+
async (url) => (String(url).includes("/v/") ? textRes(200, "tok") : jsonRes(200, {})),
|
|
267
|
+
async () => { await assert.rejects(() => runOps("teleport", {}), /unknown ops action: teleport/); },
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isSecretLike,
|
|
13
13
|
loadExtraPatterns,
|
|
14
14
|
sessionTargets,
|
|
15
|
+
fetchVaultValues,
|
|
15
16
|
} from "./scrub.js";
|
|
16
17
|
|
|
17
18
|
const GH_TOKEN = "ghp_0123456789abcdefABCDEF0123456789abcd"; // ghp_ + 36 chars
|
|
@@ -113,3 +114,72 @@ test("loadExtraPatterns tolerates a missing/malformed file", () => {
|
|
|
113
114
|
assert.deepEqual(loadExtraPatterns(bad), [], "malformed json → empty, never throws");
|
|
114
115
|
fs.rmSync(bad, { force: true });
|
|
115
116
|
});
|
|
117
|
+
|
|
118
|
+
test("scrubFile skips an already-scrubbed file, and --force overrides the skip", () => {
|
|
119
|
+
const { jsonl } = seedProjects();
|
|
120
|
+
const first = scrubFile(jsonl);
|
|
121
|
+
assert.ok(typeof first === "number" && first > 0, "first pass must find and redact real secrets");
|
|
122
|
+
|
|
123
|
+
const second = scrubFile(jsonl);
|
|
124
|
+
assert.equal(second, "skipped", "a file carrying the scrub marker must not be rescanned");
|
|
125
|
+
|
|
126
|
+
// The GH token pattern is deterministic — re-seed a matching literal so a
|
|
127
|
+
// force-rescan has something real to find, proving --force actually
|
|
128
|
+
// bypasses the marker rather than the file simply having nothing left.
|
|
129
|
+
fs.appendFileSync(jsonl, `still-here=${GH_TOKEN}\n`, "utf-8");
|
|
130
|
+
const forced = scrubFile(jsonl, { force: true });
|
|
131
|
+
assert.ok(typeof forced === "number" && forced > 0, "force:true must rescan and find the freshly-appended secret");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("fetchVaultValues returns [] when the daemon is unreachable — regex-only fallback, never a throw", async () => {
|
|
135
|
+
const values = await fetchVaultValues("http://127.0.0.1:1");
|
|
136
|
+
assert.deepEqual(values, []);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("fetchVaultValues filters to secret-like values and skips services with has_key:false", async () => {
|
|
140
|
+
const oldFetch = globalThis.fetch;
|
|
141
|
+
const calls = [];
|
|
142
|
+
globalThis.fetch = async (url) => {
|
|
143
|
+
calls.push(String(url));
|
|
144
|
+
if (String(url).endsWith("/knowledge")) {
|
|
145
|
+
return { ok: true, json: async () => ({ services: [
|
|
146
|
+
{ name: "real-secret", has_key: true },
|
|
147
|
+
{ name: "no-key-yet", has_key: false },
|
|
148
|
+
{ name: "looks-like-a-url", has_key: true },
|
|
149
|
+
] }) };
|
|
150
|
+
}
|
|
151
|
+
if (String(url).includes("/v/real-secret")) return { ok: true, text: async () => " ActualSecretValue123XYZ " };
|
|
152
|
+
if (String(url).includes("/v/looks-like-a-url")) return { ok: true, text: async () => "https://example.com/not-a-secret" };
|
|
153
|
+
return { ok: false, text: async () => "" };
|
|
154
|
+
};
|
|
155
|
+
try {
|
|
156
|
+
const values = await fetchVaultValues("http://127.0.0.1:9");
|
|
157
|
+
assert.deepEqual(values, [{ name: "real-secret", value: "ActualSecretValue123XYZ" }]);
|
|
158
|
+
assert.ok(!calls.some((u) => u.includes("/v/no-key-yet")), "has_key:false must never even be fetched");
|
|
159
|
+
} finally {
|
|
160
|
+
globalThis.fetch = oldFetch;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("fetchVaultValues returns [] when /knowledge itself responds non-ok", async () => {
|
|
165
|
+
const oldFetch = globalThis.fetch;
|
|
166
|
+
globalThis.fetch = async () => ({ ok: false });
|
|
167
|
+
try {
|
|
168
|
+
assert.deepEqual(await fetchVaultValues("http://127.0.0.1:9"), []);
|
|
169
|
+
} finally {
|
|
170
|
+
globalThis.fetch = oldFetch;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("fetchVaultValues skips (not throws) a single service whose /v/ fetch rejects", async () => {
|
|
175
|
+
const oldFetch = globalThis.fetch;
|
|
176
|
+
globalThis.fetch = async (url) => {
|
|
177
|
+
if (String(url).endsWith("/knowledge")) return { ok: true, json: async () => ({ services: [{ name: "flaky", has_key: true }] }) };
|
|
178
|
+
throw new Error("network blip");
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
assert.deepEqual(await fetchVaultValues("http://127.0.0.1:9"), []);
|
|
182
|
+
} finally {
|
|
183
|
+
globalThis.fetch = oldFetch;
|
|
184
|
+
}
|
|
185
|
+
});
|