granttap-mcp 0.8.30 → 0.8.32

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.
@@ -0,0 +1,15 @@
1
+ # Account recovery
2
+
3
+ `link.ts` persists the local Mac's opt-in account machine credential in its
4
+ private GrantTap config directory. The Mac app can enroll through its existing
5
+ authorized loopback connection, or the provider MCP consent flow can enroll
6
+ after a verified passkey assertion. The credential lets `poller.ts` answer
7
+ short-lived, account-authorized phone requests. `offer.ts` creates a new phone
8
+ controller key in the current room and seals its pairing half to the phone's
9
+ ephemeral public key. The website stores only the machine token hash and sealed
10
+ offer; it never receives a pairing key in plaintext.
11
+
12
+ The normal QR pairing path remains independent. A passkey authenticates the
13
+ account; an ephemeral phone public key encrypts the pairing offer end to end.
14
+
15
+ This runtime module is covered by the repository MIT License.
@@ -0,0 +1,48 @@
1
+ import { hostname } from "node:os";
2
+ import { loadAccountLink, saveAccountLink } from "./link";
3
+
4
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/;
5
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
+
7
+ /** Native Mac app authorizes this MCP with its account session over protected loopback. */
8
+ export async function enrollMacAccount(accountToken: string, origin: string,
9
+ request: typeof fetch = fetch): Promise<{ accountId: string; machineId: string }> {
10
+ if (!TOKEN.test(accountToken) || new URL(origin).protocol !== "https:") {
11
+ throw new Error("Invalid account authorization.");
12
+ }
13
+ const headers = { authorization: `Bearer ${accountToken}`, accept: "application/json" };
14
+ const response = await request(`${origin}/api/account/me`, {
15
+ headers, signal: AbortSignal.timeout(8_000), redirect: "error",
16
+ });
17
+ if (!response.ok) throw new Error("Account passkey session was not accepted.");
18
+ const me = await response.json() as { accountId?: string };
19
+ if (!me.accountId || !UUID.test(me.accountId)) throw new Error("Invalid account identity.");
20
+ const existing = loadAccountLink();
21
+ if (existing) {
22
+ if (existing.accountId !== me.accountId) {
23
+ throw new Error("This Mac is linked to a different account. Revoke it first.");
24
+ }
25
+ const linked = await request(`${origin}/api/account/machines`, {
26
+ headers, signal: AbortSignal.timeout(8_000), redirect: "error",
27
+ });
28
+ if (!linked.ok) throw new Error("Account computer list is unavailable.");
29
+ const list = await linked.json() as { machines?: Array<{ id?: string }> };
30
+ if (!Array.isArray(list.machines)) throw new Error("Invalid account computer list.");
31
+ if (list.machines.some(machine => machine.id === existing.machineId)) {
32
+ return { accountId: existing.accountId, machineId: existing.machineId };
33
+ }
34
+ }
35
+ const registration = await request(`${origin}/api/account/machines`, {
36
+ method: "POST", headers: { ...headers, "content-type": "application/json" },
37
+ body: JSON.stringify({ name: hostname().slice(0, 80) }),
38
+ signal: AbortSignal.timeout(8_000), redirect: "error",
39
+ });
40
+ if (registration.status !== 201 && registration.status !== 200) {
41
+ throw new Error(`Account machine registration failed (${registration.status}).`);
42
+ }
43
+ const row = await registration.json() as { id?: string; machineToken?: string };
44
+ if (!row.id || !row.machineToken || !saveAccountLink({
45
+ accountId: me.accountId, machineId: row.id, machineToken: row.machineToken,
46
+ })) throw new Error("Account machine authorization could not be saved.");
47
+ return { accountId: me.accountId, machineId: row.id };
48
+ }
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir } from "../config/runtime/paths";
4
+ import { writePrivateFile } from "../config/access/write-private";
5
+
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/;
8
+
9
+ export type AccountLink = { accountId: string; machineId: string; machineToken: string };
10
+
11
+ function valid(value: unknown): value is AccountLink {
12
+ if (!value || typeof value !== "object") return false;
13
+ const row = value as Partial<AccountLink>;
14
+ return typeof row.accountId === "string" && UUID.test(row.accountId)
15
+ && typeof row.machineId === "string" && UUID.test(row.machineId)
16
+ && typeof row.machineToken === "string" && TOKEN.test(row.machineToken);
17
+ }
18
+
19
+ function path(): string { return join(configDir(), "account-link.json"); }
20
+
21
+ export function loadAccountLink(): AccountLink | null {
22
+ try {
23
+ const row = JSON.parse(readFileSync(path(), "utf8")) as unknown;
24
+ return valid(row) ? row : null;
25
+ } catch { return null; }
26
+ }
27
+
28
+ export function saveAccountLink(value: AccountLink): boolean {
29
+ if (!valid(value)) return false;
30
+ writePrivateFile(path(), `${JSON.stringify(value)}\n`);
31
+ return true;
32
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync } from "node:fs";
2
+ import { generateKeyPair, randomId, seal } from "../../../../packages/core/crypto";
3
+ import { createPairing, DEFAULT_RELAY_URL, machineConfigPath, phonePairingPath,
4
+ saveConfig } from "../config";
5
+ import { applyNetworkRoute } from "../device-network/settings";
6
+ import { readNetworkSettings } from "../device-network/settings";
7
+ import { parkedPairingHalves } from "../pairing";
8
+ import { prunePendingControllers, rememberPendingController } from "../pairing/controllers";
9
+
10
+ export type RecoveryRequest = { id: string; phonePublicKey: string; expiresAt: number };
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+ const KEY = /^[A-Za-z0-9_-]{43}$/;
13
+
14
+ /** The account server sees this envelope but has no key that can open it. */
15
+ export function createRecoveryOffer(request: RecoveryRequest, machineId: string): string {
16
+ if (!UUID.test(request.id) || !UUID.test(machineId) || !KEY.test(request.phonePublicKey)
17
+ || Buffer.from(request.phonePublicKey, "base64url").length !== 32
18
+ || request.expiresAt <= Date.now() || request.expiresAt > Date.now() + 5 * 60_000) {
19
+ throw new Error("Invalid or expired account recovery request.");
20
+ }
21
+ const existing = parkedPairingHalves();
22
+ if (!existing && (existsSync(machineConfigPath()) || existsSync(phonePairingPath()))) {
23
+ throw new Error("Existing pairing is incomplete; repair it before account recovery.");
24
+ }
25
+ const network = readNetworkSettings();
26
+ const relayUrl = network.mode === "managed" ? DEFAULT_RELAY_URL : network.endpoint;
27
+ let { machineCfg, phoneCfg } = existing ?? createPairing(relayUrl);
28
+ if (existing) {
29
+ machineCfg = prunePendingControllers(machineCfg);
30
+ const extra = machineCfg.extraPeerPublicKeys ?? [];
31
+ if (extra.length >= 16) throw new Error("This computer has reached its controller device limit.");
32
+ const keys = generateKeyPair();
33
+ phoneCfg = { ...phoneCfg, senderId: randomId(8), myPublicKey: keys.publicKey,
34
+ mySecretKey: keys.secretKey };
35
+ machineCfg = { ...machineCfg, extraPeerPublicKeys: [...extra, keys.publicKey] };
36
+ }
37
+ const routedPhone = applyNetworkRoute(phoneCfg);
38
+ const ephemeral = generateKeyPair();
39
+ const payload = { schema: "granttap.account-offer.v1", requestId: request.id,
40
+ machineId, pairing: routedPhone };
41
+ const recipient = Buffer.from(request.phonePublicKey, "base64url").toString("base64");
42
+ const box = seal(payload, recipient, ephemeral.secretKey);
43
+ // Persist authorization before the server can deliver the phone half.
44
+ if (!existing) {
45
+ saveConfig(machineConfigPath(), machineCfg);
46
+ saveConfig(phonePairingPath(), phoneCfg);
47
+ } else {
48
+ rememberPendingController(machineCfg.room, phoneCfg.myPublicKey, request.expiresAt);
49
+ saveConfig(machineConfigPath(), machineCfg);
50
+ }
51
+ return JSON.stringify({ senderPublicKey: ephemeral.publicKey, ...box });
52
+ }
@@ -0,0 +1,54 @@
1
+ import { loadAccountLink } from "./link";
2
+ import { createRecoveryOffer, type RecoveryRequest } from "./offer";
3
+
4
+ type PendingResponse = { requests?: RecoveryRequest[] };
5
+ const offers = new Map<string, string>();
6
+
7
+ /** Poll the account bridge; the server receives only an encrypted phone half. */
8
+ export async function pollAccountRecoveryOnce(origin: string, request: typeof fetch = fetch,
9
+ pairingChanged: () => void = () => {}): Promise<void> {
10
+ const link = loadAccountLink();
11
+ if (!link) return;
12
+ if (new URL(origin).protocol !== "https:") throw new Error("Account recovery requires HTTPS.");
13
+ const headers = { authorization: `Bearer ${link.machineToken}`, accept: "application/json" };
14
+ const response = await request(`${origin}/api/account/machine/requests`, {
15
+ headers, signal: AbortSignal.timeout(8_000), redirect: "error",
16
+ });
17
+ if (!response.ok) throw new Error(`Account recovery poll failed (${response.status}).`);
18
+ const body = await response.json() as PendingResponse;
19
+ if (!Array.isArray(body.requests)) throw new Error("Invalid account recovery response.");
20
+ const live = new Set(body.requests.map(item => item.id));
21
+ for (const id of offers.keys()) if (!live.has(id)) offers.delete(id);
22
+ for (const item of body.requests) {
23
+ try {
24
+ let offer = offers.get(item.id);
25
+ if (!offer) {
26
+ offer = createRecoveryOffer(item, link.machineId);
27
+ pairingChanged();
28
+ }
29
+ offers.set(item.id, offer);
30
+ const sent = await request(`${origin}/api/account/machine/requests/${item.id}/offer`, {
31
+ method: "POST", headers: { ...headers, "content-type": "application/json" },
32
+ body: JSON.stringify({ encryptedOffer: offer }),
33
+ signal: AbortSignal.timeout(8_000), redirect: "error",
34
+ });
35
+ if (sent.ok || sent.status === 404) offers.delete(item.id);
36
+ } catch {
37
+ // A transient failure retains its sealed offer for a later attempt.
38
+ }
39
+ }
40
+ }
41
+
42
+ export function startAccountRecoveryPoller(origin: string, pairingChanged: () => void): () => void {
43
+ let busy = false;
44
+ const tick = async () => {
45
+ if (busy) return;
46
+ busy = true;
47
+ try { await pollAccountRecoveryOnce(origin, fetch, pairingChanged); } catch { /* retry on next tick */ }
48
+ finally { busy = false; }
49
+ };
50
+ void tick();
51
+ const timer = setInterval(() => { void tick(); }, 10_000);
52
+ timer.unref();
53
+ return () => { clearInterval(timer); offers.clear(); };
54
+ }
@@ -1,4 +1,5 @@
1
1
  import { chmod, mkdtemp, rm } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { createServer, type Socket } from "node:net";
@@ -30,6 +31,10 @@ export const DESKTOP_OPERATIONS = new Set([
30
31
  "desktop.network_status", "desktop.network_configure", "desktop.own_relay",
31
32
  ]);
32
33
 
34
+ export function windowsDesktopPipePath(id: string): string {
35
+ return String.raw`\\.\pipe\granttap-desktop-${id}`;
36
+ }
37
+
33
38
  export function desktopOperationTimeout(operation: unknown, input: unknown): number {
34
39
  if (operation === "desktop.mesh_snapshot"
35
40
  && (input as { refresh_graph?: unknown } | undefined)?.refresh_graph === "true") return 120_000;
@@ -57,8 +62,10 @@ export async function startDesktopEngineBridge(options: {
57
62
  client?: Pick<EngineClient, "request" | "close">;
58
63
  onPairingChanged?: () => void;
59
64
  } = {}): Promise<{ socketPath: string; close: () => Promise<void> }> {
60
- const directory = await mkdtemp(join(tmpdir(), "granttap-desktop-"));
61
- const socketPath = join(directory, "engine.sock");
65
+ const directory = process.platform === "win32" ? null
66
+ : await mkdtemp(join(tmpdir(), "granttap-desktop-"));
67
+ const socketPath = directory ? join(directory, "engine.sock")
68
+ : windowsDesktopPipePath(randomUUID());
62
69
  const engine = options.client ?? new EngineClient({
63
70
  socketPath: options.engineSocketPath ?? join(configDir(), "engine.sock"),
64
71
  });
@@ -121,14 +128,15 @@ export async function startDesktopEngineBridge(options: {
121
128
  try {
122
129
  await new Promise<void>((resolve, reject) => {
123
130
  server.once("error", reject);
124
- server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); });
131
+ server.listen({ path: socketPath, readableAll: false, writableAll: false },
132
+ () => { server.removeListener("error", reject); resolve(); });
125
133
  });
126
- await chmod(socketPath, 0o600);
134
+ if (directory) await chmod(socketPath, 0o600);
127
135
  } catch (error) {
128
136
  activity.close();
129
137
  enrichment.close();
130
138
  engine.close();
131
- await rm(directory, { recursive: true, force: true });
139
+ if (directory) await rm(directory, { recursive: true, force: true });
132
140
  throw error;
133
141
  }
134
142
  return {
@@ -140,7 +148,7 @@ export async function startDesktopEngineBridge(options: {
140
148
  controllerEnrollment.close();
141
149
  await new Promise<void>((resolve) => server.close(() => resolve()));
142
150
  engine.close();
143
- await rm(directory, { recursive: true, force: true });
151
+ if (directory) await rm(directory, { recursive: true, force: true });
144
152
  },
145
153
  };
146
154
  }
@@ -13,6 +13,9 @@ its device-only Keychain and never shares it with a provider.
13
13
  invoke only the existing allowlisted desktop operations; inputs are still
14
14
  validated by the private bridge and task/policy layers. It never grants a
15
15
  provider broader MCP scope. The installer/runtime remains outside the sandbox.
16
+ `/desktop/account/link` uses the same native grant, verifies the app's fresh
17
+ passkey account session with granttap.com, and stores a revocable machine
18
+ credential locally. It returns only the account and machine identifiers.
16
19
  Tests exercise real HTTP and Unix sockets, consent forgery rejection, PKCE,
17
20
  one-use codes, expiration, restart and authenticated forwarding.
18
21
 
@@ -3,6 +3,8 @@ import type { Express } from 'express';
3
3
  import { DesktopNativeAccess } from './access';
4
4
  import { invokeDesktop } from './invoke';
5
5
  import { DESKTOP_OPERATIONS, desktopOperationTimeout } from '../engine-bridge';
6
+ import { enrollMacAccount } from '../../../../bridge/src/account-recovery/enroll';
7
+ import { websiteOrigin } from '../../oauth/consent/website-session';
6
8
 
7
9
  export function installDesktopNativeRoutes(app: Express, socketPath: string, origin: string,
8
10
  access = new DesktopNativeAccess()): void {
@@ -39,6 +41,18 @@ export function installDesktopNativeRoutes(app: Express, socketPath: string, ori
39
41
  try { res.json({ access_token: access.exchange(String(req.body?.code ?? ''), String(req.body?.verifier ?? '')) }); }
40
42
  catch { res.status(401).json({ error: 'Native code could not be verified' }); }
41
43
  });
44
+ app.post('/desktop/account/link', express.json({ limit: '2kb' }), async (req, res) => {
45
+ const nativeToken = String(req.headers.authorization ?? '').replace(/^Bearer /, '');
46
+ if (!access.verify(nativeToken)) {
47
+ res.status(401).json({ error: 'Authorize GrantTap for Mac first' }); return;
48
+ }
49
+ try {
50
+ res.json(await enrollMacAccount(String(req.body?.accountToken ?? ''),
51
+ websiteOrigin() ?? 'https://granttap.com'));
52
+ } catch {
53
+ res.status(403).json({ error: 'Account link could not be verified' });
54
+ }
55
+ });
42
56
  app.post('/desktop/invoke', (req, res, next) => {
43
57
  const auth = String(req.headers.authorization ?? '');
44
58
  if (!auth.startsWith('Bearer ') || !access.verify(auth.slice(7))) {
@@ -15,7 +15,7 @@ import express from "express";
15
15
  import { relay, resetRelay } from "./create-server";
16
16
  import { resetPairingWatches } from "../oauth/consent/pairing-view";
17
17
  import { GrantTapOAuthProvider } from "../oauth-provider";
18
- import { resetConnectWatchers } from "../oauth/consent/website-session";
18
+ import { resetConnectWatchers, websiteOrigin } from "../oauth/consent/website-session";
19
19
  import { isMachineConfigured, phoneReachability } from "../status/pairing-status";
20
20
  import { packageVersion } from "../status/package-version";
21
21
  import { desktopStatusSnapshot } from "../status/desktop-status";
@@ -24,6 +24,7 @@ import { createMcpSessionHandler } from "./mcp-handler";
24
24
  import { startDesktopEngineBridgeProcess } from "../desktop/process/bridge-process";
25
25
 
26
26
  import { installDesktopNativeRoutes } from "../desktop/native-access/routes";
27
+ import { startAccountRecoveryPoller } from "../../../bridge/src/account-recovery/poller";
27
28
 
28
29
  export const DEFAULT_HTTP_HOST = "127.0.0.1";
29
30
  export const DEFAULT_HTTP_PORT = 17342;
@@ -110,12 +111,16 @@ export async function startHttpMcpServer(options: ServeOptions = {}): Promise<{
110
111
 
111
112
  provider.resumeConnectWatches();
112
113
  void relay();
114
+ const accountOrigin = websiteOrigin();
115
+ const stopAccountRecovery = accountOrigin
116
+ ? startAccountRecoveryPoller(accountOrigin, () => { resetRelay(); void relay(); }) : () => {};
113
117
 
114
118
  return {
115
119
  host,
116
120
  port,
117
121
  mcpUrl: mcpUrl.href,
118
122
  close: async () => {
123
+ stopAccountRecovery();
119
124
  await sessions.close();
120
125
  resetConnectWatchers();
121
126
  resetPairingWatches();
@@ -11,12 +11,14 @@ export type PendingAuth = {
11
11
  client: OAuthClientInformationFull;
12
12
  params: AuthorizationParams;
13
13
  createdAt: number;
14
+ requestSecret?: string;
14
15
  };
15
16
 
16
17
  type StoredPending = {
17
18
  client: OAuthClientInformationFull;
18
19
  createdAt: number;
19
20
  params: Omit<AuthorizationParams, "resource"> & { resource?: string };
21
+ requestSecret?: string;
20
22
  };
21
23
 
22
24
  function pendingPath(): string {
@@ -34,6 +36,7 @@ export function loadPending(): Map<string, PendingAuth> {
34
36
  pending.set(id, {
35
37
  client: entry.client,
36
38
  createdAt: entry.createdAt,
39
+ requestSecret: entry.requestSecret,
37
40
  params: {
38
41
  ...entry.params,
39
42
  resource: entry.params.resource ? new URL(entry.params.resource) : undefined,
@@ -54,6 +57,7 @@ export function savePending(pending: Map<string, PendingAuth>): void {
54
57
  raw[id] = {
55
58
  client: entry.client,
56
59
  createdAt: entry.createdAt,
60
+ requestSecret: entry.requestSecret,
57
61
  params: {
58
62
  ...entry.params,
59
63
  resource: entry.params.resource?.href,
@@ -2,10 +2,12 @@
2
2
  * The coding-app browser talks only to granttap.com.
3
3
  * This helper publishes the public consent snapshot there. A QR scan — a
4
4
  * phone marked seen — is the Approve. Deny stays a website action.
5
- * Pairing keys never leave the computer.
5
+ * Consent snapshots contain no pairing keys; account recovery sends only a
6
+ * sealed phone half through a separate short-lived mailbox.
6
7
  */
7
8
  import { PENDING_TTL_MS } from "./pending";
8
9
  import type { ConnectSnapshot } from "../session/connect-snapshot";
10
+ import { loadAccountLink, saveAccountLink } from "../../../../bridge/src/account-recovery/link";
9
11
 
10
12
  export type ConnectDecision = "approve" | "deny" | "passkey";
11
13
  export type ConsentMethod = "phone" | "passkey";
@@ -14,8 +16,30 @@ type ConnectRow = ConnectSnapshot & {
14
16
  decision?: ConnectDecision;
15
17
  redirectUrl?: string;
16
18
  error?: string;
19
+ accountId?: string;
20
+ machineId?: string;
21
+ machineToken?: string;
17
22
  };
18
23
 
24
+ function headers(secret?: string, json = false): Record<string, string> {
25
+ return { accept: "application/json", ...(json ? { "content-type": "application/json" } : {}),
26
+ ...(secret ? { authorization: `Bearer ${secret}` } : {}) };
27
+ }
28
+
29
+ function acceptAccountLink(row: ConnectRow): void {
30
+ if (row.decision !== "passkey" || !row.accountId || !row.machineId) {
31
+ throw new Error("Passkey did not link this Mac to an account.");
32
+ }
33
+ const existing = loadAccountLink();
34
+ if (row.machineToken) {
35
+ if (!saveAccountLink({ accountId: row.accountId, machineId: row.machineId,
36
+ machineToken: row.machineToken })) throw new Error("Account link could not be saved.");
37
+ } else if (!existing || existing.accountId !== row.accountId
38
+ || existing.machineId !== row.machineId) {
39
+ throw new Error("This account is not linked to this Mac.");
40
+ }
41
+ }
42
+
19
43
  const watchers = new Map<string, AbortController>();
20
44
 
21
45
  export function websiteOrigin(): string | undefined {
@@ -32,10 +56,11 @@ export async function publishConnectRequest(
32
56
  origin: string,
33
57
  requestId: string,
34
58
  snapshot: ConnectSnapshot,
59
+ requestSecret?: string,
35
60
  ): Promise<void> {
36
61
  const response = await fetch(`${origin}/api/connect/requests/${requestId}`, {
37
62
  method: "PUT",
38
- headers: { "content-type": "application/json", accept: "application/json" },
63
+ headers: headers(requestSecret, true),
39
64
  body: JSON.stringify(snapshot),
40
65
  signal: AbortSignal.timeout(4_000),
41
66
  });
@@ -50,10 +75,11 @@ export async function publishConnectRequestRetry(
50
75
  requestId: string,
51
76
  snapshot: ConnectSnapshot,
52
77
  attempts = 3,
78
+ requestSecret?: string,
53
79
  ): Promise<boolean> {
54
80
  for (let attempt = 0; attempt < attempts; attempt++) {
55
81
  try {
56
- await publishConnectRequest(origin, requestId, snapshot);
82
+ await publishConnectRequest(origin, requestId, snapshot, requestSecret);
57
83
  return true;
58
84
  } catch {
59
85
  if (attempt + 1 < attempts) {
@@ -67,9 +93,10 @@ export async function publishConnectRequestRetry(
67
93
  export async function readConnectRequest(
68
94
  origin: string,
69
95
  requestId: string,
96
+ requestSecret?: string,
70
97
  ): Promise<ConnectRow | undefined> {
71
98
  const response = await fetch(`${origin}/api/connect/requests/${requestId}`, {
72
- headers: { accept: "application/json" },
99
+ headers: headers(requestSecret),
73
100
  signal: AbortSignal.timeout(4_000),
74
101
  });
75
102
  if (response.status === 404) return undefined;
@@ -99,10 +126,11 @@ export async function publishConnectError(
99
126
  origin: string,
100
127
  requestId: string,
101
128
  error: string,
129
+ requestSecret?: string,
102
130
  ): Promise<void> {
103
131
  await fetch(`${origin}/api/connect/requests/${requestId}`, {
104
132
  method: "PUT",
105
- headers: { "content-type": "application/json", accept: "application/json" },
133
+ headers: headers(requestSecret, true),
106
134
  body: JSON.stringify({ error: error.slice(0, 300) }),
107
135
  signal: AbortSignal.timeout(4_000),
108
136
  }).catch(() => {});
@@ -113,7 +141,7 @@ export function watchConnectDecision(
113
141
  requestId: string,
114
142
  snapshot: ConnectSnapshot | (() => ConnectSnapshot),
115
143
  complete: (approve: boolean, method: ConsentMethod) => { redirectUrl: string },
116
- options: { pollMs?: number } = {},
144
+ options: { pollMs?: number; requestSecret?: string } = {},
117
145
  ): void {
118
146
  const current = (): ConnectSnapshot => (typeof snapshot === "function" ? snapshot() : snapshot);
119
147
  const pollMs = options.pollMs ?? 3_000;
@@ -126,9 +154,10 @@ export function watchConnectDecision(
126
154
  while (!ac.signal.aborted && Date.now() < deadline) {
127
155
  try {
128
156
  const live = current();
129
- const row = await readConnectRequest(origin, requestId);
157
+ const row = await readConnectRequest(origin, requestId, options.requestSecret);
130
158
  if (row?.decision === "approve" || row?.decision === "deny" || row?.decision === "passkey") {
131
159
  try {
160
+ if (row.decision === "passkey") acceptAccountLink(row);
132
161
  const { redirectUrl } = complete(row.decision !== "deny",
133
162
  row.decision === "passkey" ? "passkey" : "phone");
134
163
  await publishConnectRedirect(origin, requestId, redirectUrl);
@@ -138,6 +167,7 @@ export function watchConnectDecision(
138
167
  origin,
139
168
  requestId,
140
169
  error instanceof Error ? error.message : String(error),
170
+ options.requestSecret,
141
171
  );
142
172
  // A failed publish used to stop the watcher after deleting the
143
173
  // pending id. Approve then sat on "Waiting for this computer".
@@ -145,7 +175,7 @@ export function watchConnectDecision(
145
175
  }
146
176
  const body = JSON.stringify(live);
147
177
  if (!row || body !== lastBody) {
148
- await publishConnectRequest(origin, requestId, live);
178
+ await publishConnectRequest(origin, requestId, live, options.requestSecret);
149
179
  lastBody = body;
150
180
  }
151
181
  } catch {
@@ -10,6 +10,7 @@ import { isTerminalTaskState } from "../../../../bridge/src/mesh/admin/convergen
10
10
  import { localMeshStore } from "../../../../bridge/src/mesh/local-remote/local";
11
11
  import { connectionRuntimeStatus } from "../../mcp-tools/connect/relay";
12
12
  import { isMachineConfigured, listPairedPhones, readOnlyMachineConfigPath } from "../../status/pairing-status";
13
+ import { loadAccountLink } from "../../../../bridge/src/account-recovery/link";
13
14
 
14
15
  export type ConnectProvider = {
15
16
  id: "codex" | "claude" | "cursor";
@@ -29,6 +30,7 @@ export type ConnectSnapshot = {
29
30
  computerName: string;
30
31
  paired: boolean;
31
32
  passkeyCapable?: boolean;
33
+ machineId?: string;
32
34
  /** First 8 hex chars of the pairing room. Never the full id. */
33
35
  roomPrefix: string;
34
36
  phones: ReturnType<typeof listPairedPhones>;
@@ -50,6 +52,7 @@ export function buildConnectSnapshot(clientName?: string): ConnectSnapshot {
50
52
  computerName: hostname(),
51
53
  paired,
52
54
  passkeyCapable: true,
55
+ machineId: loadAccountLink()?.machineId,
53
56
  roomPrefix: room ? room.slice(0, 8) : "",
54
57
  phones: listPairedPhones(runtime.phoneLastSeenAt),
55
58
  providers: inspectAgentIntegrations()
@@ -4,7 +4,7 @@
4
4
  * Authorize means: confirm this Mac's GrantTap pairing for a client (issue a
5
5
  * bearer token). E2EE keys stay in ~/.granttap — OAuth does not replace pair.
6
6
  */
7
- import { randomUUID } from "node:crypto";
7
+ import { randomBytes, randomUUID } from "node:crypto";
8
8
  import type { Response } from "express";
9
9
  import {
10
10
  InvalidGrantError,
@@ -60,7 +60,8 @@ export class GrantTapOAuthProvider implements OAuthServerProvider {
60
60
  createPending(client: OAuthClientInformationFull, params: AuthorizationParams): string {
61
61
  this.gcPending();
62
62
  const id = randomUUID();
63
- this.pending.set(id, { client, params, createdAt: Date.now() });
63
+ this.pending.set(id, { client, params, createdAt: Date.now(),
64
+ requestSecret: randomBytes(32).toString("base64url") });
64
65
  savePending(this.pending);
65
66
  return id;
66
67
  }
@@ -114,8 +115,10 @@ export class GrantTapOAuthProvider implements OAuthServerProvider {
114
115
  if (origin) {
115
116
  const snapshot = () => buildConnectSnapshot(client.client_name);
116
117
  watchConnectDecision(origin, pendingId, snapshot, (approve, method) =>
117
- this.completeConsent(pendingId, approve, method));
118
- void publishConnectRequestRetry(origin, pendingId, snapshot());
118
+ this.completeConsent(pendingId, approve, method),
119
+ { requestSecret: this.getPending(pendingId)?.requestSecret });
120
+ void publishConnectRequestRetry(origin, pendingId, snapshot(), 3,
121
+ this.getPending(pendingId)?.requestSecret);
119
122
  }
120
123
  }
121
124
 
@@ -127,8 +130,10 @@ export class GrantTapOAuthProvider implements OAuthServerProvider {
127
130
  const snapshot = () =>
128
131
  buildConnectSnapshot(this.getPending(pendingId)?.client.client_name);
129
132
  watchConnectDecision(origin, pendingId, snapshot, (approve, method) =>
130
- this.completeConsent(pendingId, approve, method));
131
- void publishConnectRequestRetry(origin, pendingId, snapshot());
133
+ this.completeConsent(pendingId, approve, method),
134
+ { requestSecret: this.getPending(pendingId)?.requestSecret });
135
+ void publishConnectRequestRetry(origin, pendingId, snapshot(), 3,
136
+ this.getPending(pendingId)?.requestSecret);
132
137
  }
133
138
  }
134
139
 
@@ -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.30
13
+ npm install -g granttap-mcp@0.8.32
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.30\";\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.32\";\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.30";
10
+ const PACKAGE = "granttap-mcp@0.8.32";
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.30",
3
+ "version": "0.8.32",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {