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.
CLAUDE.md and SKALD.md had drifted apart. CLAUDE.md still described a
hand-maintained connectors.json (pre-compile.py), an rsync deploy, 5
connectors, and a files[] that excluded connector.json.
CLAUDE.md is now the single working document, carrying every SKALD.md
section — full schemas for connectors.json / fragment.json /
connector.json, reserved enums, the auth/deliver/env/verify fields,
placeholder syntax, friendly tool names, icon conventions, file
integrity, local workflow and deploy — corrected against the actual
repo state: 18 connectors, the scripts/compile.py pipeline, and
connector.json included in the hashed files[].
It also names CONNECTOR_MANIFEST_GUIDE.md (repo root) as the
authoritative connector-authoring spec.
The server never implemented `initialize`, so every MCP client got
-32601 to its opening request and aborted before listing a tool. Also
missing: `notifications/initialized` and `ping`; `tools/list` returned a
bare array instead of {"tools": [...]}; notifications (no `id`) got a
full response written to stdout.
Rewritten to the shape the other local connectors already use (TOOLS
manifest + TOOL_DISPATCH, `_text_result` with isError, handle_request
returning None for notifications).
Also fixed:
- include_articles always returned nothing: trendspyg emits
`news_articles`, the mapper read `articles`. explore_link kept too.
- fn(**arguments) turned a bad argument into -32603; handlers now take
an args dict and coerce/clamp.
- errors were returned as successful results; now isError: true, with
trendspyg's typed exceptions translated into actionable messages.
- browser calls could run ~100s (10 retries x 8s); capped at ~25s.
- verify.py shipped but connector.json declared no `verify` block; wired
it and upgraded the script to a real RSS fetch.
- explore mutated trendspyg's ExploreEnvelope; dropped the no-op
output_format param; trendspyg pinned >=1.6.0 and imported defensively.
WhatsApp delivers a history sync at login, not on reconnect, so an in-memory
store left the connector blind after every process restart (23 in two weeks).
The store is now mirrored to store/, next to auth/ and bind-mounted the same
way.
Not SQLite: node:sqlite needs Node 22 (unflagged only from 24) and the runtime
image ships Debian trixie's nodejs = 20.19.2; better-sqlite3 is native and the
slim image has no toolchain. So an append-only JSONL log for messages plus a
debounced JSON snapshot for chats/contacts, both written tmp+rename so a crash
cannot truncate them. Compaction on load and every 500 appends keeps the log
from creeping upward.
Also fixes a pre-existing duplication bug: pushMessage appended unconditionally,
re-adding every message a history re-sync redelivered. It now returns false on a
known id and the message is skipped in both the transcript and the log.
MAX_MSGS_PER_CHAT 200 -> 500. logout deletes store/ with auth/, so re-linking a
different phone cannot inherit the previous account's history.
Data at rest: message text is now written to the user's bind-mounted home.
Nothing but session keys was persisted before.
Verified on skald-runtime:v4 with a seeded store: 751 lines with 50 duplicates,
a 700-message chat and a torn trailing line -> 701 loaded, compacted to 501, cap
applied, second run 501 -> 501 unchanged.
Diagnosed from the live server (two users, two accounts, one shared log).
- history sync was gated off entirely on Baileys 6.7.x: the library derives
shouldSyncHistoryMessage from syncFullHistory when unset, and the connector
shipped syncFullHistory: false. Now both are passed explicitly, so behaviour
no longer depends on the resolved Baileys version. browser -> ['Mac OS', ...]
because PLATFORM_MAP only grants a desktop-grade sync to Mac OS / Windows.
- reconnects leaked their socket, leaving two writers over auth/: no end(), no
removeAllListeners(). ~15 reconnects/day produced 1422 Bad MAC lines, all on
the account's own LID device 0. Added teardownSock(), per-socket generation
guards, and a single-slot reconnect timer with exponential backoff.
- undecryptable messages (stubType CIPHERTEXT) were stored with empty text and
rendered as blank transcript lines; now labelled and counted. Text-less
protocol frames are dropped.
- fetchLatestBaileysVersion() ran on every reconnect; now cached for 6h.
- libsignal's direct console.error spam collapsed into one counted line, log
lines tagged with the linked account, console.log redirected off stdout.
Baileys pinned ^6.7.9 -> 7.0.0-rc14 (the range resolved to 6.7.24 for one user
and 6.17.16 for the other). RC is ESM-only, so index.js is now ESM.
Verified on skald-runtime:v4 (Node 20.19.2): initialize, tools/list, status,
list_chats, version fetch, QR, and logout -> teardown -> single reconnect.
Remaining gap: the store is still in-memory only, so synced history is lost on
the next process restart.
Activating Exa always failed with "Exa API key is invalid or
unauthorized (HTTP 403)" — the key was never actually tested.
Two bugs stacked:
1. mcp.exa.ai is behind Cloudflare, which bans urllib's default
Python-urllib/3.x agent with 403 / "error code: 1010" before Exa
sees the request. verify.py sent no User-Agent and mapped any 403
to "API key is invalid". Reproduced on the server with no key set
at all — same "invalid key" message.
2. The probed endpoint cannot validate a key anyway: JSON-RPC
initialize against the MCP endpoint returns 200 no matter what
?exaApiKey= carries (checked with a real key, a bogus key, and no
key). Fixing only the headers would have flipped the bug to
accepting every key, including garbage.
With a key, the probe is now a minimal POST to api.exa.ai/search with
the key in the x-api-key header — the only call that exercises the
credential (200 valid, 401/403 + Exa JSON error invalid, 402 out of
credits, 429 valid but throttled). With no key it probes MCP
initialize and reports reachability only, never validity. Both
requests send a User-Agent; the MCP one also sends
Accept: application/json, text/event-stream (else HTTP 406).
An opaque 401/403 with no Exa JSON error is now reported as "blocked
before reaching the API — the key was not tested", instead of blaming
the credential.
Also re-aligns manifest/fragment versions to 5 / 1.0.4 (were 2/1.0.1
vs 4/1.0.3; skald reads installed_version from the manifest, so the
update badge would never have appeared) and raises verify.timeout_secs
15 -> 20.