@agentvault/grok-bridge 0.1.0

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/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @agentvault/grok-bridge
2
+
3
+ Bridges a Grok agent into E2E-encrypted AgentVault direct messages and rooms.
4
+
5
+ **This package is a separate entity from `@agentvault/claude-bridge`.** They share
6
+ no runtime code. The only shared dependency is `@agentvault/agentvault`, the
7
+ protocol and crypto library.
8
+
9
+ ## Copied-from ledger — REVIEW PERIODICALLY
10
+
11
+ These files were copied once from the Claude bridge and are now owned here. A fix
12
+ there does NOT reach us.
13
+
14
+ | File | Copied from `packages/claude-room-bridge` at | Last reviewed |
15
+ |---|---|---|
16
+ | `src/config.ts` | `73a770168a5df33a51f06c8875444f55c9241c07` | `73a770168a5df33a51f06c8875444f55c9241c07` |
17
+ | `src/log.ts` | `9b28a272e87600e78627dbd0f53fcbd192e01e73` | `9b28a272e87600e78627dbd0f53fcbd192e01e73` |
18
+ | `src/bridge.ts` | `f3a7d53f67246e59c471891aee667e002a23b701` | `f3a7d53f67246e59c471891aee667e002a23b701` |
19
+ | `src/service/` | `16c95f46c654a34613aeb2a4ff94eeede9e671ef` | `16c95f46c654a34613aeb2a4ff94eeede9e671ef` |
20
+
21
+ To review one:
22
+
23
+ ```bash
24
+ git log <last-reviewed-sha>..HEAD -- packages/claude-room-bridge/src/<file>
25
+ ```
26
+
27
+ Triage the diff, port anything that applies, then **move the "Last reviewed" SHA
28
+ forward whether or not you ported anything**. Recording "looked, nothing to port"
29
+ is the point: an unmoved SHA then honestly means nobody looked, instead of being
30
+ indistinguishable from nothing having changed.
31
+
32
+ ⚠️ **Do not port `ActiveTarget.reply()`.** It reads the live target and reopens
33
+ the DM→room leak. It has no production callers on the Claude side either.
34
+
35
+ ## Ship gates — NOT done yet
36
+
37
+ | Gate | Status |
38
+ |---|---|
39
+ | 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`** |
40
+ | 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** |
41
+ | **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 |
42
+ | **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 |
43
+
44
+ ## The fenced-mute bug (2026-08-15)
45
+
46
+ A blanket `cancelled` on every agent-initiated request made a **fenced agent permanently mute**.
47
+
48
+ A clean fence loads no permissions, so grok raises `session/request_permission` for *every* tool
49
+ call — including `room__say`, the agent's only voice. The bridge refused it, and the turn ended with
50
+ `stopReason: "cancelled"`, `spoke: false`, and nothing said. The skeleton's entire milestone
51
+ ("reply in a 1:1 DM, and `room__say` in a room") was impossible as shipped.
52
+
53
+ The design spec predicted it verbatim: *"clean fence ⇒ everything asks … the bridge must explicitly
54
+ allow it or the agent goes mute."*
55
+
56
+ **Why 156 tests missed it.** `FakeAcpChild` never raises a permission request on its own, so
57
+ `onRequest` was only ever exercised by tests that constructed the request themselves — and none did.
58
+ The earlier **unfenced** spike could not see it either: unfenced, grok asks for nothing at all, which
59
+ is exactly why that run looked healthy.
60
+
61
+ `GrokDriver.onAgentRequest` now allows exactly one tool — `GROK_ROOM_SAY_TOOL_NAME`, imported rather
62
+ than restated so the allowlist cannot drift from the tool actually shipped — by selecting the
63
+ agent's own offered `allow_once` option. Everything else, and any non-permission request, still
64
+ ends the turn. If a future grok stops offering `allow_once`, it refuses rather than inventing an id.
65
+
66
+ Four tests now cover the branch, using the frame shape captured from the real binary.
67
+
68
+ ## Security notes
69
+
70
+ - 🔐 **Never run grok with `--debug-file`.** It writes the OAuth bearer token in
71
+ plaintext. Use `RUST_LOG=xai_grok_shell=trace`.
72
+ - The room MCP URL carries an auth token. `.mcp.json` is written `0600` and the
73
+ URL is never passed in argv. It is still a credential anywhere it is echoed —
74
+ traces, crash reports, `lsof`. Treat any diagnostic capture as a secret.
75
+ - Daemon auth is `XAI_API_KEY` in the process env (not baked into the
76
+ launchd/systemd plist) or a seeded `auth.json` inside the fence.
77
+ - The fence (`HOME` + `GROK_HOME`) is required **even with tools off**. Unfenced,
78
+ grok loads the operator's entire personal MCP fleet.
79
+
80
+ ## Testing
81
+
82
+ ```bash
83
+ npx vitest run # the gate — run locally before every commit
84
+ npm run typecheck # a BASELINE, not a gate. Observed: 2 errors
85
+ ```
@@ -0,0 +1,86 @@
1
+ import type { EventEmitter } from "node:events";
2
+ /** The structural shape both a real ChildProcess and the test fake satisfy. */
3
+ export interface AcpChildLike {
4
+ stdin: {
5
+ write(chunk: string): boolean;
6
+ on?(event: "error", cb: (e: Error) => void): unknown;
7
+ };
8
+ stdout: EventEmitter;
9
+ on(event: "exit", cb: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
10
+ /** Present on a real ChildProcess, absent on the test fake. `stop()` uses it
11
+ * to actually end the process: disposing the connection alone leaves the
12
+ * grok child running (measured 2026-08-15). */
13
+ kill?(signal?: NodeJS.Signals): unknown;
14
+ }
15
+ export declare class AcpChildDied extends Error {
16
+ readonly method: string;
17
+ readonly code: number | null;
18
+ readonly signal: NodeJS.Signals | null;
19
+ constructor(method: string, code: number | null, signal: NodeJS.Signals | null);
20
+ }
21
+ export declare class AcpTimeout extends Error {
22
+ readonly method: string;
23
+ readonly ms: number;
24
+ constructor(method: string, ms: number);
25
+ }
26
+ /**
27
+ * JSON-RPC over a child's stdio. Transport only — it knows nothing about ACP
28
+ * methods, sessions or Grok.
29
+ *
30
+ * stdout is JSON-RPC ONLY on a real grok child (measured: zero parse failures
31
+ * across every spike run; logs go to stderr), so an unparseable line is a real
32
+ * anomaly rather than expected noise. It is counted, not silently dropped.
33
+ */
34
+ export declare class AcpConnection {
35
+ private readonly child;
36
+ private nextId;
37
+ private readonly pending;
38
+ /** Undecoded leftover. Decode UTF-8 only on complete lines so a 4-byte
39
+ * character split across two `data` events cannot become U+FFFD. */
40
+ private buf;
41
+ private notify;
42
+ private handle;
43
+ private unparseable;
44
+ private unmatchedResponses;
45
+ private failedNotifications;
46
+ private stdinErrors;
47
+ private disposed;
48
+ private timedOut;
49
+ private readonly defaultTimeoutMs;
50
+ private readonly onData;
51
+ private death;
52
+ constructor(child: AcpChildLike, opts?: {
53
+ defaultTimeoutMs?: number;
54
+ });
55
+ timedOutCount(): number;
56
+ onNotification(cb: (method: string, params: any) => void): void;
57
+ /** Agent-initiated requests (e.g. session/request_permission). */
58
+ onRequest(cb: (method: string, params: any) => unknown): void;
59
+ request<T>(method: string, params: unknown, opts?: {
60
+ timeoutMs?: number;
61
+ }): Promise<T>;
62
+ /**
63
+ * Fire-and-forget JSON-RPC notification: no id, no pending entry, no response
64
+ * expected. Never throws — callers such as a driver's abort() are
65
+ * contractually non-throwing, and there is no promise to reject.
66
+ *
67
+ * Named `sendNotification`, not `notify`: the class already has a private
68
+ * `notify` field holding the inbound-notification callback registered via
69
+ * `onNotification()`. A method of the same name is a duplicate class member —
70
+ * the field silently wins at runtime (esbuild warns, but does not fail the
71
+ * build) — so this is the outbound counterpart's own name, not a collision.
72
+ */
73
+ sendNotification(method: string, params: unknown): void;
74
+ failedNotificationCount(): number;
75
+ /** Reject every in-flight request. Called on child death — see Task 3. */
76
+ failAll(err: Error): void;
77
+ dispose(): void;
78
+ unparseableCount(): number;
79
+ unmatchedResponseCount(): number;
80
+ stdinErrorCount(): number;
81
+ private send;
82
+ private clearPending;
83
+ private handleData;
84
+ private dispatch;
85
+ }
86
+ //# sourceMappingURL=acp-connection.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The boundary the Grok bridge drives its model through.
3
+ *
4
+ * GROK-INTERNAL. This interface has exactly ONE implementation (`GrokDriver`)
5
+ * and it is not a shared abstraction: the Claude bridge is a separate entity
6
+ * with its own session type and MUST NOT be put behind this. An interface that
7
+ * fits the single implementation it was written against confirms nothing.
8
+ *
9
+ * Two properties callers depend on:
10
+ * - `start()` is the two-frame ACP HANDSHAKE (`initialize` + `session/new`).
11
+ * It is not a run loop, and it resolves before any turn happens.
12
+ * - `deliver()` is ONE turn, and it is serialized: it throws if a turn is
13
+ * already in flight. `GrokSession` relies on that to route the session-level
14
+ * `onSay` to the correct per-message reply sink.
15
+ */
16
+ export interface TurnResult {
17
+ /** ACP `stopReason` from the session/prompt RESULT. Never inferred from updates. */
18
+ stopReason: string;
19
+ /** Did the agent actually address the conversation? Assistant prose is NOT
20
+ * speaking — only a room__say tool call is. These are different bits. */
21
+ spoke: boolean;
22
+ /** Assistant prose for this turn, for observe-only paths. */
23
+ text: string;
24
+ telemetry?: {
25
+ modelId?: string;
26
+ totalTokens?: number;
27
+ numTurns?: number;
28
+ apiDurationMs?: number;
29
+ };
30
+ }
31
+ export interface AgentDriverOpts {
32
+ /** The agent's ONLY way to address the conversation. */
33
+ onSay: (text: string) => void | Promise<void>;
34
+ onTurnEnd?: (result: TurnResult) => void;
35
+ }
36
+ export interface AgentDriver {
37
+ start(): Promise<void>;
38
+ deliver(text: string): Promise<TurnResult>;
39
+ /** Queue timeout / cancellation. Must not throw if nothing is in flight. */
40
+ abort(): void;
41
+ stop(): Promise<void>;
42
+ }
43
+ //# sourceMappingURL=agent-driver.d.ts.map
@@ -0,0 +1,164 @@
1
+ /**
2
+ * COPIED from packages/claude-room-bridge/src/bridge.ts at
3
+ * f3a7d53f67246e59c471891aee667e002a23b701, then trimmed.
4
+ * This file is OWNED by the Grok bridge — edit it freely. It is NOT shared, and
5
+ * a fix on the Claude side does NOT reach it. See README.md for the port review
6
+ * process.
7
+ *
8
+ * Removed on copy: arming, approvals, audit and the worker tool gate (spec §7).
9
+ * Those are Claude-shaped; Grok's gate is the pre_tool_use hook and arrives with
10
+ * the tools-on posture.
11
+ */
12
+ /** `RoomSession` is declared ONCE in this package, by grok-session.ts. The Claude
13
+ * bridge declares its own in its bridge.ts; that declaration was NOT copied.
14
+ * Type-only, so this cannot create a runtime import cycle. */
15
+ import type { RoomSession } from "./grok-session.js";
16
+ export interface RoomMessage {
17
+ roomId: string;
18
+ senderName: string;
19
+ plaintext: string;
20
+ /** True when the sender is another agent (SecureChannel derives this from the
21
+ * room roster). Used to decide reply expectation: a human/owner speaking in a
22
+ * room always expects a reply; agent-authored traffic does not (the agent may
23
+ * stay silent, and it prevents agent↔agent reply loops). Absent on older
24
+ * payloads / tests → treated as "not an agent" is NOT assumed: replyExpected is
25
+ * set only when we affirmatively know the sender is a human (=== false). */
26
+ senderIsAgent?: boolean;
27
+ }
28
+ /** Metadata SecureChannel attaches to a 1:1 `message` event. `roomId` is set only
29
+ * when the `message` actually originated in a room (handled by room_message), so
30
+ * its ABSENCE is how we identify a true owner↔agent 1:1 DM. */
31
+ export interface MessageMeta {
32
+ roomId?: string;
33
+ /** #621 — set by SecureChannel when the message decrypted under the owner's
34
+ * 1:1 material. Gates the plain-text reply fallback; see the `message` handler. */
35
+ ownerDm1to1?: boolean;
36
+ }
37
+ /** #392 room hush (advisory) — emitted by SecureChannel from the backend's
38
+ * `room_hushed` event. ``hushedUntil`` is an ISO string, or null when cleared. */
39
+ export interface RoomHushed {
40
+ roomId: string;
41
+ hushedUntil: string | null;
42
+ }
43
+ export interface RoomChannel {
44
+ on(ev: "room_message", cb: (e: RoomMessage) => void): unknown;
45
+ on(ev: "room_joined", cb: (e: {
46
+ roomId: string;
47
+ name?: string;
48
+ }) => void): unknown;
49
+ on(ev: "message", cb: (text: string, metadata: MessageMeta) => void): unknown;
50
+ on(ev: "room_hushed", cb: (e: RoomHushed) => void): unknown;
51
+ on(ev: "error", cb: (err: unknown) => void): unknown;
52
+ /** Fires when the WS (re)opens. */
53
+ on(ev: "ready", cb: () => void): unknown;
54
+ /** SecureChannel's connection lifecycle signal. It never emits a bare
55
+ * `"close"` — a WS drop is reported as `state` moving to `"disconnected"`
56
+ * (or `"error"`), and a completed reconnect as `"ready"` (both the dedicated
57
+ * `state` value AND the separate `"ready"` event). */
58
+ on(ev: "state", cb: (s: string) => void): unknown;
59
+ sendToRoom(roomId: string, text: string): Promise<void>;
60
+ send(text: string): Promise<void>;
61
+ }
62
+ /**
63
+ * #392 cooperative quiet: tracks per-room hush windows so the native agent holds
64
+ * (does not room_say) while the owner has hushed the room. Advisory — the agent
65
+ * cooperates; the hard backstops are owner mute / remove (enforced server-side).
66
+ */
67
+ export declare class RoomHushState {
68
+ private until;
69
+ set(roomId: string, hushedUntilIso: string | null): void;
70
+ isHushed(roomId: string, now?: number): boolean;
71
+ }
72
+ /** Where a reply should go: a room (sendToRoom) or a 1:1 DM (send). */
73
+ export type BridgeTarget = {
74
+ kind: "room";
75
+ roomId: string;
76
+ } | {
77
+ kind: "dm";
78
+ };
79
+ /** Channel surface ActiveTarget needs to route an outbound reply. */
80
+ export interface ReplyChannel {
81
+ sendToRoom(roomId: string, text: string): Promise<void>;
82
+ send(text: string): Promise<void>;
83
+ }
84
+ /**
85
+ * Tracks the destination of the most recent inbound message so a single Grok
86
+ * session can answer BOTH room traffic and 1:1 owner DMs.
87
+ *
88
+ * `current` is deliberately mutable and always points at the NEWEST inbound.
89
+ * Outbound replies never read it: the bridge calls `snapshotReply()` once per
90
+ * inbound and hands the resulting closure to the session, which holds it for
91
+ * that message's whole turn. A room message arriving mid-compose therefore
92
+ * cannot redirect a private 1:1 reply into the room.
93
+ *
94
+ * There is intentionally NO method that sends using the live target. The Claude
95
+ * bridge still carries one (`reply()`); it has no production callers there and
96
+ * reopens the leak the moment it is called. Do not port it.
97
+ */
98
+ export declare class ActiveTarget {
99
+ private target;
100
+ setRoom(roomId: string): void;
101
+ setDm(): void;
102
+ get current(): BridgeTarget | null;
103
+ /**
104
+ * Capture the CURRENT target into an immutable reply closure. This is the leak
105
+ * fix: a reply built here routes to where its inbound message came from, even if
106
+ * a later inbound flips the live `current` target. The bridge captures one of
107
+ * these per inbound (at push time) and hands it to the session, so a room message
108
+ * arriving mid-compose can never redirect a private 1:1 reply into the room.
109
+ */
110
+ snapshotReply(channel: ReplyChannel, log?: (msg: string) => void): (text: string) => Promise<void>;
111
+ }
112
+ export interface LifecycleChannel {
113
+ on(ev: "auth_failed", cb: (e: {
114
+ reason: string;
115
+ }) => void): unknown;
116
+ on(ev: "session_replaced", cb: (e: {
117
+ code?: number;
118
+ }) => void): unknown;
119
+ on(ev: "error", cb: (err: unknown) => void): unknown;
120
+ }
121
+ /**
122
+ * Wire terminal lifecycle events — conditions SecureChannel can't recover from
123
+ * by reconnecting, after which the daemon would otherwise idle forever. We log
124
+ * clearly and exit. Three terminal signals:
125
+ * - `auth_failed` — device revoked / JWT expired (needs re-enroll).
126
+ * - `session_replaced` — another session took over (close code 4000/4001).
127
+ * - `error` matching "Reconnect loop detected" — the one terminal error the
128
+ * channel emits AFTER it has given up reconnecting.
129
+ * All OTHER `error` events are transient (handled + survived by wireBridge's
130
+ * own `error` listener, #387) and recover via the built-in reconnect — so we
131
+ * deliberately do NOT exit on the generic `error`/`"error"`-state, which would
132
+ * regress that survival behavior.
133
+ */
134
+ export declare function attachLifecycle(channel: LifecycleChannel, opts?: {
135
+ log?: (msg: string) => void;
136
+ onTerminal?: (reason: string, o: {
137
+ restart: boolean;
138
+ }) => void;
139
+ }): void;
140
+ /**
141
+ * How a ROOM turn is announced to the model (#366 / #499 Facet A).
142
+ *
143
+ * Reproduced live 2026-07-29: asked "what room are you in?" *in* a room, the
144
+ * agent answered "This is our 1:1 DM, so no room name to report." Everything
145
+ * beneath the model was correct — the bridge logged `inbound from chris in
146
+ * c6a2734d` then `said to room c6a2734d`, and the message is recorded against
147
+ * room "New Test" in the database. The turn was simply pushed as
148
+ * `[chris]: <text>`, which carries no channel context whatsoever, so the model
149
+ * had nothing to distinguish a room from a DM and defaulted to DM.
150
+ *
151
+ * The room NAME is best-effort (learned from `room_joined`), but the fact that
152
+ * this is a room is not: when the name is unknown we still say "room" and fall
153
+ * back to a short id. Getting "which room" wrong is a cosmetic answer; getting
154
+ * "am I in a room" wrong changes how the agent addresses everyone in it.
155
+ *
156
+ * A 1:1 DM is deliberately left unlabelled — that path is not broken, and an
157
+ * unmarked turn already reads as a DM, which is what the model assumes.
158
+ */
159
+ export declare function roomTurnPrefix(roomId: string, senderName: string, roomName?: string): string;
160
+ export declare function wireBridge(channel: RoomChannel, session: RoomSession, target: ActiveTarget, opts?: {
161
+ roomFilter?: string;
162
+ log?: (msg: string) => void;
163
+ }): void;
164
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1,43 @@
1
+ export declare function hasPersistedCreds(dataDir: string): boolean;
2
+ export declare function slugify(name: string): string;
3
+ export type DataDirSource = "explicit" | "per-agent";
4
+ export declare function resolveDataDir(env: NodeJS.ProcessEnv): {
5
+ dataDir: string;
6
+ source: DataDirSource;
7
+ };
8
+ export interface BridgeConfig {
9
+ inviteToken: string;
10
+ dataDir: string;
11
+ dataDirSource: DataDirSource;
12
+ apiUrl: string;
13
+ agentName: string;
14
+ roomFilter?: string;
15
+ /** Per-agent filesystem sandbox. Always set: auto-defaults to a sibling of
16
+ * the key/data dir unless AV_WORKSPACE_DIR overrides it. */
17
+ workspaceDir: string;
18
+ /** Absolute path to the grok binary. Optional — `grokSpawnArgs` falls back to
19
+ * resolving "grok" from PATH. Set it in production: a launchd daemon's PATH
20
+ * normally does NOT contain ~/.grok/bin, so the PATH fallback fails with
21
+ * ENOENT on the very first spawn. */
22
+ grokPath?: string;
23
+ /** Sandbox profile passed to grok via GROK_SANDBOX. Default "workspace". */
24
+ sandboxProfile: string;
25
+ /** The agent's fenced HOME. Defaults to <dataDir>/fence. REQUIRED even with
26
+ * tools off: unfenced, grok loads the operator's entire personal MCP fleet
27
+ * (measured 2026-08-15 — imessage, twilio-docs, amplitude). */
28
+ fenceHome: string;
29
+ /** xAI key forwarded to the grok child as `XAI_API_KEY`. Not baked into the
30
+ * launchd/systemd plist — lives in the process env or a seeded auth.json
31
+ * inside the fence. */
32
+ apiKey?: string;
33
+ /** Agent identity + speaking instructions, prepended to every prompt.
34
+ * Overridable via AV_SYSTEM_PROMPT; index.ts supplies a default.
35
+ *
36
+ * RE-ADDED 2026-08-15 after the first live run. An earlier ruling dropped
37
+ * this field on the reasoning that the say tool's own description already
38
+ * instructs the agent. It does not: given a bare message the model answered
39
+ * in prose and never called room__say, so the reply was discarded. */
40
+ systemPrompt?: string;
41
+ }
42
+ export declare function loadConfig(env: NodeJS.ProcessEnv, argv?: string[]): BridgeConfig;
43
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1,25 @@
1
+ export type SeedResult = "seeded" | "already-present" | "no-host-credential";
2
+ /**
3
+ * Copy the host's grok OAuth credential into the agent's fence, ONCE.
4
+ *
5
+ * The fence deliberately hides `~/.grok`, which is what lets one machine run
6
+ * several agents without them sharing state. But the onboarding story is
7
+ * "run `grok login` and it works" — so the bridge has to bring exactly one
8
+ * file across that boundary.
9
+ *
10
+ * ⚠️ NEVER overwrite an existing fence credential. MEASURED 2026-08-15: a
11
+ * fenced child refreshes its own token and rewrites this file in place (the
12
+ * expiry moved forward 7h while the host file stayed byte-identical). Copying
13
+ * on every start would therefore replace a LIVE credential with a stale host
14
+ * one, and the agent would die at the host token's expiry instead of running
15
+ * indefinitely.
16
+ *
17
+ * Copies auth.json alone — never the rest of ~/.grok, which holds the
18
+ * operator's own config, sessions and MCP fleet.
19
+ */
20
+ export declare function seedFenceCredential(opts: {
21
+ fenceHome: string;
22
+ hostHome: string;
23
+ log?: (m: string) => void;
24
+ }): SeedResult;
25
+ //# sourceMappingURL=fence-auth.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { type AcpChildLike } from "./acp-connection.js";
2
+ import type { AgentDriver, AgentDriverOpts, TurnResult } from "./agent-driver.js";
3
+ export interface GrokDriverOpts extends AgentDriverOpts {
4
+ cwd: string;
5
+ /** A turn can legitimately run for minutes while the model thinks. This is
6
+ * DELIBERATELY separate from AcpConnection's short handshake default — one
7
+ * blanket timeout would kill healthy turns. */
8
+ turnTimeoutMs?: number;
9
+ }
10
+ /**
11
+ * Drives `grok agent stdio` over ACP.
12
+ *
13
+ * The child is injected rather than spawned here: the spec's acceptance test for
14
+ * this seam is that the driver can be exercised end-to-end against a fake stdio
15
+ * child, with no binary, network or credentials.
16
+ */
17
+ export declare class GrokDriver implements AgentDriver {
18
+ private readonly child;
19
+ private readonly opts;
20
+ private readonly acp;
21
+ private session;
22
+ /** State for the turn currently in flight. Null between turns, so a late
23
+ * update frame after a turn ends cannot bleed into the next one. */
24
+ private current;
25
+ constructor(child: AcpChildLike, opts: GrokDriverOpts);
26
+ sessionId(): string | null;
27
+ start(): Promise<void>;
28
+ deliver(text: string): Promise<TurnResult>;
29
+ abort(): void;
30
+ stop(): Promise<void>;
31
+ /** Called by index.ts HTTP `onSay` (and tests). That is the only production
32
+ * writer of `TurnResult.spoke` — constructor `onSay` is an empty stub so
33
+ * this method does not double-route the say.
34
+ *
35
+ * `onSay` is typed `void | Promise<void>`, so a plain function that throws
36
+ * synchronously is legal to pass in. `Promise.resolve(f())` evaluates `f()`
37
+ * first, so wrapping only the rejection case leaves a synchronous throw free
38
+ * to propagate straight out of this method. The whole body is wrapped so
39
+ * neither failure mode escapes. */
40
+ noteSaid(text: string): void;
41
+ /**
42
+ * Answer an agent-initiated request. Fail-closed with ONE exception: the
43
+ * agent's own voice.
44
+ *
45
+ * MEASURED 2026-08-15 against grok 1.0.3, fenced: a clean fence has no
46
+ * permissions loaded, so the agent raises `session/request_permission` for
47
+ * EVERY tool call — including `room__say`. A blanket `cancelled` therefore
48
+ * made the agent MUTE: it asked to speak, the bridge refused, and the turn
49
+ * ended with stopReason "cancelled" and spoke=false. The design spec named
50
+ * this trap exactly — "clean fence ⇒ everything asks … the bridge must
51
+ * explicitly allow it or the agent goes mute" — and the blanket handler
52
+ * walked into it. No unit test caught it because the fake child never raises
53
+ * a permission request of its own, and the UNFENCED spike could not see it
54
+ * because unfenced grok asks for nothing at all.
55
+ *
56
+ * Allowing exactly one tool is not a weakening of the gate. `room__say` is
57
+ * the agent's only channel to the conversation, it is the capability this
58
+ * bridge exists to provide, and it is delivered solely through our own
59
+ * `--plugin-dir` MCP server. Everything else — Bash included, verified in the
60
+ * same run — still ends the turn.
61
+ */
62
+ private onAgentRequest;
63
+ private onNotification;
64
+ }
65
+ //# sourceMappingURL=grok-driver.d.ts.map
@@ -0,0 +1,34 @@
1
+ /** Strip a credential from a log line. Empty secret is a no-op — `split("")`
2
+ * would insert the replacement between every character. */
3
+ export declare function redactCredential(text: string, secret: string): string;
4
+ export interface RoomMcpServer {
5
+ /** JSON-RPC methods this listener has received, in order.
6
+ *
7
+ * Diagnostic only — it changes no behaviour. It exists because "did the
8
+ * sandboxed child actually reach this listener?" is otherwise unanswerable
9
+ * from outside: a successful `tools/list` is silent, and its absence looks
10
+ * identical to an agent that simply had nothing to say. The Landlock gate
11
+ * asserts on it. */
12
+ seenMethods(): string[];
13
+ /** Full tokened URL to place in .mcp.json. TREAT AS A CREDENTIAL. */
14
+ url: string;
15
+ token: string;
16
+ close(): Promise<void>;
17
+ }
18
+ /**
19
+ * Serve the room MCP over loopback, IN THIS PROCESS.
20
+ *
21
+ * Why HTTP rather than a stdio child: `handleMcpFrame` running in a child means
22
+ * the say never reaches the driver, so `TurnResult.spoke` is permanently false
23
+ * (measured 2026-08-15). In-process keeps `onSay` a closure and makes `spoke`
24
+ * real — which is also what makes the DM auto-reply fallback safe.
25
+ *
26
+ * SECURITY. Any local process can reach a loopback port, so the path carries a
27
+ * random token and a mismatch is refused. The token must never be logged, and
28
+ * the URL must never be passed in argv (it would be visible in `ps`).
29
+ */
30
+ export declare function startRoomMcpServer(opts: {
31
+ onSay: (text: string) => void;
32
+ log?: (m: string) => void;
33
+ }): Promise<RoomMcpServer>;
34
+ //# sourceMappingURL=grok-room-http.d.ts.map
@@ -0,0 +1,2 @@
1
+ export declare function handleMcpFrame(frame: any, onSay: (text: string) => void): object | null;
2
+ //# sourceMappingURL=grok-room-mcp.d.ts.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The tool name as the MODEL sees it: <mcp server key> + "__" + <tool name>.
3
+ * The MCP server itself receives `say`. Not `mcp__room__say` — that is Claude's
4
+ * convention and would silently match nothing here.
5
+ */
6
+ export declare const GROK_ROOM_SAY_TOOL_NAME = "room__say";
7
+ /**
8
+ * Write a Grok plugin directory exposing exactly one capability.
9
+ *
10
+ * Passed via `--plugin-dir`, which is process-scoped and always trusted — so
11
+ * this never touches the user's ~/.grok/config.toml or the workspace.
12
+ */
13
+ export declare function writeRoomPlugin(dir: string, mcpCommand: string, mcpArgs: string[]): void;
14
+ /**
15
+ * Write a plugin dir whose room server is reached over loopback HTTP.
16
+ *
17
+ * The URL carries the listener's auth token, so the file is 0600. It must NOT
18
+ * be passed on a command line — argv is world-readable via `ps`. The token is
19
+ * still a credential anywhere it is echoed: traces, crash reports, lsof. Treat
20
+ * any diagnostic capture as a secret.
21
+ */
22
+ export declare function writeRoomPluginHttp(dir: string, url: string): void;
23
+ //# sourceMappingURL=grok-room-plugin.d.ts.map
@@ -0,0 +1,80 @@
1
+ import type { TurnResult } from "./agent-driver.js";
2
+ /** Grok's OWN copy of the room-session contract. The Claude bridge has its own;
3
+ * the two are deliberately unrelated and must not be unified. */
4
+ export interface RoomSession {
5
+ push(text: string, reply?: (text: string) => Promise<void>, opts?: {
6
+ autoReplyOnText?: boolean;
7
+ replyExpected?: boolean;
8
+ }): void;
9
+ }
10
+ export interface DriverLike {
11
+ deliver(text: string): Promise<TurnResult>;
12
+ }
13
+ /**
14
+ * Serializes inbound messages onto the driver and routes the say back.
15
+ *
16
+ * THE LEAK THIS CLOSES. The destination is captured PER MESSAGE and held for
17
+ * the duration of that message's turn. A room message arriving while the agent
18
+ * is mid-compose on a DM cannot redirect the private reply into the room,
19
+ * because the newer message only becomes active when its own turn starts.
20
+ *
21
+ * This is safe with the driver's single session-level `onSay` because
22
+ * `deliver()` is serialized — one turn owns `current` at a time.
23
+ */
24
+ export declare class GrokSession implements RoomSession {
25
+ private readonly driver;
26
+ private readonly opts;
27
+ private queue;
28
+ private running;
29
+ /** The message whose turn is in flight, or null between turns. Null is a
30
+ * DISTINCT state from "a message that carries no reply sink" — conflating
31
+ * them is what let a post-turn say fall through to the session-level sink. */
32
+ private current;
33
+ private saidThisTurn;
34
+ private drain;
35
+ constructor(driver: DriverLike, opts?: {
36
+ onSay?: (text: string) => Promise<void>;
37
+ log?: (m: string) => void;
38
+ /**
39
+ * Agent identity + speaking instructions, prepended to EVERY prompt.
40
+ *
41
+ * FOUND LIVE 2026-08-15: without this the model receives a bare message
42
+ * with a `room__say` tool available and no framing that calling it is how
43
+ * it speaks. It answered in prose and said nothing. The tool's own
44
+ * description was NOT sufficient — an earlier ruling assumed it would be,
45
+ * and the first real DM disproved it.
46
+ *
47
+ * Prepended per turn rather than sent once, deliberately: a session-level
48
+ * hook exists (`session/new` `_meta.agentProfile`, measured 2026-08-15 as
49
+ * applied) but its schema is unverified, and this codebase has already met
50
+ * three flags that were accepted and silently did nothing. A prompt prefix
51
+ * cannot fail silently — if it is missing, the prompt is visibly missing
52
+ * it. Revisit once `_meta.agentProfile` is probed properly.
53
+ */
54
+ identity?: string;
55
+ });
56
+ push(text: string, reply?: (text: string) => Promise<void>, opts?: {
57
+ autoReplyOnText?: boolean;
58
+ replyExpected?: boolean;
59
+ }): void;
60
+ /** Hand this to startRoomMcpServer({ onSay }). The behaviour is stable for
61
+ * the life of the session — the per-message routing happens HERE, not in
62
+ * the listener — but each call returns a NEW closure; do not rely on
63
+ * identity (e.g. for `===` comparisons or `removeListener`-style APIs). */
64
+ sayHandler(): (text: string) => void;
65
+ /** Resolves when the queue is empty. For tests and shutdown. */
66
+ idle(): Promise<void>;
67
+ private log;
68
+ private run;
69
+ /**
70
+ * #416: when the owner expects an answer and the model produced prose but
71
+ * never called the say tool, send the prose rather than dropping it.
72
+ *
73
+ * ONLY SAFE BECAUSE `spoke` IS REAL. Over a stdio MCP child the say never
74
+ * reaches the driver, so `spoke` is permanently false and this would fire on
75
+ * every turn — double-posting everything the agent said. It depends on the
76
+ * in-process HTTP listener.
77
+ */
78
+ private maybeFallback;
79
+ }
80
+ //# sourceMappingURL=grok-session.d.ts.map