@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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +112 -0
- package/dist/interactions/chat.d.ts +28 -0
- package/dist/interactions/chat.js +199 -0
- package/dist/interactions/prompts.d.ts +68 -0
- package/dist/interactions/prompts.js +246 -0
- package/dist/interactions/receipts.d.ts +7 -0
- package/dist/interactions/receipts.js +69 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +64 -0
- package/dist/outbound/delivery.d.ts +131 -0
- package/dist/outbound/delivery.js +404 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +81 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +18 -0
- package/dist/session-meta.js +39 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +65 -0
- package/dist/state/identities.js +135 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +176 -0
- package/dist/state/ledger.js +256 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- 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
|
-
#
|
|
1
|
+
# @sigrun/ext-gateway
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|