@lifeaitools/clauth 1.30.12 → 1.30.14

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.
@@ -140,6 +140,10 @@ export function validatePluginManifest(manifest, sourcePath = "") {
140
140
  id,
141
141
  version,
142
142
  publisher: String(manifest.publisher || "unknown"),
143
+ // Only trusted managed manifests may opt into automatic startup. User
144
+ // plugins remain awaiting_enable until an operator explicitly enables them.
145
+ core: manifest.core === true,
146
+ enable_default: manifest.enable_default === true,
143
147
  sourcePath,
144
148
  destination: normalizeDestination(manifest.destination),
145
149
  lifecycle_owner: normalizeLifecycleOwner(manifest.lifecycle_owner),
@@ -226,15 +230,17 @@ export function discoverPlugins() {
226
230
  throw new Error("user plugin cannot silently shadow a managed plugin id");
227
231
  }
228
232
  const prior = byId.get(manifest.id);
229
- const state = prior?.enabled ? (prior.manifest_hash === hash ? "current" : "version_drift") : "awaiting_enable";
230
- const plugin = { ...manifest, source, discovery_root: root, manifest_hash: hash, state, enabled: Boolean(prior?.enabled), discovered_at: now() };
233
+ const autoEnabled = source === "managed" && manifest.core === true && manifest.enable_default === true;
234
+ const enabled = Boolean(prior?.enabled || autoEnabled);
235
+ const state = enabled ? (prior?.manifest_hash === hash || autoEnabled ? "current" : "version_drift") : "awaiting_enable";
236
+ const plugin = { ...manifest, source, discovery_root: root, manifest_hash: hash, state, enabled, discovered_at: now() };
231
237
  plugins.push(plugin);
232
238
  for (const surface of plugin.surfaces) surfaces.push({ ...surface, enabled: plugin.enabled, state });
233
239
  for (const credential of plugin.credentials) {
234
240
  events.push({ kind: "credential_required", plugin_id: plugin.id, credential, value_set: false });
235
241
  }
236
242
  seen.add(plugin.id);
237
- events.push({ kind: "plugin_discovered", plugin_id: plugin.id, state, source, manifest_hash: hash });
243
+ events.push({ kind: autoEnabled && !prior?.enabled ? "core_plugin_auto_enabled" : "plugin_discovered", plugin_id: plugin.id, state, source, manifest_hash: hash });
238
244
  } catch (error) {
239
245
  const id = path.basename(path.dirname(manifestPath));
240
246
  plugins.push({
@@ -109,6 +109,24 @@ test("discovery merges managed and user roots without silent managed-id shadowin
109
109
  assert.match(invalid.error, /shadow/);
110
110
  }));
111
111
 
112
+ test("trusted managed core plugins auto-enable while user plugins remain opt-in", () => withTempSupervisor((root) => {
113
+ const managed = path.join(root, "managed");
114
+ const user = path.join(root, "user");
115
+ process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
116
+ process.env.CLAUTH_USER_PLUGIN_ROOTS = user;
117
+ writePlugin(root, "managed", "core-mcp", baseManifest("core-mcp", { core: true, enable_default: true }));
118
+ writePlugin(root, "user", "user-mcp", baseManifest("user-mcp", { core: true, enable_default: true }));
119
+
120
+ const result = discoverPlugins();
121
+ const core = result.plugins.find((plugin) => plugin.id === "core-mcp");
122
+ const userPlugin = result.plugins.find((plugin) => plugin.id === "user-mcp");
123
+ assert.equal(core.enabled, true);
124
+ assert.equal(core.state, "current");
125
+ assert.equal(result.events.some((event) => event.kind === "core_plugin_auto_enabled" && event.plugin_id === "core-mcp"), true);
126
+ assert.equal(userPlugin.enabled, false);
127
+ assert.equal(userPlugin.state, "awaiting_enable");
128
+ }));
129
+
112
130
  test("plugin test marks a private candidate and never creates a public route", () => withTempSupervisor((root) => {
113
131
  const managed = path.join(root, "managed");
114
132
  process.env.CLAUTH_MANAGED_PLUGIN_ROOTS = managed;
@@ -0,0 +1,339 @@
1
+ // cli/webdav-service.js
2
+ // WebDAV child-process supervisor — rclone serve webdav
3
+ //
4
+ // ISOLATION: This module has zero coupling to existing fs_* tool handlers.
5
+ // Rollback: set webdav.enabled=false in the config block (or remove it)
6
+ // + delete this file + remove the two-line import in serve.js.
7
+ //
8
+ // Design: lazy-start. The rclone child spawns on the first fs_dav_setup call.
9
+ // Subsequent calls return the running child's status without restarting.
10
+ // On child crash: exponential-backoff restart loop (2s → 5s → 15s → 60s).
11
+
12
+ import { spawn, execSync } from "child_process";
13
+ import { writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from "fs";
14
+ import { tmpdir, homedir } from "os";
15
+ import { join } from "path";
16
+ import crypto from "crypto";
17
+ import * as api from "./api.js";
18
+ import { deriveToken } from "./fingerprint.js";
19
+
20
+ const WEBDAV_PORT = 8620;
21
+ const WEBDAV_USER = "claude";
22
+ const CRED_NAME = "webdav-claude";
23
+ const RESTART_DELAYS = [2000, 5000, 15000, 60000];
24
+
25
+ const APPDATA = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
26
+ const CONFIG_PATH = join(APPDATA, "clauth", "webdav-config.json");
27
+
28
+ const DEFAULT_CONFIG = {
29
+ enabled: true,
30
+ port: WEBDAV_PORT,
31
+ credential: CRED_NAME,
32
+ upstreams: [],
33
+ };
34
+
35
+ // ── Config persistence ──────────────────────────────────────────
36
+
37
+ export function loadConfig() {
38
+ try {
39
+ const raw = readFileSync(CONFIG_PATH, "utf8");
40
+ const cfg = JSON.parse(raw);
41
+ if (!Array.isArray(cfg.upstreams)) cfg.upstreams = [];
42
+ return { ...DEFAULT_CONFIG, ...cfg };
43
+ } catch {
44
+ return { ...DEFAULT_CONFIG };
45
+ }
46
+ }
47
+
48
+ export function saveConfig(cfg) {
49
+ const dir = join(APPDATA, "clauth");
50
+ mkdirSync(dir, { recursive: true });
51
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
52
+ _fsCfgCache = null;
53
+ }
54
+
55
+ export function addMount(name, mountPath) {
56
+ const cfg = loadConfig();
57
+ if (cfg.upstreams.some(u => u.name === name)) return { error: `Mount '${name}' already exists` };
58
+ cfg.upstreams.push({ name, path: mountPath });
59
+ saveConfig(cfg);
60
+ return { ok: true, upstreams: cfg.upstreams };
61
+ }
62
+
63
+ export function removeMount(name) {
64
+ const cfg = loadConfig();
65
+ const before = cfg.upstreams.length;
66
+ cfg.upstreams = cfg.upstreams.filter(u => u.name !== name);
67
+ if (cfg.upstreams.length === before) return { error: `Mount '${name}' not found` };
68
+ saveConfig(cfg);
69
+ return { ok: true, upstreams: cfg.upstreams };
70
+ }
71
+
72
+ export function updateMount(name, updates) {
73
+ const cfg = loadConfig();
74
+ const mount = cfg.upstreams.find(u => u.name === name);
75
+ if (!mount) return { error: `Mount '${name}' not found` };
76
+ if (updates.credential !== undefined) cfg.credential = updates.credential;
77
+ saveConfig(cfg);
78
+ return { ok: true, upstreams: cfg.upstreams };
79
+ }
80
+
81
+ let _fsCfgCache = null;
82
+
83
+ // ── Module state ────────────────────────────────────────────────
84
+ let _child = null;
85
+ let _configPath = null;
86
+ let _status = "stopped"; // stopped | starting | running | error
87
+ let _error = null;
88
+ let _restartCount = 0;
89
+ let _startedAt = null;
90
+ let _vaultFn = null; // () => { password, machineHash }
91
+ let _cfg = null;
92
+
93
+ // ── Public API ───────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Ensure the rclone WebDAV child is running.
97
+ * Idempotent — safe to call multiple times.
98
+ * Returns { ok: true } or { error: string }.
99
+ */
100
+ export async function ensureRunning(vaultFn, config) {
101
+ if (vaultFn) _vaultFn = vaultFn;
102
+ if (config) _cfg = config;
103
+ if (!_cfg) _cfg = loadConfig();
104
+
105
+ if (_status === "running" && _child) return { ok: true };
106
+ if (_status === "starting") return { ok: true, message: "still starting" };
107
+
108
+ return _startChild();
109
+ }
110
+
111
+ /** Current supervisor status — safe to call at any time. */
112
+ export function getStatus() {
113
+ return {
114
+ status: _status,
115
+ port: WEBDAV_PORT,
116
+ url: _status === "running" ? `http://127.0.0.1:${WEBDAV_PORT}` : null,
117
+ error: _error || null,
118
+ restart_count: _restartCount,
119
+ started_at: _startedAt || null,
120
+ };
121
+ }
122
+
123
+ /** Kill the child and clean up the temp config file. */
124
+ export function shutdown() {
125
+ if (_child) {
126
+ _child.removeAllListeners();
127
+ _child.kill("SIGTERM");
128
+ _child = null;
129
+ }
130
+ _cleanupConfig();
131
+ _status = "stopped";
132
+ }
133
+
134
+ /**
135
+ * rclone-compatible AES-CTR "obscure" — same algorithm as `rclone obscure`.
136
+ * Used by fs_dav_setup to embed a non-plaintext credential in the mount script.
137
+ * The result can be passed as RCLONE_CONFIG_xxx_PASS.
138
+ */
139
+ export function rcloneObscure(password) {
140
+ // Key from rclone source: lib/obscure/obscure.go cryptKey
141
+ const key = Buffer.from([
142
+ 0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
143
+ 0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
144
+ 0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
145
+ 0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
146
+ ]);
147
+ const iv = crypto.randomBytes(16);
148
+ const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
149
+ const ct = Buffer.concat([cipher.update(Buffer.from(password, "utf8")), cipher.final()]);
150
+ return Buffer.concat([iv, ct]).toString("base64url");
151
+ }
152
+
153
+ // ── Internal ─────────────────────────────────────────────────────
154
+
155
+ async function _startChild() {
156
+ _status = "starting";
157
+ _error = null;
158
+
159
+ if (!_cfg?.upstreams?.length) {
160
+ _status = "error";
161
+ _error = "No mount points configured — add them in the clauth dashboard";
162
+ return { error: _error };
163
+ }
164
+
165
+ // 1. Resolve credential from vault
166
+ let pass;
167
+ try {
168
+ const vault = _vaultFn?.();
169
+ if (!vault?.password) {
170
+ _status = "error";
171
+ _error = "Vault is locked — unlock clauth first";
172
+ return { error: _error };
173
+ }
174
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
175
+ const credName = _cfg.credential || CRED_NAME;
176
+ const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, credName);
177
+ if (!result.value) {
178
+ _status = "error";
179
+ _error = `Credential '${credName}' not found — register it in the vault first`;
180
+ return { error: _error };
181
+ }
182
+ pass = result.value;
183
+ } catch (err) {
184
+ _status = "error";
185
+ _error = `Credential fetch failed: ${err.message}`;
186
+ return { error: _error };
187
+ }
188
+
189
+ // 2. Locate rclone
190
+ const rclonePath = _findRclone();
191
+ if (!rclonePath) {
192
+ _status = "error";
193
+ _error = "rclone not found — install with: winget install Rclone.Rclone";
194
+ return { error: _error };
195
+ }
196
+
197
+ // 3. Write rclone config — combine backend with quoted entries for paths with spaces.
198
+ // Per rclone docs: "dir=remote:path with space" (quote the whole entry).
199
+ const mounts = _cfg.upstreams || [];
200
+ const combineUpstreams = mounts
201
+ .map(u => `"${u.name}=${u.path.replace(/\\/g, "/")}"`)
202
+ .join(" ");
203
+ const configContent = `[srv]\ntype = combine\nupstreams = ${combineUpstreams}\n`;
204
+ _cleanupConfig();
205
+ _configPath = join(tmpdir(), `.rclone-webdav-${process.pid}.conf`);
206
+ try {
207
+ writeFileSync(_configPath, configContent, { mode: 0o600 });
208
+ } catch (err) {
209
+ _status = "error";
210
+ _error = `Config write failed: ${err.message}`;
211
+ return { error: _error };
212
+ }
213
+
214
+ // 4. Spawn rclone
215
+ return new Promise(resolve => {
216
+ let settled = false;
217
+ const settle = (val) => { if (!settled) { settled = true; resolve(val); } };
218
+
219
+ try {
220
+ _child = spawn(rclonePath, [
221
+ "serve", "webdav", "srv:",
222
+ "--config", _configPath,
223
+ "--addr", `127.0.0.1:${WEBDAV_PORT}`,
224
+ "--user", WEBDAV_USER,
225
+ "--pass", pass,
226
+ "--no-modtime",
227
+ "--vfs-cache-mode", "full",
228
+ "--vfs-write-back", "0s",
229
+ "--ignore-size",
230
+ "--cache-dir", join(process.env.TEMP || tmpdir(), "rclone-dav-cache"),
231
+ "--transfers", "4",
232
+ ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
233
+
234
+ } catch (spawnErr) {
235
+ _child = null;
236
+ _status = "error";
237
+ _error = spawnErr.message;
238
+ _cleanupConfig();
239
+ settle({ error: spawnErr.message });
240
+ return;
241
+ }
242
+
243
+ _child.stdout.on("data", () => {});
244
+
245
+ _child.stderr.on("data", (chunk) => {
246
+ const line = chunk.toString().trim();
247
+ if (line.includes("Serving WebDAV") || line.includes("Listening on")) {
248
+ _status = "running";
249
+ _startedAt = new Date().toISOString();
250
+ settle({ ok: true });
251
+ }
252
+ // Surface errors but don't stop on every stderr line — rclone logs normally to stderr
253
+ if (/\berror\b/i.test(line) || /\bfatal\b/i.test(line)) {
254
+ _error = line.slice(0, 300);
255
+ }
256
+ });
257
+
258
+ _child.on("error", (err) => {
259
+ _child = null;
260
+ _status = "error";
261
+ _error = err.message;
262
+ _cleanupConfig();
263
+ settle({ error: err.message });
264
+ _scheduleRestart();
265
+ });
266
+
267
+ _child.on("exit", (code, signal) => {
268
+ _child = null;
269
+ if (signal === "SIGTERM" || signal === "SIGKILL" || code === 0) {
270
+ _status = "stopped";
271
+ _cleanupConfig();
272
+ return;
273
+ }
274
+ _status = "error";
275
+ _error = `rclone exited unexpectedly (code=${code} signal=${signal})`;
276
+ _cleanupConfig();
277
+ settle({ error: _error });
278
+ _scheduleRestart();
279
+ });
280
+
281
+ // Optimistic fallback: if rclone doesn't print "Serving WebDAV" within 2s, assume running
282
+ setTimeout(() => {
283
+ if (_status === "starting") {
284
+ _status = "running";
285
+ _startedAt = _startedAt || new Date().toISOString();
286
+ }
287
+ settle({ ok: true });
288
+ }, 2000);
289
+ });
290
+ }
291
+
292
+ function _scheduleRestart() {
293
+ const delay = RESTART_DELAYS[Math.min(_restartCount, RESTART_DELAYS.length - 1)];
294
+ _restartCount++;
295
+ setTimeout(() => {
296
+ if (_status === "stopped") return; // intentionally stopped, don't restart
297
+ _startChild();
298
+ }, delay);
299
+ }
300
+
301
+ function _cleanupConfig() {
302
+ if (_configPath) {
303
+ try { unlinkSync(_configPath); } catch {}
304
+ _configPath = null;
305
+ }
306
+ }
307
+
308
+ function _findRclone() {
309
+ const home = homedir();
310
+ const wingetBase = process.env.LOCALAPPDATA
311
+ ? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Packages")
312
+ : null;
313
+ const execOpts = { stdio: "pipe", timeout: 4000, windowsHide: true };
314
+
315
+ const candidates = [
316
+ "rclone",
317
+ "C:\\Program Files\\rclone\\rclone.exe",
318
+ join(home, "scoop", "apps", "rclone", "current", "rclone.exe"),
319
+ "C:\\ProgramData\\scoop\\apps\\rclone\\current\\rclone.exe",
320
+ ];
321
+
322
+ // Winget installs into a version-stamped subdirectory — glob for it
323
+ if (wingetBase) {
324
+ const wingetPkg = join(wingetBase, "Rclone.Rclone_Microsoft.Winget.Source_8wekyb3d8bbwe");
325
+ try {
326
+ for (const d of readdirSync(wingetPkg)) {
327
+ if (d.startsWith("rclone-")) candidates.push(join(wingetPkg, d, "rclone.exe"));
328
+ }
329
+ } catch {}
330
+ }
331
+
332
+ for (const c of candidates) {
333
+ try {
334
+ execSync(`"${c}" version`, execOpts);
335
+ return c;
336
+ } catch {}
337
+ }
338
+ return null;
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.30.12",
3
+ "version": "1.30.14",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,20 +8,28 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "bash scripts/build.sh",
11
+ "test": "node --test cli/commands/scrub.test.js cli/api.classify.test.js test/studio-debug.test.mjs test/dashboard-key-generator.test.mjs && node test-auth-verdict.mjs",
12
+ "test:studio-debug": "node --test test/studio-debug.test.mjs",
13
+ "test:agent-pool": "node test/agent-pool.test.mjs",
14
+ "test:call-agent-10": "node test/call-agent-10-skills.test.mjs",
15
+ "test:call-agent-guard": "node test/call-agent-guard.test.mjs",
16
+ "test:tintin-settings": "node test/tintin-settings.test.mjs",
11
17
  "postinstall": "node scripts/postinstall.js",
12
18
  "worker:start": "node cli/index.js serve",
13
19
  "worker:stop": "curl -s http://127.0.0.1:52437/shutdown 2>nul || taskkill /F /IM cloudflared.exe 2>nul & exit 0",
14
20
  "worker:restart": "npm run worker:stop && timeout /t 3 /nobreak >nul && npm run worker:start"
15
21
  },
16
22
  "dependencies": {
23
+ "@vscode/ripgrep": "^1.15.9",
17
24
  "chalk": "^5.3.0",
18
25
  "commander": "^12.1.0",
19
26
  "conf": "^13.0.0",
27
+ "fast-glob": "^3.3.2",
20
28
  "inquirer": "^10.1.0",
21
29
  "node-fetch": "^3.3.2",
22
30
  "ora": "^8.1.0",
23
- "@vscode/ripgrep": "^1.15.9",
24
- "fast-glob": "^3.3.2"
31
+ "regen-root": "file:../../regen-root.wt/x-claude-sv",
32
+ "typescript": "^5.9.3"
25
33
  },
26
34
  "engines": {
27
35
  "node": ">=18.0.0"
@@ -158,6 +158,31 @@ async function main() {
158
158
  installCodevelopTerminalProfiles();
159
159
  }
160
160
 
161
+ // Windows: install dependencies via winget if not already present
162
+ if (os.platform() === "win32") {
163
+ const deps = [
164
+ { cmd: "rclone", args: ["version"], wingetId: "Rclone.Rclone", label: "rclone (WebDAV bridge)" },
165
+ { cmd: "pwsh", args: ["--version"], wingetId: "Microsoft.PowerShell", label: "PowerShell 7 (fs_exec shell)" },
166
+ ];
167
+ for (const dep of deps) {
168
+ const check = spawnSync(dep.cmd, dep.args, { stdio: "pipe", timeout: 5000, windowsHide: true });
169
+ if (check.status === 0) {
170
+ console.log(` ✓ ${dep.label} already installed`);
171
+ } else {
172
+ console.log(` Installing ${dep.label}...`);
173
+ const r = spawnSync("winget", [
174
+ "install", "--id", dep.wingetId,
175
+ "--silent", "--accept-package-agreements", "--accept-source-agreements",
176
+ ], { stdio: "inherit", timeout: 120000 });
177
+ if (r.status === 0) {
178
+ console.log(` ✓ ${dep.label} installed`);
179
+ } else {
180
+ console.log(` ! ${dep.label} install skipped — install manually: winget install ${dep.wingetId}`);
181
+ }
182
+ }
183
+ }
184
+ }
185
+
161
186
  console.log(" Run 'clauth doctor' to verify installation\n");
162
187
  }
163
188