@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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { MCP_TOOLS, MCP_WRITE_TOOL_NAMES, filterMcpToolsForWriteMode } from "./tool-schemas.js";
|
|
5
|
+
import { MCP_TOOLS as reExported } from "../commands/serve.js";
|
|
6
|
+
|
|
7
|
+
test("MCP_TOOLS is a non-empty array of schema objects with unique names", () => {
|
|
8
|
+
assert.ok(Array.isArray(MCP_TOOLS));
|
|
9
|
+
assert.ok(MCP_TOOLS.length > 50, "the real tool catalog is much larger than 50 — a near-empty array means the extraction lost data");
|
|
10
|
+
const names = MCP_TOOLS.map((t) => t.name);
|
|
11
|
+
assert.equal(new Set(names).size, names.length, "duplicate tool names would mean two schemas collided during the move");
|
|
12
|
+
for (const tool of MCP_TOOLS) {
|
|
13
|
+
assert.equal(typeof tool.name, "string");
|
|
14
|
+
assert.equal(typeof tool.description, "string");
|
|
15
|
+
assert.equal(typeof tool.inputSchema, "object");
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("MCP_WRITE_TOOL_NAMES names only tools that actually exist in MCP_TOOLS", () => {
|
|
20
|
+
const names = new Set(MCP_TOOLS.map((t) => t.name));
|
|
21
|
+
for (const writeName of MCP_WRITE_TOOL_NAMES) {
|
|
22
|
+
assert.ok(names.has(writeName), `${writeName} is in MCP_WRITE_TOOL_NAMES but not in MCP_TOOLS — a stale/renamed entry`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("filterMcpToolsForWriteMode hides write tools by default and reveals them under CLAUTH_MCP_WRITE=1", () => {
|
|
27
|
+
const old = process.env.CLAUTH_MCP_WRITE;
|
|
28
|
+
try {
|
|
29
|
+
delete process.env.CLAUTH_MCP_WRITE;
|
|
30
|
+
const hidden = filterMcpToolsForWriteMode(MCP_TOOLS);
|
|
31
|
+
assert.equal(hidden.length, MCP_TOOLS.length - MCP_WRITE_TOOL_NAMES.size);
|
|
32
|
+
assert.ok(hidden.every((t) => !MCP_WRITE_TOOL_NAMES.has(t.name)));
|
|
33
|
+
|
|
34
|
+
process.env.CLAUTH_MCP_WRITE = "1";
|
|
35
|
+
const revealed = filterMcpToolsForWriteMode(MCP_TOOLS);
|
|
36
|
+
assert.equal(revealed.length, MCP_TOOLS.length);
|
|
37
|
+
} finally {
|
|
38
|
+
if (old === undefined) delete process.env.CLAUTH_MCP_WRITE;
|
|
39
|
+
else process.env.CLAUTH_MCP_WRITE = old;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Regression: cli/index.js does `import { MCP_TOOLS } from "./commands/serve.js"`.
|
|
44
|
+
// serve.js no longer DEFINES MCP_TOOLS — it re-exports the moved module's copy.
|
|
45
|
+
// If that re-export line were ever dropped, this is the test that catches it,
|
|
46
|
+
// not a runtime failure discovered by whoever next runs `clauth mcp list`.
|
|
47
|
+
test("serve.js re-exports the SAME MCP_TOOLS array the tool-schemas module defines", () => {
|
|
48
|
+
assert.equal(reExported, MCP_TOOLS, "serve.js's re-export must be the identical array reference, not a copy");
|
|
49
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// cli/ops/pm2-preflight.js
|
|
2
|
+
// PM2 is clauth's deployment fabric -- the ops server (cli/http/servers/
|
|
3
|
+
// ops-server.js) and `clauth ops install` cannot exist without it. Both used
|
|
4
|
+
// to do `import pm2 from "pm2"` as a static top-level import: a missing or
|
|
5
|
+
// corrupted node_modules/pm2 install made that import throw at module-load
|
|
6
|
+
// time, which -- because serve.js imports ops-server.js unconditionally --
|
|
7
|
+
// crashed the ENTIRE clauth daemon (vault included), not just the ops
|
|
8
|
+
// feature, with no recovery attempt. pm2 is a real declared dependency in
|
|
9
|
+
// package.json, so a plain `npm install` in clauth's own repo root restores
|
|
10
|
+
// it; this is the shared repair path both call sites use instead of a bare
|
|
11
|
+
// static import.
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
17
|
+
|
|
18
|
+
export async function ensurePm2() {
|
|
19
|
+
try {
|
|
20
|
+
const mod = await import("pm2");
|
|
21
|
+
return mod.default ?? mod;
|
|
22
|
+
} catch (firstError) {
|
|
23
|
+
try {
|
|
24
|
+
execSync("npm install", { cwd: ROOT_DIR, stdio: "pipe" });
|
|
25
|
+
} catch (installError) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`pm2 is missing and \`npm install\` failed to restore it (${installError.message}). ` +
|
|
28
|
+
`clauth's ops/deployment fabric cannot start without pm2. Original error: ${firstError.message}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const mod = await import("pm2");
|
|
33
|
+
return mod.default ?? mod;
|
|
34
|
+
} catch (secondError) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`pm2 is still unavailable after \`npm install\` (${secondError.message}). ` +
|
|
37
|
+
`clauth's ops/deployment fabric cannot start without pm2.`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A cheap presence-only check (no repair) for callers that just want to know
|
|
44
|
+
// before deciding whether to run the repair themselves -- e.g. `clauth
|
|
45
|
+
// install`'s prerequisite check, which reports the finding rather than
|
|
46
|
+
// silently repairing mid-install.
|
|
47
|
+
export async function pm2Available() {
|
|
48
|
+
try {
|
|
49
|
+
await import("pm2");
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/cli/recovery.js
CHANGED
|
@@ -9,6 +9,12 @@ import * as api from "./api.js";
|
|
|
9
9
|
const RECOVERY_VERSION = 1;
|
|
10
10
|
|
|
11
11
|
function getRecoveryDir() {
|
|
12
|
+
// Test isolation escape hatch, same pattern as supervisor-registry.js's
|
|
13
|
+
// CLAUTH_SUPERVISOR_DIR: getConfOptions() has no override and always resolves
|
|
14
|
+
// to the real user's AppData, so without this a test calling
|
|
15
|
+
// snapshotCredentialBeforeWrite() would write into Dave's actual recovery
|
|
16
|
+
// folder rather than a throwaway temp dir.
|
|
17
|
+
if (process.env.CLAUTH_RECOVERY_DIR) return process.env.CLAUTH_RECOVERY_DIR;
|
|
12
18
|
const opts = getConfOptions();
|
|
13
19
|
if (opts.cwd) return path.join(opts.cwd, "recovery");
|
|
14
20
|
return path.join(os.homedir(), ".config", "clauth", "recovery");
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
normalizeCredentialValue,
|
|
9
|
+
snapshotCredentialBeforeWrite,
|
|
10
|
+
writeCredentialWithRecovery,
|
|
11
|
+
} from "./recovery.js";
|
|
12
|
+
|
|
13
|
+
// `fn` is always async — MUST be awaited inside try, or this finally fires
|
|
14
|
+
// (and rmSync deletes the dir) before fn's body has even made its first fetch
|
|
15
|
+
// call, and every assertion inside fn then races a directory that is already
|
|
16
|
+
// gone.
|
|
17
|
+
async function withTempRecoveryDir(fn) {
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-recovery-test-"));
|
|
19
|
+
const old = process.env.CLAUTH_RECOVERY_DIR;
|
|
20
|
+
process.env.CLAUTH_RECOVERY_DIR = dir;
|
|
21
|
+
const machineIdOld = process.env.CLAUTH_MACHINE_ID;
|
|
22
|
+
process.env.CLAUTH_MACHINE_ID = "recovery-test-machine";
|
|
23
|
+
try {
|
|
24
|
+
return await fn(dir);
|
|
25
|
+
} finally {
|
|
26
|
+
if (old === undefined) delete process.env.CLAUTH_RECOVERY_DIR;
|
|
27
|
+
else process.env.CLAUTH_RECOVERY_DIR = old;
|
|
28
|
+
if (machineIdOld === undefined) delete process.env.CLAUTH_MACHINE_ID;
|
|
29
|
+
else process.env.CLAUTH_MACHINE_ID = machineIdOld;
|
|
30
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// api.js's post() calls the global fetch — mocking it here needs no ESM
|
|
35
|
+
// module-mock trickery, since fetch is a global rather than a named export.
|
|
36
|
+
function withMockFetch(handler, fn) {
|
|
37
|
+
const old = globalThis.fetch;
|
|
38
|
+
globalThis.fetch = handler;
|
|
39
|
+
return (async () => {
|
|
40
|
+
try { return await fn(); } finally { globalThis.fetch = old; }
|
|
41
|
+
})();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function jsonResponse(status, body) {
|
|
45
|
+
return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── normalizeCredentialValue: pure, no mocking needed ────────────────────────
|
|
49
|
+
|
|
50
|
+
test("normalizeCredentialValue passes ordinary values through untouched", () => {
|
|
51
|
+
assert.equal(normalizeCredentialValue("hello", { service: "npm-token" }), "hello");
|
|
52
|
+
assert.equal(normalizeCredentialValue(42, {}), 42);
|
|
53
|
+
assert.equal(normalizeCredentialValue(null, {}), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("normalizeCredentialValue normalizes line endings and trailing newline on a PEM key", () => {
|
|
57
|
+
const pem = "-----BEGIN RSA PRIVATE KEY-----\r\nabc\r\ndef\r\n-----END RSA PRIVATE KEY-----";
|
|
58
|
+
const out = normalizeCredentialValue(pem, { service: "some-key" });
|
|
59
|
+
assert.ok(!out.includes("\r"), "CRLF must be normalized to LF for a PEM value");
|
|
60
|
+
assert.ok(out.endsWith("-----END RSA PRIVATE KEY-----\n"), "must end with exactly one trailing newline");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("normalizeCredentialValue treats an ssh-typed or ssh-named service as key material even without PEM headers", () => {
|
|
64
|
+
const raw = "ssh-ed25519 AAAAC3...\r\n";
|
|
65
|
+
const bySuffix = normalizeCredentialValue(raw, { service: "vultr-dev-ssh" });
|
|
66
|
+
assert.ok(!bySuffix.includes("\r"));
|
|
67
|
+
const byKeyType = normalizeCredentialValue(raw, { keyType: "ssh", service: "anything" });
|
|
68
|
+
assert.ok(!byKeyType.includes("\r"));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("normalizeCredentialValue leaves a plain token's newlines alone — it is not key material", () => {
|
|
72
|
+
const value = "npm_abc123\r\nsome-trailing-junk";
|
|
73
|
+
assert.equal(normalizeCredentialValue(value, { service: "npm-token" }), value);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── snapshotCredentialBeforeWrite: needs a fetch mock + isolated dir ────────
|
|
77
|
+
|
|
78
|
+
test("snapshotCredentialBeforeWrite refuses without password, machineHash, or service", async () => {
|
|
79
|
+
const result = await snapshotCredentialBeforeWrite({ password: "", machineHash: "h", service: "s" });
|
|
80
|
+
assert.equal(result.ok, false);
|
|
81
|
+
assert.equal(result.skipped, "missing_context");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("snapshotCredentialBeforeWrite writes an encrypted, recoverable snapshot for an existing credential", async () =>
|
|
85
|
+
await withTempRecoveryDir(async (dir) => {
|
|
86
|
+
await withMockFetch(
|
|
87
|
+
async () => jsonResponse(200, { value: "super-secret-value", key_type: "token" }),
|
|
88
|
+
async () => {
|
|
89
|
+
const result = await snapshotCredentialBeforeWrite({
|
|
90
|
+
password: "hunter2", machineHash: "abc123", service: "demo-service",
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
93
|
+
assert.ok(fs.existsSync(result.filePath), "the snapshot file must actually exist on disk");
|
|
94
|
+
const payload = JSON.parse(fs.readFileSync(result.filePath, "utf8"));
|
|
95
|
+
assert.equal(payload.service, "demo-service");
|
|
96
|
+
assert.equal(payload.cipher, "aes-256-gcm");
|
|
97
|
+
// The ciphertext must not contain the plaintext — the whole point of
|
|
98
|
+
// encrypting the snapshot rather than writing the raw value to disk.
|
|
99
|
+
const raw = fs.readFileSync(result.filePath, "utf8");
|
|
100
|
+
assert.ok(!raw.includes("super-secret-value"), "the plaintext secret leaked into the snapshot file");
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
test("snapshotCredentialBeforeWrite is a safe no-op when there is no existing value to protect", async () =>
|
|
106
|
+
await withTempRecoveryDir(async (dir) => {
|
|
107
|
+
await withMockFetch(
|
|
108
|
+
async () => jsonResponse(200, { value: null }),
|
|
109
|
+
async () => {
|
|
110
|
+
const result = await snapshotCredentialBeforeWrite({
|
|
111
|
+
password: "hunter2", machineHash: "abc123", service: "brand-new-service",
|
|
112
|
+
});
|
|
113
|
+
assert.equal(result.ok, false);
|
|
114
|
+
assert.equal(result.skipped, "no_existing_value");
|
|
115
|
+
assert.equal(fs.readdirSync(dir).length, 0, "nothing should be written when there is nothing to snapshot");
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
test("snapshotCredentialBeforeWrite reports retrieve_failed without throwing when the vault is unreachable", async () =>
|
|
121
|
+
await withTempRecoveryDir(async () => {
|
|
122
|
+
await withMockFetch(
|
|
123
|
+
async () => { throw new Error("fetch failed"); },
|
|
124
|
+
async () => {
|
|
125
|
+
const result = await snapshotCredentialBeforeWrite({
|
|
126
|
+
password: "hunter2", machineHash: "abc123", service: "demo-service",
|
|
127
|
+
});
|
|
128
|
+
assert.equal(result.ok, false);
|
|
129
|
+
assert.equal(result.skipped, "retrieve_failed");
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
// ── writeCredentialWithRecovery: snapshot-then-write orchestration ─────────
|
|
135
|
+
|
|
136
|
+
test("writeCredentialWithRecovery snapshots the OLD value before writing the new one", async () =>
|
|
137
|
+
await withTempRecoveryDir(async (dir) => {
|
|
138
|
+
const calls = [];
|
|
139
|
+
await withMockFetch(
|
|
140
|
+
async (url, opts) => {
|
|
141
|
+
const body = JSON.parse(opts.body);
|
|
142
|
+
calls.push(body.action || url);
|
|
143
|
+
if (String(url).includes("/status")) return jsonResponse(200, { services: [{ name: "demo-service", key_type: "token" }] });
|
|
144
|
+
if (String(url).includes("/retrieve")) return jsonResponse(200, { value: "old-value" });
|
|
145
|
+
if (String(url).includes("/write")) return jsonResponse(200, { ok: true });
|
|
146
|
+
return jsonResponse(404, { error: "unknown route" });
|
|
147
|
+
},
|
|
148
|
+
async () => {
|
|
149
|
+
const { result, snapshot } = await writeCredentialWithRecovery({
|
|
150
|
+
password: "hunter2", machineHash: "abc123", service: "demo-service", value: "new-value",
|
|
151
|
+
});
|
|
152
|
+
assert.equal(result.ok, true);
|
|
153
|
+
assert.equal(snapshot.ok, true, "the pre-write snapshot must succeed so 'old-value' is recoverable");
|
|
154
|
+
const payload = JSON.parse(fs.readFileSync(snapshot.filePath, "utf8"));
|
|
155
|
+
assert.equal(payload.service, "demo-service");
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
assert.ok(fs.readdirSync(dir).length >= 1, "a snapshot file must exist after the write");
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
test("writeCredentialWithRecovery still writes the new value even when there was nothing to snapshot", async () =>
|
|
162
|
+
await withTempRecoveryDir(async () => {
|
|
163
|
+
await withMockFetch(
|
|
164
|
+
async (url) => {
|
|
165
|
+
if (String(url).includes("/status")) return jsonResponse(200, { services: [] });
|
|
166
|
+
if (String(url).includes("/retrieve")) return jsonResponse(200, { value: null });
|
|
167
|
+
if (String(url).includes("/write")) return jsonResponse(200, { ok: true });
|
|
168
|
+
return jsonResponse(404, { error: "unknown route" });
|
|
169
|
+
},
|
|
170
|
+
async () => {
|
|
171
|
+
const { result, snapshot } = await writeCredentialWithRecovery({
|
|
172
|
+
password: "hunter2", machineHash: "abc123", service: "brand-new-service", value: "new-value",
|
|
173
|
+
});
|
|
174
|
+
assert.equal(result.ok, true, "the write itself must not be blocked by a failed/skipped snapshot");
|
|
175
|
+
assert.equal(snapshot.ok, false);
|
|
176
|
+
assert.equal(snapshot.skipped, "no_existing_value");
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
}));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// cli/services/agent-pool-instance.js
|
|
2
|
+
// The lazily-instantiated singleton behind call_agent's interactive warm pool
|
|
3
|
+
// (call_agent MCP tool + POST /call-agent). Split out of
|
|
4
|
+
// agent-pool-singleton.js -- the SOLID scorer flagged that file's original
|
|
5
|
+
// shape (74.1 total) as mixing this concern with the separate DelegationLane
|
|
6
|
+
// singleton, which shares no worker/slot/queue with this pool.
|
|
7
|
+
//
|
|
8
|
+
// AgentPool itself is defined in ../commands/agent-pool.js; this module only
|
|
9
|
+
// owns the module-level singleton state and lazy construction.
|
|
10
|
+
|
|
11
|
+
import { AgentPool } from "../commands/agent-pool.js";
|
|
12
|
+
|
|
13
|
+
let _agentPool = null;
|
|
14
|
+
export function getAgentPool() {
|
|
15
|
+
if (_agentPool) return _agentPool;
|
|
16
|
+
const poolSize = parseInt(process.env.CLAUTH_AGENT_POOL_SIZE || "2", 10);
|
|
17
|
+
const warmSize = parseInt(process.env.CLAUTH_AGENT_WARM_SIZE || String(Number.isFinite(poolSize) ? poolSize : 2), 10);
|
|
18
|
+
_agentPool = new AgentPool({
|
|
19
|
+
poolSize: Number.isFinite(poolSize) ? poolSize : 2,
|
|
20
|
+
warmSize: Number.isFinite(warmSize) ? warmSize : 2,
|
|
21
|
+
defaultModel: process.env.CLAUTH_AGENT_MODEL || "claude-haiku-4-5",
|
|
22
|
+
});
|
|
23
|
+
_agentPool.startReaper();
|
|
24
|
+
console.log(`[agent-pool] init: binary=${_agentPool.binary ? 'found' : 'MISSING'} warmEnabled=${_agentPool.warmEnabled} available=${_agentPool.available()} poolSize=${_agentPool.poolSize} warmSize=${_agentPool.warmSize}`);
|
|
25
|
+
// WP-P2: boot persistent re-targetable warm workers in the background so the
|
|
26
|
+
// first real call_agent dispatch hits a hot worker (sub-boot latency). Never
|
|
27
|
+
// blocks daemon startup; if warm isn't ready yet, dispatch falls back to cold.
|
|
28
|
+
if (_agentPool.warmEnabled && _agentPool.available()) {
|
|
29
|
+
_agentPool.prime().then((r) => {
|
|
30
|
+
console.log(`[agent-pool] prime complete: mode=${r.mode} warmed=${r.warmed} workers=${r.workers ?? 'n/a'}`);
|
|
31
|
+
}).catch((e) => {
|
|
32
|
+
console.error(`[agent-pool] prime failed: ${e.message}`);
|
|
33
|
+
});
|
|
34
|
+
} else {
|
|
35
|
+
console.log(`[agent-pool] prime skipped: warmEnabled=${_agentPool.warmEnabled} available=${_agentPool.available()}`);
|
|
36
|
+
}
|
|
37
|
+
return _agentPool;
|
|
38
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// cli/services/claude-binary.js
|
|
2
|
+
// Locates the `claude` CLI binary, and the default working directory, for
|
|
3
|
+
// spawning terminal/chitchat sessions. Shared by the terminal session manager
|
|
4
|
+
// and the chitchat collab sessions — neither owns these exclusively, so they
|
|
5
|
+
// get their own small module rather than living inside either.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export const CHITCHAT_CWD = "C:/Dev/regen-root";
|
|
11
|
+
export const CHITCHAT_FALLBACK_CWD = CHITCHAT_CWD;
|
|
12
|
+
|
|
13
|
+
export function findClaudeBinary() {
|
|
14
|
+
const candidates = [
|
|
15
|
+
process.env.CLAUDE_BIN,
|
|
16
|
+
path.join(os.homedir(), '.local', 'bin', process.platform === 'win32' ? 'claude.exe' : 'claude'),
|
|
17
|
+
path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
|
|
18
|
+
path.join(process.env.APPDATA || '', 'npm', 'claude'),
|
|
19
|
+
'claude', // PATH fallback
|
|
20
|
+
].filter(Boolean);
|
|
21
|
+
|
|
22
|
+
for (const c of candidates) {
|
|
23
|
+
try {
|
|
24
|
+
execSync(`"${c}" --version`, { stdio: 'ignore', timeout: 3000 });
|
|
25
|
+
return c;
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// cli/services/delegation-lane-instance.js
|
|
2
|
+
// The lazily-instantiated singleton hop-limited DelegationLane for sub-agent
|
|
3
|
+
// call_agent calls. Split out of agent-pool-singleton.js -- the SOLID scorer
|
|
4
|
+
// flagged that file's original shape as mixing this concern with the
|
|
5
|
+
// separate interactive AgentPool singleton.
|
|
6
|
+
//
|
|
7
|
+
// A SEPARATE, cold-spawn-only lane for DELEGATED (sub-agent) call_agent calls.
|
|
8
|
+
// It shares NO worker, slot, or queue with the interactive AgentPool, so a
|
|
9
|
+
// parent worker delegating to a sub-agent can never starve or hold an
|
|
10
|
+
// interactive pool slot (anti-deadlock -- see DelegationLane in agent-pool.js).
|
|
11
|
+
//
|
|
12
|
+
// The ENTIRE delegation capability is gated behind CLAUTH_AGENT_DELEGATION
|
|
13
|
+
// (default OFF). When off, delegationEnabled() is false, getDelegationLane()
|
|
14
|
+
// returns null and is never constructed, and runCallAgent ignores
|
|
15
|
+
// agent_context.delegation entirely -- call_agent is byte-identical to today.
|
|
16
|
+
|
|
17
|
+
import { DelegationLane } from "../commands/agent-pool.js";
|
|
18
|
+
|
|
19
|
+
export function delegationEnabled() {
|
|
20
|
+
return String(process.env.CLAUTH_AGENT_DELEGATION ?? "0") === "1";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let _delegationLane = null;
|
|
24
|
+
export function getDelegationLane() {
|
|
25
|
+
if (!delegationEnabled()) return null; // gate OFF → lane never exists
|
|
26
|
+
if (_delegationLane) return _delegationLane;
|
|
27
|
+
const maxInFlight = parseInt(process.env.CLAUTH_AGENT_DELEGATION_MAX_INFLIGHT || "4", 10);
|
|
28
|
+
const maxHops = parseInt(process.env.CLAUTH_AGENT_DELEGATION_MAX_HOPS || "2", 10);
|
|
29
|
+
_delegationLane = new DelegationLane({
|
|
30
|
+
maxInFlight: Number.isFinite(maxInFlight) ? maxInFlight : 4,
|
|
31
|
+
maxHops: Number.isFinite(maxHops) ? maxHops : 2,
|
|
32
|
+
defaultModel: process.env.CLAUTH_AGENT_MODEL || "claude-haiku-4-5",
|
|
33
|
+
});
|
|
34
|
+
return _delegationLane;
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { delegationEnabled, getDelegationLane } from "./delegation-lane-instance.js";
|
|
5
|
+
|
|
6
|
+
test("delegationEnabled is off by default — call_agent is byte-identical without it", () => {
|
|
7
|
+
const old = process.env.CLAUTH_AGENT_DELEGATION;
|
|
8
|
+
try {
|
|
9
|
+
delete process.env.CLAUTH_AGENT_DELEGATION;
|
|
10
|
+
assert.equal(delegationEnabled(), false);
|
|
11
|
+
} finally {
|
|
12
|
+
if (old === undefined) delete process.env.CLAUTH_AGENT_DELEGATION;
|
|
13
|
+
else process.env.CLAUTH_AGENT_DELEGATION = old;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("getDelegationLane never constructs a lane while the gate is off", () => {
|
|
18
|
+
const old = process.env.CLAUTH_AGENT_DELEGATION;
|
|
19
|
+
try {
|
|
20
|
+
delete process.env.CLAUTH_AGENT_DELEGATION;
|
|
21
|
+
assert.equal(getDelegationLane(), null);
|
|
22
|
+
} finally {
|
|
23
|
+
if (old === undefined) delete process.env.CLAUTH_AGENT_DELEGATION;
|
|
24
|
+
else process.env.CLAUTH_AGENT_DELEGATION = old;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("delegationEnabled reads CLAUTH_AGENT_DELEGATION=1 as on", () => {
|
|
29
|
+
const old = process.env.CLAUTH_AGENT_DELEGATION;
|
|
30
|
+
try {
|
|
31
|
+
process.env.CLAUTH_AGENT_DELEGATION = "1";
|
|
32
|
+
assert.equal(delegationEnabled(), true);
|
|
33
|
+
} finally {
|
|
34
|
+
if (old === undefined) delete process.env.CLAUTH_AGENT_DELEGATION;
|
|
35
|
+
else process.env.CLAUTH_AGENT_DELEGATION = old;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// cli/services/file-io.js
|
|
2
|
+
// Small file primitives shared by the fs_* MCP tools: hashing, atomic writes,
|
|
3
|
+
// and stat+hash metadata. Split out of fs-mount.js for the same SRP reason as
|
|
4
|
+
// git-exec.js/fs-upload-sessions.js — these are file-content concerns, not
|
|
5
|
+
// mount-resolution concerns.
|
|
6
|
+
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { mkdir, writeFile, rename, stat, readFile } from "node:fs/promises";
|
|
10
|
+
|
|
11
|
+
export function sha256Hex(value) {
|
|
12
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function atomicWriteText(filePath, content) {
|
|
16
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
17
|
+
const tempPath = path.join(
|
|
18
|
+
path.dirname(filePath),
|
|
19
|
+
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
|
|
20
|
+
);
|
|
21
|
+
await writeFile(tempPath, content, "utf8");
|
|
22
|
+
await rename(tempPath, filePath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function fileInfo(filePath, requestedPath) {
|
|
26
|
+
const s = await stat(filePath);
|
|
27
|
+
const info = {
|
|
28
|
+
path: requestedPath,
|
|
29
|
+
type: s.isDirectory() ? "dir" : "file",
|
|
30
|
+
size: s.size,
|
|
31
|
+
modified: s.mtime.toISOString(),
|
|
32
|
+
};
|
|
33
|
+
if (s.isFile()) {
|
|
34
|
+
const content = await readFile(filePath);
|
|
35
|
+
info.sha256 = sha256Hex(content);
|
|
36
|
+
}
|
|
37
|
+
return info;
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { sha256Hex } from "./file-io.js";
|
|
5
|
+
|
|
6
|
+
test("sha256Hex hashes deterministically", () => {
|
|
7
|
+
assert.equal(sha256Hex("hello"), sha256Hex("hello"));
|
|
8
|
+
assert.notEqual(sha256Hex("hello"), sha256Hex("world"));
|
|
9
|
+
assert.match(sha256Hex("hello"), /^[0-9a-f]{64}$/);
|
|
10
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// cli/services/fs-mount.js
|
|
2
|
+
// Fileserver-mount resolution: loads mount config from the vault (cached),
|
|
3
|
+
// resolves a requested path safely within a mount, and checks the mount's
|
|
4
|
+
// access-flag string. Everything else that used to live in this file (upload
|
|
5
|
+
// sessions, git-exec wrappers, file-io, vault-read, git-import-path guards)
|
|
6
|
+
// moved to its own single-purpose module — see the sibling files in this
|
|
7
|
+
// directory — after the SOLID scorer flagged this file's original 12-export
|
|
8
|
+
// shape as low-SRP ("10 connected component(s) across 12 members").
|
|
9
|
+
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { deriveToken } from "../fingerprint.js";
|
|
12
|
+
import * as api from "../api.js";
|
|
13
|
+
|
|
14
|
+
let _fsMountsCache = null;
|
|
15
|
+
let _fsMountsCacheTime = 0;
|
|
16
|
+
const FS_CACHE_TTL = 60000; // 1 minute
|
|
17
|
+
|
|
18
|
+
export async function getFileserverMounts(vault) {
|
|
19
|
+
if (!vault.password) return { error: "Vault is locked — unlock first" };
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
if (_fsMountsCache && now - _fsMountsCacheTime < FS_CACHE_TTL) return { mounts: _fsMountsCache };
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
25
|
+
const result = await api.status(vault.password, vault.machineHash, token, timestamp);
|
|
26
|
+
if (result.error) return { error: result.error };
|
|
27
|
+
const mounts = [];
|
|
28
|
+
for (const s of (result.services || [])) {
|
|
29
|
+
if (s.key_type === "fileserver" && s.enabled) {
|
|
30
|
+
try {
|
|
31
|
+
const { token: t2, timestamp: ts2 } = deriveToken(vault.password, vault.machineHash);
|
|
32
|
+
const secret = await api.retrieve(vault.password, vault.machineHash, t2, ts2, s.name);
|
|
33
|
+
if (secret.value) {
|
|
34
|
+
const config = JSON.parse(secret.value);
|
|
35
|
+
mounts.push({ name: s.name, path: config.path, access: config.access || "r" });
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
_fsMountsCache = mounts;
|
|
41
|
+
_fsMountsCacheTime = now;
|
|
42
|
+
return { mounts };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { error: `Mount lookup failed: ${err.message}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function resolveInMount(requestedPath, mountName, vault) {
|
|
49
|
+
const { mounts, error } = await getFileserverMounts(vault);
|
|
50
|
+
if (error) return { error };
|
|
51
|
+
if (!mounts || mounts.length === 0) return { error: "No fileserver services configured. Add one with key_type='fileserver' and value: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (access flags: r=read w=write d=delete g=git)" };
|
|
52
|
+
const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
|
|
53
|
+
if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
|
|
54
|
+
if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
|
|
55
|
+
const resolved = path.resolve(mount.path, requestedPath);
|
|
56
|
+
const normalized = path.normalize(resolved);
|
|
57
|
+
const relative = path.relative(path.normalize(mount.path), normalized);
|
|
58
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
59
|
+
return { error: `Path escapes mount: ${requestedPath}` };
|
|
60
|
+
}
|
|
61
|
+
return { resolved: normalized, mount };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function checkAccess(mount, flag) {
|
|
65
|
+
return mount.access.includes(flag);
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { resolveInMount, checkAccess } from "./fs-mount.js";
|
|
5
|
+
|
|
6
|
+
test("getFileserverMounts is refused without a vault password", async () => {
|
|
7
|
+
const result = await resolveInMount("a.txt", null, { password: null });
|
|
8
|
+
assert.equal(result.error, "Vault is locked — unlock first");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("checkAccess reads the mount's access-flag string directly", () => {
|
|
12
|
+
assert.equal(checkAccess({ access: "rwdg" }, "w"), true);
|
|
13
|
+
assert.equal(checkAccess({ access: "r" }, "w"), false);
|
|
14
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// cli/services/fs-upload-sessions.js
|
|
2
|
+
// Chunked fs_write_chunk upload session registry: size/count limits + TTL
|
|
3
|
+
// eviction. Split out of fs-mount.js (was one of 12 loosely-related exports
|
|
4
|
+
// in a single file, which the SOLID scorer flagged as low-SRP — "10 connected
|
|
5
|
+
// component(s) across 12 members," i.e. mostly-independent siblings crammed
|
|
6
|
+
// together). This file has exactly one reason to change: upload-session
|
|
7
|
+
// bookkeeping.
|
|
8
|
+
|
|
9
|
+
export const FS_UPLOAD_SESSIONS = new Map();
|
|
10
|
+
export const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
|
|
11
|
+
export const FS_MAX_CHUNKS = 500;
|
|
12
|
+
export const FS_MAX_CHUNK_BYTES = 128 * 1024;
|
|
13
|
+
export const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export function cleanupFsUploadSessions() {
|
|
16
|
+
const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
|
|
17
|
+
for (const [id, session] of FS_UPLOAD_SESSIONS) {
|
|
18
|
+
if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { FS_UPLOAD_SESSIONS, cleanupFsUploadSessions } from "./fs-upload-sessions.js";
|
|
5
|
+
|
|
6
|
+
test("cleanupFsUploadSessions evicts sessions past the TTL and keeps fresh ones", () => {
|
|
7
|
+
FS_UPLOAD_SESSIONS.clear();
|
|
8
|
+
FS_UPLOAD_SESSIONS.set("stale", { updatedAt: 0 });
|
|
9
|
+
FS_UPLOAD_SESSIONS.set("fresh", { updatedAt: Date.now() });
|
|
10
|
+
cleanupFsUploadSessions();
|
|
11
|
+
assert.equal(FS_UPLOAD_SESSIONS.has("stale"), false);
|
|
12
|
+
assert.equal(FS_UPLOAD_SESSIONS.has("fresh"), true);
|
|
13
|
+
FS_UPLOAD_SESSIONS.delete("fresh");
|
|
14
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// cli/services/git-exec.js
|
|
2
|
+
// Simple synchronous git spawn wrappers used by fs_exec and the git-import
|
|
3
|
+
// path specifically. Distinct from ../lib/fs-git.js, which holds the pure,
|
|
4
|
+
// independently-testable git plumbing (commit/diff/branch/status) — these
|
|
5
|
+
// two do NOT duplicate that; they exist because fs_exec needs a raw,
|
|
6
|
+
// unopinionated "run git, give me stdout or throw" primitive in both text
|
|
7
|
+
// and buffer form.
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
export function runGit(cwd, args, opts = {}) {
|
|
12
|
+
const res = spawnSync("git", args, {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
17
|
+
});
|
|
18
|
+
if (res.status !== 0) {
|
|
19
|
+
const detail = (res.stderr || res.stdout || "").trim();
|
|
20
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
21
|
+
}
|
|
22
|
+
return (res.stdout || "").trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runGitRaw(cwd, args, opts = {}) {
|
|
26
|
+
const res = spawnSync("git", args, {
|
|
27
|
+
cwd,
|
|
28
|
+
encoding: "buffer",
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
31
|
+
});
|
|
32
|
+
if (res.status !== 0) {
|
|
33
|
+
const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
|
|
34
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
35
|
+
}
|
|
36
|
+
return res.stdout || Buffer.alloc(0);
|
|
37
|
+
}
|