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.
- package/apps/bridge/src/account-recovery/README.md +15 -0
- package/apps/bridge/src/account-recovery/enroll.ts +48 -0
- package/apps/bridge/src/account-recovery/link.ts +32 -0
- package/apps/bridge/src/account-recovery/offer.ts +52 -0
- package/apps/bridge/src/account-recovery/poller.ts +54 -0
- package/apps/mcp/src/desktop/engine-bridge.ts +14 -6
- package/apps/mcp/src/desktop/native-access/README.md +3 -0
- package/apps/mcp/src/desktop/native-access/routes.ts +14 -0
- package/apps/mcp/src/http/http-server.ts +6 -1
- package/apps/mcp/src/oauth/consent/pending.ts +4 -0
- package/apps/mcp/src/oauth/consent/website-session.ts +38 -8
- package/apps/mcp/src/oauth/session/connect-snapshot.ts +3 -0
- package/apps/mcp/src/oauth-provider.ts +11 -6
- 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
|
@@ -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 =
|
|
61
|
-
|
|
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,
|
|
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
|
-
*
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.32
|
|
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.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.
|
|
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;
|