@fitchmultz/pi-subagents 0.46.2 → 0.48.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 +25 -0
- package/README.md +1 -1
- 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 +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -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 +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- 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 +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- 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 +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -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 +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.48.0] - 2026-10-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Remote live/saved session discovery, workspace-aware spawn with task/model selection, and resume by stable ID or unique name. Reuse discoverable live owners rather than reopening their journal; retain native model selection and history on resume. Owned cancellation preserves accepted queues, while owned stop observes actual process closure.
|
|
8
|
+
- Certificate-authenticated native control and bounded recent transcripts for updated live Pi owners, plus read-only saved transcripts. Foreign cancellation/shutdown receipts describe the requested native stage and mode limitations; they do not claim process exit or terminate detached work.
|
|
9
|
+
- Persistent named peer groups and broadcasts with per-recipient receipts, explicit partial outcomes and no automatic mutation retries.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Preserve confirmed, unknown and unattempted broadcast outcomes when the broker stalls or disconnects, without disclosing partial results after identity revocation or certificate expiry.
|
|
14
|
+
- Inspect native resume identity before opening a writable journal so rejected requests cannot publish an incomplete tail. Saved transcripts tolerate interrupted malformed records while retaining structural limits.
|
|
15
|
+
- Reduce archive lookup allocation churn with shared bounded scratch storage, and switch to header-only uniqueness checks after a verified exact native-ID match.
|
|
16
|
+
|
|
17
|
+
## [0.47.0] - 2026-10-13
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Unattended remote Intercom: private durable mailboxes with bounded cursor drains, persistent authenticated presence, and optional HTTPS fallback wake after two minutes without consumer acknowledgement. Helper crashes, idle leases and broker outages retain unread messages and unanswered asks. Consumer acknowledgement suppresses fallback without discarding reply obligations.
|
|
22
|
+
- Client provisioning supports distinct bridge ports, configurable certificate lifetimes and same-CA server renewal for independent instances.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Preserve local identity policies during certificate rotation, and prevent consumer-acknowledged inbound asks from generating redundant fallback wakes.
|
|
27
|
+
|
|
3
28
|
## [0.46.2] - 2026-10-12
|
|
4
29
|
|
|
5
30
|
### Changed
|
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ Children normally inherit Pi's tools, extensions, project instructions and skill
|
|
|
79
79
|
|
|
80
80
|
Intercom is included. It lets Pi sessions exchange messages and lets a child ask its parent for a decision.
|
|
81
81
|
|
|
82
|
-
Read the [Intercom guide](docs/intercom.md) for setup and host limits. The [secure bridge](docs/intercom-bridge.md)
|
|
82
|
+
Read the [Intercom guide](docs/intercom.md) for setup and host limits. The [secure bridge](docs/intercom-bridge.md) adds unattended remote messaging, durable inboxes, groups, live/saved session discovery, spawn/resume and native controls for an authenticated remote agent.
|
|
83
83
|
|
|
84
84
|
Indexed history requires SQLite 3.53.4 through Node's built-in `node:sqlite` binding. Other SQLite versions fail. See the [storage requirements](docs/performance.md#storage-budgets-and-recovery) and [host startup costs](docs/performance.md#necessary-costs-and-limits).
|
|
85
85
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.48.0","sha256":"288e6058a52baeafc2529e0a8bc267541c4dbb2e21de11af1ef89058f4809750"});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Fault, BroadcastFault } from "./bridge-protocol.js";
|
|
3
|
+
export async function broadcastTargets(session, targets, message, context) {
|
|
4
|
+
const receipts = [];
|
|
5
|
+
for (const peer of targets.peers) {
|
|
6
|
+
if (context.signal.aborted) {
|
|
7
|
+
receipts.push({
|
|
8
|
+
peer: peer.id,
|
|
9
|
+
attempted: false,
|
|
10
|
+
error: {
|
|
11
|
+
code: "not_attempted",
|
|
12
|
+
message: "Broadcast stopped before sending.",
|
|
13
|
+
deliveryUnknown: false,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const id = randomUUID();
|
|
19
|
+
try {
|
|
20
|
+
// No hidden retries; sequential admission bounds broker pressure.
|
|
21
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
22
|
+
const receipt = await session.send(peer.id, message, context, { messageId: id });
|
|
23
|
+
receipts.push({ peer: peer.id, attempted: true, ...receipt });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const fault = error instanceof Fault
|
|
27
|
+
? error
|
|
28
|
+
: new Fault(503, "send_unconfirmed", "Target delivery could not be confirmed.");
|
|
29
|
+
receipts.push({
|
|
30
|
+
peer: peer.id,
|
|
31
|
+
attempted: true,
|
|
32
|
+
error: {
|
|
33
|
+
code: fault.code,
|
|
34
|
+
message: fault.message,
|
|
35
|
+
...fault.details,
|
|
36
|
+
id,
|
|
37
|
+
deliveryUnknown: fault.details.accepted !== false,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const failed = receipts.filter((receipt) => receipt.accepted !== true).length + targets.errors.length;
|
|
43
|
+
const outcome = {
|
|
44
|
+
status: failed === 0 ? "complete" : "partial",
|
|
45
|
+
receipts,
|
|
46
|
+
errors: targets.errors,
|
|
47
|
+
};
|
|
48
|
+
// Transport closure must not erase outcomes, or disclose them after authorization is revoked.
|
|
49
|
+
session.authorize();
|
|
50
|
+
try {
|
|
51
|
+
session.guard(context.signal);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof Fault) {
|
|
55
|
+
throw new BroadcastFault(error, outcome);
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return outcome;
|
|
60
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { nativePeerId } from "./session-targets.js";
|
|
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 { JournalFrames } from "../shared/journal-reader.js";
|
|
6
|
+
import { loadNativeSession } from "../shared/native-session-loader.js";
|
|
7
|
+
import { Fault, cancelled } from "./bridge-protocol.js";
|
|
8
|
+
import { resolveIntercomPresenceName } from "./identity.js";
|
|
9
|
+
import { within } from "./bridge-launch-policy.js";
|
|
10
|
+
/** Reject links at every journal path component; ordinary owned 0644 journals are valid. */
|
|
11
|
+
export function trustedJournal(policy, file) {
|
|
12
|
+
const root = [policy.sessionDir, ...policy.resumeDirs].find((dir) => within(dir, file));
|
|
13
|
+
if (root === undefined) {
|
|
14
|
+
throw new Fault(403, "journal_forbidden", "Session journal is outside trusted resumeDirs.");
|
|
15
|
+
}
|
|
16
|
+
let path = root;
|
|
17
|
+
for (const part of ["", ...relative(root, file).split(sep).filter(Boolean)]) {
|
|
18
|
+
path = join(path, part);
|
|
19
|
+
const stat = lstatSync(path);
|
|
20
|
+
if (stat.isSymbolicLink() ||
|
|
21
|
+
stat.uid !== process.getuid?.() ||
|
|
22
|
+
(!stat.isDirectory() && path !== file)) {
|
|
23
|
+
throw new Fault(403, "journal_forbidden", "Session journal must have an owned, link-free path.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!lstatSync(file).isFile()) {
|
|
27
|
+
throw new Fault(403, "journal_forbidden", "Session journal must be an owned regular file.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Reads and projection input are consumed synchronously; prefix/UTF-8 carry never retains this view.
|
|
31
|
+
const METADATA_BYTES = Buffer.allocUnsafe(1024 * 1024);
|
|
32
|
+
const METADATA_FIELDS = new Set(["type", "id", "version", "timestamp", "cwd", "name", "parentId"]);
|
|
33
|
+
// ponytail: fast-skip native type-first envelopes only; other key ordering
|
|
34
|
+
// uses the bounded parser. Extend the fast path if native serialization changes.
|
|
35
|
+
const BODY_ENTRY = /^\{"type":"(?:message|model_change|thinking_level_change|compaction|branch_summary|custom|custom_message|label|usage|context_edit)",/;
|
|
36
|
+
class MetadataFrames {
|
|
37
|
+
prefix = Buffer.alloc(0);
|
|
38
|
+
skip;
|
|
39
|
+
frames;
|
|
40
|
+
nativeHeader;
|
|
41
|
+
constructor(frames, nativeHeader) {
|
|
42
|
+
this.frames = frames;
|
|
43
|
+
this.nativeHeader = nativeHeader;
|
|
44
|
+
}
|
|
45
|
+
part(bytes, complete) {
|
|
46
|
+
let remainder = bytes;
|
|
47
|
+
if (this.skip === undefined) {
|
|
48
|
+
const count = Math.min(64 - this.prefix.length, bytes.length);
|
|
49
|
+
this.prefix = Buffer.concat([this.prefix, bytes.subarray(0, count)]);
|
|
50
|
+
remainder = bytes.subarray(count);
|
|
51
|
+
if (this.prefix.length < 64 && !complete) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.skip = this.nativeHeader() && BODY_ENTRY.test(this.prefix.toString("ascii"));
|
|
55
|
+
if (!this.skip) {
|
|
56
|
+
this.frames.write(this.prefix);
|
|
57
|
+
}
|
|
58
|
+
this.prefix = Buffer.alloc(0);
|
|
59
|
+
}
|
|
60
|
+
if (!this.skip) {
|
|
61
|
+
this.frames.write(remainder);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
write(bytes) {
|
|
65
|
+
for (let from = 0; from < bytes.length;) {
|
|
66
|
+
const newline = bytes.indexOf(10, from);
|
|
67
|
+
const end = newline < 0 ? bytes.length : newline + 1;
|
|
68
|
+
this.part(bytes.subarray(from, end), newline >= 0);
|
|
69
|
+
if (newline >= 0) {
|
|
70
|
+
this.skip = undefined;
|
|
71
|
+
}
|
|
72
|
+
from = end;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finish() {
|
|
76
|
+
if (this.prefix.length > 0) {
|
|
77
|
+
this.part(Buffer.alloc(0), true);
|
|
78
|
+
}
|
|
79
|
+
this.frames.finish();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function readMetadata(file, sdk, signal, mode) {
|
|
83
|
+
const headerOnly = mode === "header";
|
|
84
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
85
|
+
try {
|
|
86
|
+
const metadata = [];
|
|
87
|
+
let first = true;
|
|
88
|
+
const frames = new JournalFrames((path) => path.length === 0 || (path.length === 1 && METADATA_FIELDS.has(String(path[0]))), ({ value }) => {
|
|
89
|
+
if (first &&
|
|
90
|
+
(value.type !== "session" ||
|
|
91
|
+
typeof value.id !== "string" ||
|
|
92
|
+
typeof value.cwd !== "string")) {
|
|
93
|
+
throw new Error("Not a native session journal");
|
|
94
|
+
}
|
|
95
|
+
first = false;
|
|
96
|
+
if (value.type === "session" && metadata.length === 0) {
|
|
97
|
+
metadata.push(value);
|
|
98
|
+
}
|
|
99
|
+
else if (value.type === "session_info") {
|
|
100
|
+
metadata[1] = value;
|
|
101
|
+
}
|
|
102
|
+
}, { policy: "inspect", requireNewline: mode !== "resume" });
|
|
103
|
+
const bytes = headerOnly ? METADATA_BYTES.subarray(0, 64 * 1024) : METADATA_BYTES;
|
|
104
|
+
const reader = new MetadataFrames(frames, () => !first);
|
|
105
|
+
for (;;) {
|
|
106
|
+
cancelled(signal);
|
|
107
|
+
// One shared file position/buffer: reads must be serial, and yield to cancellation.
|
|
108
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
109
|
+
await yieldTurn(undefined, { signal });
|
|
110
|
+
const bytesRead = readSync(fd, bytes, 0, bytes.length, null);
|
|
111
|
+
if (bytesRead === 0) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
reader.write(bytes.subarray(0, bytesRead));
|
|
115
|
+
if (headerOnly && metadata.length > 0) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
reader.finish();
|
|
120
|
+
const header = metadata.at(0);
|
|
121
|
+
if (header?.type !== "session" ||
|
|
122
|
+
typeof header.id !== "string" ||
|
|
123
|
+
typeof header.cwd !== "string") {
|
|
124
|
+
throw new Error("Not a native session journal");
|
|
125
|
+
}
|
|
126
|
+
// Native naming and ID semantics, without opening/migrating a live journal or hydrating bodies.
|
|
127
|
+
const entries = sdk.parseSessionEntries(metadata.map((entry) => JSON.stringify(entry)).join("\n"));
|
|
128
|
+
const manager = sdk.SessionManager.inMemory(header.cwd, undefined, entries);
|
|
129
|
+
return {
|
|
130
|
+
sessionId: manager.getSessionId(),
|
|
131
|
+
peerId: nativePeerId(manager.getSessionId()),
|
|
132
|
+
name: manager.getSessionName(),
|
|
133
|
+
cwd: manager.getCwd(),
|
|
134
|
+
sessionFile: file,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
closeSync(fd);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Native open repairs EOF framing; inspect its identity before allowing that write. */
|
|
142
|
+
export function inspectResumeIdentity(file, sdk, signal) {
|
|
143
|
+
return readMetadata(file, sdk, signal, "resume");
|
|
144
|
+
}
|
|
145
|
+
export function savedView(row, peer) {
|
|
146
|
+
return {
|
|
147
|
+
id: row.sessionId,
|
|
148
|
+
sessionId: row.sessionId,
|
|
149
|
+
peerId: peer?.id,
|
|
150
|
+
name: row.name,
|
|
151
|
+
cwd: row.cwd,
|
|
152
|
+
status: peer?.status ?? "saved",
|
|
153
|
+
lastSeen: peer?.lastSeen ?? row.lastSeen,
|
|
154
|
+
saved: true,
|
|
155
|
+
owned: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export function exactPeer(row, peers) {
|
|
159
|
+
const expectedName = resolveIntercomPresenceName(row.name, row.sessionId);
|
|
160
|
+
const peer = peers.find((candidate) => candidate.id === row.peerId);
|
|
161
|
+
if (peer && (peer.cwd !== row.cwd || peer.name !== expectedName)) {
|
|
162
|
+
throw new Fault(409, "active_session", "Native session has a live peer with changed identity; do not reopen its journal.");
|
|
163
|
+
}
|
|
164
|
+
if (!peer &&
|
|
165
|
+
peers.some((candidate) => !candidate.id.startsWith("pi-") &&
|
|
166
|
+
candidate.name === expectedName &&
|
|
167
|
+
candidate.cwd === row.cwd)) {
|
|
168
|
+
throw new Fault(409, "active_session", "A matching live peer has unproven native identity; refusing to reopen its possible journal.");
|
|
169
|
+
}
|
|
170
|
+
return peer;
|
|
171
|
+
}
|
|
172
|
+
function liveView(peer, state) {
|
|
173
|
+
return {
|
|
174
|
+
id: peer.id,
|
|
175
|
+
peerId: peer.id,
|
|
176
|
+
name: peer.name,
|
|
177
|
+
cwd: peer.cwd,
|
|
178
|
+
status: state ?? peer.status,
|
|
179
|
+
lastSeen: peer.lastSeen,
|
|
180
|
+
saved: false,
|
|
181
|
+
owned: state !== undefined,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export async function browseSessions(catalog, query, context) {
|
|
185
|
+
const peers = query.live ? await context.peers(context.signal) : [];
|
|
186
|
+
const { policy } = context;
|
|
187
|
+
if (query.saved && !policy) {
|
|
188
|
+
throw new Fault(403, "launch_disabled", "Saved sessions require local launch/resume policy.");
|
|
189
|
+
}
|
|
190
|
+
const page = policy && query.saved
|
|
191
|
+
? await catalog.page(policy, query, context.signal)
|
|
192
|
+
: { sessions: [], nextOffset: undefined };
|
|
193
|
+
const rows = page.sessions.map((row) => {
|
|
194
|
+
const state = context.activeState(row.sessionId);
|
|
195
|
+
const peer = exactPeer(row, peers);
|
|
196
|
+
return {
|
|
197
|
+
...savedView(row, peer),
|
|
198
|
+
status: state ?? peer?.status ?? "saved",
|
|
199
|
+
owned: state !== undefined,
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
const included = new Set(page.sessions.map((row) => row.peerId));
|
|
203
|
+
const live = peers.filter((peer) => !included.has(peer.id) &&
|
|
204
|
+
(query.q === undefined ||
|
|
205
|
+
`${peer.id} ${peer.name ?? ""} ${peer.cwd}`.toLowerCase().includes(query.q.toLowerCase())));
|
|
206
|
+
if (!query.saved) {
|
|
207
|
+
const end = query.offset + query.limit;
|
|
208
|
+
return {
|
|
209
|
+
sessions: live
|
|
210
|
+
.slice(query.offset, end)
|
|
211
|
+
.map((peer) => liveView(peer, context.activeState(peer.id))),
|
|
212
|
+
nextOffset: end < live.length ? end : undefined,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
sessions: [
|
|
217
|
+
...rows,
|
|
218
|
+
...live
|
|
219
|
+
.slice(0, query.limit - rows.length)
|
|
220
|
+
.map((peer) => liveView(peer, context.activeState(peer.id))),
|
|
221
|
+
],
|
|
222
|
+
liveTruncated: rows.length + live.length > query.limit ? true : undefined,
|
|
223
|
+
nextOffset: page.nextOffset,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function uniqueSavedSession(matches) {
|
|
227
|
+
if (matches.length > 1) {
|
|
228
|
+
throw new Fault(409, "ambiguous_session", "Multiple saved sessions match; choose an exact unique native ID.");
|
|
229
|
+
}
|
|
230
|
+
const session = matches.at(0);
|
|
231
|
+
if (!session) {
|
|
232
|
+
throw new Fault(404, "unknown_session", "No saved native session matches within trusted resumeDirs.");
|
|
233
|
+
}
|
|
234
|
+
return session;
|
|
235
|
+
}
|
|
236
|
+
export class SessionCatalog {
|
|
237
|
+
cache = new Map();
|
|
238
|
+
headers = new Map();
|
|
239
|
+
async *walk(dir, signal) {
|
|
240
|
+
await yieldTurn(undefined, { signal });
|
|
241
|
+
const stat = lstatSync(dir);
|
|
242
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid?.()) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const directory = opendirSync(dir);
|
|
246
|
+
let visited = 0;
|
|
247
|
+
try {
|
|
248
|
+
for (let row = directory.readSync(); row !== null; row = directory.readSync()) {
|
|
249
|
+
// Yield during offset skips and empty-directory traversal too.
|
|
250
|
+
if (visited++ % 32 === 0) {
|
|
251
|
+
// Directory entries must be processed without retaining a corpus-sized array.
|
|
252
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
253
|
+
await yieldTurn(undefined, { signal });
|
|
254
|
+
}
|
|
255
|
+
cancelled(signal);
|
|
256
|
+
const file = join(dir, row.name);
|
|
257
|
+
if (row.isDirectory()) {
|
|
258
|
+
yield* this.walk(file, signal);
|
|
259
|
+
}
|
|
260
|
+
else if (row.isFile() && row.name.endsWith(".jsonl")) {
|
|
261
|
+
yield file;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
directory.closeSync();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async *files(policy, signal) {
|
|
270
|
+
const unique = [...new Set([policy.sessionDir, ...policy.resumeDirs])];
|
|
271
|
+
const roots = unique.filter((root) => !unique.some((other) => other !== root && within(other, root)));
|
|
272
|
+
for (const root of roots) {
|
|
273
|
+
yield* this.walk(root, signal);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async read(policy, file, signal, headerOnly = false) {
|
|
277
|
+
cancelled(signal);
|
|
278
|
+
try {
|
|
279
|
+
trustedJournal(policy, file);
|
|
280
|
+
const stat = lstatSync(file);
|
|
281
|
+
const stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
282
|
+
const cache = headerOnly ? this.headers : this.cache;
|
|
283
|
+
const cached = this.cache.get(file) ?? cache.get(file);
|
|
284
|
+
if (cached?.stamp === stamp) {
|
|
285
|
+
return cached.session;
|
|
286
|
+
}
|
|
287
|
+
const sdk = await loadNativeSession(policy.packageRoot);
|
|
288
|
+
cancelled(signal);
|
|
289
|
+
const session = {
|
|
290
|
+
...(await readMetadata(file, sdk, signal, headerOnly ? "header" : "published")),
|
|
291
|
+
lastSeen: stat.mtimeMs,
|
|
292
|
+
};
|
|
293
|
+
if (cache.size >= 100000) {
|
|
294
|
+
cache.clear();
|
|
295
|
+
}
|
|
296
|
+
cache.set(file, { stamp, session });
|
|
297
|
+
return session;
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
cancelled(signal);
|
|
301
|
+
if (error instanceof Fault) {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async resolve(policy, selector, signal) {
|
|
308
|
+
if (selector.target !== undefined) {
|
|
309
|
+
return this.resolveTarget(policy, selector.target, signal);
|
|
310
|
+
}
|
|
311
|
+
const matches = [];
|
|
312
|
+
for await (const file of this.files(policy, signal)) {
|
|
313
|
+
// Inspect native metadata, not a filename/prefix guess. Yield so request cancellation can run.
|
|
314
|
+
await yieldTurn(undefined, { signal });
|
|
315
|
+
const row = await this.read(policy, file, signal, selector.sessionId !== undefined);
|
|
316
|
+
if (row &&
|
|
317
|
+
(selector.sessionId === undefined
|
|
318
|
+
? row.name === selector.name
|
|
319
|
+
: row.sessionId === selector.sessionId || row.peerId === selector.sessionId)) {
|
|
320
|
+
matches.push(row);
|
|
321
|
+
}
|
|
322
|
+
if (matches.length > 1) {
|
|
323
|
+
throw new Fault(409, "ambiguous_session", "Multiple saved sessions match; choose an exact unique native ID.");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const session = uniqueSavedSession(matches);
|
|
327
|
+
return (await this.read(policy, session.sessionFile, signal)) ?? session;
|
|
328
|
+
}
|
|
329
|
+
async resolveTarget(policy, target, signal) {
|
|
330
|
+
const ids = [];
|
|
331
|
+
const names = [];
|
|
332
|
+
for await (const file of this.files(policy, signal)) {
|
|
333
|
+
// Once an exact native ID is proven, only headers can affect its uniqueness.
|
|
334
|
+
const row = await this.read(policy, file, signal, ids.length > 0);
|
|
335
|
+
if (!row) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if ((row.sessionId === target || row.peerId === target) && ids.length < 2) {
|
|
339
|
+
ids.push(row);
|
|
340
|
+
}
|
|
341
|
+
if (row.name === target && names.length < 2) {
|
|
342
|
+
names.push(row);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Preserve exact-ID priority without paying for two complete archive scans.
|
|
346
|
+
const session = uniqueSavedSession(ids.length > 0 ? ids : names);
|
|
347
|
+
return (await this.read(policy, session.sessionFile, signal)) ?? session;
|
|
348
|
+
}
|
|
349
|
+
async page(policy, query, signal) {
|
|
350
|
+
const sessions = [];
|
|
351
|
+
let offset = 0;
|
|
352
|
+
let scanned = 0;
|
|
353
|
+
for await (const file of this.files(policy, signal)) {
|
|
354
|
+
if (offset++ < query.offset) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (scanned++ >= 1000 || sessions.length >= query.limit) {
|
|
358
|
+
return { sessions, nextOffset: offset - 1 };
|
|
359
|
+
}
|
|
360
|
+
// Each bounded metadata read yields to cancellation; no whole-corpus pathname array.
|
|
361
|
+
const row = await this.read(policy, file, signal);
|
|
362
|
+
if (row &&
|
|
363
|
+
(query.q === undefined ||
|
|
364
|
+
`${row.sessionId} ${row.name ?? ""} ${row.cwd}`
|
|
365
|
+
.toLowerCase()
|
|
366
|
+
.includes(query.q.toLowerCase()))) {
|
|
367
|
+
sessions.push(row);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { sessions };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -1,29 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
3
|
import { createSecureContext } from "node:tls";
|
|
4
|
+
import { parseEnv } from "node:util";
|
|
4
5
|
import { isUnknownArray } from "../shared/unknown.js";
|
|
5
6
|
import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.js";
|
|
6
7
|
import { launchPolicy } from "./bridge-launch-policy.js";
|
|
8
|
+
import { stateDirectory } from "./bridge-mailbox.js";
|
|
9
|
+
import { readBridgeFile } from "./bridge-files.js";
|
|
7
10
|
const MAX_CLIENTS = 64;
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const stat = fstatSync(fd);
|
|
12
|
-
if (!stat.isFile() || stat.size > limit) {
|
|
13
|
-
throw new Error("Configuration/TLS file must be a bounded regular file.");
|
|
14
|
-
}
|
|
15
|
-
if (privateFile && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600)) {
|
|
16
|
-
throw new Error("Config and TLS key must be owned by the current user with mode 0600.");
|
|
17
|
-
}
|
|
18
|
-
const data = readFileSync(fd);
|
|
19
|
-
if (data.length > limit) {
|
|
20
|
-
throw new Error("Configuration/TLS file exceeds its size limit.");
|
|
21
|
-
}
|
|
22
|
-
return data;
|
|
11
|
+
function wakePolicy(value) {
|
|
12
|
+
if (value === undefined) {
|
|
13
|
+
return;
|
|
23
14
|
}
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
const input = object(value, ["envFile"]);
|
|
16
|
+
const envFile = label(input.envFile, "wake credential file", 4096);
|
|
17
|
+
if (!isAbsolute(envFile)) {
|
|
18
|
+
throw new Error("Wake credential file must be absolute.");
|
|
26
19
|
}
|
|
20
|
+
const env = parseEnv(readBridgeFile(envFile, true, 16384).toString("utf8"));
|
|
21
|
+
const url = new URL(label(env.GROK_WAKE_WEBHOOK_URL, "wake webhook URL", 8192));
|
|
22
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") {
|
|
23
|
+
throw new Error("Wake webhook requires HTTPS without URL credentials or a fragment.");
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
envFile,
|
|
27
|
+
url: url.href,
|
|
28
|
+
key: label(env.GROK_WAKE_WEBHOOK_KEY, "wake webhook key", 4096),
|
|
29
|
+
};
|
|
27
30
|
}
|
|
28
31
|
function identities(value) {
|
|
29
32
|
if (!isUnknownArray(value) || value.length > MAX_CLIENTS) {
|
|
@@ -32,7 +35,7 @@ function identities(value) {
|
|
|
32
35
|
const clients = new Map();
|
|
33
36
|
const names = new Set();
|
|
34
37
|
for (const raw of value) {
|
|
35
|
-
const entry = object(raw, ["fingerprint256", "name", "cwd", "launch"]);
|
|
38
|
+
const entry = object(raw, ["fingerprint256", "name", "cwd", "launch", "wake"]);
|
|
36
39
|
const fingerprint256 = fingerprint(entry.fingerprint256);
|
|
37
40
|
const name = label(entry.name, "name", 128).trim();
|
|
38
41
|
const cwd = label(entry.cwd, "cwd", 4096);
|
|
@@ -51,14 +54,27 @@ function identities(value) {
|
|
|
51
54
|
name,
|
|
52
55
|
cwd: launch ? realpathSync(cwd) : cwd,
|
|
53
56
|
launch,
|
|
57
|
+
wake: wakePolicy(entry.wake),
|
|
54
58
|
});
|
|
55
59
|
names.add(name.toLowerCase());
|
|
56
60
|
}
|
|
57
61
|
return clients;
|
|
58
62
|
}
|
|
63
|
+
function configInput(path) {
|
|
64
|
+
const raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(readBridgeFile(path, true, BODY_LIMIT)));
|
|
65
|
+
return object(raw, ["port", "ca", "cert", "key", "clients", "stateDir"]);
|
|
66
|
+
}
|
|
67
|
+
/** Recipient admission reads private policy and the public CA, never the server key. */
|
|
68
|
+
export function loadControlPolicy(path) {
|
|
69
|
+
const input = configInput(path);
|
|
70
|
+
const ca = label(input.ca, "ca path", 4096);
|
|
71
|
+
if (!isAbsolute(ca)) {
|
|
72
|
+
throw new Error("CA path must be absolute.");
|
|
73
|
+
}
|
|
74
|
+
return { clients: identities(input.clients), ca: readBridgeFile(ca, false, 1024 * 1024) };
|
|
75
|
+
}
|
|
59
76
|
export function loadConfig(path) {
|
|
60
|
-
const
|
|
61
|
-
const input = object(raw, ["port", "ca", "cert", "key", "clients"]);
|
|
77
|
+
const input = configInput(path);
|
|
62
78
|
const port = input.port === undefined ? 9443 : input.port;
|
|
63
79
|
if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) {
|
|
64
80
|
throw new Error("Invalid HTTPS port.");
|
|
@@ -71,15 +87,20 @@ export function loadConfig(path) {
|
|
|
71
87
|
}
|
|
72
88
|
const clients = identities(input.clients);
|
|
73
89
|
const tls = {
|
|
74
|
-
ca:
|
|
75
|
-
cert:
|
|
76
|
-
key:
|
|
90
|
+
ca: readBridgeFile(ca, false, 1024 * 1024),
|
|
91
|
+
cert: readBridgeFile(cert, false, 1024 * 1024),
|
|
92
|
+
key: readBridgeFile(key, true, 1024 * 1024),
|
|
77
93
|
};
|
|
78
94
|
createSecureContext({ ...tls, minVersion: "TLSv1.2" });
|
|
79
|
-
|
|
95
|
+
const stateDir = stateDirectory(input.stateDir);
|
|
96
|
+
if (stateDir === undefined && [...clients.values()].some((identity) => identity.wake)) {
|
|
97
|
+
throw new Error("Wake delivery requires a durable stateDir.");
|
|
98
|
+
}
|
|
99
|
+
return { port, ca, cert, key, clients, tls, stateDir };
|
|
80
100
|
}
|
|
81
101
|
export function sameListener(next, current) {
|
|
82
|
-
return (next.
|
|
102
|
+
return (next.stateDir === current.stateDir &&
|
|
103
|
+
next.port === current.port &&
|
|
83
104
|
next.ca === current.ca &&
|
|
84
105
|
next.cert === current.cert &&
|
|
85
106
|
next.key === current.key &&
|