granttap-mcp 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/LICENSE +21 -0
- package/README.md +88 -0
- package/apps/bridge/src/adapters.ts +124 -0
- package/apps/bridge/src/approval.ts +67 -0
- package/apps/bridge/src/bin/claude-hook.ts +92 -0
- package/apps/bridge/src/bin/codex-hook.ts +72 -0
- package/apps/bridge/src/bin/setup.ts +11 -0
- package/apps/bridge/src/config.ts +169 -0
- package/apps/bridge/src/install.ts +133 -0
- package/apps/mcp/src/server.ts +147 -0
- package/bin/granttap-mcp.mjs +46 -0
- package/package.json +62 -0
- package/packages/core/crypto.ts +113 -0
- package/packages/core/relay-client.ts +199 -0
- package/packages/protocol/schema.ts +199 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelayClient — the shared machine/phone endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Both the machine-side bridge and the phone talk to the relay through this.
|
|
5
|
+
* It holds the E2EE keys, seals every outgoing Payload for the peer, opens
|
|
6
|
+
* incoming envelopes, and exposes a small event + request/response API.
|
|
7
|
+
*
|
|
8
|
+
* Transport is a plain WebSocket. The relay only ever sees Envelopes (opaque
|
|
9
|
+
* ciphertext bodies), so the same client works with both the Node relay and
|
|
10
|
+
* the Cloudflare Durable Object deployment.
|
|
11
|
+
*/
|
|
12
|
+
import WebSocket from "ws";
|
|
13
|
+
import { Envelope, PROTOCOL_VERSION, Payload, type Role } from "../protocol/schema";
|
|
14
|
+
import { open, seal } from "./crypto";
|
|
15
|
+
|
|
16
|
+
export type PeerConfig = {
|
|
17
|
+
relayUrl: string;
|
|
18
|
+
room: string;
|
|
19
|
+
role: Role;
|
|
20
|
+
deviceName: string;
|
|
21
|
+
senderId: string;
|
|
22
|
+
myPublicKey: string;
|
|
23
|
+
mySecretKey: string;
|
|
24
|
+
peerPublicKey: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Listener = (p: Payload) => void;
|
|
28
|
+
|
|
29
|
+
export type RelayClientOptions = {
|
|
30
|
+
/** Persistent processes reconnect in the background; one-shot hooks leave this off. */
|
|
31
|
+
autoReconnect?: boolean;
|
|
32
|
+
minReconnectMs?: number;
|
|
33
|
+
maxReconnectMs?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SendOptions = {
|
|
37
|
+
/** Delivery lifetime for relay hold queues. Omit only for non-expiring hello packets. */
|
|
38
|
+
ttlMs?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class RelayClient {
|
|
42
|
+
private ws?: WebSocket;
|
|
43
|
+
private listeners = new Set<Listener>();
|
|
44
|
+
private intentionalClose = false;
|
|
45
|
+
private connectPromise?: Promise<void>;
|
|
46
|
+
private reconnectTimer?: NodeJS.Timeout;
|
|
47
|
+
private reconnectDelay: number;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly cfg: PeerConfig,
|
|
51
|
+
private readonly opts: RelayClientOptions = {},
|
|
52
|
+
) {
|
|
53
|
+
this.reconnectDelay = opts.minReconnectMs ?? 1_000;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private otherRole(): Role {
|
|
57
|
+
return this.cfg.role === "machine" ? "phone" : "machine";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
connect(timeoutMs = 10_000): Promise<void> {
|
|
61
|
+
if (this.isConnected) return Promise.resolve();
|
|
62
|
+
if (this.connectPromise) return this.connectPromise;
|
|
63
|
+
|
|
64
|
+
this.intentionalClose = false;
|
|
65
|
+
const pending = new Promise<void>((resolve, reject) => {
|
|
66
|
+
// The room rides in the URL: the Cloudflare relay must pick a Durable
|
|
67
|
+
// Object before the socket upgrades. The Node relay simply ignores it.
|
|
68
|
+
const url = new URL(this.cfg.relayUrl);
|
|
69
|
+
url.searchParams.set("room", this.cfg.room);
|
|
70
|
+
const ws = new WebSocket(url.toString());
|
|
71
|
+
this.ws = ws;
|
|
72
|
+
let opened = false;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
ws.terminate();
|
|
75
|
+
reject(new Error(`relay connect timeout (${this.cfg.relayUrl})`));
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
|
|
78
|
+
ws.on("open", () => {
|
|
79
|
+
opened = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
this.reconnectDelay = this.opts.minReconnectMs ?? 1_000;
|
|
82
|
+
// Announce ourselves. The envelope's cleartext (room, from) is what the
|
|
83
|
+
// relay uses to register this socket; the Hello body stays encrypted.
|
|
84
|
+
void this
|
|
85
|
+
.send(
|
|
86
|
+
{ type: "hello", role: this.cfg.role, deviceName: this.cfg.deviceName, createdAt: Date.now() },
|
|
87
|
+
"all",
|
|
88
|
+
)
|
|
89
|
+
.catch(() => {});
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
ws.on("message", (data: WebSocket.RawData) => this.onRaw(data.toString()));
|
|
93
|
+
ws.on("error", (err) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
if (!opened) reject(err);
|
|
96
|
+
});
|
|
97
|
+
ws.on("close", () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
if (this.ws === ws) this.ws = undefined;
|
|
100
|
+
if (!opened) reject(new Error(`relay connection closed (${this.cfg.relayUrl})`));
|
|
101
|
+
this.scheduleReconnect();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
this.connectPromise = pending.finally(() => {
|
|
105
|
+
this.connectPromise = undefined;
|
|
106
|
+
});
|
|
107
|
+
return this.connectPromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private onRaw(raw: string): void {
|
|
111
|
+
const parsed = Envelope.safeParse(safeJson(raw));
|
|
112
|
+
if (!parsed.success) return;
|
|
113
|
+
const env = parsed.data;
|
|
114
|
+
if (env.room !== this.cfg.room) return;
|
|
115
|
+
if (env.expiresAt != null && env.expiresAt <= Date.now()) return;
|
|
116
|
+
const body = open(env.nonce, env.box, this.cfg.peerPublicKey, this.cfg.mySecretKey);
|
|
117
|
+
if (body === null) return; // not for us, or tampered
|
|
118
|
+
const payload = Payload.safeParse(body);
|
|
119
|
+
if (!payload.success) return;
|
|
120
|
+
for (const l of this.listeners) l(payload.data);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async send(
|
|
124
|
+
payload: Payload,
|
|
125
|
+
to: Role | "all" = this.otherRole(),
|
|
126
|
+
options: SendOptions = {},
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const ws = this.ws;
|
|
129
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
|
|
130
|
+
const { nonce, box } = seal(payload, this.cfg.peerPublicKey, this.cfg.mySecretKey);
|
|
131
|
+
const env: Envelope = {
|
|
132
|
+
v: PROTOCOL_VERSION,
|
|
133
|
+
room: this.cfg.room,
|
|
134
|
+
from: this.cfg.role,
|
|
135
|
+
to,
|
|
136
|
+
senderId: this.cfg.senderId,
|
|
137
|
+
expiresAt: options.ttlMs == null ? undefined : Date.now() + options.ttlMs,
|
|
138
|
+
nonce,
|
|
139
|
+
box,
|
|
140
|
+
};
|
|
141
|
+
ws.send(JSON.stringify(env));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onMessage(l: Listener): () => void {
|
|
145
|
+
this.listeners.add(l);
|
|
146
|
+
return () => this.listeners.delete(l);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve with the first payload matching `pred`, or reject on timeout. */
|
|
150
|
+
waitFor<T extends Payload>(pred: (p: Payload) => p is T, timeoutMs: number): Promise<T> {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
off();
|
|
154
|
+
reject(new Error("waitFor timeout"));
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
const off = this.onMessage((p) => {
|
|
157
|
+
if (pred(p)) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
off();
|
|
160
|
+
resolve(p);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close(): void {
|
|
167
|
+
this.intentionalClose = true;
|
|
168
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
169
|
+
this.reconnectTimer = undefined;
|
|
170
|
+
this.ws?.close();
|
|
171
|
+
this.ws = undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get isConnected(): boolean {
|
|
175
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private scheduleReconnect(): void {
|
|
179
|
+
if (!this.opts.autoReconnect || this.intentionalClose || this.reconnectTimer) return;
|
|
180
|
+
const delay = this.reconnectDelay;
|
|
181
|
+
const max = this.opts.maxReconnectMs ?? 15_000;
|
|
182
|
+
this.reconnectDelay = Math.min(max, delay * 2);
|
|
183
|
+
this.reconnectTimer = setTimeout(() => {
|
|
184
|
+
this.reconnectTimer = undefined;
|
|
185
|
+
void this.connect().catch(() => {
|
|
186
|
+
this.scheduleReconnect();
|
|
187
|
+
});
|
|
188
|
+
}, delay);
|
|
189
|
+
this.reconnectTimer.unref?.();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function safeJson(s: string): unknown {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(s);
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol for GrantTap.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* - Payload: the meaningful messages exchanged between a machine (running a
|
|
6
|
+
* coding agent) and a phone/watch/web client. These are ALWAYS
|
|
7
|
+
* end-to-end encrypted — the relay never sees them in the clear.
|
|
8
|
+
* - Envelope: the thin routing wrapper the relay actually reads. Its body is
|
|
9
|
+
* opaque ciphertext; only `room`, `from`, `to` are visible so the
|
|
10
|
+
* relay can route without ever learning content (zero-knowledge).
|
|
11
|
+
*
|
|
12
|
+
* The payload set is agent-neutral on purpose: `agent` is just a string, so the
|
|
13
|
+
* same phone UI approves Claude Code, Codex, or anything else that speaks it.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
export const PROTOCOL_VERSION = 1 as const;
|
|
18
|
+
|
|
19
|
+
export const Role = z.enum(["machine", "phone"]);
|
|
20
|
+
export type Role = z.infer<typeof Role>;
|
|
21
|
+
|
|
22
|
+
/** Which agent produced a request. Open string: "claude", "codex", ... */
|
|
23
|
+
export const AgentId = z.string().min(1);
|
|
24
|
+
|
|
25
|
+
export const Risk = z.enum(["low", "medium", "high"]);
|
|
26
|
+
export type Risk = z.infer<typeof Risk>;
|
|
27
|
+
|
|
28
|
+
/** machine -> phone: "the agent wants to do X, may it?" */
|
|
29
|
+
export const ApprovalRequest = z.object({
|
|
30
|
+
type: z.literal("approval.request"),
|
|
31
|
+
requestId: z.string(),
|
|
32
|
+
agent: AgentId,
|
|
33
|
+
kind: z.literal("permission"),
|
|
34
|
+
tool: z.string(), // "Bash", "Edit", "shell", ...
|
|
35
|
+
title: z.string(), // short human line for the notification/watch
|
|
36
|
+
command: z.string().optional(), // full command / detail for the phone screen
|
|
37
|
+
cwd: z.string().optional(),
|
|
38
|
+
sessionId: z.string().optional(),
|
|
39
|
+
risk: Risk.default("medium"),
|
|
40
|
+
createdAt: z.number(),
|
|
41
|
+
});
|
|
42
|
+
export type ApprovalRequest = z.infer<typeof ApprovalRequest>;
|
|
43
|
+
|
|
44
|
+
/** phone -> machine: the tap on Approve/Deny (watch, notification, or in-app). */
|
|
45
|
+
export const ApprovalDecision = z.object({
|
|
46
|
+
type: z.literal("approval.decision"),
|
|
47
|
+
requestId: z.string(),
|
|
48
|
+
decision: z.enum(["allow", "deny"]),
|
|
49
|
+
note: z.string().optional(),
|
|
50
|
+
decidedBy: z.string().optional(), // "watch", "phone", "web"
|
|
51
|
+
decidedAt: z.number(),
|
|
52
|
+
});
|
|
53
|
+
export type ApprovalDecision = z.infer<typeof ApprovalDecision>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* phone -> machine: free-text sent to the agent's session.
|
|
57
|
+
* This is the foundation for the NEXT step (voice): speech-to-text on the
|
|
58
|
+
* phone/watch simply produces `text` here — no protocol change needed.
|
|
59
|
+
*/
|
|
60
|
+
export const UserMessage = z.object({
|
|
61
|
+
type: z.literal("user.message"),
|
|
62
|
+
text: z.string(),
|
|
63
|
+
/** Correlates a reply with an MCP `ask`; absent for ordinary session chat. */
|
|
64
|
+
requestId: z.string().optional(),
|
|
65
|
+
sessionId: z.string().optional(),
|
|
66
|
+
createdAt: z.number(),
|
|
67
|
+
});
|
|
68
|
+
export type UserMessage = z.infer<typeof UserMessage>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* machine -> phone: something the agent said / a status line.
|
|
72
|
+
* The "listen from them" half of the mic idea maps onto this + TTS later.
|
|
73
|
+
*/
|
|
74
|
+
export const AgentEvent = z.object({
|
|
75
|
+
type: z.literal("agent.event"),
|
|
76
|
+
text: z.string(),
|
|
77
|
+
/** Present for questions/replies that belong to one request-response exchange. */
|
|
78
|
+
requestId: z.string().optional(),
|
|
79
|
+
kind: z.enum(["question", "status", "response"]).optional(),
|
|
80
|
+
sessionId: z.string().optional(),
|
|
81
|
+
createdAt: z.number(),
|
|
82
|
+
});
|
|
83
|
+
export type AgentEvent = z.infer<typeof AgentEvent>;
|
|
84
|
+
|
|
85
|
+
/** phone -> machine: start/stop streaming the safe visible activity of one chat. */
|
|
86
|
+
export const SessionSubscription = z.object({
|
|
87
|
+
type: z.literal("session.subscribe"),
|
|
88
|
+
sessionId: z.string(),
|
|
89
|
+
active: z.boolean(),
|
|
90
|
+
createdAt: z.number(),
|
|
91
|
+
});
|
|
92
|
+
export type SessionSubscription = z.infer<typeof SessionSubscription>;
|
|
93
|
+
|
|
94
|
+
export const ActivityEntry = z.object({
|
|
95
|
+
id: z.string(),
|
|
96
|
+
kind: z.enum(["message", "tool", "final", "status"]),
|
|
97
|
+
text: z.string(),
|
|
98
|
+
createdAt: z.number(),
|
|
99
|
+
});
|
|
100
|
+
export type ActivityEntry = z.infer<typeof ActivityEntry>;
|
|
101
|
+
|
|
102
|
+
/** Where a session currently stands. */
|
|
103
|
+
export const SessionState = z.enum(["working", "waiting", "idle"]);
|
|
104
|
+
export type SessionState = z.infer<typeof SessionState>;
|
|
105
|
+
|
|
106
|
+
/** machine -> phone: visible agent output/tool summaries, never hidden reasoning. */
|
|
107
|
+
export const SessionActivity = z.object({
|
|
108
|
+
type: z.literal("session.activity"),
|
|
109
|
+
sessionId: z.string(),
|
|
110
|
+
agent: AgentId,
|
|
111
|
+
state: SessionState,
|
|
112
|
+
entries: z.array(ActivityEntry),
|
|
113
|
+
generatedAt: z.number(),
|
|
114
|
+
});
|
|
115
|
+
export type SessionActivity = z.infer<typeof SessionActivity>;
|
|
116
|
+
|
|
117
|
+
/** One live chat/session on a machine, with its real token spend. */
|
|
118
|
+
export const SessionInfo = z.object({
|
|
119
|
+
sessionId: z.string(),
|
|
120
|
+
agent: AgentId,
|
|
121
|
+
title: z.string().optional(),
|
|
122
|
+
cwd: z.string().optional(),
|
|
123
|
+
branch: z.string().optional(),
|
|
124
|
+
model: z.string().optional(),
|
|
125
|
+
state: SessionState,
|
|
126
|
+
startedAt: z.number(),
|
|
127
|
+
lastActivityAt: z.number(),
|
|
128
|
+
/** Tokens for this session, and for its most recent turn. */
|
|
129
|
+
tokensSession: z.number(),
|
|
130
|
+
tokensLastTurn: z.number(),
|
|
131
|
+
});
|
|
132
|
+
export type SessionInfo = z.infer<typeof SessionInfo>;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* machine -> phone: what's running right now.
|
|
136
|
+
* Sent periodically so the phone can show live sessions even when nothing needs
|
|
137
|
+
* a decision — "is it working, for how long, and what has it cost".
|
|
138
|
+
*/
|
|
139
|
+
export const SessionsStatus = z.object({
|
|
140
|
+
type: z.literal("sessions.status"),
|
|
141
|
+
machine: z.string(),
|
|
142
|
+
sessions: z.array(SessionInfo),
|
|
143
|
+
/** Tokens in the same recent-log window used to discover visible sessions. */
|
|
144
|
+
tokensRecent: z.number().optional(),
|
|
145
|
+
tokenWindowHours: z.number().optional(),
|
|
146
|
+
/** @deprecated Compatibility alias for older phone builds; not truly all-time. */
|
|
147
|
+
tokensAllTime: z.number().optional(),
|
|
148
|
+
/** Current gating switch + exclusions, so the phone can show/toggle them. */
|
|
149
|
+
gatingEnabled: z.boolean().optional(),
|
|
150
|
+
excludedSessions: z.array(z.string()).optional(),
|
|
151
|
+
generatedAt: z.number(),
|
|
152
|
+
});
|
|
153
|
+
export type SessionsStatus = z.infer<typeof SessionsStatus>;
|
|
154
|
+
|
|
155
|
+
/** phone -> machine: flip gating on/off, or exclude/include a session. */
|
|
156
|
+
export const ConfigSet = z.object({
|
|
157
|
+
type: z.literal("config.set"),
|
|
158
|
+
enabled: z.boolean().optional(),
|
|
159
|
+
excludeSession: z.string().optional(),
|
|
160
|
+
includeSession: z.string().optional(),
|
|
161
|
+
createdAt: z.number(),
|
|
162
|
+
});
|
|
163
|
+
export type ConfigSet = z.infer<typeof ConfigSet>;
|
|
164
|
+
|
|
165
|
+
/** First payload each side sends after connecting (identifies the device). */
|
|
166
|
+
export const Hello = z.object({
|
|
167
|
+
type: z.literal("hello"),
|
|
168
|
+
role: Role,
|
|
169
|
+
deviceName: z.string(),
|
|
170
|
+
createdAt: z.number(),
|
|
171
|
+
});
|
|
172
|
+
export type Hello = z.infer<typeof Hello>;
|
|
173
|
+
|
|
174
|
+
export const Payload = z.discriminatedUnion("type", [
|
|
175
|
+
ApprovalRequest,
|
|
176
|
+
ApprovalDecision,
|
|
177
|
+
UserMessage,
|
|
178
|
+
AgentEvent,
|
|
179
|
+
SessionSubscription,
|
|
180
|
+
SessionActivity,
|
|
181
|
+
SessionsStatus,
|
|
182
|
+
ConfigSet,
|
|
183
|
+
Hello,
|
|
184
|
+
]);
|
|
185
|
+
export type Payload = z.infer<typeof Payload>;
|
|
186
|
+
|
|
187
|
+
/** The routed unit. `nonce`+`box` are the sealed Payload; relay can't open it. */
|
|
188
|
+
export const Envelope = z.object({
|
|
189
|
+
v: z.literal(PROTOCOL_VERSION),
|
|
190
|
+
room: z.string(), // pairing id — the relay routes strictly within a room
|
|
191
|
+
from: Role,
|
|
192
|
+
to: z.union([Role, z.literal("all")]),
|
|
193
|
+
senderId: z.string(),
|
|
194
|
+
/** Relay-visible delivery deadline; content stays encrypted. */
|
|
195
|
+
expiresAt: z.number().optional(),
|
|
196
|
+
nonce: z.string(), // base64
|
|
197
|
+
box: z.string(), // base64: nacl.box(JSON(Payload))
|
|
198
|
+
});
|
|
199
|
+
export type Envelope = z.infer<typeof Envelope>;
|