The connector asked for a sudo password on every privileged call and failed whenever nobody answered it, which is every unattended run. - Always probe `sudo -n` first, even for aliases set to sudo="prompt". `_sudo_prefix` used to elicit unconditionally, so a host granting this user NOPASSWD still opened an Agent Inbox prompt; with no human there it hit the client's 300s ELICITATION_DEADLINE, got back `cancel`, and surfaced as "sudo password required (user declined or timed out)". sudo refuses before running anything when it wants a password, so the probe is side-effect free. - Strip a leading `sudo` from `command` and turn it into sudo=true. Agents write `exec(command="sudo systemctl restart x")`: with sudo=false that ran a tty-less sudo, with sudo=true it nested `sudo -S ... sudo ...` whose inner prompt had no tty either. Handles -u/-n/-S/-E/-H/-i/-k/-p/--; an unknown flag leaves the command alone. sudo_user now implies sudo=true. - Run privileged commands as `sh -c '<command>'`, so `&&`, pipes and redirections are elevated too instead of only the first word. - Add SSH_MCP_SUDO_PASSWORD (optional, secret) for unattended runs. It is consulted only after `sudo -n` proved a password is needed, so on a NOPASSWD host it never lands in the command's own stdin. - Actionable errors for every sudo failure mode, and a `hint` on a nested sudo we could not peel off. General review of the same server: - Drain stdout and stderr together and make timeout_sec a real wall-clock deadline. Both streams share one SSH channel window, so reading stdout to EOF first stalled once a chatty stderr filled it. Command stdin is now closed after the optional password. - Queue messages that arrive while awaiting an elicitation reply instead of discarding them, so a concurrent tools/call is not lost. - Record the client's `elicitation` capability at initialize and fail fast when it is absent rather than blocking on a prompt nobody can answer. - Tolerate null/string integer arguments (depth, max_results, context_lines, timeout_sec). - Realign the version across both manifests: fragment.json said 5/1.0.4 while connector.json said 2/1.0.1, so the feed was permanently ahead of the installed version and offered an update forever. Also lands the pending docs work: CONNECTOR_MANIFEST_GUIDE.md as the single source of truth, docs/connector.manifest_guide.md retired to a pointer, CLAUDE.md audited against the repo, compile.py docstring fixed, and an opencode.json config.
135 lines
4.1 KiB
Python
135 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Compile connectors/index.json + fragment.json + file hash scan → connectors.json
|
|
|
|
Usage:
|
|
python3 scripts/compile.py # writes connectors/connectors.json
|
|
python3 scripts/compile.py --verify # checks existing index is up to date
|
|
|
|
Reads:
|
|
- connectors/index.json — ordered list of connector folder ids
|
|
- connectors/<id>/fragment.json — index entry without files[]
|
|
- physical file scan — SHA-256 and size of every file in each folder
|
|
|
|
Produces:
|
|
- connectors/connectors.json — full index with files[] and SHA-256
|
|
|
|
Auto-excluded: fragment.json, connectors.json, index.json, compile.sh, compile.py,
|
|
update_hashes.py, .DS_Store, and every subdirectory. `connector.json` IS hashed —
|
|
it ships with the connector and the index is what pins it.
|
|
|
|
Note: only the folder's top level is scanned, so a connector must keep its files
|
|
flat; anything in a subdirectory never reaches files[].
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
INDEX_DIR = os.path.join(ROOT, "connectors")
|
|
|
|
EXCLUDE_FILES = {
|
|
"fragment.json",
|
|
"connectors.json",
|
|
"index.json",
|
|
"compile.sh",
|
|
"compile.py",
|
|
"update_hashes.py",
|
|
".DS_Store",
|
|
}
|
|
|
|
EXCLUDE_DIRS = {"scripts", "__pycache__", ".git"}
|
|
|
|
|
|
def scan_files(folder_path: str) -> list[dict]:
|
|
"""Scan all files in folder_path, return [{path, sha256, size}, ...] sorted."""
|
|
files = []
|
|
for fname in sorted(os.listdir(folder_path)):
|
|
fpath = os.path.join(folder_path, fname)
|
|
if os.path.isdir(fpath):
|
|
continue
|
|
if fname in EXCLUDE_FILES:
|
|
continue
|
|
with open(fpath, "rb") as f:
|
|
data = f.read()
|
|
files.append({
|
|
"path": fname,
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"size": len(data),
|
|
})
|
|
return files
|
|
|
|
|
|
def compile_index() -> list[dict]:
|
|
"""Build the connectors list from index.json + fragments + file scan."""
|
|
index_path = os.path.join(INDEX_DIR, "index.json")
|
|
if not os.path.exists(index_path):
|
|
print("❌ connectors/index.json not found. Create it first.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
with open(index_path) as f:
|
|
order: list[str] = json.load(f)
|
|
|
|
connectors = []
|
|
for i, connector_id in enumerate(order):
|
|
frag_path = os.path.join(INDEX_DIR, connector_id, "fragment.json")
|
|
if not os.path.exists(frag_path):
|
|
print(
|
|
f"⚠️ [{i+1}/{len(order)}] {connector_id} — fragment.json not found, skipping.",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
|
|
with open(frag_path) as f:
|
|
fragment: dict = json.load(f)
|
|
|
|
folder_path = os.path.join(INDEX_DIR, connector_id)
|
|
files = scan_files(folder_path)
|
|
fragment["files"] = files
|
|
|
|
n_files = len(files)
|
|
print(f"✓ [{i+1}/{len(order)}] {connector_id} — {n_files} file{'s' if n_files != 1 else ''}")
|
|
connectors.append(fragment)
|
|
|
|
return connectors
|
|
|
|
|
|
def write_index(connectors: list[dict]) -> None:
|
|
"""Write the compiled connectors.json."""
|
|
output = {"version": 1, "connectors": connectors}
|
|
out_path = os.path.join(INDEX_DIR, "connectors.json")
|
|
with open(out_path, "w") as f:
|
|
json.dump(output, f, indent=2)
|
|
f.write("\n")
|
|
print(f"\n✅ Written {out_path} ({len(connectors)} connectors)")
|
|
|
|
|
|
def verify_index() -> bool:
|
|
"""Verify existing connectors.json matches what compilation would produce."""
|
|
with open(os.path.join(INDEX_DIR, "connectors.json")) as f:
|
|
current = json.load(f)
|
|
|
|
fresh = compile_index()
|
|
|
|
if current["connectors"] == fresh:
|
|
print("✅ Index is up to date — no changes.")
|
|
return True
|
|
else:
|
|
print("❌ Index is stale. Run compile.py to regenerate.")
|
|
return False
|
|
|
|
|
|
def main() -> None:
|
|
if "--verify" in sys.argv:
|
|
sys.exit(0 if verify_index() else 1)
|
|
|
|
connectors = compile_index()
|
|
write_index(connectors)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|