@lifeaitools/clauth 1.30.26 → 1.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clauth-skill/SKILL.md +0 -31
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/.clauth-skill/references/operator-guide.md +0 -27
- package/README.md +2 -48
- package/cli/api.js +238 -238
- package/cli/commands/agent-pool.js +51 -15
- package/cli/commands/install.js +396 -396
- package/cli/commands/login.js +135 -0
- package/cli/commands/login.test.js +73 -0
- package/cli/commands/serve.js +16 -846
- package/cli/commands/uninstall.js +164 -164
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +29 -20
- package/cli/supervisor-registry.js +1 -6
- package/cli/watchdog-registry.js +2 -30
- package/cli/watchdog-registry.test.js +5 -28
- package/cli/webdav-service.js +339 -339
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +4 -6
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/scripts/bootstrap.cjs +121 -121
- package/scripts/build.mjs +66 -0
- package/scripts/build.sh +5 -45
- package/scripts/postinstall.js +189 -189
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
- package/cli/commands/ops-install.js +0 -211
- package/cli/commands/ops.js +0 -69
- package/cli/ops/coolify-adapter.js +0 -80
- package/cli/ops/deployment-adapter.js +0 -63
- package/cli/ops/job-store.js +0 -116
- package/cli/ops/operation-policy.js +0 -51
- package/cli/ops/pm2-adapter.js +0 -128
- package/cli/ops/serialized-executor.js +0 -9
package/README.md
CHANGED
|
@@ -102,6 +102,8 @@ clauth write pw Change password
|
|
|
102
102
|
clauth enable <svc|all> Activate service
|
|
103
103
|
clauth disable <svc|all> Suspend service
|
|
104
104
|
clauth get <service> Retrieve a key
|
|
105
|
+
clauth login <target> SSH into a managed box by name (e.g. `vultr`) — never
|
|
106
|
+
hand-fetch a key + hand-roll ssh yourself
|
|
105
107
|
clauth npm whoami Verify npm token without PowerShell secret plumbing
|
|
106
108
|
clauth npm sync-github-secret LIFEAI/rdc-skills
|
|
107
109
|
Update GitHub NPM_TOKEN from clauth
|
|
@@ -162,54 +164,6 @@ description, timestamps, and `has_key`. It never returns raw secrets or Vault
|
|
|
162
164
|
secret names. Use `GET /v/<service>` only for the one exact secret needed by the
|
|
163
165
|
next command. Do not bulk-fetch every service.
|
|
164
166
|
|
|
165
|
-
### PM2 deployment control-plane installer
|
|
166
|
-
|
|
167
|
-
On a managed host, store the allowed PM2 targets and deployment definitions in a
|
|
168
|
-
server-side JSON file, then install or update the supervised endpoint:
|
|
169
|
-
|
|
170
|
-
```bash
|
|
171
|
-
# Linux
|
|
172
|
-
clauth ops install --config /etc/clauth/ops-control-plane.json
|
|
173
|
-
|
|
174
|
-
# Windows PowerShell
|
|
175
|
-
clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
The installer is idempotent on Linux and Windows: it creates or updates the
|
|
179
|
-
`clauth-deployment` PM2 process, probes its loopback health endpoint, and proves
|
|
180
|
-
that the operations catalog refuses requests without a bearer token. It does not
|
|
181
|
-
store or print a bearer token. Use `--dry-run` to validate the policy file before
|
|
182
|
-
changing PM2.
|
|
183
|
-
|
|
184
|
-
The policy is an explicit application allowlist; keep `allow_host_wide` false
|
|
185
|
-
unless a host-wide administrative operation has been deliberately approved. A
|
|
186
|
-
minimal local policy looks like:
|
|
187
|
-
|
|
188
|
-
```json
|
|
189
|
-
{
|
|
190
|
-
"port": 53141,
|
|
191
|
-
"pm2_name": "clauth-deployment",
|
|
192
|
-
"operations": {
|
|
193
|
-
"agent_enabled": ["restart", "reload"],
|
|
194
|
-
"agent_applications": {
|
|
195
|
-
"restart": ["codeflow-mcp"],
|
|
196
|
-
"reload": ["codeflow-mcp"]
|
|
197
|
-
},
|
|
198
|
-
"allow_host_wide": false
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
Verify the policy before installation, then verify the loopback service without
|
|
204
|
-
ever placing a bearer in a shell command:
|
|
205
|
-
|
|
206
|
-
```bash
|
|
207
|
-
clauth ops install --config <policy-path> --dry-run
|
|
208
|
-
clauth ops install --config <policy-path>
|
|
209
|
-
curl -i http://127.0.0.1:<port>/health
|
|
210
|
-
curl -i http://127.0.0.1:<port>/v1/ops/catalog # must return 401 without a bearer
|
|
211
|
-
```
|
|
212
|
-
|
|
213
167
|
---
|
|
214
168
|
|
|
215
169
|
## MCP Server — 5 Namespaces, 63 Tools
|
package/cli/api.js
CHANGED
|
@@ -1,238 +1,238 @@
|
|
|
1
|
-
// cli/api.js
|
|
2
|
-
// Thin client that calls the auth-vault Edge Function
|
|
3
|
-
|
|
4
|
-
import { createRequire } from "module";
|
|
5
|
-
const require = createRequire(import.meta.url);
|
|
6
|
-
|
|
7
|
-
import Conf from "conf";
|
|
8
|
-
import { getConfOptions } from "./conf-path.js";
|
|
9
|
-
|
|
10
|
-
const config = new Conf(getConfOptions());
|
|
11
|
-
|
|
12
|
-
// ============================================================
|
|
13
|
-
// Get Edge Function base URL from local config
|
|
14
|
-
// ============================================================
|
|
15
|
-
export function getBaseUrl() {
|
|
16
|
-
const url = config.get("supabase_url") || process.env.CLAUTH_SUPABASE_URL;
|
|
17
|
-
if (!url) throw new Error("Supabase URL not configured. Run: clauth setup");
|
|
18
|
-
return `${url}/functions/v1/auth-vault`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function getAnonKey() {
|
|
22
|
-
const key = config.get("supabase_anon_key") || process.env.CLAUTH_SUPABASE_ANON_KEY;
|
|
23
|
-
if (!key) throw new Error("Supabase anon key not configured. Run: clauth setup");
|
|
24
|
-
return key;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ============================================================
|
|
28
|
-
// Core POST helper
|
|
29
|
-
// ============================================================
|
|
30
|
-
// Bound every vault round-trip with a wall-clock timeout. Without this, a TCP
|
|
31
|
-
// stall (DNS black-hole, half-open socket — NOT a clean 4xx) hangs the caller
|
|
32
|
-
// forever. Callers that treat persistence as best-effort (e.g. the call_agent
|
|
33
|
-
// scratchpad mirror) rely on this resolving to an error rather than blocking.
|
|
34
|
-
// Override via CLAUTH_VAULT_TIMEOUT_MS; default 8s.
|
|
35
|
-
const VAULT_FETCH_TIMEOUT_MS = (() => {
|
|
36
|
-
const n = Number(process.env.CLAUTH_VAULT_TIMEOUT_MS);
|
|
37
|
-
return Number.isFinite(n) && n > 0 ? n : 8000;
|
|
38
|
-
})();
|
|
39
|
-
|
|
40
|
-
// ============================================================
|
|
41
|
-
// Backend / external-resource error model
|
|
42
|
-
// ============================================================
|
|
43
|
-
// A VaultBackendError means the vault backend (the auth-vault Edge Function or
|
|
44
|
-
// the Postgres database behind it) never rendered an auth verdict — the request
|
|
45
|
-
// timed out, the network failed, the function 5xx'd, the DB was unreachable, or
|
|
46
|
-
// we were rate-limited. These are TRANSIENT and must NEVER be counted as an
|
|
47
|
-
// authentication failure (a wrong password). Treating a database timeout as a
|
|
48
|
-
// bad password is the bug that turned a DB blip into a permanent vault lockout.
|
|
49
|
-
export class VaultBackendError extends Error {
|
|
50
|
-
constructor(kind, message, { status = null, detail = null, cause = null } = {}) {
|
|
51
|
-
super(message);
|
|
52
|
-
this.name = "VaultBackendError";
|
|
53
|
-
this.kind = kind; // timeout | network | server_error | rate_limited | db_error | unknown
|
|
54
|
-
this.isBackend = true; // marker for callers: do not strike
|
|
55
|
-
this.retriable = true; // the verdict was never rendered — safe to retry
|
|
56
|
-
this.status = status; // HTTP status if the server responded
|
|
57
|
-
this.detail = detail; // human-readable backend detail
|
|
58
|
-
if (cause) this.cause = cause;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Server `reason` strings that are genuine AUTH VERDICTS (the backend evaluated
|
|
63
|
-
// the credential and answered). These DO count — they are not backend errors.
|
|
64
|
-
const AUTH_VERDICT_REASONS = [
|
|
65
|
-
"invalid_token", "wrong_password", "invalid_password",
|
|
66
|
-
"machine_locked", "machine_disabled", "machine_not_found",
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
// Classify a server-supplied `reason` string. Returns a VaultBackendError kind
|
|
70
|
-
// when the reason describes a backend/transient condition, or null when the
|
|
71
|
-
// reason is a real auth verdict (or unknown — caller decides).
|
|
72
|
-
export function classifyServerReason(reason) {
|
|
73
|
-
if (!reason || typeof reason !== "string") return null;
|
|
74
|
-
const r = reason.toLowerCase();
|
|
75
|
-
if (AUTH_VERDICT_REASONS.some((v) => r.includes(v))) return null; // genuine verdict
|
|
76
|
-
if (/rate[_\s-]?limit/.test(r)) return "rate_limited";
|
|
77
|
-
if (/(database|\bdb\b|postgres|connection|pool|statement timeout|unavailable|internal|timeout|5\d\d)/.test(r)) return "db_error";
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Classify a thrown error (transport/timeout/network/typed). Returns a kind
|
|
82
|
-
// string when it is a backend/transient failure, or null when it is not.
|
|
83
|
-
export function classifyBackendError(err) {
|
|
84
|
-
if (!err) return null;
|
|
85
|
-
if (err instanceof VaultBackendError) return err.kind;
|
|
86
|
-
const name = err.name || "";
|
|
87
|
-
const msg = (err.message || "").toString();
|
|
88
|
-
if (name === "TimeoutError" || name === "AbortError" || /\babort(ed)?\b|\btim(e|ed)?\s*out\b|timeout/i.test(msg)) return "timeout";
|
|
89
|
-
if (/fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EPIPE|network|socket hang/i.test(msg)) return "network";
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function post(route, body) {
|
|
94
|
-
const url = `${getBaseUrl()}/${route}`;
|
|
95
|
-
const anonKey = getAnonKey();
|
|
96
|
-
|
|
97
|
-
let res;
|
|
98
|
-
try {
|
|
99
|
-
res = await fetch(url, {
|
|
100
|
-
method: "POST",
|
|
101
|
-
headers: {
|
|
102
|
-
"Content-Type": "application/json",
|
|
103
|
-
"Authorization": `Bearer ${anonKey}`
|
|
104
|
-
},
|
|
105
|
-
body: JSON.stringify(body),
|
|
106
|
-
signal: AbortSignal.timeout(VAULT_FETCH_TIMEOUT_MS)
|
|
107
|
-
});
|
|
108
|
-
} catch (err) {
|
|
109
|
-
// Transport-level failure — the backend never answered. Surface a typed,
|
|
110
|
-
// non-strike error so callers report "backend unreachable", not "bad password".
|
|
111
|
-
const kind = classifyBackendError(err) || "network";
|
|
112
|
-
const why = kind === "timeout"
|
|
113
|
-
? `vault backend did not respond within ${VAULT_FETCH_TIMEOUT_MS}ms`
|
|
114
|
-
: `cannot reach vault backend (${err.message || "network error"})`;
|
|
115
|
-
throw new VaultBackendError(kind, why, { detail: err.message || String(err), cause: err });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let data;
|
|
119
|
-
try {
|
|
120
|
-
data = await res.json();
|
|
121
|
-
} catch (err) {
|
|
122
|
-
// 2xx/5xx with a non-JSON or empty body — the function errored without a
|
|
123
|
-
// structured verdict. Transient backend condition, not an auth failure.
|
|
124
|
-
throw new VaultBackendError("server_error", `vault backend returned an unreadable response (HTTP ${res.status})`, { status: res.status, detail: err.message });
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// HTTP 5xx (or any non-ok with no structured error) = backend problem, never a verdict.
|
|
128
|
-
if (res.status >= 500 || (!res.ok && !data.error)) {
|
|
129
|
-
const reasonKind = classifyServerReason(data.reason || data.error) || "server_error";
|
|
130
|
-
throw new VaultBackendError(reasonKind, `vault backend error (HTTP ${res.status})`, { status: res.status, detail: data.error || data.reason || `HTTP ${res.status}` });
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 4xx WITH a structured error: could be a real verdict (invalid_token) or a
|
|
134
|
-
// transient backend signal surfaced as 4xx (rate_limited). Promote the latter
|
|
135
|
-
// to a typed backend error; leave genuine verdicts in `data` for the caller.
|
|
136
|
-
if (data.error) {
|
|
137
|
-
const reasonKind = classifyServerReason(data.reason || data.error);
|
|
138
|
-
if (reasonKind) {
|
|
139
|
-
throw new VaultBackendError(reasonKind, `vault backend ${reasonKind.replace("_", " ")}`, { status: res.status, detail: data.error || data.reason });
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return data;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ============================================================
|
|
147
|
-
// Auth-bearing calls (require HMAC token)
|
|
148
|
-
// ============================================================
|
|
149
|
-
async function authPost(route, password, machineHash, token, timestamp, extra = {}) {
|
|
150
|
-
return post(route, {
|
|
151
|
-
machine_hash: machineHash,
|
|
152
|
-
token,
|
|
153
|
-
timestamp,
|
|
154
|
-
password,
|
|
155
|
-
...extra
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ============================================================
|
|
160
|
-
// Exported API surface
|
|
161
|
-
// ============================================================
|
|
162
|
-
|
|
163
|
-
export async function retrieve(password, machineHash, token, timestamp, service) {
|
|
164
|
-
return authPost("retrieve", password, machineHash, token, timestamp, { service });
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export async function write(password, machineHash, token, timestamp, service, value) {
|
|
168
|
-
return authPost("write", password, machineHash, token, timestamp, { service, value });
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export async function enable(password, machineHash, token, timestamp, service, enabled) {
|
|
172
|
-
return authPost("enable", password, machineHash, token, timestamp, { service, enabled });
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export async function addService(password, machineHash, token, timestamp, name, label, key_type, description, project) {
|
|
176
|
-
const extra = { name, label, key_type, description };
|
|
177
|
-
if (project) extra.project = project;
|
|
178
|
-
return authPost("add", password, machineHash, token, timestamp, extra);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export async function updateService(password, machineHash, token, timestamp, service, updates) {
|
|
182
|
-
return authPost("update", password, machineHash, token, timestamp, { service, ...updates });
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export async function removeService(password, machineHash, token, timestamp, service, confirm) {
|
|
186
|
-
return authPost("remove", password, machineHash, token, timestamp, { service, confirm });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function revoke(password, machineHash, token, timestamp, service, confirm) {
|
|
190
|
-
return authPost("revoke", password, machineHash, token, timestamp, { service, confirm });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export async function status(password, machineHash, token, timestamp, project) {
|
|
194
|
-
const extra = {};
|
|
195
|
-
if (project) extra.project = project;
|
|
196
|
-
return authPost("status", password, machineHash, token, timestamp, extra);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export async function test(password, machineHash, token, timestamp) {
|
|
200
|
-
return authPost("test", password, machineHash, token, timestamp);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export async function changePassword(password, machineHash, token, timestamp, newSeedHash) {
|
|
204
|
-
return authPost("change-password", password, machineHash, token, timestamp, { new_hmac_seed_hash: newSeedHash });
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export async function createEnrollment(password, machineHash, token, timestamp, label, ttlMinutes, installId) {
|
|
208
|
-
const extra = {};
|
|
209
|
-
if (label) extra.label = label;
|
|
210
|
-
if (ttlMinutes) extra.ttl_minutes = ttlMinutes;
|
|
211
|
-
if (installId) extra.install_id = installId;
|
|
212
|
-
return authPost("create-enrollment", password, machineHash, token, timestamp, extra);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export async function registerMachine(machineHash, seedHash, label, adminToken, extras = {}) {
|
|
216
|
-
return post("register-machine", {
|
|
217
|
-
machine_hash: machineHash,
|
|
218
|
-
hmac_seed_hash: seedHash,
|
|
219
|
-
label,
|
|
220
|
-
admin_token: adminToken,
|
|
221
|
-
...extras
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function redeemEnrollment(machineHash, seedHash, label, enrollmentCode) {
|
|
226
|
-
return post("redeem-enrollment", {
|
|
227
|
-
machine_hash: machineHash,
|
|
228
|
-
hmac_seed_hash: seedHash,
|
|
229
|
-
label,
|
|
230
|
-
enrollment_code: enrollmentCode
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export default {
|
|
235
|
-
retrieve, write, enable, addService, updateService, removeService, revoke,
|
|
236
|
-
status, test, createEnrollment, registerMachine, redeemEnrollment, getBaseUrl, getAnonKey,
|
|
237
|
-
VaultBackendError, classifyBackendError, classifyServerReason
|
|
238
|
-
};
|
|
1
|
+
// cli/api.js
|
|
2
|
+
// Thin client that calls the auth-vault Edge Function
|
|
3
|
+
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
import Conf from "conf";
|
|
8
|
+
import { getConfOptions } from "./conf-path.js";
|
|
9
|
+
|
|
10
|
+
const config = new Conf(getConfOptions());
|
|
11
|
+
|
|
12
|
+
// ============================================================
|
|
13
|
+
// Get Edge Function base URL from local config
|
|
14
|
+
// ============================================================
|
|
15
|
+
export function getBaseUrl() {
|
|
16
|
+
const url = config.get("supabase_url") || process.env.CLAUTH_SUPABASE_URL;
|
|
17
|
+
if (!url) throw new Error("Supabase URL not configured. Run: clauth setup");
|
|
18
|
+
return `${url}/functions/v1/auth-vault`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getAnonKey() {
|
|
22
|
+
const key = config.get("supabase_anon_key") || process.env.CLAUTH_SUPABASE_ANON_KEY;
|
|
23
|
+
if (!key) throw new Error("Supabase anon key not configured. Run: clauth setup");
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============================================================
|
|
28
|
+
// Core POST helper
|
|
29
|
+
// ============================================================
|
|
30
|
+
// Bound every vault round-trip with a wall-clock timeout. Without this, a TCP
|
|
31
|
+
// stall (DNS black-hole, half-open socket — NOT a clean 4xx) hangs the caller
|
|
32
|
+
// forever. Callers that treat persistence as best-effort (e.g. the call_agent
|
|
33
|
+
// scratchpad mirror) rely on this resolving to an error rather than blocking.
|
|
34
|
+
// Override via CLAUTH_VAULT_TIMEOUT_MS; default 8s.
|
|
35
|
+
const VAULT_FETCH_TIMEOUT_MS = (() => {
|
|
36
|
+
const n = Number(process.env.CLAUTH_VAULT_TIMEOUT_MS);
|
|
37
|
+
return Number.isFinite(n) && n > 0 ? n : 8000;
|
|
38
|
+
})();
|
|
39
|
+
|
|
40
|
+
// ============================================================
|
|
41
|
+
// Backend / external-resource error model
|
|
42
|
+
// ============================================================
|
|
43
|
+
// A VaultBackendError means the vault backend (the auth-vault Edge Function or
|
|
44
|
+
// the Postgres database behind it) never rendered an auth verdict — the request
|
|
45
|
+
// timed out, the network failed, the function 5xx'd, the DB was unreachable, or
|
|
46
|
+
// we were rate-limited. These are TRANSIENT and must NEVER be counted as an
|
|
47
|
+
// authentication failure (a wrong password). Treating a database timeout as a
|
|
48
|
+
// bad password is the bug that turned a DB blip into a permanent vault lockout.
|
|
49
|
+
export class VaultBackendError extends Error {
|
|
50
|
+
constructor(kind, message, { status = null, detail = null, cause = null } = {}) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "VaultBackendError";
|
|
53
|
+
this.kind = kind; // timeout | network | server_error | rate_limited | db_error | unknown
|
|
54
|
+
this.isBackend = true; // marker for callers: do not strike
|
|
55
|
+
this.retriable = true; // the verdict was never rendered — safe to retry
|
|
56
|
+
this.status = status; // HTTP status if the server responded
|
|
57
|
+
this.detail = detail; // human-readable backend detail
|
|
58
|
+
if (cause) this.cause = cause;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Server `reason` strings that are genuine AUTH VERDICTS (the backend evaluated
|
|
63
|
+
// the credential and answered). These DO count — they are not backend errors.
|
|
64
|
+
const AUTH_VERDICT_REASONS = [
|
|
65
|
+
"invalid_token", "wrong_password", "invalid_password",
|
|
66
|
+
"machine_locked", "machine_disabled", "machine_not_found",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Classify a server-supplied `reason` string. Returns a VaultBackendError kind
|
|
70
|
+
// when the reason describes a backend/transient condition, or null when the
|
|
71
|
+
// reason is a real auth verdict (or unknown — caller decides).
|
|
72
|
+
export function classifyServerReason(reason) {
|
|
73
|
+
if (!reason || typeof reason !== "string") return null;
|
|
74
|
+
const r = reason.toLowerCase();
|
|
75
|
+
if (AUTH_VERDICT_REASONS.some((v) => r.includes(v))) return null; // genuine verdict
|
|
76
|
+
if (/rate[_\s-]?limit/.test(r)) return "rate_limited";
|
|
77
|
+
if (/(database|\bdb\b|postgres|connection|pool|statement timeout|unavailable|internal|timeout|5\d\d)/.test(r)) return "db_error";
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Classify a thrown error (transport/timeout/network/typed). Returns a kind
|
|
82
|
+
// string when it is a backend/transient failure, or null when it is not.
|
|
83
|
+
export function classifyBackendError(err) {
|
|
84
|
+
if (!err) return null;
|
|
85
|
+
if (err instanceof VaultBackendError) return err.kind;
|
|
86
|
+
const name = err.name || "";
|
|
87
|
+
const msg = (err.message || "").toString();
|
|
88
|
+
if (name === "TimeoutError" || name === "AbortError" || /\babort(ed)?\b|\btim(e|ed)?\s*out\b|timeout/i.test(msg)) return "timeout";
|
|
89
|
+
if (/fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EPIPE|network|socket hang/i.test(msg)) return "network";
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function post(route, body) {
|
|
94
|
+
const url = `${getBaseUrl()}/${route}`;
|
|
95
|
+
const anonKey = getAnonKey();
|
|
96
|
+
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await fetch(url, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
"Authorization": `Bearer ${anonKey}`
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
signal: AbortSignal.timeout(VAULT_FETCH_TIMEOUT_MS)
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Transport-level failure — the backend never answered. Surface a typed,
|
|
110
|
+
// non-strike error so callers report "backend unreachable", not "bad password".
|
|
111
|
+
const kind = classifyBackendError(err) || "network";
|
|
112
|
+
const why = kind === "timeout"
|
|
113
|
+
? `vault backend did not respond within ${VAULT_FETCH_TIMEOUT_MS}ms`
|
|
114
|
+
: `cannot reach vault backend (${err.message || "network error"})`;
|
|
115
|
+
throw new VaultBackendError(kind, why, { detail: err.message || String(err), cause: err });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let data;
|
|
119
|
+
try {
|
|
120
|
+
data = await res.json();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// 2xx/5xx with a non-JSON or empty body — the function errored without a
|
|
123
|
+
// structured verdict. Transient backend condition, not an auth failure.
|
|
124
|
+
throw new VaultBackendError("server_error", `vault backend returned an unreadable response (HTTP ${res.status})`, { status: res.status, detail: err.message });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// HTTP 5xx (or any non-ok with no structured error) = backend problem, never a verdict.
|
|
128
|
+
if (res.status >= 500 || (!res.ok && !data.error)) {
|
|
129
|
+
const reasonKind = classifyServerReason(data.reason || data.error) || "server_error";
|
|
130
|
+
throw new VaultBackendError(reasonKind, `vault backend error (HTTP ${res.status})`, { status: res.status, detail: data.error || data.reason || `HTTP ${res.status}` });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 4xx WITH a structured error: could be a real verdict (invalid_token) or a
|
|
134
|
+
// transient backend signal surfaced as 4xx (rate_limited). Promote the latter
|
|
135
|
+
// to a typed backend error; leave genuine verdicts in `data` for the caller.
|
|
136
|
+
if (data.error) {
|
|
137
|
+
const reasonKind = classifyServerReason(data.reason || data.error);
|
|
138
|
+
if (reasonKind) {
|
|
139
|
+
throw new VaultBackendError(reasonKind, `vault backend ${reasonKind.replace("_", " ")}`, { status: res.status, detail: data.error || data.reason });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return data;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============================================================
|
|
147
|
+
// Auth-bearing calls (require HMAC token)
|
|
148
|
+
// ============================================================
|
|
149
|
+
async function authPost(route, password, machineHash, token, timestamp, extra = {}) {
|
|
150
|
+
return post(route, {
|
|
151
|
+
machine_hash: machineHash,
|
|
152
|
+
token,
|
|
153
|
+
timestamp,
|
|
154
|
+
password,
|
|
155
|
+
...extra
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================
|
|
160
|
+
// Exported API surface
|
|
161
|
+
// ============================================================
|
|
162
|
+
|
|
163
|
+
export async function retrieve(password, machineHash, token, timestamp, service) {
|
|
164
|
+
return authPost("retrieve", password, machineHash, token, timestamp, { service });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function write(password, machineHash, token, timestamp, service, value) {
|
|
168
|
+
return authPost("write", password, machineHash, token, timestamp, { service, value });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function enable(password, machineHash, token, timestamp, service, enabled) {
|
|
172
|
+
return authPost("enable", password, machineHash, token, timestamp, { service, enabled });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function addService(password, machineHash, token, timestamp, name, label, key_type, description, project) {
|
|
176
|
+
const extra = { name, label, key_type, description };
|
|
177
|
+
if (project) extra.project = project;
|
|
178
|
+
return authPost("add", password, machineHash, token, timestamp, extra);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function updateService(password, machineHash, token, timestamp, service, updates) {
|
|
182
|
+
return authPost("update", password, machineHash, token, timestamp, { service, ...updates });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function removeService(password, machineHash, token, timestamp, service, confirm) {
|
|
186
|
+
return authPost("remove", password, machineHash, token, timestamp, { service, confirm });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function revoke(password, machineHash, token, timestamp, service, confirm) {
|
|
190
|
+
return authPost("revoke", password, machineHash, token, timestamp, { service, confirm });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function status(password, machineHash, token, timestamp, project) {
|
|
194
|
+
const extra = {};
|
|
195
|
+
if (project) extra.project = project;
|
|
196
|
+
return authPost("status", password, machineHash, token, timestamp, extra);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function test(password, machineHash, token, timestamp) {
|
|
200
|
+
return authPost("test", password, machineHash, token, timestamp);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function changePassword(password, machineHash, token, timestamp, newSeedHash) {
|
|
204
|
+
return authPost("change-password", password, machineHash, token, timestamp, { new_hmac_seed_hash: newSeedHash });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function createEnrollment(password, machineHash, token, timestamp, label, ttlMinutes, installId) {
|
|
208
|
+
const extra = {};
|
|
209
|
+
if (label) extra.label = label;
|
|
210
|
+
if (ttlMinutes) extra.ttl_minutes = ttlMinutes;
|
|
211
|
+
if (installId) extra.install_id = installId;
|
|
212
|
+
return authPost("create-enrollment", password, machineHash, token, timestamp, extra);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function registerMachine(machineHash, seedHash, label, adminToken, extras = {}) {
|
|
216
|
+
return post("register-machine", {
|
|
217
|
+
machine_hash: machineHash,
|
|
218
|
+
hmac_seed_hash: seedHash,
|
|
219
|
+
label,
|
|
220
|
+
admin_token: adminToken,
|
|
221
|
+
...extras
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function redeemEnrollment(machineHash, seedHash, label, enrollmentCode) {
|
|
226
|
+
return post("redeem-enrollment", {
|
|
227
|
+
machine_hash: machineHash,
|
|
228
|
+
hmac_seed_hash: seedHash,
|
|
229
|
+
label,
|
|
230
|
+
enrollment_code: enrollmentCode
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default {
|
|
235
|
+
retrieve, write, enable, addService, updateService, removeService, revoke,
|
|
236
|
+
status, test, createEnrollment, registerMachine, redeemEnrollment, getBaseUrl, getAnonKey,
|
|
237
|
+
VaultBackendError, classifyBackendError, classifyServerReason
|
|
238
|
+
};
|
|
@@ -403,6 +403,39 @@ function resolveClaudeBinary(explicit) {
|
|
|
403
403
|
return null;
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
function quoteCmdArgument(value) {
|
|
407
|
+
return `"${String(value)
|
|
408
|
+
.replace(/"/g, '""')
|
|
409
|
+
.replace(/[%^&|<>()!]/g, "^$&")}"`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function buildClaudeSpawn(binary, args, windowsHide) {
|
|
413
|
+
const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(binary);
|
|
414
|
+
if (!isCmdShim) {
|
|
415
|
+
return {
|
|
416
|
+
command: binary,
|
|
417
|
+
spawnArgs: args,
|
|
418
|
+
spawnOptions: { shell: false, windowsHide },
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// `cmd.exe` parses the target as a command line, not an argv vector. Passing
|
|
423
|
+
// a separately quoted target lets Node backslash-escape it; passing every
|
|
424
|
+
// argument verbatim loses multi-word system prompts. A single `call` command
|
|
425
|
+
// line preserves both the target and its arguments, including profile paths
|
|
426
|
+
// with spaces.
|
|
427
|
+
return {
|
|
428
|
+
command: "cmd.exe",
|
|
429
|
+
spawnArgs: [
|
|
430
|
+
"/d",
|
|
431
|
+
"/s",
|
|
432
|
+
"/c",
|
|
433
|
+
["call", quoteCmdArgument(binary), ...args.map(quoteCmdArgument)].join(" "),
|
|
434
|
+
],
|
|
435
|
+
spawnOptions: { shell: false, windowsVerbatimArguments: true, windowsHide },
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
406
439
|
function makeJobId(prefix = "call") {
|
|
407
440
|
return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
408
441
|
}
|
|
@@ -556,16 +589,17 @@ export class WarmWorker {
|
|
|
556
589
|
async start() {
|
|
557
590
|
let proc;
|
|
558
591
|
try {
|
|
559
|
-
const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
|
|
560
592
|
const args = buildWarmArgs({ model: this.model, appendSystemPrompt: this.appendSystemPrompt });
|
|
561
|
-
const command
|
|
562
|
-
|
|
593
|
+
const { command, spawnArgs, spawnOptions } = buildClaudeSpawn(
|
|
594
|
+
this.binary,
|
|
595
|
+
args,
|
|
596
|
+
!this.allowVisibleWindow,
|
|
597
|
+
);
|
|
563
598
|
proc = this._spawn(command, spawnArgs, {
|
|
564
599
|
cwd: this.cwd,
|
|
565
600
|
env: process.env, // inherits CLI login session; never sets ANTHROPIC_API_KEY
|
|
566
601
|
stdio: ["pipe", "pipe", "pipe"],
|
|
567
|
-
|
|
568
|
-
windowsHide: !this.allowVisibleWindow,
|
|
602
|
+
...spawnOptions,
|
|
569
603
|
});
|
|
570
604
|
} catch (e) {
|
|
571
605
|
this.state = "dead";
|
|
@@ -584,6 +618,14 @@ export class WarmWorker {
|
|
|
584
618
|
proc.stderr?.on("data", (d) => {
|
|
585
619
|
this._stderr = `${this._stderr || ""}${d.toString()}`.slice(-20000);
|
|
586
620
|
});
|
|
621
|
+
// Without this, a write() to a stdin pipe whose child already exited fails
|
|
622
|
+
// ASYNCHRONOUSLY (EPIPE) via the stream's own 'error' event, not via the
|
|
623
|
+
// synchronous try/catch around write() in send() below. An unhandled
|
|
624
|
+
// 'error' event on a stream is fatal to the whole process by Node's
|
|
625
|
+
// design — one dead warm worker was taking down the entire clauth daemon
|
|
626
|
+
// for the whole fleet. The two other spawn sites in this file already
|
|
627
|
+
// guard this the same way; this one was missed.
|
|
628
|
+
proc.stdin?.on("error", () => {});
|
|
587
629
|
proc.on("error", (e) => this._onDeath(`proc_error: ${e.message}`));
|
|
588
630
|
proc.on("close", (code) => this._onDeath(`exit_${code}`));
|
|
589
631
|
|
|
@@ -1463,15 +1505,12 @@ export class AgentPool {
|
|
|
1463
1505
|
|
|
1464
1506
|
let proc;
|
|
1465
1507
|
try {
|
|
1466
|
-
const
|
|
1467
|
-
const command = isCmdShim ? "cmd" : this.binary;
|
|
1468
|
-
const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
|
|
1508
|
+
const { command, spawnArgs, spawnOptions } = buildClaudeSpawn(this.binary, cmdArgs, true);
|
|
1469
1509
|
proc = this._spawn(command, spawnArgs, {
|
|
1470
1510
|
cwd,
|
|
1471
1511
|
env: process.env, // inherits CLI login session; no ANTHROPIC_API_KEY injected
|
|
1472
1512
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1473
|
-
|
|
1474
|
-
windowsHide: true,
|
|
1513
|
+
...spawnOptions,
|
|
1475
1514
|
});
|
|
1476
1515
|
} catch (e) {
|
|
1477
1516
|
job.status = "failed";
|
|
@@ -1841,15 +1880,12 @@ export class DelegationLane {
|
|
|
1841
1880
|
|
|
1842
1881
|
let proc;
|
|
1843
1882
|
try {
|
|
1844
|
-
const
|
|
1845
|
-
const command = isCmdShim ? "cmd" : this.binary;
|
|
1846
|
-
const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
|
|
1883
|
+
const { command, spawnArgs, spawnOptions } = buildClaudeSpawn(this.binary, cmdArgs, true);
|
|
1847
1884
|
proc = this._spawn(command, spawnArgs, {
|
|
1848
1885
|
cwd,
|
|
1849
1886
|
env: process.env, // inherits CLI login; never sets ANTHROPIC_API_KEY
|
|
1850
1887
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1851
|
-
|
|
1852
|
-
windowsHide: true,
|
|
1888
|
+
...spawnOptions,
|
|
1853
1889
|
});
|
|
1854
1890
|
} catch (e) {
|
|
1855
1891
|
return resolve({
|