@wrongstack/core 0.306.0 → 0.306.2
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 +162 -57
- 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 +98 -4
- 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 +25 -0
- package/dist/coordination/techstack-mailbox-consumer.d.ts +17 -0
- package/dist/core/index.js +39 -12
- package/dist/defaults/index.js +6 -39
- package/dist/execution/index.js +6 -1
- package/dist/hq/index.js +6 -39
- package/dist/index.js +185 -103
- 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 +6 -50
- package/dist/session-catalog/project-server.js +6 -39
- package/dist/storage/index.js +6 -1
- 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/package.json +3 -3
|
@@ -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
|
|
@@ -2191,17 +2199,24 @@ function credentialRevoke(db, credentialId, reason, by) {
|
|
|
2191
2199
|
function credentialRotate(db, transaction, credentialId, options) {
|
|
2192
2200
|
const old = credentialGet(db, credentialId);
|
|
2193
2201
|
if (old === null) return null;
|
|
2202
|
+
if (old.status === "revoked") return null;
|
|
2194
2203
|
return credentialIssue(db, transaction, {
|
|
2195
2204
|
principalId: old.principalId,
|
|
2196
2205
|
projectId: old.projectId ?? options?.projectId,
|
|
2197
2206
|
kind: old.kind,
|
|
2198
2207
|
capabilities: options?.capabilities ?? old.capabilities,
|
|
2199
|
-
ttlMs: options?.ttlMs ??
|
|
2208
|
+
ttlMs: options?.ttlMs ?? previousCredentialTtlMs(old),
|
|
2200
2209
|
notBefore: options?.notBefore,
|
|
2201
2210
|
supersedes: credentialId,
|
|
2202
2211
|
issuedBy: options?.issuedBy
|
|
2203
2212
|
});
|
|
2204
2213
|
}
|
|
2214
|
+
function previousCredentialTtlMs(credential) {
|
|
2215
|
+
const issuedAt = new Date(credential.issuedAt).getTime();
|
|
2216
|
+
const expiresAt = new Date(credential.expiresAt).getTime();
|
|
2217
|
+
const ttlMs = expiresAt - issuedAt;
|
|
2218
|
+
return Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : MAX_CREDENTIAL_TTL[credential.kind];
|
|
2219
|
+
}
|
|
2205
2220
|
|
|
2206
2221
|
// src/coordination/sqlite-mailbox-schema.ts
|
|
2207
2222
|
import * as fs2 from "node:fs";
|
|
@@ -2660,9 +2675,14 @@ var SqliteMailbox = class {
|
|
|
2660
2675
|
const type = query.type === void 0 ? void 0 : normalizeMailboxMessageType(query.type);
|
|
2661
2676
|
const priorityRank = { low: 0, normal: 1, high: 2 };
|
|
2662
2677
|
const minimumRank = query.minPriority === void 0 ? 0 : priorityRank[query.minPriority];
|
|
2678
|
+
if (query.ids !== void 0 && query.ids.length === 0) return [];
|
|
2663
2679
|
const statuses = query.unreadBy === void 0 ? await this.getAgentStatuses() : void 0;
|
|
2664
2680
|
const where = [];
|
|
2665
2681
|
const params = [];
|
|
2682
|
+
if (query.ids !== void 0) {
|
|
2683
|
+
where.push(`id IN (${query.ids.map(() => "?").join(", ")})`);
|
|
2684
|
+
params.push(...query.ids);
|
|
2685
|
+
}
|
|
2666
2686
|
if (query.to !== void 0) {
|
|
2667
2687
|
where.push("(to_id = ? OR to_id = ?)");
|
|
2668
2688
|
params.push(query.to, "*");
|
|
@@ -2735,7 +2755,9 @@ var SqliteMailbox = class {
|
|
|
2735
2755
|
params.push(query.limit ?? 50);
|
|
2736
2756
|
}
|
|
2737
2757
|
const rows = this.stmt(sql).all(...params);
|
|
2758
|
+
const idFilter = query.ids === void 0 ? void 0 : new Set(query.ids);
|
|
2738
2759
|
const messages = this.materializeMessageRows(rows).filter((message) => {
|
|
2760
|
+
if (idFilter !== void 0 && !idFilter.has(message.id)) return false;
|
|
2739
2761
|
if (query.to !== void 0 && message.to !== query.to && message.to !== "*") return false;
|
|
2740
2762
|
if (query.from !== void 0 && message.from !== query.from) return false;
|
|
2741
2763
|
if (query.sessionId !== void 0 && message.senderSessionId !== query.sessionId)
|
|
@@ -2840,11 +2862,76 @@ var SqliteMailbox = class {
|
|
|
2840
2862
|
}
|
|
2841
2863
|
return updated;
|
|
2842
2864
|
}
|
|
2865
|
+
/**
|
|
2866
|
+
* Count the messages this actor has neither read nor completed.
|
|
2867
|
+
*
|
|
2868
|
+
* Pushed into SQL rather than filtering `readMessages()`. The pre-tool hook
|
|
2869
|
+
* asks for this repeatedly — `mailbox-hooks.ts` throttles it to once a
|
|
2870
|
+
* second, which bounds the frequency but not the cost — and the JS form
|
|
2871
|
+
* materialized EVERY row in `messages`, joined the whole `message_receipts`
|
|
2872
|
+
* table, and folded per-actor receipt state across all of them just to
|
|
2873
|
+
* return an integer.
|
|
2874
|
+
*
|
|
2875
|
+
* The predicate is deliberately the same one {@link query} builds for
|
|
2876
|
+
* `{ unreadBy, incompleteOnly }`, because it has to agree exactly with
|
|
2877
|
+
* `isMessageCompletedForActor` and `isMailboxMessageVisibleTo`:
|
|
2878
|
+
*
|
|
2879
|
+
* - **unread** — no per-actor receipt carrying `read_at`, and no legacy
|
|
2880
|
+
* `readBy` key. Both are checked: `ackMany` writes the receipt row AND
|
|
2881
|
+
* mirrors the timestamp into the message's `readBy` JSON.
|
|
2882
|
+
* - **incomplete** — not `legacy_global_completion`, no per-actor receipt
|
|
2883
|
+
* carrying `completed_at`, and the aggregate `completed` flag counts only
|
|
2884
|
+
* when the message has no receipts at all (once any actor has a receipt,
|
|
2885
|
+
* completion is per-actor and the aggregate flag is not authoritative).
|
|
2886
|
+
* - **audience** — `leaders` mail is invisible unless the actor's base
|
|
2887
|
+
* identity is `leader`. This call path carries no role, matching the
|
|
2888
|
+
* `isMailboxMessageVisibleTo(message, forAgentId)` it replaces.
|
|
2889
|
+
*/
|
|
2843
2890
|
async unreadCount(forAgentId, sessionId) {
|
|
2844
2891
|
const sessionAddress = sessionId === void 0 ? void 0 : sessionRecipient(sessionId);
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2892
|
+
const where = [];
|
|
2893
|
+
const params = [];
|
|
2894
|
+
const recipients = ["to_id = ?", "to_id = '*'"];
|
|
2895
|
+
params.push(forAgentId);
|
|
2896
|
+
if (sessionAddress !== void 0) {
|
|
2897
|
+
recipients.push("to_id = ?");
|
|
2898
|
+
params.push(sessionAddress);
|
|
2899
|
+
}
|
|
2900
|
+
where.push(`(${recipients.join(" OR ")})`);
|
|
2901
|
+
where.push("deleted_at IS NULL");
|
|
2902
|
+
if (!isMailboxLeader(forAgentId)) {
|
|
2903
|
+
where.push("COALESCE(json_extract(data, '$.audience'), 'all') <> 'leaders'");
|
|
2904
|
+
}
|
|
2905
|
+
where.push(`NOT EXISTS (
|
|
2906
|
+
SELECT 1 FROM message_receipts AS read_receipt
|
|
2907
|
+
WHERE read_receipt.message_id = messages.id
|
|
2908
|
+
AND read_receipt.actor_id = ?
|
|
2909
|
+
AND read_receipt.read_at IS NOT NULL
|
|
2910
|
+
)`);
|
|
2911
|
+
params.push(forAgentId);
|
|
2912
|
+
where.push(`NOT EXISTS (
|
|
2913
|
+
SELECT 1 FROM json_each(json_extract(data, '$.readBy')) AS legacy_read
|
|
2914
|
+
WHERE legacy_read.key = ?
|
|
2915
|
+
)`);
|
|
2916
|
+
params.push(forAgentId);
|
|
2917
|
+
where.push("legacy_global_completion = 0");
|
|
2918
|
+
where.push(`NOT EXISTS (
|
|
2919
|
+
SELECT 1 FROM message_receipts AS completed_receipt
|
|
2920
|
+
WHERE completed_receipt.message_id = messages.id
|
|
2921
|
+
AND completed_receipt.actor_id = ?
|
|
2922
|
+
AND completed_receipt.completed_at IS NOT NULL
|
|
2923
|
+
)`);
|
|
2924
|
+
params.push(forAgentId);
|
|
2925
|
+
where.push(`(
|
|
2926
|
+
completed = 0 OR EXISTS (
|
|
2927
|
+
SELECT 1 FROM message_receipts AS any_receipt
|
|
2928
|
+
WHERE any_receipt.message_id = messages.id
|
|
2929
|
+
)
|
|
2930
|
+
)`);
|
|
2931
|
+
const row = this.stmt(
|
|
2932
|
+
`SELECT COUNT(*) AS total FROM messages WHERE ${where.join(" AND ")}`
|
|
2933
|
+
).get(...params);
|
|
2934
|
+
return Number(row?.total ?? 0);
|
|
2848
2935
|
}
|
|
2849
2936
|
async softDelete(mailId, by) {
|
|
2850
2937
|
const message = this.findMessage(mailId);
|
|
@@ -3402,6 +3489,13 @@ async function writeMetadata() {
|
|
|
3402
3489
|
const metadata = { ...serverStatus(), authToken };
|
|
3403
3490
|
await atomicWrite(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
3404
3491
|
`, { mode: 384 });
|
|
3492
|
+
await restrictFilePermissions(metadataPath, {
|
|
3493
|
+
label: "mailbox-server-metadata",
|
|
3494
|
+
// stdio is 'ignore' for this detached process; stderr is where its other
|
|
3495
|
+
// diagnostics go, so a failed hardening is at least consistent with them.
|
|
3496
|
+
warn: (message) => process.stderr.write(`${message}
|
|
3497
|
+
`)
|
|
3498
|
+
});
|
|
3405
3499
|
}
|
|
3406
3500
|
async function removeOwnedMetadata() {
|
|
3407
3501
|
try {
|
|
@@ -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;
|
|
@@ -33,6 +33,31 @@ export declare class SqliteMailbox implements Mailbox {
|
|
|
33
33
|
query(query: MailboxQuery): Promise<MailboxMessage[]>;
|
|
34
34
|
ack(input: MailboxAckInput): Promise<MailboxMessage | null>;
|
|
35
35
|
ackMany(input: MailboxAckBatchInput): Promise<MailboxMessage[]>;
|
|
36
|
+
/**
|
|
37
|
+
* Count the messages this actor has neither read nor completed.
|
|
38
|
+
*
|
|
39
|
+
* Pushed into SQL rather than filtering `readMessages()`. The pre-tool hook
|
|
40
|
+
* asks for this repeatedly — `mailbox-hooks.ts` throttles it to once a
|
|
41
|
+
* second, which bounds the frequency but not the cost — and the JS form
|
|
42
|
+
* materialized EVERY row in `messages`, joined the whole `message_receipts`
|
|
43
|
+
* table, and folded per-actor receipt state across all of them just to
|
|
44
|
+
* return an integer.
|
|
45
|
+
*
|
|
46
|
+
* The predicate is deliberately the same one {@link query} builds for
|
|
47
|
+
* `{ unreadBy, incompleteOnly }`, because it has to agree exactly with
|
|
48
|
+
* `isMessageCompletedForActor` and `isMailboxMessageVisibleTo`:
|
|
49
|
+
*
|
|
50
|
+
* - **unread** — no per-actor receipt carrying `read_at`, and no legacy
|
|
51
|
+
* `readBy` key. Both are checked: `ackMany` writes the receipt row AND
|
|
52
|
+
* mirrors the timestamp into the message's `readBy` JSON.
|
|
53
|
+
* - **incomplete** — not `legacy_global_completion`, no per-actor receipt
|
|
54
|
+
* carrying `completed_at`, and the aggregate `completed` flag counts only
|
|
55
|
+
* when the message has no receipts at all (once any actor has a receipt,
|
|
56
|
+
* completion is per-actor and the aggregate flag is not authoritative).
|
|
57
|
+
* - **audience** — `leaders` mail is invisible unless the actor's base
|
|
58
|
+
* identity is `leader`. This call path carries no role, matching the
|
|
59
|
+
* `isMailboxMessageVisibleTo(message, forAgentId)` it replaces.
|
|
60
|
+
*/
|
|
36
61
|
unreadCount(forAgentId: string, sessionId?: string): Promise<number>;
|
|
37
62
|
softDelete(mailId: string, by: string): Promise<MailboxMessage | null>;
|
|
38
63
|
restore(mailId: string): Promise<MailboxMessage | null>;
|
|
@@ -24,6 +24,23 @@ export interface TechStackConsumerOptions {
|
|
|
24
24
|
}>;
|
|
25
25
|
/** Agent id that the consumer watches for. Default: 'tech-stack'. */
|
|
26
26
|
targetAgent?: string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* The only sender whose `assign` messages may spawn an agent. Default:
|
|
29
|
+
* `'dep-watcher'` — the `watcherAgentId` default of
|
|
30
|
+
* {@link attachDepWatcherBridge}, which is the pipeline this consumer exists
|
|
31
|
+
* to serve. Matched on the base identity, so a session-qualified
|
|
32
|
+
* `dep-watcher@<tag>` also passes.
|
|
33
|
+
*
|
|
34
|
+
* Without this the consumer acted on an `assign` addressed to `tech-stack`
|
|
35
|
+
* from ANY sender. The mailbox is a shared bus: every agent on the project
|
|
36
|
+
* can send one with `mail_send`, and so can any external credential holding
|
|
37
|
+
* `mail.send.actionable` when the HTTP bridge is enabled. The message body
|
|
38
|
+
* then chose a file path and was pasted verbatim into the task of a freshly
|
|
39
|
+
* spawned subagent holding `read`, `fetch` and `mailbox` — a peer-writable
|
|
40
|
+
* path into an agent that can read files, reach the network, and broadcast
|
|
41
|
+
* to everyone. Restricting the sender is what makes the spawn intentional.
|
|
42
|
+
*/
|
|
43
|
+
senderAgentId?: string | undefined;
|
|
27
44
|
/** Agent id that sends the completion ack. Default: 'tech-stack-consumer'. */
|
|
28
45
|
consumerAgentId?: string | undefined;
|
|
29
46
|
/** Polling interval in ms. Default: 5000. */
|
package/dist/core/index.js
CHANGED
|
@@ -1505,24 +1505,34 @@ var RemoteMailbox = class {
|
|
|
1505
1505
|
}
|
|
1506
1506
|
publishHqRegistryEvent(event, payload) {
|
|
1507
1507
|
const publisher = this.hqPublisher;
|
|
1508
|
-
if (!publisher || !event.startsWith("mailbox.agent_") && !event.startsWith("mailbox.client_")) {
|
|
1508
|
+
if (!publisher || this.closed || !event.startsWith("mailbox.agent_") && !event.startsWith("mailbox.client_")) {
|
|
1509
1509
|
return;
|
|
1510
1510
|
}
|
|
1511
1511
|
const mailboxId = `${path5.basename(this.projectDir)}:mailbox`;
|
|
1512
1512
|
const record = typeof payload === "object" && payload !== null ? payload : {};
|
|
1513
1513
|
const agentId = typeof record["agentId"] === "string" ? record["agentId"] : void 0;
|
|
1514
1514
|
const action = event === "mailbox.agent_registered" ? "agent.registered" : event === "mailbox.agent_heartbeat" ? "agent.heartbeat" : event === "mailbox.agent_deregistered" ? "agent.deregistered" : void 0;
|
|
1515
|
-
|
|
1516
|
-
const agent = agentId ? statuses.find((candidate) => candidate.agentId === agentId) : void 0;
|
|
1515
|
+
if (action !== "agent.registered") {
|
|
1517
1516
|
if (action) {
|
|
1518
1517
|
publisher.publishMailboxEvent({
|
|
1519
1518
|
mailboxId,
|
|
1520
1519
|
action,
|
|
1521
|
-
...agent ? { agent } : {},
|
|
1522
1520
|
...agentId ? { summary: agentId } : {}
|
|
1523
1521
|
});
|
|
1524
1522
|
}
|
|
1525
1523
|
if (action !== "agent.heartbeat") this.scheduleHqSnapshot(mailboxId);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
void this.getAgentStatuses().then((statuses) => {
|
|
1527
|
+
if (this.closed) return;
|
|
1528
|
+
const agent = agentId ? statuses.find((candidate) => candidate.agentId === agentId) : void 0;
|
|
1529
|
+
publisher.publishMailboxEvent({
|
|
1530
|
+
mailboxId,
|
|
1531
|
+
action,
|
|
1532
|
+
...agent ? { agent } : {},
|
|
1533
|
+
...agentId ? { summary: agentId } : {}
|
|
1534
|
+
});
|
|
1535
|
+
this.scheduleHqSnapshot(mailboxId);
|
|
1526
1536
|
}).catch(() => {
|
|
1527
1537
|
});
|
|
1528
1538
|
}
|
|
@@ -1654,7 +1664,12 @@ import * as syncFs from "node:fs";
|
|
|
1654
1664
|
import * as path7 from "node:path";
|
|
1655
1665
|
|
|
1656
1666
|
// src/security/file-permissions.ts
|
|
1657
|
-
|
|
1667
|
+
import {
|
|
1668
|
+
restrictDirPermissions,
|
|
1669
|
+
restrictFilePermissions,
|
|
1670
|
+
SECRET_DIR_MODE,
|
|
1671
|
+
SECRET_FILE_MODE
|
|
1672
|
+
} from "@wrongstack/persistence";
|
|
1658
1673
|
|
|
1659
1674
|
// src/utils/pid.ts
|
|
1660
1675
|
function isPidAlive(pid) {
|
|
@@ -7389,6 +7404,24 @@ function deriveCachePrefixKey(systemPrompt) {
|
|
|
7389
7404
|
return key;
|
|
7390
7405
|
}
|
|
7391
7406
|
|
|
7407
|
+
// src/utils/memory-evidence-fence.ts
|
|
7408
|
+
var MEMORY_EVIDENCE_TAG = "memory_evidence";
|
|
7409
|
+
var FENCE_DELIMITER = /\[[ \t]*\/?[ \t]*memory_evidence\b[^\]\n]*\]/gi;
|
|
7410
|
+
function sanitizeMemoryEvidenceBody(text) {
|
|
7411
|
+
return text.replace(FENCE_DELIMITER, (match) => `(${match.slice(1, -1)})`);
|
|
7412
|
+
}
|
|
7413
|
+
function sanitizeMemoryEvidenceSource(source) {
|
|
7414
|
+
const collapsed = source.replace(/[^a-z0-9_.-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/, "");
|
|
7415
|
+
return collapsed || "memory";
|
|
7416
|
+
}
|
|
7417
|
+
function formatMemoryEvidenceBlock(source, body) {
|
|
7418
|
+
const label = sanitizeMemoryEvidenceSource(source);
|
|
7419
|
+
const safe = sanitizeMemoryEvidenceBody(body);
|
|
7420
|
+
return `[${MEMORY_EVIDENCE_TAG} source="${label}"]
|
|
7421
|
+
${safe}
|
|
7422
|
+
[/${MEMORY_EVIDENCE_TAG}]`;
|
|
7423
|
+
}
|
|
7424
|
+
|
|
7392
7425
|
// src/utils/message-invariants.ts
|
|
7393
7426
|
function repairToolUseAdjacency(messages) {
|
|
7394
7427
|
const removedToolUses = [];
|
|
@@ -8014,15 +8047,9 @@ function buildMemoryEvidenceBlocks(ctx) {
|
|
|
8014
8047
|
if (remaining <= 0) break;
|
|
8015
8048
|
const text = entry.text.trim();
|
|
8016
8049
|
if (!text) continue;
|
|
8017
|
-
const source = entry.source.replace(/[^a-z0-9_.-]+/gi, "-").slice(0, 80) || "memory";
|
|
8018
8050
|
const bounded = text.slice(0, remaining);
|
|
8019
8051
|
remaining -= bounded.length;
|
|
8020
|
-
blocks.push({
|
|
8021
|
-
type: "text",
|
|
8022
|
-
text: `[memory_evidence source="${source}"]
|
|
8023
|
-
${bounded}
|
|
8024
|
-
[/memory_evidence]`
|
|
8025
|
-
});
|
|
8052
|
+
blocks.push({ type: "text", text: formatMemoryEvidenceBlock(entry.source, bounded) });
|
|
8026
8053
|
}
|
|
8027
8054
|
return blocks;
|
|
8028
8055
|
}
|
package/dist/defaults/index.js
CHANGED
|
@@ -9820,45 +9820,12 @@ import * as fsp17 from "node:fs/promises";
|
|
|
9820
9820
|
import * as path23 from "node:path";
|
|
9821
9821
|
|
|
9822
9822
|
// src/security/file-permissions.ts
|
|
9823
|
-
import {
|
|
9824
|
-
|
|
9825
|
-
|
|
9826
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
try {
|
|
9830
|
-
const { execFile: execFile2 } = await import("node:child_process");
|
|
9831
|
-
const { promisify: promisify2 } = await import("node:util");
|
|
9832
|
-
const execFileAsync = promisify2(execFile2);
|
|
9833
|
-
const user = windowsAccountName();
|
|
9834
|
-
if (!user) {
|
|
9835
|
-
warn(
|
|
9836
|
-
`[${label}] Could not determine the current Windows user for ${filePath}; skipping icacls hardening.`
|
|
9837
|
-
);
|
|
9838
|
-
return;
|
|
9839
|
-
}
|
|
9840
|
-
await execFileAsync("icacls", [filePath, "/inheritance:r", "/grant:r", `${user}:(F)`], {
|
|
9841
|
-
windowsHide: true
|
|
9842
|
-
});
|
|
9843
|
-
} catch {
|
|
9844
|
-
warn(
|
|
9845
|
-
`[${label}] Could not restrict permissions on ${filePath} \u2014 it may be readable by other users on this system.`
|
|
9846
|
-
);
|
|
9847
|
-
}
|
|
9848
|
-
} else {
|
|
9849
|
-
try {
|
|
9850
|
-
await chmod(filePath, SECRET_FILE_MODE);
|
|
9851
|
-
} catch {
|
|
9852
|
-
}
|
|
9853
|
-
}
|
|
9854
|
-
}
|
|
9855
|
-
function windowsAccountName() {
|
|
9856
|
-
const username = process.env.USERNAME || process.env.USER;
|
|
9857
|
-
if (!username || username.includes("\0")) return void 0;
|
|
9858
|
-
const domain = process.env.USERDOMAIN;
|
|
9859
|
-
if (domain && !domain.includes("\0")) return `${domain}\\${username}`;
|
|
9860
|
-
return username;
|
|
9861
|
-
}
|
|
9823
|
+
import {
|
|
9824
|
+
restrictDirPermissions,
|
|
9825
|
+
restrictFilePermissions,
|
|
9826
|
+
SECRET_DIR_MODE,
|
|
9827
|
+
SECRET_FILE_MODE
|
|
9828
|
+
} from "@wrongstack/persistence";
|
|
9862
9829
|
|
|
9863
9830
|
// src/security/secret-scrubber.ts
|
|
9864
9831
|
var PATTERNS = [
|
package/dist/execution/index.js
CHANGED
|
@@ -7270,7 +7270,12 @@ import { dirname as dirname3 } from "node:path";
|
|
|
7270
7270
|
import { createInterface } from "node:readline";
|
|
7271
7271
|
|
|
7272
7272
|
// src/security/file-permissions.ts
|
|
7273
|
-
|
|
7273
|
+
import {
|
|
7274
|
+
restrictDirPermissions,
|
|
7275
|
+
restrictFilePermissions,
|
|
7276
|
+
SECRET_DIR_MODE,
|
|
7277
|
+
SECRET_FILE_MODE
|
|
7278
|
+
} from "@wrongstack/persistence";
|
|
7274
7279
|
|
|
7275
7280
|
// src/coordination/brain-ledger.ts
|
|
7276
7281
|
var QUESTION_MAX = 200;
|
package/dist/hq/index.js
CHANGED
|
@@ -2050,45 +2050,12 @@ import * as fs3 from "node:fs/promises";
|
|
|
2050
2050
|
import * as path4 from "node:path";
|
|
2051
2051
|
|
|
2052
2052
|
// src/security/file-permissions.ts
|
|
2053
|
-
import {
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
try {
|
|
2060
|
-
const { execFile } = await import("node:child_process");
|
|
2061
|
-
const { promisify } = await import("node:util");
|
|
2062
|
-
const execFileAsync = promisify(execFile);
|
|
2063
|
-
const user = windowsAccountName();
|
|
2064
|
-
if (!user) {
|
|
2065
|
-
warn(
|
|
2066
|
-
`[${label}] Could not determine the current Windows user for ${filePath}; skipping icacls hardening.`
|
|
2067
|
-
);
|
|
2068
|
-
return;
|
|
2069
|
-
}
|
|
2070
|
-
await execFileAsync("icacls", [filePath, "/inheritance:r", "/grant:r", `${user}:(F)`], {
|
|
2071
|
-
windowsHide: true
|
|
2072
|
-
});
|
|
2073
|
-
} catch {
|
|
2074
|
-
warn(
|
|
2075
|
-
`[${label}] Could not restrict permissions on ${filePath} \u2014 it may be readable by other users on this system.`
|
|
2076
|
-
);
|
|
2077
|
-
}
|
|
2078
|
-
} else {
|
|
2079
|
-
try {
|
|
2080
|
-
await chmod(filePath, SECRET_FILE_MODE);
|
|
2081
|
-
} catch {
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
function windowsAccountName() {
|
|
2086
|
-
const username = process.env.USERNAME || process.env.USER;
|
|
2087
|
-
if (!username || username.includes("\0")) return void 0;
|
|
2088
|
-
const domain = process.env.USERDOMAIN;
|
|
2089
|
-
if (domain && !domain.includes("\0")) return `${domain}\\${username}`;
|
|
2090
|
-
return username;
|
|
2091
|
-
}
|
|
2053
|
+
import {
|
|
2054
|
+
restrictDirPermissions,
|
|
2055
|
+
restrictFilePermissions,
|
|
2056
|
+
SECRET_DIR_MODE,
|
|
2057
|
+
SECRET_FILE_MODE
|
|
2058
|
+
} from "@wrongstack/persistence";
|
|
2092
2059
|
|
|
2093
2060
|
// src/utils/pid.ts
|
|
2094
2061
|
function isPidAlive(pid) {
|