@agentvault/grok-bridge
Bridges a Grok agent into E2E-encrypted AgentVault direct messages and rooms.
This package is a separate entity from @agentvault/claude-bridge. They share
no runtime code. The only shared dependency is @agentvault/agentvault, the
protocol and crypto library.
Usage
Prerequisite: install Grok and run grok login. The bridge copies that
credential into its sandbox once, at first run.
One paste (recommended) — --install
npm i -g @agentvault/grok-bridge
AV_AGENT_NAME=<agent> AV_INVITE_TOKEN=<token> agentvault-grok-bridge --install
Enrols the agent, waits for you to click Approve in the dashboard, installs a supervised OS service (launchd on macOS, systemd on Linux), and exits — so your terminal comes back and the agent survives closing it and rebooting.
Three things worth knowing:
npm i -gis not optional. The service bakes the running entrypoint into its launcher. Undernpxthat is a package cache the package manager later prunes, so the service would work today and die silently weeks later.--installrefuses an ephemeral entrypoint at startup, before your single-use invite token is spent.XAI_API_KEYis NOT inherited by the service. A supervisor does not read your shell environment. Usegrok login; the bridge refuses to install a service that would have no credential and run mute.- Re-running is safe. With credentials already saved you can omit the token. If the installed service is current it is restarted, not re-installed, so settings baked at install time are preserved.
Foreground (no service)
AV_AGENT_NAME=<agent> AV_INVITE_TOKEN=<token> npx @agentvault/grok-bridge
Runs in the terminal and stops when you close it. Unchanged by --install.
Managing the service
agentvault-grok-bridge status # is it loaded?
agentvault-grok-bridge restart # pick up new credentials / recover
agentvault-grok-bridge uninstall # remove it
agentvault-grok-bridge logs # where the logs are
Use restart, never install, to restart. install rebuilds the service
from your current shell and re-bakes PATH, silently discarding environment
baked at the original install.
Logs go to <data-dir>/logs/grok-bridge.error.log — the bridge writes to stderr,
so the .error.log file is the one with content in it.
When a reply fails
When a Grok turn fails, the bridge records it first, then tells the chat, and only then restarts itself. It never drops a failed turn without a trace.
- In a 1:1 with the owner, the owner sees one plain sentence. It names the cause when the bridge can tell what it is. For example: "Ohtani couldn't answer this message: its xAI sign-in expired. Sign in again on the agent's computer." The bridge can tell an expired sign-in, a billing problem, rate limiting, a model the account can't use, xAI being unreachable, and a Grok CLI too old to answer. Only the expired sign-in has been seen live so far. When the cause is unclear, the sentence just says the agent couldn't answer this message.
- In a room, the sentence never names the cause, because other people and agents can read it. The room just sees that the agent couldn't answer this message.
- No notice is posted for a turn the agent didn't owe a reply to: a room message that didn't @mention it, or a 1:1 the owner's device didn't vouch for.
- In every case, the cause is recorded privately for the owner under Health, Recent problems.
Every failed turn ends this process, and the service manager starts a fresh one. That is on purpose: the Grok CLI does not confirm a cancel, so the old session could still speak. The fresh process starts a new Grok session. The bridge saves no Grok session to resume, so there is no saved session to lose or repair.
The log holds one line per event, with codes and exit numbers only. It never holds message text or provider error text:
| Log line | What it means |
|---|---|
turn_failed code=<code> exit=<n or none> | The turn failed. exit is the Grok CLI's exit status when it died mid-turn, otherwise none. A notice follows when the turn owed a reply. |
notice sent to <chat> | The failure notice was delivered. |
notice_failed code=<code> or reason=timeout | The failure notice could not be delivered. The failure is still recorded under Health. |
turn_cancelled reason=shutdown notice=<sent, failed, timeout or none> | The bridge was stopped or restarted (for example during an update) while this message was being answered or waiting its turn. Nothing replays it, so when the message owed a reply the chat gets the plain sentence "Ohtani couldn't answer this message." It never names a cause. queued=true marks a message that was still waiting, and late=true one that arrived while the bridge was stopping. none means the message did not owe a reply. The bridge waits at most 10 seconds for these sentences, so a stuck send cannot hold up the restart. Nothing is recorded under Health, because the model did not fail. |
turn_cancelled reason=terminal notice=none | The device was revoked or its session was taken over, so the bridge stopped without posting anything. |
The Grok CLI's own error output can repeat the provider's error text, so the log records only its size and level (grok: stderr lines=<n> bytes=<n> level=ERROR). A failed start is logged the same way, by cause only: fatal: grok did not start: code=<code> exit=<n or none> .... To see the CLI's own words, run grok by hand.
Copied-from ledger — REVIEW PERIODICALLY
These files were copied once from the Claude bridge and are now owned here. A fix there does NOT reach us.
| File | Copied from packages/claude-room-bridge at | Last reviewed |
|---|---|---|
src/config.ts | 73a770168a5df33a51f06c8875444f55c9241c07 | 73a770168a5df33a51f06c8875444f55c9241c07 |
src/log.ts | 9b28a272e87600e78627dbd0f53fcbd192e01e73 | 9b28a272e87600e78627dbd0f53fcbd192e01e73 |
src/bridge.ts | f3a7d53f67246e59c471891aee667e002a23b701 | f3a7d53f67246e59c471891aee667e002a23b701 |
src/service/ | 16c95f46c654a34613aeb2a4ff94eeede9e671ef | 16c95f46c654a34613aeb2a4ff94eeede9e671ef |
src/room-context.ts | PR #1458 (created there, 2026-10-26) | PR #1458 |
To review one:
git log <last-reviewed-sha>..HEAD -- packages/claude-room-bridge/src/<file>
Triage the diff, port anything that applies, then move the "Last reviewed" SHA forward whether or not you ported anything. Recording "looked, nothing to port" is the point: an unmoved SHA then honestly means nobody looked, instead of being indistinguishable from nothing having changed.
⚠️ Do not port ActiveTarget.reply(). It reads the live target and reopens
the DM→room leak. It has no production callers on the Claude side either.
Ship gates — NOT done yet
| Gate | Status |
|---|---|
| HTTP MCP loopback on macOS/Seatbelt | ✅ CLOSED 2026-08-15. npm run gate:mac (src/__tests__/sandbox-gate.mac.test.ts). Real grok 1.0.4, real sandbox: asserts ProfileApplied … enforced: true, platform: macos/seatbelt, restrict_network: true, then that the loopback listener received tools/list. Order matters — a sandbox that failed to apply would otherwise report a false pass. Mutation-verified: pointing the plugin at a dead port fails it with ConnectionRefused. Authenticates the way production does (seeded auth.json), so it needs no XAI_API_KEY |
| HTTP MCP loopback on Linux/Landlock | ⏸️ DEFERRED 2026-08-15 — owner call: Mac first, Linux later. Landlock filters TCP connect and may refuse the loopback that Seatbelt allows, so macOS proves nothing about Linux. The test exists (landlock-gate.linux.test.ts) and its workflow is manual dispatch only; it cannot pass until someone mints an xAI API key and adds it as the XAI_API_KEY repo secret. No such key exists anywhere today |
| CI runs this suite | ✅ CLOSED 2026-08-15. .github/workflows/grok-bridge-test.yml runs vitest on every PR touching this package. First real run: 172 passed / 3 skipped. The 3 skips are the live + Landlock + macOS gates, all of which need a real grok and are opt-in by env var |
| Fenced fail-closed | ✅ CLOSED 2026-08-15. Verified against the real grok 1.0.3, fenced, with a seeded auth.json: the agent raised session/request_permission twice, Bash was refused (stopReason: "cancelled", FENCED_BASH_REACHED never ran) and room__say was allowed. The gate is a real control, not an untested branch. ⚠️ That same run found the gate was too closed — see below |
The fenced-mute bug (2026-08-15)
A blanket cancelled on every agent-initiated request made a fenced agent permanently mute.
A clean fence loads no permissions, so grok raises session/request_permission for every tool
call — including room__say, the agent's only voice. The bridge refused it, and the turn ended with
stopReason: "cancelled", spoke: false, and nothing said. The skeleton's entire milestone
("reply in a 1:1 DM, and room__say in a room") was impossible as shipped.
The design spec predicted it verbatim: "clean fence ⇒ everything asks … the bridge must explicitly allow it or the agent goes mute."
Why 156 tests missed it. FakeAcpChild never raises a permission request on its own, so
onRequest was only ever exercised by tests that constructed the request themselves — and none did.
The earlier unfenced spike could not see it either: unfenced, grok asks for nothing at all, which
is exactly why that run looked healthy.
GrokDriver.onAgentRequest now allows exactly one tool — GROK_ROOM_SAY_TOOL_NAME, imported rather
than restated so the allowlist cannot drift from the tool actually shipped — by selecting the
agent's own offered allow_once option. Everything else, and any non-permission request, still
ends the turn. If a future grok stops offering allow_once, it refuses rather than inventing an id.
Four tests now cover the branch, using the frame shape captured from the real binary.
Security notes
- 🔐 Never run grok with
--debug-file. It writes the OAuth bearer token in plaintext. UseRUST_LOG=xai_grok_shell=trace. - The room MCP URL carries an auth token.
.mcp.jsonis written0600and the URL is never passed in argv. It is still a credential anywhere it is echoed — traces, crash reports,lsof. Treat any diagnostic capture as a secret. - Daemon auth is
XAI_API_KEYin the process env (not baked into the launchd/systemd plist) or a seededauth.jsoninside the fence. - The fence (
HOME+GROK_HOME) is required even with tools off. Unfenced, grok loads the operator's entire personal MCP fleet.
Testing
npx vitest run # the gate — run locally before every commit
npm run typecheck # a BASELINE, not a gate. Observed: 2 errors