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.
- package/apps/bridge/src/approvals/index.ts +2 -1
- package/apps/bridge/src/bin/hooks/cursor-after-shell.ts +3 -1
- package/apps/bridge/src/bin/runtime/monitor.ts +2 -0
- package/apps/bridge/src/pairing/controllers.ts +39 -10
- package/apps/mcp/src/connection-center/widget.html +1 -1
- package/apps/mcp/src/mcp-tools/connect/relay.ts +3 -2
- package/apps/mcp/src/status/pairing-status.ts +3 -1
- package/cursor-plugin/.cursor-plugin/plugin.json +1 -1
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/relay-client-types.ts +2 -0
- package/packages/core/relay-client.ts +9 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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",
|
package/cursor-plugin/README.md
CHANGED
|
@@ -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.
|
|
13
|
+
npm install -g granttap-mcp@0.8.19
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
@@ -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 },
|