granttap-mcp 0.8.19 → 0.8.20

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.
@@ -19,6 +19,7 @@ import {
19
19
  type ApprovalRegistrationHandle,
20
20
  } from "./state";
21
21
  import { primeSessionKeys, sendSessionPayload } from "../host/session-keys";
22
+ import { controllerPeerGate } from "../pairing/controllers";
22
23
 
23
24
  /**
24
25
  * Nobody answered — relay unreachable, or the phone never responded before the
@@ -44,7 +45,7 @@ export async function requestApproval(
44
45
  opts: RequestApprovalOpts = {},
45
46
  ): Promise<ApprovalDecision> {
46
47
  const timeoutMs = opts.timeoutMs ?? 60_000;
47
- const client = opts.client ?? new RelayClient(cfg);
48
+ const client = opts.client ?? new RelayClient(cfg, { peerAllowed: controllerPeerGate(cfg) });
48
49
  const ownsClient = !opts.client;
49
50
  let cancelDecisionWait = () => {};
50
51
  const registration = registerPendingApproval(req);
@@ -9,6 +9,7 @@ import {
9
9
  } from "../../approvals/state";
10
10
  import { isProviderEnabled, loadConfig, machineConfigPath } from "../../config";
11
11
  import { cursorRootSessionId } from "../../sessions/cursor";
12
+ import { controllerPeerGate } from "../../pairing/controllers";
12
13
 
13
14
  const SHELL_TOOLS = new Set([
14
15
  "bash",
@@ -75,7 +76,8 @@ async function main(): Promise<void> {
75
76
 
76
77
  if (cancelled.length > 0) {
77
78
  try {
78
- const client = new RelayClient(loadConfig(machineConfigPath()));
79
+ const config = loadConfig(machineConfigPath());
80
+ const client = new RelayClient(config, { peerAllowed: controllerPeerGate(config) });
79
81
  await client.connect();
80
82
  try {
81
83
  for (const { request, accepted } of cancelled) {
@@ -4,6 +4,7 @@ import { RelayClient } from "../../../../../packages/core/relay-client";
4
4
  import { configDir, loadConfig, machineConfigPath } from "../../config";
5
5
  import { EngineSupervisor } from "../../engine/runtime/engine-supervisor";
6
6
  import { startSessionMonitor } from "../../monitor";
7
+ import { controllerPeerGate } from "../../pairing/controllers";
7
8
 
8
9
  let client: RelayClient;
9
10
  let monitor: ReturnType<typeof startSessionMonitor>;
@@ -15,6 +16,7 @@ try {
15
16
  client = new RelayClient(cfg, {
16
17
  autoReconnect: true,
17
18
  replayPath: join(configDir(), `replay-${cfg.room}.json`),
19
+ peerAllowed: controllerPeerGate(cfg),
18
20
  });
19
21
  monitor = startSessionMonitor(client);
20
22
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  /** A QR may authorize a new phone key only for its one-time enrollment window. */
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { PeerConfig } from "../../../../packages/core/relay-client";
5
5
  import { saveConfig } from "../config/access/pairing";
@@ -12,19 +12,20 @@ function path(): string {
12
12
  return join(configDir(), "pending-controllers.json");
13
13
  }
14
14
 
15
- function load(): PendingController[] {
15
+ function loadStrict(): PendingController[] | null {
16
16
  try {
17
17
  const value = JSON.parse(readFileSync(path(), "utf8")) as unknown;
18
- if (!Array.isArray(value)) return [];
19
- return value.slice(0, 16).filter((item): item is PendingController => {
18
+ if (!Array.isArray(value) || value.length > 16) return null;
19
+ const valid = (item: unknown): item is PendingController => {
20
20
  if (!item || typeof item !== "object") return false;
21
21
  const record = item as Partial<PendingController>;
22
22
  return typeof record.room === "string" && record.room.length > 0
23
23
  && typeof record.peerPublicKey === "string" && record.peerPublicKey.length > 0
24
24
  && typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt);
25
- });
25
+ };
26
+ return value.every(valid) ? value as PendingController[] : null;
26
27
  } catch {
27
- return [];
28
+ return null;
28
29
  }
29
30
  }
30
31
 
@@ -33,22 +34,50 @@ function save(pending: PendingController[]): void {
33
34
  }
34
35
 
35
36
  export function rememberPendingController(room: string, peerPublicKey: string, expiresAt: number): void {
36
- const pending = load().filter((item) => item.room !== room || item.peerPublicKey !== peerPublicKey);
37
+ const loaded = loadStrict();
38
+ if (loaded === null && existsSync(path())) throw new Error("controller enrollment journal is invalid");
39
+ const pending = (loaded ?? []).filter((item) => item.room !== room || item.peerPublicKey !== peerPublicKey);
37
40
  save([...pending, { room, peerPublicKey, expiresAt }].slice(-16));
38
41
  }
39
42
 
40
43
  /** Authenticated activity from this exact key makes its grant durable. */
41
- export function confirmPendingController(room: string, peerPublicKey: string): boolean {
42
- const pending = load();
44
+ export function confirmPendingController(room: string, peerPublicKey: string, now = Date.now()): boolean {
45
+ const pending = loadStrict();
46
+ if (pending === null) return false;
47
+ if (pending.some((item) => item.room === room && item.peerPublicKey === peerPublicKey
48
+ && item.expiresAt <= now)) return false;
43
49
  const remaining = pending.filter((item) => item.room !== room || item.peerPublicKey !== peerPublicKey);
44
50
  if (remaining.length === pending.length) return false;
45
51
  save(remaining);
46
52
  return true;
47
53
  }
48
54
 
55
+ /** Every machine-side RelayClient uses this before parsing or sending a payload. */
56
+ export function controllerPeerGate(config: PeerConfig, now = Date.now): (peerPublicKey: string) => boolean {
57
+ return (peerPublicKey) => {
58
+ if (config.role !== "machine" || peerPublicKey === config.peerPublicKey) return true;
59
+ try {
60
+ const live = JSON.parse(readFileSync(machineConfigPath(), "utf8")) as PeerConfig;
61
+ if (live.room !== config.room || live.myPublicKey !== config.myPublicKey
62
+ || !live.extraPeerPublicKeys?.includes(peerPublicKey)) return false;
63
+ } catch { return false; }
64
+ const pending = loadStrict();
65
+ if (pending === null) return false;
66
+ const record = pending.find((item) => item.room === config.room && item.peerPublicKey === peerPublicKey);
67
+ return record === undefined || record.expiresAt > now();
68
+ };
69
+ }
70
+
49
71
  /** Expired, unclaimed QR keys cannot consume every controller slot forever. */
50
72
  export function prunePendingControllers(config: PeerConfig, now = Date.now()): PeerConfig {
51
- const pending = load();
73
+ const pending = loadStrict();
74
+ if (pending === null) {
75
+ if ((config.extraPeerPublicKeys?.length ?? 0) === 0) return config;
76
+ const next = { ...config, extraPeerPublicKeys: [] };
77
+ saveConfig(machineConfigPath(), next);
78
+ save([]);
79
+ return next;
80
+ }
52
81
  const expired = new Set(pending.filter((item) =>
53
82
  item.room === config.room && item.expiresAt <= now).map((item) => item.peerPublicKey));
54
83
  if (expired.size === 0) return config;
@@ -38,7 +38,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
38
38
  <h3>Provider readiness</h3><ul id="providers"></ul>
39
39
  <p>No GrantTap account or password is required. Pair using GrantTap on iPhone → Settings → Connections.
40
40
  Provider sign-in is separate.</p>
41
- <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.18</code> and <code>granttap setup</code> locally.
41
+ <p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.19</code> and <code>granttap setup</code> locally.
42
42
  A saved pairing alone does not prove your phone or the background helper is online.</p>
43
43
  <p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
44
44
  <a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
@@ -11,7 +11,7 @@ import { applyPairingJoin } from "../../../../bridge/src/pairing";
11
11
  import { installMonitorHelper, reloadMonitorHelper } from "../../../../bridge/src/install";
12
12
  import { recordPhoneSeen } from "../../../../bridge/src/pairing/presence";
13
13
  import { completeEnrollment } from "../../connection-center/enrollment";
14
- import { confirmPendingController } from "../../../../bridge/src/pairing/controllers";
14
+ import { confirmPendingController, controllerPeerGate } from "../../../../bridge/src/pairing/controllers";
15
15
 
16
16
  const ASK_TIMEOUT_MS = Number(
17
17
  process.env.GRANTTAP_ASK_TIMEOUT_MS ?? process.env.NODVOX_ASK_TIMEOUT_MS ?? 180_000,
@@ -31,7 +31,8 @@ export type TaskInteractionScope = {
31
31
  export async function relay(): Promise<RelayClient | null> {
32
32
  try {
33
33
  if (!client) {
34
- client = new RelayClient(loadConfig(machineConfigPath()), { autoReconnect: true });
34
+ const config = loadConfig(machineConfigPath());
35
+ client = new RelayClient(config, { autoReconnect: true, peerAllowed: controllerPeerGate(config) });
35
36
  client.onMessage((payload, peerPublicKey) => {
36
37
  if (payload.type === "pairing.join") {
37
38
  const result = applyPairingJoin(payload);
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { loadConfig } from "../../../bridge/src/config";
6
6
  import { hasControllerPresence, readControllerLastSeenAt, readPhoneLastSeenAt } from "../../../bridge/src/pairing/presence";
7
+ import { controllerPeerGate } from "../../../bridge/src/pairing/controllers";
7
8
 
8
9
  export type PairedPhone = {
9
10
  name: string;
@@ -59,8 +60,9 @@ export function listPairedPhones(phoneLastSeenAt: number | null = null, now = Da
59
60
  ), null);
60
61
  const seen = lastSeenAt != null && now - lastSeenAt < 60_000;
61
62
  const primary: PairedPhone = { name, status: seen ? "seen" : "paired", lastSeenAt };
63
+ const allowed = controllerPeerGate(machine);
62
64
  const additional = (machine.extraPeerPublicKeys ?? [])
63
- .filter((key) => key && key !== machine.peerPublicKey)
65
+ .filter((key) => key && key !== machine.peerPublicKey && allowed(key))
64
66
  .map((key, index): PairedPhone => {
65
67
  const at = readControllerLastSeenAt(key);
66
68
  const suffix = createHash("sha256").update(key).digest("hex").slice(0, 6);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "granttap",
3
3
  "displayName": "GrantTap",
4
- "version": "0.1.11",
4
+ "version": "0.1.12",
5
5
  "description": "The agent connects this computer in chat. Authorize in plugin settings opens the device window. Scan the QR in GrantTap — that scan authorizes. Device list and Add another stay there and in the GrantTap app.",
6
6
  "author": {
7
7
  "name": "GrantTap",
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
10
10
  Install the supported CLI on this computer, then run:
11
11
 
12
12
  ```bash
13
- npm install -g granttap-mcp@0.8.18
13
+ npm install -g granttap-mcp@0.8.19
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -4,7 +4,7 @@
4
4
  "command": "node",
5
5
  "args": [
6
6
  "-e",
7
- "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.18\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
7
+ "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.19\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
8
8
  ]
9
9
  }
10
10
  }
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
7
7
  const { existsSync } = require("node:fs");
8
8
  const { delimiter, dirname, join } = require("node:path");
9
9
 
10
- const PACKAGE = "granttap-mcp@0.8.18";
10
+ const PACKAGE = "granttap-mcp@0.8.19";
11
11
  const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
12
12
  const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
13
13
  ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.19",
3
+ "version": "0.8.20",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,8 @@ export type RelayClientOptions = {
32
32
  pongTimeoutMs?: number;
33
33
  /** Durable ciphertext fingerprints so a restart cannot replay a consumed box. */
34
34
  replayPath?: string;
35
+ /** Refuse a revoked or expired controller before handling or sending payloads. */
36
+ peerAllowed?: (peerPublicKey: string) => boolean;
35
37
  };
36
38
 
37
39
  export type SendOptions = {
@@ -61,6 +61,11 @@ export class RelayClient {
61
61
  return this.cfg.role === "machine" ? "phone" : "machine";
62
62
  }
63
63
 
64
+ private peerIsAllowed(peerPublicKey: string): boolean {
65
+ try { return this.opts.peerAllowed?.(peerPublicKey) ?? true; }
66
+ catch { return false; }
67
+ }
68
+
64
69
  connect(timeoutMs = 10_000): Promise<void> {
65
70
  if (this.isConnected) return Promise.resolve();
66
71
  if (this.connectPromise) return this.connectPromise;
@@ -138,6 +143,7 @@ export class RelayClient {
138
143
  );
139
144
  if (opened === null) return; // not for us, or tampered
140
145
  const { body, peerPublicKey } = opened;
146
+ if (!this.peerIsAllowed(peerPublicKey)) return;
141
147
  const parsedPayload = Payload.safeParse(body);
142
148
  if (!parsedPayload.success) {
143
149
  const type = body && typeof body === "object" && "type" in body ? String((body as { type?: unknown }).type) : "";
@@ -219,7 +225,9 @@ export class RelayClient {
219
225
  ): Promise<void> {
220
226
  const ws = this.ws;
221
227
  if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
222
- const peers = peerPublicKeys(this.cfg.peerPublicKey, this.cfg.extraPeerPublicKeys);
228
+ const peers = peerPublicKeys(this.cfg.peerPublicKey, this.cfg.extraPeerPublicKeys)
229
+ .filter((peerPublicKey) => this.peerIsAllowed(peerPublicKey));
230
+ if (peers.length === 0) throw new Error("no authorized pairing peers");
223
231
  for (const peerPublicKey of peers) {
224
232
  ws.send(sealEnvelope(
225
233
  { ...this.cfg, peerPublicKey },