@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 +85 -0
- package/dist/acp-connection.d.ts +86 -0
- package/dist/agent-driver.d.ts +43 -0
- package/dist/bridge.d.ts +164 -0
- package/dist/config.d.ts +43 -0
- package/dist/fence-auth.d.ts +25 -0
- package/dist/grok-driver.d.ts +65 -0
- package/dist/grok-room-http.d.ts +34 -0
- package/dist/grok-room-mcp.d.ts +2 -0
- package/dist/grok-room-plugin.d.ts +23 -0
- package/dist/grok-session.d.ts +80 -0
- package/dist/grok-spawn.d.ts +42 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +98998 -0
- package/dist/log.d.ts +22 -0
- package/dist/service/backend.d.ts +29 -0
- package/dist/service/backend.d.ts.map +1 -0
- package/dist/service/launchd.d.ts +21 -0
- package/dist/service/launchd.d.ts.map +1 -0
- package/dist/service/launcher.d.ts +16 -0
- package/dist/service/launcher.d.ts.map +1 -0
- package/dist/service/spec.d.ts +20 -0
- package/dist/service/spec.d.ts.map +1 -0
- package/dist/service/subcommand.d.ts +11 -0
- package/dist/service/subcommand.d.ts.map +1 -0
- package/dist/service/systemd.d.ts +22 -0
- package/dist/service/systemd.d.ts.map +1 -0
- package/dist/version.d.ts +7 -0
- package/package.json +29 -0
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
|
package/dist/bridge.d.ts
ADDED
|
@@ -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
|
package/dist/config.d.ts
ADDED
|
@@ -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,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
|