@lifeaitools/clauth 1.5.81 → 1.5.83
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 +6 -5
- package/README.md +22 -16
- package/cli/commands/npm.js +123 -0
- package/cli/commands/serve.js +642 -71
- package/cli/index.js +134 -0
- package/package.json +1 -1
package/.clauth-skill/SKILL.md
CHANGED
|
@@ -141,11 +141,12 @@ See `references/keys-guide.md` for where to find every credential.
|
|
|
141
141
|
```
|
|
142
142
|
clauth install [--ref R] [--pat P] First-time: provision Supabase + install skill
|
|
143
143
|
clauth setup [--admin-token T] [-p P] Register this machine
|
|
144
|
-
clauth status [-p P] All services + state
|
|
145
|
-
clauth test [-p P] Verify HMAC connection
|
|
146
|
-
clauth list [-p P] Service names
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
clauth status [-p P] All services + state
|
|
145
|
+
clauth test [-p P] Verify HMAC connection
|
|
146
|
+
clauth list [-p P] Service names
|
|
147
|
+
clauth search <query> [-p P] Search names, metadata, and redacted server addresses
|
|
148
|
+
|
|
149
|
+
clauth write key <service> [-p P] Store a credential
|
|
149
150
|
clauth write pw [-p P] Change password
|
|
150
151
|
clauth enable <svc|all> [-p P] Activate service
|
|
151
152
|
clauth disable <svc|all> [-p P] Suspend service
|
package/README.md
CHANGED
|
@@ -72,16 +72,20 @@ clauth get github
|
|
|
72
72
|
```
|
|
73
73
|
clauth install Provision Supabase + install Claude skill
|
|
74
74
|
clauth setup Register this machine with the vault
|
|
75
|
-
clauth status All services + state
|
|
76
|
-
clauth
|
|
75
|
+
clauth status All services + state
|
|
76
|
+
clauth search <query> Find services by name, project, description, or redacted address
|
|
77
|
+
clauth test Verify connection
|
|
77
78
|
|
|
78
79
|
clauth write key <service> Store a credential
|
|
79
80
|
clauth write pw Change password
|
|
80
81
|
clauth enable <svc|all> Activate service
|
|
81
82
|
clauth disable <svc|all> Suspend service
|
|
82
|
-
clauth get <service> Retrieve a key
|
|
83
|
-
|
|
84
|
-
clauth
|
|
83
|
+
clauth get <service> Retrieve a key
|
|
84
|
+
clauth npm whoami Verify npm token without PowerShell secret plumbing
|
|
85
|
+
clauth npm sync-github-secret LIFEAI/rdc-skills
|
|
86
|
+
Update GitHub NPM_TOKEN from clauth
|
|
87
|
+
|
|
88
|
+
clauth add service <n> Register new service
|
|
85
89
|
clauth remove service <n> Remove service
|
|
86
90
|
clauth revoke <svc|all> Delete key (destructive)
|
|
87
91
|
```
|
|
@@ -123,7 +127,7 @@ Full daemon operations reference: see `regen-root/.claude/rules/clauth.md`.
|
|
|
123
127
|
|
|
124
128
|
---
|
|
125
129
|
|
|
126
|
-
## MCP Server — 3 Namespaces,
|
|
130
|
+
## MCP Server — 3 Namespaces, 32 Tools
|
|
127
131
|
|
|
128
132
|
clauth is the single MCP interface for all local tools. One process, namespaced paths:
|
|
129
133
|
|
|
@@ -131,16 +135,18 @@ clauth is the single MCP interface for all local tools. One process, namespaced
|
|
|
131
135
|
|------|-----------|-------|-------------|
|
|
132
136
|
| `/clauth` | `clauth_*` | 13 | Credential vault operations |
|
|
133
137
|
| `/gws` | `gws_*` | 6 | Google Workspace (Gmail, Calendar, Drive) |
|
|
134
|
-
| `/fs` | `fs_*` |
|
|
135
|
-
| `/mcp` | all |
|
|
136
|
-
|
|
137
|
-
### FS Tools
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
- `fs_read`, `fs_write`, `fs_list`, `fs_grep`, `fs_glob`, `fs_delete`, `fs_mkdir`, `fs_mounts`
|
|
141
|
-
- Uses `node:fs/promises` (async), `@vscode/ripgrep` (shipped binary), `fast-glob`
|
|
142
|
-
- Permission flags per mount: `r` (read), `w` (write), `d` (delete)
|
|
143
|
-
- Mount config stored as "fileserver" service type in vault — only configurable through web UI
|
|
138
|
+
| `/fs` | `fs_*` | 13 | Filesystem (read, write, append, chunked write, URL ingest, Git import, stat, grep, glob, delete, mkdir, mounts) |
|
|
139
|
+
| `/mcp` | all | 32 | All namespaces combined (Claude Code) |
|
|
140
|
+
|
|
141
|
+
### FS Tools
|
|
142
|
+
|
|
143
|
+
13 filesystem tools with path-jail security:
|
|
144
|
+
- `fs_read`, `fs_write`, `fs_stat`, `fs_append`, `fs_write_chunk`, `fs_ingest_url`, `fs_import_git_files`, `fs_list`, `fs_grep`, `fs_glob`, `fs_delete`, `fs_mkdir`, `fs_mounts`
|
|
145
|
+
- Uses `node:fs/promises` (async), `@vscode/ripgrep` (shipped binary), `fast-glob`
|
|
146
|
+
- Permission flags per mount: `r` (read), `w` (write), `d` (delete)
|
|
147
|
+
- Mount config stored as "fileserver" service type in vault — only configurable through web UI
|
|
148
|
+
- Large writes should use `fs_write_chunk`; cloud-to-local transfer should use `fs_ingest_url`; guarded appends should pass `expected_sha256` from `fs_stat`
|
|
149
|
+
- Durable new files authored by Claude.ai in GitHub should use `fs_import_git_files` so the local dirty monorepo fetches and restores only named paths without `git pull`
|
|
144
150
|
|
|
145
151
|
### GWS Tools
|
|
146
152
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
|
|
6
|
+
const CLAUTH_NPM_URL = "http://127.0.0.1:52437/v/npm";
|
|
7
|
+
|
|
8
|
+
function run(cmd, args, opts = {}) {
|
|
9
|
+
const useCmd = process.platform === "win32" && ["npm", "gh"].includes(cmd);
|
|
10
|
+
const executable = useCmd ? "cmd.exe" : cmd;
|
|
11
|
+
const finalArgs = useCmd ? ["/d", "/s", "/c", cmd, ...args] : args;
|
|
12
|
+
const result = spawnSync(executable, finalArgs, {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
...opts,
|
|
15
|
+
env: { ...process.env, ...(opts.env || {}) }
|
|
16
|
+
});
|
|
17
|
+
if (result.error) throw result.error;
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function redactNpmTokenList(text) {
|
|
22
|
+
return String(text || "").replace(/npm_[A-Za-z0-9]+/g, token => `${token.slice(0, 9)}...${token.slice(-4)}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fetchNpmToken() {
|
|
26
|
+
const response = await fetch(CLAUTH_NPM_URL);
|
|
27
|
+
if (!response.ok) throw new Error(`failed to fetch npm token from clauth daemon: HTTP ${response.status}`);
|
|
28
|
+
const token = (await response.text()).trim();
|
|
29
|
+
if (!token) throw new Error("clauth npm token is empty");
|
|
30
|
+
if (!token.startsWith("npm_")) throw new Error("clauth npm token does not look like an npm token");
|
|
31
|
+
return token;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function withNpmAuth(token, fn) {
|
|
35
|
+
const npmrc = path.join(os.tmpdir(), `clauth-npm-${process.pid}-${Date.now()}.npmrc`);
|
|
36
|
+
try {
|
|
37
|
+
fs.writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}`, { encoding: "utf8", mode: 0o600 });
|
|
38
|
+
return fn(npmrc);
|
|
39
|
+
} finally {
|
|
40
|
+
try { fs.rmSync(npmrc, { force: true }); } catch {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runNpmWithToken(token, args) {
|
|
45
|
+
return withNpmAuth(token, npmrc => run("npm", ["--userconfig", npmrc, ...args]));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function printResult(result, { redact = true } = {}) {
|
|
49
|
+
const stdout = redact ? redactNpmTokenList(result.stdout) : result.stdout;
|
|
50
|
+
const stderr = redact ? redactNpmTokenList(result.stderr) : result.stderr;
|
|
51
|
+
if (stdout) process.stdout.write(stdout);
|
|
52
|
+
if (stderr) process.stderr.write(stderr);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function usage() {
|
|
56
|
+
console.log(`clauth npm <action>
|
|
57
|
+
|
|
58
|
+
Actions:
|
|
59
|
+
whoami Verify the clauth npm token identity
|
|
60
|
+
tokens List npm token metadata with token strings redacted
|
|
61
|
+
set-local Write the clauth npm token to the user npm config
|
|
62
|
+
sync-github-secret <repo> Set repo secret NPM_TOKEN from clauth, e.g. LIFEAI/rdc-skills
|
|
63
|
+
rerun <run-id> --repo <repo> Rerun a failed GitHub Actions workflow
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runNpm(action = "help", opts = {}) {
|
|
68
|
+
if (action === "help") {
|
|
69
|
+
usage();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const token = await fetchNpmToken();
|
|
74
|
+
|
|
75
|
+
if (action === "whoami") {
|
|
76
|
+
const result = runNpmWithToken(token, ["whoami", "--registry=https://registry.npmjs.org/"]);
|
|
77
|
+
printResult(result);
|
|
78
|
+
if (result.status !== 0) process.exitCode = result.status;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (action === "tokens") {
|
|
83
|
+
const result = runNpmWithToken(token, ["token", "list", "--json", "--registry=https://registry.npmjs.org/"]);
|
|
84
|
+
printResult(result);
|
|
85
|
+
if (result.status !== 0) process.exitCode = result.status;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (action === "set-local") {
|
|
90
|
+
const result = run("npm", ["config", "set", "//registry.npmjs.org/:_authToken", token, "--location=user"]);
|
|
91
|
+
if (result.status !== 0) {
|
|
92
|
+
printResult(result);
|
|
93
|
+
process.exitCode = result.status;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log("local npm auth updated from clauth service 'npm'");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (action === "sync-github-secret") {
|
|
101
|
+
const repo = opts.repo || opts.args?.[0];
|
|
102
|
+
if (!repo) throw new Error("repo is required, e.g. clauth npm sync-github-secret LIFEAI/rdc-skills");
|
|
103
|
+
const result = run("gh", ["secret", "set", "NPM_TOKEN", "--repo", repo], { input: token });
|
|
104
|
+
printResult(result);
|
|
105
|
+
if (result.status !== 0) process.exitCode = result.status;
|
|
106
|
+
else console.log(`GitHub secret NPM_TOKEN updated for ${repo}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (action === "rerun") {
|
|
111
|
+
const runId = opts.args?.[0];
|
|
112
|
+
const repo = opts.repo;
|
|
113
|
+
if (!runId || !repo) throw new Error("usage: clauth npm rerun <run-id> --repo LIFEAI/rdc-skills");
|
|
114
|
+
const result = run("gh", ["run", "rerun", runId, "--repo", repo, "--failed"]);
|
|
115
|
+
printResult(result);
|
|
116
|
+
if (result.status !== 0) process.exitCode = result.status;
|
|
117
|
+
else console.log(`rerun requested for ${repo} run ${runId}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
usage();
|
|
122
|
+
throw new Error(`unknown clauth npm action: ${action}`);
|
|
123
|
+
}
|
package/cli/commands/serve.js
CHANGED
|
@@ -17,7 +17,7 @@ import ora from "ora";
|
|
|
17
17
|
import { execSync as execSyncTop } from "child_process";
|
|
18
18
|
import Conf from "conf";
|
|
19
19
|
import { getConfOptions } from "../conf-path.js";
|
|
20
|
-
import { readdir, readFile, writeFile, rm, mkdir, stat, rename } from "node:fs/promises";
|
|
20
|
+
import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename } from "node:fs/promises";
|
|
21
21
|
import fg from "fast-glob";
|
|
22
22
|
import { rgPath } from "@vscode/ripgrep";
|
|
23
23
|
import { createStudioDebugRuntime } from "../studio-debug.js";
|
|
@@ -5076,8 +5076,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5076
5076
|
}
|
|
5077
5077
|
|
|
5078
5078
|
// POST /update-service — update service metadata (project, label, description)
|
|
5079
|
-
if (method === "POST" && reqPath === "/update-service") {
|
|
5080
|
-
if (lockedGuard(res)) return;
|
|
5079
|
+
if (method === "POST" && reqPath === "/update-service") {
|
|
5080
|
+
if (lockedGuard(res)) return;
|
|
5081
5081
|
|
|
5082
5082
|
let body;
|
|
5083
5083
|
try { body = await readBody(req); } catch {
|
|
@@ -5108,10 +5108,39 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5108
5108
|
return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
|
|
5109
5109
|
} catch (err) {
|
|
5110
5110
|
return strike(res, 502, err.message);
|
|
5111
|
-
}
|
|
5112
|
-
}
|
|
5113
|
-
|
|
5114
|
-
|
|
5111
|
+
}
|
|
5112
|
+
}
|
|
5113
|
+
|
|
5114
|
+
if (method === "GET" && reqPath === "/search") {
|
|
5115
|
+
if (lockedGuard(res)) return;
|
|
5116
|
+
const query = (url.searchParams.get("q") || url.searchParams.get("query") || "").trim();
|
|
5117
|
+
const project = (url.searchParams.get("project") || "").trim() || undefined;
|
|
5118
|
+
const includeAddresses = url.searchParams.get("addresses") !== "false";
|
|
5119
|
+
if (!query) {
|
|
5120
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5121
|
+
return res.end(JSON.stringify({ error: "q query parameter is required" }));
|
|
5122
|
+
}
|
|
5123
|
+
|
|
5124
|
+
try {
|
|
5125
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
5126
|
+
const result = await searchServices({
|
|
5127
|
+
password,
|
|
5128
|
+
machineHash,
|
|
5129
|
+
token,
|
|
5130
|
+
timestamp,
|
|
5131
|
+
project,
|
|
5132
|
+
query,
|
|
5133
|
+
includeAddresses,
|
|
5134
|
+
whitelist
|
|
5135
|
+
});
|
|
5136
|
+
if (result.error) return strike(res, 502, result.error);
|
|
5137
|
+
return ok(res, result);
|
|
5138
|
+
} catch (err) {
|
|
5139
|
+
return strike(res, 502, err.message);
|
|
5140
|
+
}
|
|
5141
|
+
}
|
|
5142
|
+
|
|
5143
|
+
// Unknown route — a wrong URL is not an auth failure. Log it, return 404,
|
|
5115
5144
|
// but do NOT increment failCount (which locks the vault at MAX_FAILS).
|
|
5116
5145
|
// Auth failures (wrong password, wrong token) still strike via /auth and /get/:service.
|
|
5117
5146
|
try {
|
|
@@ -5482,8 +5511,8 @@ async function actionForeground(opts) {
|
|
|
5482
5511
|
// Reuses the same auth model as the HTTP daemon.
|
|
5483
5512
|
// Secrets are delivered via temp files — never in the MCP response.
|
|
5484
5513
|
|
|
5485
|
-
import { createInterface } from "readline";
|
|
5486
|
-
import { execSync, spawn as spawnProc } from "child_process";
|
|
5514
|
+
import { createInterface } from "readline";
|
|
5515
|
+
import { execSync, spawn as spawnProc, spawnSync } from "child_process";
|
|
5487
5516
|
|
|
5488
5517
|
// ── Monkey dispatch — headless Claude CLI worker ─────────────────
|
|
5489
5518
|
function findClaudeBinary() {
|
|
@@ -6086,9 +6115,9 @@ function stopCodevelopSession(session_id) {
|
|
|
6086
6115
|
return { stopped: true, session_id };
|
|
6087
6116
|
}
|
|
6088
6117
|
|
|
6089
|
-
const ENV_MAP = {
|
|
6090
|
-
"github": "GITHUB_TOKEN",
|
|
6091
|
-
"supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
6118
|
+
const ENV_MAP = {
|
|
6119
|
+
"github": "GITHUB_TOKEN",
|
|
6120
|
+
"supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
6092
6121
|
"supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
|
|
6093
6122
|
"supabase-db": "SUPABASE_DB_URL",
|
|
6094
6123
|
"vercel": "VERCEL_TOKEN",
|
|
@@ -6100,11 +6129,116 @@ const ENV_MAP = {
|
|
|
6100
6129
|
"rocketreach": "ROCKETREACH_API_KEY",
|
|
6101
6130
|
"npm": "NPM_TOKEN",
|
|
6102
6131
|
"namecheap": "NAMECHEAP_API_KEY",
|
|
6103
|
-
"gmail": "GMAIL_CREDENTIALS",
|
|
6104
|
-
};
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6132
|
+
"gmail": "GMAIL_CREDENTIALS",
|
|
6133
|
+
};
|
|
6134
|
+
|
|
6135
|
+
const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
|
|
6136
|
+
const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
|
|
6137
|
+
|
|
6138
|
+
function normalizeSearchText(value) {
|
|
6139
|
+
return String(value || "").toLowerCase();
|
|
6140
|
+
}
|
|
6141
|
+
|
|
6142
|
+
function redactUrlish(value) {
|
|
6143
|
+
const text = String(value || "").trim();
|
|
6144
|
+
if (!text) return "";
|
|
6145
|
+
try {
|
|
6146
|
+
const url = new URL(text);
|
|
6147
|
+
if (url.username) url.username = "***";
|
|
6148
|
+
if (url.password) url.password = "***";
|
|
6149
|
+
return url.toString();
|
|
6150
|
+
} catch {
|
|
6151
|
+
return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
6154
|
+
|
|
6155
|
+
function collectAddressHints(value, keyType) {
|
|
6156
|
+
if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
|
|
6157
|
+
const hints = new Set();
|
|
6158
|
+
|
|
6159
|
+
function add(candidate) {
|
|
6160
|
+
if (candidate === undefined || candidate === null) return;
|
|
6161
|
+
const text = redactUrlish(candidate);
|
|
6162
|
+
if (text) hints.add(text);
|
|
6163
|
+
}
|
|
6164
|
+
|
|
6165
|
+
function walk(node, fieldName = "") {
|
|
6166
|
+
if (node === undefined || node === null) return;
|
|
6167
|
+
if (typeof node === "string") {
|
|
6168
|
+
if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
|
|
6169
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
|
|
6170
|
+
return;
|
|
6171
|
+
}
|
|
6172
|
+
if (Array.isArray(node)) {
|
|
6173
|
+
for (const item of node) walk(item, fieldName);
|
|
6174
|
+
return;
|
|
6175
|
+
}
|
|
6176
|
+
if (typeof node === "object") {
|
|
6177
|
+
for (const [key, child] of Object.entries(node)) walk(child, key);
|
|
6178
|
+
}
|
|
6179
|
+
}
|
|
6180
|
+
|
|
6181
|
+
try {
|
|
6182
|
+
walk(JSON.parse(value));
|
|
6183
|
+
} catch {
|
|
6184
|
+
walk(value);
|
|
6185
|
+
}
|
|
6186
|
+
|
|
6187
|
+
return [...hints];
|
|
6188
|
+
}
|
|
6189
|
+
|
|
6190
|
+
async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = true, whitelist }) {
|
|
6191
|
+
const q = normalizeSearchText(query);
|
|
6192
|
+
if (!q) return { error: "query is required" };
|
|
6193
|
+
|
|
6194
|
+
const result = await api.status(password, machineHash, token, timestamp, project);
|
|
6195
|
+
if (result.error) return { error: result.error };
|
|
6196
|
+
|
|
6197
|
+
let services = result.services || [];
|
|
6198
|
+
if (whitelist) services = services.filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
|
|
6199
|
+
|
|
6200
|
+
const matches = [];
|
|
6201
|
+
for (const s of services) {
|
|
6202
|
+
const fields = {
|
|
6203
|
+
name: s.name,
|
|
6204
|
+
label: s.label,
|
|
6205
|
+
project: s.project,
|
|
6206
|
+
type: s.key_type,
|
|
6207
|
+
description: s.description
|
|
6208
|
+
};
|
|
6209
|
+
const matched = Object.entries(fields)
|
|
6210
|
+
.filter(([, value]) => normalizeSearchText(value).includes(q))
|
|
6211
|
+
.map(([field]) => field);
|
|
6212
|
+
|
|
6213
|
+
let addressHints = [];
|
|
6214
|
+
if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
|
|
6215
|
+
const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
|
|
6216
|
+
if (!secret.error) {
|
|
6217
|
+
addressHints = collectAddressHints(secret.value, s.key_type);
|
|
6218
|
+
if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
|
|
6219
|
+
}
|
|
6220
|
+
}
|
|
6221
|
+
|
|
6222
|
+
if (matched.length) {
|
|
6223
|
+
matches.push({
|
|
6224
|
+
name: s.name,
|
|
6225
|
+
label: s.label || null,
|
|
6226
|
+
project: s.project || null,
|
|
6227
|
+
key_type: s.key_type,
|
|
6228
|
+
enabled: !!s.enabled,
|
|
6229
|
+
has_key: !!s.vault_key,
|
|
6230
|
+
description: s.description || null,
|
|
6231
|
+
matched: [...new Set(matched)],
|
|
6232
|
+
address_hints: addressHints
|
|
6233
|
+
});
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
|
|
6237
|
+
return { query, count: matches.length, matches };
|
|
6238
|
+
}
|
|
6239
|
+
|
|
6240
|
+
// ── Filesystem service config — loaded from clauth vault ──
|
|
6241
|
+
let _fsMountsCache = null;
|
|
6108
6242
|
let _fsMountsCacheTime = 0;
|
|
6109
6243
|
const FS_CACHE_TTL = 60000; // 1 minute
|
|
6110
6244
|
|
|
@@ -6138,24 +6272,115 @@ async function getFileserverMounts(vault) {
|
|
|
6138
6272
|
}
|
|
6139
6273
|
}
|
|
6140
6274
|
|
|
6141
|
-
async function resolveInMount(requestedPath, mountName, vault) {
|
|
6275
|
+
async function resolveInMount(requestedPath, mountName, vault) {
|
|
6142
6276
|
const { mounts, error } = await getFileserverMounts(vault);
|
|
6143
6277
|
if (error) return { error };
|
|
6144
6278
|
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\": \"rwd\"}" };
|
|
6145
6279
|
const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
|
|
6146
6280
|
if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
|
|
6147
6281
|
if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
|
|
6148
|
-
const resolved = path.resolve(mount.path, requestedPath);
|
|
6149
|
-
const normalized = path.normalize(resolved);
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
}
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6282
|
+
const resolved = path.resolve(mount.path, requestedPath);
|
|
6283
|
+
const normalized = path.normalize(resolved);
|
|
6284
|
+
const relative = path.relative(path.normalize(mount.path), normalized);
|
|
6285
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
6286
|
+
return { error: `Path escapes mount: ${requestedPath}` };
|
|
6287
|
+
}
|
|
6288
|
+
return { resolved: normalized, mount };
|
|
6289
|
+
}
|
|
6290
|
+
|
|
6291
|
+
function checkAccess(mount, flag) {
|
|
6292
|
+
return mount.access.includes(flag);
|
|
6293
|
+
}
|
|
6294
|
+
|
|
6295
|
+
function sha256Hex(value) {
|
|
6296
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
6297
|
+
}
|
|
6298
|
+
|
|
6299
|
+
async function atomicWriteText(filePath, content) {
|
|
6300
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
6301
|
+
const tempPath = path.join(
|
|
6302
|
+
path.dirname(filePath),
|
|
6303
|
+
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`
|
|
6304
|
+
);
|
|
6305
|
+
await writeFile(tempPath, content, "utf8");
|
|
6306
|
+
await rename(tempPath, filePath);
|
|
6307
|
+
}
|
|
6308
|
+
|
|
6309
|
+
async function fileInfo(filePath, requestedPath) {
|
|
6310
|
+
const s = await stat(filePath);
|
|
6311
|
+
const info = {
|
|
6312
|
+
path: requestedPath,
|
|
6313
|
+
type: s.isDirectory() ? "dir" : "file",
|
|
6314
|
+
size: s.size,
|
|
6315
|
+
modified: s.mtime.toISOString(),
|
|
6316
|
+
};
|
|
6317
|
+
if (s.isFile()) {
|
|
6318
|
+
const content = await readFile(filePath);
|
|
6319
|
+
info.sha256 = sha256Hex(content);
|
|
6320
|
+
}
|
|
6321
|
+
return info;
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
const FS_UPLOAD_SESSIONS = new Map();
|
|
6325
|
+
const FS_UPLOAD_TTL_MS = 30 * 60 * 1000;
|
|
6326
|
+
const FS_MAX_CHUNKS = 500;
|
|
6327
|
+
const FS_MAX_CHUNK_BYTES = 128 * 1024;
|
|
6328
|
+
const FS_MAX_INGEST_BYTES = 25 * 1024 * 1024;
|
|
6329
|
+
const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
|
|
6330
|
+
"docs/",
|
|
6331
|
+
".rdc/plans/",
|
|
6332
|
+
".rdc/guides/",
|
|
6333
|
+
".claude/context/",
|
|
6334
|
+
".claude/rules/",
|
|
6335
|
+
".rdc/relay/from-claude-ai/",
|
|
6336
|
+
];
|
|
6337
|
+
|
|
6338
|
+
function cleanupFsUploadSessions() {
|
|
6339
|
+
const cutoff = Date.now() - FS_UPLOAD_TTL_MS;
|
|
6340
|
+
for (const [id, session] of FS_UPLOAD_SESSIONS) {
|
|
6341
|
+
if (session.updatedAt < cutoff) FS_UPLOAD_SESSIONS.delete(id);
|
|
6342
|
+
}
|
|
6343
|
+
}
|
|
6344
|
+
|
|
6345
|
+
function runGit(cwd, args, opts = {}) {
|
|
6346
|
+
const res = spawnSync("git", args, {
|
|
6347
|
+
cwd,
|
|
6348
|
+
encoding: "utf8",
|
|
6349
|
+
windowsHide: true,
|
|
6350
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6351
|
+
});
|
|
6352
|
+
if (res.status !== 0) {
|
|
6353
|
+
const detail = (res.stderr || res.stdout || "").trim();
|
|
6354
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6355
|
+
}
|
|
6356
|
+
return (res.stdout || "").trim();
|
|
6357
|
+
}
|
|
6358
|
+
|
|
6359
|
+
function runGitRaw(cwd, args, opts = {}) {
|
|
6360
|
+
const res = spawnSync("git", args, {
|
|
6361
|
+
cwd,
|
|
6362
|
+
encoding: "buffer",
|
|
6363
|
+
windowsHide: true,
|
|
6364
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
6365
|
+
});
|
|
6366
|
+
if (res.status !== 0) {
|
|
6367
|
+
const detail = Buffer.concat([res.stderr || Buffer.alloc(0), res.stdout || Buffer.alloc(0)]).toString("utf8").trim();
|
|
6368
|
+
throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
6369
|
+
}
|
|
6370
|
+
return res.stdout || Buffer.alloc(0);
|
|
6371
|
+
}
|
|
6372
|
+
|
|
6373
|
+
function normalizeRepoPath(p) {
|
|
6374
|
+
if (!p || typeof p !== "string") return null;
|
|
6375
|
+
const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
6376
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
6377
|
+
if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
|
|
6378
|
+
return parts.join("/");
|
|
6379
|
+
}
|
|
6380
|
+
|
|
6381
|
+
function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
|
|
6382
|
+
return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
|
|
6383
|
+
}
|
|
6159
6384
|
|
|
6160
6385
|
const MCP_TOOLS = [
|
|
6161
6386
|
{
|
|
@@ -6178,14 +6403,28 @@ const MCP_TOOLS = [
|
|
|
6178
6403
|
description: "List all services with type, enabled state, key presence, and last retrieval time",
|
|
6179
6404
|
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6180
6405
|
},
|
|
6181
|
-
{
|
|
6182
|
-
name: "clauth_list",
|
|
6183
|
-
description: "List registered service names",
|
|
6184
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6185
|
-
},
|
|
6186
|
-
{
|
|
6187
|
-
name: "
|
|
6188
|
-
description: "
|
|
6406
|
+
{
|
|
6407
|
+
name: "clauth_list",
|
|
6408
|
+
description: "List registered service names",
|
|
6409
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
6410
|
+
},
|
|
6411
|
+
{
|
|
6412
|
+
name: "clauth_search",
|
|
6413
|
+
description: "Search registered services by name, label, project, description, type, or redacted address hints from address-bearing secrets",
|
|
6414
|
+
inputSchema: {
|
|
6415
|
+
type: "object",
|
|
6416
|
+
properties: {
|
|
6417
|
+
query: { type: "string", description: "Search text, such as part of a service name, project, host, URL, or filesystem path" },
|
|
6418
|
+
project: { type: "string", description: "Optional project scope" },
|
|
6419
|
+
addresses: { type: "boolean", default: true, description: "Include redacted address hints from connstring/fileserver/oauth secrets" }
|
|
6420
|
+
},
|
|
6421
|
+
required: ["query"],
|
|
6422
|
+
additionalProperties: false
|
|
6423
|
+
}
|
|
6424
|
+
},
|
|
6425
|
+
{
|
|
6426
|
+
name: "clauth_get",
|
|
6427
|
+
description: "Retrieve a secret and deliver to a temp file (default), clipboard, or stdout. Temp files auto-delete after 30 seconds.",
|
|
6189
6428
|
inputSchema: {
|
|
6190
6429
|
type: "object",
|
|
6191
6430
|
properties: {
|
|
@@ -6648,9 +6887,9 @@ const MCP_TOOLS = [
|
|
|
6648
6887
|
additionalProperties: false,
|
|
6649
6888
|
},
|
|
6650
6889
|
},
|
|
6651
|
-
{
|
|
6652
|
-
name: "fs_write",
|
|
6653
|
-
description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
|
|
6890
|
+
{
|
|
6891
|
+
name: "fs_write",
|
|
6892
|
+
description: "Write content to a file. Creates parent directories if needed. Overwrites existing file.",
|
|
6654
6893
|
inputSchema: {
|
|
6655
6894
|
type: "object",
|
|
6656
6895
|
properties: {
|
|
@@ -6659,12 +6898,93 @@ const MCP_TOOLS = [
|
|
|
6659
6898
|
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6660
6899
|
},
|
|
6661
6900
|
required: ["path", "content"],
|
|
6662
|
-
additionalProperties: false,
|
|
6663
|
-
},
|
|
6664
|
-
},
|
|
6665
|
-
{
|
|
6666
|
-
name: "
|
|
6667
|
-
description: "
|
|
6901
|
+
additionalProperties: false,
|
|
6902
|
+
},
|
|
6903
|
+
},
|
|
6904
|
+
{
|
|
6905
|
+
name: "fs_stat",
|
|
6906
|
+
description: "Get file or directory metadata. Files include a SHA-256 hash for guarded edits.",
|
|
6907
|
+
inputSchema: {
|
|
6908
|
+
type: "object",
|
|
6909
|
+
properties: {
|
|
6910
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
6911
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6912
|
+
},
|
|
6913
|
+
required: ["path"],
|
|
6914
|
+
additionalProperties: false,
|
|
6915
|
+
},
|
|
6916
|
+
},
|
|
6917
|
+
{
|
|
6918
|
+
name: "fs_append",
|
|
6919
|
+
description: "Append text to a file, optionally guarded by the current file SHA-256 hash.",
|
|
6920
|
+
inputSchema: {
|
|
6921
|
+
type: "object",
|
|
6922
|
+
properties: {
|
|
6923
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
6924
|
+
content: { type: "string", description: "Text to append" },
|
|
6925
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6926
|
+
expected_sha256: { type: "string", description: "Optional current file SHA-256. If provided and the file exists, append only when it matches." },
|
|
6927
|
+
},
|
|
6928
|
+
required: ["path", "content"],
|
|
6929
|
+
additionalProperties: false,
|
|
6930
|
+
},
|
|
6931
|
+
},
|
|
6932
|
+
{
|
|
6933
|
+
name: "fs_write_chunk",
|
|
6934
|
+
description: "Stage chunked text writes and atomically publish when all chunks arrive. Use when single write arguments are too large.",
|
|
6935
|
+
inputSchema: {
|
|
6936
|
+
type: "object",
|
|
6937
|
+
properties: {
|
|
6938
|
+
upload_id: { type: "string", description: "Caller-chosen idempotency key for this file upload" },
|
|
6939
|
+
path: { type: "string", description: "Relative path within mount" },
|
|
6940
|
+
chunk_index: { type: "number", description: "Zero-based chunk index" },
|
|
6941
|
+
total_chunks: { type: "number", description: "Total chunks expected" },
|
|
6942
|
+
content: { type: "string", description: "Chunk text content" },
|
|
6943
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6944
|
+
expected_sha256: { type: "string", description: "Optional SHA-256 of the final assembled file content" },
|
|
6945
|
+
},
|
|
6946
|
+
required: ["upload_id", "path", "chunk_index", "total_chunks", "content"],
|
|
6947
|
+
additionalProperties: false,
|
|
6948
|
+
},
|
|
6949
|
+
},
|
|
6950
|
+
{
|
|
6951
|
+
name: "fs_ingest_url",
|
|
6952
|
+
description: "Download text from an http(s) URL and atomically write it inside the mount. Useful for moving cloud files into the local filesystem.",
|
|
6953
|
+
inputSchema: {
|
|
6954
|
+
type: "object",
|
|
6955
|
+
properties: {
|
|
6956
|
+
url: { type: "string", description: "HTTPS or HTTP URL to fetch" },
|
|
6957
|
+
path: { type: "string", description: "Relative output path within mount" },
|
|
6958
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6959
|
+
max_bytes: { type: "number", description: "Maximum download size in bytes (default 5MB, hard max 25MB)" },
|
|
6960
|
+
expected_sha256: { type: "string", description: "Optional SHA-256 of fetched content before writing" },
|
|
6961
|
+
},
|
|
6962
|
+
required: ["url", "path"],
|
|
6963
|
+
additionalProperties: false,
|
|
6964
|
+
},
|
|
6965
|
+
},
|
|
6966
|
+
{
|
|
6967
|
+
name: "fs_import_git_files",
|
|
6968
|
+
description: "Fetch a Git ref and restore only named files into the mounted repo, optionally committing just those files. Designed for Claude.ai GitHub uploads into dirty local monorepos.",
|
|
6969
|
+
inputSchema: {
|
|
6970
|
+
type: "object",
|
|
6971
|
+
properties: {
|
|
6972
|
+
remote: { type: "string", description: "Git remote name (default: origin)" },
|
|
6973
|
+
ref: { type: "string", description: "Branch, tag, or commit to fetch/restore from" },
|
|
6974
|
+
paths: { type: "array", items: { type: "string" }, description: "Repo-relative file paths to import" },
|
|
6975
|
+
mode: { type: "string", enum: ["new_only", "overwrite"], description: "new_only refuses existing local paths. overwrite restores named paths only." },
|
|
6976
|
+
commit: { type: "boolean", description: "When true, stage only imported paths and create a local commit. Never pushes." },
|
|
6977
|
+
message: { type: "string", description: "Commit subject when commit=true" },
|
|
6978
|
+
mount: { type: "string", description: "Mount name (default: first mount)" },
|
|
6979
|
+
allowed_prefixes: { type: "array", items: { type: "string" }, description: "Optional path allowlist prefixes. Defaults to docs and agent corpus paths." },
|
|
6980
|
+
},
|
|
6981
|
+
required: ["ref", "paths"],
|
|
6982
|
+
additionalProperties: false,
|
|
6983
|
+
},
|
|
6984
|
+
},
|
|
6985
|
+
{
|
|
6986
|
+
name: "fs_list",
|
|
6987
|
+
description: "List directory contents with file type, size, and modification time.",
|
|
6668
6988
|
inputSchema: {
|
|
6669
6989
|
type: "object",
|
|
6670
6990
|
properties: {
|
|
@@ -6821,10 +7141,10 @@ async function handleMcpTool(vault, name, args) {
|
|
|
6821
7141
|
}
|
|
6822
7142
|
}
|
|
6823
7143
|
|
|
6824
|
-
case "clauth_list": {
|
|
6825
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
6826
|
-
try {
|
|
6827
|
-
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7144
|
+
case "clauth_list": {
|
|
7145
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7146
|
+
try {
|
|
7147
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
6828
7148
|
const result = await api.status(vault.password, vault.machineHash, token, timestamp);
|
|
6829
7149
|
if (result.error) return mcpError(result.error);
|
|
6830
7150
|
let services = result.services || [];
|
|
@@ -6833,13 +7153,34 @@ async function handleMcpTool(vault, name, args) {
|
|
|
6833
7153
|
}
|
|
6834
7154
|
return mcpResult(services.map(s => s.name).join(", "));
|
|
6835
7155
|
} catch (err) {
|
|
6836
|
-
return mcpError(err.message);
|
|
6837
|
-
}
|
|
6838
|
-
}
|
|
6839
|
-
|
|
6840
|
-
case "
|
|
6841
|
-
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
6842
|
-
|
|
7156
|
+
return mcpError(err.message);
|
|
7157
|
+
}
|
|
7158
|
+
}
|
|
7159
|
+
|
|
7160
|
+
case "clauth_search": {
|
|
7161
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7162
|
+
try {
|
|
7163
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
7164
|
+
const result = await searchServices({
|
|
7165
|
+
password: vault.password,
|
|
7166
|
+
machineHash: vault.machineHash,
|
|
7167
|
+
token,
|
|
7168
|
+
timestamp,
|
|
7169
|
+
query: args.query,
|
|
7170
|
+
project: args.project,
|
|
7171
|
+
includeAddresses: args.addresses !== false,
|
|
7172
|
+
whitelist: vault.whitelist
|
|
7173
|
+
});
|
|
7174
|
+
if (result.error) return mcpError(result.error);
|
|
7175
|
+
return mcpResult(JSON.stringify(result, null, 2));
|
|
7176
|
+
} catch (err) {
|
|
7177
|
+
return mcpError(err.message);
|
|
7178
|
+
}
|
|
7179
|
+
}
|
|
7180
|
+
|
|
7181
|
+
case "clauth_get": {
|
|
7182
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
7183
|
+
const service = (args.service || "").toLowerCase();
|
|
6843
7184
|
const target = args.target || "file";
|
|
6844
7185
|
if (!service) return mcpError("service is required");
|
|
6845
7186
|
if (vault.whitelist && !vault.whitelist.includes(service)) {
|
|
@@ -7133,20 +7474,250 @@ async function handleMcpTool(vault, name, args) {
|
|
|
7133
7474
|
}
|
|
7134
7475
|
}
|
|
7135
7476
|
|
|
7136
|
-
case "fs_write": {
|
|
7137
|
-
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7138
|
-
if (r.error) return mcpError(r.error);
|
|
7139
|
-
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7140
|
-
try {
|
|
7141
|
-
await
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7477
|
+
case "fs_write": {
|
|
7478
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7479
|
+
if (r.error) return mcpError(r.error);
|
|
7480
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7481
|
+
try {
|
|
7482
|
+
await atomicWriteText(r.resolved, args.content);
|
|
7483
|
+
return mcpResult(`Written: ${args.path} (${Buffer.byteLength(args.content)} bytes)`);
|
|
7484
|
+
} catch (err) {
|
|
7485
|
+
return mcpError(`Write failed: ${err.message}`);
|
|
7486
|
+
}
|
|
7487
|
+
}
|
|
7488
|
+
|
|
7489
|
+
case "fs_stat": {
|
|
7490
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7491
|
+
if (r.error) return mcpError(r.error);
|
|
7492
|
+
if (!checkAccess(r.mount, "r")) return mcpError("Read access denied on this mount");
|
|
7493
|
+
try {
|
|
7494
|
+
return mcpResult(JSON.stringify(await fileInfo(r.resolved, args.path), null, 2));
|
|
7495
|
+
} catch (err) {
|
|
7496
|
+
if (err.code === "ENOENT") return mcpError(`Not found: ${args.path}`);
|
|
7497
|
+
return mcpError(`Stat failed: ${err.message}`);
|
|
7498
|
+
}
|
|
7499
|
+
}
|
|
7500
|
+
|
|
7501
|
+
case "fs_append": {
|
|
7502
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7503
|
+
if (r.error) return mcpError(r.error);
|
|
7504
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7505
|
+
try {
|
|
7506
|
+
try {
|
|
7507
|
+
const current = await readFile(r.resolved);
|
|
7508
|
+
if (args.expected_sha256 && sha256Hex(current) !== args.expected_sha256) {
|
|
7509
|
+
return mcpError("Append rejected: current file hash does not match expected_sha256");
|
|
7510
|
+
}
|
|
7511
|
+
} catch (err) {
|
|
7512
|
+
if (err.code !== "ENOENT") throw err;
|
|
7513
|
+
if (args.expected_sha256) return mcpError("Append rejected: file does not exist for expected_sha256 guard");
|
|
7514
|
+
}
|
|
7515
|
+
await mkdir(path.dirname(r.resolved), { recursive: true });
|
|
7516
|
+
await appendFile(r.resolved, args.content, "utf8");
|
|
7517
|
+
const info = await fileInfo(r.resolved, args.path);
|
|
7518
|
+
return mcpResult(JSON.stringify({ appended_bytes: Buffer.byteLength(args.content), ...info }, null, 2));
|
|
7519
|
+
} catch (err) {
|
|
7520
|
+
return mcpError(`Append failed: ${err.message}`);
|
|
7521
|
+
}
|
|
7522
|
+
}
|
|
7523
|
+
|
|
7524
|
+
case "fs_write_chunk": {
|
|
7525
|
+
cleanupFsUploadSessions();
|
|
7526
|
+
const { upload_id, chunk_index, total_chunks, content } = args;
|
|
7527
|
+
const index = Number(chunk_index);
|
|
7528
|
+
const total = Number(total_chunks);
|
|
7529
|
+
if (!Number.isInteger(index) || !Number.isInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
7530
|
+
return mcpError("Invalid chunk_index/total_chunks");
|
|
7531
|
+
}
|
|
7532
|
+
if (total > FS_MAX_CHUNKS) return mcpError(`Too many chunks: max ${FS_MAX_CHUNKS}`);
|
|
7533
|
+
if (Buffer.byteLength(content, "utf8") > FS_MAX_CHUNK_BYTES) {
|
|
7534
|
+
return mcpError(`Chunk too large: max ${FS_MAX_CHUNK_BYTES} bytes`);
|
|
7535
|
+
}
|
|
7536
|
+
|
|
7537
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7538
|
+
if (r.error) return mcpError(r.error);
|
|
7539
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7540
|
+
|
|
7541
|
+
const key = `${r.mount.name}:${args.path}:${upload_id}`;
|
|
7542
|
+
let session = FS_UPLOAD_SESSIONS.get(key);
|
|
7543
|
+
if (!session) {
|
|
7544
|
+
session = { path: args.path, resolved: r.resolved, total, chunks: new Map(), expectedSha256: args.expected_sha256 || null, updatedAt: Date.now() };
|
|
7545
|
+
FS_UPLOAD_SESSIONS.set(key, session);
|
|
7546
|
+
}
|
|
7547
|
+
if (session.total !== total || session.path !== args.path || session.resolved !== r.resolved) {
|
|
7548
|
+
return mcpError("Upload id collision: path or total_chunks differs from existing session");
|
|
7549
|
+
}
|
|
7550
|
+
if (args.expected_sha256 && session.expectedSha256 && args.expected_sha256 !== session.expectedSha256) {
|
|
7551
|
+
return mcpError("Upload id collision: expected_sha256 differs from existing session");
|
|
7552
|
+
}
|
|
7553
|
+
|
|
7554
|
+
session.chunks.set(index, content);
|
|
7555
|
+
session.updatedAt = Date.now();
|
|
7556
|
+
|
|
7557
|
+
if (session.chunks.size < total) {
|
|
7558
|
+
return mcpResult(JSON.stringify({ upload_id, status: "staged", received_chunks: session.chunks.size, total_chunks: total }, null, 2));
|
|
7559
|
+
}
|
|
7560
|
+
|
|
7561
|
+
const assembled = Array.from({ length: total }, (_, i) => session.chunks.get(i)).join("");
|
|
7562
|
+
const actualSha = sha256Hex(assembled);
|
|
7563
|
+
if (session.expectedSha256 && actualSha !== session.expectedSha256) {
|
|
7564
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
7565
|
+
return mcpError(`Final SHA-256 mismatch: expected ${session.expectedSha256}, got ${actualSha}`);
|
|
7566
|
+
}
|
|
7567
|
+
|
|
7568
|
+
try {
|
|
7569
|
+
await atomicWriteText(r.resolved, assembled);
|
|
7570
|
+
FS_UPLOAD_SESSIONS.delete(key);
|
|
7571
|
+
return mcpResult(JSON.stringify({ upload_id, status: "written", path: args.path, bytes: Buffer.byteLength(assembled), sha256: actualSha }, null, 2));
|
|
7572
|
+
} catch (err) {
|
|
7573
|
+
return mcpError(`Chunked write failed: ${err.message}`);
|
|
7574
|
+
}
|
|
7575
|
+
}
|
|
7576
|
+
|
|
7577
|
+
case "fs_ingest_url": {
|
|
7578
|
+
const r = await resolveInMount(args.path, args.mount, vault);
|
|
7579
|
+
if (r.error) return mcpError(r.error);
|
|
7580
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7581
|
+
|
|
7582
|
+
let url;
|
|
7583
|
+
try {
|
|
7584
|
+
url = new URL(args.url);
|
|
7585
|
+
} catch {
|
|
7586
|
+
return mcpError("Invalid URL");
|
|
7587
|
+
}
|
|
7588
|
+
if (!["http:", "https:"].includes(url.protocol)) return mcpError("Only http(s) URLs are supported");
|
|
7589
|
+
|
|
7590
|
+
const maxBytes = Math.min(Number(args.max_bytes || 5 * 1024 * 1024), FS_MAX_INGEST_BYTES);
|
|
7591
|
+
try {
|
|
7592
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
7593
|
+
if (!response.ok) return mcpError(`Fetch failed: HTTP ${response.status}`);
|
|
7594
|
+
const length = Number(response.headers.get("content-length") || 0);
|
|
7595
|
+
if (length && length > maxBytes) return mcpError(`Fetch rejected: content-length ${length} exceeds max_bytes ${maxBytes}`);
|
|
7596
|
+
|
|
7597
|
+
const reader = response.body?.getReader();
|
|
7598
|
+
if (!reader) return mcpError("Fetch failed: response body is not readable");
|
|
7599
|
+
|
|
7600
|
+
let received = 0;
|
|
7601
|
+
const chunks = [];
|
|
7602
|
+
while (true) {
|
|
7603
|
+
const { done, value } = await reader.read();
|
|
7604
|
+
if (done) break;
|
|
7605
|
+
received += value.byteLength;
|
|
7606
|
+
if (received > maxBytes) return mcpError(`Fetch rejected: response exceeds max_bytes ${maxBytes}`);
|
|
7607
|
+
chunks.push(Buffer.from(value));
|
|
7608
|
+
}
|
|
7609
|
+
|
|
7610
|
+
const content = Buffer.concat(chunks).toString("utf8");
|
|
7611
|
+
const actualSha = sha256Hex(content);
|
|
7612
|
+
if (args.expected_sha256 && actualSha !== args.expected_sha256) {
|
|
7613
|
+
return mcpError(`Fetched SHA-256 mismatch: expected ${args.expected_sha256}, got ${actualSha}`);
|
|
7614
|
+
}
|
|
7615
|
+
await atomicWriteText(r.resolved, content);
|
|
7616
|
+
return mcpResult(JSON.stringify({ status: "written", path: args.path, bytes: Buffer.byteLength(content), sha256: actualSha, source: url.href }, null, 2));
|
|
7617
|
+
} catch (err) {
|
|
7618
|
+
return mcpError(`Ingest failed: ${err.message}`);
|
|
7619
|
+
}
|
|
7620
|
+
}
|
|
7621
|
+
|
|
7622
|
+
case "fs_import_git_files": {
|
|
7623
|
+
if (!Array.isArray(args.paths) || args.paths.length === 0) return mcpError("paths must be a non-empty array");
|
|
7624
|
+
if (args.paths.length > 25) return mcpError("Too many paths: max 25 per import");
|
|
7625
|
+
|
|
7626
|
+
const r = await resolveInMount(".", args.mount, vault);
|
|
7627
|
+
if (r.error) return mcpError(r.error);
|
|
7628
|
+
if (!checkAccess(r.mount, "w")) return mcpError("Write access denied on this mount");
|
|
7629
|
+
|
|
7630
|
+
const repoRoot = r.resolved;
|
|
7631
|
+
const remote = args.remote || "origin";
|
|
7632
|
+
const mode = args.mode || "new_only";
|
|
7633
|
+
const doCommit = args.commit === true;
|
|
7634
|
+
const allowedPrefixes = Array.isArray(args.allowed_prefixes) && args.allowed_prefixes.length > 0
|
|
7635
|
+
? args.allowed_prefixes.map((p) => normalizeRepoPath(p.endsWith("/") ? p : `${p}/`)).filter(Boolean)
|
|
7636
|
+
: FS_GIT_IMPORT_ALLOWED_PREFIXES;
|
|
7637
|
+
|
|
7638
|
+
try {
|
|
7639
|
+
const topLevel = path.normalize(runGit(repoRoot, ["rev-parse", "--show-toplevel"]));
|
|
7640
|
+
if (topLevel.toLowerCase() !== path.normalize(repoRoot).toLowerCase()) {
|
|
7641
|
+
return mcpError(`Mount root is not the git repo root: ${repoRoot} (repo root: ${topLevel})`);
|
|
7642
|
+
}
|
|
7643
|
+
|
|
7644
|
+
const normalizedPaths = [];
|
|
7645
|
+
for (const rawPath of args.paths) {
|
|
7646
|
+
const normalized = normalizeRepoPath(rawPath);
|
|
7647
|
+
if (!normalized) return mcpError(`Invalid repo path: ${rawPath}`);
|
|
7648
|
+
if (!isAllowedGitImportPath(normalized, allowedPrefixes)) return mcpError(`Path not allowed for git import: ${normalized}`);
|
|
7649
|
+
normalizedPaths.push(normalized);
|
|
7650
|
+
}
|
|
7651
|
+
|
|
7652
|
+
if (mode === "new_only") {
|
|
7653
|
+
for (const rel of normalizedPaths) {
|
|
7654
|
+
const localPath = path.join(repoRoot, rel);
|
|
7655
|
+
try {
|
|
7656
|
+
await stat(localPath);
|
|
7657
|
+
return mcpError(`Import refused: local path already exists in new_only mode: ${rel}`);
|
|
7658
|
+
} catch (err) {
|
|
7659
|
+
if (err.code !== "ENOENT") throw err;
|
|
7660
|
+
}
|
|
7661
|
+
}
|
|
7662
|
+
}
|
|
7663
|
+
|
|
7664
|
+
if (doCommit) {
|
|
7665
|
+
const staged = runGit(repoRoot, ["diff", "--cached", "--name-only"]);
|
|
7666
|
+
if (staged) return mcpError(`Import refused: index already has staged files:\n${staged}`);
|
|
7667
|
+
if (!args.message || !args.message.trim()) return mcpError("message is required when commit=true");
|
|
7668
|
+
}
|
|
7669
|
+
|
|
7670
|
+
runGit(repoRoot, ["fetch", "--no-tags", remote, args.ref]);
|
|
7671
|
+
const sourceCommit = runGit(repoRoot, ["rev-parse", "FETCH_HEAD"]);
|
|
7672
|
+
|
|
7673
|
+
for (const rel of normalizedPaths) {
|
|
7674
|
+
runGit(repoRoot, ["cat-file", "-e", `${sourceCommit}:${rel}`]);
|
|
7675
|
+
}
|
|
7676
|
+
|
|
7677
|
+
runGit(repoRoot, ["restore", `--source=${sourceCommit}`, "--", ...normalizedPaths]);
|
|
7678
|
+
|
|
7679
|
+
const imported = [];
|
|
7680
|
+
for (const rel of normalizedPaths) {
|
|
7681
|
+
const localPath = path.join(repoRoot, rel);
|
|
7682
|
+
const info = await fileInfo(localPath, rel);
|
|
7683
|
+
const sourceBlob = runGit(repoRoot, ["rev-parse", `${sourceCommit}:${rel}`]);
|
|
7684
|
+
const sourceSize = Number(runGitRaw(repoRoot, ["cat-file", "-s", `${sourceCommit}:${rel}`]).toString("utf8").trim());
|
|
7685
|
+
imported.push({ ...info, source_blob: sourceBlob, source_size: sourceSize });
|
|
7686
|
+
}
|
|
7687
|
+
|
|
7688
|
+
let localCommit = null;
|
|
7689
|
+
if (doCommit) {
|
|
7690
|
+
runGit(repoRoot, ["add", "--", ...normalizedPaths]);
|
|
7691
|
+
const body = [
|
|
7692
|
+
args.message.trim(),
|
|
7693
|
+
"",
|
|
7694
|
+
"Imported from Claude.ai GitHub upload.",
|
|
7695
|
+
"",
|
|
7696
|
+
`Source remote: ${remote}`,
|
|
7697
|
+
`Source ref: ${args.ref}`,
|
|
7698
|
+
`Source commit: ${sourceCommit}`,
|
|
7699
|
+
"",
|
|
7700
|
+
"Paths:",
|
|
7701
|
+
...normalizedPaths.map((p) => `- ${p}`),
|
|
7702
|
+
].join("\n");
|
|
7703
|
+
runGit(repoRoot, ["commit", "-m", body]);
|
|
7704
|
+
localCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
7705
|
+
}
|
|
7706
|
+
|
|
7707
|
+
return mcpResult(JSON.stringify({
|
|
7708
|
+
status: "ok",
|
|
7709
|
+
mode,
|
|
7710
|
+
committed: doCommit,
|
|
7711
|
+
source_commit: sourceCommit,
|
|
7712
|
+
local_commit: localCommit,
|
|
7713
|
+
imported,
|
|
7714
|
+
}, null, 2));
|
|
7715
|
+
} catch (err) {
|
|
7716
|
+
return mcpError(`Git import failed: ${err.message}`);
|
|
7717
|
+
}
|
|
7718
|
+
}
|
|
7719
|
+
|
|
7720
|
+
case "fs_list": {
|
|
7150
7721
|
const dirPath = args.path || ".";
|
|
7151
7722
|
const r = await resolveInMount(dirPath, args.mount, vault);
|
|
7152
7723
|
if (r.error) return mcpError(r.error);
|
package/cli/index.js
CHANGED
|
@@ -40,6 +40,97 @@ async function getAuth(pw) {
|
|
|
40
40
|
return { password, machineHash, token, timestamp };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
|
|
44
|
+
const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
|
|
45
|
+
|
|
46
|
+
function normalizeSearchText(value) {
|
|
47
|
+
return String(value || "").toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function redactUrlish(value) {
|
|
51
|
+
const text = String(value || "").trim();
|
|
52
|
+
if (!text) return "";
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(text);
|
|
55
|
+
if (url.username) url.username = "***";
|
|
56
|
+
if (url.password) url.password = "***";
|
|
57
|
+
return url.toString();
|
|
58
|
+
} catch {
|
|
59
|
+
return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function collectAddressHints(value, keyType) {
|
|
64
|
+
if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
|
|
65
|
+
const hints = new Set();
|
|
66
|
+
|
|
67
|
+
function add(candidate) {
|
|
68
|
+
if (candidate === undefined || candidate === null) return;
|
|
69
|
+
const text = redactUrlish(candidate);
|
|
70
|
+
if (text) hints.add(text);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function walk(node, fieldName = "") {
|
|
74
|
+
if (node === undefined || node === null) return;
|
|
75
|
+
if (typeof node === "string") {
|
|
76
|
+
if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
|
|
77
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(node)) {
|
|
81
|
+
for (const item of node) walk(item, fieldName);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (typeof node === "object") {
|
|
85
|
+
for (const [key, child] of Object.entries(node)) walk(child, key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
walk(JSON.parse(value));
|
|
91
|
+
} catch {
|
|
92
|
+
walk(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [...hints];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function searchServices(auth, query, opts = {}) {
|
|
99
|
+
const q = normalizeSearchText(query);
|
|
100
|
+
if (!q) throw new Error("Search query is required");
|
|
101
|
+
const result = await api.status(auth.password, auth.machineHash, auth.token, auth.timestamp, opts.project);
|
|
102
|
+
if (result.error) throw new Error(result.error);
|
|
103
|
+
|
|
104
|
+
const services = result.services || [];
|
|
105
|
+
const rows = [];
|
|
106
|
+
|
|
107
|
+
for (const s of services) {
|
|
108
|
+
const fields = {
|
|
109
|
+
name: s.name,
|
|
110
|
+
label: s.label,
|
|
111
|
+
project: s.project,
|
|
112
|
+
type: s.key_type,
|
|
113
|
+
description: s.description
|
|
114
|
+
};
|
|
115
|
+
const matched = Object.entries(fields)
|
|
116
|
+
.filter(([, value]) => normalizeSearchText(value).includes(q))
|
|
117
|
+
.map(([field]) => field);
|
|
118
|
+
|
|
119
|
+
let addressHints = [];
|
|
120
|
+
if (opts.addresses !== false && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && s.vault_key) {
|
|
121
|
+
const secret = await api.retrieve(auth.password, auth.machineHash, auth.token, auth.timestamp, s.name);
|
|
122
|
+
if (!secret.error) {
|
|
123
|
+
addressHints = collectAddressHints(secret.value, s.key_type);
|
|
124
|
+
if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (matched.length) rows.push({ ...s, matched: [...new Set(matched)], addressHints });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
|
|
43
134
|
// ============================================================
|
|
44
135
|
// Program
|
|
45
136
|
// ============================================================
|
|
@@ -58,6 +149,7 @@ import { runUninstall } from './commands/uninstall.js';
|
|
|
58
149
|
import { runScrub } from './commands/scrub.js';
|
|
59
150
|
import { runServe } from './commands/serve.js';
|
|
60
151
|
import { runCodevelop } from './commands/codevelop.js';
|
|
152
|
+
import { runNpm } from './commands/npm.js';
|
|
61
153
|
|
|
62
154
|
program
|
|
63
155
|
.command('install')
|
|
@@ -126,6 +218,16 @@ program
|
|
|
126
218
|
await runCodevelop({ ...opts, action });
|
|
127
219
|
});
|
|
128
220
|
|
|
221
|
+
program
|
|
222
|
+
.command("npm")
|
|
223
|
+
.description("Operate npm auth safely through the clauth npm service")
|
|
224
|
+
.argument("[action]", "whoami | tokens | set-local | sync-github-secret | rerun | help", "help")
|
|
225
|
+
.argument("[args...]", "Action arguments")
|
|
226
|
+
.option("--repo <repo>", "GitHub repo, e.g. LIFEAI/rdc-skills")
|
|
227
|
+
.action(async (action, args, opts) => {
|
|
228
|
+
await runNpm(action, { ...opts, args });
|
|
229
|
+
});
|
|
230
|
+
|
|
129
231
|
// ──────────────────────────────────────────────
|
|
130
232
|
// clauth setup
|
|
131
233
|
// ──────────────────────────────────────────────
|
|
@@ -410,6 +512,38 @@ program
|
|
|
410
512
|
console.log();
|
|
411
513
|
});
|
|
412
514
|
|
|
515
|
+
program
|
|
516
|
+
.command("search <query>")
|
|
517
|
+
.description("Search services by name, label, project, description, type, or redacted address hints")
|
|
518
|
+
.option("-p, --pw <password>")
|
|
519
|
+
.option("--project <name>", "Filter by project scope")
|
|
520
|
+
.option("--no-addresses", "Skip address-bearing secret metadata scans")
|
|
521
|
+
.action(async (query, opts) => {
|
|
522
|
+
const auth = await getAuth(opts.pw);
|
|
523
|
+
const spinner = ora("Searching services...").start();
|
|
524
|
+
try {
|
|
525
|
+
const rows = await searchServices(auth, query, { project: opts.project, addresses: opts.addresses });
|
|
526
|
+
spinner.stop();
|
|
527
|
+
console.log(chalk.cyan(`\n Search results for "${query}":\n`));
|
|
528
|
+
if (!rows.length) {
|
|
529
|
+
console.log(chalk.gray(" No matching services found.\n"));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
console.log(chalk.bold(" " + "SERVICE".padEnd(24) + "TYPE".padEnd(12) + "PROJECT".padEnd(20) + "MATCHED"));
|
|
533
|
+
console.log(" " + "─".repeat(78));
|
|
534
|
+
for (const s of rows) {
|
|
535
|
+
const project = s.project || "global";
|
|
536
|
+
console.log(` ${chalk.bold(s.name.padEnd(24))}${String(s.key_type || "").padEnd(12)}${project.padEnd(20)}${s.matched.join(", ")}`);
|
|
537
|
+
if (s.label) console.log(chalk.gray(` label: ${s.label}`));
|
|
538
|
+
if (s.description) console.log(chalk.gray(` description: ${s.description}`));
|
|
539
|
+
for (const hint of s.addressHints || []) console.log(chalk.gray(` address: ${hint}`));
|
|
540
|
+
}
|
|
541
|
+
console.log();
|
|
542
|
+
} catch (err) {
|
|
543
|
+
spinner.fail(chalk.red(err.message));
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
413
547
|
// ──────────────────────────────────────────────
|
|
414
548
|
// clauth test <service|all>
|
|
415
549
|
// ──────────────────────────────────────────────
|