@fitchmultz/pi-subagents 0.48.0 → 0.49.1
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 +20 -0
- package/agents/worker.md +2 -2
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +1 -1
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-routes.js +25 -6
- package/dist/pi-intercom/bridge-session.js +60 -45
- package/dist/pi-intercom/bridge-storage.js +73 -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 +10 -2
- package/docs/intercom-bridge.md +56 -25
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +39 -5
- package/src/pi-intercom/bridge-config.ts +1 -1
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-routes.ts +38 -8
- package/src/pi-intercom/bridge-session.ts +64 -61
- package/src/pi-intercom/bridge-storage.ts +83 -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 +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## [0.49.1] - 2026-10-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Worker verification guidance now requires an execution command and venue, carries applicable execution policy through delegation and continuation, and accepts inspected remote verification evidence without implying local execution.
|
|
10
|
+
|
|
11
|
+
## [0.49.0] - 2026-10-14
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- Stopped-helper audit preflight for preserving known remaining wake allowance during migration; unverified legacy usage conservatively reserves the first hour.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
3
23
|
## [0.48.0] - 2026-10-14
|
|
4
24
|
|
|
5
25
|
### Added
|
package/agents/worker.md
CHANGED
|
@@ -20,13 +20,13 @@ Critical rules:
|
|
|
20
20
|
- Complete the full requested task, not just the first obvious step.
|
|
21
21
|
- If context is missing, retrieve discoverable facts with tools first.
|
|
22
22
|
- Make ordinary reversible implementation decisions within the assigned outcome. Ask only when required information or authority is unavailable after discovery, or an action would exceed the task's scope, risk irreversible loss or private-data disclosure, or create a substantial new financial commitment.
|
|
23
|
-
- Before finalizing,
|
|
23
|
+
- Before finalizing, obtain and inspect the appropriate verification evidence for the scope of the change. Required verification does not imply local execution. Carry the applicable execution policy through every delegation and continuation; use remote results as acceptance evidence.
|
|
24
24
|
|
|
25
25
|
Preflight (before editing):
|
|
26
26
|
|
|
27
27
|
1. Confirm git status is understandable for the task scope.
|
|
28
28
|
2. Identify exact files to change.
|
|
29
|
-
3. Identify the
|
|
29
|
+
3. Identify the verification command and its execution venue for the change, following the applicable execution policy.
|
|
30
30
|
4. State the smallest viable change that delivers the briefed outcome and scope; never narrow the requested outcome or scope to shrink the diff.
|
|
31
31
|
5. An answer selecting a direction settles that design decision. It authorizes implementation only when the current task also requests implementation. Planning, review and explicit approval holds remain read-only. If implementation was already requested, do not ask for the same approval again.
|
|
32
32
|
6. Resolve routine unknowns through inspection and make the call. Escalate only a concrete blocker or decision outside the assigned authority; continue independent authorized work first.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.49.1","sha256":"6cd61dacb62268abb87c7005b638048a141cd17ad6091e486146bbd5e1d01338"});
|
|
@@ -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) {
|
|
@@ -1,63 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isAbsolute, join } from "node:path";
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
1
|
+
import { privateDatabase } from "./bridge-storage.js";
|
|
4
2
|
import { isRecord } from "../shared/unknown.js";
|
|
5
3
|
import { normalizeMessage, normalizeSessionInfo } from "./types.js";
|
|
6
|
-
import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint,
|
|
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
|
-
}
|
|
4
|
+
import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint, } from "./bridge-protocol.js";
|
|
37
5
|
function database(stateDir, fingerprint) {
|
|
38
|
-
|
|
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 });
|
|
6
|
+
const { db } = privateDatabase(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
|
|
54
7
|
try {
|
|
55
8
|
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
9
|
CREATE TABLE IF NOT EXISTS mailbox (
|
|
62
10
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
11
|
id TEXT NOT NULL UNIQUE,
|
|
@@ -10,7 +10,10 @@ export class PersistentPresence {
|
|
|
10
10
|
owner;
|
|
11
11
|
constructor(owner) {
|
|
12
12
|
this.owner = owner;
|
|
13
|
-
|
|
13
|
+
this.restoreAll();
|
|
14
|
+
}
|
|
15
|
+
restoreAll() {
|
|
16
|
+
for (const identity of this.owner.clients().values()) {
|
|
14
17
|
this.restore(identity);
|
|
15
18
|
}
|
|
16
19
|
}
|
|
@@ -33,6 +36,7 @@ export class PersistentPresence {
|
|
|
33
36
|
cwd: identity.cwd,
|
|
34
37
|
expires: value.expires,
|
|
35
38
|
});
|
|
39
|
+
this.owner.restored(identity, value.expires);
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
catch {
|
|
@@ -9,7 +9,7 @@ const fields = {
|
|
|
9
9
|
"GET /v1/inbox": [],
|
|
10
10
|
"POST /v1/register": [],
|
|
11
11
|
"POST /v1/ack": ["ids"],
|
|
12
|
-
"POST /v1/send": ["to", "message"],
|
|
12
|
+
"POST /v1/send": ["to", "message", "passive", "messageId"],
|
|
13
13
|
"POST /v1/ask": ["to", "message", "timeoutMs"],
|
|
14
14
|
"POST /v1/reply": ["replyTo", "message"],
|
|
15
15
|
"POST /v1/start": ["name"],
|
|
@@ -60,16 +60,23 @@ function queryFor(req, allowed) {
|
|
|
60
60
|
return query;
|
|
61
61
|
}
|
|
62
62
|
function inboxQuery(req) {
|
|
63
|
-
const query = queryFor(req, ["after", "limit"]);
|
|
63
|
+
const query = queryFor(req, ["after", "limit", "waitMs"]);
|
|
64
64
|
const after = query.get("after") ?? "0";
|
|
65
65
|
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
66
|
+
const waitMs = query.get("waitMs") ?? "0";
|
|
66
67
|
if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
67
68
|
!Number.isSafeInteger(Number(after)) ||
|
|
68
69
|
!/^[1-9]\d{0,2}$/.test(limit) ||
|
|
69
|
-
Number(limit) > INBOX_COUNT
|
|
70
|
-
|
|
70
|
+
Number(limit) > INBOX_COUNT ||
|
|
71
|
+
!/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)) {
|
|
72
|
+
throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256; waitMs must be 0..10000.");
|
|
71
73
|
}
|
|
72
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
route: "GET /v1/inbox",
|
|
76
|
+
after: Number(after),
|
|
77
|
+
limit: Number(limit),
|
|
78
|
+
waitMs: Number(waitMs),
|
|
79
|
+
};
|
|
73
80
|
}
|
|
74
81
|
function pageNumber(value, fallback, min, max) {
|
|
75
82
|
const number = value === null ? fallback : Number(value);
|
|
@@ -183,7 +190,7 @@ function messagingOperation(route, input, req) {
|
|
|
183
190
|
}
|
|
184
191
|
return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
|
|
185
192
|
case "POST /v1/send":
|
|
186
|
-
return
|
|
193
|
+
return send(input);
|
|
187
194
|
case "POST /v1/ask":
|
|
188
195
|
return {
|
|
189
196
|
route,
|
|
@@ -200,6 +207,18 @@ function messagingOperation(route, input, req) {
|
|
|
200
207
|
return nativeControl(route, input);
|
|
201
208
|
}
|
|
202
209
|
}
|
|
210
|
+
function send(input) {
|
|
211
|
+
if (input.passive !== undefined && typeof input.passive !== "boolean") {
|
|
212
|
+
throw new Fault(400, "invalid_input", "passive must be a boolean.");
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
route: "POST /v1/send",
|
|
216
|
+
message: text(input.message),
|
|
217
|
+
to: label(input.to, "to").trim(),
|
|
218
|
+
passive: input.passive,
|
|
219
|
+
messageId: input.messageId === undefined ? undefined : label(input.messageId, "message ID"),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
203
222
|
export async function operationFor(req, route, signal) {
|
|
204
223
|
const input = await operationInput(req, route, signal);
|
|
205
224
|
switch (route) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
3
|
import { IntercomClient } from "./broker/client.js";
|
|
3
4
|
import { resolveSessionProjectId } from "./session-targets.js";
|
|
4
5
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
@@ -21,6 +22,7 @@ export class Session {
|
|
|
21
22
|
});
|
|
22
23
|
requests = new Set();
|
|
23
24
|
inbox;
|
|
25
|
+
inboxChanged = new AbortController();
|
|
24
26
|
replying = new Set();
|
|
25
27
|
pending;
|
|
26
28
|
controls = new ControlReplies();
|
|
@@ -48,9 +50,7 @@ export class Session {
|
|
|
48
50
|
this.touch();
|
|
49
51
|
}
|
|
50
52
|
guard(signal) {
|
|
51
|
-
|
|
52
|
-
cancelled(signal);
|
|
53
|
-
}
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
54
|
if (this.closed) {
|
|
55
55
|
throw this.closeReason;
|
|
56
56
|
}
|
|
@@ -211,12 +211,22 @@ export class Session {
|
|
|
211
211
|
this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
|
|
212
212
|
return;
|
|
213
213
|
}
|
|
214
|
-
if (result
|
|
215
|
-
|
|
214
|
+
if (result !== "duplicate" || !this.inbox.persistent) {
|
|
215
|
+
if (result !== "stored") {
|
|
216
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
217
|
+
}
|
|
218
|
+
this.inboxChanged.abort();
|
|
219
|
+
this.inboxChanged = new AbortController();
|
|
216
220
|
}
|
|
217
|
-
|
|
221
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
222
|
+
...this.fields(),
|
|
223
|
+
peer: from.id,
|
|
224
|
+
messageId: message.id,
|
|
225
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
226
|
+
});
|
|
218
227
|
if (result !== "lost") {
|
|
219
228
|
this.resolveReply(from, message);
|
|
229
|
+
this.owner.mailboxChanged?.();
|
|
220
230
|
}
|
|
221
231
|
this.presence();
|
|
222
232
|
}
|
|
@@ -228,14 +238,6 @@ export class Session {
|
|
|
228
238
|
pending.resolve({ from, message });
|
|
229
239
|
return true;
|
|
230
240
|
}
|
|
231
|
-
recordReceive(peer, messageId, result) {
|
|
232
|
-
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
233
|
-
...this.fields(),
|
|
234
|
-
peer,
|
|
235
|
-
messageId,
|
|
236
|
-
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
241
|
presence() {
|
|
240
242
|
if (this.closed) {
|
|
241
243
|
return;
|
|
@@ -311,17 +313,6 @@ export class Session {
|
|
|
311
313
|
const waiting = new WaitingAsk();
|
|
312
314
|
this.pending = waiting;
|
|
313
315
|
const { signal } = context;
|
|
314
|
-
const abort = () => {
|
|
315
|
-
try {
|
|
316
|
-
cancelled(signal);
|
|
317
|
-
}
|
|
318
|
-
catch (error) {
|
|
319
|
-
waiting.reject(error instanceof Error
|
|
320
|
-
? error
|
|
321
|
-
: new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
|
|
322
|
-
}
|
|
323
|
-
};
|
|
324
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
325
316
|
try {
|
|
326
317
|
await this.connect(signal);
|
|
327
318
|
const peer = await this.target(operation.to, signal);
|
|
@@ -338,16 +329,37 @@ export class Session {
|
|
|
338
329
|
}
|
|
339
330
|
finally {
|
|
340
331
|
waiting.clear();
|
|
341
|
-
|
|
342
|
-
if (this.pending === waiting) {
|
|
343
|
-
this.pending = undefined;
|
|
344
|
-
}
|
|
332
|
+
this.pending = undefined;
|
|
345
333
|
}
|
|
346
334
|
}
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
335
|
+
async readInbox(operation, context) {
|
|
336
|
+
const read = () => ({
|
|
337
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
338
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
339
|
+
});
|
|
340
|
+
const deadline = performance.now() + operation.waitMs;
|
|
341
|
+
const losses = read().lostMessages;
|
|
342
|
+
for (;;) {
|
|
343
|
+
// Subscribe to the post-commit signal before rereading the authoritative cursor page.
|
|
344
|
+
const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
|
|
345
|
+
const page = read();
|
|
346
|
+
const remaining = deadline - performance.now();
|
|
347
|
+
if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
|
|
348
|
+
return page;
|
|
349
|
+
}
|
|
350
|
+
log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
|
|
351
|
+
try {
|
|
352
|
+
// A nonmatching commit must rearm the same cursor, not start parallel readers.
|
|
353
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
354
|
+
await delay(remaining, undefined, { signal });
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (!signal.aborted) {
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.guard(context.signal);
|
|
362
|
+
}
|
|
351
363
|
}
|
|
352
364
|
async answer(replyTo, message, context) {
|
|
353
365
|
const entry = this.inbox.get(replyTo);
|
|
@@ -371,6 +383,7 @@ export class Session {
|
|
|
371
383
|
}
|
|
372
384
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
373
385
|
this.inbox.remove(replyTo);
|
|
386
|
+
this.owner.mailboxChanged?.();
|
|
374
387
|
this.presence();
|
|
375
388
|
return data;
|
|
376
389
|
}
|
|
@@ -389,28 +402,30 @@ export class Session {
|
|
|
389
402
|
await this.connect(context.signal);
|
|
390
403
|
}
|
|
391
404
|
if (operation.route === "POST /v1/ack") {
|
|
392
|
-
|
|
405
|
+
const result = this.inbox.ack(operation.ids);
|
|
406
|
+
this.owner.mailboxChanged?.();
|
|
407
|
+
this.presence();
|
|
408
|
+
return result;
|
|
393
409
|
}
|
|
394
|
-
return
|
|
395
|
-
...this.inbox.page(operation.after, operation.limit),
|
|
396
|
-
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
397
|
-
};
|
|
410
|
+
return this.readInbox(operation, context);
|
|
398
411
|
}
|
|
399
412
|
await this.connect(context.signal);
|
|
400
413
|
switch (operation.route) {
|
|
401
414
|
case "POST /v1/register":
|
|
402
415
|
return { sessionId: this.client.sessionId, name: this.identity.name };
|
|
403
|
-
case "GET /v1/list":
|
|
404
|
-
const sessions = await this.peers(context.signal);
|
|
416
|
+
case "GET /v1/list":
|
|
405
417
|
return {
|
|
406
418
|
sessionId: this.client.sessionId,
|
|
407
|
-
sessions: bridgeSessionList(
|
|
419
|
+
sessions: bridgeSessionList(await this.peers(context.signal)),
|
|
408
420
|
};
|
|
421
|
+
case "POST /v1/send": {
|
|
422
|
+
const peer = await this.target(operation.to, context.signal);
|
|
423
|
+
const receipt = await this.send(peer.id, operation.message, context, {
|
|
424
|
+
messageId: operation.messageId,
|
|
425
|
+
delivery: operation.passive === true ? "passive" : "steer",
|
|
426
|
+
});
|
|
427
|
+
return { ...receipt };
|
|
409
428
|
}
|
|
410
|
-
case "POST /v1/send":
|
|
411
|
-
return {
|
|
412
|
-
...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
|
|
413
|
-
};
|
|
414
429
|
case "POST /v1/reply":
|
|
415
430
|
return this.answer(operation.replyTo, operation.message, context);
|
|
416
431
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { label } from "./bridge-protocol.js";
|
|
6
|
+
export function stateDirectory(value) {
|
|
7
|
+
if (value === undefined) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const path = label(value, "stateDir", 4096);
|
|
11
|
+
const stat = lstatSync(path);
|
|
12
|
+
if (!isAbsolute(path) ||
|
|
13
|
+
!stat.isDirectory() ||
|
|
14
|
+
stat.uid !== process.getuid?.() ||
|
|
15
|
+
(stat.mode & 0o777) !== 0o700) {
|
|
16
|
+
throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
|
|
17
|
+
}
|
|
18
|
+
return realpathSync(path);
|
|
19
|
+
}
|
|
20
|
+
export function privateFile(path) {
|
|
21
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
22
|
+
try {
|
|
23
|
+
const stat = fstatSync(fd);
|
|
24
|
+
if (!stat.isFile() ||
|
|
25
|
+
stat.uid !== process.getuid?.() ||
|
|
26
|
+
(stat.mode & 0o777) !== 0o600 ||
|
|
27
|
+
stat.nlink !== 1) {
|
|
28
|
+
throw new Error("Storage files must be owned regular files with mode 0600 and one link.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Native SQLite FULL commits own durability; never replace an unreadable existing database. */
|
|
36
|
+
export function privateDatabase(stateDir, name) {
|
|
37
|
+
stateDirectory(stateDir);
|
|
38
|
+
const path = join(stateDir, name);
|
|
39
|
+
let created = false;
|
|
40
|
+
try {
|
|
41
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
42
|
+
closeSync(fd);
|
|
43
|
+
created = true;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (!isRecord(error) || error.code !== "EEXIST") {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
privateFile(path);
|
|
51
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
52
|
+
try {
|
|
53
|
+
lstatSync(`${path}${suffix}`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
privateFile(`${path}${suffix}`);
|
|
62
|
+
}
|
|
63
|
+
const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
|
|
64
|
+
try {
|
|
65
|
+
db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;
|
|
66
|
+
PRAGMA cache_size=-1024; PRAGMA busy_timeout=1000;`);
|
|
67
|
+
return { db, created };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
db.close();
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { privateDatabase, privateFile } from "./bridge-storage.js";
|
|
6
|
+
import { isRecord } from "../shared/unknown.js";
|
|
7
|
+
export const WAKE_OWNER_FILE = /^wake-owner-[0-9a-f-]{36}\.lock$/;
|
|
8
|
+
/** SQLite's OS lock survives pauses and is released on exit, without PID reuse or stale leases. */
|
|
9
|
+
export class WakeOwner {
|
|
10
|
+
name = `wake-owner-${randomUUID()}.lock`;
|
|
11
|
+
db;
|
|
12
|
+
path;
|
|
13
|
+
constructor(stateDir) {
|
|
14
|
+
this.path = join(stateDir, this.name);
|
|
15
|
+
this.db = privateDatabase(stateDir, this.name).db;
|
|
16
|
+
try {
|
|
17
|
+
this.db.exec("BEGIN EXCLUSIVE");
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
this.db.close();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
close() {
|
|
25
|
+
this.db.close();
|
|
26
|
+
rmSync(this.path, { force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function stoppedWakeOwner(stateDir, name) {
|
|
30
|
+
if (!WAKE_OWNER_FILE.test(name)) {
|
|
31
|
+
throw new Error("Invalid wake owner.");
|
|
32
|
+
}
|
|
33
|
+
const path = join(stateDir, name);
|
|
34
|
+
try {
|
|
35
|
+
privateFile(path);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const db = new DatabaseSync(path);
|
|
44
|
+
try {
|
|
45
|
+
db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE");
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (isRecord(error) && error.errcode === 5) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
db.close();
|
|
56
|
+
}
|
|
57
|
+
}
|