@wrongstack/core 0.306.0 → 0.306.3
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/dist/chronicle/index.js +6 -1
- package/dist/chronicle/project-server.js +13 -3
- package/dist/coordination/index.d.ts +1 -0
- package/dist/coordination/index.js +210 -103
- package/dist/coordination/mailbox-codecs.d.ts +29 -10
- package/dist/coordination/mailbox-constants.d.ts +30 -16
- package/dist/coordination/mailbox-health.d.ts +16 -0
- package/dist/coordination/mailbox-http-validation.d.ts +2 -1
- package/dist/coordination/mailbox-parse-state.d.ts +28 -10
- package/dist/coordination/mailbox-project-server.js +136 -20
- package/dist/coordination/mailbox-types.d.ts +44 -6
- package/dist/coordination/package-outdated-watcher.d.ts +15 -1
- package/dist/coordination/sqlite-mailbox-credentials.d.ts +26 -0
- package/dist/coordination/sqlite-mailbox.d.ts +26 -0
- package/dist/coordination/techstack-mailbox-consumer.d.ts +17 -0
- package/dist/core/index.js +39 -12
- package/dist/defaults/index.js +58 -87
- package/dist/execution/index.js +10 -3
- package/dist/hq/index.js +6 -39
- package/dist/index.js +239 -153
- package/dist/infrastructure/index.js +6 -39
- package/dist/plugin/index.js +6 -2
- package/dist/security/file-permissions.d.ts +12 -35
- package/dist/security/index.js +8 -51
- package/dist/security/kanban-boundary.d.ts +3 -1
- package/dist/session-catalog/project-server.js +6 -39
- package/dist/storage/index.js +56 -49
- package/dist/types/blocks.d.ts +9 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +22 -0
- package/dist/utils/memory-evidence-fence.d.ts +47 -0
- package/instructions/agents/code-reviewer.md +3 -0
- package/instructions/coordination/subagent-baseline.md +10 -1
- package/instructions/system-lite.md +8 -5
- package/instructions/system-pro.md +26 -0
- package/instructions/system.md +21 -0
- package/package.json +3 -3
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* GM-P0.2: These are the single canonical validators that every untrusted
|
|
5
|
-
* boundary (tools, HTTP bridge, WebSocket server, HQ gateway, slash commands)
|
|
6
|
-
* MUST use to parse and validate mailbox inputs. They enforce:
|
|
2
|
+
* Actor-aware mailbox boundary codecs.
|
|
7
3
|
*
|
|
4
|
+
* They enforce:
|
|
8
5
|
* - Type + recipient normalization and semantic validation
|
|
9
6
|
* - Known-field rejection for mutations; forward-compatible tolerance for queries
|
|
10
|
-
* - Actor-override rejection (body-supplied `from`, `readerId`,
|
|
11
|
-
* - Capability checks
|
|
7
|
+
* - Actor-override rejection (body-supplied `from`, `readerId`, `unreadBy`, …)
|
|
8
|
+
* - Capability checks, via the canonical implication graph
|
|
9
|
+
* - The shared request bounds from `mailbox-constants.ts`
|
|
10
|
+
*
|
|
11
|
+
* ## What actually uses these
|
|
12
|
+
*
|
|
13
|
+
* GM-P0.2 introduced this module as "the single canonical validator every
|
|
14
|
+
* untrusted boundary MUST use". That is not what happened, and the docstring
|
|
15
|
+
* claiming otherwise was actively dangerous — it invited new surfaces to wire
|
|
16
|
+
* themselves here on the assumption that the path was battle-tested by the
|
|
17
|
+
* HTTP bridge. Reality:
|
|
18
|
+
*
|
|
19
|
+
* - `parseMailboxSendInput` — used by the `mail_send` tool
|
|
20
|
+
* (`mail-tools.ts`). This is the only codec here with a production caller.
|
|
21
|
+
* - `parseMailboxQueryInput` / `parseMailboxAckInput` — exported, no
|
|
22
|
+
* production caller.
|
|
23
|
+
* - `parseMailboxRegistrationInput` / `parseMailboxHeartbeatInput` — not
|
|
24
|
+
* exported from `coordination/index.ts`, no caller anywhere.
|
|
25
|
+
* - The HTTP bridge validates through `mailbox-http-validation.ts`; the
|
|
26
|
+
* WebUI WebSocket server through `ws-payload-validation.ts`.
|
|
12
27
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
28
|
+
* Unused validators rot in a way unused helpers do not: nothing exercises the
|
|
29
|
+
* rule, so a gap survives review. Two were found here — a body-supplied
|
|
30
|
+
* `unreadBy` that drove the store's leaders-only audience gate, and an
|
|
31
|
+
* unbounded `limit` — both fixed below and both pinned by tests. Keep it that
|
|
32
|
+
* way, or delete the codec: an unenforced boundary is worse than no boundary,
|
|
33
|
+
* because it reads like one.
|
|
15
34
|
*
|
|
16
35
|
* @module mailbox-codecs
|
|
17
36
|
*/
|
|
@@ -54,7 +73,7 @@ export declare function parseMailboxSendInput(payload: Record<string, unknown>,
|
|
|
54
73
|
* Parse and validate a query payload from an untrusted boundary.
|
|
55
74
|
*
|
|
56
75
|
* Queries tolerate unknown fields (forward compatibility for read-only clients).
|
|
57
|
-
*
|
|
76
|
+
* Identity fields (`unreadBy`, `readerRole`) come from the actor, never the body.
|
|
58
77
|
*
|
|
59
78
|
* @throws {MailboxValidationError} on any validation failure.
|
|
60
79
|
*/
|
|
@@ -19,20 +19,11 @@ export declare const CLIENT_STALE_MS = 60000;
|
|
|
19
19
|
/** Heartbeat updates are throttled to at most this interval (per agent/client). */
|
|
20
20
|
export declare const HEARTBEAT_THROTTLE_MS = 5000;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* JSONL line separator. Still live: the one-shot legacy import
|
|
23
|
+
* (`SqliteMailbox.migrateLegacyFiles`) reads `_mailbox.jsonl` through
|
|
24
|
+
* `mailbox-message-codec.ts` / `mailbox-parse-state.ts`.
|
|
25
25
|
*/
|
|
26
|
-
export declare const REGISTRY_CACHE_TTL_MS = 2000;
|
|
27
|
-
/** JSONL line separator. */
|
|
28
26
|
export declare const LINE_SEPARATOR = "\n";
|
|
29
|
-
/**
|
|
30
|
-
* Soft cap on the in-memory message cache. The cache mirrors the JSONL
|
|
31
|
-
* message file; under normal load it stays well under this. If a pathological
|
|
32
|
-
* mailbox exceeds the cap we fall back to reading from disk rather than
|
|
33
|
-
* holding an unbounded buffer in memory.
|
|
34
|
-
*/
|
|
35
|
-
export declare const MESSAGE_CACHE_MAX_ENTRIES = 10000;
|
|
36
27
|
/** Background mailbox awareness polling interval (cross-process fallback). */
|
|
37
28
|
export declare const MAILBOX_AWARENESS_INTERVAL_MS = 30000;
|
|
38
29
|
/** Agent heartbeat interval in the attach layer. */
|
|
@@ -98,8 +89,31 @@ export declare const AUTO_COMPACT_DEFAULT_TTL_MS = 86400000;
|
|
|
98
89
|
* {@link AUTO_COMPACT_DEFAULT_TTL_MS}.
|
|
99
90
|
*/
|
|
100
91
|
export declare const AUTO_COMPACT_TYPE_TTL_MS: Readonly<Record<string, number>>;
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Ceiling on `limit` for any query arriving from an untrusted boundary.
|
|
94
|
+
*
|
|
95
|
+
* `limit` used to be validated as "a positive integer" and nothing else, so a
|
|
96
|
+
* caller could ask for `1e9`. Read paths fan a query out across every
|
|
97
|
+
* recipient address the caller answers to and pass the limit straight through,
|
|
98
|
+
* and the store pre-limits in SQL — so an absurd limit is not clamped
|
|
99
|
+
* anywhere: it materializes every matching row (a `JSON.parse` plus a receipt
|
|
100
|
+
* fold each) once per address.
|
|
101
|
+
*
|
|
102
|
+
* 500 is far above what any real reader asks for — the agent loop uses 10,
|
|
103
|
+
* `mail_inbox` defaults to 20, the HQ snapshot to 50.
|
|
104
|
+
*/
|
|
105
|
+
export declare const MAILBOX_MAX_QUERY_LIMIT = 500;
|
|
106
|
+
/**
|
|
107
|
+
* Ceiling on batch acknowledgement size from an untrusted boundary.
|
|
108
|
+
*
|
|
109
|
+
* `Mailbox.ackMany` applies the whole batch inside ONE `BEGIN IMMEDIATE` and
|
|
110
|
+
* does a message lookup per entry. Unbounded (except by a 256 KB body cap that
|
|
111
|
+
* still fits roughly 4,700 acks), a single request meant ~9,400 statements
|
|
112
|
+
* holding the project's only write lock while every other surface — agent
|
|
113
|
+
* loop, TUI, WebUI — waited out `busy_timeout` and then failed.
|
|
114
|
+
*
|
|
115
|
+
* The same ceiling applies to read limits because a `check` acks what it
|
|
116
|
+
* returns: an uncapped limit there is an uncapped ack batch.
|
|
117
|
+
*/
|
|
118
|
+
export declare const MAILBOX_MAX_ACK_BATCH = 500;
|
|
105
119
|
//# sourceMappingURL=mailbox-constants.d.ts.map
|
|
@@ -24,6 +24,22 @@
|
|
|
24
24
|
* The watchdog is a passive observer: it does NOT start the bridge.
|
|
25
25
|
* Starting the bridge is the user's job (`wstack mailbox serve` or
|
|
26
26
|
* `/mailbox-serve`). The watchdog then reports on what the user did.
|
|
27
|
+
*
|
|
28
|
+
* ## Scope: the HTTP bridge, NOT the project mailbox owner
|
|
29
|
+
*
|
|
30
|
+
* Do not reach for this to check whether the mailbox is up. The two processes
|
|
31
|
+
* are unrelated:
|
|
32
|
+
*
|
|
33
|
+
* - The **project mailbox owner** (`mailbox-project-server.ts`) is required
|
|
34
|
+
* and self-healing — clients spawn it on demand and it idles out after five
|
|
35
|
+
* minutes. Its liveness is `MailboxProjectServerConnection.probeStatus()`,
|
|
36
|
+
* which is what the TUI/WebUI connections-health surfaces call.
|
|
37
|
+
* - The **HTTP bridge** this watchdog probes is an optional façade that exists
|
|
38
|
+
* so EXTERNAL agents can reach the mailbox over HTTP. Since 2026-08-07 its
|
|
39
|
+
* feature gate (`features.mailboxBridge`) defaults to `'off'`, so on a
|
|
40
|
+
* default install there is nothing here to watch — which is why this class
|
|
41
|
+
* has no production caller. It stays exported for operators who turn the
|
|
42
|
+
* bridge on; construct it only alongside a bridge you actually started.
|
|
27
43
|
*/
|
|
28
44
|
import type { Mailbox, MailboxSendInput } from './mailbox-types.js';
|
|
29
45
|
export interface MailboxHealthWatchdogOptions {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentHeartbeatInput, AgentRegistrationInput, ClientHeartbeatInput, ClientRegistrationInput, MailboxAckBatchInput, MailboxAckInput, MailboxActorContext, MailboxMessage, MailboxQuery, MailboxSendInput } from './mailbox-types.js';
|
|
2
2
|
export declare const MAILBOX_HTTP_MAX_AGE_CEILING_MS: number;
|
|
3
|
+
export { MAILBOX_MAX_ACK_BATCH, MAILBOX_MAX_QUERY_LIMIT, } from './mailbox-constants.js';
|
|
3
4
|
export declare class MailboxHttpValidationError extends Error {
|
|
4
5
|
}
|
|
5
6
|
export interface MailboxCheckInput {
|
|
@@ -40,7 +41,7 @@ export declare function requireString(object: unknown, key: string): string;
|
|
|
40
41
|
*/
|
|
41
42
|
export declare function parseSinceMs(url: string, defaultMaxAgeMs: number | undefined): SinceResolution;
|
|
42
43
|
export declare function filterMailboxMessagesByTimestamp(messages: readonly MailboxMessage[], minTimestampIso: string | undefined): MailboxMessage[];
|
|
43
|
-
export declare function validateSend(body: unknown, actorId?: string): MailboxSendInput;
|
|
44
|
+
export declare function validateSend(body: unknown, actorId?: string, actorSessionId?: string): MailboxSendInput;
|
|
44
45
|
export declare function validateQuery(body: unknown): MailboxQuery;
|
|
45
46
|
export declare function validateCheck(body: unknown, actorId?: string): MailboxCheckInput;
|
|
46
47
|
export declare function validateAck(body: unknown, actorId?: string): MailboxAckInput;
|
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Parse state for the legacy mailbox JSONL file.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* re-projects every message. That is the correct shape for a one-shot read, but
|
|
6
|
-
* the read path is anything but one-shot — `unreadCount()`/`query()` consult the
|
|
7
|
-
* cache on every tool call, and any append by another session invalidates it.
|
|
8
|
-
* On a mailbox holding a day of fleet traffic (~3 MB / ~2.8k lines) that turned
|
|
9
|
-
* into the single largest allocation source in the whole TUI process: ~78% of
|
|
10
|
-
* all bytes allocated while idle, which V8 then let pile up as garbage until a
|
|
11
|
-
* major GC — read as "RAM keeps growing and /clear doesn't help".
|
|
4
|
+
* ## Where this runs today
|
|
12
5
|
*
|
|
13
|
-
*
|
|
6
|
+
* ONE caller: `SqliteMailbox.migrateLegacyFiles()` via `parseMailboxFile()`,
|
|
7
|
+
* i.e. the one-shot import of a pre-SQLite `_mailbox.jsonl` that runs at most
|
|
8
|
+
* once per project and is then fenced off by the `legacy_files_imported`
|
|
9
|
+
* marker. Nothing reads JSONL on the live path any more: the detached owner
|
|
10
|
+
* holds the only handle and every query is SQL.
|
|
11
|
+
*
|
|
12
|
+
* The incremental half of this module — `createMailboxParseState` +
|
|
13
|
+
* `ingestMailboxChunk` called with an appended chunk — has NO production
|
|
14
|
+
* caller. `parseMailboxFile()` is implemented on top of it (one fold, not
|
|
15
|
+
* two), so the code runs, but the append path and its invariants (stale-index
|
|
16
|
+
* tracking, `firstNewIndex`, duplicate-id fan-out) are exercised only by
|
|
17
|
+
* tests. Treat a change there as unvalidated by production traffic.
|
|
18
|
+
*
|
|
19
|
+
* ## Why it was built this way
|
|
20
|
+
*
|
|
21
|
+
* Historical, and worth keeping because the fold logic is still the thing
|
|
22
|
+
* that has to be exact: when the mailbox WAS a JSONL file, the read path was
|
|
23
|
+
* anything but one-shot — `unreadCount()`/`query()` consulted a cache on every
|
|
24
|
+
* tool call and any append by another session invalidated it. On a mailbox
|
|
25
|
+
* holding a day of fleet traffic (~3 MB / ~2.8k lines) a full re-parse per
|
|
26
|
+
* read became the single largest allocation source in the TUI process: ~78% of
|
|
27
|
+
* all bytes allocated while idle, piling up as garbage until a major GC — read
|
|
28
|
+
* as "RAM keeps growing and /clear doesn't help". That pressure is gone with
|
|
29
|
+
* the store; the exactness requirement is not.
|
|
30
|
+
*
|
|
31
|
+
* The module keeps enough state alongside the projections that an APPEND can
|
|
14
32
|
* be folded in without touching the bytes that were already parsed:
|
|
15
33
|
*
|
|
16
34
|
* - `messages` — base messages in file order, v1 acks already folded
|
|
@@ -251,6 +251,14 @@ function makePatternMatcher(pattern) {
|
|
|
251
251
|
return (e) => e === pattern;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// src/security/file-permissions.ts
|
|
255
|
+
import {
|
|
256
|
+
restrictDirPermissions,
|
|
257
|
+
restrictFilePermissions,
|
|
258
|
+
SECRET_DIR_MODE,
|
|
259
|
+
SECRET_FILE_MODE
|
|
260
|
+
} from "@wrongstack/persistence";
|
|
261
|
+
|
|
254
262
|
// src/utils/atomic-write.ts
|
|
255
263
|
import {
|
|
256
264
|
createPersistencePrimitives
|
|
@@ -1964,16 +1972,18 @@ function persistReceipt(db, messageId, state) {
|
|
|
1964
1972
|
}
|
|
1965
1973
|
function materializeMessageRows(db, rows) {
|
|
1966
1974
|
if (rows.length === 0) return [];
|
|
1967
|
-
const
|
|
1968
|
-
const
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1975
|
+
const receiptRows = [];
|
|
1976
|
+
for (const ids of chunk(
|
|
1977
|
+
rows.map((row) => row.id),
|
|
1978
|
+
400
|
|
1979
|
+
)) {
|
|
1980
|
+
const receiptSql = `
|
|
1973
1981
|
SELECT message_id, actor_id, read_at, completed_at, completed_by, outcome
|
|
1974
1982
|
FROM message_receipts
|
|
1983
|
+
WHERE message_id IN (${ids.map(() => "?").join(", ")})
|
|
1975
1984
|
`;
|
|
1976
|
-
|
|
1985
|
+
receiptRows.push(...db.prepare(receiptSql).all(...ids));
|
|
1986
|
+
}
|
|
1977
1987
|
const receiptState = /* @__PURE__ */ new Map();
|
|
1978
1988
|
for (const row of receiptRows) {
|
|
1979
1989
|
const states = receiptState.get(row.message_id) ?? {};
|
|
@@ -2002,8 +2012,16 @@ function materializeMessageRows(db, rows) {
|
|
|
2002
2012
|
});
|
|
2003
2013
|
}
|
|
2004
2014
|
function deleteMessages(db, ids) {
|
|
2005
|
-
const
|
|
2006
|
-
|
|
2015
|
+
for (const chunkIds of chunk(ids, 400)) {
|
|
2016
|
+
db.prepare(`DELETE FROM messages WHERE id IN (${chunkIds.map(() => "?").join(", ")})`).run(
|
|
2017
|
+
...chunkIds
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
function* chunk(values, size) {
|
|
2022
|
+
for (let start = 0; start < values.length; start += size) {
|
|
2023
|
+
yield values.slice(start, start + size);
|
|
2024
|
+
}
|
|
2007
2025
|
}
|
|
2008
2026
|
function persistAgent(db, agent) {
|
|
2009
2027
|
db.prepare(`
|
|
@@ -2191,17 +2209,24 @@ function credentialRevoke(db, credentialId, reason, by) {
|
|
|
2191
2209
|
function credentialRotate(db, transaction, credentialId, options) {
|
|
2192
2210
|
const old = credentialGet(db, credentialId);
|
|
2193
2211
|
if (old === null) return null;
|
|
2212
|
+
if (old.status === "revoked") return null;
|
|
2194
2213
|
return credentialIssue(db, transaction, {
|
|
2195
2214
|
principalId: old.principalId,
|
|
2196
2215
|
projectId: old.projectId ?? options?.projectId,
|
|
2197
2216
|
kind: old.kind,
|
|
2198
2217
|
capabilities: options?.capabilities ?? old.capabilities,
|
|
2199
|
-
ttlMs: options?.ttlMs ??
|
|
2218
|
+
ttlMs: options?.ttlMs ?? previousCredentialTtlMs(old),
|
|
2200
2219
|
notBefore: options?.notBefore,
|
|
2201
2220
|
supersedes: credentialId,
|
|
2202
2221
|
issuedBy: options?.issuedBy
|
|
2203
2222
|
});
|
|
2204
2223
|
}
|
|
2224
|
+
function previousCredentialTtlMs(credential) {
|
|
2225
|
+
const issuedAt = new Date(credential.issuedAt).getTime();
|
|
2226
|
+
const expiresAt = new Date(credential.expiresAt).getTime();
|
|
2227
|
+
const ttlMs = expiresAt - issuedAt;
|
|
2228
|
+
return Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : MAX_CREDENTIAL_TTL[credential.kind];
|
|
2229
|
+
}
|
|
2205
2230
|
|
|
2206
2231
|
// src/coordination/sqlite-mailbox-schema.ts
|
|
2207
2232
|
import * as fs2 from "node:fs";
|
|
@@ -2249,11 +2274,11 @@ function createMailboxParseState(raw) {
|
|
|
2249
2274
|
ingestMailboxChunk(state, raw);
|
|
2250
2275
|
return state;
|
|
2251
2276
|
}
|
|
2252
|
-
function ingestMailboxChunk(state,
|
|
2277
|
+
function ingestMailboxChunk(state, chunk2) {
|
|
2253
2278
|
const firstNewIndex = state.messages.length;
|
|
2254
2279
|
const ackRecords = [];
|
|
2255
2280
|
const staleExisting = /* @__PURE__ */ new Set();
|
|
2256
|
-
for (const line of
|
|
2281
|
+
for (const line of chunk2.split(LINE_SEPARATOR)) {
|
|
2257
2282
|
if (line.trim().length === 0) continue;
|
|
2258
2283
|
let parsed;
|
|
2259
2284
|
try {
|
|
@@ -2565,6 +2590,7 @@ var SqliteMailbox = class {
|
|
|
2565
2590
|
lastHeartbeat = /* @__PURE__ */ new Map();
|
|
2566
2591
|
lastClientHeartbeat = /* @__PURE__ */ new Map();
|
|
2567
2592
|
autoCompactTimer = null;
|
|
2593
|
+
autoCompactInFlight;
|
|
2568
2594
|
closed = false;
|
|
2569
2595
|
stmt(sql) {
|
|
2570
2596
|
return this.db.prepare(sql);
|
|
@@ -2660,9 +2686,14 @@ var SqliteMailbox = class {
|
|
|
2660
2686
|
const type = query.type === void 0 ? void 0 : normalizeMailboxMessageType(query.type);
|
|
2661
2687
|
const priorityRank = { low: 0, normal: 1, high: 2 };
|
|
2662
2688
|
const minimumRank = query.minPriority === void 0 ? 0 : priorityRank[query.minPriority];
|
|
2689
|
+
if (query.ids !== void 0 && query.ids.length === 0) return [];
|
|
2663
2690
|
const statuses = query.unreadBy === void 0 ? await this.getAgentStatuses() : void 0;
|
|
2664
2691
|
const where = [];
|
|
2665
2692
|
const params = [];
|
|
2693
|
+
if (query.ids !== void 0) {
|
|
2694
|
+
where.push(`id IN (${query.ids.map(() => "?").join(", ")})`);
|
|
2695
|
+
params.push(...query.ids);
|
|
2696
|
+
}
|
|
2666
2697
|
if (query.to !== void 0) {
|
|
2667
2698
|
where.push("(to_id = ? OR to_id = ?)");
|
|
2668
2699
|
params.push(query.to, "*");
|
|
@@ -2735,7 +2766,9 @@ var SqliteMailbox = class {
|
|
|
2735
2766
|
params.push(query.limit ?? 50);
|
|
2736
2767
|
}
|
|
2737
2768
|
const rows = this.stmt(sql).all(...params);
|
|
2769
|
+
const idFilter = query.ids === void 0 ? void 0 : new Set(query.ids);
|
|
2738
2770
|
const messages = this.materializeMessageRows(rows).filter((message) => {
|
|
2771
|
+
if (idFilter !== void 0 && !idFilter.has(message.id)) return false;
|
|
2739
2772
|
if (query.to !== void 0 && message.to !== query.to && message.to !== "*") return false;
|
|
2740
2773
|
if (query.from !== void 0 && message.from !== query.from) return false;
|
|
2741
2774
|
if (query.sessionId !== void 0 && message.senderSessionId !== query.sessionId)
|
|
@@ -2840,11 +2873,76 @@ var SqliteMailbox = class {
|
|
|
2840
2873
|
}
|
|
2841
2874
|
return updated;
|
|
2842
2875
|
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Count the messages this actor has neither read nor completed.
|
|
2878
|
+
*
|
|
2879
|
+
* Pushed into SQL rather than filtering `readMessages()`. The pre-tool hook
|
|
2880
|
+
* asks for this repeatedly — `mailbox-hooks.ts` throttles it to once a
|
|
2881
|
+
* second, which bounds the frequency but not the cost — and the JS form
|
|
2882
|
+
* materialized EVERY row in `messages`, joined the whole `message_receipts`
|
|
2883
|
+
* table, and folded per-actor receipt state across all of them just to
|
|
2884
|
+
* return an integer.
|
|
2885
|
+
*
|
|
2886
|
+
* The predicate is deliberately the same one {@link query} builds for
|
|
2887
|
+
* `{ unreadBy, incompleteOnly }`, because it has to agree exactly with
|
|
2888
|
+
* `isMessageCompletedForActor` and `isMailboxMessageVisibleTo`:
|
|
2889
|
+
*
|
|
2890
|
+
* - **unread** — no per-actor receipt carrying `read_at`, and no legacy
|
|
2891
|
+
* `readBy` key. Both are checked: `ackMany` writes the receipt row AND
|
|
2892
|
+
* mirrors the timestamp into the message's `readBy` JSON.
|
|
2893
|
+
* - **incomplete** — not `legacy_global_completion`, no per-actor receipt
|
|
2894
|
+
* carrying `completed_at`, and the aggregate `completed` flag counts only
|
|
2895
|
+
* when the message has no receipts at all (once any actor has a receipt,
|
|
2896
|
+
* completion is per-actor and the aggregate flag is not authoritative).
|
|
2897
|
+
* - **audience** — `leaders` mail is invisible unless the actor's base
|
|
2898
|
+
* identity is `leader`. This call path carries no role, matching the
|
|
2899
|
+
* `isMailboxMessageVisibleTo(message, forAgentId)` it replaces.
|
|
2900
|
+
*/
|
|
2843
2901
|
async unreadCount(forAgentId, sessionId) {
|
|
2844
2902
|
const sessionAddress = sessionId === void 0 ? void 0 : sessionRecipient(sessionId);
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2903
|
+
const where = [];
|
|
2904
|
+
const params = [];
|
|
2905
|
+
const recipients = ["to_id = ?", "to_id = '*'"];
|
|
2906
|
+
params.push(forAgentId);
|
|
2907
|
+
if (sessionAddress !== void 0) {
|
|
2908
|
+
recipients.push("to_id = ?");
|
|
2909
|
+
params.push(sessionAddress);
|
|
2910
|
+
}
|
|
2911
|
+
where.push(`(${recipients.join(" OR ")})`);
|
|
2912
|
+
where.push("deleted_at IS NULL");
|
|
2913
|
+
if (!isMailboxLeader(forAgentId)) {
|
|
2914
|
+
where.push("COALESCE(json_extract(data, '$.audience'), 'all') <> 'leaders'");
|
|
2915
|
+
}
|
|
2916
|
+
where.push(`NOT EXISTS (
|
|
2917
|
+
SELECT 1 FROM message_receipts AS read_receipt
|
|
2918
|
+
WHERE read_receipt.message_id = messages.id
|
|
2919
|
+
AND read_receipt.actor_id = ?
|
|
2920
|
+
AND read_receipt.read_at IS NOT NULL
|
|
2921
|
+
)`);
|
|
2922
|
+
params.push(forAgentId);
|
|
2923
|
+
where.push(`NOT EXISTS (
|
|
2924
|
+
SELECT 1 FROM json_each(json_extract(data, '$.readBy')) AS legacy_read
|
|
2925
|
+
WHERE legacy_read.key = ?
|
|
2926
|
+
)`);
|
|
2927
|
+
params.push(forAgentId);
|
|
2928
|
+
where.push("legacy_global_completion = 0");
|
|
2929
|
+
where.push(`NOT EXISTS (
|
|
2930
|
+
SELECT 1 FROM message_receipts AS completed_receipt
|
|
2931
|
+
WHERE completed_receipt.message_id = messages.id
|
|
2932
|
+
AND completed_receipt.actor_id = ?
|
|
2933
|
+
AND completed_receipt.completed_at IS NOT NULL
|
|
2934
|
+
)`);
|
|
2935
|
+
params.push(forAgentId);
|
|
2936
|
+
where.push(`(
|
|
2937
|
+
completed = 0 OR EXISTS (
|
|
2938
|
+
SELECT 1 FROM message_receipts AS any_receipt
|
|
2939
|
+
WHERE any_receipt.message_id = messages.id
|
|
2940
|
+
)
|
|
2941
|
+
)`);
|
|
2942
|
+
const row = this.stmt(
|
|
2943
|
+
`SELECT COUNT(*) AS total FROM messages WHERE ${where.join(" AND ")}`
|
|
2944
|
+
).get(...params);
|
|
2945
|
+
return Number(row?.total ?? 0);
|
|
2848
2946
|
}
|
|
2849
2947
|
async softDelete(mailId, by) {
|
|
2850
2948
|
const message = this.findMessage(mailId);
|
|
@@ -2936,10 +3034,14 @@ var SqliteMailbox = class {
|
|
|
2936
3034
|
* heartbeat from that id simply is not throttled and writes once more.
|
|
2937
3035
|
*/
|
|
2938
3036
|
pruneHeartbeats(map, nowMs) {
|
|
2939
|
-
if (map.size <= HEARTBEAT_TRACKING_MAX_ENTRIES) return;
|
|
2940
3037
|
for (const [id, at] of map) {
|
|
2941
3038
|
if (nowMs - at > HEARTBEAT_TRACKING_TTL_MS) map.delete(id);
|
|
2942
3039
|
}
|
|
3040
|
+
while (map.size > HEARTBEAT_TRACKING_MAX_ENTRIES) {
|
|
3041
|
+
const oldest = map.keys().next().value;
|
|
3042
|
+
if (oldest === void 0) break;
|
|
3043
|
+
map.delete(oldest);
|
|
3044
|
+
}
|
|
2943
3045
|
}
|
|
2944
3046
|
async deregisterAgent(agentId) {
|
|
2945
3047
|
this.stmt("DELETE FROM agents WHERE agent_id = ?").run(agentId);
|
|
@@ -3043,7 +3145,14 @@ var SqliteMailbox = class {
|
|
|
3043
3145
|
return purgeStale(this.compactionCtx(), options);
|
|
3044
3146
|
}
|
|
3045
3147
|
async autoCompact(options) {
|
|
3046
|
-
|
|
3148
|
+
if (this.autoCompactInFlight !== void 0) return this.autoCompactInFlight;
|
|
3149
|
+
const inFlight = autoCompact(this.compactionCtx(), options);
|
|
3150
|
+
this.autoCompactInFlight = inFlight;
|
|
3151
|
+
try {
|
|
3152
|
+
return await inFlight;
|
|
3153
|
+
} finally {
|
|
3154
|
+
if (this.autoCompactInFlight === inFlight) this.autoCompactInFlight = void 0;
|
|
3155
|
+
}
|
|
3047
3156
|
}
|
|
3048
3157
|
/** Bundle of store operations the retention sweeps drive. */
|
|
3049
3158
|
compactionCtx() {
|
|
@@ -3359,9 +3468,9 @@ function handleMessage(state, message) {
|
|
|
3359
3468
|
scheduleIdleStop();
|
|
3360
3469
|
});
|
|
3361
3470
|
}
|
|
3362
|
-
function onData(state,
|
|
3471
|
+
function onData(state, chunk2) {
|
|
3363
3472
|
state.lastSeenAt = Date.now();
|
|
3364
|
-
state.buffer +=
|
|
3473
|
+
state.buffer += chunk2;
|
|
3365
3474
|
while (true) {
|
|
3366
3475
|
const newline = state.buffer.indexOf("\n");
|
|
3367
3476
|
if (newline < 0) {
|
|
@@ -3402,6 +3511,13 @@ async function writeMetadata() {
|
|
|
3402
3511
|
const metadata = { ...serverStatus(), authToken };
|
|
3403
3512
|
await atomicWrite(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
3404
3513
|
`, { mode: 384 });
|
|
3514
|
+
await restrictFilePermissions(metadataPath, {
|
|
3515
|
+
label: "mailbox-server-metadata",
|
|
3516
|
+
// stdio is 'ignore' for this detached process; stderr is where its other
|
|
3517
|
+
// diagnostics go, so a failed hardening is at least consistent with them.
|
|
3518
|
+
warn: (message) => process.stderr.write(`${message}
|
|
3519
|
+
`)
|
|
3520
|
+
});
|
|
3405
3521
|
}
|
|
3406
3522
|
async function removeOwnedMetadata() {
|
|
3407
3523
|
try {
|
|
@@ -3450,7 +3566,7 @@ var server = net.createServer((socket) => {
|
|
|
3450
3566
|
void metadataWritten.then(() => {
|
|
3451
3567
|
if (!socket.destroyed) send(state, { type: "hello", ...serverInfo });
|
|
3452
3568
|
});
|
|
3453
|
-
socket.on("data", (
|
|
3569
|
+
socket.on("data", (chunk2) => onData(state, chunk2));
|
|
3454
3570
|
socket.on("error", () => {
|
|
3455
3571
|
});
|
|
3456
3572
|
socket.on("close", () => {
|
|
@@ -105,12 +105,14 @@ export { MAILBOX_TYPE_PROPERTIES, type MailboxMessageType, type MailboxTypeCateg
|
|
|
105
105
|
* 6. **Awareness polling**: `btw` messages intercepted by background
|
|
106
106
|
* polling are queued via `setBtwNote()` for injection at a safe loop
|
|
107
107
|
* boundary, not folded inline.
|
|
108
|
-
* 7. **Agent registry**: `getAgentStatuses()` reads the dedicated
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
108
|
+
* 7. **Agent registry**: `getAgentStatuses()` reads the dedicated `agents`
|
|
109
|
+
* table, not mailbox message content. That table is populated by
|
|
110
|
+
* `registerAgent` / `heartbeat` — never by `status`-type messages, which
|
|
111
|
+
* are ordinary mail and carry no presence meaning. There is no fallback
|
|
112
|
+
* that derives presence from message content: the registry is either the
|
|
113
|
+
* owner's table or nothing. (It was `_mailbox.registry.json` before the
|
|
114
|
+
* SQLite cutover; both that file and the derive-from-messages fallback are
|
|
115
|
+
* gone.)
|
|
114
116
|
* 8. **Request-scoped context**: delivered raw mailbox blocks are removed
|
|
115
117
|
* after one successful provider evaluation. Durable assistant/tool/task
|
|
116
118
|
* consequences remain; routine mail does not occupy later requests.
|
|
@@ -132,6 +134,26 @@ export type MailboxAudience = 'all' | 'leaders';
|
|
|
132
134
|
export declare function mailboxIdentityBase(agentId: string): string;
|
|
133
135
|
/** Whether a mailbox identity belongs to the session's main/leader agent. */
|
|
134
136
|
export declare function isMailboxLeader(agentId: string, role?: string): boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Whether a sender identity belongs to a named agent family.
|
|
139
|
+
*
|
|
140
|
+
* Matches the family id exactly, or as the `<family>-<suffix>` prefix that a
|
|
141
|
+
* spawned worker carries. Both forms occur in practice for the same logical
|
|
142
|
+
* sender: a pipeline agent may post under its plain id (`dep-watcher`, which
|
|
143
|
+
* `makeDependencyWatcherConfig` sends as), or a subagent may post under the
|
|
144
|
+
* name it was spawned with — `host-subagent-factory` sets `ctx.agentId` from
|
|
145
|
+
* the spawn name, so the tech-stack worker spawned as
|
|
146
|
+
* `tech-stack-package.json` writes mail from `tech-stack-package.json@<tag>`.
|
|
147
|
+
* A consumer gating on the plain family id with `===` would silently reject
|
|
148
|
+
* its own pipeline.
|
|
149
|
+
*
|
|
150
|
+
* Same shape as the existing `chimera` / `chimera-*` check in
|
|
151
|
+
* `applyMailboxSendPolicy` and `host-subagent-factory`.
|
|
152
|
+
*
|
|
153
|
+
* Session/process qualifiers are stripped first, so `<family>@<tag>` and
|
|
154
|
+
* `<family>#<pid>` match too.
|
|
155
|
+
*/
|
|
156
|
+
export declare function isMailboxSenderInFamily(senderId: string, family: string): boolean;
|
|
135
157
|
/** Whether a message may be consumed by the supplied agent identity. */
|
|
136
158
|
export declare function isMailboxMessageVisibleTo(message: Pick<MailboxMessage, 'audience'>, agentId: string, role?: string): boolean;
|
|
137
159
|
/**
|
|
@@ -381,6 +403,22 @@ export interface MailboxAgentStatus {
|
|
|
381
403
|
source?: 'cli' | 'webui' | 'mcp' | 'acp' | 'http' | undefined;
|
|
382
404
|
}
|
|
383
405
|
export interface MailboxQuery {
|
|
406
|
+
/**
|
|
407
|
+
* Restrict the result to these message ids.
|
|
408
|
+
*
|
|
409
|
+
* Exists so a caller that already knows which messages it cares about can
|
|
410
|
+
* ask about exactly those instead of pulling a set and scanning it. The
|
|
411
|
+
* HTTP bridge's per-actor visibility checks did the latter — answering
|
|
412
|
+
* "is this one message mine?" by materializing every message addressed to
|
|
413
|
+
* the actor, which on a busy project meant a full table read (plus the
|
|
414
|
+
* whole receipt table, plus a retention projection per row) for every
|
|
415
|
+
* single `ack`.
|
|
416
|
+
*
|
|
417
|
+
* An empty array matches nothing. Untrusted request codecs deliberately do
|
|
418
|
+
* NOT accept this field — `validateQuery` whitelists, so it stays a
|
|
419
|
+
* server-side narrowing rather than something a caller can widen.
|
|
420
|
+
*/
|
|
421
|
+
ids?: readonly string[] | undefined;
|
|
384
422
|
/** Filter by recipient agent id. */
|
|
385
423
|
to?: string | undefined;
|
|
386
424
|
/** Filter by sender agent id. */
|
|
@@ -58,7 +58,21 @@ export interface PackageOutdatedWatcherOptions {
|
|
|
58
58
|
pollIntervalMs?: number | undefined;
|
|
59
59
|
/** Agent id that runs this watcher. Default: 'pkg-outdated-watcher'. */
|
|
60
60
|
watcherAgentId?: string | undefined;
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* The only sender whose `result` messages this watcher acts on. Default:
|
|
63
|
+
* `'tech-stack'`. Matched as an agent family, so the worker the tech-stack
|
|
64
|
+
* consumer spawns as `tech-stack-<manifest>` also passes — `ctx.agentId`
|
|
65
|
+
* comes from the spawn name, so its mail arrives from
|
|
66
|
+
* `tech-stack-package.json@<tag>`, not from a bare `tech-stack`.
|
|
67
|
+
*
|
|
68
|
+
* This option was declared and documented from the start but never read:
|
|
69
|
+
* it appeared exactly once in the codebase, in this interface. The watcher
|
|
70
|
+
* therefore acted on a `result` addressed to it from ANY sender, and the
|
|
71
|
+
* body chose both the package names and — through `getPackageAuthor` —
|
|
72
|
+
* whether the resulting HIGH-priority notification went to one agent or was
|
|
73
|
+
* broadcast to `*`. Any agent on the project could use it to push
|
|
74
|
+
* attacker-chosen text to everyone, under a watcher's identity.
|
|
75
|
+
*/
|
|
62
76
|
techStackAgentId?: string | undefined;
|
|
63
77
|
/** Called to send a notification to an agent. */
|
|
64
78
|
onNotify: (msg: OutdatedNotifyMessage) => Promise<void>;
|
|
@@ -29,6 +29,32 @@ export declare function credentialIssue(db: DatabaseSync, transaction: <T>(run:
|
|
|
29
29
|
*/
|
|
30
30
|
export declare function credentialVerify(db: DatabaseSync, credentialId: string, secret: string): CredentialValidation;
|
|
31
31
|
export declare function credentialRevoke(db: DatabaseSync, credentialId: string, reason?: string, by?: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Rotate a credential: mint a successor for the same principal and mark the
|
|
34
|
+
* predecessor `rotated_out` with an overlap window.
|
|
35
|
+
*
|
|
36
|
+
* Two rules that are easy to get wrong, and were:
|
|
37
|
+
*
|
|
38
|
+
* 1. **A revoked credential is not rotatable.** Rotation has the same
|
|
39
|
+
* capability requirement as issuance, so this is not a privilege boundary —
|
|
40
|
+
* but it IS an audit and intent boundary. Rotating a revoked credential
|
|
41
|
+
* minted a fresh ACTIVE credential carrying the revoked one's principal and
|
|
42
|
+
* capabilities, and recorded it as an ordinary rotation
|
|
43
|
+
* (`supersedes: <revoked-id>`). Revocation is the emergency lever; undoing
|
|
44
|
+
* it must be an explicit re-issue that reads like one in the log, not a
|
|
45
|
+
* rotate that reads like routine hygiene. `rotated_out` and expired-but-
|
|
46
|
+
* active credentials stay rotatable: "my credential lapsed, roll it" is a
|
|
47
|
+
* legitimate flow, and neither state asserts that the holder was distrusted.
|
|
48
|
+
*
|
|
49
|
+
* 2. **Rotation preserves the credential's lifetime**, it does not reset it to
|
|
50
|
+
* the maximum for the kind. Defaulting to `MAX_CREDENTIAL_TTL` meant a
|
|
51
|
+
* deliberately short-lived credential — a 5-minute agent token minted for
|
|
52
|
+
* one task — silently became a 7-day one the first time it was rotated,
|
|
53
|
+
* with nothing in the call expressing that intent. The original duration is
|
|
54
|
+
* recoverable from the record (`expiresAt - issuedAt`), and
|
|
55
|
+
* `createMailboxCredential` still clamps it to the per-kind maximum, so
|
|
56
|
+
* preserving it can only ever narrow the grant.
|
|
57
|
+
*/
|
|
32
58
|
export declare function credentialRotate(db: DatabaseSync, transaction: <T>(run: () => T) => T, credentialId: string, options?: Partial<IssueCredentialOptions>): {
|
|
33
59
|
credential: MailboxCredential;
|
|
34
60
|
secret: string;
|
|
@@ -14,6 +14,7 @@ export declare class SqliteMailbox implements Mailbox {
|
|
|
14
14
|
private readonly lastHeartbeat;
|
|
15
15
|
private readonly lastClientHeartbeat;
|
|
16
16
|
private autoCompactTimer;
|
|
17
|
+
private autoCompactInFlight;
|
|
17
18
|
private closed;
|
|
18
19
|
constructor(projectDir: string, events?: EventBus, eventEmitter?: MailboxEventEmitter);
|
|
19
20
|
private stmt;
|
|
@@ -33,6 +34,31 @@ export declare class SqliteMailbox implements Mailbox {
|
|
|
33
34
|
query(query: MailboxQuery): Promise<MailboxMessage[]>;
|
|
34
35
|
ack(input: MailboxAckInput): Promise<MailboxMessage | null>;
|
|
35
36
|
ackMany(input: MailboxAckBatchInput): Promise<MailboxMessage[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Count the messages this actor has neither read nor completed.
|
|
39
|
+
*
|
|
40
|
+
* Pushed into SQL rather than filtering `readMessages()`. The pre-tool hook
|
|
41
|
+
* asks for this repeatedly — `mailbox-hooks.ts` throttles it to once a
|
|
42
|
+
* second, which bounds the frequency but not the cost — and the JS form
|
|
43
|
+
* materialized EVERY row in `messages`, joined the whole `message_receipts`
|
|
44
|
+
* table, and folded per-actor receipt state across all of them just to
|
|
45
|
+
* return an integer.
|
|
46
|
+
*
|
|
47
|
+
* The predicate is deliberately the same one {@link query} builds for
|
|
48
|
+
* `{ unreadBy, incompleteOnly }`, because it has to agree exactly with
|
|
49
|
+
* `isMessageCompletedForActor` and `isMailboxMessageVisibleTo`:
|
|
50
|
+
*
|
|
51
|
+
* - **unread** — no per-actor receipt carrying `read_at`, and no legacy
|
|
52
|
+
* `readBy` key. Both are checked: `ackMany` writes the receipt row AND
|
|
53
|
+
* mirrors the timestamp into the message's `readBy` JSON.
|
|
54
|
+
* - **incomplete** — not `legacy_global_completion`, no per-actor receipt
|
|
55
|
+
* carrying `completed_at`, and the aggregate `completed` flag counts only
|
|
56
|
+
* when the message has no receipts at all (once any actor has a receipt,
|
|
57
|
+
* completion is per-actor and the aggregate flag is not authoritative).
|
|
58
|
+
* - **audience** — `leaders` mail is invisible unless the actor's base
|
|
59
|
+
* identity is `leader`. This call path carries no role, matching the
|
|
60
|
+
* `isMailboxMessageVisibleTo(message, forAgentId)` it replaces.
|
|
61
|
+
*/
|
|
36
62
|
unreadCount(forAgentId: string, sessionId?: string): Promise<number>;
|
|
37
63
|
softDelete(mailId: string, by: string): Promise<MailboxMessage | null>;
|
|
38
64
|
restore(mailId: string): Promise<MailboxMessage | null>;
|