@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-linking (spec §8.2): the webui/TUI `/link <channel>` command
|
|
3
|
+
* issues a single-use code bound to the invoking user and channel, and
|
|
4
|
+
* the chat-side `redeemLink` (pipeline step 4) consumes it in a DM.
|
|
5
|
+
*
|
|
6
|
+
* Proof by holding both sessions: the code is the only bridge, so it is
|
|
7
|
+
* single-use, channel-bound, expires with pairing codes (3600000 ms),
|
|
8
|
+
* and a code exposed in a GROUP is invalidated (other members saw it —
|
|
9
|
+
* M9; the group refusal lives in the pipeline step, which owns the
|
|
10
|
+
* chat-shape check).
|
|
11
|
+
*
|
|
12
|
+
* A sender already linked to another user gets core's conflict refusal
|
|
13
|
+
* and the pinned reply; the code is NOT consumed until the link lands,
|
|
14
|
+
* so the user can remove the conflicting link and send the SAME code.
|
|
15
|
+
*/
|
|
16
|
+
import { normalizeCode } from "../access/codes.js";
|
|
17
|
+
import { chatProviderOf } from "../state/identities.js";
|
|
18
|
+
/** The pinned refusal for an unnamed caller (spec §8.2). */
|
|
19
|
+
export const UNNAMED_CALLER = "run this as a named user";
|
|
20
|
+
/** The pinned conflict reply (the code stays live). */
|
|
21
|
+
export const LINK_CONFLICT_REPLY = "This chat account is already linked to another sigrun user. Remove that link under account links in sigrun, then send the code again.";
|
|
22
|
+
/** The pinned invalid-code reply. */
|
|
23
|
+
const INVALID_CODE_REPLY = "That code is not valid.";
|
|
24
|
+
/**
|
|
25
|
+
* Handle `/link <code>` from a DM: redeem, link, and return the reply
|
|
26
|
+
* text (the pipeline sends it). Core owns the conflict check; the
|
|
27
|
+
* static allowlist never blocks a self-link (it shadows the link
|
|
28
|
+
* afterward, exactly as any stale link).
|
|
29
|
+
*/
|
|
30
|
+
export async function redeemLink(deps, channel, event) {
|
|
31
|
+
const code = normalizeCode(event.text.replace(/^\/link\s*/, ""));
|
|
32
|
+
const now = deps.now();
|
|
33
|
+
const user = deps.linkCodes.peek(code, channel, now);
|
|
34
|
+
if (user === undefined)
|
|
35
|
+
return INVALID_CODE_REPLY;
|
|
36
|
+
const linked = await deps.identities.link(channel, event.sender.id, user, "self", event.chat);
|
|
37
|
+
if (!linked.linked)
|
|
38
|
+
return LINK_CONFLICT_REPLY;
|
|
39
|
+
// Redeem only after the link succeeds: a conflict keeps the code live.
|
|
40
|
+
const redeemed = deps.linkCodes.redeem(code, channel, now);
|
|
41
|
+
if (redeemed === undefined) {
|
|
42
|
+
deps.log.warn(`self-link for user "${user}" on provider "${chatProviderOf(channel)}" is durable but code redemption failed`);
|
|
43
|
+
return INVALID_CODE_REPLY;
|
|
44
|
+
}
|
|
45
|
+
deps.log.info(`self-link landed on channel "${channel}" for user "${redeemed}"`);
|
|
46
|
+
return `You are linked to sigrun as ${redeemed}.`;
|
|
47
|
+
}
|
|
48
|
+
/** Build the `link` slash command over the engine's shared state. */
|
|
49
|
+
export function createLinkCommand(deps) {
|
|
50
|
+
return {
|
|
51
|
+
name: "link",
|
|
52
|
+
description: "Link this chat account to a channel with a single-use code",
|
|
53
|
+
args: "<channel>",
|
|
54
|
+
run: async (ctx) => {
|
|
55
|
+
if (ctx.principal.user === undefined) {
|
|
56
|
+
return { notice: UNNAMED_CALLER };
|
|
57
|
+
}
|
|
58
|
+
const channel = ctx.args.trim();
|
|
59
|
+
if (channel === "") {
|
|
60
|
+
return { notice: "usage: /link <channel>" };
|
|
61
|
+
}
|
|
62
|
+
if (!deps.knownChannels().includes(channel)) {
|
|
63
|
+
return { notice: `unknown channel ${channel}.` };
|
|
64
|
+
}
|
|
65
|
+
const config = await deps.readConfig();
|
|
66
|
+
const block = config.channels[channel];
|
|
67
|
+
if (block === undefined || "failed" in block || !block.enabled) {
|
|
68
|
+
return { notice: `channel ${channel} is disabled.` };
|
|
69
|
+
}
|
|
70
|
+
const code = deps.linkCodes.issue(ctx.principal.user, channel, deps.now());
|
|
71
|
+
return {
|
|
72
|
+
notice: `Link code ${code}. Send "/link ${code}" to the bot in a direct ` +
|
|
73
|
+
`message on ${channel} to finish linking.`,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/pair` command (spec §8.2): the admin surface for pending
|
|
3
|
+
* pairing requests, runnable from any webui/TUI conversation through
|
|
4
|
+
* the extension-command invoke route (`POST /v1/conversations/:id/
|
|
5
|
+
* commands/pair`) — and, through the same decision functions, from
|
|
6
|
+
* chat via the pairing-notice buttons (§8.2: every admin's home DM
|
|
7
|
+
* gets the request; the buttons carry opaque tokens the pipeline
|
|
8
|
+
* resolves).
|
|
9
|
+
*
|
|
10
|
+
* Admin-only (EFFECTIVE admin — the caller's principal carries it from
|
|
11
|
+
* the stored flag OR the persisted group grant, C8). An unnamed caller
|
|
12
|
+
* (the no-auth loopback method) is refused with the pinned
|
|
13
|
+
* "run this as a named user".
|
|
14
|
+
*
|
|
15
|
+
* `approve <code> <user>` links the REQUESTER (the platform sender who
|
|
16
|
+
* asked to pair) to the named directory user via the identity store
|
|
17
|
+
* (`via: "pairing"`), sets their DM as the home chat, and sends them a
|
|
18
|
+
* "linked" message through their channel. Core refuses a sender already
|
|
19
|
+
* linked to another user; the request then stays pending for an admin
|
|
20
|
+
* to resolve (deny, or remove the link).
|
|
21
|
+
*/
|
|
22
|
+
import type { ChannelContribution } from "@sigrun/sigrun/extension";
|
|
23
|
+
import type { EngineLog } from "../log.js";
|
|
24
|
+
import type { IdentityStore } from "../state/identities.js";
|
|
25
|
+
import type { PairingStore } from "../access/pairing.js";
|
|
26
|
+
/** The `/pair` decision path's runtime dependencies (the engine wires them). */
|
|
27
|
+
export interface PairDeps {
|
|
28
|
+
/** The host: the directory the approved user must exist in. */
|
|
29
|
+
readonly host: {
|
|
30
|
+
readonly users: {
|
|
31
|
+
list(): Promise<readonly {
|
|
32
|
+
readonly user: string;
|
|
33
|
+
}[]>;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
readonly identities: IdentityStore;
|
|
37
|
+
readonly pairing: PairingStore;
|
|
38
|
+
/**
|
|
39
|
+
* The channel adapter for `channelOf(id)` — the "linked" reply rides
|
|
40
|
+
* the requester's own channel (undefined: the channel went away; the
|
|
41
|
+
* reply is skipped, the link still lands).
|
|
42
|
+
*/
|
|
43
|
+
readonly channelOf: (channel: string) => ChannelContribution | undefined;
|
|
44
|
+
readonly log: EngineLog;
|
|
45
|
+
readonly now: () => number;
|
|
46
|
+
}
|
|
47
|
+
/** Who invoked the command. */
|
|
48
|
+
export interface PairCaller {
|
|
49
|
+
readonly user: string | undefined;
|
|
50
|
+
readonly admin: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** The pinned refusal for an unnamed caller (spec §8.2). */
|
|
53
|
+
export declare const UNNAMED_CALLER = "run this as a named user";
|
|
54
|
+
/** The pinned conflict reply (spec §8.2: never silently re-pointed). */
|
|
55
|
+
export declare const PAIR_CONFLICT_REPLY = "That sender is already linked to another user.";
|
|
56
|
+
/**
|
|
57
|
+
* Run `/pair` with `args` as `caller`. Every path resolves a string
|
|
58
|
+
* reply (the command's notice); nothing throws across the boundary.
|
|
59
|
+
*/
|
|
60
|
+
export declare function runPair(deps: PairDeps, args: string, caller: PairCaller): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Apply `deny <code>` (the command and the button share this path).
|
|
63
|
+
* Returns the decision reply for the admin surface.
|
|
64
|
+
*/
|
|
65
|
+
export declare function denyPairing(deps: PairDeps, code: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Apply `approve <code> <user>` (the command and the "Approve as"
|
|
68
|
+
* buttons share this path): directory check, core-backed link, then
|
|
69
|
+
* consume the queue row and send the requester's "linked" message.
|
|
70
|
+
* Core's conflict refusal leaves the request pending.
|
|
71
|
+
*/
|
|
72
|
+
export declare function approvePairing(deps: PairDeps, code: string, namedUser: string): Promise<string>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/pair` command (spec §8.2): the admin surface for pending
|
|
3
|
+
* pairing requests, runnable from any webui/TUI conversation through
|
|
4
|
+
* the extension-command invoke route (`POST /v1/conversations/:id/
|
|
5
|
+
* commands/pair`) — and, through the same decision functions, from
|
|
6
|
+
* chat via the pairing-notice buttons (§8.2: every admin's home DM
|
|
7
|
+
* gets the request; the buttons carry opaque tokens the pipeline
|
|
8
|
+
* resolves).
|
|
9
|
+
*
|
|
10
|
+
* Admin-only (EFFECTIVE admin — the caller's principal carries it from
|
|
11
|
+
* the stored flag OR the persisted group grant, C8). An unnamed caller
|
|
12
|
+
* (the no-auth loopback method) is refused with the pinned
|
|
13
|
+
* "run this as a named user".
|
|
14
|
+
*
|
|
15
|
+
* `approve <code> <user>` links the REQUESTER (the platform sender who
|
|
16
|
+
* asked to pair) to the named directory user via the identity store
|
|
17
|
+
* (`via: "pairing"`), sets their DM as the home chat, and sends them a
|
|
18
|
+
* "linked" message through their channel. Core refuses a sender already
|
|
19
|
+
* linked to another user; the request then stays pending for an admin
|
|
20
|
+
* to resolve (deny, or remove the link).
|
|
21
|
+
*/
|
|
22
|
+
import { normalizeCode } from "../access/codes.js";
|
|
23
|
+
/** The pinned refusal for an unnamed caller (spec §8.2). */
|
|
24
|
+
export const UNNAMED_CALLER = "run this as a named user";
|
|
25
|
+
/** The pinned conflict reply (spec §8.2: never silently re-pointed). */
|
|
26
|
+
export const PAIR_CONFLICT_REPLY = "That sender is already linked to another user.";
|
|
27
|
+
/** The usage line (the args grammar, one line). */
|
|
28
|
+
const USAGE = "usage: /pair [approve <code> <user> | deny <code>]";
|
|
29
|
+
/**
|
|
30
|
+
* Run `/pair` with `args` as `caller`. Every path resolves a string
|
|
31
|
+
* reply (the command's notice); nothing throws across the boundary.
|
|
32
|
+
*/
|
|
33
|
+
export async function runPair(deps, args, caller) {
|
|
34
|
+
if (caller.user === undefined)
|
|
35
|
+
return UNNAMED_CALLER;
|
|
36
|
+
if (!caller.admin) {
|
|
37
|
+
return "only admins can manage pairing requests.";
|
|
38
|
+
}
|
|
39
|
+
const parts = args.trim().split(/\s+/).filter((part) => part !== "");
|
|
40
|
+
if (parts.length === 0)
|
|
41
|
+
return listPending(deps);
|
|
42
|
+
const verb = parts[0];
|
|
43
|
+
if (verb === "deny" && parts.length === 2) {
|
|
44
|
+
return denyPairing(deps, normalizeCode(parts[1] ?? ""));
|
|
45
|
+
}
|
|
46
|
+
if (verb === "approve" && parts.length === 3) {
|
|
47
|
+
return approvePairing(deps, normalizeCode(parts[1] ?? ""), parts[2] ?? "");
|
|
48
|
+
}
|
|
49
|
+
return USAGE;
|
|
50
|
+
}
|
|
51
|
+
/** `""` — one line per pending request (channel, sender, code, age). */
|
|
52
|
+
function listPending(deps) {
|
|
53
|
+
const now = deps.now();
|
|
54
|
+
const rows = deps.pairing.pending();
|
|
55
|
+
if (rows.length === 0)
|
|
56
|
+
return "no pending pairing requests.";
|
|
57
|
+
return rows
|
|
58
|
+
.map((row) => {
|
|
59
|
+
const minutes = Math.max(0, Math.round((now - row.createdAt) / 60_000));
|
|
60
|
+
const who = row.senderName === undefined ? row.senderId : row.senderName;
|
|
61
|
+
return `${row.channel}: ${who} — /pair approve ${row.code} <user> (${minutes}m ago)`;
|
|
62
|
+
})
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Apply `deny <code>` (the command and the button share this path).
|
|
67
|
+
* Returns the decision reply for the admin surface.
|
|
68
|
+
*/
|
|
69
|
+
export function denyPairing(deps, code) {
|
|
70
|
+
if (deps.pairing.deny(code))
|
|
71
|
+
return `pairing request ${code} denied.`;
|
|
72
|
+
return `unknown pairing code ${code}.`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Apply `approve <code> <user>` (the command and the "Approve as"
|
|
76
|
+
* buttons share this path): directory check, core-backed link, then
|
|
77
|
+
* consume the queue row and send the requester's "linked" message.
|
|
78
|
+
* Core's conflict refusal leaves the request pending.
|
|
79
|
+
*/
|
|
80
|
+
export async function approvePairing(deps, code, namedUser) {
|
|
81
|
+
const directory = await deps.host.users.list();
|
|
82
|
+
if (!directory.some((row) => row.user === namedUser)) {
|
|
83
|
+
return `unknown user ${namedUser}.`;
|
|
84
|
+
}
|
|
85
|
+
// The pre-approve read: find the row WITHOUT consuming it.
|
|
86
|
+
const request = deps.pairing.pending().find((row) => row.code === code);
|
|
87
|
+
if (request === undefined) {
|
|
88
|
+
return `unknown pairing code ${code}.`;
|
|
89
|
+
}
|
|
90
|
+
const linked = await deps.identities.link(request.channel, request.senderId, namedUser, "pairing", request.chat);
|
|
91
|
+
if (!linked.linked)
|
|
92
|
+
return PAIR_CONFLICT_REPLY;
|
|
93
|
+
deps.pairing.approve(code, deps.now());
|
|
94
|
+
deps.log.info(`pairing approved on channel "${request.channel}" for user "${namedUser}"`);
|
|
95
|
+
const channel = deps.channelOf(request.channel);
|
|
96
|
+
if (channel !== undefined) {
|
|
97
|
+
try {
|
|
98
|
+
await channel.send({
|
|
99
|
+
chat: request.chat,
|
|
100
|
+
text: `You are linked to sigrun as ${namedUser}.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
// The link landed; the celebratory notice is best-effort (a
|
|
105
|
+
// down channel never rolls back an approval).
|
|
106
|
+
deps.log.error("pairing linked-notice send failed", error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return `linked ${request.senderName ?? request.senderId} to ${namedUser}.`;
|
|
110
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway configuration (spec §8.1): `settings/gateway.json` through the
|
|
3
|
+
* engine's scoped settings store, parsed PER READ (live config — every
|
|
4
|
+
* field except a group's `tools`/`extensions` is read per event).
|
|
5
|
+
*
|
|
6
|
+
* Malformed-config posture: a malformed block for one channel or group
|
|
7
|
+
* disables THAT channel or group with the reason (the `{failed}` forms)
|
|
8
|
+
* and never affects its siblings; malformed top-level scalars fall back
|
|
9
|
+
* to their defaults. The parser never throws.
|
|
10
|
+
*
|
|
11
|
+
* Types are imported from `@sigrun/sigrun/extension` type-only (the
|
|
12
|
+
* types-in-main-package contract); this module's runtime imports are
|
|
13
|
+
* none — it is a pure function over a parsed-JSON object.
|
|
14
|
+
*/
|
|
15
|
+
import type { SigrunHost } from "@sigrun/sigrun/extension";
|
|
16
|
+
/** The DM admission policy (spec §8.2). */
|
|
17
|
+
export type DmPolicy = "pairing" | "allowlist" | "disabled";
|
|
18
|
+
/** One group chat's config (spec §8.1). */
|
|
19
|
+
export interface GroupConfig {
|
|
20
|
+
readonly chat: string;
|
|
21
|
+
readonly owner: string;
|
|
22
|
+
readonly mention: "required" | "always";
|
|
23
|
+
readonly members: "linked" | "anyone";
|
|
24
|
+
readonly tools?: readonly string[];
|
|
25
|
+
readonly extensions?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/** One channel's config (spec §8.1). */
|
|
28
|
+
export interface ChannelConfig {
|
|
29
|
+
readonly enabled: boolean;
|
|
30
|
+
readonly dm: DmPolicy;
|
|
31
|
+
readonly selfLink: boolean;
|
|
32
|
+
readonly allow: readonly {
|
|
33
|
+
sender: string;
|
|
34
|
+
user: string;
|
|
35
|
+
}[];
|
|
36
|
+
readonly groups: readonly (GroupConfig | {
|
|
37
|
+
readonly chat: string;
|
|
38
|
+
readonly failed: string;
|
|
39
|
+
})[];
|
|
40
|
+
readonly busy: "followUp" | "steer";
|
|
41
|
+
readonly stream: boolean;
|
|
42
|
+
readonly allowBots: boolean;
|
|
43
|
+
}
|
|
44
|
+
/** The whole `settings/gateway.json` shape (spec §8.1). */
|
|
45
|
+
export interface GatewayConfig {
|
|
46
|
+
readonly channels: Readonly<Record<string, ChannelConfig | {
|
|
47
|
+
readonly failed: string;
|
|
48
|
+
}>>;
|
|
49
|
+
readonly media: {
|
|
50
|
+
readonly maxBytes: number;
|
|
51
|
+
};
|
|
52
|
+
readonly notify: {
|
|
53
|
+
readonly home: boolean;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Default media cap (16 MiB — the readFile ceiling, spec §8.1). */
|
|
57
|
+
export declare const DEFAULT_MEDIA_MAX_BYTES = 16777216;
|
|
58
|
+
/** Parse the raw settings object into the validated {@link GatewayConfig}. */
|
|
59
|
+
export declare function parseGatewayConfig(raw: Record<string, unknown>): GatewayConfig;
|
|
60
|
+
/**
|
|
61
|
+
* Read the config LIVE through the engine's scoped settings store: the
|
|
62
|
+
* store's object parsed per call, so an operator's edit takes effect on
|
|
63
|
+
* the next event without a restart (only `enabled` is restart-required).
|
|
64
|
+
*/
|
|
65
|
+
export declare function readConfig(host: SigrunHost): Promise<GatewayConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway configuration (spec §8.1): `settings/gateway.json` through the
|
|
3
|
+
* engine's scoped settings store, parsed PER READ (live config — every
|
|
4
|
+
* field except a group's `tools`/`extensions` is read per event).
|
|
5
|
+
*
|
|
6
|
+
* Malformed-config posture: a malformed block for one channel or group
|
|
7
|
+
* disables THAT channel or group with the reason (the `{failed}` forms)
|
|
8
|
+
* and never affects its siblings; malformed top-level scalars fall back
|
|
9
|
+
* to their defaults. The parser never throws.
|
|
10
|
+
*
|
|
11
|
+
* Types are imported from `@sigrun/sigrun/extension` type-only (the
|
|
12
|
+
* types-in-main-package contract); this module's runtime imports are
|
|
13
|
+
* none — it is a pure function over a parsed-JSON object.
|
|
14
|
+
*/
|
|
15
|
+
/** Default media cap (16 MiB — the readFile ceiling, spec §8.1). */
|
|
16
|
+
export const DEFAULT_MEDIA_MAX_BYTES = 16_777_216;
|
|
17
|
+
/** Parse the raw settings object into the validated {@link GatewayConfig}. */
|
|
18
|
+
export function parseGatewayConfig(raw) {
|
|
19
|
+
return {
|
|
20
|
+
channels: parseChannels(raw.channels),
|
|
21
|
+
media: parseMedia(raw.media),
|
|
22
|
+
notify: parseNotify(raw.notify),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read the config LIVE through the engine's scoped settings store: the
|
|
27
|
+
* store's object parsed per call, so an operator's edit takes effect on
|
|
28
|
+
* the next event without a restart (only `enabled` is restart-required).
|
|
29
|
+
*/
|
|
30
|
+
export async function readConfig(host) {
|
|
31
|
+
const raw = await host.stores.settings.get();
|
|
32
|
+
return parseGatewayConfig(raw);
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Channel parsing
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
function parseChannels(value) {
|
|
38
|
+
if (!isRecord(value))
|
|
39
|
+
return {};
|
|
40
|
+
const channels = {};
|
|
41
|
+
for (const [id, block] of Object.entries(value)) {
|
|
42
|
+
channels[id] = parseChannel(block, id);
|
|
43
|
+
}
|
|
44
|
+
return channels;
|
|
45
|
+
}
|
|
46
|
+
function parseChannel(value, id) {
|
|
47
|
+
if (!isRecord(value)) {
|
|
48
|
+
return { failed: `channel "${id}" config is not an object` };
|
|
49
|
+
}
|
|
50
|
+
if (value.dm !== undefined && !isDmPolicy(value.dm)) {
|
|
51
|
+
return {
|
|
52
|
+
failed: `channel "${id}" dm must be "pairing" | "allowlist" | "disabled"`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const dm = value.dm === undefined ? "pairing" : value.dm;
|
|
56
|
+
if (value.enabled !== undefined && typeof value.enabled !== "boolean") {
|
|
57
|
+
return { failed: `channel "${id}" enabled must be a boolean` };
|
|
58
|
+
}
|
|
59
|
+
if (value.busy !== undefined &&
|
|
60
|
+
value.busy !== "followUp" &&
|
|
61
|
+
value.busy !== "steer") {
|
|
62
|
+
return { failed: `channel "${id}" busy must be "followUp" | "steer"` };
|
|
63
|
+
}
|
|
64
|
+
const allow = parseAllow(value.allow, id);
|
|
65
|
+
if (typeof allow === "string")
|
|
66
|
+
return { failed: allow };
|
|
67
|
+
const groups = parseGroups(value.groups, id);
|
|
68
|
+
if (typeof groups === "string")
|
|
69
|
+
return { failed: groups };
|
|
70
|
+
if (value.selfLink !== undefined && typeof value.selfLink !== "boolean") {
|
|
71
|
+
return { failed: `channel "${id}" selfLink must be a boolean` };
|
|
72
|
+
}
|
|
73
|
+
if (value.stream !== undefined && typeof value.stream !== "boolean") {
|
|
74
|
+
return { failed: `channel "${id}" stream must be a boolean` };
|
|
75
|
+
}
|
|
76
|
+
if (value.allowBots !== undefined && typeof value.allowBots !== "boolean") {
|
|
77
|
+
return { failed: `channel "${id}" allowBots must be a boolean` };
|
|
78
|
+
}
|
|
79
|
+
// selfLink defaults true for pairing channels, false otherwise (spec
|
|
80
|
+
// §8.1: an allowlist channel's operator chose a closed list).
|
|
81
|
+
const selfLinkDefault = dm === "pairing";
|
|
82
|
+
return {
|
|
83
|
+
enabled: value.enabled === undefined ? true : value.enabled,
|
|
84
|
+
dm,
|
|
85
|
+
selfLink: value.selfLink === undefined ? selfLinkDefault : value.selfLink,
|
|
86
|
+
allow,
|
|
87
|
+
groups,
|
|
88
|
+
busy: value.busy === undefined ? "followUp" : value.busy,
|
|
89
|
+
stream: value.stream === undefined ? true : value.stream,
|
|
90
|
+
allowBots: value.allowBots === undefined ? false : value.allowBots,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function isDmPolicy(value) {
|
|
94
|
+
return value === "pairing" || value === "allowlist" || value === "disabled";
|
|
95
|
+
}
|
|
96
|
+
function parseAllow(value, id) {
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
return [];
|
|
99
|
+
if (!Array.isArray(value)) {
|
|
100
|
+
return `channel "${id}" allow must be an array`;
|
|
101
|
+
}
|
|
102
|
+
const entries = [];
|
|
103
|
+
for (const entry of value) {
|
|
104
|
+
if (!isRecord(entry)) {
|
|
105
|
+
return `channel "${id}" allow entries must be objects`;
|
|
106
|
+
}
|
|
107
|
+
if (typeof entry.sender !== "string" || entry.sender === "") {
|
|
108
|
+
return `channel "${id}" allow entries need a non-empty string sender`;
|
|
109
|
+
}
|
|
110
|
+
if (typeof entry.user !== "string" || entry.user === "") {
|
|
111
|
+
return `channel "${id}" allow entries need a non-empty string user`;
|
|
112
|
+
}
|
|
113
|
+
entries.push({ sender: entry.sender, user: entry.user });
|
|
114
|
+
}
|
|
115
|
+
return entries;
|
|
116
|
+
}
|
|
117
|
+
function parseGroups(value, id) {
|
|
118
|
+
if (value === undefined)
|
|
119
|
+
return [];
|
|
120
|
+
if (!Array.isArray(value)) {
|
|
121
|
+
return `channel "${id}" groups must be an array`;
|
|
122
|
+
}
|
|
123
|
+
return value.map((group) => parseGroup(group));
|
|
124
|
+
}
|
|
125
|
+
function parseGroup(value) {
|
|
126
|
+
if (!isRecord(value)) {
|
|
127
|
+
return { chat: "", failed: "group config is not an object" };
|
|
128
|
+
}
|
|
129
|
+
// The failed-group form still names WHICH chat failed: an absent or
|
|
130
|
+
// non-string chat renders as the empty string so the row is shaped.
|
|
131
|
+
const chat = typeof value.chat === "string" ? value.chat : "";
|
|
132
|
+
if (chat === "") {
|
|
133
|
+
return { chat, failed: "group needs a non-empty string chat" };
|
|
134
|
+
}
|
|
135
|
+
if (typeof value.owner !== "string" || value.owner === "") {
|
|
136
|
+
return { chat, failed: `group "${chat}" needs a non-empty string owner` };
|
|
137
|
+
}
|
|
138
|
+
if (value.mention !== undefined &&
|
|
139
|
+
value.mention !== "required" &&
|
|
140
|
+
value.mention !== "always") {
|
|
141
|
+
return {
|
|
142
|
+
chat,
|
|
143
|
+
failed: `group "${chat}" mention must be "required" | "always"`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (value.members !== undefined &&
|
|
147
|
+
value.members !== "linked" &&
|
|
148
|
+
value.members !== "anyone") {
|
|
149
|
+
return {
|
|
150
|
+
chat,
|
|
151
|
+
failed: `group "${chat}" members must be "linked" | "anyone"`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const members = value.members === undefined ? "linked" : value.members;
|
|
155
|
+
if (value.tools !== undefined && !isStringArray(value.tools)) {
|
|
156
|
+
return {
|
|
157
|
+
chat,
|
|
158
|
+
failed: `group "${chat}" tools must be an array of strings`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (value.extensions !== undefined && !isStringArray(value.extensions)) {
|
|
162
|
+
return {
|
|
163
|
+
chat,
|
|
164
|
+
failed: `group "${chat}" extensions must be an array of strings`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const tools = value.tools;
|
|
168
|
+
let extensions = value.extensions;
|
|
169
|
+
if (members === "anyone") {
|
|
170
|
+
// Spec §8.1: "anyone" REQUIRES a non-empty tools array — an open
|
|
171
|
+
// group without a pinned tool set would run the full default agent
|
|
172
|
+
// for untrusted senders.
|
|
173
|
+
if (tools === undefined || tools.length === 0) {
|
|
174
|
+
return {
|
|
175
|
+
chat,
|
|
176
|
+
failed: `group "${chat}" members: "anyone" requires a non-empty tools array`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Spec §8.10: the gateway's own prompt section rides along on
|
|
180
|
+
// anyone groups (the model must see its chat context).
|
|
181
|
+
if (extensions !== undefined && !extensions.includes("gateway")) {
|
|
182
|
+
extensions = [...extensions, "gateway"];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
chat,
|
|
187
|
+
owner: value.owner,
|
|
188
|
+
mention: value.mention === undefined ? "required" : value.mention,
|
|
189
|
+
members,
|
|
190
|
+
...(tools === undefined ? {} : { tools }),
|
|
191
|
+
...(extensions === undefined ? {} : { extensions }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Media / notify parsing
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
function parseMedia(value) {
|
|
198
|
+
if (isRecord(value) &&
|
|
199
|
+
typeof value.maxBytes === "number" &&
|
|
200
|
+
value.maxBytes > 0) {
|
|
201
|
+
return { maxBytes: value.maxBytes };
|
|
202
|
+
}
|
|
203
|
+
return { maxBytes: DEFAULT_MEDIA_MAX_BYTES };
|
|
204
|
+
}
|
|
205
|
+
function parseNotify(value) {
|
|
206
|
+
if (isRecord(value) && typeof value.home === "boolean") {
|
|
207
|
+
return { home: value.home };
|
|
208
|
+
}
|
|
209
|
+
return { home: true };
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Small helpers
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
function isRecord(value) {
|
|
215
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
216
|
+
}
|
|
217
|
+
function isStringArray(value) {
|
|
218
|
+
return (Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
219
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway engine (spec §8.0, gateway C2): chat-transport supervision
|
|
3
|
+
* over the boot's channel bindings. ONE engine per boot; `start` runs
|
|
4
|
+
* after the server listens, `stopIntake` before the shutdown drain,
|
|
5
|
+
* `stop` after it.
|
|
6
|
+
*
|
|
7
|
+
* Task 2 owns supervision: `start` reads the LIVE config once for the
|
|
8
|
+
* restart-required enablement (a channel absent from `channels`, an
|
|
9
|
+
* `enabled: false`, or a `{failed}` config block never starts its
|
|
10
|
+
* adapter — the engine records a static row instead), then hands every
|
|
11
|
+
* enabled channel a {@link Supervisor}. Health (§8.12) reads the
|
|
12
|
+
* supervisors' unhealthy dwell plus the ledger's failed-in-last-hour
|
|
13
|
+
* count (Task 5: the engine-owned ledger feeds the `failedRecently`
|
|
14
|
+
* seam — tests may still override it).
|
|
15
|
+
*
|
|
16
|
+
* The inbound pipeline (Task 3) drives `receive` (§8.4): the config
|
|
17
|
+
* block is read LIVE per event, admission runs the ordered steps, and
|
|
18
|
+
* the supervisor counts every outcome. `action` (button taps) stays a
|
|
19
|
+
* no-op until the token table lands (Tasks 4/9).
|
|
20
|
+
*
|
|
21
|
+
* `EngineInternals` is the test/task-internal surface (the state-dir
|
|
22
|
+
* path, the status read) — NOT part of the loader contract, which sees
|
|
23
|
+
* only the {@link GatewayEngine} four.
|
|
24
|
+
*/
|
|
25
|
+
import type { GatewayEngine, SigrunHost } from "@sigrun/sigrun/extension";
|
|
26
|
+
import { type EngineLog } from "./log.js";
|
|
27
|
+
import { createPipeline } from "./inbound/pipeline.js";
|
|
28
|
+
import { type ChannelStatus } from "./supervisor.js";
|
|
29
|
+
import type { PairDeps } from "./commands/pair.js";
|
|
30
|
+
import { Ledger } from "./state/ledger.js";
|
|
31
|
+
import type { AccessState } from "./inbound/pipeline.js";
|
|
32
|
+
import type { StatusPairingRow } from "./status-route.js";
|
|
33
|
+
import type { ToolDeps } from "./tools.js";
|
|
34
|
+
import type { ChannelSectionDeps } from "./section.js";
|
|
35
|
+
/**
|
|
36
|
+
* The engine's injectable dependencies (tests and later tasks). Task 1
|
|
37
|
+
* carried only the log; every later seam lands here so the constructor
|
|
38
|
+
* signature never changes twice.
|
|
39
|
+
*/
|
|
40
|
+
export interface EngineDeps {
|
|
41
|
+
readonly log?: EngineLog;
|
|
42
|
+
/** The timer bundle supervision rides. Default: real clock, real
|
|
43
|
+
* timers, crypto random (the factory never wires a timer at
|
|
44
|
+
* construction — the pi lifecycle rule; the supervisor starts them
|
|
45
|
+
* only from its `start`). */
|
|
46
|
+
readonly timers?: {
|
|
47
|
+
now(): number;
|
|
48
|
+
random(): number;
|
|
49
|
+
setTimer(fn: () => void, ms: number): () => void;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The ledger's failed-in-last-hour count (§8.12's second health
|
|
53
|
+
* input). Default: the engine's OWN ledger over the real clock —
|
|
54
|
+
* tests inject a stub for the pre-ledger engine tests.
|
|
55
|
+
*/
|
|
56
|
+
readonly failedRecently?: () => number;
|
|
57
|
+
/** Test/task-internal ledger seam for shutdown and recovery tests. */
|
|
58
|
+
readonly deliveryLedger?: Ledger;
|
|
59
|
+
/** Construction-count test seam; production uses the real pipeline. */
|
|
60
|
+
readonly pipelineFactory?: typeof createPipeline;
|
|
61
|
+
/** Test seam for the receipt wait; production keeps the pinned 30 s. */
|
|
62
|
+
readonly toolReceiptWaitMs?: number;
|
|
63
|
+
}
|
|
64
|
+
/** The task-internal surface over the contract four (see the module doc). */
|
|
65
|
+
export interface EngineInternals {
|
|
66
|
+
/** The engine's state directory: `<stateDir>/gateway` (the scoped
|
|
67
|
+
* `host.stores.paths.state` plus the engine's own segment). */
|
|
68
|
+
readonly stateDir: string;
|
|
69
|
+
/** The channel ids the last `start` recorded, in order. */
|
|
70
|
+
readonly channelIds: () => readonly string[];
|
|
71
|
+
/**
|
|
72
|
+
* The status route's read (Task 2): every channel's current row —
|
|
73
|
+
* supervised and static (config-error/disabled) alike.
|
|
74
|
+
*/
|
|
75
|
+
status(): {
|
|
76
|
+
readonly channels: Readonly<Record<string, ChannelStatus>>;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The status route's pairing read (Task 4): every live pending
|
|
80
|
+
* request WITHOUT the code (hashed sender — §8.12's rule that
|
|
81
|
+
* operator output never carries raw platform ids).
|
|
82
|
+
*/
|
|
83
|
+
pairingStatus(): readonly StatusPairingRow[];
|
|
84
|
+
/** The engine-wide access state (pipelines and commands share it). */
|
|
85
|
+
accessState(): AccessState;
|
|
86
|
+
/** The `/pair` decision deps over the shared state (the factory's
|
|
87
|
+
* command closes over these — `host` is the factory's own host). */
|
|
88
|
+
pairDeps(host: SigrunHost): PairDeps;
|
|
89
|
+
/**
|
|
90
|
+
* The settled-run fan-out (the factory's `runSettled` closes over
|
|
91
|
+
* this): enqueues the finals/notices, then kicks the affected
|
|
92
|
+
* channels' workers.
|
|
93
|
+
*/
|
|
94
|
+
settle(event: import("@sigrun/sigrun/extension").RunSettledEvent, api: import("@sigrun/sigrun/extension").MemoryEventApi, context: import("@sigrun/sigrun/extension").HandlerContext): Promise<void>;
|
|
95
|
+
/** The home-chat notifier and its enqueue-then-kick delivery path. */
|
|
96
|
+
notifier(): import("@sigrun/sigrun/extension").NotifierContribution;
|
|
97
|
+
/** Delivered prompt edits and bound-chat outcome notices. */
|
|
98
|
+
interactionSettled(event: import("@sigrun/sigrun/extension").ExtensionLifecyclePayload["interactionSettled"], context: import("@sigrun/sigrun/extension").HandlerContext): Promise<void>;
|
|
99
|
+
/** Agent tools and bound-chat prompt section over the same shared stores. */
|
|
100
|
+
toolDeps(): ToolDeps;
|
|
101
|
+
sectionDeps(): ChannelSectionDeps;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build the engine over the host. The factory wires the real
|
|
105
|
+
* dependencies; tests inject the clock/log/timer seams through the same
|
|
106
|
+
* parameter.
|
|
107
|
+
*/
|
|
108
|
+
export declare function createEngine(host: SigrunHost, deps?: EngineDeps): GatewayEngine & EngineInternals;
|