granttap-mcp 0.8.54 → 0.8.55
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/host/session-keys.ts +1 -1
- package/apps/bridge/src/monitor/handlers/catalog.ts +2 -1
- package/apps/bridge/src/monitor/handlers/inbound.ts +9 -6
- package/apps/bridge/src/session-catalog/README.md +2 -0
- package/apps/bridge/src/session-images/README.md +6 -0
- package/apps/bridge/src/session-images/index.ts +3 -2
- package/apps/bridge/src/transcript-history/images/advertised-entry.ts +43 -0
- package/apps/bridge/src/transcript-history/images/native.ts +5 -2
- package/apps/bridge/src/transcript-history/images.ts +6 -2
- package/apps/bridge/src/transcript-history/index.ts +1 -0
- package/apps/mcp/src/desktop/image/index.ts +17 -8
- 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/outbound-scheduler/README.md +9 -7
- package/packages/core/outbound-scheduler/index.ts +0 -1
- package/packages/core/relay-client-types.ts +2 -0
- package/packages/core/relay-client.ts +6 -9
- package/packages/core/outbound-scheduler/active-controllers.ts +0 -15
|
@@ -102,6 +102,6 @@ async function sendScopedPayload(input: {
|
|
|
102
102
|
key,
|
|
103
103
|
purpose,
|
|
104
104
|
createdAt: Date.now(),
|
|
105
|
-
}, to, { ttlMs: options.ttlMs });
|
|
105
|
+
}, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey });
|
|
106
106
|
await client.sendSession(payload, sessionId, to, options);
|
|
107
107
|
}
|
|
@@ -56,7 +56,7 @@ export async function publishSessionEvents(
|
|
|
56
56
|
sessionId: string,
|
|
57
57
|
status?: SessionsStatus,
|
|
58
58
|
threadId?: string,
|
|
59
|
-
history?: { history?: boolean; historyCursor?: string },
|
|
59
|
+
history?: { history?: boolean; historyCursor?: string; peerPublicKey?: string },
|
|
60
60
|
): Promise<boolean> {
|
|
61
61
|
const session = resolveSession(client, sessionId, status);
|
|
62
62
|
if (!session) return false;
|
|
@@ -66,6 +66,7 @@ export async function publishSessionEvents(
|
|
|
66
66
|
await sendSessionPayload(client, activityWithImages(activity), sessionId, "phone", {
|
|
67
67
|
ttlMs: INTERVAL_MS * 24,
|
|
68
68
|
reliable: false,
|
|
69
|
+
peerPublicKey: history?.peerPublicKey,
|
|
69
70
|
});
|
|
70
71
|
return true;
|
|
71
72
|
}
|
|
@@ -83,9 +83,9 @@ export async function handleMonitorMessage(
|
|
|
83
83
|
void publish().catch(() => {});
|
|
84
84
|
return true;
|
|
85
85
|
} else if (payload.type === "session.subscribe") {
|
|
86
|
-
return handleInboundSubscription(client, payload, subscriptions, publish);
|
|
86
|
+
return handleInboundSubscription(client, payload, subscriptions, publish, input.peerPublicKey);
|
|
87
87
|
} else if (payload.type === "session.events" || payload.type === "session.image.query") {
|
|
88
|
-
return handleSessionRead(client, payload);
|
|
88
|
+
return handleSessionRead(client, payload, input.peerPublicKey);
|
|
89
89
|
} else if (payload.type === "sessions.refresh") {
|
|
90
90
|
return handleInboundRefresh(client, payload, publish);
|
|
91
91
|
} else if (payload.type === "sessions.history.query") {
|
|
@@ -266,9 +266,10 @@ async function handleInboundTaskCreate(
|
|
|
266
266
|
function handleInboundSubscription(
|
|
267
267
|
client: RelayClient, payload: Extract<Payload, { type: "session.subscribe" }>,
|
|
268
268
|
subscriptions: Set<string>, publish: (forceHistory?: boolean) => Promise<void>,
|
|
269
|
+
peerPublicKey?: string,
|
|
269
270
|
): boolean {
|
|
270
271
|
// The transcript goes first, before a potentially slow catalog rescan.
|
|
271
|
-
void publishSessionEvents(client, payload.sessionId).catch(() => false);
|
|
272
|
+
void publishSessionEvents(client, payload.sessionId, undefined, undefined, { peerPublicKey }).catch(() => false);
|
|
272
273
|
if (handleSubscription(subscriptions, payload)) {
|
|
273
274
|
void publish().catch(() => {});
|
|
274
275
|
}
|
|
@@ -276,8 +277,10 @@ function handleInboundSubscription(
|
|
|
276
277
|
}
|
|
277
278
|
|
|
278
279
|
async function handleSessionRead(client: RelayClient,
|
|
279
|
-
payload: Extract<Payload, { type: 'session.events' | 'session.image.query' }
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
payload: Extract<Payload, { type: 'session.events' | 'session.image.query' }>,
|
|
281
|
+
peerPublicKey?: string): Promise<boolean> {
|
|
282
|
+
if (payload.type === 'session.image.query') await publishSessionImage(client, payload, peerPublicKey);
|
|
283
|
+
else await publishSessionEvents(client, payload.sessionId, undefined, payload.threadId,
|
|
284
|
+
{ ...payload, peerPublicKey }).catch(() => false);
|
|
282
285
|
return true;
|
|
283
286
|
}
|
|
@@ -21,3 +21,5 @@ Engine binding recovery waits for the first connected catalog publication before
|
|
|
21
21
|
Codex discovery shares repeated checkout verification only within one synchronous scan. The next scan verifies repository identity again; no long-lived checkout cache can cross a repository change. This keeps startup catalog delivery ahead of repeated Git subprocesses.
|
|
22
22
|
|
|
23
23
|
Subscribed transcript heads use a separate, single-flight transfer loop. A stalled head never occupies the current catalog publication slot; stopping the monitor cancels further queued reads. The iPhone keeps its historical backfill independent of both loops.
|
|
24
|
+
|
|
25
|
+
Authenticated subscription and transcript requests pass the verified requester key to the encrypted reply. A requested page does not wait for copies addressed to other paired controllers. Shared catalogue and chat updates remain available to passive controllers.
|
|
@@ -17,3 +17,9 @@ Native image identities remain readable after the chat head advances. Codex
|
|
|
17
17
|
attachments are located by timestamp and content block together, so a caption
|
|
18
18
|
with the same timestamp cannot hide the picture. Original bytes are delivered in
|
|
19
19
|
bounded encrypted chunks and the Apple client retains them for offline use.
|
|
20
|
+
|
|
21
|
+
An authenticated image query returns its key grant and chunk only to the requesting controller. Shared chat publication still retains every authorized controller, including one receiving passively.
|
|
22
|
+
|
|
23
|
+
Saved native picture identities remain addressable after newer transcript pages append. Lookup walks bounded windows toward the original row, verifies the exact conversation and block identity, and stops at the file start or a non-advancing cursor. There is no page-count expiry for a previously advertised attachment.
|
|
24
|
+
|
|
25
|
+
Workspace screenshots in older replies resolve the original native row and its exact advertised image identity before applying the existing Task, Execution and workspace access checks. A request never supplies a filesystem path, and looking up an older reply does not widen access outside its authorized workspace.
|
|
@@ -22,7 +22,8 @@ export function readSessionImage(query: SessionImageQuery, session: SessionInfo,
|
|
|
22
22
|
return sessionImageChunk(session, activity, query.imageId, query.offset, query.historyCursor);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export async function publishSessionImage(client: RelayClient, query: SessionImageQuery
|
|
25
|
+
export async function publishSessionImage(client: RelayClient, query: SessionImageQuery,
|
|
26
|
+
peerPublicKey?: string): Promise<boolean> {
|
|
26
27
|
const session = publishedSession(client, query.sessionId)
|
|
27
28
|
?? scanSessions().sessions.find(s => s.sessionId === query.sessionId)
|
|
28
29
|
?? scanSessionHistory().find(s => s.sessionId === query.sessionId);
|
|
@@ -33,6 +34,6 @@ export async function publishSessionImage(client: RelayClient, query: SessionIma
|
|
|
33
34
|
requestId: query.requestId, imageId: query.imageId, offset: query.offset,
|
|
34
35
|
...(chunk ? { mimeType: chunk.mime_type, totalBytes: chunk.total_bytes,
|
|
35
36
|
dataBase64: chunk.data_base64 } : { unavailable: true }) }), query.sessionId,
|
|
36
|
-
'phone', { ttlMs: 20_000, reliable: false });
|
|
37
|
+
'phone', { ttlMs: 20_000, reliable: false, peerPublicKey });
|
|
37
38
|
return chunk !== undefined;
|
|
38
39
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ActivityEntry, SessionInfo } from "../../../../../packages/protocol/schema";
|
|
2
|
+
import { appendCodexActivity } from "../../sessions/scan/codex/activity";
|
|
3
|
+
import { codexLogPathForSession } from "../../sessions/scan/codex/shared";
|
|
4
|
+
import { appendClaudeActivity, claudeLogPath } from "../../sessions/scan/claude";
|
|
5
|
+
import { safeParse, ts } from "../../sessions/support/common";
|
|
6
|
+
import { previousNativeLines } from "../native-lines";
|
|
7
|
+
|
|
8
|
+
/** Recover only the exact previously advertised row in this native conversation. */
|
|
9
|
+
export function nativeTranscriptEntry(session: SessionInfo, entryId: string,
|
|
10
|
+
cursor?: string): ActivityEntry | undefined {
|
|
11
|
+
if (!entryId.startsWith(`${session.sessionId}:`)) return undefined;
|
|
12
|
+
const path = session.agent === "codex" ? codexLogPathForSession(session.sessionId, session.startedAt)
|
|
13
|
+
: session.agent === "claude" ? claudeLogPath(session.sessionId) : undefined;
|
|
14
|
+
if (!path) return undefined;
|
|
15
|
+
const stamp = session.agent === "codex" ? Number(entryId.slice(session.sessionId.length + 1).split(":")[0]) : undefined;
|
|
16
|
+
if (stamp != null && !Number.isFinite(stamp)) return undefined;
|
|
17
|
+
let pageCursor = cursor;
|
|
18
|
+
try {
|
|
19
|
+
while (true) {
|
|
20
|
+
const window = previousNativeLines(path, session.sessionId, pageCursor);
|
|
21
|
+
if (!window) return undefined;
|
|
22
|
+
let earliest = Infinity;
|
|
23
|
+
for (const line of window.lines) {
|
|
24
|
+
const row = safeParse(line.text), at = ts(row?.timestamp);
|
|
25
|
+
if (at) earliest = Math.min(earliest, at);
|
|
26
|
+
if (stamp != null && at !== stamp) continue;
|
|
27
|
+
const out: ActivityEntry[] = [], seen = new Set<string>(), observations = new Map();
|
|
28
|
+
if (session.agent === "codex") {
|
|
29
|
+
appendCodexActivity({ out, seen, session, lines: [line.text], observations,
|
|
30
|
+
changes: new Map(), details: new Map(), fullText: true });
|
|
31
|
+
} else {
|
|
32
|
+
appendClaudeActivity({ out, seen, session, lines: [line.text], observations, fullText: true });
|
|
33
|
+
}
|
|
34
|
+
const found = out.find(entry => entry.id === entryId);
|
|
35
|
+
if (found) return found;
|
|
36
|
+
}
|
|
37
|
+
if (window.start === 0 || (stamp != null && earliest < stamp)) return undefined;
|
|
38
|
+
const next = window.lines[0]?.offset ?? window.start;
|
|
39
|
+
if (next >= window.end) return undefined;
|
|
40
|
+
pageCursor = window.cursorAt(next);
|
|
41
|
+
}
|
|
42
|
+
} catch { return undefined; }
|
|
43
|
+
}
|
|
@@ -22,7 +22,8 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
|
|
|
22
22
|
if (!path || !['claude', 'codex'].includes(session.agent)) return undefined;
|
|
23
23
|
try {
|
|
24
24
|
let pageCursor = cursor;
|
|
25
|
-
|
|
25
|
+
// Old advertised pictures remain readable as the conversation grows.
|
|
26
|
+
while (true) {
|
|
26
27
|
const window = previousNativeLines(path, session.sessionId, pageCursor);
|
|
27
28
|
if (!window) return undefined;
|
|
28
29
|
const lines = window.lines.map(line => line.text);
|
|
@@ -49,7 +50,9 @@ export function nativeTranscriptImage(session: SessionInfo, imageId: string, off
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
if (window.start === 0) break;
|
|
52
|
-
|
|
53
|
+
const next = window.lines[0]?.offset ?? window.start;
|
|
54
|
+
if (next >= window.end) break;
|
|
55
|
+
pageCursor = window.cursorAt(next);
|
|
53
56
|
}
|
|
54
57
|
} catch { return undefined; }
|
|
55
58
|
return undefined;
|
|
@@ -11,7 +11,9 @@ export function nativeImageLines(sessionId: string, entryId: string): string[] |
|
|
|
11
11
|
if (!path || !Number.isFinite(stamp) || !Number.isSafeInteger(blockIndex) || blockIndex < 0) return undefined;
|
|
12
12
|
let cursor: string | undefined;
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
// A saved attachment does not expire when newer transcript windows append.
|
|
15
|
+
// Each window stays bounded and its cursor must move toward the file start.
|
|
16
|
+
while (true) {
|
|
15
17
|
const window = previousNativeLines(path, sessionId, cursor);
|
|
16
18
|
if (!window) return undefined;
|
|
17
19
|
let earliest = Infinity;
|
|
@@ -24,7 +26,9 @@ export function nativeImageLines(sessionId: string, entryId: string): string[] |
|
|
|
24
26
|
&& p.content[blockIndex]?.type === "input_image") return [line.text];
|
|
25
27
|
}
|
|
26
28
|
if (window.start === 0 || earliest < stamp) return undefined;
|
|
27
|
-
|
|
29
|
+
const next = window.lines[0]?.offset ?? window.start;
|
|
30
|
+
if (next >= window.end) return undefined;
|
|
31
|
+
cursor = window.cursorAt(next);
|
|
28
32
|
}
|
|
29
33
|
} catch { return undefined; }
|
|
30
34
|
return undefined;
|
|
@@ -8,6 +8,7 @@ import { recordedToolDetails } from "./tool-details";
|
|
|
8
8
|
import { completedTurnChanges } from "./turn-changes";
|
|
9
9
|
export { nativeImageLines } from "./images";
|
|
10
10
|
export { nativeTranscriptImage } from './images/native';
|
|
11
|
+
export { nativeTranscriptEntry } from './images/advertised-entry';
|
|
11
12
|
|
|
12
13
|
function boundedEntry(entry: ActivityEntry): ActivityEntry {
|
|
13
14
|
const result = { ...entry };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { artifactImages, artifactImageChunk } from "./artifacts";
|
|
2
2
|
import { desktopTaskActivity } from "../task-activity";
|
|
3
3
|
import type { ActivityEntry, SessionInfo, SessionActivity } from '../../../../../packages/protocol/schema';
|
|
4
|
-
import { nativeTranscriptImage } from '../../../../bridge/src/transcript-history';
|
|
4
|
+
import { nativeTranscriptImage, nativeTranscriptEntry } from '../../../../bridge/src/transcript-history';
|
|
5
5
|
import { readStoreState } from '../../../../bridge/src/mesh/store/state';
|
|
6
6
|
import { configDir } from '../../../../bridge/src/config';
|
|
7
7
|
import { join } from 'node:path';
|
|
@@ -10,16 +10,25 @@ export function transcriptImages(entry: ActivityEntry) {
|
|
|
10
10
|
return artifactImages(entry).map(({ id, name, markdown }) => ({ id, name, markdown }));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function sessionArtifact(session: SessionInfo, activity: { entries: Array<Parameters<typeof artifactImages>[0]> },
|
|
14
|
+
imageId: string, cursor?: string) {
|
|
15
|
+
const current = activity.entries.flatMap(artifactImages).find(image => image.id === imageId);
|
|
16
|
+
if (current) return current;
|
|
17
|
+
const entryId = imageId.match(/^(.*):image:[a-f0-9]{24}$/)?.[1];
|
|
18
|
+
const saved = entryId ? nativeTranscriptEntry(session, entryId, cursor) : undefined;
|
|
19
|
+
return saved ? artifactImages(saved).find(image => image.id === imageId) : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
export function sessionImageChunk(session: SessionInfo, activity: SessionActivity,
|
|
14
23
|
imageId: string, offset: number, cursor?: string, storePath?: string) {
|
|
15
24
|
if (activity.sessionId !== session.sessionId) return undefined;
|
|
16
25
|
const native = activity.entries.some(entry => entry.id === imageId && entry.kind === 'user'
|
|
17
26
|
&& entry.attachments?.includes('Image'));
|
|
18
|
-
if (native || imageId.startsWith(`${session.sessionId}:`)) {
|
|
27
|
+
if (!imageId.includes(':image:') && (native || imageId.startsWith(`${session.sessionId}:`))) {
|
|
19
28
|
const picture = nativeTranscriptImage(session, imageId, offset, cursor);
|
|
20
29
|
if (picture) return picture;
|
|
21
30
|
}
|
|
22
|
-
const artifact =
|
|
31
|
+
const artifact = sessionArtifact(session, activity, imageId, cursor);
|
|
23
32
|
if (!artifact) return undefined;
|
|
24
33
|
const loaded = readStoreState(storePath ?? join(configDir(), 'project-mesh.json'));
|
|
25
34
|
if (loaded.status !== 'ok') return undefined;
|
|
@@ -46,7 +55,11 @@ export function desktopTaskImage(input: unknown, storePath?: string,
|
|
|
46
55
|
const activity = cachedActivity ?? desktopTaskActivity(query, storePath);
|
|
47
56
|
if (!activity?.session_id || !activity.agent
|
|
48
57
|
|| activity.project_id !== query.project_id || activity.task_id !== query.task_id) return undefined;
|
|
49
|
-
const
|
|
58
|
+
const session: SessionInfo = { sessionId: activity.session_id, agent: activity.agent,
|
|
59
|
+
state: "idle", startedAt: 0, lastActivityAt: 0, tokensSession: 0, tokensLastTurn: 0 };
|
|
60
|
+
const cursor = query.history_cursor;
|
|
61
|
+
if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 512)) return undefined;
|
|
62
|
+
const artifact = sessionArtifact(session, activity, imageId, cursor as string | undefined);
|
|
50
63
|
if (artifact) {
|
|
51
64
|
const chunk = artifactImageChunk({ projectId: activity.project_id, taskId: activity.task_id,
|
|
52
65
|
sessionId: activity.session_id, provider: activity.agent, image: artifact, offset, storePath });
|
|
@@ -55,10 +68,6 @@ export function desktopTaskImage(input: unknown, storePath?: string,
|
|
|
55
68
|
if (!["codex", "claude"].includes(activity.agent)
|
|
56
69
|
|| !activity.entries.some((entry) => entry.id === imageId
|
|
57
70
|
&& entry.kind === "user" && entry.attachments?.includes("Image"))) return undefined;
|
|
58
|
-
const cursor = query.history_cursor;
|
|
59
|
-
if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 512)) return undefined;
|
|
60
|
-
const session: SessionInfo = { sessionId: activity.session_id, agent: activity.agent,
|
|
61
|
-
state: "idle", startedAt: 0, lastActivityAt: 0, tokensSession: 0, tokensLastTurn: 0 };
|
|
62
71
|
const chunk = nativeTranscriptImage(session, imageId, offset, cursor as string | undefined);
|
|
63
72
|
return chunk ? { operation: "desktop.task_image", image_id: imageId, ...chunk } : undefined;
|
|
64
73
|
}
|
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.55
|
|
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.55\";\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.55";
|
|
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
|
@@ -11,11 +11,13 @@ Tests live in `tests/`; they include an actual WebSocket server with the relay's
|
|
|
11
11
|
rate limit. A socket write is transport evidence, never an approval receipt.
|
|
12
12
|
|
|
13
13
|
MIT License; see the repository LICENSE.
|
|
14
|
-
#
|
|
14
|
+
# Controller delivery
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
Catalogs, liveness and shared conversation updates reach every authorized
|
|
17
|
+
controller, including a phone receiving passively. Silence never removes access
|
|
18
|
+
to its local replica. Authenticated history, subscription and image requests
|
|
19
|
+
return their independent key grant and encrypted result only to the requesting
|
|
20
|
+
controller, with priority over queued periodic snapshots. Older callers without
|
|
21
|
+
a requester retain the complete authorized recipient set. Every explicit target
|
|
22
|
+
passes the pairing and controller authorization checks. Routing never deletes a
|
|
23
|
+
pairing or changes approval policy.
|
|
@@ -3,7 +3,6 @@ import type { PeerConfig, SendOptions } from "../relay-client-types";
|
|
|
3
3
|
import type { Payload, Role } from "../../protocol/schema";
|
|
4
4
|
import { peerPublicKeys } from "../crypto";
|
|
5
5
|
import { sealEnvelope } from "../relay-envelope";
|
|
6
|
-
export { ActiveControllerRecipients } from "./active-controllers";
|
|
7
6
|
|
|
8
7
|
type Job = { frame: string; bytes: number; resolve: () => void; reject: (error: Error) => void };
|
|
9
8
|
const CLOSED = () => new Error("relay connection closed before delivery");
|
|
@@ -41,6 +41,8 @@ export type RelayClientOptions = {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export type SendOptions = {
|
|
44
|
+
/** Reply only to the authenticated requester; absent means all authorized peers. */
|
|
45
|
+
peerPublicKey?: string;
|
|
44
46
|
/** Delivery lifetime for relay hold queues. Omit only for non-expiring hello packets. */
|
|
45
47
|
ttlMs?: number;
|
|
46
48
|
deliveryId?: string;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the Cloudflare Durable Object deployment.
|
|
11
11
|
*/
|
|
12
12
|
import WebSocket from "ws";
|
|
13
|
-
import { OutboundFrameQueue, sendEncryptedPayload
|
|
13
|
+
import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { Envelope, Payload, type Role } from "../protocol/schema";
|
|
16
16
|
import { openFromPeersWithIdentity, openWithTransferKey, peerPublicKeys, seal, sealWithTransferKey } from "./crypto";
|
|
@@ -34,7 +34,6 @@ export class RelayClient {
|
|
|
34
34
|
private livenessTimer?: NodeJS.Timeout;
|
|
35
35
|
private awaitingPong = false;
|
|
36
36
|
private readonly sessionKeys = new Map<string, string>();
|
|
37
|
-
private readonly activeControllers = new ActiveControllerRecipients();
|
|
38
37
|
/**
|
|
39
38
|
* Delivery ids are relay-controlled metadata, so replay protection is bound
|
|
40
39
|
* to the authenticated ciphertext itself. Keep a bounded process-local set:
|
|
@@ -200,9 +199,6 @@ export class RelayClient {
|
|
|
200
199
|
this.sessionKeys.set(payload.sessionId, payload.key);
|
|
201
200
|
}
|
|
202
201
|
if (this.processingCiphertexts.has(fingerprint)) return;
|
|
203
|
-
if (this.cfg.role === "machine" && (payload.type !== "hello" || payload.role === "phone")) {
|
|
204
|
-
this.activeControllers.observe(peerPublicKey);
|
|
205
|
-
}
|
|
206
202
|
this.processingCiphertexts.add(fingerprint);
|
|
207
203
|
try {
|
|
208
204
|
const results = await Promise.all([...this.listeners].map(async (listener) => {
|
|
@@ -238,10 +234,11 @@ export class RelayClient {
|
|
|
238
234
|
const ws = this.ws;
|
|
239
235
|
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
|
|
240
236
|
if (!this.frames) throw new Error("relay not connected");
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
237
|
+
if (options.peerPublicKey) {
|
|
238
|
+
await this.sendToPeer(payload, options.peerPublicKey, options);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await sendEncryptedPayload(this.frames, this.cfg, key => this.peerIsAllowed(key),
|
|
245
242
|
{ payload, to, options });
|
|
246
243
|
}
|
|
247
244
|
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/** Activity routes replaceable traffic; authorization remains a separate check. */
|
|
2
|
-
export class ActiveControllerRecipients {
|
|
3
|
-
private readonly seen = new Map<string, number>();
|
|
4
|
-
|
|
5
|
-
observe(peer: string, now = Date.now()): void {
|
|
6
|
-
this.seen.set(peer, now);
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
allows(peer: string, replaceable: boolean, now = Date.now()): boolean {
|
|
10
|
-
if (!replaceable) return true;
|
|
11
|
-
const active = [...this.seen].filter(([, at]) => now >= at && now - at <= 90_000);
|
|
12
|
-
// A newly connected transport has not heard its controllers yet.
|
|
13
|
-
return active.length === 0 || active.some(([key]) => key === peer);
|
|
14
|
-
}
|
|
15
|
-
}
|