granttap-mcp 0.8.54 → 0.8.56
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/README.md +21 -0
- package/apps/bridge/src/engine/README.md +5 -0
- package/apps/bridge/src/engine/runtime/engine-projects.ts +15 -10
- package/apps/bridge/src/engine/runtime/repository-graph/cache.ts +54 -0
- package/apps/bridge/src/engine/runtime/repository-graph-jobs.ts +47 -5
- package/apps/bridge/src/host/session-key-store/README.md +12 -0
- package/apps/bridge/src/host/session-key-store/index.ts +102 -0
- package/apps/bridge/src/host/session-keys.ts +5 -29
- package/apps/bridge/src/monitor/handlers/catalog.ts +2 -1
- package/apps/bridge/src/monitor/handlers/inbound.ts +12 -7
- package/apps/bridge/src/monitor/mesh-publication/README.md +3 -0
- package/apps/bridge/src/monitor/mesh-publication/enrichment.ts +11 -0
- package/apps/bridge/src/monitor/mesh-publication/index.ts +4 -4
- 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/task-files/README.md +16 -0
- package/apps/bridge/src/task-files/index.ts +96 -0
- package/apps/bridge/src/task-files/relay.ts +14 -0
- 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/engine-bridge.ts +1 -1
- package/apps/mcp/src/desktop/image/index.ts +17 -8
- package/apps/mcp/src/desktop/operations/index.ts +2 -0
- package/apps/mcp/src/desktop/workspace/README.md +8 -0
- package/apps/mcp/src/desktop/workspace/task-files.ts +30 -0
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/docs/images/iphone-chat-files.png +0 -0
- package/docs/images/iphone-chat-work-summary.png +0 -0
- 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/protocol/messages/mesh/snapshot.ts +1 -0
- package/packages/protocol/messages/sessions/workspace.ts +13 -0
- package/packages/protocol/schema.ts +4 -0
- package/packages/core/outbound-scheduler/active-controllers.ts +0 -15
package/README.md
CHANGED
|
@@ -23,6 +23,12 @@ index so a restart can send current heads of the saved Now chats before a fresh
|
|
|
23
23
|
discovery scan. The Apple clients keep downloaded conversations, pictures, and
|
|
24
24
|
Mesh data locally for offline use; clearing that cache preserves the account login.
|
|
25
25
|
|
|
26
|
+
The chat's **Files** pill opens recorded changes for the current reply and the
|
|
27
|
+
Task's **All files** tree. Directory and text reads are paged, encrypted for the
|
|
28
|
+
requesting controller, and restricted to that native execution's admitted
|
|
29
|
+
checkout. Received previews remain on the device for offline inspection.
|
|
30
|
+
**Agents** opens nested execution conversations within the same Task.
|
|
31
|
+
|
|
26
32
|
The MCP runtime, plugins, and CLI are open source under the MIT License.
|
|
27
33
|
The Apple apps, Engine, relay, and website are separately licensed commercial
|
|
28
34
|
products or source. Their licenses do not change this repository's MIT grant.
|
|
@@ -150,6 +156,21 @@ separate from their path identity. Missing content evidence remains unknown.
|
|
|
150
156
|
|
|
151
157
|
<p align="center"><em>These are deterministic simulator fixtures, not reports from a live customer repository.</em></p>
|
|
152
158
|
|
|
159
|
+
Health and Graph share one architecture build state. With Engine enabled,
|
|
160
|
+
active repository bindings schedule missing or changed analysis automatically;
|
|
161
|
+
manual rebuilds join pending work and return their own delivery receipts.
|
|
162
|
+
Usable reports persist privately on the computer across MCP restarts, while
|
|
163
|
+
the phone keeps its last received map visible during a rebuild. Source Git
|
|
164
|
+
revision and analysis content fingerprint are separate freshness facts.
|
|
165
|
+
Partial and unavailable reports retain their scope and status.
|
|
166
|
+
|
|
167
|
+
<p align="center">
|
|
168
|
+
<img src="docs/images/iphone-chat-work-summary.png" width="200" alt="Files and Agents above the composer with deterministic sample work">
|
|
169
|
+
<img src="docs/images/iphone-chat-files.png" width="200" alt="Saved Task checkout folder tree">
|
|
170
|
+
</p>
|
|
171
|
+
|
|
172
|
+
<p align="center"><em>Native build181 captures with deterministic sample data.</em></p>
|
|
173
|
+
|
|
153
174
|
An authenticated controller can request a separate, one-time device credential
|
|
154
175
|
for another iPhone or iPad from each Live linked computer. The phone packages
|
|
155
176
|
the resulting links in an encrypted, single-use QR. This adds the new phone to
|
|
@@ -16,6 +16,11 @@ ticks reuse a fresh bounded report, and failed analyses back off. An absent
|
|
|
16
16
|
report leaves the graph unavailable and Cortex degraded; it does not block
|
|
17
17
|
policy IPC: repository analysis owns a separate bounded-lifetime socket, while
|
|
18
18
|
policy and Backbone requests keep their control connection. It does not turn a repository label into an architecture result.
|
|
19
|
+
Explicit builds join and prioritize the same pending repository job. Confirmed
|
|
20
|
+
reports are retained in a bounded private computer cache across restarts, served
|
|
21
|
+
while a new observation runs, and refreshed after the observation expires. Git
|
|
22
|
+
admission revision (`sourceRevision`) remains separate from Weavatrix's content
|
|
23
|
+
fingerprint (`revision`). Failed analyses cannot erase the durable last report.
|
|
19
24
|
Claude, Codex, and Cursor hooks can evaluate the engine's local Project
|
|
20
25
|
Governance policy only when both `GRANTTAP_ENGINE_ENABLED` and
|
|
21
26
|
`GRANTTAP_PROJECT_POLICY_ENABLED` are enabled. Project `DENY` and `ASK` precede
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
} from "../protocol/engine-protocol";
|
|
17
17
|
import { RepositoryGraphJobs } from "./repository-graph-jobs";
|
|
18
18
|
import { fitRepositoryGraph } from "./repository-graph/fit";
|
|
19
|
+
import { RepositoryGraphCache } from "./repository-graph/cache";
|
|
19
20
|
|
|
20
21
|
export type LocalProjectBinding = {
|
|
21
22
|
summary: ProjectBindingSummary;
|
|
@@ -27,9 +28,11 @@ export type LocalProjectBinding = {
|
|
|
27
28
|
|
|
28
29
|
let sharedClient: EngineClient | undefined;
|
|
29
30
|
const pendingBindingSyncs = new Map<string, Promise<boolean>>();
|
|
30
|
-
|
|
31
|
+
// The Apple Mesh envelope is 256KiB; leave room for Tasks, bindings and receipts.
|
|
32
|
+
const MAX_REPOSITORY_GRAPH_WIRE_BYTES = 192 * 1_024 - 128;
|
|
31
33
|
const MAX_SINGLE_GRAPH_WIRE_BYTES = 192 * 1_024;
|
|
32
|
-
const repositoryGraphJobs = new RepositoryGraphJobs<ProjectRepositoryGraph>(
|
|
34
|
+
const repositoryGraphJobs = new RepositoryGraphJobs<ProjectRepositoryGraph>(Date.now,120_000,300_000,32,
|
|
35
|
+
new RepositoryGraphCache(()=>join(configDir(),"repository-graph-cache")));
|
|
33
36
|
|
|
34
37
|
export async function syncProjectBinding(
|
|
35
38
|
project: Project,
|
|
@@ -192,19 +195,19 @@ export async function projectRepositoryGraphs(
|
|
|
192
195
|
const graphs = options.background
|
|
193
196
|
? repositories.map((binding) => repositoryGraphJobs.read(
|
|
194
197
|
JSON.stringify([projectId, binding.repositoryId, binding.revision]),
|
|
195
|
-
() => analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000),
|
|
198
|
+
() => analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000, binding.revision),
|
|
196
199
|
options.priority ?? 0,
|
|
197
200
|
))
|
|
198
|
-
: await Promise.all(repositories.map((binding) =>
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
})));
|
|
201
|
+
: await Promise.all(repositories.map((binding) => repositoryGraphJobs.refresh(
|
|
202
|
+
JSON.stringify([projectId, binding.repositoryId, binding.revision]),
|
|
203
|
+
() => analyzeRepositoryGraph(client, projectId, binding.repositoryId, 120_000, binding.revision),
|
|
204
|
+
)));
|
|
203
205
|
return boundRepositoryGraphs(graphs.filter((item): item is ProjectRepositoryGraph => item != null));
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
async function analyzeRepositoryGraph(
|
|
207
209
|
client: EngineClientLike | undefined, projectId: string, repositoryId: string, timeoutMs: number,
|
|
210
|
+
sourceRevision?: string,
|
|
208
211
|
): Promise<ProjectRepositoryGraph | undefined> {
|
|
209
212
|
// The daemon serves a socket serially. Analysis must not occupy the channel
|
|
210
213
|
// used for policy, bindings and Backbone reads while a repository is scanned.
|
|
@@ -222,6 +225,7 @@ async function analyzeRepositoryGraph(
|
|
|
222
225
|
return {
|
|
223
226
|
projectId: graph.project_id, repositoryId: graph.repository_id,
|
|
224
227
|
revision: graph.revision, weavatrixVersion: graph.weavatrix_version,
|
|
228
|
+
sourceRevision,
|
|
225
229
|
analysisId: graph.analysis_id ?? undefined,
|
|
226
230
|
analysisStatus: graph.analysis_status,
|
|
227
231
|
architectureHypotheses: graph.architecture_hypotheses,
|
|
@@ -269,9 +273,10 @@ async function analyzeRepositoryGraph(
|
|
|
269
273
|
function boundRepositoryGraphs(graphs: ProjectRepositoryGraph[]): ProjectRepositoryGraph[] {
|
|
270
274
|
const result: ProjectRepositoryGraph[] = [];
|
|
271
275
|
let remaining = MAX_REPOSITORY_GRAPH_WIRE_BYTES;
|
|
272
|
-
for (const graph of graphs) {
|
|
276
|
+
for (const [index, graph] of graphs.entries()) {
|
|
273
277
|
if (remaining < 1_024) break;
|
|
274
|
-
const
|
|
278
|
+
const fairShare = Math.floor(remaining / (graphs.length - index));
|
|
279
|
+
const bounded = fitRepositoryGraph(graph, Math.min(fairShare, MAX_SINGLE_GRAPH_WIRE_BYTES));
|
|
275
280
|
if (!bounded) continue;
|
|
276
281
|
result.push(bounded);
|
|
277
282
|
remaining -= Buffer.byteLength(JSON.stringify(bounded), "utf8");
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ProjectRepositoryGraph } from '../../../../../../packages/protocol/schema';
|
|
5
|
+
import { fitRepositoryGraph } from './fit';
|
|
6
|
+
/** Private computer-side observations; no hand-authored architecture enters this cache. */
|
|
7
|
+
export class RepositoryGraphCache {
|
|
8
|
+
constructor(private readonly directory: string | (() => string), private readonly now: () => number = Date.now) { }
|
|
9
|
+
read(key: string): {
|
|
10
|
+
value: ProjectRepositoryGraph;
|
|
11
|
+
savedAt: number;
|
|
12
|
+
} | undefined {
|
|
13
|
+
try {
|
|
14
|
+
const path = this.path(key);
|
|
15
|
+
if (statSync(path).size > 256 * 1024)
|
|
16
|
+
return undefined;
|
|
17
|
+
const record = JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
const graph = ProjectRepositoryGraph.safeParse(record.value);
|
|
19
|
+
if (record.key !== key || !Number.isFinite(record.savedAt) || !graph.success
|
|
20
|
+
|| !['COMPLETE', 'INCOMPLETE'].includes(graph.data.analysisStatus ?? ''))
|
|
21
|
+
return undefined;
|
|
22
|
+
return { value: graph.data, savedAt: record.savedAt };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
write(key: string, value: ProjectRepositoryGraph): void {
|
|
29
|
+
if (!['COMPLETE', 'INCOMPLETE'].includes(value.analysisStatus ?? ''))
|
|
30
|
+
return;
|
|
31
|
+
const bounded = fitRepositoryGraph(value, 192 * 1024);
|
|
32
|
+
if (!bounded) return;
|
|
33
|
+
const json = JSON.stringify({ key, value: bounded, savedAt: this.now() });
|
|
34
|
+
if (Buffer.byteLength(json) > 256 * 1024)
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
const directory = typeof this.directory === 'function' ? this.directory() : this.directory;
|
|
38
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
39
|
+
const path = this.path(key), temporary = path + '.tmp';
|
|
40
|
+
writeFileSync(temporary, json, { mode: 0o600 });
|
|
41
|
+
renameSync(temporary, path);
|
|
42
|
+
const files = readdirSync(directory).filter(name => name.endsWith('.json'))
|
|
43
|
+
.map(name => ({ path: join(directory, name), mtime: statSync(join(directory, name)).mtimeMs }))
|
|
44
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
45
|
+
for (const file of files.slice(128))
|
|
46
|
+
unlinkSync(file.path);
|
|
47
|
+
}
|
|
48
|
+
catch { /* The live graph remains available when disk storage is unavailable. */ }
|
|
49
|
+
}
|
|
50
|
+
private path(key: string): string {
|
|
51
|
+
const directory = typeof this.directory === 'function' ? this.directory() : this.directory;
|
|
52
|
+
return join(directory, createHash('sha256').update(key).digest('hex') + '.json');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -4,6 +4,7 @@ export class RepositoryGraphJobs<T> {
|
|
|
4
4
|
value?: T;
|
|
5
5
|
retryAt: number;
|
|
6
6
|
pending: boolean;
|
|
7
|
+
listeners: Array<(value: T | undefined) => void>;
|
|
7
8
|
}>();
|
|
8
9
|
private readonly queue: Array<{
|
|
9
10
|
key: string; run: () => Promise<T | undefined>; priority: number;
|
|
@@ -15,25 +16,61 @@ export class RepositoryGraphJobs<T> {
|
|
|
15
16
|
private readonly successMs = 120_000,
|
|
16
17
|
private readonly failureMs = 300_000,
|
|
17
18
|
private readonly capacity = 32,
|
|
19
|
+
private readonly storage?: {read(key:string):{value:T;savedAt:number}|undefined;write(key:string,value:T):void},
|
|
18
20
|
) {}
|
|
19
21
|
|
|
20
|
-
read(key: string, run: () => Promise<T | undefined>, priority = 0): T | undefined {
|
|
22
|
+
read(key: string, run: () => Promise<T | undefined>, priority = 0, force = false): T | undefined {
|
|
23
|
+
if (!this.entries.has(key)) {
|
|
24
|
+
const saved = this.storage?.read(key);
|
|
25
|
+
if (saved) this.entries.set(key, {value:saved.value,retryAt:saved.savedAt+this.successMs,
|
|
26
|
+
pending:false,listeners:[]});
|
|
27
|
+
}
|
|
21
28
|
const previous = this.entries.get(key);
|
|
22
29
|
if (previous?.pending) return previous.value;
|
|
23
|
-
if (previous && this.now() < previous.retryAt) return previous.value;
|
|
24
|
-
if (this.
|
|
30
|
+
if (previous && !force && this.now() < previous.retryAt) return previous.value;
|
|
31
|
+
if (!this.admit(priority)) return undefined;
|
|
25
32
|
if (!previous && this.entries.size >= this.capacity * 2) {
|
|
26
33
|
const oldest = [...this.entries].find(([, entry]) => !entry.pending)?.[0];
|
|
27
34
|
if (!oldest) return undefined;
|
|
28
35
|
this.entries.delete(oldest);
|
|
29
36
|
}
|
|
30
|
-
this.entries.set(key, { value: previous?.value, retryAt: 0, pending: true });
|
|
37
|
+
this.entries.set(key, { value: previous?.value, retryAt: 0, pending: true, listeners: [] });
|
|
31
38
|
const before = this.queue.findIndex((job) => job.priority < priority);
|
|
32
39
|
this.queue.splice(before < 0 ? this.queue.length : before, 0, { key, run, priority });
|
|
33
40
|
void this.drain();
|
|
34
41
|
return previous?.value;
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/** A requested build joins the same revision's automatic job and raises its queue priority. */
|
|
45
|
+
refresh(key: string, run: () => Promise<T | undefined>, priority = Number.MAX_SAFE_INTEGER): Promise<T | undefined> {
|
|
46
|
+
if (!this.entries.get(key)?.pending) this.read(key, run, priority, true);
|
|
47
|
+
const entry = this.entries.get(key);
|
|
48
|
+
if (!entry?.pending) return Promise.resolve(undefined);
|
|
49
|
+
const index = this.queue.findIndex(job => job.key === key);
|
|
50
|
+
if (index >= 0) {
|
|
51
|
+
const job = this.queue.splice(index, 1)[0]!;
|
|
52
|
+
job.priority = Math.max(priority, job.priority);
|
|
53
|
+
const before = this.queue.findIndex(other => other.priority < job.priority);
|
|
54
|
+
this.queue.splice(before < 0 ? this.queue.length : before, 0, job);
|
|
55
|
+
}
|
|
56
|
+
return new Promise(resolve => entry.listeners.push(resolve));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private admit(priority: number): boolean {
|
|
60
|
+
if (this.queue.length < this.capacity) return true;
|
|
61
|
+
const last = this.queue[this.queue.length - 1];
|
|
62
|
+
if (!last || last.priority >= priority) return false;
|
|
63
|
+
this.queue.pop();
|
|
64
|
+
const entry = this.entries.get(last.key);
|
|
65
|
+
if (entry) {
|
|
66
|
+
entry.pending = false;
|
|
67
|
+
entry.retryAt = this.now() + this.failureMs;
|
|
68
|
+
for (const listener of entry.listeners) listener(undefined);
|
|
69
|
+
entry.listeners = [];
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
37
74
|
/** An explicit refresh supersedes an older queued or running observation. */
|
|
38
75
|
remember(key: string, value: T | undefined): void {
|
|
39
76
|
for (let index = this.queue.length - 1; index >= 0; index--) {
|
|
@@ -44,7 +81,9 @@ export class RepositoryGraphJobs<T> {
|
|
|
44
81
|
if (!oldest) return;
|
|
45
82
|
this.entries.delete(oldest);
|
|
46
83
|
}
|
|
47
|
-
this.entries.
|
|
84
|
+
for (const listener of this.entries.get(key)?.listeners ?? []) listener(value);
|
|
85
|
+
if (value !== undefined) this.storage?.write(key, value);
|
|
86
|
+
this.entries.set(key, { value, pending: false, listeners: [],
|
|
48
87
|
retryAt: this.now() + (value === undefined ? this.failureMs : this.successMs) });
|
|
49
88
|
}
|
|
50
89
|
|
|
@@ -58,11 +97,14 @@ export class RepositoryGraphJobs<T> {
|
|
|
58
97
|
let value: T | undefined;
|
|
59
98
|
try { value = await job.run(); } catch { /* Retry after backoff. */ }
|
|
60
99
|
if (this.entries.get(job.key) !== entry) continue;
|
|
100
|
+
if (value !== undefined) this.storage?.write(job.key, value);
|
|
61
101
|
this.entries.set(job.key, {
|
|
62
102
|
value,
|
|
63
103
|
retryAt: this.now() + (value === undefined ? this.failureMs : this.successMs),
|
|
64
104
|
pending: false,
|
|
105
|
+
listeners: [],
|
|
65
106
|
});
|
|
107
|
+
for (const listener of entry?.listeners ?? []) listener(value);
|
|
66
108
|
}
|
|
67
109
|
} finally {
|
|
68
110
|
this.running = false;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Task key persistence
|
|
2
|
+
|
|
3
|
+
`persistedSessionKey` preserves one stable task encryption key across concurrent
|
|
4
|
+
MCP processes. New keys use an exclusive computer-local creation lock, re-read
|
|
5
|
+
the current dictionary under that lock, and atomically replace the protected
|
|
6
|
+
snapshot. Existing keys remain readable without waiting for another creator.
|
|
7
|
+
An unreadable dictionary is never replaced with an empty one. Locks left by
|
|
8
|
+
terminated owners can be recovered; active owners are never displaced.
|
|
9
|
+
|
|
10
|
+
The public transport entry remains `../session-keys.ts`. Tests exercise actual
|
|
11
|
+
concurrent processes and the durable dictionary without logging key material.
|
|
12
|
+
This module uses the repository MIT License.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { generateTransferKey } from "../../../../../packages/core/crypto";
|
|
5
|
+
type SessionKeys = Record<string, string>;
|
|
6
|
+
const pause = new Int32Array(new SharedArrayBuffer(4));
|
|
7
|
+
export function loadSessionKeys(path: string): SessionKeys {
|
|
8
|
+
if (!existsSync(path))
|
|
9
|
+
return {};
|
|
10
|
+
if (statSync(path).size > 8 * 1024 * 1024)
|
|
11
|
+
throw Error("Task key store exceeds its private storage limit");
|
|
12
|
+
let value: unknown;
|
|
13
|
+
try {
|
|
14
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw Error("Task key store is unreadable");
|
|
18
|
+
}
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
20
|
+
throw Error("Task key store is unreadable");
|
|
21
|
+
return Object.fromEntries(Object.entries(value).filter((entry): entry is [
|
|
22
|
+
string,
|
|
23
|
+
string
|
|
24
|
+
] => typeof entry[1] === "string" && entry[1].length >= 43 && entry[1].length <= 44));
|
|
25
|
+
}
|
|
26
|
+
/** Creation is serialized across local MCP processes; reads use atomic snapshots. */
|
|
27
|
+
export function persistedSessionKey(path: string, sessionId: string, waitMs = 5000): string {
|
|
28
|
+
const existing = loadSessionKeys(path);
|
|
29
|
+
if (Object.hasOwn(existing, sessionId))
|
|
30
|
+
return existing[sessionId]!;
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
32
|
+
const lock = path + ".lock", deadline = Date.now() + waitMs;
|
|
33
|
+
const owner = acquire(lock, deadline);
|
|
34
|
+
try {
|
|
35
|
+
const keys = loadSessionKeys(path);
|
|
36
|
+
if (Object.hasOwn(keys, sessionId))
|
|
37
|
+
return keys[sessionId]!;
|
|
38
|
+
const key = generateTransferKey();
|
|
39
|
+
const temporary = path + "." + process.pid + "." + randomUUID() + ".tmp";
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(temporary, JSON.stringify({ ...keys, [sessionId]: key }) + "\n", { mode: 0o600 });
|
|
42
|
+
renameSync(temporary, path);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(temporary);
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
}
|
|
50
|
+
return key;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
unlinkSync(owner);
|
|
54
|
+
rmdirSync(lock);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function acquire(path: string, deadline: number): string {
|
|
58
|
+
for (;;) {
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(path, { mode: 0o700 });
|
|
61
|
+
const owner = path + "/owner-" + process.pid + "-" + randomUUID();
|
|
62
|
+
writeFileSync(owner, "", { flag: "wx", mode: 0o600 });
|
|
63
|
+
return owner;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
|
|
67
|
+
throw error;
|
|
68
|
+
removeDeadOwner(path);
|
|
69
|
+
if (Date.now() >= deadline)
|
|
70
|
+
throw Error("Task key creation is busy; retry later");
|
|
71
|
+
Atomics.wait(pause, 0, 0, 10);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function removeDeadOwner(path: string): void {
|
|
76
|
+
try {
|
|
77
|
+
const entries = readdirSync(path);
|
|
78
|
+
if (entries.length === 0) {
|
|
79
|
+
if (Date.now() - statSync(path).mtimeMs > 30000)
|
|
80
|
+
rmdirSync(path);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (entries.length !== 1)
|
|
84
|
+
return;
|
|
85
|
+
const owner = entries[0]!, match = /^owner-(\d+)-/.exec(owner);
|
|
86
|
+
if (!match)
|
|
87
|
+
return;
|
|
88
|
+
const pid = Number(match[1]);
|
|
89
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
process.kill(pid, 0);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") {
|
|
96
|
+
unlinkSync(path + "/" + owner);
|
|
97
|
+
rmdirSync(path);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch { /* Another creator may already have released the lock. */ }
|
|
102
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { loadSessionKeys, persistedSessionKey } from "./session-key-store";
|
|
4
4
|
import { RelayClient, type SendOptions } from "../../../../packages/core/relay-client";
|
|
5
5
|
import type {
|
|
6
6
|
MeshClaimReleaseResult,
|
|
@@ -13,41 +13,17 @@ import type {
|
|
|
13
13
|
} from "../../../../packages/protocol/schema";
|
|
14
14
|
import { configDir } from "../config";
|
|
15
15
|
|
|
16
|
-
type SessionKeys = Record<string, string>;
|
|
17
|
-
|
|
18
16
|
export function sessionKeysPath(): string {
|
|
19
17
|
return join(configDir(), "session-keys.json");
|
|
20
18
|
}
|
|
21
19
|
|
|
22
|
-
function load(): SessionKeys {
|
|
23
|
-
try {
|
|
24
|
-
const value = JSON.parse(readFileSync(sessionKeysPath(), "utf8")) as unknown;
|
|
25
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
26
|
-
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] =>
|
|
27
|
-
typeof entry[1] === "string" && entry[1].length >= 43 && entry[1].length <= 44));
|
|
28
|
-
} catch {
|
|
29
|
-
return {};
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function save(keys: SessionKeys): void {
|
|
34
|
-
mkdirSync(configDir(), { recursive: true });
|
|
35
|
-
writeFileSync(sessionKeysPath(), `${JSON.stringify(keys, null, 2)}\n`, { mode: 0o600 });
|
|
36
|
-
chmodSync(sessionKeysPath(), 0o600);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
20
|
export function sessionKey(sessionId: string): string {
|
|
40
|
-
|
|
41
|
-
if (keys[sessionId]) return keys[sessionId]!;
|
|
42
|
-
const key = generateTransferKey();
|
|
43
|
-
keys[sessionId] = key;
|
|
44
|
-
save(keys);
|
|
45
|
-
return key;
|
|
21
|
+
return persistedSessionKey(sessionKeysPath(), sessionId);
|
|
46
22
|
}
|
|
47
23
|
|
|
48
24
|
export function primeSessionKeys(client: RelayClient): void {
|
|
49
25
|
if (!existsSync(sessionKeysPath())) return;
|
|
50
|
-
for (const [sessionId, key] of Object.entries(
|
|
26
|
+
for (const [sessionId, key] of Object.entries(loadSessionKeys(sessionKeysPath()))) client.setSessionKey(sessionId, key);
|
|
51
27
|
}
|
|
52
28
|
|
|
53
29
|
/**
|
|
@@ -102,6 +78,6 @@ async function sendScopedPayload(input: {
|
|
|
102
78
|
key,
|
|
103
79
|
purpose,
|
|
104
80
|
createdAt: Date.now(),
|
|
105
|
-
}, to, { ttlMs: options.ttlMs });
|
|
81
|
+
}, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey });
|
|
106
82
|
await client.sendSession(payload, sessionId, to, options);
|
|
107
83
|
}
|
|
@@ -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
|
}
|
|
@@ -35,6 +35,7 @@ import { handleTaskCreate } from "./task-create";
|
|
|
35
35
|
import { handleUserMessage } from "./user-message";
|
|
36
36
|
import { publishSessionEvents } from "./catalog";
|
|
37
37
|
import { publishSessionImage } from '../../session-images';
|
|
38
|
+
import { publishTaskWorkspace } from '../../task-files/relay';
|
|
38
39
|
import { publishHistoryPage } from "../history/pages";
|
|
39
40
|
import { requestProjectGraphRefresh } from "../graph-refresh";
|
|
40
41
|
import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../controller-knowledge";
|
|
@@ -83,9 +84,9 @@ export async function handleMonitorMessage(
|
|
|
83
84
|
void publish().catch(() => {});
|
|
84
85
|
return true;
|
|
85
86
|
} else if (payload.type === "session.subscribe") {
|
|
86
|
-
return handleInboundSubscription(client, payload, subscriptions, publish);
|
|
87
|
-
} else if (payload.type === "session.events" || payload.type === "session.image.query") {
|
|
88
|
-
return handleSessionRead(client, payload);
|
|
87
|
+
return handleInboundSubscription(client, payload, subscriptions, publish, input.peerPublicKey);
|
|
88
|
+
} else if (payload.type === "session.events" || payload.type === "session.image.query" || payload.type === "session.workspace.query") {
|
|
89
|
+
return handleSessionRead(client, payload, input.peerPublicKey);
|
|
89
90
|
} else if (payload.type === "sessions.refresh") {
|
|
90
91
|
return handleInboundRefresh(client, payload, publish);
|
|
91
92
|
} else if (payload.type === "sessions.history.query") {
|
|
@@ -266,9 +267,10 @@ async function handleInboundTaskCreate(
|
|
|
266
267
|
function handleInboundSubscription(
|
|
267
268
|
client: RelayClient, payload: Extract<Payload, { type: "session.subscribe" }>,
|
|
268
269
|
subscriptions: Set<string>, publish: (forceHistory?: boolean) => Promise<void>,
|
|
270
|
+
peerPublicKey?: string,
|
|
269
271
|
): boolean {
|
|
270
272
|
// The transcript goes first, before a potentially slow catalog rescan.
|
|
271
|
-
void publishSessionEvents(client, payload.sessionId).catch(() => false);
|
|
273
|
+
void publishSessionEvents(client, payload.sessionId, undefined, undefined, { peerPublicKey }).catch(() => false);
|
|
272
274
|
if (handleSubscription(subscriptions, payload)) {
|
|
273
275
|
void publish().catch(() => {});
|
|
274
276
|
}
|
|
@@ -276,8 +278,11 @@ function handleInboundSubscription(
|
|
|
276
278
|
}
|
|
277
279
|
|
|
278
280
|
async function handleSessionRead(client: RelayClient,
|
|
279
|
-
payload: Extract<Payload, { type: 'session.events' | 'session.image.query'
|
|
280
|
-
|
|
281
|
-
|
|
281
|
+
payload: Extract<Payload, { type: 'session.events' | 'session.image.query' | 'session.workspace.query' }>,
|
|
282
|
+
peerPublicKey?: string): Promise<boolean> {
|
|
283
|
+
if (payload.type === 'session.workspace.query') await publishTaskWorkspace(client, payload, peerPublicKey);
|
|
284
|
+
else if (payload.type === 'session.image.query') await publishSessionImage(client, payload, peerPublicKey);
|
|
285
|
+
else await publishSessionEvents(client, payload.sessionId, undefined, payload.threadId,
|
|
286
|
+
{ ...payload, peerPublicKey }).catch(() => false);
|
|
282
287
|
return true;
|
|
283
288
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Mesh publication
|
|
2
2
|
|
|
3
|
+
Ready enriched Projects are delivered independently. A slow binding restoration
|
|
4
|
+
or unavailable Project cannot delay a completed report from another Project.
|
|
5
|
+
|
|
3
6
|
`index.ts` owns one background Mesh publication per monitor transport. Project
|
|
4
7
|
projection yields to live decisions and uses the observed native catalog. Plain
|
|
5
8
|
snapshots, policy reports, and Engine enrichment remain separate from fresh chat
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MeshSnapshot } from '../../../../../packages/protocol/schema';
|
|
2
|
+
import { enrichMeshSnapshot } from '../../mesh/runtime/snapshot/enriched';
|
|
3
|
+
/** Publish ready Projects independently so one slow binding cannot hold other reports. */
|
|
4
|
+
export async function publishEnrichedMeshes(snapshots: MeshSnapshot[], send: (snapshot: MeshSnapshot) => Promise<void>, enrich: typeof enrichMeshSnapshot = enrichMeshSnapshot): Promise<void> {
|
|
5
|
+
await Promise.all(snapshots.map(async (snapshot) => {
|
|
6
|
+
try {
|
|
7
|
+
await send(await enrich(snapshot));
|
|
8
|
+
}
|
|
9
|
+
catch { /* This Project is retried on the next publication tick. */ }
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { RelayClient } from "../../../../../packages/core/relay-client";
|
|
2
2
|
import type { SessionsStatus } from "../../../../../packages/protocol/schema";
|
|
3
3
|
import { localMeshStore } from "../../mesh/local-remote/local";
|
|
4
|
-
import { meshSnapshotsAsync
|
|
4
|
+
import { meshSnapshotsAsync } from "../../mesh/runtime";
|
|
5
|
+
import { publishEnrichedMeshes } from "./enrichment";
|
|
5
6
|
import { deriveObservedClaims } from "../../mesh/observed/claims";
|
|
6
7
|
import { ingestRuntimeInvocations } from "../../engine/invocation/ingest";
|
|
7
8
|
import { publishProjectPolicyStatuses } from "../../project-policy/runtime";
|
|
@@ -29,11 +30,10 @@ export function publishMonitorMeshes(client: RelayClient, status: SessionsStatus
|
|
|
29
30
|
}
|
|
30
31
|
if (!token.active || !client.isConnected) return;
|
|
31
32
|
await publishProjectPolicyStatuses(client, snapshots.map(mesh => mesh.projectId)).catch(() => {});
|
|
32
|
-
|
|
33
|
-
for (const snapshot of enriched) {
|
|
33
|
+
await publishEnrichedMeshes(snapshots, async snapshot => {
|
|
34
34
|
if (!token.active || !client.isConnected) return;
|
|
35
35
|
await sendMeshPayload(client, snapshot, "phone", { ttlMs: INTERVAL_MS * 24, reliable: false }).catch(() => {});
|
|
36
|
-
}
|
|
36
|
+
});
|
|
37
37
|
})();
|
|
38
38
|
void work.catch(() => {}).finally(() => { if (pending.get(client) === token) pending.delete(client); });
|
|
39
39
|
}
|
|
@@ -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,16 @@
|
|
|
1
|
+
# Task files
|
|
2
|
+
|
|
3
|
+
`readTaskWorkspace` reads a directory page or a bounded UTF-8 file from the exact
|
|
4
|
+
native execution workspace. It accepts relative paths, resolves symlinks inside
|
|
5
|
+
that workspace, and rejects credentials, traversal and external paths. The
|
|
6
|
+
viewer performs no writes, shell operations, model calls or provider actions.
|
|
7
|
+
|
|
8
|
+
`publishTaskWorkspace` resolves the execution through the computer's published
|
|
9
|
+
catalog and returns an authenticated task-encrypted reply to the requesting
|
|
10
|
+
controller. The local Mac counterpart validates Project, Task and Execution
|
|
11
|
+
identity before using the same reader. Directory pages contain at most 200
|
|
12
|
+
entries; file previews contain at most 128 KiB and explicitly mark truncation.
|
|
13
|
+
|
|
14
|
+
Module tests cover real files, nested directories, boundaries and pagination.
|
|
15
|
+
|
|
16
|
+
This module uses the repository MIT License.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { isUtf8 } from 'node:buffer';
|
|
2
|
+
import { closeSync, lstatSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import type { SessionInfo } from '../../../../packages/protocol/schema';
|
|
5
|
+
export type TaskWorkspaceQuery = {
|
|
6
|
+
mode: 'directory' | 'file';
|
|
7
|
+
path: string;
|
|
8
|
+
offset?: number;
|
|
9
|
+
};
|
|
10
|
+
export type TaskWorkspaceEntry = {
|
|
11
|
+
path: string;
|
|
12
|
+
name: string;
|
|
13
|
+
kind: 'directory' | 'file';
|
|
14
|
+
size?: number;
|
|
15
|
+
};
|
|
16
|
+
export type TaskWorkspaceRead = {
|
|
17
|
+
path: string;
|
|
18
|
+
entries?: TaskWorkspaceEntry[];
|
|
19
|
+
text?: string;
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
nextOffset?: number;
|
|
22
|
+
};
|
|
23
|
+
const protectedPath = (path: string) => path.split('/').some(part => /^\.env(?:\.|$)/i.test(part)
|
|
24
|
+
|| ['.git', '.ssh', '.aws', '.private_keys', '.codex', '.claude', '.cursor', '.granttap', '.nodvox'].includes(part)
|
|
25
|
+
|| /\.(?:p8|pem|key)$/i.test(part));
|
|
26
|
+
function admittedPath(root: string, path: string): string | undefined {
|
|
27
|
+
if (path.includes('\0') || path.includes('\\') || isAbsolute(path) || /^[A-Za-z]:/.test(path)
|
|
28
|
+
|| path.split('/').includes('..') || protectedPath(path))
|
|
29
|
+
return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const target = realpathSync(resolve(root, path));
|
|
32
|
+
const within = relative(root, target);
|
|
33
|
+
return within === '..' || within.startsWith('..' + sep) || isAbsolute(within)
|
|
34
|
+
|| protectedPath(within.split(sep).join('/')) ? undefined : target;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Only the exact known native execution's workspace is readable. */
|
|
41
|
+
export function readTaskWorkspace(session: SessionInfo, query: TaskWorkspaceQuery): TaskWorkspaceRead | undefined {
|
|
42
|
+
const path = session.worktree ?? session.cwd;
|
|
43
|
+
if (!path || !['directory', 'file'].includes(query.mode))
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
const root = realpathSync(path), target = admittedPath(root, query.path);
|
|
47
|
+
if (!target || !statSync(root).isDirectory())
|
|
48
|
+
return undefined;
|
|
49
|
+
const offset = query.offset ?? 0;
|
|
50
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (query.mode === 'file')
|
|
53
|
+
return file(target, query.path, offset);
|
|
54
|
+
if (!statSync(target).isDirectory())
|
|
55
|
+
return undefined;
|
|
56
|
+
const entries = readdirSync(target).flatMap(name => {
|
|
57
|
+
const path = query.path ? query.path + '/' + name : name;
|
|
58
|
+
const full = admittedPath(root, path);
|
|
59
|
+
if (!full)
|
|
60
|
+
return [];
|
|
61
|
+
const info = statSync(full);
|
|
62
|
+
if (!info.isDirectory() && !info.isFile())
|
|
63
|
+
return [];
|
|
64
|
+
return [{ path, name, kind: info.isDirectory() ? 'directory' as const : 'file' as const,
|
|
65
|
+
...(info.isFile() ? { size: info.size } : {}) }];
|
|
66
|
+
}).sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'directory' ? -1 : 1);
|
|
67
|
+
const next = offset + 200, truncated = next < entries.length;
|
|
68
|
+
return { path: query.path, entries: entries.slice(offset, next), truncated,
|
|
69
|
+
...(truncated ? { nextOffset: next } : {}) };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function file(target: string, path: string, offset: number): TaskWorkspaceRead | undefined {
|
|
76
|
+
const info = lstatSync(target);
|
|
77
|
+
if (!info.isFile() || offset > info.size)
|
|
78
|
+
return undefined;
|
|
79
|
+
const fd = openSync(target, 'r');
|
|
80
|
+
try {
|
|
81
|
+
const bytes = Buffer.alloc(Math.min(info.size - offset, 128 * 1024));
|
|
82
|
+
const count = readSync(fd, bytes, 0, bytes.length, offset);
|
|
83
|
+
let read = bytes.subarray(0, count);
|
|
84
|
+
// A bounded preview may end inside a UTF8 character; invalid interior bytes remain refused.
|
|
85
|
+
if (info.size > offset + count)
|
|
86
|
+
for (let trim = 1; trim <= 3 && !isUtf8(read); trim++)
|
|
87
|
+
read = bytes.subarray(0, count - trim);
|
|
88
|
+
if (!isUtf8(read) || /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(read.toString('utf8')))
|
|
89
|
+
return undefined;
|
|
90
|
+
const next = offset + read.length, truncated = info.size > next;
|
|
91
|
+
return { path, text: read.toString('utf8'), truncated, ...(truncated ? { nextOffset: next } : {}) };
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RelayClient } from '../../../../packages/core/relay-client';
|
|
2
|
+
import { SessionWorkspaceResult, type SessionWorkspaceQuery } from '../../../../packages/protocol/schema';
|
|
3
|
+
import { publishedSession } from '../session-catalog';
|
|
4
|
+
import { sendSessionPayload } from '../host/session-keys';
|
|
5
|
+
import { readTaskWorkspace } from '.';
|
|
6
|
+
/** Session identity comes from the computer's admitted catalog, never from a requested root. */
|
|
7
|
+
export async function publishTaskWorkspace(client: RelayClient, query: SessionWorkspaceQuery, peerPublicKey?: string): Promise<boolean> {
|
|
8
|
+
const session = publishedSession(client, query.sessionId);
|
|
9
|
+
const result = session ? readTaskWorkspace(session, query) : undefined;
|
|
10
|
+
await sendSessionPayload(client, SessionWorkspaceResult.parse({ type: 'session.workspace.result',
|
|
11
|
+
sessionId: query.sessionId, requestId: query.requestId,
|
|
12
|
+
...(result ?? { path: query.path, truncated: false, unavailable: true }), createdAt: Date.now() }), query.sessionId, 'phone', { peerPublicKey, ttlMs: 30000, reliable: false });
|
|
13
|
+
return result !== undefined;
|
|
14
|
+
}
|
|
@@ -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 };
|
|
@@ -18,7 +18,7 @@ export const DESKTOP_OPERATIONS = new Set([
|
|
|
18
18
|
"project.list_bindings", "policy.get", "policy.coverage",
|
|
19
19
|
"graph.get_backbone", "memory.history", "invocation.history",
|
|
20
20
|
"desktop.project", "desktop.mesh_snapshot", "desktop.workspace", "desktop.task_activity",
|
|
21
|
-
"desktop.machine_load", "desktop.capability_usage", "desktop.task_image",
|
|
21
|
+
"desktop.machine_load", "desktop.capability_usage", "desktop.task_image", "desktop.task_workspace",
|
|
22
22
|
"desktop.installed_skills", "desktop.policy_status", "desktop.policy_set", "desktop.project_auto_accept",
|
|
23
23
|
"desktop.mesh_snapshots",
|
|
24
24
|
"desktop.task_send",
|
|
@@ -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
|
}
|
|
@@ -10,6 +10,7 @@ import type { DesktopTaskActivityRunner } from "../task-activity-runner";
|
|
|
10
10
|
import type { EngineClient } from "../../../../bridge/src/engine/runtime/engine-client";
|
|
11
11
|
import type { EngineOperation } from "../../../../bridge/src/engine/protocol/engine-protocol";
|
|
12
12
|
import { desktopTaskSend } from "../delivery/task-send";
|
|
13
|
+
import { desktopTaskWorkspace } from "../workspace/task-files";
|
|
13
14
|
import { desktopTaskHandoff } from "../delivery/task-handoff";
|
|
14
15
|
import { desktopTaskCreate } from "../delivery/task-create";
|
|
15
16
|
import { desktopMeshCreate } from "../delivery/mesh-create";
|
|
@@ -54,6 +55,7 @@ export function desktopReadOperation(input: {
|
|
|
54
55
|
if (operation === "desktop.machine_load") return desktopMachineLoad();
|
|
55
56
|
if (operation === "desktop.task_activity") return activity.read(queryInput, storePath);
|
|
56
57
|
if (operation === "desktop.task_image") return activity.image(queryInput, storePath);
|
|
58
|
+
if (operation === "desktop.task_workspace") return Promise.resolve(desktopTaskWorkspace(queryInput, storePath));
|
|
57
59
|
if (operation === "desktop.task_send") return desktopTaskSend(queryInput, storePath);
|
|
58
60
|
if (operation === "desktop.task_handoff") return desktopTaskHandoff(queryInput, storePath);
|
|
59
61
|
if (operation === "desktop.task_create") return desktopTaskCreate(queryInput, storePath);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Task workspace reader
|
|
2
|
+
|
|
3
|
+
`desktopTaskWorkspace` admits a Project, Task and exact native Execution before
|
|
4
|
+
reading its recorded workspace through the shared `task-files` public entry.
|
|
5
|
+
It exposes paged directory listings and bounded UTF-8 previews, with no writes.
|
|
6
|
+
Tests live in `../models/tests/task-workspace.test.ts`.
|
|
7
|
+
|
|
8
|
+
This module uses the repository MIT License.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { configDir } from '../../../../bridge/src/config';
|
|
3
|
+
import { readStoreState } from '../../../../bridge/src/mesh/store/state';
|
|
4
|
+
import { readTaskWorkspace } from '../../../../bridge/src/task-files';
|
|
5
|
+
import type { SessionInfo } from '../../../../../packages/protocol/schema';
|
|
6
|
+
/** Native desktop requests must match the Task's recorded execution exactly. */
|
|
7
|
+
export function desktopTaskWorkspace(input: unknown, storePath = join(configDir(), 'project-mesh.json')) {
|
|
8
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
9
|
+
return undefined;
|
|
10
|
+
const query = input as Record<string, unknown>;
|
|
11
|
+
const { project_id: projectId, task_id: taskId, session_id: sessionId, mode, path, offset } = query;
|
|
12
|
+
if (typeof projectId !== 'string' || typeof taskId !== 'string' || typeof sessionId !== 'string'
|
|
13
|
+
|| !['directory', 'file'].includes(String(mode)) || typeof path !== 'string' || path.length > 4096
|
|
14
|
+
|| offset !== undefined && (!Number.isSafeInteger(offset) || Number(offset) < 0))
|
|
15
|
+
return undefined;
|
|
16
|
+
const loaded = readStoreState(storePath);
|
|
17
|
+
if (loaded.status !== 'ok')
|
|
18
|
+
return undefined;
|
|
19
|
+
const tasks = loaded.state.tasks.filter(t => t.taskId === taskId);
|
|
20
|
+
if (tasks.length !== 1 || tasks[0]?.projectId !== projectId)
|
|
21
|
+
return undefined;
|
|
22
|
+
const executions = loaded.state.executions.filter(e => e.taskId === taskId && e.sessionId === sessionId);
|
|
23
|
+
if (executions.length !== 1)
|
|
24
|
+
return undefined;
|
|
25
|
+
const execution = executions[0]!;
|
|
26
|
+
const session: SessionInfo = { sessionId, agent: execution.provider, cwd: execution.workspace,
|
|
27
|
+
state: 'idle', startedAt: execution.startedAt, lastActivityAt: execution.updatedAt ?? execution.startedAt, tokensSession: 0, tokensLastTurn: 0 };
|
|
28
|
+
const result = readTaskWorkspace(session, { mode: mode as 'directory' | 'file', path, offset: offset as number | undefined });
|
|
29
|
+
return result ? { operation: 'desktop.task_workspace', ...result } : undefined;
|
|
30
|
+
}
|
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.56
|
|
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.56\";\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.56";
|
|
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;
|
|
Binary file
|
|
Binary file
|
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
|
|
|
@@ -154,6 +154,7 @@ export const ProjectRepositoryGraph = z.object({
|
|
|
154
154
|
repositoryId: z.string().trim().min(1).max(512),
|
|
155
155
|
revision: z.string().trim().min(1).max(512),
|
|
156
156
|
weavatrixVersion: z.string().trim().min(1).max(64),
|
|
157
|
+
sourceRevision: z.string().trim().min(1).max(512).optional(),
|
|
157
158
|
analysisId: z.string().trim().min(1).max(128).optional(),
|
|
158
159
|
analysisStatus: z.enum(["COMPLETE", "INCOMPLETE", "UNAVAILABLE"]).optional(),
|
|
159
160
|
analysisErrorCode: z.string().regex(/^[A-Z_]{2,64}$/).optional(),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const scope = { sessionId: z.string().min(1).max(512), requestId: z.string().min(1).max(128) };
|
|
3
|
+
export const SessionWorkspaceQuery = z.object({ type: z.literal('session.workspace.query'), ...scope,
|
|
4
|
+
mode: z.enum(['directory', 'file']), path: z.string().max(4096), offset: z.number().int().nonnegative().optional(),
|
|
5
|
+
createdAt: z.number().finite() }).strict();
|
|
6
|
+
export type SessionWorkspaceQuery = z.infer<typeof SessionWorkspaceQuery>;
|
|
7
|
+
export const SessionWorkspaceResult = z.object({ type: z.literal('session.workspace.result'), ...scope,
|
|
8
|
+
path: z.string().max(4096), entries: z.array(z.object({ path: z.string().max(4096), name: z.string().max(1024),
|
|
9
|
+
kind: z.enum(['directory', 'file']), size: z.number().int().nonnegative().optional() }).strict()).max(200).optional(),
|
|
10
|
+
text: z.string().max(128 * 1024).optional(), truncated: z.boolean(),
|
|
11
|
+
nextOffset: z.number().int().nonnegative().optional(), unavailable: z.boolean().optional(),
|
|
12
|
+
createdAt: z.number().finite() }).strict();
|
|
13
|
+
export type SessionWorkspaceResult = z.infer<typeof SessionWorkspaceResult>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Public encrypted wire protocol entry point. */
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { SessionImageQuery, SessionImageChunk } from './messages/session-images';
|
|
4
|
+
import { SessionWorkspaceQuery, SessionWorkspaceResult } from './messages/sessions/workspace';
|
|
5
|
+
export { SessionWorkspaceQuery, SessionWorkspaceResult } from './messages/sessions/workspace';
|
|
4
6
|
export { SessionImageQuery, SessionImageChunk } from './messages/session-images';
|
|
5
7
|
import { ProviderHookTrust, ProviderHookTrustResult } from "./messages/provider-hooks";
|
|
6
8
|
import {
|
|
@@ -126,6 +128,8 @@ export const Payload = z.union([
|
|
|
126
128
|
SessionActivity,
|
|
127
129
|
SessionImageQuery,
|
|
128
130
|
SessionImageChunk,
|
|
131
|
+
SessionWorkspaceQuery,
|
|
132
|
+
SessionWorkspaceResult,
|
|
129
133
|
CapabilityUsageStatus,
|
|
130
134
|
SessionKeyGrant,
|
|
131
135
|
SessionSealed,
|
|
@@ -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
|
-
}
|