granttap-mcp 0.6.4 → 0.6.5
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 +9 -1
- package/SECURITY.md +13 -3
- package/apps/bridge/src/bin/claude-hook.ts +10 -1
- package/apps/bridge/src/bin/codex-hook.ts +2 -1
- package/apps/bridge/src/capabilities.ts +24 -3
- package/apps/bridge/src/config.ts +44 -5
- package/apps/bridge/src/delivery.ts +61 -13
- package/apps/bridge/src/monitor.ts +37 -12
- package/apps/bridge/src/pairing.ts +5 -1
- package/apps/bridge/src/scheduler.ts +3 -1
- package/apps/bridge/src/session-keys.ts +2 -1
- package/apps/mcp/src/server.ts +19 -8
- package/bin/granttap-mcp.mjs +2 -1
- package/package.json +3 -3
- package/packages/core/relay-client.ts +64 -10
- package/packages/protocol/schema.ts +18 -1
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ Existing beta state under `~/.nodvox/` is migrated automatically.
|
|
|
109
109
|
| Approval hook | `PermissionRequest` when Codex hooks are enabled | `PreToolUse` |
|
|
110
110
|
| Resume an existing task | Yes, through the local Codex CLI | Yes, through the local Claude CLI |
|
|
111
111
|
| Start a new persistent task | Yes | Yes |
|
|
112
|
-
| Up to five attachments | Images through Codex image inputs; documents as local paths | Local image/document paths in the turn |
|
|
112
|
+
| Up to five attachments within one encrypted-frame budget | Images through Codex image inputs; documents as local paths | Local image/document paths in the turn |
|
|
113
113
|
| Change filesystem access from iPhone | Read-only, workspace, or full for the next GrantTap turn | Not exposed; the existing Claude policy remains authoritative |
|
|
114
114
|
| Disable MCP per task | Enforced for later GrantTap-delivered turns | Enforced for later GrantTap-delivered turns |
|
|
115
115
|
| Usage and context | Reported when present in local task logs | Reported when present in local task logs |
|
|
@@ -152,6 +152,14 @@ The default answer timeout is three minutes. Override it with
|
|
|
152
152
|
Durable Objects, and APNs path.
|
|
153
153
|
- APNs carries only a content-neutral wake. It contains no title, prompt,
|
|
154
154
|
command, path, task kind, or response.
|
|
155
|
+
- Cloudflare accepts at most a 32 MiB WebSocket frame. Because task messages are
|
|
156
|
+
sealed and base64-encoded twice, attachments share a 16,000,000-character
|
|
157
|
+
base64 budget (about 12 MB raw total); five small files fit, five 6 MB files do not.
|
|
158
|
+
|
|
159
|
+
The in-chat QR is marked user-only for MCP hosts, but that annotation is not a
|
|
160
|
+
cryptographic separation from the model provider. Use CLI `connect` when the
|
|
161
|
+
model provider itself is part of your threat model; Cloudflare still receives
|
|
162
|
+
only the mailbox id and ciphertext in either flow.
|
|
155
163
|
|
|
156
164
|
The relay can still observe operational metadata: opaque room/mailbox IDs, IP
|
|
157
165
|
addresses, timing, ciphertext sizes, and APNs device token/environment. A
|
package/SECURITY.md
CHANGED
|
@@ -18,8 +18,8 @@ to an issue, or pasted into logs. The package avoids printing device secret
|
|
|
18
18
|
keys. Existing configuration files are backed up before hook installation.
|
|
19
19
|
|
|
20
20
|
Current pairings contain three independent secret classes: NaCl endpoint keys,
|
|
21
|
-
random per-task keys, and a random `pushAuth` room credential that
|
|
22
|
-
|
|
21
|
+
random per-task keys, and a random `pushAuth` room credential that authorizes
|
|
22
|
+
the room WebSocket and APNs device-token registration at the relay. None may be logged.
|
|
23
23
|
The relay receives only a hash of `pushAuth` and never receives an endpoint's
|
|
24
24
|
secret encryption key.
|
|
25
25
|
|
|
@@ -31,6 +31,11 @@ secret encryption key.
|
|
|
31
31
|
separate random 256-bit transfer key. Only the mailbox id is used in the HTTP
|
|
32
32
|
path. The key stays in the QR/manual token, so a relay operator or a Durable
|
|
33
33
|
Object database dump cannot open the parked pairing blob.
|
|
34
|
+
- The terminal `connect` flow keeps that transfer key outside model context.
|
|
35
|
+
The MCP `connect` tool deliberately returns a QR in chat for convenience and
|
|
36
|
+
marks it user-only, but MCP audience annotations are not a cryptographic
|
|
37
|
+
boundary: depending on the host, the chat/model provider may receive tool
|
|
38
|
+
images. Use terminal pairing when the model provider is in your threat model.
|
|
34
39
|
- Every attached Codex or Claude Code task receives a separate random 256-bit
|
|
35
40
|
task key. Task messages, attachments, visible activity, access/MCP changes,
|
|
36
41
|
compaction results, and task-bound approvals use this additional authenticated
|
|
@@ -43,6 +48,8 @@ secret encryption key.
|
|
|
43
48
|
transport, network, Cloudflare, Durable Objects, and APNs.
|
|
44
49
|
- APNs is only a content-neutral wake. It contains no task kind, request id,
|
|
45
50
|
delivery id, title, prompt, command, path, or response.
|
|
51
|
+
It is a best-effort silent background notification; after waking, iOS pulls
|
|
52
|
+
and decrypts the queue and creates the single actionable local notification.
|
|
46
53
|
- Scheduler-planner turns and their structured drafts cross Cloudflare only as
|
|
47
54
|
authenticated device-to-device ciphertext. The selected CLI runs locally in
|
|
48
55
|
ephemeral read-only/plan mode; it cannot silently turn planning into a
|
|
@@ -65,7 +72,10 @@ different task's key—not against an already authorized endpoint.
|
|
|
65
72
|
|
|
66
73
|
The delivery ledger and scheduler history are stored only on the paired Mac.
|
|
67
74
|
They contain random message ids and local task metadata and are bounded and
|
|
68
|
-
expired; do not attach real copies to bug reports.
|
|
75
|
+
expired; do not attach real copies to bug reports. Delivery ACK is emitted only
|
|
76
|
+
after a consumer accepts the decrypted payload. A processing lease recovers
|
|
77
|
+
after a crash; agent execution is at-least-once across that rare crash window,
|
|
78
|
+
because third-party Codex/Claude CLIs do not expose a transactional idempotency key.
|
|
69
79
|
|
|
70
80
|
Chat history and capability metadata follow the same cryptographic boundary.
|
|
71
81
|
The bridge reads a bounded set of local Codex/Claude logs, emits only chat
|
|
@@ -28,7 +28,16 @@ async function main(): Promise<void> {
|
|
|
28
28
|
try {
|
|
29
29
|
input = JSON.parse(raw) as HookInput;
|
|
30
30
|
} catch {
|
|
31
|
-
|
|
31
|
+
process.stdout.write(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
hookSpecificOutput: {
|
|
34
|
+
hookEventName: "PreToolUse",
|
|
35
|
+
permissionDecision: "deny",
|
|
36
|
+
permissionDecisionReason: "GrantTap received invalid hook JSON",
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
// Gating paused, or this session is exempt → abstain (empty output = Claude
|
|
@@ -37,7 +37,8 @@ async function main(): Promise<void> {
|
|
|
37
37
|
try {
|
|
38
38
|
input = JSON.parse(raw) as HookInput;
|
|
39
39
|
} catch {
|
|
40
|
-
|
|
40
|
+
process.stdout.write(denyOutput("GrantTap received invalid hook JSON"));
|
|
41
|
+
return;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
// Gating paused or this session exempt → stay silent (Codex uses its own flow).
|
|
@@ -166,7 +166,9 @@ async function safeIconData(src: string, remoteUrl?: URL): Promise<{
|
|
|
166
166
|
} | undefined> {
|
|
167
167
|
const dataMatch = src.match(/^data:image\/(png|jpe?g);base64,([a-z0-9+/=\s]+)$/i);
|
|
168
168
|
if (dataMatch) {
|
|
169
|
-
const
|
|
169
|
+
const encoded = dataMatch[2]!.replace(/\s/g, "");
|
|
170
|
+
if (encoded.length > Math.ceil(MAX_MCP_ICON_BYTES * 4 / 3) + 4) return undefined;
|
|
171
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
170
172
|
const mimeType = imageMime(bytes);
|
|
171
173
|
if (!mimeType || bytes.length > MAX_MCP_ICON_BYTES) return undefined;
|
|
172
174
|
return { src: `data:${mimeType};base64,${bytes.toString("base64")}`, mimeType };
|
|
@@ -197,7 +199,8 @@ async function safeIconData(src: string, remoteUrl?: URL): Promise<{
|
|
|
197
199
|
if (!response.ok) return undefined;
|
|
198
200
|
const length = Number(response.headers.get("content-length") ?? 0);
|
|
199
201
|
if (Number.isFinite(length) && length > MAX_MCP_ICON_BYTES) return undefined;
|
|
200
|
-
const bytes =
|
|
202
|
+
const bytes = await readResponseBodyLimited(response, MAX_MCP_ICON_BYTES);
|
|
203
|
+
if (!bytes) return undefined;
|
|
201
204
|
const mimeType = imageMime(bytes);
|
|
202
205
|
if (!mimeType || bytes.length > MAX_MCP_ICON_BYTES) return undefined;
|
|
203
206
|
return { src: `data:${mimeType};base64,${bytes.toString("base64")}`, mimeType };
|
|
@@ -208,6 +211,24 @@ async function safeIconData(src: string, remoteUrl?: URL): Promise<{
|
|
|
208
211
|
return undefined;
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
async function readResponseBodyLimited(response: Response, maxBytes: number): Promise<Buffer | undefined> {
|
|
215
|
+
if (!response.body) return undefined;
|
|
216
|
+
const reader = response.body.getReader();
|
|
217
|
+
const chunks: Uint8Array[] = [];
|
|
218
|
+
let total = 0;
|
|
219
|
+
for (;;) {
|
|
220
|
+
const { done, value } = await reader.read();
|
|
221
|
+
if (done) break;
|
|
222
|
+
total += value.byteLength;
|
|
223
|
+
if (total > maxBytes) {
|
|
224
|
+
await reader.cancel().catch(() => {});
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
chunks.push(value);
|
|
228
|
+
}
|
|
229
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
|
|
230
|
+
}
|
|
231
|
+
|
|
211
232
|
async function normalizedIcons(
|
|
212
233
|
info: Implementation,
|
|
213
234
|
descriptor: McpDescriptor,
|
|
@@ -272,7 +293,7 @@ async function normalizeServerMetadata(
|
|
|
272
293
|
async function probeMetadata(descriptor: McpDescriptor): Promise<ServerMetadata | undefined> {
|
|
273
294
|
const config = descriptor.transport;
|
|
274
295
|
if (!config || !descriptor.configuredEnabled) return undefined;
|
|
275
|
-
const client = new Client({ name: "granttap-metadata", version: "0.6.
|
|
296
|
+
const client = new Client({ name: "granttap-metadata", version: "0.6.5" });
|
|
276
297
|
try {
|
|
277
298
|
const type = typeof config.type === "string" ? config.type : undefined;
|
|
278
299
|
if ((type === "streamable_http" || type === "http" || type === "sse") &&
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { hostname } from "node:os";
|
|
13
13
|
import { generateKeyPair, randomId } from "../../../packages/core/crypto";
|
|
14
14
|
import type { PeerConfig } from "../../../packages/core/relay-client";
|
|
@@ -88,7 +88,8 @@ export function loadRuntimeConfig(): RuntimeConfig {
|
|
|
88
88
|
|
|
89
89
|
export function saveRuntimeConfig(cfg: RuntimeConfig): void {
|
|
90
90
|
mkdirSync(configDir(), { recursive: true });
|
|
91
|
-
writeFileSync(runtimeConfigPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
91
|
+
writeFileSync(runtimeConfigPath(), JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
92
|
+
chmodSync(runtimeConfigPath(), 0o600);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
/** True when the hook should stay out of the way for this session. */
|
|
@@ -105,6 +106,8 @@ export function phonePairingPath(): string {
|
|
|
105
106
|
export function saveConfig(path: string, cfg: PeerConfig): void {
|
|
106
107
|
mkdirSync(configDir(), { recursive: true });
|
|
107
108
|
writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
109
|
+
// `mode` only applies when creating a file; repair permissive legacy files too.
|
|
110
|
+
chmodSync(path, 0o600);
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
export function loadConfig(path: string): PeerConfig {
|
|
@@ -123,6 +126,32 @@ const unb64url = (s: string): string => {
|
|
|
123
126
|
return t + "=".repeat((4 - (t.length % 4)) % 4);
|
|
124
127
|
};
|
|
125
128
|
|
|
129
|
+
/** Canonicalize relay endpoints and reject schemes that can expose pairing data. */
|
|
130
|
+
export function normalizeRelayUrl(value: string): string {
|
|
131
|
+
let url: URL;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(value);
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error("relay URL is invalid");
|
|
136
|
+
}
|
|
137
|
+
if (url.protocol !== "wss:" && url.protocol !== "ws:") {
|
|
138
|
+
throw new Error("relay URL must use wss:// (ws:// is allowed for local development)");
|
|
139
|
+
}
|
|
140
|
+
const loopback = url.hostname === "localhost" || url.hostname.endsWith(".localhost")
|
|
141
|
+
|| url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
|
|
142
|
+
if (url.protocol === "ws:" && !loopback) {
|
|
143
|
+
throw new Error("unencrypted ws:// is allowed only for a loopback development relay");
|
|
144
|
+
}
|
|
145
|
+
if (url.username || url.password || url.hash || url.search) {
|
|
146
|
+
throw new Error("relay URL must not contain credentials, a query, or a fragment");
|
|
147
|
+
}
|
|
148
|
+
return url.toString().replace(/\/$/, "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validPairingKey(value: string): boolean {
|
|
152
|
+
return /^[A-Za-z0-9_-]{43,44}$/.test(value) && Buffer.from(unb64url(value), "base64").length === 32;
|
|
153
|
+
}
|
|
154
|
+
|
|
126
155
|
export function pairingUri(cfg: PeerConfig): string {
|
|
127
156
|
const q = new URLSearchParams({
|
|
128
157
|
v: "1",
|
|
@@ -148,9 +177,17 @@ export function parsePairingUri(uri: string): PeerConfig | null {
|
|
|
148
177
|
return null;
|
|
149
178
|
}
|
|
150
179
|
const get = (k: string) => q.get(k) ?? "";
|
|
151
|
-
if (
|
|
180
|
+
if (get("v") !== "1" || !/^[a-f0-9]{16,64}$/.test(get("r"))) return null;
|
|
181
|
+
if (!["s", "p", "k"].every((key) => validPairingKey(get(key)))) return null;
|
|
182
|
+
if (get("i").length > 180 || (get("a") && !/^[a-f0-9]{64}$/.test(get("a")))) return null;
|
|
183
|
+
let relayUrl: string;
|
|
184
|
+
try {
|
|
185
|
+
relayUrl = normalizeRelayUrl(get("u"));
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
152
189
|
return {
|
|
153
|
-
relayUrl
|
|
190
|
+
relayUrl,
|
|
154
191
|
room: get("r"),
|
|
155
192
|
role: "phone",
|
|
156
193
|
deviceName: "phone",
|
|
@@ -164,9 +201,11 @@ export function parsePairingUri(uri: string): PeerConfig | null {
|
|
|
164
201
|
|
|
165
202
|
/** Create a fresh machine<->phone pairing. */
|
|
166
203
|
export function createPairing(relayUrl: string): { machineCfg: PeerConfig; phoneCfg: PeerConfig } {
|
|
204
|
+
relayUrl = normalizeRelayUrl(relayUrl);
|
|
167
205
|
const machine = generateKeyPair();
|
|
168
206
|
const phone = generateKeyPair();
|
|
169
|
-
|
|
207
|
+
// 128 bits keeps the relay's TOFU room rendezvous unguessable in practice.
|
|
208
|
+
const room = randomId(16);
|
|
170
209
|
const pushAuth = randomId(32);
|
|
171
210
|
|
|
172
211
|
const machineCfg: PeerConfig = {
|
|
@@ -1,39 +1,87 @@
|
|
|
1
1
|
/** Persistent idempotency ledger for phone messages and their delivery receipts. */
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { configDir } from "./config";
|
|
5
5
|
|
|
6
6
|
const LIMIT = 500;
|
|
7
7
|
const MAX_AGE_MS = 7 * 24 * 60 * 60_000;
|
|
8
|
+
const PROCESSING_LEASE_MS = 5 * 60_000;
|
|
9
|
+
|
|
10
|
+
type DeliveryRecord = {
|
|
11
|
+
state: "processing" | "completed";
|
|
12
|
+
updatedAt: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type DeliveryStart = "started" | "processing" | "completed";
|
|
8
16
|
|
|
9
17
|
export function deliveryLedgerPath(): string {
|
|
10
18
|
return join(configDir(), "delivery-ledger.json");
|
|
11
19
|
}
|
|
12
20
|
|
|
13
|
-
function load(): Record<string,
|
|
21
|
+
function load(): Record<string, DeliveryRecord> {
|
|
14
22
|
try {
|
|
15
23
|
const raw = JSON.parse(readFileSync(deliveryLedgerPath(), "utf8"));
|
|
16
24
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
const records: Record<string, DeliveryRecord> = {};
|
|
26
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
27
|
+
// Migrate the original `{ id: acceptedAt }` ledger as completed work.
|
|
28
|
+
if (typeof value === "number") records[id] = { state: "completed", updatedAt: value };
|
|
29
|
+
else if (value && typeof value === "object"
|
|
30
|
+
&& ((value as DeliveryRecord).state === "processing"
|
|
31
|
+
|| (value as DeliveryRecord).state === "completed")
|
|
32
|
+
&& typeof (value as DeliveryRecord).updatedAt === "number") {
|
|
33
|
+
records[id] = value as DeliveryRecord;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return records;
|
|
21
37
|
} catch {
|
|
22
38
|
return {};
|
|
23
39
|
}
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
export function hasAcceptedDelivery(messageId: string, now = Date.now()): boolean {
|
|
27
|
-
const
|
|
28
|
-
return
|
|
43
|
+
const record = load()[messageId];
|
|
44
|
+
return record?.state === "completed" && record.updatedAt >= now - MAX_AGE_MS;
|
|
29
45
|
}
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
function save(records: Record<string, DeliveryRecord>, now: number): void {
|
|
32
48
|
const cutoff = now - MAX_AGE_MS;
|
|
33
|
-
const
|
|
34
|
-
.filter(([,
|
|
35
|
-
.sort((a, b) => b[1] - a[1])
|
|
36
|
-
|
|
49
|
+
const next = Object.fromEntries(Object.entries(records)
|
|
50
|
+
.filter(([, record]) => record.updatedAt >= cutoff && record.updatedAt <= now)
|
|
51
|
+
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
|
52
|
+
.slice(0, LIMIT));
|
|
37
53
|
mkdirSync(configDir(), { recursive: true });
|
|
38
54
|
writeFileSync(deliveryLedgerPath(), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
55
|
+
chmodSync(deliveryLedgerPath(), 0o600);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Acquire a durable processing lease before invoking an agent. */
|
|
59
|
+
export function beginDelivery(messageId: string, now = Date.now()): DeliveryStart {
|
|
60
|
+
const records = load();
|
|
61
|
+
const current = records[messageId];
|
|
62
|
+
if (current?.state === "completed" && current.updatedAt >= now - MAX_AGE_MS) return "completed";
|
|
63
|
+
if (current?.state === "processing" && current.updatedAt >= now - PROCESSING_LEASE_MS) {
|
|
64
|
+
return "processing";
|
|
65
|
+
}
|
|
66
|
+
records[messageId] = { state: "processing", updatedAt: now };
|
|
67
|
+
save(records, now);
|
|
68
|
+
return "started";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function completeDelivery(messageId: string, now = Date.now()): void {
|
|
72
|
+
const records = load();
|
|
73
|
+
records[messageId] = { state: "completed", updatedAt: now };
|
|
74
|
+
save(records, now);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Release a failed attempt so a relay retry can safely try again. */
|
|
78
|
+
export function abandonDelivery(messageId: string, now = Date.now()): void {
|
|
79
|
+
const records = load();
|
|
80
|
+
delete records[messageId];
|
|
81
|
+
save(records, now);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @deprecated Compatibility wrapper for callers/tests from the old ledger. */
|
|
85
|
+
export function rememberAcceptedDelivery(messageId: string, now = Date.now()): void {
|
|
86
|
+
completeDelivery(messageId, now);
|
|
39
87
|
}
|
|
@@ -20,7 +20,7 @@ import { configDir, loadRuntimeConfig, saveRuntimeConfig } from "./config";
|
|
|
20
20
|
import { mcpServersForSession, workspaceSkills } from "./capabilities";
|
|
21
21
|
import { compactCodexSession } from "./codex-control";
|
|
22
22
|
import { createClaudeSession, createCodexSession, deliverToSession } from "./reply";
|
|
23
|
-
import {
|
|
23
|
+
import { abandonDelivery, beginDelivery, completeDelivery } from "./delivery";
|
|
24
24
|
import { inspectAgentIntegrations } from "./install";
|
|
25
25
|
import { primeSessionKeys, sendSessionPayload } from "./session-keys";
|
|
26
26
|
import {
|
|
@@ -132,47 +132,72 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
132
132
|
}
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
-
const off = client.onMessage((payload) => {
|
|
135
|
+
const off = client.onMessage(async (payload) => {
|
|
136
136
|
// Codex may start one MCP server per open task. Exactly one instance owns
|
|
137
137
|
// phone routing, so a single phone message can never create duplicate tasks.
|
|
138
|
-
if (!leadership.acquire()) return;
|
|
138
|
+
if (!leadership.acquire()) return false;
|
|
139
139
|
if (payload.type === "user.message") {
|
|
140
|
+
// Correlated replies belong to the MCP `ask` waiter in one process.
|
|
141
|
+
if (payload.requestId) return false;
|
|
142
|
+
let deliveryStarted = false;
|
|
140
143
|
if (payload.messageId) {
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
const state = beginDelivery(payload.messageId);
|
|
145
|
+
if (state === "completed") {
|
|
146
|
+
await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (state === "processing") return false;
|
|
150
|
+
deliveryStarted = true;
|
|
145
151
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
try {
|
|
153
|
+
await handleUserMessage(client, payload);
|
|
154
|
+
if (payload.messageId && deliveryStarted) {
|
|
155
|
+
completeDelivery(payload.messageId);
|
|
156
|
+
await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
|
|
157
|
+
}
|
|
158
|
+
void publish().catch(() => {});
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
if (payload.messageId && deliveryStarted) abandonDelivery(payload.messageId);
|
|
162
|
+
return false;
|
|
149
163
|
}
|
|
150
164
|
} else if (payload.type === "config.set") {
|
|
151
165
|
handleConfigSet(payload);
|
|
152
166
|
void publish().catch(() => {});
|
|
167
|
+
return true;
|
|
153
168
|
} else if (payload.type === "session.subscribe") {
|
|
154
169
|
handleSubscription(subscriptions, payload);
|
|
155
170
|
void publish().catch(() => {});
|
|
171
|
+
return true;
|
|
156
172
|
} else if (payload.type === "session.access.set") {
|
|
157
173
|
handleAccessSet(payload);
|
|
158
174
|
void publish().catch(() => {});
|
|
175
|
+
return true;
|
|
159
176
|
} else if (payload.type === "session.mcp.set") {
|
|
160
177
|
handleMcpSet(payload);
|
|
161
178
|
void publish().catch(() => {});
|
|
179
|
+
return true;
|
|
162
180
|
} else if (payload.type === "session.compact") {
|
|
163
|
-
|
|
181
|
+
await handleCompact(client, payload);
|
|
182
|
+
void publish().catch(() => {});
|
|
183
|
+
return true;
|
|
164
184
|
} else if (payload.type === "schedule.set") {
|
|
165
185
|
handleScheduleSet(payload);
|
|
166
186
|
void publish().catch(() => {});
|
|
187
|
+
return true;
|
|
167
188
|
} else if (payload.type === "schedule.delete") {
|
|
168
189
|
handleScheduleDelete(payload);
|
|
169
190
|
void publish().catch(() => {});
|
|
191
|
+
return true;
|
|
170
192
|
} else if (payload.type === "schedule.run") {
|
|
171
193
|
handleScheduleRun(payload);
|
|
172
194
|
void publish().catch(() => {});
|
|
195
|
+
return true;
|
|
173
196
|
} else if (payload.type === "schedule.plan.request") {
|
|
174
|
-
|
|
197
|
+
await handleSchedulePlan(client, payload);
|
|
198
|
+
return true;
|
|
175
199
|
}
|
|
200
|
+
return false;
|
|
176
201
|
});
|
|
177
202
|
|
|
178
203
|
const timer = setInterval(() => void publish().catch(() => {}), INTERVAL_MS);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
createPairing,
|
|
10
10
|
machineConfigPath,
|
|
11
|
+
normalizeRelayUrl,
|
|
11
12
|
phonePairingPath,
|
|
12
13
|
saveConfig,
|
|
13
14
|
} from "./config";
|
|
@@ -30,7 +31,9 @@ export type OneTimePairing = {
|
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
export function relayHttpBase(relayUrl: string): string {
|
|
33
|
-
|
|
34
|
+
const url = new URL(normalizeRelayUrl(relayUrl));
|
|
35
|
+
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
36
|
+
return url.toString().replace(/\/$/, "");
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
/**
|
|
@@ -66,6 +69,7 @@ export async function createOneTimePairing(
|
|
|
66
69
|
headers: { "content-type": "application/json" },
|
|
67
70
|
body: JSON.stringify(sealed),
|
|
68
71
|
signal: AbortSignal.timeout(10_000),
|
|
72
|
+
redirect: "error",
|
|
69
73
|
});
|
|
70
74
|
} catch (error) {
|
|
71
75
|
throw new Error(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Terminal-free local schedules shared by Codex and Claude Code. */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import {
|
|
@@ -42,6 +42,7 @@ export function scheduleHistorySnapshot(): ScheduleRunRecord[] {
|
|
|
42
42
|
function saveHistory(history: ScheduleRunRecord[]): void {
|
|
43
43
|
mkdirSync(configDir(), { recursive: true });
|
|
44
44
|
writeFileSync(scheduleHistoryPath(), `${JSON.stringify(history.slice(0, 200), null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
chmodSync(scheduleHistoryPath(), 0o600);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export function loadSchedules(): ScheduledTask[] {
|
|
@@ -60,6 +61,7 @@ export function loadSchedules(): ScheduledTask[] {
|
|
|
60
61
|
function saveSchedules(tasks: ScheduledTask[]): void {
|
|
61
62
|
mkdirSync(configDir(), { recursive: true });
|
|
62
63
|
writeFileSync(schedulesPath(), `${JSON.stringify(tasks, null, 2)}\n`, { mode: 0o600 });
|
|
64
|
+
chmodSync(schedulesPath(), 0o600);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export function scheduledSnapshot(now = Date.now()): ScheduledTask[] {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { generateTransferKey } from "../../../packages/core/crypto";
|
|
4
4
|
import { RelayClient, type SendOptions } from "../../../packages/core/relay-client";
|
|
@@ -25,6 +25,7 @@ function load(): SessionKeys {
|
|
|
25
25
|
function save(keys: SessionKeys): void {
|
|
26
26
|
mkdirSync(configDir(), { recursive: true });
|
|
27
27
|
writeFileSync(sessionKeysPath(), `${JSON.stringify(keys, null, 2)}\n`, { mode: 0o600 });
|
|
28
|
+
chmodSync(sessionKeysPath(), 0o600);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export function sessionKey(sessionId: string): string {
|
package/apps/mcp/src/server.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { z } from "zod";
|
|
|
19
19
|
import QRCode from "qrcode";
|
|
20
20
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
21
21
|
import { RelayClient } from "../../../packages/core/relay-client";
|
|
22
|
-
import { loadConfig, machineConfigPath } from "../../bridge/src/config";
|
|
22
|
+
import { loadConfig, machineConfigPath, normalizeRelayUrl } from "../../bridge/src/config";
|
|
23
23
|
import { installClaudeHook, installCodexHook, installMonitorHelper } from "../../bridge/src/install";
|
|
24
24
|
import { randomId } from "../../../packages/core/crypto";
|
|
25
25
|
import type { Payload } from "../../../packages/protocol/schema";
|
|
@@ -67,7 +67,7 @@ async function main(): Promise<void> {
|
|
|
67
67
|
const server = new McpServer({
|
|
68
68
|
name: "granttap",
|
|
69
69
|
title: "GrantTap",
|
|
70
|
-
version: "0.6.
|
|
70
|
+
version: "0.6.5",
|
|
71
71
|
websiteUrl: "https://granttap.com",
|
|
72
72
|
icons: [
|
|
73
73
|
{
|
|
@@ -84,7 +84,13 @@ async function main(): Promise<void> {
|
|
|
84
84
|
{
|
|
85
85
|
relayUrl: z
|
|
86
86
|
.string()
|
|
87
|
+
.max(2_048)
|
|
87
88
|
.url()
|
|
89
|
+
.refine((value) => {
|
|
90
|
+
try { normalizeRelayUrl(value); return true; } catch { return false; }
|
|
91
|
+
}, {
|
|
92
|
+
message: "Relay URL must use wss:// (or loopback ws:// for local development)",
|
|
93
|
+
})
|
|
88
94
|
.optional()
|
|
89
95
|
.describe("Optional wss:// relay URL. Omit to use the GrantTap production relay."),
|
|
90
96
|
},
|
|
@@ -107,13 +113,18 @@ async function main(): Promise<void> {
|
|
|
107
113
|
type: "text",
|
|
108
114
|
text: [
|
|
109
115
|
"Scan this QR with GrantTap on iPhone to pair this computer.",
|
|
110
|
-
`Manual secure token: ${pairing.manualToken}`,
|
|
111
116
|
`Relay: ${pairing.httpBase}`,
|
|
112
117
|
`The encrypted mailbox is single-use and expires after ${PAIRING_CODE_TTL_MINUTES} minutes.`,
|
|
113
|
-
"The relay receives only a random mailbox id and ciphertext; the independent 256-bit transfer key stays in this QR
|
|
118
|
+
"The relay receives only a random mailbox id and ciphertext; the independent 256-bit transfer key stays in this user-only QR.",
|
|
114
119
|
].join("\n"),
|
|
120
|
+
annotations: { audience: ["user"] },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: "image",
|
|
124
|
+
data: png.toString("base64"),
|
|
125
|
+
mimeType: "image/png",
|
|
126
|
+
annotations: { audience: ["user"] },
|
|
115
127
|
},
|
|
116
|
-
{ type: "image", data: png.toString("base64"), mimeType: "image/png" },
|
|
117
128
|
],
|
|
118
129
|
};
|
|
119
130
|
} catch (error) {
|
|
@@ -133,7 +144,7 @@ async function main(): Promise<void> {
|
|
|
133
144
|
server.tool(
|
|
134
145
|
"notify",
|
|
135
146
|
"Push a short status/message to the user's phone. Fire-and-forget — use it to keep them informed without blocking.",
|
|
136
|
-
{ message: z.string().describe("Text to show on the phone") },
|
|
147
|
+
{ message: z.string().min(1).max(8_000).describe("Text to show on the phone") },
|
|
137
148
|
async ({ message }) => {
|
|
138
149
|
const c = await relay();
|
|
139
150
|
if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
|
|
@@ -149,7 +160,7 @@ async function main(): Promise<void> {
|
|
|
149
160
|
server.tool(
|
|
150
161
|
"ask_yes_no",
|
|
151
162
|
"Ask the user a yes/no question on their phone/watch and wait for the tap. Returns 'yes' or 'no'.",
|
|
152
|
-
{ question: z.string().describe("A question answerable with yes/no") },
|
|
163
|
+
{ question: z.string().min(1).max(8_000).describe("A question answerable with yes/no") },
|
|
153
164
|
async ({ question }) => {
|
|
154
165
|
const c = await relay();
|
|
155
166
|
if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
|
|
@@ -183,7 +194,7 @@ async function main(): Promise<void> {
|
|
|
183
194
|
server.tool(
|
|
184
195
|
"ask",
|
|
185
196
|
"Ask the user an open question on their phone/watch and wait for their spoken or typed reply. Returns their answer text.",
|
|
186
|
-
{ question: z.string().describe("The question to ask") },
|
|
197
|
+
{ question: z.string().min(1).max(8_000).describe("The question to ask") },
|
|
187
198
|
async ({ question }) => {
|
|
188
199
|
const c = await relay();
|
|
189
200
|
if (!c) return { content: [{ type: "text", text: NOT_PAIRED }] };
|
package/bin/granttap-mcp.mjs
CHANGED
|
@@ -32,7 +32,8 @@ if (command === "mcp") {
|
|
|
32
32
|
process.exit(1);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const
|
|
35
|
+
const forwardedArgs = command === "connect" ? process.argv.slice(3) : [];
|
|
36
|
+
const child = spawn(process.execPath, ["--import", "tsx", entry, ...forwardedArgs], {
|
|
36
37
|
stdio: "inherit",
|
|
37
38
|
env: process.env,
|
|
38
39
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granttap-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Control Codex and Claude Code from iPhone and Apple Watch with zero-knowledge E2EE approvals, replies, and task sync.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"prepublishOnly": "npm test && npm run typecheck"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
26
26
|
"qrcode": "^1.5.4",
|
|
27
|
-
"tsx": "^4.
|
|
27
|
+
"tsx": "^4.23.4",
|
|
28
28
|
"tweetnacl": "^1.0.3",
|
|
29
29
|
"ws": "^8.18.0",
|
|
30
30
|
"zod": "^3.24.1"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the Cloudflare Durable Object deployment.
|
|
11
11
|
*/
|
|
12
12
|
import WebSocket from "ws";
|
|
13
|
-
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
14
14
|
import { Envelope, PROTOCOL_VERSION, Payload, type Role } from "../protocol/schema";
|
|
15
15
|
import { open, openWithTransferKey, seal, sealWithTransferKey } from "./crypto";
|
|
16
16
|
|
|
@@ -27,7 +27,8 @@ export type PeerConfig = {
|
|
|
27
27
|
pushAuth?: string;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** Return true only after this consumer has durably accepted the payload. */
|
|
31
|
+
type Listener = (p: Payload) => boolean | void | Promise<boolean | void>;
|
|
31
32
|
|
|
32
33
|
export type RelayClientOptions = {
|
|
33
34
|
/** Persistent processes reconnect in the background; one-shot hooks leave this off. */
|
|
@@ -52,6 +53,15 @@ export class RelayClient {
|
|
|
52
53
|
private reconnectTimer?: NodeJS.Timeout;
|
|
53
54
|
private reconnectDelay: number;
|
|
54
55
|
private readonly sessionKeys = new Map<string, string>();
|
|
56
|
+
/**
|
|
57
|
+
* Delivery ids are relay-controlled metadata, so replay protection is bound
|
|
58
|
+
* to the authenticated ciphertext itself. Keep a bounded process-local set:
|
|
59
|
+
* relay retries are acknowledged, but never delivered to the application
|
|
60
|
+
* twice even if an attacker changes the clear-text delivery id.
|
|
61
|
+
*/
|
|
62
|
+
private readonly seenCiphertexts = new Set<string>();
|
|
63
|
+
private readonly processingCiphertexts = new Set<string>();
|
|
64
|
+
private static readonly MAX_SEEN_CIPHERTEXTS = 2_048;
|
|
55
65
|
|
|
56
66
|
constructor(
|
|
57
67
|
private readonly cfg: PeerConfig,
|
|
@@ -101,7 +111,7 @@ export class RelayClient {
|
|
|
101
111
|
.catch(() => {});
|
|
102
112
|
resolve();
|
|
103
113
|
});
|
|
104
|
-
ws.on("message", (data: WebSocket.RawData) => this.onRaw(data.toString()));
|
|
114
|
+
ws.on("message", (data: WebSocket.RawData) => void this.onRaw(data.toString()));
|
|
105
115
|
ws.on("error", (err) => {
|
|
106
116
|
clearTimeout(timer);
|
|
107
117
|
if (!opened) reject(err);
|
|
@@ -119,11 +129,13 @@ export class RelayClient {
|
|
|
119
129
|
return this.connectPromise;
|
|
120
130
|
}
|
|
121
131
|
|
|
122
|
-
private onRaw(raw: string): void {
|
|
132
|
+
private async onRaw(raw: string): Promise<void> {
|
|
123
133
|
const parsed = Envelope.safeParse(safeJson(raw));
|
|
124
134
|
if (!parsed.success) return;
|
|
125
135
|
const env = parsed.data;
|
|
126
136
|
if (env.room !== this.cfg.room) return;
|
|
137
|
+
if (env.from !== this.otherRole()) return;
|
|
138
|
+
if (env.to !== this.cfg.role && env.to !== "all") return;
|
|
127
139
|
if (env.expiresAt != null && env.expiresAt <= Date.now()) return;
|
|
128
140
|
const body = open(env.nonce, env.box, this.cfg.peerPublicKey, this.cfg.mySecretKey);
|
|
129
141
|
if (body === null) return; // not for us, or tampered
|
|
@@ -131,20 +143,60 @@ export class RelayClient {
|
|
|
131
143
|
if (!parsedPayload.success) return;
|
|
132
144
|
let payload: Payload = parsedPayload.data;
|
|
133
145
|
if (payload.type === "session.key.grant") {
|
|
134
|
-
|
|
146
|
+
// Only the machine may attach a task and grant its independent key.
|
|
147
|
+
if (this.cfg.role !== "phone") return;
|
|
135
148
|
} else if (payload.type === "session.sealed") {
|
|
136
149
|
const key = this.sessionKeys.get(payload.sessionId);
|
|
137
150
|
if (!key) return;
|
|
138
151
|
const inner = openWithTransferKey(payload.nonce, payload.box, key);
|
|
139
152
|
const parsedInner = Payload.safeParse(inner);
|
|
140
|
-
if (
|
|
141
|
-
|
|
153
|
+
if (
|
|
154
|
+
!parsedInner.success ||
|
|
155
|
+
parsedInner.data.type === "session.key.grant" ||
|
|
156
|
+
parsedInner.data.type === "session.sealed"
|
|
157
|
+
) return;
|
|
158
|
+
const innerSessionId = "sessionId" in parsedInner.data ? parsedInner.data.sessionId : undefined;
|
|
159
|
+
if (typeof innerSessionId === "string" && innerSessionId !== payload.sessionId) return;
|
|
142
160
|
payload = parsedInner.data;
|
|
143
161
|
}
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
|
|
163
|
+
const fingerprint = createHash("sha256")
|
|
164
|
+
.update(env.nonce)
|
|
165
|
+
.update(".")
|
|
166
|
+
.update(env.box)
|
|
167
|
+
.digest("base64url");
|
|
168
|
+
if (this.seenCiphertexts.has(fingerprint)) {
|
|
169
|
+
this.ackDelivery(env.deliveryId);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (payload.type === "session.key.grant") {
|
|
173
|
+
this.sessionKeys.set(payload.sessionId, payload.key);
|
|
174
|
+
}
|
|
175
|
+
if (this.processingCiphertexts.has(fingerprint)) return;
|
|
176
|
+
this.processingCiphertexts.add(fingerprint);
|
|
177
|
+
try {
|
|
178
|
+
const results = await Promise.all([...this.listeners].map(async (listener) => {
|
|
179
|
+
try { return (await listener(payload)) === true; } catch { return false; }
|
|
180
|
+
}));
|
|
181
|
+
// Hello and key grants are completely consumed by RelayClient itself.
|
|
182
|
+
const accepted = payload.type === "hello" || payload.type === "session.key.grant"
|
|
183
|
+
|| results.some(Boolean);
|
|
184
|
+
if (!accepted) return;
|
|
185
|
+
this.seenCiphertexts.add(fingerprint);
|
|
186
|
+
if (this.seenCiphertexts.size > RelayClient.MAX_SEEN_CIPHERTEXTS) {
|
|
187
|
+
const oldest = this.seenCiphertexts.values().next().value;
|
|
188
|
+
if (oldest) this.seenCiphertexts.delete(oldest);
|
|
189
|
+
}
|
|
190
|
+
this.ackDelivery(env.deliveryId);
|
|
191
|
+
} finally {
|
|
192
|
+
this.processingCiphertexts.delete(fingerprint);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private ackDelivery(deliveryId: string | undefined): void {
|
|
197
|
+
if (deliveryId && this.ws?.readyState === WebSocket.OPEN) {
|
|
198
|
+
this.ws.send(JSON.stringify({ type: "relay.ack", deliveryId }));
|
|
146
199
|
}
|
|
147
|
-
for (const l of this.listeners) l(payload);
|
|
148
200
|
}
|
|
149
201
|
|
|
150
202
|
async send(
|
|
@@ -214,7 +266,9 @@ export class RelayClient {
|
|
|
214
266
|
clearTimeout(timer);
|
|
215
267
|
off();
|
|
216
268
|
resolve(p);
|
|
269
|
+
return true;
|
|
217
270
|
}
|
|
271
|
+
return false;
|
|
218
272
|
});
|
|
219
273
|
});
|
|
220
274
|
}
|
|
@@ -65,6 +65,13 @@ export const UserAttachment = z.object({
|
|
|
65
65
|
});
|
|
66
66
|
export type UserAttachment = z.infer<typeof UserAttachment>;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Task messages are sealed twice (task key, then device key), and each NaCl
|
|
70
|
+
* layer base64-encodes its ciphertext. This budget keeps the final frame below
|
|
71
|
+
* Cloudflare's 32 MiB WebSocket receive limit with JSON overhead.
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_ATTACHMENT_BASE64_CHARS = 16_000_000;
|
|
74
|
+
|
|
68
75
|
export const UserMessage = z.object({
|
|
69
76
|
type: z.literal("user.message"),
|
|
70
77
|
/** Stable id makes phone retries idempotent and powers delivery receipts. */
|
|
@@ -477,7 +484,17 @@ export const Payload = z.discriminatedUnion("type", [
|
|
|
477
484
|
SchedulePlanRequest,
|
|
478
485
|
SchedulePlanResult,
|
|
479
486
|
Hello,
|
|
480
|
-
])
|
|
487
|
+
]).superRefine((payload, ctx) => {
|
|
488
|
+
if (payload.type !== "user.message") return;
|
|
489
|
+
const encodedCharacters = payload.attachments?.reduce((total, item) => total + item.data.length, 0) ?? 0;
|
|
490
|
+
if (encodedCharacters > MAX_ATTACHMENT_BASE64_CHARS) {
|
|
491
|
+
ctx.addIssue({
|
|
492
|
+
code: z.ZodIssueCode.custom,
|
|
493
|
+
path: ["attachments"],
|
|
494
|
+
message: `attachments exceed the ${MAX_ATTACHMENT_BASE64_CHARS}-character encrypted transport budget`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
});
|
|
481
498
|
export type Payload = z.infer<typeof Payload>;
|
|
482
499
|
|
|
483
500
|
/** The routed unit. `nonce`+`box` are the sealed Payload; relay can't open it. */
|