@fitchmultz/pi-subagents 0.47.0 → 0.49.0
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/CHANGELOG.md +29 -3
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +15 -3
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +195 -27
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -66
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +39 -39
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +315 -174
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +213 -38
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +20 -3
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +291 -49
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +98 -96
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +41 -49
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { nativePeerId } from "./session-targets.ts";
|
|
2
|
+
import { closeSync, constants, lstatSync, opendirSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { setImmediate as yieldTurn } from "node:timers/promises";
|
|
5
|
+
import type * as NativeSession from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { JournalFrames } from "../shared/journal-reader.ts";
|
|
7
|
+
import { loadNativeSession } from "../shared/native-session-loader.ts";
|
|
8
|
+
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
9
|
+
import { Fault, cancelled } from "./bridge-protocol.ts";
|
|
10
|
+
import type { SessionInfo } from "./types.ts";
|
|
11
|
+
import { resolveIntercomPresenceName } from "./identity.ts";
|
|
12
|
+
import { within, type LaunchPolicy } from "./bridge-launch-policy.ts";
|
|
13
|
+
|
|
14
|
+
export interface SavedSession {
|
|
15
|
+
readonly sessionId: string;
|
|
16
|
+
readonly peerId: string;
|
|
17
|
+
readonly name?: string;
|
|
18
|
+
readonly cwd: string;
|
|
19
|
+
readonly sessionFile: string;
|
|
20
|
+
readonly lastSeen: number;
|
|
21
|
+
}
|
|
22
|
+
export interface CatalogQuery {
|
|
23
|
+
readonly live: boolean;
|
|
24
|
+
readonly saved: boolean;
|
|
25
|
+
readonly q?: string;
|
|
26
|
+
readonly offset: number;
|
|
27
|
+
readonly limit: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Reject links at every journal path component; ordinary owned 0644 journals are valid. */
|
|
31
|
+
export function trustedJournal(policy: LaunchPolicy, file: string): void {
|
|
32
|
+
const root = [policy.sessionDir, ...policy.resumeDirs].find((dir) => within(dir, file));
|
|
33
|
+
if (root === undefined) {
|
|
34
|
+
throw new Fault(403, "journal_forbidden", "Session journal is outside trusted resumeDirs.");
|
|
35
|
+
}
|
|
36
|
+
let path = root;
|
|
37
|
+
for (const part of ["", ...relative(root, file).split(sep).filter(Boolean)]) {
|
|
38
|
+
path = join(path, part);
|
|
39
|
+
const stat = lstatSync(path);
|
|
40
|
+
if (
|
|
41
|
+
stat.isSymbolicLink() ||
|
|
42
|
+
stat.uid !== process.getuid?.() ||
|
|
43
|
+
(!stat.isDirectory() && path !== file)
|
|
44
|
+
) {
|
|
45
|
+
throw new Fault(
|
|
46
|
+
403,
|
|
47
|
+
"journal_forbidden",
|
|
48
|
+
"Session journal must have an owned, link-free path.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!lstatSync(file).isFile()) {
|
|
53
|
+
throw new Fault(403, "journal_forbidden", "Session journal must be an owned regular file.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Reads and projection input are consumed synchronously; prefix/UTF-8 carry never retains this view.
|
|
57
|
+
const METADATA_BYTES = Buffer.allocUnsafe(1024 * 1024);
|
|
58
|
+
const METADATA_FIELDS = new Set(["type", "id", "version", "timestamp", "cwd", "name", "parentId"]);
|
|
59
|
+
// ponytail: fast-skip native type-first envelopes only; other key ordering
|
|
60
|
+
// uses the bounded parser. Extend the fast path if native serialization changes.
|
|
61
|
+
const BODY_ENTRY =
|
|
62
|
+
/^\{"type":"(?:message|model_change|thinking_level_change|compaction|branch_summary|custom|custom_message|label|usage|context_edit)",/;
|
|
63
|
+
class MetadataFrames {
|
|
64
|
+
private prefix = Buffer.alloc(0);
|
|
65
|
+
private skip: boolean | undefined;
|
|
66
|
+
private readonly frames: JournalFrames;
|
|
67
|
+
private readonly nativeHeader: () => boolean;
|
|
68
|
+
constructor(frames: JournalFrames, nativeHeader: () => boolean) {
|
|
69
|
+
this.frames = frames;
|
|
70
|
+
this.nativeHeader = nativeHeader;
|
|
71
|
+
}
|
|
72
|
+
private part(bytes: Buffer, complete: boolean): void {
|
|
73
|
+
let remainder = bytes;
|
|
74
|
+
if (this.skip === undefined) {
|
|
75
|
+
const count = Math.min(64 - this.prefix.length, bytes.length);
|
|
76
|
+
this.prefix = Buffer.concat([this.prefix, bytes.subarray(0, count)]);
|
|
77
|
+
remainder = bytes.subarray(count);
|
|
78
|
+
if (this.prefix.length < 64 && !complete) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.skip = this.nativeHeader() && BODY_ENTRY.test(this.prefix.toString("ascii"));
|
|
82
|
+
if (!this.skip) {
|
|
83
|
+
this.frames.write(this.prefix);
|
|
84
|
+
}
|
|
85
|
+
this.prefix = Buffer.alloc(0);
|
|
86
|
+
}
|
|
87
|
+
if (!this.skip) {
|
|
88
|
+
this.frames.write(remainder);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
write(bytes: Buffer): void {
|
|
92
|
+
for (let from = 0; from < bytes.length;) {
|
|
93
|
+
const newline = bytes.indexOf(10, from);
|
|
94
|
+
const end = newline < 0 ? bytes.length : newline + 1;
|
|
95
|
+
this.part(bytes.subarray(from, end), newline >= 0);
|
|
96
|
+
if (newline >= 0) {
|
|
97
|
+
this.skip = undefined;
|
|
98
|
+
}
|
|
99
|
+
from = end;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
finish(): void {
|
|
103
|
+
if (this.prefix.length > 0) {
|
|
104
|
+
this.part(Buffer.alloc(0), true);
|
|
105
|
+
}
|
|
106
|
+
this.frames.finish();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function readMetadata(
|
|
110
|
+
file: string,
|
|
111
|
+
sdk: typeof NativeSession,
|
|
112
|
+
signal: AbortSignal,
|
|
113
|
+
mode: "header" | "published" | "resume",
|
|
114
|
+
): Promise<Omit<SavedSession, "lastSeen">> {
|
|
115
|
+
const headerOnly = mode === "header";
|
|
116
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
117
|
+
try {
|
|
118
|
+
const metadata: UnknownRecord[] = [];
|
|
119
|
+
let first = true;
|
|
120
|
+
const frames = new JournalFrames(
|
|
121
|
+
(path) => path.length === 0 || (path.length === 1 && METADATA_FIELDS.has(String(path[0]))),
|
|
122
|
+
({ value }) => {
|
|
123
|
+
if (
|
|
124
|
+
first &&
|
|
125
|
+
(value.type !== "session" ||
|
|
126
|
+
typeof value.id !== "string" ||
|
|
127
|
+
typeof value.cwd !== "string")
|
|
128
|
+
) {
|
|
129
|
+
throw new Error("Not a native session journal");
|
|
130
|
+
}
|
|
131
|
+
first = false;
|
|
132
|
+
if (value.type === "session" && metadata.length === 0) {
|
|
133
|
+
metadata.push(value);
|
|
134
|
+
} else if (value.type === "session_info") {
|
|
135
|
+
metadata[1] = value;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{ policy: "inspect", requireNewline: mode !== "resume" },
|
|
139
|
+
);
|
|
140
|
+
const bytes = headerOnly ? METADATA_BYTES.subarray(0, 64 * 1024) : METADATA_BYTES;
|
|
141
|
+
const reader = new MetadataFrames(frames, () => !first);
|
|
142
|
+
for (;;) {
|
|
143
|
+
cancelled(signal);
|
|
144
|
+
// One shared file position/buffer: reads must be serial, and yield to cancellation.
|
|
145
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
146
|
+
await yieldTurn(undefined, { signal });
|
|
147
|
+
const bytesRead = readSync(fd, bytes, 0, bytes.length, null);
|
|
148
|
+
if (bytesRead === 0) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
reader.write(bytes.subarray(0, bytesRead));
|
|
152
|
+
if (headerOnly && metadata.length > 0) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
reader.finish();
|
|
157
|
+
const header = metadata.at(0);
|
|
158
|
+
if (
|
|
159
|
+
header?.type !== "session" ||
|
|
160
|
+
typeof header.id !== "string" ||
|
|
161
|
+
typeof header.cwd !== "string"
|
|
162
|
+
) {
|
|
163
|
+
throw new Error("Not a native session journal");
|
|
164
|
+
}
|
|
165
|
+
// Native naming and ID semantics, without opening/migrating a live journal or hydrating bodies.
|
|
166
|
+
const entries = sdk.parseSessionEntries(
|
|
167
|
+
metadata.map((entry) => JSON.stringify(entry)).join("\n"),
|
|
168
|
+
);
|
|
169
|
+
const manager = sdk.SessionManager.inMemory(header.cwd, undefined, entries);
|
|
170
|
+
return {
|
|
171
|
+
sessionId: manager.getSessionId(),
|
|
172
|
+
peerId: nativePeerId(manager.getSessionId()),
|
|
173
|
+
name: manager.getSessionName(),
|
|
174
|
+
cwd: manager.getCwd(),
|
|
175
|
+
sessionFile: file,
|
|
176
|
+
};
|
|
177
|
+
} finally {
|
|
178
|
+
closeSync(fd);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Native open repairs EOF framing; inspect its identity before allowing that write. */
|
|
183
|
+
export function inspectResumeIdentity(
|
|
184
|
+
file: string,
|
|
185
|
+
sdk: typeof NativeSession,
|
|
186
|
+
signal: AbortSignal,
|
|
187
|
+
): Promise<Pick<SavedSession, "sessionId" | "cwd" | "name">> {
|
|
188
|
+
return readMetadata(file, sdk, signal, "resume");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function savedView(row: SavedSession, peer?: SessionInfo): UnknownRecord {
|
|
192
|
+
return {
|
|
193
|
+
id: row.sessionId,
|
|
194
|
+
sessionId: row.sessionId,
|
|
195
|
+
peerId: peer?.id,
|
|
196
|
+
name: row.name,
|
|
197
|
+
cwd: row.cwd,
|
|
198
|
+
status: peer?.status ?? "saved",
|
|
199
|
+
lastSeen: peer?.lastSeen ?? row.lastSeen,
|
|
200
|
+
saved: true,
|
|
201
|
+
owned: false,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export function exactPeer(
|
|
205
|
+
row: SavedSession,
|
|
206
|
+
peers: readonly SessionInfo[],
|
|
207
|
+
): SessionInfo | undefined {
|
|
208
|
+
const expectedName = resolveIntercomPresenceName(row.name, row.sessionId);
|
|
209
|
+
const peer = peers.find((candidate) => candidate.id === row.peerId);
|
|
210
|
+
if (peer && (peer.cwd !== row.cwd || peer.name !== expectedName)) {
|
|
211
|
+
throw new Fault(
|
|
212
|
+
409,
|
|
213
|
+
"active_session",
|
|
214
|
+
"Native session has a live peer with changed identity; do not reopen its journal.",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (
|
|
218
|
+
!peer &&
|
|
219
|
+
peers.some(
|
|
220
|
+
(candidate) =>
|
|
221
|
+
!candidate.id.startsWith("pi-") &&
|
|
222
|
+
candidate.name === expectedName &&
|
|
223
|
+
candidate.cwd === row.cwd,
|
|
224
|
+
)
|
|
225
|
+
) {
|
|
226
|
+
throw new Fault(
|
|
227
|
+
409,
|
|
228
|
+
"active_session",
|
|
229
|
+
"A matching live peer has unproven native identity; refusing to reopen its possible journal.",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return peer;
|
|
233
|
+
}
|
|
234
|
+
interface BrowseContext {
|
|
235
|
+
readonly policy?: LaunchPolicy;
|
|
236
|
+
readonly signal: AbortSignal;
|
|
237
|
+
readonly peers: (signal: AbortSignal) => Promise<readonly SessionInfo[]>;
|
|
238
|
+
readonly activeState: (id: string) => string | undefined;
|
|
239
|
+
}
|
|
240
|
+
function liveView(peer: SessionInfo, state?: string): UnknownRecord {
|
|
241
|
+
return {
|
|
242
|
+
id: peer.id,
|
|
243
|
+
peerId: peer.id,
|
|
244
|
+
name: peer.name,
|
|
245
|
+
cwd: peer.cwd,
|
|
246
|
+
status: state ?? peer.status,
|
|
247
|
+
lastSeen: peer.lastSeen,
|
|
248
|
+
saved: false,
|
|
249
|
+
owned: state !== undefined,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export async function browseSessions(
|
|
253
|
+
catalog: SessionCatalog,
|
|
254
|
+
query: CatalogQuery,
|
|
255
|
+
context: BrowseContext,
|
|
256
|
+
): Promise<UnknownRecord> {
|
|
257
|
+
const peers = query.live ? await context.peers(context.signal) : [];
|
|
258
|
+
const { policy } = context;
|
|
259
|
+
if (query.saved && !policy) {
|
|
260
|
+
throw new Fault(403, "launch_disabled", "Saved sessions require local launch/resume policy.");
|
|
261
|
+
}
|
|
262
|
+
const page =
|
|
263
|
+
policy && query.saved
|
|
264
|
+
? await catalog.page(policy, query, context.signal)
|
|
265
|
+
: { sessions: [], nextOffset: undefined };
|
|
266
|
+
const rows = page.sessions.map((row) => {
|
|
267
|
+
const state = context.activeState(row.sessionId);
|
|
268
|
+
const peer = exactPeer(row, peers);
|
|
269
|
+
return {
|
|
270
|
+
...savedView(row, peer),
|
|
271
|
+
status: state ?? peer?.status ?? "saved",
|
|
272
|
+
owned: state !== undefined,
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
const included = new Set(page.sessions.map((row) => row.peerId));
|
|
276
|
+
const live = peers.filter(
|
|
277
|
+
(peer) =>
|
|
278
|
+
!included.has(peer.id) &&
|
|
279
|
+
(query.q === undefined ||
|
|
280
|
+
`${peer.id} ${peer.name ?? ""} ${peer.cwd}`.toLowerCase().includes(query.q.toLowerCase())),
|
|
281
|
+
);
|
|
282
|
+
if (!query.saved) {
|
|
283
|
+
const end = query.offset + query.limit;
|
|
284
|
+
return {
|
|
285
|
+
sessions: live
|
|
286
|
+
.slice(query.offset, end)
|
|
287
|
+
.map((peer) => liveView(peer, context.activeState(peer.id))),
|
|
288
|
+
nextOffset: end < live.length ? end : undefined,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
sessions: [
|
|
293
|
+
...rows,
|
|
294
|
+
...live
|
|
295
|
+
.slice(0, query.limit - rows.length)
|
|
296
|
+
.map((peer) => liveView(peer, context.activeState(peer.id))),
|
|
297
|
+
],
|
|
298
|
+
liveTruncated: rows.length + live.length > query.limit ? true : undefined,
|
|
299
|
+
nextOffset: page.nextOffset,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function uniqueSavedSession(matches: readonly SavedSession[]): SavedSession {
|
|
304
|
+
if (matches.length > 1) {
|
|
305
|
+
throw new Fault(
|
|
306
|
+
409,
|
|
307
|
+
"ambiguous_session",
|
|
308
|
+
"Multiple saved sessions match; choose an exact unique native ID.",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const session = matches.at(0);
|
|
312
|
+
if (!session) {
|
|
313
|
+
throw new Fault(
|
|
314
|
+
404,
|
|
315
|
+
"unknown_session",
|
|
316
|
+
"No saved native session matches within trusted resumeDirs.",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return session;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export class SessionCatalog {
|
|
323
|
+
private readonly cache = new Map<
|
|
324
|
+
string,
|
|
325
|
+
{ readonly stamp: string; readonly session: SavedSession }
|
|
326
|
+
>();
|
|
327
|
+
private readonly headers = new Map<
|
|
328
|
+
string,
|
|
329
|
+
{ readonly stamp: string; readonly session: SavedSession }
|
|
330
|
+
>();
|
|
331
|
+
|
|
332
|
+
private async *walk(dir: string, signal: AbortSignal): AsyncGenerator<string> {
|
|
333
|
+
await yieldTurn(undefined, { signal });
|
|
334
|
+
const stat = lstatSync(dir);
|
|
335
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid?.()) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const directory = opendirSync(dir);
|
|
339
|
+
let visited = 0;
|
|
340
|
+
try {
|
|
341
|
+
for (let row = directory.readSync(); row !== null; row = directory.readSync()) {
|
|
342
|
+
// Yield during offset skips and empty-directory traversal too.
|
|
343
|
+
if (visited++ % 32 === 0) {
|
|
344
|
+
// Directory entries must be processed without retaining a corpus-sized array.
|
|
345
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
346
|
+
await yieldTurn(undefined, { signal });
|
|
347
|
+
}
|
|
348
|
+
cancelled(signal);
|
|
349
|
+
const file = join(dir, row.name);
|
|
350
|
+
if (row.isDirectory()) {
|
|
351
|
+
yield* this.walk(file, signal);
|
|
352
|
+
} else if (row.isFile() && row.name.endsWith(".jsonl")) {
|
|
353
|
+
yield file;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} finally {
|
|
357
|
+
directory.closeSync();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
private async *files(policy: LaunchPolicy, signal: AbortSignal): AsyncGenerator<string> {
|
|
361
|
+
const unique = [...new Set([policy.sessionDir, ...policy.resumeDirs])];
|
|
362
|
+
const roots = unique.filter(
|
|
363
|
+
(root) => !unique.some((other) => other !== root && within(other, root)),
|
|
364
|
+
);
|
|
365
|
+
for (const root of roots) {
|
|
366
|
+
yield* this.walk(root, signal);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async read(
|
|
370
|
+
policy: LaunchPolicy,
|
|
371
|
+
file: string,
|
|
372
|
+
signal: AbortSignal,
|
|
373
|
+
headerOnly = false,
|
|
374
|
+
): Promise<SavedSession | undefined> {
|
|
375
|
+
cancelled(signal);
|
|
376
|
+
try {
|
|
377
|
+
trustedJournal(policy, file);
|
|
378
|
+
const stat = lstatSync(file);
|
|
379
|
+
const stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
380
|
+
const cache = headerOnly ? this.headers : this.cache;
|
|
381
|
+
const cached = this.cache.get(file) ?? cache.get(file);
|
|
382
|
+
if (cached?.stamp === stamp) {
|
|
383
|
+
return cached.session;
|
|
384
|
+
}
|
|
385
|
+
const sdk = await loadNativeSession(policy.packageRoot);
|
|
386
|
+
cancelled(signal);
|
|
387
|
+
const session = {
|
|
388
|
+
...(await readMetadata(file, sdk, signal, headerOnly ? "header" : "published")),
|
|
389
|
+
lastSeen: stat.mtimeMs,
|
|
390
|
+
};
|
|
391
|
+
if (cache.size >= 100000) {
|
|
392
|
+
cache.clear();
|
|
393
|
+
}
|
|
394
|
+
cache.set(file, { stamp, session });
|
|
395
|
+
return session;
|
|
396
|
+
} catch (error) {
|
|
397
|
+
cancelled(signal);
|
|
398
|
+
if (error instanceof Fault) {
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async resolve(
|
|
405
|
+
policy: LaunchPolicy,
|
|
406
|
+
selector: { readonly sessionId?: string; readonly name?: string; readonly target?: string },
|
|
407
|
+
signal: AbortSignal,
|
|
408
|
+
): Promise<SavedSession> {
|
|
409
|
+
if (selector.target !== undefined) {
|
|
410
|
+
return this.resolveTarget(policy, selector.target, signal);
|
|
411
|
+
}
|
|
412
|
+
const matches: SavedSession[] = [];
|
|
413
|
+
for await (const file of this.files(policy, signal)) {
|
|
414
|
+
// Inspect native metadata, not a filename/prefix guess. Yield so request cancellation can run.
|
|
415
|
+
await yieldTurn(undefined, { signal });
|
|
416
|
+
const row = await this.read(policy, file, signal, selector.sessionId !== undefined);
|
|
417
|
+
if (
|
|
418
|
+
row &&
|
|
419
|
+
(selector.sessionId === undefined
|
|
420
|
+
? row.name === selector.name
|
|
421
|
+
: row.sessionId === selector.sessionId || row.peerId === selector.sessionId)
|
|
422
|
+
) {
|
|
423
|
+
matches.push(row);
|
|
424
|
+
}
|
|
425
|
+
if (matches.length > 1) {
|
|
426
|
+
throw new Fault(
|
|
427
|
+
409,
|
|
428
|
+
"ambiguous_session",
|
|
429
|
+
"Multiple saved sessions match; choose an exact unique native ID.",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const session = uniqueSavedSession(matches);
|
|
434
|
+
return (await this.read(policy, session.sessionFile, signal)) ?? session;
|
|
435
|
+
}
|
|
436
|
+
private async resolveTarget(
|
|
437
|
+
policy: LaunchPolicy,
|
|
438
|
+
target: string,
|
|
439
|
+
signal: AbortSignal,
|
|
440
|
+
): Promise<SavedSession> {
|
|
441
|
+
const ids: SavedSession[] = [];
|
|
442
|
+
const names: SavedSession[] = [];
|
|
443
|
+
for await (const file of this.files(policy, signal)) {
|
|
444
|
+
// Once an exact native ID is proven, only headers can affect its uniqueness.
|
|
445
|
+
const row = await this.read(policy, file, signal, ids.length > 0);
|
|
446
|
+
if (!row) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if ((row.sessionId === target || row.peerId === target) && ids.length < 2) {
|
|
450
|
+
ids.push(row);
|
|
451
|
+
}
|
|
452
|
+
if (row.name === target && names.length < 2) {
|
|
453
|
+
names.push(row);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Preserve exact-ID priority without paying for two complete archive scans.
|
|
457
|
+
const session = uniqueSavedSession(ids.length > 0 ? ids : names);
|
|
458
|
+
return (await this.read(policy, session.sessionFile, signal)) ?? session;
|
|
459
|
+
}
|
|
460
|
+
async page(
|
|
461
|
+
policy: LaunchPolicy,
|
|
462
|
+
query: CatalogQuery,
|
|
463
|
+
signal: AbortSignal,
|
|
464
|
+
): Promise<{ readonly sessions: SavedSession[]; readonly nextOffset?: number }> {
|
|
465
|
+
const sessions: SavedSession[] = [];
|
|
466
|
+
let offset = 0;
|
|
467
|
+
let scanned = 0;
|
|
468
|
+
for await (const file of this.files(policy, signal)) {
|
|
469
|
+
if (offset++ < query.offset) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (scanned++ >= 1000 || sessions.length >= query.limit) {
|
|
473
|
+
return { sessions, nextOffset: offset - 1 };
|
|
474
|
+
}
|
|
475
|
+
// Each bounded metadata read yields to cancellation; no whole-corpus pathname array.
|
|
476
|
+
const row = await this.read(policy, file, signal);
|
|
477
|
+
if (
|
|
478
|
+
row &&
|
|
479
|
+
(query.q === undefined ||
|
|
480
|
+
`${row.sessionId} ${row.name ?? ""} ${row.cwd}`
|
|
481
|
+
.toLowerCase()
|
|
482
|
+
.includes(query.q.toLowerCase()))
|
|
483
|
+
) {
|
|
484
|
+
sessions.push(row);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return { sessions };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -5,7 +5,7 @@ import { parseEnv } from "node:util";
|
|
|
5
5
|
import { isUnknownArray } from "../shared/unknown.ts";
|
|
6
6
|
import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.ts";
|
|
7
7
|
import { launchPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
|
|
8
|
-
import { stateDirectory } from "./bridge-
|
|
8
|
+
import { stateDirectory } from "./bridge-storage.ts";
|
|
9
9
|
import { readBridgeFile } from "./bridge-files.ts";
|
|
10
10
|
|
|
11
11
|
const MAX_CLIENTS = 64;
|
|
@@ -87,11 +87,28 @@ function identities(value: unknown): Map<string, Identity> {
|
|
|
87
87
|
return clients;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
function configInput(path: string): Record<string, unknown> {
|
|
91
91
|
const raw: unknown = JSON.parse(
|
|
92
92
|
new TextDecoder("utf-8", { fatal: true }).decode(readBridgeFile(path, true, BODY_LIMIT)),
|
|
93
93
|
);
|
|
94
|
-
|
|
94
|
+
return object(raw, ["port", "ca", "cert", "key", "clients", "stateDir"]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Recipient admission reads private policy and the public CA, never the server key. */
|
|
98
|
+
export function loadControlPolicy(path: string): {
|
|
99
|
+
readonly clients: ReadonlyMap<string, Identity>;
|
|
100
|
+
readonly ca: Buffer;
|
|
101
|
+
} {
|
|
102
|
+
const input = configInput(path);
|
|
103
|
+
const ca = label(input.ca, "ca path", 4096);
|
|
104
|
+
if (!isAbsolute(ca)) {
|
|
105
|
+
throw new Error("CA path must be absolute.");
|
|
106
|
+
}
|
|
107
|
+
return { clients: identities(input.clients), ca: readBridgeFile(ca, false, 1024 * 1024) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function loadConfig(path: string): Config {
|
|
111
|
+
const input = configInput(path);
|
|
95
112
|
const port = input.port === undefined ? 9443 : input.port;
|
|
96
113
|
if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) {
|
|
97
114
|
throw new Error("Invalid HTTPS port.");
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Message } from "./types.ts";
|
|
3
|
+
import { Fault, wait } from "./bridge-protocol.ts";
|
|
4
|
+
import type { IntercomClient } from "./broker/client.ts";
|
|
5
|
+
import type { TranscriptOptions } from "./transcript.ts";
|
|
6
|
+
import {
|
|
7
|
+
CONTROL_FALLBACK,
|
|
8
|
+
CONTROL_SCHEMA,
|
|
9
|
+
isControlResult,
|
|
10
|
+
type NativeControlResult,
|
|
11
|
+
type NativeControlVerb,
|
|
12
|
+
} from "./native-control-protocol.ts";
|
|
13
|
+
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
14
|
+
|
|
15
|
+
interface Pending {
|
|
16
|
+
readonly peer: string;
|
|
17
|
+
readonly verb: NativeControlVerb;
|
|
18
|
+
readonly reply: Promise<NativeControlResult>;
|
|
19
|
+
readonly resolve: (result: NativeControlResult) => void;
|
|
20
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
21
|
+
readonly timer: NodeJS.Timeout;
|
|
22
|
+
disconnected: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** One bounded request owner; exact socket-derived peer, replyTo, version and verb correlation. */
|
|
25
|
+
export class ControlReplies {
|
|
26
|
+
private readonly pending = new Map<string, Pending>();
|
|
27
|
+
private open(
|
|
28
|
+
peer: string,
|
|
29
|
+
verb: NativeControlVerb,
|
|
30
|
+
timeoutMs: number,
|
|
31
|
+
): {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly reply: Promise<NativeControlResult>;
|
|
34
|
+
readonly clear: () => void;
|
|
35
|
+
readonly observedDisconnected: boolean;
|
|
36
|
+
} {
|
|
37
|
+
if (this.pending.size >= 8) {
|
|
38
|
+
throw new Fault(429, "too_many_controls", "At most eight native controls may be in flight.");
|
|
39
|
+
}
|
|
40
|
+
const id = randomUUID();
|
|
41
|
+
const { promise: reply, resolve, reject } = Promise.withResolvers<NativeControlResult>();
|
|
42
|
+
// A disconnect may reject while send is awaiting its broker receipt.
|
|
43
|
+
void reply.catch(() => {
|
|
44
|
+
/* Request owner observes rejection after its send receipt. */
|
|
45
|
+
});
|
|
46
|
+
const timer = setTimeout(
|
|
47
|
+
() =>
|
|
48
|
+
reject(
|
|
49
|
+
new Fault(
|
|
50
|
+
504,
|
|
51
|
+
"control_unconfirmed",
|
|
52
|
+
"No correlated native response. Owner may be old/unavailable; execution is unknown. Do not automatically retry.",
|
|
53
|
+
{ id, deliveryUnknown: true },
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
timeoutMs,
|
|
57
|
+
);
|
|
58
|
+
const pending = { peer, verb, reply, resolve, reject, timer, disconnected: false };
|
|
59
|
+
this.pending.set(id, pending);
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
reply,
|
|
63
|
+
clear: () => this.clear(id),
|
|
64
|
+
get observedDisconnected() {
|
|
65
|
+
return pending.disconnected;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async request(
|
|
70
|
+
client: Readonly<Pick<IntercomClient, "send">>,
|
|
71
|
+
operation: {
|
|
72
|
+
readonly peer: string;
|
|
73
|
+
readonly verb: NativeControlVerb;
|
|
74
|
+
readonly options?: TranscriptOptions;
|
|
75
|
+
readonly certificate: string;
|
|
76
|
+
readonly timeoutMs: number;
|
|
77
|
+
},
|
|
78
|
+
context: {
|
|
79
|
+
readonly signal: AbortSignal;
|
|
80
|
+
readonly guard: () => void;
|
|
81
|
+
readonly onPeer: (peer: string) => void;
|
|
82
|
+
},
|
|
83
|
+
): Promise<UnknownRecord> {
|
|
84
|
+
context.onPeer(operation.peer);
|
|
85
|
+
const waiting = this.open(operation.peer, operation.verb, operation.timeoutMs);
|
|
86
|
+
try {
|
|
87
|
+
context.guard();
|
|
88
|
+
const receipt = await wait(
|
|
89
|
+
client.send(operation.peer, {
|
|
90
|
+
text: CONTROL_FALLBACK,
|
|
91
|
+
messageId: waiting.id,
|
|
92
|
+
delivery: "passive",
|
|
93
|
+
control: {
|
|
94
|
+
schema: CONTROL_SCHEMA,
|
|
95
|
+
kind: "request",
|
|
96
|
+
verb: operation.verb,
|
|
97
|
+
certificate: operation.certificate,
|
|
98
|
+
options: operation.options,
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
context.signal,
|
|
102
|
+
).catch((error: unknown) => {
|
|
103
|
+
context.guard();
|
|
104
|
+
if (error instanceof Fault) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
throw new Fault(
|
|
108
|
+
504,
|
|
109
|
+
"control_unconfirmed",
|
|
110
|
+
"Broker receipt was not received; native execution is unknown. Do not automatically retry.",
|
|
111
|
+
{ id: waiting.id, deliveryUnknown: true },
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
context.guard();
|
|
115
|
+
if (!receipt.accepted) {
|
|
116
|
+
throw new Fault(
|
|
117
|
+
409,
|
|
118
|
+
"delivery_failed",
|
|
119
|
+
receipt.reason ?? "Broker rejected native control.",
|
|
120
|
+
{ id: receipt.id },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const result = await wait(waiting.reply, context.signal);
|
|
124
|
+
context.guard();
|
|
125
|
+
return {
|
|
126
|
+
...receipt,
|
|
127
|
+
peer: operation.peer,
|
|
128
|
+
status: result.status,
|
|
129
|
+
native: result,
|
|
130
|
+
observedDisconnected: waiting.observedDisconnected,
|
|
131
|
+
processExited: false,
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
waiting.clear();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
receive(peer: string, message: Message): boolean {
|
|
138
|
+
if (!Object.hasOwn(message, "control")) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const pending = message.replyTo === undefined ? undefined : this.pending.get(message.replyTo);
|
|
142
|
+
if (
|
|
143
|
+
pending &&
|
|
144
|
+
pending.peer === peer &&
|
|
145
|
+
message.delivery === "passive" &&
|
|
146
|
+
isControlResult(message.control) &&
|
|
147
|
+
message.control.verb === pending.verb
|
|
148
|
+
) {
|
|
149
|
+
pending.resolve(message.control);
|
|
150
|
+
}
|
|
151
|
+
// Unsolicited/spoofed/malformed control results do not enter the remote mailbox.
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
peerLeft(peer: string): void {
|
|
155
|
+
for (const [id, pending] of this.pending) {
|
|
156
|
+
if (pending.peer === peer) {
|
|
157
|
+
pending.disconnected = true;
|
|
158
|
+
pending.reject(
|
|
159
|
+
new Fault(
|
|
160
|
+
409,
|
|
161
|
+
"peer_disconnected",
|
|
162
|
+
"Native peer disconnected; this does not establish process exit.",
|
|
163
|
+
{ id, observedDisconnected: true, processExited: false, deliveryUnknown: true },
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
close(error: Readonly<Error>): void {
|
|
170
|
+
for (const [id, pending] of this.pending) {
|
|
171
|
+
pending.reject(error);
|
|
172
|
+
this.clear(id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
private clear(id: string): void {
|
|
176
|
+
clearTimeout(this.pending.get(id)?.timer);
|
|
177
|
+
this.pending.delete(id);
|
|
178
|
+
}
|
|
179
|
+
}
|