Files
skald-connectors/scripts/compile.py
T
Daniele d84ce13dab ssh: fix sudo failing with "password required" (v6 / 1.1.0)
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.
2026-09-03 23:48:00 +01:00

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()