@fitchmultz/pi-subagents 0.46.2 → 0.47.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 +13 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +35 -26
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-routes.js +27 -3
- package/dist/pi-intercom/bridge-session.js +60 -64
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +55 -7
- package/docs/intercom-bridge.md +39 -17
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +25 -1
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-config.ts +46 -36
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-routes.ts +41 -4
- package/src/pi-intercom/bridge-session.ts +73 -71
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +58 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.47.0] - 2026-10-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
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
|
+
- 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
|
+
- 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. Client provisioning supports distinct bridge ports, configurable certificate lifetimes and same-CA server renewal for independent instances.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Preserve local identity policies during certificate rotation, and prevent consumer-acknowledged inbound asks from generating redundant fallback wakes.
|
|
15
|
+
|
|
3
16
|
## [0.46.2] - 2026-10-12
|
|
4
17
|
|
|
5
18
|
### 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.47.0","sha256":"f190373e25edf9819e0980435d5aebcb696b5e8d4ce2c841e665192836767f09"});
|
|
@@ -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,15 @@ 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
|
}
|
|
59
63
|
export function loadConfig(path) {
|
|
60
|
-
const raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(
|
|
61
|
-
const input = object(raw, ["port", "ca", "cert", "key", "clients"]);
|
|
64
|
+
const raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(readBridgeFile(path, true, BODY_LIMIT)));
|
|
65
|
+
const input = object(raw, ["port", "ca", "cert", "key", "clients", "stateDir"]);
|
|
62
66
|
const port = input.port === undefined ? 9443 : input.port;
|
|
63
67
|
if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) {
|
|
64
68
|
throw new Error("Invalid HTTPS port.");
|
|
@@ -71,15 +75,20 @@ export function loadConfig(path) {
|
|
|
71
75
|
}
|
|
72
76
|
const clients = identities(input.clients);
|
|
73
77
|
const tls = {
|
|
74
|
-
ca:
|
|
75
|
-
cert:
|
|
76
|
-
key:
|
|
78
|
+
ca: readBridgeFile(ca, false, 1024 * 1024),
|
|
79
|
+
cert: readBridgeFile(cert, false, 1024 * 1024),
|
|
80
|
+
key: readBridgeFile(key, true, 1024 * 1024),
|
|
77
81
|
};
|
|
78
82
|
createSecureContext({ ...tls, minVersion: "TLSv1.2" });
|
|
79
|
-
|
|
83
|
+
const stateDir = stateDirectory(input.stateDir);
|
|
84
|
+
if (stateDir === undefined && [...clients.values()].some((identity) => identity.wake)) {
|
|
85
|
+
throw new Error("Wake delivery requires a durable stateDir.");
|
|
86
|
+
}
|
|
87
|
+
return { port, ca, cert, key, clients, tls, stateDir };
|
|
80
88
|
}
|
|
81
89
|
export function sameListener(next, current) {
|
|
82
|
-
return (next.
|
|
90
|
+
return (next.stateDir === current.stateDir &&
|
|
91
|
+
next.port === current.port &&
|
|
83
92
|
next.ca === current.ca &&
|
|
84
93
|
next.cert === current.cert &&
|
|
85
94
|
next.key === current.key &&
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export function readBridgeFile(path, privateFile, limit) {
|
|
5
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
6
|
+
try {
|
|
7
|
+
const stat = fstatSync(fd);
|
|
8
|
+
if (!stat.isFile() || stat.size > limit) {
|
|
9
|
+
throw new Error("Configuration/TLS file must be a bounded regular file.");
|
|
10
|
+
}
|
|
11
|
+
if (privateFile && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600)) {
|
|
12
|
+
throw new Error("Config and TLS key must be owned by the current user with mode 0600.");
|
|
13
|
+
}
|
|
14
|
+
const data = readFileSync(fd);
|
|
15
|
+
if (data.length > limit) {
|
|
16
|
+
throw new Error("Configuration/TLS file exceeds its size limit.");
|
|
17
|
+
}
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
closeSync(fd);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function writeBridgeJson(path, value) {
|
|
25
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
26
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
27
|
+
try {
|
|
28
|
+
try {
|
|
29
|
+
writeFileSync(fd, JSON.stringify(value));
|
|
30
|
+
fsyncSync(fd);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
renameSync(temporary, path);
|
|
36
|
+
const directory = openSync(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
|
|
37
|
+
try {
|
|
38
|
+
fsyncSync(directory);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
closeSync(directory);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
rmSync(temporary, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { isRecord } from "../shared/unknown.js";
|
|
5
|
+
import { normalizeMessage, normalizeSessionInfo } from "./types.js";
|
|
6
|
+
import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint, label, } from "./bridge-protocol.js";
|
|
7
|
+
export function stateDirectory(value) {
|
|
8
|
+
if (value === undefined) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const path = label(value, "stateDir", 4096);
|
|
12
|
+
const stat = lstatSync(path);
|
|
13
|
+
if (!isAbsolute(path) ||
|
|
14
|
+
!stat.isDirectory() ||
|
|
15
|
+
stat.uid !== process.getuid?.() ||
|
|
16
|
+
(stat.mode & 0o777) !== 0o700) {
|
|
17
|
+
throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
|
|
18
|
+
}
|
|
19
|
+
return realpathSync(path);
|
|
20
|
+
}
|
|
21
|
+
function privateFile(path, create = false) {
|
|
22
|
+
const flags = create ? constants.O_RDWR | constants.O_CREAT : constants.O_RDONLY;
|
|
23
|
+
const fd = openSync(path, flags | constants.O_NOFOLLOW, 0o600);
|
|
24
|
+
try {
|
|
25
|
+
const stat = fstatSync(fd);
|
|
26
|
+
if (!stat.isFile() ||
|
|
27
|
+
stat.uid !== process.getuid?.() ||
|
|
28
|
+
(stat.mode & 0o777) !== 0o600 ||
|
|
29
|
+
stat.nlink !== 1) {
|
|
30
|
+
throw new Error("Mailbox files must be owned regular files with mode 0600 and one link.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function database(stateDir, fingerprint) {
|
|
38
|
+
stateDirectory(stateDir);
|
|
39
|
+
const path = join(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
|
|
40
|
+
privateFile(path, true);
|
|
41
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
42
|
+
try {
|
|
43
|
+
lstatSync(`${path}${suffix}`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
privateFile(`${path}${suffix}`);
|
|
52
|
+
}
|
|
53
|
+
const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
|
|
54
|
+
try {
|
|
55
|
+
db.exec(`
|
|
56
|
+
PRAGMA journal_mode=DELETE;
|
|
57
|
+
PRAGMA synchronous=FULL;
|
|
58
|
+
PRAGMA secure_delete=ON;
|
|
59
|
+
PRAGMA cache_size=-1024;
|
|
60
|
+
PRAGMA busy_timeout=1000;
|
|
61
|
+
CREATE TABLE IF NOT EXISTS mailbox (
|
|
62
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
id TEXT NOT NULL UNIQUE,
|
|
64
|
+
envelope TEXT,
|
|
65
|
+
bytes INTEGER NOT NULL,
|
|
66
|
+
ask INTEGER NOT NULL,
|
|
67
|
+
receivedAt INTEGER NOT NULL,
|
|
68
|
+
acked INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
notified INTEGER NOT NULL DEFAULT 1
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS unread ON mailbox(sequence) WHERE acked=0;
|
|
72
|
+
CREATE INDEX IF NOT EXISTS asks ON mailbox(ask) WHERE ask=1 AND acked=0;
|
|
73
|
+
CREATE INDEX IF NOT EXISTS notifications ON mailbox(sequence) WHERE notified=0;
|
|
74
|
+
CREATE TABLE IF NOT EXISTS losses (
|
|
75
|
+
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
|
76
|
+
count INTEGER NOT NULL
|
|
77
|
+
);
|
|
78
|
+
INSERT OR IGNORE INTO losses VALUES(1, 0);
|
|
79
|
+
`);
|
|
80
|
+
return db;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
db.close();
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function decode(value) {
|
|
88
|
+
const raw = typeof value === "string" ? JSON.parse(value) : undefined;
|
|
89
|
+
const from = isRecord(raw) ? normalizeSessionInfo(raw.from) : null;
|
|
90
|
+
const message = isRecord(raw) ? normalizeMessage(raw.message) : null;
|
|
91
|
+
if (!from || !message) {
|
|
92
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored mailbox entry is invalid.");
|
|
93
|
+
}
|
|
94
|
+
return { from, message };
|
|
95
|
+
}
|
|
96
|
+
/** Persistent payloads leave only ID tombstones after acknowledgement, preventing replay resurrection. */
|
|
97
|
+
export class Mailbox {
|
|
98
|
+
db;
|
|
99
|
+
entries = new Map();
|
|
100
|
+
bytes = 0;
|
|
101
|
+
sequence = 0;
|
|
102
|
+
failure;
|
|
103
|
+
admissionFailed = false;
|
|
104
|
+
notifications;
|
|
105
|
+
constructor(stateDir, fingerprint, notifications = false) {
|
|
106
|
+
try {
|
|
107
|
+
this.db = stateDir === undefined ? undefined : database(stateDir, fingerprint);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Fault(507, "mailbox_storage_failed", "Cannot open private mailbox storage.");
|
|
111
|
+
}
|
|
112
|
+
this.notifications = notifications;
|
|
113
|
+
}
|
|
114
|
+
get persistent() {
|
|
115
|
+
return this.db !== undefined;
|
|
116
|
+
}
|
|
117
|
+
stored(action) {
|
|
118
|
+
if (this.failure) {
|
|
119
|
+
throw this.failure;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return action();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw error instanceof Fault
|
|
126
|
+
? error
|
|
127
|
+
: new Fault(507, "mailbox_storage_failed", "Mailbox storage operation failed.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
add(envelope) {
|
|
131
|
+
if (!this.db) {
|
|
132
|
+
return this.addVolatile(envelope);
|
|
133
|
+
}
|
|
134
|
+
const db = this.db;
|
|
135
|
+
return this.stored(() => {
|
|
136
|
+
try {
|
|
137
|
+
const payload = JSON.stringify(envelope);
|
|
138
|
+
const inserted = db
|
|
139
|
+
.prepare("INSERT OR IGNORE INTO mailbox(id,envelope,bytes,ask,receivedAt,notified) VALUES(?,?,?,?,?,?)")
|
|
140
|
+
.run(envelope.message.id, payload, Buffer.byteLength(payload), envelope.message.expectsReply === true ? 1 : 0, Date.now(), this.notifications ? 0 : 1);
|
|
141
|
+
this.admissionFailed = false;
|
|
142
|
+
return inserted.changes === 0 ? "duplicate" : "stored";
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
this.admissionFailed = true;
|
|
146
|
+
try {
|
|
147
|
+
db.prepare("UPDATE losses SET count=MIN(count+1,9007199254740991) WHERE singleton=1").run();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
this.failure = new Fault(507, "mailbox_storage_failed", "Mailbox admission and loss recording failed; loss count is not durable.", { lossRecorded: false });
|
|
151
|
+
throw this.failure;
|
|
152
|
+
}
|
|
153
|
+
return "lost";
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
addVolatile(envelope) {
|
|
158
|
+
if (this.entries.has(envelope.message.id)) {
|
|
159
|
+
return "duplicate";
|
|
160
|
+
}
|
|
161
|
+
const bytes = Buffer.byteLength(JSON.stringify(envelope));
|
|
162
|
+
if (this.entries.size >= INBOX_COUNT || this.bytes + bytes > INBOX_BYTES) {
|
|
163
|
+
return "lost";
|
|
164
|
+
}
|
|
165
|
+
this.entries.set(envelope.message.id, {
|
|
166
|
+
...envelope,
|
|
167
|
+
bytes,
|
|
168
|
+
sequence: ++this.sequence,
|
|
169
|
+
receivedAt: Date.now(),
|
|
170
|
+
acked: 0,
|
|
171
|
+
});
|
|
172
|
+
this.bytes += bytes;
|
|
173
|
+
return "stored";
|
|
174
|
+
}
|
|
175
|
+
get(id) {
|
|
176
|
+
if (!this.db) {
|
|
177
|
+
return this.entries.get(id);
|
|
178
|
+
}
|
|
179
|
+
const db = this.db;
|
|
180
|
+
return this.stored(() => {
|
|
181
|
+
const row = db.prepare("SELECT envelope FROM mailbox WHERE id=? AND acked=0").get(id);
|
|
182
|
+
return row ? decode(row.envelope) : undefined;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
remove(id) {
|
|
186
|
+
if (this.db) {
|
|
187
|
+
const db = this.db;
|
|
188
|
+
this.stored(() => db
|
|
189
|
+
.prepare("UPDATE mailbox SET acked=1, envelope=CASE WHEN notified=1 THEN NULL ELSE envelope END, bytes=CASE WHEN notified=1 THEN 0 ELSE bytes END WHERE id=?")
|
|
190
|
+
.run(id));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const entry = this.entries.get(id);
|
|
194
|
+
if (entry) {
|
|
195
|
+
this.bytes -= entry.bytes;
|
|
196
|
+
this.entries.delete(id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
ack(ids) {
|
|
200
|
+
const acked = [];
|
|
201
|
+
const retained = [];
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
const entry = this.get(id);
|
|
204
|
+
if (entry?.message.expectsReply === true) {
|
|
205
|
+
// Durable consumer intake owns the primary wake; keep only the reply obligation here.
|
|
206
|
+
this.notificationAccepted(id);
|
|
207
|
+
retained.push(id);
|
|
208
|
+
}
|
|
209
|
+
else if (entry) {
|
|
210
|
+
this.remove(id);
|
|
211
|
+
acked.push(id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { acked, retained };
|
|
215
|
+
}
|
|
216
|
+
page(after, limit) {
|
|
217
|
+
return this.readPage(after, limit, false);
|
|
218
|
+
}
|
|
219
|
+
pendingNotifications(after = 0, limit = INBOX_COUNT) {
|
|
220
|
+
return this.readPage(after, limit, true);
|
|
221
|
+
}
|
|
222
|
+
notificationAccepted(id) {
|
|
223
|
+
const db = this.db;
|
|
224
|
+
if (db) {
|
|
225
|
+
this.stored(() => db
|
|
226
|
+
.prepare("UPDATE mailbox SET notified=1, envelope=CASE WHEN acked=1 THEN NULL ELSE envelope END, bytes=CASE WHEN acked=1 THEN 0 ELSE bytes END WHERE id=?")
|
|
227
|
+
.run(id));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
readPage(after, limit, notifications) {
|
|
231
|
+
if (!Number.isSafeInteger(after) ||
|
|
232
|
+
after < 0 ||
|
|
233
|
+
!Number.isInteger(limit) ||
|
|
234
|
+
limit < 1 ||
|
|
235
|
+
limit > INBOX_COUNT) {
|
|
236
|
+
throw new Fault(400, "invalid_input", "Invalid mailbox page bounds.");
|
|
237
|
+
}
|
|
238
|
+
return this.stored(() => {
|
|
239
|
+
const candidates = this.candidates(after, limit, notifications);
|
|
240
|
+
const messages = [];
|
|
241
|
+
let bytes = 0;
|
|
242
|
+
let cursor = after;
|
|
243
|
+
let hasMore = false;
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
const row = indexRow(candidate);
|
|
246
|
+
if (messages.length === limit || (messages.length > 0 && bytes + row.bytes > INBOX_BYTES)) {
|
|
247
|
+
hasMore = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
const envelope = decode(isRecord(candidate) && typeof candidate.envelope === "string"
|
|
251
|
+
? candidate.envelope
|
|
252
|
+
: JSON.stringify(candidate));
|
|
253
|
+
messages.push(notifications
|
|
254
|
+
? { ...envelope, receivedAt: row.receivedAt, acknowledged: row.acked === 1 }
|
|
255
|
+
: envelope);
|
|
256
|
+
bytes += row.bytes;
|
|
257
|
+
cursor = row.sequence;
|
|
258
|
+
}
|
|
259
|
+
return { messages, nextCursor: String(cursor), hasMore };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
candidates(after, limit, notifications) {
|
|
263
|
+
if (this.db) {
|
|
264
|
+
return this.db
|
|
265
|
+
.prepare(`SELECT sequence,bytes,receivedAt,acked,envelope FROM mailbox WHERE ${notifications ? "notified=0" : "acked=0"} AND sequence>? ORDER BY sequence LIMIT ?`)
|
|
266
|
+
.iterate(after, limit + 1);
|
|
267
|
+
}
|
|
268
|
+
return notifications
|
|
269
|
+
? []
|
|
270
|
+
: [...this.entries.values()].filter((entry) => entry.sequence > after).slice(0, limit + 1);
|
|
271
|
+
}
|
|
272
|
+
status(volatileLosses) {
|
|
273
|
+
const db = this.db;
|
|
274
|
+
const lostMessages = db
|
|
275
|
+
? this.stored(() => db.prepare("SELECT count FROM losses WHERE singleton=1").get()?.count)
|
|
276
|
+
: volatileLosses;
|
|
277
|
+
if (typeof lostMessages !== "number") {
|
|
278
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored loss counter is invalid.");
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
lostMessages,
|
|
282
|
+
overflow: lostMessages > 0,
|
|
283
|
+
storageError: this.persistent && lostMessages > 0,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
presence() {
|
|
287
|
+
const db = this.db;
|
|
288
|
+
if (!db) {
|
|
289
|
+
return {
|
|
290
|
+
pendingAsks: [...this.entries.values()].filter((entry) => entry.message.expectsReply === true).length,
|
|
291
|
+
acceptsAsks: this.entries.size < INBOX_COUNT && this.bytes < INBOX_BYTES,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return this.stored(() => {
|
|
295
|
+
const pendingAsks = db
|
|
296
|
+
.prepare("SELECT COUNT(*) AS count FROM mailbox WHERE ask=1 AND acked=0")
|
|
297
|
+
.get()?.count;
|
|
298
|
+
if (typeof pendingAsks !== "number") {
|
|
299
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored ask counter is invalid.");
|
|
300
|
+
}
|
|
301
|
+
return { pendingAsks, acceptsAsks: !this.admissionFailed };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
close() {
|
|
305
|
+
this.db?.close();
|
|
306
|
+
const discarded = this.entries.size;
|
|
307
|
+
this.entries.clear();
|
|
308
|
+
this.bytes = 0;
|
|
309
|
+
return discarded;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function indexRow(row) {
|
|
313
|
+
if (!isRecord(row)) {
|
|
314
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored mailbox index is invalid.");
|
|
315
|
+
}
|
|
316
|
+
const { sequence, bytes, receivedAt, acked } = row;
|
|
317
|
+
if (typeof sequence !== "number" ||
|
|
318
|
+
typeof bytes !== "number" ||
|
|
319
|
+
typeof receivedAt !== "number" ||
|
|
320
|
+
typeof acked !== "number") {
|
|
321
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored mailbox index is invalid.");
|
|
322
|
+
}
|
|
323
|
+
return { sequence, bytes, receivedAt, acked };
|
|
324
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isRecord } from "../shared/unknown.js";
|
|
3
|
+
import { readBridgeFile, writeBridgeJson } from "./bridge-files.js";
|
|
4
|
+
import { log } from "./bridge-protocol.js";
|
|
5
|
+
export class PersistentPresence {
|
|
6
|
+
registrations = new Map();
|
|
7
|
+
stopped = false;
|
|
8
|
+
timer;
|
|
9
|
+
running;
|
|
10
|
+
owner;
|
|
11
|
+
constructor(owner) {
|
|
12
|
+
this.owner = owner;
|
|
13
|
+
for (const identity of owner.clients().values()) {
|
|
14
|
+
this.restore(identity);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
path(fingerprint) {
|
|
18
|
+
return join(this.owner.stateDir, `presence-${fingerprint}.json`);
|
|
19
|
+
}
|
|
20
|
+
restore(identity) {
|
|
21
|
+
try {
|
|
22
|
+
const value = JSON.parse(readBridgeFile(this.path(identity.fingerprint256), true, 8192).toString("utf8"));
|
|
23
|
+
if (isRecord(value) &&
|
|
24
|
+
value.fingerprint === identity.fingerprint256 &&
|
|
25
|
+
value.name === identity.name &&
|
|
26
|
+
value.cwd === identity.cwd &&
|
|
27
|
+
typeof value.expires === "number" &&
|
|
28
|
+
Number.isFinite(value.expires) &&
|
|
29
|
+
value.expires > Date.now()) {
|
|
30
|
+
this.registrations.set(identity.fingerprint256, {
|
|
31
|
+
fingerprint: identity.fingerprint256,
|
|
32
|
+
name: identity.name,
|
|
33
|
+
cwd: identity.cwd,
|
|
34
|
+
expires: value.expires,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// An absent/invalid saved registration never grants a certificate identity.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
authenticated(identity, expires) {
|
|
43
|
+
const previous = this.registrations.get(identity.fingerprint256);
|
|
44
|
+
if (previous?.expires === expires &&
|
|
45
|
+
previous.name === identity.name &&
|
|
46
|
+
previous.cwd === identity.cwd) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const registration = {
|
|
50
|
+
fingerprint: identity.fingerprint256,
|
|
51
|
+
name: identity.name,
|
|
52
|
+
cwd: identity.cwd,
|
|
53
|
+
expires,
|
|
54
|
+
};
|
|
55
|
+
writeBridgeJson(this.path(identity.fingerprint256), registration);
|
|
56
|
+
this.registrations.set(identity.fingerprint256, registration);
|
|
57
|
+
this.start();
|
|
58
|
+
}
|
|
59
|
+
start() {
|
|
60
|
+
if (!this.stopped && !this.running) {
|
|
61
|
+
clearTimeout(this.timer);
|
|
62
|
+
this.timer = setTimeout(() => {
|
|
63
|
+
this.running = this.maintain().finally(() => {
|
|
64
|
+
this.running = undefined;
|
|
65
|
+
if (!this.stopped) {
|
|
66
|
+
this.timer = setTimeout(() => this.start(), 15000);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}, 0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async maintain() {
|
|
73
|
+
for (const registration of this.registrations.values()) {
|
|
74
|
+
if (this.stopped) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const identity = this.owner.clients().get(registration.fingerprint);
|
|
78
|
+
if (!identity ||
|
|
79
|
+
identity.name !== registration.name ||
|
|
80
|
+
identity.cwd !== registration.cwd ||
|
|
81
|
+
registration.expires <= Date.now()) {
|
|
82
|
+
this.registrations.delete(registration.fingerprint);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
// Each identity has one connection attempt; preserve the native broker's spawn safeguards.
|
|
87
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
88
|
+
await this.owner.connect(identity, registration.expires);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
log("presence_retry", { name: identity.name, result: "broker_unavailable" });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async stop() {
|
|
96
|
+
this.stopped = true;
|
|
97
|
+
clearTimeout(this.timer);
|
|
98
|
+
await this.running;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -21,16 +21,39 @@ export function routeFor(req) {
|
|
|
21
21
|
Object.keys(req.headers).some((key) => key.startsWith("sec-fetch-"))) {
|
|
22
22
|
throw new Fault(403, "browser_forbidden", "Browser access is not supported.");
|
|
23
23
|
}
|
|
24
|
-
const route = `${req.method ?? ""} ${req.url ?? ""}`;
|
|
24
|
+
const route = `${req.method ?? ""} ${(req.url ?? "").split("?")[0]}`;
|
|
25
25
|
if (!isRoute(route)) {
|
|
26
26
|
throw new Fault(404, "not_found", "Unknown method or path.");
|
|
27
27
|
}
|
|
28
|
+
if (req.url?.includes("?") === true && route !== "GET /v1/inbox") {
|
|
29
|
+
throw new Fault(400, "invalid_input", "Only inbox accepts query parameters.");
|
|
30
|
+
}
|
|
31
|
+
checkGetBody(req);
|
|
32
|
+
return route;
|
|
33
|
+
}
|
|
34
|
+
function checkGetBody(req) {
|
|
28
35
|
if (req.method === "GET" &&
|
|
29
36
|
(req.headers["transfer-encoding"] !== undefined ||
|
|
30
37
|
(req.headers["content-length"] !== undefined && req.headers["content-length"] !== "0"))) {
|
|
31
38
|
throw new Fault(400, "invalid_input", "GET requests must not have a body.");
|
|
32
39
|
}
|
|
33
|
-
|
|
40
|
+
}
|
|
41
|
+
function inboxQuery(req) {
|
|
42
|
+
const url = req.url ?? "";
|
|
43
|
+
const separator = url.indexOf("?");
|
|
44
|
+
const query = new URLSearchParams(separator === -1 ? "" : url.slice(separator + 1));
|
|
45
|
+
if ([...query.keys()].some((key) => !["after", "limit"].includes(key) || query.getAll(key).length !== 1)) {
|
|
46
|
+
throw new Fault(400, "invalid_input", "Unknown or repeated inbox query parameter.");
|
|
47
|
+
}
|
|
48
|
+
const after = query.get("after") ?? "0";
|
|
49
|
+
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
50
|
+
if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
51
|
+
!Number.isSafeInteger(Number(after)) ||
|
|
52
|
+
!/^[1-9]\d{0,2}$/.test(limit) ||
|
|
53
|
+
Number(limit) > INBOX_COUNT) {
|
|
54
|
+
throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256.");
|
|
55
|
+
}
|
|
56
|
+
return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
|
|
34
57
|
}
|
|
35
58
|
function timeout(value) {
|
|
36
59
|
const ms = value === undefined ? ASK_TIMEOUT : value;
|
|
@@ -43,10 +66,11 @@ export async function operationFor(req, route, signal) {
|
|
|
43
66
|
const input = req.method === "POST" ? object(await body(req, signal), fields[route]) : {};
|
|
44
67
|
switch (route) {
|
|
45
68
|
case "GET /v1/list":
|
|
46
|
-
case "GET /v1/inbox":
|
|
47
69
|
case "POST /v1/register":
|
|
48
70
|
case "GET /v1/sessions":
|
|
49
71
|
return { route };
|
|
72
|
+
case "GET /v1/inbox":
|
|
73
|
+
return inboxQuery(req);
|
|
50
74
|
case "POST /v1/start":
|
|
51
75
|
return { route, name: label(input.name, "session name", 64).trim() };
|
|
52
76
|
case "POST /v1/stop":
|