@sigrun/ext-gateway 0.0.0-stage → 0.1.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +112 -0
  38. package/dist/interactions/chat.d.ts +28 -0
  39. package/dist/interactions/chat.js +199 -0
  40. package/dist/interactions/prompts.d.ts +68 -0
  41. package/dist/interactions/prompts.js +246 -0
  42. package/dist/interactions/receipts.d.ts +7 -0
  43. package/dist/interactions/receipts.js +69 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +64 -0
  52. package/dist/outbound/delivery.d.ts +131 -0
  53. package/dist/outbound/delivery.js +404 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +81 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +18 -0
  65. package/dist/session-meta.js +39 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +65 -0
  69. package/dist/state/identities.js +135 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +176 -0
  73. package/dist/state/ledger.js +256 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
package/CHANGELOG.md ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.2] - 2026-10-28
11
+
12
+ ### Fixed
13
+ - The interaction notifier and chat answer correlation now inspect
14
+ records through core's non-consuming `peekInteraction` instead of the
15
+ one-shot delivery read. The early-answer race is closed: a secret
16
+ interaction answered in the webui before the notifier's first pass no
17
+ longer loses the owning extension's raw-secret delivery (the gateway
18
+ owns no delivery; regression-tested with a fixture owner).
19
+ - Home-DM settled notices report failed/aborted runs truthfully:
20
+ `Reply ready` only for answered runs; unanswered runs read `Run ended
21
+ without a reply` with the settle reason (empty reasons included).
22
+ Recipient, dedup, bound-conversation suppression, and `notify.home`
23
+ gating are unchanged.
24
+
25
+ ## [0.1.1] - 2026-10-27
26
+ ### Fixed
27
+ - First real publish. `0.1.0` was registered with the npm name bootstrap but
28
+ never published: a staged `0.1.0` entry (left by the bootstrap) made the
29
+ registry reject the release with `E409 Cannot publish over previously
30
+ staged version`. Rejecting that entry removed the package name entirely,
31
+ so the name was re-bootstrapped and `0.1.1` published past it. The package
32
+ contents are the 0.1.0 contents plus the `@sigrun/shared` 0.2.0 peer.
33
+
34
+ ### Changed
35
+ - `@sigrun/shared` peer follows the family 0.2.0 cut.
36
+
37
+ ## [0.1.0] - 2026-10-23
38
+ ### Added
39
+ - Channel supervision with retry/backoff, adapter status and pairing
40
+ health surfaces, and bounded ordered shutdown.
41
+ - The live inbound pipeline: dedupe, allowlist/pairing/self-link access,
42
+ DM and group routing, albums, media uploads, and chat commands.
43
+ - Durable final delivery with access re-checks, retry classification,
44
+ crash recovery, home notifications, and one-shot job targets.
45
+ - Live response previews through adapter editing, typing fallback,
46
+ authoritative final hand-off, and preview cleanup at shutdown.
47
+ - `send_message` and `list_chats` agent tools and the bound-chat prompt
48
+ section.
49
+ - User-deletion cleanup for gateway identity links and home chats.
50
+ - Operator guide covering configuration, pairing, groups, jobs, and
51
+ disabling the engine or one channel.
52
+ - Repository scaffold from the sigrun-ext-codemode shape (host modules as
53
+ `peerDependencies` mirrored in `devDependencies`; `@sigrun/sigrun` and
54
+ `@sigrun/shared` as `file:` devDependencies; manifest hygiene check
55
+ wired into `npm test`; dist committed).
56
+ - The loader-contract factory: `createGateway(host)` →
57
+ `{ extension, gateway }` with the pi-durable extension (declared name
58
+ `gateway`) and the engine skeleton — `start` creates
59
+ `<stateDir>/gateway/`, starts every READY channel adapter, and records
60
+ the channel set; `stopIntake`/`stop` resolve; `health` reads `"ok"`.
61
+ - `parseGatewayConfig`/`readConfig` (live per-read parsing of
62
+ `settings/gateway.json`; malformed channel/group blocks parse to
63
+ `{failed}` forms without affecting siblings).
64
+ - `JsonFile<T>` state primitive (serialized updates, atomic 0600 writes,
65
+ malformed-file errors naming the path).
66
+ - `hashId` + the scoped `createLog` logger with per-key `once` dedupe.
67
+ - `test/fake-host.ts`: the in-memory `SigrunHost` (conversations, users,
68
+ access, identity, models, commands, interactions, stores; `fake.calls`
69
+ ordered log, `fake.setClaimed`, `fake.pushView`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael MacLeod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,242 @@
1
- # Temporary Holding Version
1
+ # @sigrun/ext-gateway
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The gateway engine for [sigrun](https://github.com/sigrun-ai/sigrun):
4
+ chat-platform supervision, access-controlled inbound conversations,
5
+ live responses, and durable outbound delivery. Platform adapters are
6
+ separate extensions; this package owns their shared lifecycle and chat
7
+ semantics.
8
+
9
+ ## Install and operation
10
+
11
+ Sigrun includes the gateway as its fourth built-in extension, enabled by
12
+ default. Install or enable channel adapters through sigrun's normal
13
+ extension configuration, then configure the gateway in
14
+ `<config-dir>/settings/gateway.json`.
15
+
16
+ To disable only the engine, set this in sigrun's `settings.json` and
17
+ restart. Contributed adapters then remain inert and do not receive chat
18
+ traffic.
19
+
20
+ ```json
21
+ {
22
+ "builtinExtensions": { "gateway": false }
23
+ }
24
+ ```
25
+
26
+ To disable one channel while leaving the engine and other channels
27
+ running, set `"enabled": false` in its gateway channel block. A channel
28
+ without a block is also disabled. Channel enablement is restart-required;
29
+ other gateway settings are live reads.
30
+
31
+ `GET /v1/ext/gateway/status` is the admin status surface. It reports
32
+ adapter state, failures, retry/backoff state, and pending pairing
33
+ requests (with sender ids hashed, never raw).
34
+
35
+ ## Configuration reference (spec §8.1)
36
+
37
+ The top-level object has three keys:
38
+
39
+ | Key | Meaning |
40
+ | --- | --- |
41
+ | `channels` | Map of adapter channel id to channel settings. |
42
+ | `media` | `{ "maxBytes": number }`; per-attachment cap, default 16 MiB. |
43
+ | `notify` | `{ "home": boolean }`; home-DM notifications, default `true`. |
44
+
45
+ A channel block supports:
46
+
47
+ | Key | Default | Meaning |
48
+ | --- | --- | --- |
49
+ | `enabled` | `true` | Start this channel at boot. Changes require restart. |
50
+ | `dm` | `"pairing"` | Unknown-DM policy: `"pairing"`, `"allowlist"`, or `"disabled"`. |
51
+ | `selfLink` | `true` for pairing, otherwise `false` | Permit `/link` self-linking in DMs. |
52
+ | `allow` | `[]` | Static sender mappings: `[{ "sender": "100000001", "user": "alice" }]`. These take precedence over paired/self-linked identities. |
53
+ | `groups` | `[]` | Explicit group-chat policy rows; unlisted groups are ignored. |
54
+ | `busy` | `"followUp"` | Input while a run is live: `"followUp"` or `"steer"`. |
55
+ | `stream` | `true` | Stream live responses when the adapter supports editing. |
56
+ | `allowBots` | `false` | Admit bot senders (the loop guard still applies). |
57
+
58
+ Group rows have `chat` (platform chat id) and `owner` (sigrun user)
59
+ required, plus:
60
+
61
+ - `mention`: `"required"` (default) admits only messages that mention
62
+ or reply to the bot; `"always"` admits every otherwise-eligible
63
+ message without a mention.
64
+ - `members`: `"linked"` (default) or `"anyone"`. The open `"anyone"`
65
+ mode requires a non-empty `tools` array; `extensions` optionally pins
66
+ the extension selection as well. Unknown senders still cannot use
67
+ control commands or answer interactions.
68
+ - `tools` and `extensions`: optional arrays for the group's fixed agent
69
+ selection. The gateway prompt is included in open-group selections.
70
+
71
+ A malformed channel block appears as `failed` in the status surface.
72
+ A malformed group block drops only that group's messages and logs the
73
+ reason; group failures do not appear in channel status. Neither disables
74
+ siblings.
75
+
76
+ Example (synthetic ids):
77
+
78
+ ```json
79
+ {
80
+ "channels": {
81
+ "telegram": {
82
+ "dm": "pairing",
83
+ "selfLink": true,
84
+ "groups": [
85
+ {
86
+ "chat": "-100000000002",
87
+ "owner": "alice",
88
+ "members": "linked",
89
+ "mention": "required"
90
+ }
91
+ ],
92
+ "busy": "followUp",
93
+ "stream": true,
94
+ "allowBots": false
95
+ }
96
+ },
97
+ "media": { "maxBytes": 16777216 },
98
+ "notify": { "home": true }
99
+ }
100
+ ```
101
+
102
+ ## Pairing and self-linking
103
+
104
+ With `dm: "pairing"`, an unknown DM sender gets a single-use pairing
105
+ code. Their message is not submitted. An effective admin approves or
106
+ denies it in any webui/TUI conversation:
107
+
108
+ ```text
109
+ /pair
110
+ /pair approve <code> <user>
111
+ /pair deny <code>
112
+ ```
113
+
114
+ Admins' home DMs also receive pairing notices, with approve/deny buttons
115
+ where supported. Pairing requests expire after one hour, with at most
116
+ one pending request per sender, three per channel, and a ten-minute
117
+ request cooldown per sender. Deleting a user removes their links and
118
+ home chats but does not reset an existing request cooldown.
119
+
120
+ Self-linking requires no admin and is how the first operator can link:
121
+
122
+ 1. In webui/TUI, run `/link <channel>`.
123
+ 2. Send the returned `/link <code>` to the bot in a direct message on
124
+ that channel.
125
+ 3. The chat account is linked to the invoking sigrun user.
126
+
127
+ Codes are single-use and expire after one hour. A code posted in a
128
+ group is invalidated; redeem only in a DM. An existing link to another
129
+ user is never silently re-pointed. Static `allow` mappings take
130
+ precedence over stored links and are useful for a closed allowlist.
131
+ Use `/home` in a DM to move a user's notification home chat there.
132
+
133
+ ## Chat commands and jobs
134
+
135
+ Bound chats support `/new`, `/stop`, `/model`, `/thinking`, `/compact`,
136
+ `/attach`, `/status`, `/home`, `/link`, `/pair`, and `/help`. Except for
137
+ the identity commands, control requires write access to the bound
138
+ conversation; `/attach` additionally requires write access to its target.
139
+ Native command menus merge non-colliding extension commands.
140
+
141
+ A prompt job with `conversation: "new"` can deliver its first settled
142
+ answer to gateway targets using `deliver`:
143
+
144
+ ```json
145
+ {
146
+ "name": "morning-note",
147
+ "schedule": { "cron": "0 9 * * *", "timezone": "UTC" },
148
+ "action": {
149
+ "kind": "prompt",
150
+ "prompt": "Write the morning note.",
151
+ "conversation": "new",
152
+ "deliver": ["loop", "telegram:-100000000002"]
153
+ }
154
+ }
155
+ ```
156
+
157
+ Use a channel id for a user's home DM or `<channel>:<chatId>` for an
158
+ explicit chat (thread suffixes belong to the opaque platform chat id).
159
+ An explicit target must be that home chat or a chat bound to a
160
+ conversation the job owner can write. Delivery also re-checks access.
161
+ Job answers are one-shot and durable: retries or later submissions do
162
+ not duplicate the delivered answer. A job targeting an existing bound
163
+ conversation uses that conversation's ordinary gateway fan-out instead.
164
+
165
+ ## Interactions in chat
166
+
167
+ Pending interaction records render through the shared block-tree text
168
+ projection. A single closed choice becomes buttons when they fit the
169
+ channel's button limit, or a numbered list otherwise; a lone text or
170
+ long-text field accepts a reply to its prompt. Other forms, including
171
+ all forms containing secrets, point to the webui instead of collecting
172
+ values over chat.
173
+
174
+ Button taps, numbered replies, and reply-to text answers require a linked
175
+ sender with current write access to the conversation. Numbered answers
176
+ match only when exactly one choice interaction is pending in that chat.
177
+ A prompt answered elsewhere is edited to show its outcome and loses its
178
+ buttons; a later tap reports that outcome rather than answering again.
179
+ Unbound conversations notify each write-access user's home DMs, while
180
+ bound conversations prompt each bound chat once.
181
+
182
+ Every gateway interaction read is a non-consuming `peekInteraction`:
183
+ the gateway owns no secret delivery (secret records are webui-plan), so
184
+ inspection can never eat the owning extension's one-shot raw-secret
185
+ read. A secret interaction answered in the webui before the notifier's
186
+ first pass is safe — the notifier peeks masked data and the owner still
187
+ receives the secret exactly once.
188
+
189
+ Home-DM settled notices are truthful about the outcome: an answered run
190
+ reads `Reply ready`; a failed or aborted run reads `Run ended without a
191
+ reply` with the settle reason, never reply-ready language.
192
+
193
+ ### Prompt retention
194
+
195
+ Platform prompt messages are never deleted by the gateway. What the
196
+ gateway retains locally is the correlation/edit metadata in
197
+ `prompts.json`: message refs and answer plans expire seven days after
198
+ the interaction settles, so a settlement edit (or a late delivery
199
+ receipt's self-heal edit) is only possible inside that window. Compact
200
+ settled tombstones — recipient identity, settlement time, and outcome
201
+ text — persist permanently for replay suppression: a delayed receipt
202
+ or replay after the row expired recreates nothing and can never make
203
+ an answered prompt answerable again. Pending, unsettled prompts are
204
+ never age-pruned. Refs/plans are therefore bounded by active prompts
205
+ plus seven days of settled history; the small tombstone set grows with
206
+ interaction history by design (the file as a whole is NOT bounded).
207
+ Legacy unversioned state migrates on startup without losing refs or
208
+ re-opening old prompts; a wholly malformed `prompts.json` fails loudly
209
+ rather than silently resetting interaction state.
210
+
211
+ ## Delivery, state, and shutdown
212
+
213
+ Responses stream through adapter edit support where available. Final
214
+ outbound messages enter a durable ledger; transient sends retry with
215
+ backoff, chat-gone errors disable the dead binding, and delivered
216
+ entries prune after seven days. State files live under
217
+ `<state-dir>/ext-gateway/gateway/` with atomic 0600 writes.
218
+
219
+ On shutdown, intake closes and open album windows flush. The event bus
220
+ finishes the last run-settle window, delivery workers drain for up to
221
+ 10 seconds, adapter stops are bounded at 5 seconds, and preview watches
222
+ are released. Undelivered `pending` or `attempting` entries remain in
223
+ the ledger for restart recovery; attempting entries are marked as
224
+ recovered before resend.
225
+
226
+ Restarting mid-operation is safe for durable delivery, with one explicit
227
+ exception: SIGKILL inside the 1.5-second album window loses the merged
228
+ submission by design. Its events were already deduplicated, so platform
229
+ redelivery is a no-op; graceful shutdown flushes the album instead.
230
+
231
+ ## Development
232
+
233
+ ```sh
234
+ npm install
235
+ npm test # vitest + manifest check
236
+ npm run typecheck
237
+ npm run build # tsc -> dist/ (COMMIT the result)
238
+ ```
239
+
240
+ Tests use `test/fake-host.ts` and synthetic users/channels only. Adapter
241
+ authors should read sigrun's `docs/extending.md`; this README is the
242
+ operator guide for the built-in engine.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Pairing/link codes (spec §8.2): 8 characters drawn from a 32-character
3
+ * unambiguous alphabet (no I/O/0/1 — the Hermes/OpenClaw shared
4
+ * parameter set) via `crypto.randomInt`, plus the normalizer that folds
5
+ * user-typed input (whitespace, `-` separators, lowercase) onto the
6
+ * canonical form before any comparison. Codes are NEVER logged.
7
+ */
8
+ /** The 32-character unambiguous code alphabet (no I, O, 0, 1). */
9
+ export declare const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
10
+ /** Draw one code: 8 uniform characters from {@link CODE_ALPHABET}. */
11
+ export declare function newCode(): string;
12
+ /**
13
+ * Fold user-typed input onto the canonical form: uppercase, then drop
14
+ * every character outside the alphabet (separators, whitespace, the
15
+ * ambiguous glyphs a paste may carry). An input that normalizes to the
16
+ * empty string simply never matches an issued code.
17
+ */
18
+ export declare function normalizeCode(input: string): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pairing/link codes (spec §8.2): 8 characters drawn from a 32-character
3
+ * unambiguous alphabet (no I/O/0/1 — the Hermes/OpenClaw shared
4
+ * parameter set) via `crypto.randomInt`, plus the normalizer that folds
5
+ * user-typed input (whitespace, `-` separators, lowercase) onto the
6
+ * canonical form before any comparison. Codes are NEVER logged.
7
+ */
8
+ import { randomInt } from "node:crypto";
9
+ /** The 32-character unambiguous code alphabet (no I, O, 0, 1). */
10
+ export const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
11
+ /** The pinned code length (spec §8.2). */
12
+ const CODE_LENGTH = 8;
13
+ /** Draw one code: 8 uniform characters from {@link CODE_ALPHABET}. */
14
+ export function newCode() {
15
+ let code = "";
16
+ for (let i = 0; i < CODE_LENGTH; i++) {
17
+ code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
18
+ }
19
+ return code;
20
+ }
21
+ /**
22
+ * Fold user-typed input onto the canonical form: uppercase, then drop
23
+ * every character outside the alphabet (separators, whitespace, the
24
+ * ambiguous glyphs a paste may carry). An input that normalizes to the
25
+ * empty string simply never matches an issued code.
26
+ */
27
+ export function normalizeCode(input) {
28
+ let out = "";
29
+ for (const char of input.toUpperCase()) {
30
+ if (CODE_ALPHABET.includes(char))
31
+ out += char;
32
+ }
33
+ return out;
34
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Self-link codes (spec §8.2): the single-use, channel-bound codes the
3
+ * webui/TUI `/link <channel>` command issues and the chat-side
4
+ * `/link <code>` redeems. Proof by holding both sessions — the code is
5
+ * the only bridge.
6
+ *
7
+ * In memory by design: a restart voids outstanding codes (the user
8
+ * re-runs `/link <channel>`; the webui session outlives nothing). The
9
+ * pinned expiry matches pairing codes (3600000 ms).
10
+ */
11
+ /** The outstanding self-link codes (one instance per engine). */
12
+ export declare class LinkCodes {
13
+ private readonly live;
14
+ /** Issue a code binding `user` on `channel`. */
15
+ issue(user: string, channel: string, now: number): string;
16
+ /**
17
+ * Redeem `code` on `channel`: the bound user when live and unexpired,
18
+ * undefined otherwise. A SUCCESSFUL redeem consumes the code; a
19
+ * wrong-channel or expired attempt does not (a wrong-channel probe
20
+ * learns nothing).
21
+ */
22
+ redeem(code: string, channel: string, now: number): string | undefined;
23
+ /**
24
+ * Peek WITHOUT consuming: the bound user when live, unexpired, and
25
+ * bound to `channel` — the self-link conflict guard reads the
26
+ * binding before the single-use consume so a conflict reply leaves
27
+ * the code live (the user fixes the link, then sends it again).
28
+ */
29
+ peek(code: string, channel: string, now: number): string | undefined;
30
+ /** Void a code outright (the group-message leak path: the code was
31
+ * seen by other members, so it can never redeem again). */
32
+ invalidate(code: string): void;
33
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Self-link codes (spec §8.2): the single-use, channel-bound codes the
3
+ * webui/TUI `/link <channel>` command issues and the chat-side
4
+ * `/link <code>` redeems. Proof by holding both sessions — the code is
5
+ * the only bridge.
6
+ *
7
+ * In memory by design: a restart voids outstanding codes (the user
8
+ * re-runs `/link <channel>`; the webui session outlives nothing). The
9
+ * pinned expiry matches pairing codes (3600000 ms).
10
+ */
11
+ import { newCode } from "./codes.js";
12
+ /** The pinned expiry (ms) — the same window as pairing codes. */
13
+ const LINK_TTL_MS = 3_600_000;
14
+ /** The outstanding self-link codes (one instance per engine). */
15
+ export class LinkCodes {
16
+ live = new Map();
17
+ /** Issue a code binding `user` on `channel`. */
18
+ issue(user, channel, now) {
19
+ const code = newCode();
20
+ this.live.set(code, { user, channel, issuedAt: now });
21
+ return code;
22
+ }
23
+ /**
24
+ * Redeem `code` on `channel`: the bound user when live and unexpired,
25
+ * undefined otherwise. A SUCCESSFUL redeem consumes the code; a
26
+ * wrong-channel or expired attempt does not (a wrong-channel probe
27
+ * learns nothing).
28
+ */
29
+ redeem(code, channel, now) {
30
+ const issued = this.live.get(code);
31
+ if (issued === undefined)
32
+ return undefined;
33
+ if (issued.channel !== channel)
34
+ return undefined;
35
+ if (now - issued.issuedAt >= LINK_TTL_MS) {
36
+ this.live.delete(code);
37
+ return undefined;
38
+ }
39
+ this.live.delete(code);
40
+ return issued.user;
41
+ }
42
+ /**
43
+ * Peek WITHOUT consuming: the bound user when live, unexpired, and
44
+ * bound to `channel` — the self-link conflict guard reads the
45
+ * binding before the single-use consume so a conflict reply leaves
46
+ * the code live (the user fixes the link, then sends it again).
47
+ */
48
+ peek(code, channel, now) {
49
+ const issued = this.live.get(code);
50
+ if (issued === undefined)
51
+ return undefined;
52
+ if (issued.channel !== channel)
53
+ return undefined;
54
+ if (now - issued.issuedAt >= LINK_TTL_MS)
55
+ return undefined;
56
+ return issued.user;
57
+ }
58
+ /** Void a code outright (the group-message leak path: the code was
59
+ * seen by other members, so it can never redeem again). */
60
+ invalidate(code) {
61
+ this.live.delete(code);
62
+ }
63
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The bot-loop guard (spec §8.2, from Hermes): an admitted bot may send
3
+ * at most 10 messages per chat per 60000 ms; once the cap trips, the
4
+ * chat enters a 300000 ms cooldown during which EVERY bot message on it
5
+ * drops. Human senders never pass through the guard.
6
+ */
7
+ /** The loop guard, one instance per engine (per chat key). */
8
+ export declare class LoopGuard {
9
+ private readonly chats;
10
+ /**
11
+ * May this bot message through? Records the admit on true; arms the
12
+ * cooldown on false (the refusal itself is the trip-wire — an
13
+ * attacker probing the cap re-arms the cooldown with every attempt
14
+ * inside it, which is exactly the behavior a loop needs stopped).
15
+ */
16
+ admit(chatKey: string, now: number): boolean;
17
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The bot-loop guard (spec §8.2, from Hermes): an admitted bot may send
3
+ * at most 10 messages per chat per 60000 ms; once the cap trips, the
4
+ * chat enters a 300000 ms cooldown during which EVERY bot message on it
5
+ * drops. Human senders never pass through the guard.
6
+ */
7
+ /** The pinned window (ms). */
8
+ const WINDOW_MS = 60_000;
9
+ /** The pinned per-window cap. */
10
+ const CAP = 10;
11
+ /** The pinned cooldown (ms). */
12
+ const COOLDOWN_MS = 300_000;
13
+ /** The loop guard, one instance per engine (per chat key). */
14
+ export class LoopGuard {
15
+ chats = new Map();
16
+ /**
17
+ * May this bot message through? Records the admit on true; arms the
18
+ * cooldown on false (the refusal itself is the trip-wire — an
19
+ * attacker probing the cap re-arms the cooldown with every attempt
20
+ * inside it, which is exactly the behavior a loop needs stopped).
21
+ */
22
+ admit(chatKey, now) {
23
+ const state = this.chats.get(chatKey) ?? { admits: [] };
24
+ if (state.cooldownUntil !== undefined && now < state.cooldownUntil) {
25
+ return false;
26
+ }
27
+ // A cooldown that lifted leaves no residue: the window restarts.
28
+ delete state.cooldownUntil;
29
+ const windowStart = now - WINDOW_MS;
30
+ const inWindow = state.admits.filter((at) => at > windowStart);
31
+ if (inWindow.length >= CAP) {
32
+ state.cooldownUntil = now + COOLDOWN_MS;
33
+ state.admits = inWindow;
34
+ this.chats.set(chatKey, state);
35
+ return false;
36
+ }
37
+ inWindow.push(now);
38
+ state.admits = inWindow;
39
+ this.chats.set(chatKey, state);
40
+ return true;
41
+ }
42
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The pairing store (spec §8.2): pending pairing requests persisted at
3
+ * `<stateDir>/gateway/pairing.json`.
4
+ *
5
+ * Pinned limits (the Hermes/OpenClaw shared parameters):
6
+ * - at most 1 pending request per sender (`limited: "sender"`);
7
+ * - at most 1 new request per sender per 600000 ms (`limited:
8
+ * "cooldown"` — enforced even after the previous request left the
9
+ * queue, so a spamming sender gets exactly one reply per window);
10
+ * - at most 3 pending per channel (`limited: "channel"` — a visible
11
+ * DoS floor an admin clears through `/pair deny` and the status
12
+ * route).
13
+ *
14
+ * Codes expire after 3600000 ms; expired entries are dropped on every
15
+ * read. Codes are never logged.
16
+ */
17
+ import type { ChatRef } from "@sigrun/sigrun/extension";
18
+ /** The pinned expiry (ms). */
19
+ export declare const PAIRING_TTL_MS = 3600000;
20
+ /** One pending pairing request. */
21
+ export interface PairingRequest {
22
+ readonly code: string;
23
+ readonly channel: string;
24
+ readonly senderId: string;
25
+ readonly senderName?: string;
26
+ /** The requester's DM — the approval's "linked" reply routes here. */
27
+ readonly chat: ChatRef;
28
+ readonly createdAt: number;
29
+ }
30
+ /** The request outcome: the code, or which limit applied. */
31
+ export type RequestResult = {
32
+ readonly code: string;
33
+ } | {
34
+ readonly limited: "sender" | "cooldown" | "channel";
35
+ };
36
+ /**
37
+ * The pairing queue, one instance per engine. The public API is
38
+ * SYNCHRONOUS by interface (the pipeline's step 5 answers inline), so
39
+ * every mutation writes the file through JsonFile's SYNCHRONOUS
40
+ * update — a crash between the verdict and a deferred flush would
41
+ * silently lose a pending request (the operator's queue).
42
+ */
43
+ export declare class PairingStore {
44
+ private readonly file;
45
+ private readonly now;
46
+ private mirror;
47
+ constructor(path: string, opts?: {
48
+ readonly now?: () => number;
49
+ });
50
+ /**
51
+ * Create (or refuse) a pairing request. The caller hands `now` so the
52
+ * pipeline can pin a clock in tests; the store's own clock seeds the
53
+ * cooldown record.
54
+ */
55
+ request(channel: string, sender: {
56
+ id: string;
57
+ name?: string;
58
+ }, chat: ChatRef, now: number): RequestResult;
59
+ /** The live pending rows, optionally filtered to one channel. */
60
+ pending(channel?: string): readonly PairingRequest[];
61
+ /** Approve by code: the request leaves the queue and returns. */
62
+ approve(code: string, now?: number): PairingRequest | undefined;
63
+ /** Deny by code: true when a live request was removed. */
64
+ deny(code: string): boolean;
65
+ /** The expired-row sweep: expired requests count against no limit. */
66
+ private livePending;
67
+ /**
68
+ * Persist the mirror: the SYNCHRONOUS atomic write (see the class
69
+ * doc). A failed persist is loud, never swallowed — the verdict
70
+ * already returned to the pipeline, but the operator must see the
71
+ * file diverged (the JsonFile throw names the path).
72
+ */
73
+ private persist;
74
+ }