@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
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## [0.49.0] - 2026-10-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Event-driven remote inbox waits through optional `waitMs` (0–10000 ms) and CLI `inbox --wait-ms`, preserving immediate reads by default, durable cursor paging and explicit acknowledgement.
|
|
10
|
+
- Native fallback wake two seconds after mailbox commit without intake acknowledgement, with durable shared admission of at most 20 attempts per sliding hour across all identities and URLs. Pending cold batches coalesce, retain retry deadlines across crashes and rotations, and recover automatically when allowance returns.
|
|
11
|
+
- Stopped-helper audit preflight for preserving known remaining wake allowance during migration; unverified legacy usage conservatively reserves the first hour.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Cancelling an inbox read no longer disconnects the certificate-bound native identity or interrupts unrelated requests. Revocation, expiry, broker disconnect and shutdown still cancel outstanding waits.
|
|
16
|
+
|
|
17
|
+
## [0.48.0] - 2026-10-14
|
|
4
18
|
|
|
5
19
|
### Added
|
|
6
20
|
|
|
7
|
-
- 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.
|
|
8
21
|
- 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.
|
|
9
22
|
- 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.
|
|
10
|
-
- Persistent named peer groups and broadcasts with per-recipient receipts, explicit partial outcomes and no automatic mutation retries.
|
|
23
|
+
- Persistent named peer groups and broadcasts with per-recipient receipts, explicit partial outcomes and no automatic mutation retries.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Preserve confirmed, unknown and unattempted broadcast outcomes when the broker stalls or disconnects, without disclosing partial results after identity revocation or certificate expiry.
|
|
28
|
+
- 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.
|
|
29
|
+
- Reduce archive lookup allocation churn with shared bounded scratch storage, and switch to header-only uniqueness checks after a verified exact native-ID match.
|
|
30
|
+
|
|
31
|
+
## [0.47.0] - 2026-10-13
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- 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.
|
|
36
|
+
- Client provisioning supports distinct bridge ports, configurable certificate lifetimes and same-CA server renewal for independent instances.
|
|
11
37
|
|
|
12
38
|
### Fixed
|
|
13
39
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.49.0","sha256":"6cd61dacb62268abb87c7005b638048a141cd17ad6091e486146bbd5e1d01338"});
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import { parseEnv } from "node:util";
|
|
|
5
5
|
import { isUnknownArray } from "../shared/unknown.js";
|
|
6
6
|
import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.js";
|
|
7
7
|
import { launchPolicy } from "./bridge-launch-policy.js";
|
|
8
|
-
import { stateDirectory } from "./bridge-
|
|
8
|
+
import { stateDirectory } from "./bridge-storage.js";
|
|
9
9
|
import { readBridgeFile } from "./bridge-files.js";
|
|
10
10
|
const MAX_CLIENTS = 64;
|
|
11
11
|
function wakePolicy(value) {
|
|
@@ -60,9 +60,21 @@ function identities(value) {
|
|
|
60
60
|
}
|
|
61
61
|
return clients;
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
function configInput(path) {
|
|
64
64
|
const raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(readBridgeFile(path, true, BODY_LIMIT)));
|
|
65
|
-
|
|
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
|
+
}
|
|
76
|
+
export function loadConfig(path) {
|
|
77
|
+
const input = configInput(path);
|
|
66
78
|
const port = input.port === undefined ? 9443 : input.port;
|
|
67
79
|
if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) {
|
|
68
80
|
throw new Error("Invalid HTTPS port.");
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Fault, wait } from "./bridge-protocol.js";
|
|
3
|
+
import { CONTROL_FALLBACK, CONTROL_SCHEMA, isControlResult, } from "./native-control-protocol.js";
|
|
4
|
+
/** One bounded request owner; exact socket-derived peer, replyTo, version and verb correlation. */
|
|
5
|
+
export class ControlReplies {
|
|
6
|
+
pending = new Map();
|
|
7
|
+
open(peer, verb, timeoutMs) {
|
|
8
|
+
if (this.pending.size >= 8) {
|
|
9
|
+
throw new Fault(429, "too_many_controls", "At most eight native controls may be in flight.");
|
|
10
|
+
}
|
|
11
|
+
const id = randomUUID();
|
|
12
|
+
const { promise: reply, resolve, reject } = Promise.withResolvers();
|
|
13
|
+
// A disconnect may reject while send is awaiting its broker receipt.
|
|
14
|
+
void reply.catch(() => {
|
|
15
|
+
/* Request owner observes rejection after its send receipt. */
|
|
16
|
+
});
|
|
17
|
+
const timer = setTimeout(() => reject(new Fault(504, "control_unconfirmed", "No correlated native response. Owner may be old/unavailable; execution is unknown. Do not automatically retry.", { id, deliveryUnknown: true })), timeoutMs);
|
|
18
|
+
const pending = { peer, verb, reply, resolve, reject, timer, disconnected: false };
|
|
19
|
+
this.pending.set(id, pending);
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
reply,
|
|
23
|
+
clear: () => this.clear(id),
|
|
24
|
+
get observedDisconnected() {
|
|
25
|
+
return pending.disconnected;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async request(client, operation, context) {
|
|
30
|
+
context.onPeer(operation.peer);
|
|
31
|
+
const waiting = this.open(operation.peer, operation.verb, operation.timeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
context.guard();
|
|
34
|
+
const receipt = await wait(client.send(operation.peer, {
|
|
35
|
+
text: CONTROL_FALLBACK,
|
|
36
|
+
messageId: waiting.id,
|
|
37
|
+
delivery: "passive",
|
|
38
|
+
control: {
|
|
39
|
+
schema: CONTROL_SCHEMA,
|
|
40
|
+
kind: "request",
|
|
41
|
+
verb: operation.verb,
|
|
42
|
+
certificate: operation.certificate,
|
|
43
|
+
options: operation.options,
|
|
44
|
+
},
|
|
45
|
+
}), context.signal).catch((error) => {
|
|
46
|
+
context.guard();
|
|
47
|
+
if (error instanceof Fault) {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
throw new Fault(504, "control_unconfirmed", "Broker receipt was not received; native execution is unknown. Do not automatically retry.", { id: waiting.id, deliveryUnknown: true });
|
|
51
|
+
});
|
|
52
|
+
context.guard();
|
|
53
|
+
if (!receipt.accepted) {
|
|
54
|
+
throw new Fault(409, "delivery_failed", receipt.reason ?? "Broker rejected native control.", { id: receipt.id });
|
|
55
|
+
}
|
|
56
|
+
const result = await wait(waiting.reply, context.signal);
|
|
57
|
+
context.guard();
|
|
58
|
+
return {
|
|
59
|
+
...receipt,
|
|
60
|
+
peer: operation.peer,
|
|
61
|
+
status: result.status,
|
|
62
|
+
native: result,
|
|
63
|
+
observedDisconnected: waiting.observedDisconnected,
|
|
64
|
+
processExited: false,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
waiting.clear();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
receive(peer, message) {
|
|
72
|
+
if (!Object.hasOwn(message, "control")) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const pending = message.replyTo === undefined ? undefined : this.pending.get(message.replyTo);
|
|
76
|
+
if (pending &&
|
|
77
|
+
pending.peer === peer &&
|
|
78
|
+
message.delivery === "passive" &&
|
|
79
|
+
isControlResult(message.control) &&
|
|
80
|
+
message.control.verb === pending.verb) {
|
|
81
|
+
pending.resolve(message.control);
|
|
82
|
+
}
|
|
83
|
+
// Unsolicited/spoofed/malformed control results do not enter the remote mailbox.
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
peerLeft(peer) {
|
|
87
|
+
for (const [id, pending] of this.pending) {
|
|
88
|
+
if (pending.peer === peer) {
|
|
89
|
+
pending.disconnected = true;
|
|
90
|
+
pending.reject(new Fault(409, "peer_disconnected", "Native peer disconnected; this does not establish process exit.", { id, observedDisconnected: true, processExited: false, deliveryUnknown: true }));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
close(error) {
|
|
95
|
+
for (const [id, pending] of this.pending) {
|
|
96
|
+
pending.reject(error);
|
|
97
|
+
this.clear(id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
clear(id) {
|
|
101
|
+
clearTimeout(this.pending.get(id)?.timer);
|
|
102
|
+
this.pending.delete(id);
|
|
103
|
+
}
|
|
104
|
+
}
|