From 5c049e61427fa1fc1ec66e983c24702f25e5ecd0 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 05:39:05 -0400 Subject: [PATCH] feat(s072): the Mattermost gateway Slice 072, stacked on slice 071 and built on its gateway core: the supervisor, adapter status, buttons as {:message, text, buttons}, and a press routed as the typed command. The live criterion ran against a throwaway Mattermost Team Edition 11.11.1 server in Docker, pinned by digest. The owner's run against their own server is queued. - Inbound arrives over the WebSocket, so the server never has to reach Trinity except for the controls. The socket authenticates in the upgrade header, which keeps it resumable, and resumes with the server's connection id and sequence number. TLS is verified explicitly, because the library's default is verify_none. Posts are handled at most once. - Approvals use buttons and an interactive dialog. Their context is HMAC-signed, the dialog state is bound to the user, single-use and 10 minutes long, and the slash command's token is compared in constant time. A dialog decides nothing: its answer reaches the router as the typed command, so the channel cap applies. An exec request was refused by the cap and receipted with basis channel_cap. - POST /gateways/callback/:adapter/:kind hands a platform's callback to its adapter to verify. An unconfigured adapter answers 404. - Posts fit the server's MaxPostSize in code points, checked by a property. - A census reads every adapter's compiled imports and refuses calls into LLM, Sessions, Permissions, Effects or Tools. A planted direct Permissions call passed both the compiler and the boundary check, and only the census caught it. - A cron delivery the server refuses is no longer marked delivered. - Under :regulated, Mattermost boots only when named in TRINITY_REGULATED_GATEWAYS. - Redaction, the secret scan and the request-log filter learn the token, matched only where it is named, since its shape is every Mattermost id's. - websockex becomes a direct dependency; it was already in the lock. Red evidence R1 to R9 is in the slice NOTES. Signed-off-by: Ayla Croft --- .env.example | 12 + CHANGELOG.md | 26 ++ README.md | 12 +- VERSIONS.md | 3 +- config/config.exs | 9 +- config/runtime.exs | 18 + docs/01-architecture.md | 9 +- docs/05-data-model.md | 2 +- docs/07-security-model.md | 44 ++ lib/mix/tasks/trinity.secrets.scan.ex | 4 + lib/trinity/gateways.ex | 42 +- lib/trinity/gateways/adapter.ex | 15 +- lib/trinity/gateways/mattermost.ex | 200 +++++++++ lib/trinity/gateways/mattermost/callbacks.ex | 184 +++++++++ lib/trinity/gateways/mattermost/client.ex | 105 +++++ .../gateways/mattermost/conversation.ex | 55 +++ lib/trinity/gateways/mattermost/events.ex | 163 ++++++++ lib/trinity/gateways/mattermost/format.ex | 131 ++++++ lib/trinity/gateways/mattermost/options.ex | 81 ++++ lib/trinity/gateways/mattermost/signing.ex | 91 ++++ lib/trinity/gateways/mattermost/socket.ex | 260 ++++++++++++ lib/trinity/gateways/mattermost/state.ex | 203 +++++++++ lib/trinity/scheduler/delivery/gateway.ex | 19 +- lib/trinity/sessions/session_row.ex | 4 +- lib/trinity/telemetry/redaction.ex | 7 + lib/trinity/versions.ex | 13 +- .../gateway_callback_controller.ex | 28 ++ lib/trinity_web/router.ex | 6 + mix.exs | 3 + scripts/dev_mattermost.sh | 78 ++++ test/secrets_scan_test.exs | 8 + test/support/mattermost/fake_server.ex | 259 ++++++++++++ test/support/mattermost/fixtures.json | 113 +++++ test/support/mattermost/helpers.ex | 81 ++++ test/support/mattermost/log_tap.ex | 49 +++ test/trinity/gateways/census_test.exs | 85 ++++ .../gateways/mattermost/adapter_test.exs | 207 ++++++++++ .../gateways/mattermost/approvals_test.exs | 390 ++++++++++++++++++ .../gateways/mattermost/delivery_test.exs | 87 ++++ .../gateways/mattermost/events_test.exs | 118 ++++++ .../gateways/mattermost/format_test.exs | 129 ++++++ .../mattermost/regulated_boot_test.exs | 129 ++++++ .../gateways/mattermost/secrets_test.exs | 272 ++++++++++++ .../gateways/mattermost/supervisor_test.exs | 92 +++++ test/trinity/telemetry/redaction_test.exs | 18 + 45 files changed, 3842 insertions(+), 22 deletions(-) create mode 100644 lib/trinity/gateways/mattermost.ex create mode 100644 lib/trinity/gateways/mattermost/callbacks.ex create mode 100644 lib/trinity/gateways/mattermost/client.ex create mode 100644 lib/trinity/gateways/mattermost/conversation.ex create mode 100644 lib/trinity/gateways/mattermost/events.ex create mode 100644 lib/trinity/gateways/mattermost/format.ex create mode 100644 lib/trinity/gateways/mattermost/options.ex create mode 100644 lib/trinity/gateways/mattermost/signing.ex create mode 100644 lib/trinity/gateways/mattermost/socket.ex create mode 100644 lib/trinity/gateways/mattermost/state.ex create mode 100644 lib/trinity_web/controllers/gateway_callback_controller.ex create mode 100755 scripts/dev_mattermost.sh create mode 100644 test/support/mattermost/fake_server.ex create mode 100644 test/support/mattermost/fixtures.json create mode 100644 test/support/mattermost/helpers.ex create mode 100644 test/support/mattermost/log_tap.ex create mode 100644 test/trinity/gateways/census_test.exs create mode 100644 test/trinity/gateways/mattermost/adapter_test.exs create mode 100644 test/trinity/gateways/mattermost/approvals_test.exs create mode 100644 test/trinity/gateways/mattermost/delivery_test.exs create mode 100644 test/trinity/gateways/mattermost/events_test.exs create mode 100644 test/trinity/gateways/mattermost/format_test.exs create mode 100644 test/trinity/gateways/mattermost/regulated_boot_test.exs create mode 100644 test/trinity/gateways/mattermost/secrets_test.exs create mode 100644 test/trinity/gateways/mattermost/supervisor_test.exs diff --git a/.env.example b/.env.example index a2bd324..66e8028 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,15 @@ TELEGRAM_BOT_TOKEN= # SECRET_KEY_BASE= # PHX_HOST= # TRINITY_AUTHORITY=local + +# ---- Mattermost gateway, optional (slice 072) ----------------------------------------- +# A server the operator names. Unset MATTERMOST_URL and no adapter starts. The callback URL +# is where that server reaches this node, for the approval dialog and the /trinity slash +# command; without it approvals are answered by command only. Under +# TRINITY_PROFILE=regulated the adapter must also be named on TRINITY_REGULATED_GATEWAYS +# (Trinity.Gateways.Mattermost) or the node refuses to boot. +MATTERMOST_URL= +MATTERMOST_CALLBACK_URL= +# The bot account's access token, and the token the server issued for the /trinity command. +MATTERMOST_BOT_TOKEN= +MATTERMOST_COMMAND_TOKEN= diff --git a/CHANGELOG.md b/CHANGELOG.md index 394ee21..382ea38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,32 @@ evidence for each increment is retained by the maintainers and summarised here. tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as the public record of delivered work. +## 2026-10-08 + +### `slice/072`: Trinity answers in Mattermost + +The second messaging platform, after Telegram. A Mattermost adapter for a server the operator names: direct +messages and channel mentions are answered, each thread its own conversation, and the reply +streams into one post that is edited as the words arrive. An approval raised during the turn is +posted with a button that opens an interactive dialog; the answer goes back through the same +router, pairing check, rate limit and channel cap as a typed command, so a request above what a +channel may approve is refused there and receipted. A `/trinity` slash command carries the +commands, and the scheduler can deliver a task's result to a channel. Posts are cut to the size +the server says it accepts, counted the way the server counts. + +Trinity opens the connection; the server is not assumed to be reachable from anywhere, and it only +calls back for the dialog and the slash command, when the operator gives it an address to call. +Every callback carries proof of where it came from or is refused unread. The bot token is read +when it is used and appears in no log line, checked on every failure path including a crash. +A node under the regulated profile refuses to start with the adapter configured unless it is named +on the gateway allow-list. Choosing Mattermost makes the channel authorizable; whether a given +deployment is authorized remains the operator's statement. + +Along the way: a scheduled delivery the platform refused is no longer recorded as delivered, and +the session origin vocabulary names Mattermost in place of the dropped Discord slot. + +Verified: gate green at exit 0; 1,065 tests passing (24 properties); coverage 80.49%. + ### `slice/071`: Trinity answers on Telegram A Telegram bot is now a channel Trinity can be reached from, alongside the console. It answers in diff --git a/README.md b/README.md index 43f8a7d..de4b286 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ enforces it. a bot that answers in private chats and in groups where it is mentioned, streams its reply by editing one message, offers approvals as buttons (within the same cap), and passes a photo to a model that can see. Set `TELEGRAM_BOT_TOKEN` and `TRINITY_GATEWAYS=telegram` to turn it on. + A Mattermost adapter ships too, for a server the operator names: direct messages and channel + mentions, replies streamed by editing one post, approvals answered in an interactive dialog + (within the same cap), a `/trinity` slash command, and scheduled results delivered to a channel. + Set `MATTERMOST_URL`, `MATTERMOST_BOT_TOKEN` and `TRINITY_GATEWAYS=mattermost` to turn it on. - **Delegates.** The assistant can hand a bounded piece of work to a child session with a `delegate` tool: its own context, its own history, and a result handed back as text. The parent's conversation never contains the child's messages, which is the point: @@ -174,9 +178,9 @@ enforces it. curator that marks old memories stale and archives the untouched ones (never deleting) run on the same queues; `/oban` shows the jobs. -Not there yet: the second messaging platform (072), the native desktop shell and signed releases -(the rest of M6), store-and-forward receipts for disconnected operation (026, waiting on an answer -from the external authority plane). The [Milestones](#milestones) section below sets out the order the remaining work +Not there yet: the native desktop shell and signed releases (the rest of M6), +store-and-forward receipts for disconnected operation (026, waiting on an answer from the external +authority plane). The [Milestones](#milestones) section below sets out the order the remaining work is being built in. The interface is a local web page; the desktop shell exists as a packaging spike, not a product. @@ -191,7 +195,7 @@ Those are failures of the substrate, not of the product. The BEAM was built to r of failure, so on it these properties can be structural rather than aspirational: - A crash in one session, tool or gateway never affects another and never loses persisted state. -- One agent, seen on the desktop, in Telegram and in Discord at the same time, in real time. +- One agent, seen on the desktop, in Telegram and in Mattermost at the same time, in real time. - Memory in tiers: a small always-on set of facts plus unlimited retrievable history. - Every side-effecting action passes a permission gate, and dangerous ones need explicit approval. - Any model or provider, cloud or local, switched by configuration. diff --git a/VERSIONS.md b/VERSIONS.md index 87fba71..b64e029 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -140,7 +140,8 @@ never pin a version hex marks as retired or vulnerable. | `luerl (+ sandbox)` | latest | πŸ” not a single package | Slice 110 only. Two packages, so no single lock key. | | `burrito` | ~> 1.6 | βœ… in `mix.lock` | ⚠️ ERTS availability drives the OTP pin, and Slice 000 measured it: only the OTP 28 line is fetchable for macOS and Linux. Corrected 2026-09-05: this row previously read `~> 1.5 / 1.5.0 βœ…`; that mark was not measured. Added at Slice 001. | | `ex_tauri` | ~> 0.2 | βœ… in `mix.lock` | ⚠️ Declares `otp_release: "~> 27.0"`, and Slice 000's probe refutes the reason it gives: OTP 28 macOS universal returns 200 and OTP 27 returns 404. Whether it runs on the pinned OTP is Slice 001's first measurement. ⚠️ 439 downloads all-time, so the ADR-0004 fallback matrix carries real weight. | -| `nostrum` | ~> 0.10 | πŸ” not yet a dependency | Discord. ⚠️ No release in roughly 18 months. R11's trigger already fires. Check intents and components against the current gateway before Slice 072. | +| `nostrum` | not used (Slice 072 retargeted to Mattermost, 2026-10-07) | πŸ” not a single package | Discord. Was the planned library for Slice 072, which the owner retargeted to Mattermost on 2026-10-07: Discord has no authorization path for a regulated deployment, and the work is dropped rather than deferred. The row stays so the decision is visible where a reader would look for the package. | +| `websockex` | ~> 0.5.1 | βœ… in `mix.lock` | The Mattermost gateway's connection to its server's WebSocket (`Trinity.Gateways.Mattermost.Socket`, Slice 072). Was transitive through req_llm and already in every release; direct since Slice 072 because a module of ours calls it. ⚠️ Its own default is `insecure: true` (`verify: :verify_none`); the socket always passes verifying `ssl_options` and never relies on the default. | | `telegex` | not used (measured at Slice 071, 2026-10-08) | πŸ” not a single package | Telegram. Measured at Slice 071: the latest release on hex is `1.9.0-rc.0` (2024-09-18) and the last stable `1.8.0` (2024-05-28); this file's own rule forbids pinning an `-rc`. Not used: `Trinity.Gateways.Telegram` speaks the Bot API with `req` (slice 071 NOTES, "The library, measured"). The row stays so the decision is visible where a reader would look for the package. | | `ex_gram` | not used (measured at Slice 071, 2026-10-08) | πŸ” not a single package | Telegram. Measured at Slice 071 on `0.71.0` (2026-10-01): its poller keeps the `getUpdates` offset in process state and starts at `-1`, so a restarted poller reprocesses or skips unconfirmed updates (Slice 071's AC5 is that property); its file download host is a module attribute that cannot be pointed at a test server; Beerware licence; 18,054 lines for what is here a handful of JSON calls. Not used: `Trinity.Gateways.Telegram` speaks the Bot API with `req` and owns its poller. | | `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | πŸ” not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. | diff --git a/config/config.exs b/config/config.exs index a742701..8684f2c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -234,6 +234,12 @@ config :logger, :default_formatter, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# Slice 072: request parameters whose names contain these are logged as [FILTERED]. Phoenix's +# default is "password" alone, and a Mattermost slash command arrives with its verification token +# in a parameter called `token` (and an action's signed context under `context.token`), and with a +# `response_url` that lets whoever holds it post into the channel for a time after the command. +config :phoenix, :filter_parameters, ["password", "token", "secret", "response_url"] + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" @@ -246,4 +252,5 @@ config :trinity, :deliveries, %{"gateway" => Trinity.Scheduler.Delivery.Gateway} # `config :trinity, :gateways, adapters: [...]` names modules, and `TRINITY_GATEWAYS` # (config/runtime.exs) names adapters from this list by their name ("telegram", "console"), so an # environment variable never mints an atom. A new platform is a module and a line here. -config :trinity, :gateways, available: [Trinity.Gateways.Console, Trinity.Gateways.Telegram] +config :trinity, :gateways, + available: [Trinity.Gateways.Console, Trinity.Gateways.Telegram, Trinity.Gateways.Mattermost] diff --git a/config/runtime.exs b/config/runtime.exs index 8804e93..bdc9924 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -52,6 +52,24 @@ if roots = System.get_env("TRINITY_FS_ROOTS") do config :trinity, :fs, roots: String.split(roots, ":", trim: true) end +# Slice 072: the Mattermost gateway's server, when the operator names one. It runs when it is +# named on TRINITY_GATEWAYS ("mattermost"); named without MATTERMOST_URL it starts idle and says +# why on /gateways. MATTERMOST_CALLBACK_URL, if set, is the base URL the server reaches this node at, +# for the approval dialog and the /trinity slash command. The bot token is MATTERMOST_BOT_TOKEN and +# the command's token MATTERMOST_COMMAND_TOKEN, both read when used (`Trinity.Config.secret/1`) +# and never here. Under TRINITY_PROFILE=regulated the adapter must also be named on +# TRINITY_REGULATED_GATEWAYS or the node refuses to boot. +if config_env() != :test do + mattermost = + [ + url: System.get_env("MATTERMOST_URL"), + callback_url: System.get_env("MATTERMOST_CALLBACK_URL") + ] + |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) + + if mattermost != [], do: config(:trinity, :mattermost, mattermost) +end + # Slice 022: the search provider's key is read at call time from BRAVE_SEARCH_API_KEY; the # provider module is configuration so a fake can stand in. if config_env() != :test do diff --git a/docs/01-architecture.md b/docs/01-architecture.md index a34b62d..756037e 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -58,7 +58,10 @@ Trinity.Application β”‚ β”‚ # in the application started a gateway) β”‚ β”œβ”€β”€ Trinity.Gateways.Router # one road from a channel to a session. Slice 070 β”‚ β”œβ”€β”€ Trinity.Gateways.Console # the in-process adapter, when configured. Slice 070 -β”‚ └── Trinity.Gateways.Telegram # outbox and poller (long-polling getUpdates). Slice 071 +β”‚ β”œβ”€β”€ Trinity.Gateways.Telegram # outbox and poller (long-polling getUpdates). Slice 071 +β”‚ └── Trinity.Gateways.Mattermost (:rest_for_one) # Slice 072, when named (TRINITY_GATEWAYS) +β”‚ β”œβ”€β”€ Mattermost.State # ETS: server facts, resume point, posts handled, the key +β”‚ └── Mattermost.Socket # WebSockex to the server; reconnects and resumes β”œβ”€β”€ Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080 β”œβ”€β”€ Trinity.Sandbox.Supervisor # Luerl workers. Slice 110 β”œβ”€β”€ Trinity.Desktop # ex_tauri bridge (tray, notifications). Slice 100 @@ -96,7 +99,7 @@ without anything failing. | `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools; at 041: staging, promotion, manager, scanner, diff, learn, `skill_manage` and `learn`) | Repo, Permissions, **Effects**, **Receipts**, Sandbox (as built at 041: Tools, Memory, Permissions, Receipts and LLM; Effects is not a dependency: the promotion is not a tool call, it writes its own effect receipt; Tools never depends on Skills) | | `Trinity.Scheduler` | Oban workers for agent tasks, delivery (as built at 050: the `tasks` and `task_runs` rows, `next_run_at` computed from a cron expression or a one-shot datetime, `Workers.Tick` (the one Cron plugin entry, every minute, enqueuing `Workers.RunTask` for what is due), `RunTask` (one turn in a fresh `origin: "cron"` session, the summary, the delivery), `Delivery` with `Delivery.Desktop`, `Parse.human/2` (a phrase to cron through the model, checked by Oban's parser); Oban itself on the app's repo, Lite on SQLite and Basic on Postgres, queues `agent_tasks`, `memory`, `maintenance`; the observer as `Trinity.Memory.ObserverWorker` and the curator as `Trinity.Memory.Curator` live in Memory) | Sessions, LLM, **Repo** (Gateways at 070) | | `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`; at 062: `Trinity.MCP.Auth`, a boundary of its own under `Trinity.MCP` with `deps: []` (the behaviour `authorize/2` answering a `Principal`, never the token; `Config`, `Token`, `JWKS`, `Discovery`, `Scopes`; the profiles `Local`, `External` and `Embedded` with `Embedded.Keys`; the client role `Client` with `Client.Store`), and `AuthHost` in `Trinity.MCP` (the configuration from the tree's paths and the authority selection, the personal profile's boot, the refusal receipts, the client role's flow for the page); `Server.Auth.Local` is gone, `Server.Plug` authorizes ahead of the transport and answers `401` with a challenge, `Server` checks scope before the gate) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) | -| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing (as built at 070: `Adapter` (the behaviour: `child_spec/1`, `capabilities/0`, `deliver/2`, `format/2`, `render_approval/2`, plus `name/1` deriving the row's adapter name from the module), `Console` (the in-process adapter the tests and `mix trinity.console` talk to), `Format` (chunking to the channel's limit, re-opening a split code fence, markdown reduced to text where a channel renders none, the default approval rendering), `Identity` and `Identities` (the `gateway_identities` rows, the pairing code and the allowlist), `Router` (one process: admission, then the token bucket, then commands, then the session; it subscribes to `session:` and `approvals:` per conversation and coalesces deltas into an edited message), `Commands` (the slash registry, including `/attach`, `/approve` and `/deny`), `Cap` (the channel trust cap) and `Receipts` (the capped-decision receipt); at 071: `Supervisor` (the router and the adapters in force, started by the application, nothing when none is configured; `TRINITY_GATEWAYS` names them from the `available:` list), the optional `status/0` callback and the `{:message, text, buttons}` outbound, attachments the router fetches only after admission, and `Telegram` (`Outbox`, `Poller`, `Updates`, `Markdown` to MarkdownV2, `Media`, `Offset`, `Client` over Req)) | Sessions, **Permissions**, PubSub | +| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing (as built at 070: `Adapter` (the behaviour: `child_spec/1`, `capabilities/0`, `deliver/2`, `format/2`, `render_approval/2`, plus `name/1` deriving the row's adapter name from the module), `Console` (the in-process adapter the tests and `mix trinity.console` talk to), `Format` (chunking to the channel's limit, re-opening a split code fence, markdown reduced to text where a channel renders none, the default approval rendering), `Identity` and `Identities` (the `gateway_identities` rows, the pairing code and the allowlist), `Router` (one process: admission, then the token bucket, then commands, then the session; it subscribes to `session:` and `approvals:` per conversation and coalesces deltas into an edited message), `Commands` (the slash registry, including `/attach`, `/approve` and `/deny`), `Cap` (the channel trust cap) and `Receipts` (the capped-decision receipt); at 071: `Supervisor` (the router and the adapters in force, started by the application, nothing when none is configured; `TRINITY_GATEWAYS` names them from the `available:` list), the optional `status/0` callback and the `{:message, text, buttons}` outbound, attachments the router fetches only after admission, and `Telegram` (`Outbox`, `Poller`, `Updates`, `Markdown` to MarkdownV2, `Media`, `Offset`, `Client` over Req; at 072 the optional `callback/2` (a platform calling back over HTTP, reached through `Gateways.callback/3` and `POST /gateways/callback/:adapter/:kind`, the adapter found by name among the configured ones), and `Mattermost`: `Options`, `State`, `Client` (REST), `Socket` (the server's WebSocket), `Events` (a frame read, pure), `Conversation` (a channel or a thread, parsed against the server's id shape), `Format` (chunks fitted to the server's `MaxPostSize` in code points), `Signing` (the dialog's HMAC) and `Callbacks` (the button, the dialog, the slash command)) | Sessions, **Permissions**, PubSub | | `Trinity.Subagents` | Delegation, result collection (as built at 080: `delegate/3` and `delegate_many/3` creating child sessions with `origin: "subagent"` and `parent_id` set, one turn each under a monitor, the awaiting shape reused from slice 050's `RunTask` including its drain of the start `:idle`; budgets of turns, tokens and wall clock; `restarts` defaulting to **0**, because replaying a turn replays arbitrary tool calls and a `:session` or `:always` approval would cover the replay silently; `cancel_subtree/1` depth first; `running?/1` asked of the process rather than of `SessionRow.status`, which is the session lifecycle and stays `active` after a subagent finishes; `short_id/1` taking the **tail** of a UUIDv7, whose head is shared between children created in the same second) | Sessions | | `Trinity.Sandbox` | Luerl runners, resource limits | none | | `Trinity.Desktop` | ex_tauri bridge | PubSub | @@ -117,7 +120,7 @@ through those modules only. `boundary` `exports:` lists enforce this. |---|---|---| | `Trinity.LLM.Provider` | `stream/3`, `generate/3`, `embed/2`, `models/0`, `capabilities/1` | config `:providers` list | | `Trinity.Tools.Tool` | `name/0`, `description/0`, `schema/0`, `risk/0`, `execute/2` | config `:tools` list + MCP dynamic registration | -| `Trinity.Gateways.Adapter` | `child_spec/1`, `deliver/2`, `capabilities/0` | config `:gateways` list | +| `Trinity.Gateways.Adapter` | `child_spec/1`, `deliver/2`, `capabilities/0`, `format/2`, `render_approval/2`; optional `callback/2` (slice 072) | config `:gateways` list (`Module` or `{Module, opts}`) | | `Trinity.Memory.VectorStore` | `upsert/3`, `search/3`, `delete/1`, `count/1` (as built at 032: the scope filter is `search/3`'s required argument) | the database adapter, at compile time (`Brute` on SQLite, `Pgvector` on Postgres; `Hnswlib` when a slice measures past 10^5 rows) | | `Trinity.Memory.Embedder` | `embed/1`, `dim/0`, `model_id/0`, `availability/0` | `config :trinity, :memory, embedder:` (`:local` default, `:hosted` opt-in only, `:fake` in the suite) | | `Trinity.Skills.Loader` | `load/1`, `validate/1` | config `:skill_loaders` (not built at 040: the three roots are fixed and `Trinity.Skills.Sources` scans them; a loader behaviour waits for a fourth kind of source) | diff --git a/docs/05-data-model.md b/docs/05-data-model.md index fc6d49c..6de3b75 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -21,7 +21,7 @@ with adapter-specific `execute/1` guarded by `repo().__adapter__()`. | title | string | auto-generated after first turn | | persona_id | fk personas | | | parent_id | fk sessions, nullable | lineage across compactions (Slice 023) | -| origin | string | "desktop" \| "telegram" \| "discord" \| "console" \| "cron" \| "subagent" \| "mcp" \| "a2a" | +| origin | string | "desktop" \| "telegram" \| "mattermost" \| "console" \| "cron" \| "subagent" \| "mcp" \| "a2a" (slice 072: "mattermost" replaced "discord", a platform that was dropped and never had an adapter) | | origin_ref | map | platform ids (chat_id, channel_id) | | status | string | "active" \| "archived" \| "compacted" | | model | string | current model (may differ from persona default) | diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 6f85c0b..c710fd5 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -365,6 +365,50 @@ regulated effect. - **Not built:** webhook mode (an inbound HTTPS route whose only credential is a header, worth a review of its own), sending images, voice notes and stickers. +### Mattermost (Slice 072, as built) + +The second platform adapter. Nothing above changes for it: it carries text to the router, the cap +applies to it with the default ceiling (`:write`), and `test/trinity/gateways/census_test.exs` +reads every adapter's compiled calls and fails if one reaches the LLM, a session, the gate, an +effect or a tool. What is particular to it: + +- **Which way the network goes.** Messages arrive over the server's WebSocket, which Trinity + opens. The server reaches Trinity only for the interactive controls (the approval dialog and + the `/trinity` slash command), and only when the operator sets a callback URL; without one the + adapter offers no control and approvals are answered by command. +- **A callback proves where it came from, or nothing in it is read.** The "Answer" button's + context is an HMAC-SHA-256 over the answers it offers, the conversation and an expiry, under a key + generated at adapter start and kept only in memory. The dialog it opens carries a second HMAC, + under a separately derived key, binding the person who pressed the button, the conversation, a + ten-minute expiry and a nonce spent on first use. The slash command is compared in constant time + with the command token the server issued (`MATTERMOST_COMMAND_TOKEN`). Anything else answers + 403 before a field is read. +- **A dialog decides nothing.** It offers the post's buttons (071's shape: a label and the command + a press sends, `/approve ` with the full id), and the one chosen is handed to the router as + that command, from that person, in that conversation; the commands live in the signed state and + are never sent to the client. Pairing, the rate + limit, the cap and `Trinity.Permissions.decide_request/3` see it exactly as they see a typed + command, so an `:exec` or `:destructive` request answered from a dialog is refused and receipted + like any other. The router offers a control only where the cap would allow the answer; the cap + is still enforced where it always was. +- **The token** is read through `Trinity.Config.secret/1` each time a request or a connection is + built. The socket keeps it in its connection for the life of that connection, which is what + lets the server resume a dropped connection and replay what was missed (measured; slice 072 + NOTES D11). No log line carries it, tested on every error path with SASL reports turned on. + `token` and `response_url` are filtered from the request log. +- **TLS** to the server is verified (OTP's trust store or a configured CA bundle, hostname + checked, TLS 1.2 at least). The WebSocket library's own default does not verify, and is not used. +- **Under `TRINITY_PROFILE=regulated`** the adapter must be named on `TRINITY_REGULATED_GATEWAYS` + or the node refuses to boot, asserted for this adapter in a spawned boot + (`test/trinity/gateways/mattermost/regulated_boot_test.exs`). Naming it makes the channel + allowed on this node; it says nothing about whether the server it talks to is authorized, which + is the operator's statement to make. + +Egress, enumerated: to the server Trinity named, the assistant's replies and approval renderings +(tool name, risk and up to forty characters of each argument), the scheduled-run summaries, and +the bot token as a bearer credential. Nothing is hashed; it is the conversation itself, sent to +the server the conversation is on. + ## Effects and receipts (Slice 024, as built) The runner in force is `Trinity.Effects.Runner`. For every validated call it asks the gate once and writes a diff --git a/lib/mix/tasks/trinity.secrets.scan.ex b/lib/mix/tasks/trinity.secrets.scan.ex index d96b9fd..8ceb181 100644 --- a/lib/mix/tasks/trinity.secrets.scan.ex +++ b/lib/mix/tasks/trinity.secrets.scan.ex @@ -18,6 +18,10 @@ defmodule Mix.Tasks.Trinity.Secrets.Scan do {"private key block", ~r/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/}, # Slice 071: `:AA<33 more>`, the shape BotFather issues. {"Telegram bot token", ~r/\b[0-9]{8,10}:AA[A-Za-z0-9_-]{33}\b/}, + # Slice 072: a Mattermost token has the shape of every Mattermost id (26 lower-case letters and + # digits), so it is found where it is named, not by its shape alone. + {"Mattermost token assignment", + ~r/(?i)\b(?:MMAUTHTOKEN|mattermost[_-]?(?:bot[_-]?|command[_-]?)?token)\b\s*[:=]\s*["']?[a-z0-9]{26}\b/}, {"generic long secret assignment", ~r/(?i)\b(?:api[_-]?key|secret[_-]?key|access[_-]?token)\b\s*[:=]\s*["'][A-Za-z0-9\/+_-]{24,}["']/} ] diff --git a/lib/trinity/gateways.ex b/lib/trinity/gateways.ex index 2ee1580..7e17609 100644 --- a/lib/trinity/gateways.ex +++ b/lib/trinity/gateways.ex @@ -11,13 +11,25 @@ defmodule Trinity.Gateways do Adapters are configured, not compiled in: `config :trinity, :gateways, adapters: [Module, …]`, or by name from the `available:` list (`TRINITY_GATEWAYS=telegram`, slice 071). `Console` ships - here and is what the tests and `mix trinity.console` talk to; `Telegram` is the first platform. + here and is what the tests and `mix trinity.console` talk to; `Telegram` is the first platform, + and `Mattermost` (slice 072) the second. `Trinity.Gateways.Supervisor` runs the router and the adapters in force, and nothing when there are none. """ use Boundary, deps: [Trinity, Trinity.Sessions], - exports: [Adapter, Cap, Console, Format, Identities, Identity, Router, Supervisor, Telegram] + exports: [ + Adapter, + Cap, + Console, + Format, + Identities, + Identity, + Mattermost, + Router, + Supervisor, + Telegram + ] @doc """ The adapter modules in force: those named in `adapters:`, then those from `available:` whose @@ -35,4 +47,30 @@ defmodule Trinity.Gateways do Enum.uniq(Keyword.get(config, :adapters, []) ++ named) end + + @doc """ + The adapter in force whose name (`Trinity.Gateways.Adapter.name/1`) is `name`, or nil (slice + 072). This is how anything holding a name from outside (a callback URL) reaches an adapter: by + looking it up among the ones in force, never by turning the outside value into a module. + """ + @spec find(String.t()) :: module() | nil + def find(name) when is_binary(name), + do: Enum.find(adapters(), &(Trinity.Gateways.Adapter.name(&1) == name)) + + @doc """ + An HTTP callback from a platform, handed to the adapter in force named `name` (slice 072). The + web layer calls this and nothing more specific: it never learns which platforms exist. Answers + `{:error, :not_found}` when no adapter in force has that name or the adapter takes no callbacks, + so an unconfigured platform is indistinguishable from a path that does not exist. + """ + @spec callback(String.t(), String.t(), map()) :: + {:ok, map()} | {:error, :not_found | :forbidden | :bad_request} + def callback(name, kind, params) when is_binary(name) and is_binary(kind) and is_map(params) do + with adapter when not is_nil(adapter) <- find(name), + true <- Code.ensure_loaded?(adapter) and function_exported?(adapter, :callback, 2) do + adapter.callback(kind, params) + else + _ -> {:error, :not_found} + end + end end diff --git a/lib/trinity/gateways/adapter.ex b/lib/trinity/gateways/adapter.ex index 6e00d03..c78234e 100644 --- a/lib/trinity/gateways/adapter.ex +++ b/lib/trinity/gateways/adapter.ex @@ -98,5 +98,18 @@ defmodule Trinity.Gateways.Adapter do """ @callback status() :: status() - @optional_callbacks status: 0 + @doc """ + An HTTP request the platform made to Trinity (slice 072): a button pressed, a dialog submitted, + a slash command. `kind` is the last segment of `POST /gateways/callback//` and + `params` the decoded body. Answers the JSON body to return, or why the request is refused. + + Optional, because a platform that only ever talks over a connection Trinity opened (the + console, a long-polling bot) never calls back. An adapter that implements it verifies the + request itself, since nothing in the web layer knows what a platform's proof of origin is, and + it still decides nothing: anything that is a decision goes to `Router.inbound/5` as a command. + """ + @callback callback(kind :: String.t(), params :: map()) :: + {:ok, map()} | {:error, :not_found | :forbidden | :bad_request} + + @optional_callbacks status: 0, callback: 2 end diff --git a/lib/trinity/gateways/mattermost.ex b/lib/trinity/gateways/mattermost.ex new file mode 100644 index 0000000..8d899a6 --- /dev/null +++ b/lib/trinity/gateways/mattermost.ex @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost do + @moduledoc """ + The Mattermost adapter (slice 072): direct messages and channel mentions in, streamed replies by + editing the bot's own post, approvals answered through an interactive dialog, one slash command, + and the scheduler's gateway delivery out. + + Run like every gateway (slice 071): named on `TRINITY_GATEWAYS` (`mattermost`) from the + `available:` list, or listed in `config :trinity, :gateways, adapters:`. Its own options are + `config :trinity, :mattermost, url: …, callback_url: …` (`config/runtime.exs` sets them from + `MATTERMOST_URL` and `MATTERMOST_CALLBACK_URL`), with whatever its child spec is given over them. + Named with no `url` it starts idle and `/gateways` says why, rather than failing the node's boot. + + The bot token is read through `Trinity.Config.secret/1` from `MATTERMOST_BOT_TOKEN` (or the + variable `token_env:` names) each time a request or a connection is built, and never logged. The + one place it outlives a call is the socket's connection, which needs it to reconnect resumably + (`Socket`'s moduledoc says why that was measured rather than assumed). + + **Which way the network goes.** Messages arrive over the server's WebSocket, which Trinity opens, + so a server Trinity can reach is all it takes to talk. The server never has to reach Trinity for + that. Only the interactive controls do: a button press, a dialog submission and the slash + command are HTTP requests the server makes to `callback_url`. Without a `callback_url` the + adapter still carries messages and renders approvals as text, and says `buttons: false`. + + **What it decides: nothing.** Like every adapter it carries text to `Trinity.Gateways.Router` + and renders what the router gives back. An approval's buttons are 071's shape, a label and the + command a press sends back; here they become the options of one dialog, and the option chosen is + handed to the router as that command, typed by the person who chose it. The identity check, the + rate limit, the channel cap and `Trinity.Permissions` see it exactly as they see a typed answer. + + The process tree, `:rest_for_one`: `State` (an ETS table of what has to outlive a dropped + connection: the server's facts, the resume point, the posts already handled, the callback key), + then `Socket`. A socket that dies is restarted and resumes; a `State` that dies takes the socket + with it, because a socket without its table would answer posts twice. + """ + @behaviour Trinity.Gateways.Adapter + + use Supervisor + + alias Trinity.Gateways.Cap + + alias Trinity.Gateways.Mattermost.{ + Callbacks, + Client, + Conversation, + Format, + Options, + Socket, + State + } + + alias Trinity.Permissions.Approval + + ## The process + + @doc """ + Starts the adapter's tree: `config :trinity, :mattermost` with `opts` over it, validated by + `Options` (a malformed value raises; a missing `url` does not, it leaves the adapter idle). + """ + @spec start_link(keyword()) :: Supervisor.on_start() + def start_link(opts \\ []) do + opts = + :trinity + |> Application.get_env(:mattermost, []) + |> Keyword.merge(opts) + |> Options.validate!() + + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl Trinity.Gateways.Adapter + def child_spec(opts), + do: %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :supervisor} + + @impl Supervisor + def init(opts) do + socket = if opts[:url], do: [{Socket, opts}], else: [] + Supervisor.init([{State, opts}] ++ socket, strategy: :rest_for_one) + end + + ## The behaviour + + @doc """ + What this server lets a message be. `max_length` is the server's own `MaxPostSize`, read on + every connect (D10 in the slice's notes); `buttons` is whether the server can reach Trinity to + press one. + """ + @impl Trinity.Gateways.Adapter + def capabilities do + %{ + markdown: true, + images: false, + buttons: State.callback_url() != nil, + edits: true, + max_length: State.max_post_size() + } + end + + @impl Trinity.Gateways.Adapter + def deliver(conversation, outbound) do + with {:ok, where} <- Conversation.parse(conversation) do + send_outbound(where, outbound) + end + end + + defp send_outbound(where, {:message, text}), do: create(where, text, %{}) + + defp send_outbound(where, {:message, text, buttons}) do + props = + case State.callback_url() do + url when is_binary(url) and buttons != [] -> + Format.button_props(buttons, where, text, url) + + _ -> + %{} + end + + create(where, text, props) + end + + defp send_outbound(_where, {:edit, post_id, text}) do + with :ok <- Conversation.check_id(post_id), + {:ok, _} <- Client.patch_post(post_id, %{"message" => text}) do + :ok + end + end + + defp send_outbound(where, {:typing, true}) do + with {:ok, _} <- Client.typing(where.channel_id, where.root_id), do: :ok + end + + defp send_outbound(_where, {:typing, false}), do: :ok + + defp create(where, text, props) do + body = + %{"channel_id" => where.channel_id, "message" => text, "root_id" => where.root_id || ""} + |> then(fn body -> if props == %{}, do: body, else: Map.put(body, "props", props) end) + + case Client.create_post(body) do + {:ok, %{"id" => id}} -> {:ok, id} + {:ok, _other} -> {:error, :unexpected_response} + {:error, _} = error -> error + end + end + + @impl Trinity.Gateways.Adapter + defdelegate format(text, capabilities), to: Format + + @doc """ + An approval in Mattermost's words, and, when the server can call back and the channel may + answer this tier, the two answers as buttons carrying the full id (slice 071's shape, and its + finding F2: a short id can name two requests). The router offers them only where the cap + allows; this checks too, so an adapter never draws a control that can only be refused. + """ + @impl Trinity.Gateways.Adapter + def render_approval(%Approval{} = approval, capabilities) do + {:message, text} = Format.render_approval(approval, capabilities) + + if capabilities.buttons and Cap.allows?(__MODULE__, approval.risk) do + {:message, text, + [{"Approve once", "/approve " <> approval.id}, {"Deny", "/deny " <> approval.id}]} + else + {:message, text} + end + end + + @impl Trinity.Gateways.Adapter + defdelegate callback(kind, params), to: Callbacks, as: :handle + + @doc """ + What `/gateways` shows (slice 071's optional `status/0`): connected to which server as whom, + idle and why, or the last thing that went wrong, in words with no credential in them. + """ + @impl Trinity.Gateways.Adapter + @spec status() :: %{state: atom(), detail: String.t()} + def status do + case State.options() do + nil -> %{state: :stopped, detail: "not started"} + %{url: nil} -> %{state: :idle, detail: "idle: MATTERMOST_URL is not set"} + options -> running_status(options) + end + end + + defp running_status(options) do + case {Trinity.Config.secret(options.token_env), State.connection()} do + {{:error, _}, _} -> + %{state: :idle, detail: "idle: #{options.token_env} is not set"} + + {_, {:connected, username}} -> + %{state: :connected, detail: "connected to #{options.url} as @#{username}"} + + {_, {:error, why}} -> + %{state: :error, detail: "#{options.url}: #{why}"} + + {_, nil} -> + %{state: :connecting, detail: "connecting to #{options.url}"} + end + end +end diff --git a/lib/trinity/gateways/mattermost/callbacks.ex b/lib/trinity/gateways/mattermost/callbacks.ex new file mode 100644 index 0000000..d79f639 --- /dev/null +++ b/lib/trinity/gateways/mattermost/callbacks.ex @@ -0,0 +1,184 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Callbacks do + @moduledoc """ + The requests a Mattermost server makes to Trinity (slice 072), at + `POST /gateways/callback/mattermost/`: + + - `action`: a post's "Answer" button was pressed. The context is verified (`Signing`), and a + dialog is opened for the person who pressed it, offering the post's buttons (slice 071's + shape: for an approval, "Approve once" and "Deny"). + - `dialog`: the dialog was submitted. The state is verified, bound to the same person and spent + once; the chosen answer's command (`/approve ` or `/deny `, the full id) is handed to + `Trinity.Gateways.Router.inbound/5` from that person, in the post's conversation. + - `command`: the `/trinity` slash command. The server's command token is compared, in constant + time, with `MATTERMOST_COMMAND_TOKEN`; the text is handed to the router as `/`. + + Nothing here decides an approval. A dialog's answer reaches `Trinity.Gateways.Commands` exactly + as a typed command does, so the pairing check, the rate limit and the channel cap apply to it, + and an `:exec` or `:destructive` request answered here is refused and receipted like any other + (docs/07, the channel trust cap). A request that cannot prove where it came from is refused + with `:forbidden` before anything else is read. + """ + + alias Trinity.Gateways.Mattermost + alias Trinity.Gateways.Mattermost.{Client, Conversation, Signing, State} + alias Trinity.Gateways.Router + + require Logger + + @doc "Handles one callback. Answers the JSON body for the server, or why it was refused." + @spec handle(String.t(), map()) :: + {:ok, map()} | {:error, :not_found | :forbidden | :bad_request} + def handle(kind, params) do + if State.options() == nil, do: {:error, :not_found}, else: dispatch(kind, params) + end + + defp dispatch("action", params), do: action(params) + defp dispatch("dialog", params), do: dialog(params) + defp dispatch("command", params), do: command(params) + defp dispatch(_kind, _params), do: {:error, :not_found} + + ## The button + + defp action( + %{"context" => %{"token" => token}, "user_id" => user_id, "channel_id" => channel_id} = + params + ) do + with {:ok, claims} <- Signing.verify("button", token), + :ok <- same_channel(claims["c"], channel_id), + true <- Conversation.id?(user_id) do + open_dialog(claims, user_id, params["trigger_id"]) + else + _ -> {:error, :forbidden} + end + end + + defp action(_params), do: {:error, :forbidden} + + defp open_dialog(claims, user_id, trigger_id) when is_binary(trigger_id) do + buttons = for [label, command] <- claims["b"], do: {label, command} + + dialog = %{ + "callback_id" => "trinity_answer", + "title" => "Answer", + "introduction_text" => claims["s"], + "submit_label" => "Answer", + "notify_on_cancel" => false, + "state" => Signing.dialog(buttons, claims["c"], user_id), + "elements" => [ + %{ + "display_name" => "Decision", + "name" => "decision", + "type" => "radio", + "options" => + buttons + |> Enum.with_index() + |> Enum.map(fn {{label, _command}, i} -> %{"text" => label, "value" => "#{i}"} end) + } + ] + } + + url = State.callback_url() <> "/gateways/callback/mattermost/dialog" + + case Client.open_dialog(%{"trigger_id" => trigger_id, "url" => url, "dialog" => dialog}) do + {:ok, _} -> + {:ok, %{}} + + {:error, reason} -> + Logger.warning("mattermost: the dialog did not open: #{describe(reason)}") + + {:ok, + %{ + "ephemeral_text" => + "The dialog did not open. Answer with the /trinity command in the message instead." + }} + end + end + + defp open_dialog(_claims, _user_id, _trigger_id), do: {:error, :bad_request} + + ## The dialog + + defp dialog(%{"cancelled" => true}), do: {:ok, %{}} + + defp dialog(%{"state" => state, "user_id" => user_id, "channel_id" => channel_id} = params) do + with {:ok, claims} <- Signing.verify("dialog", state), + true <- claims["u"] == user_id, + :ok <- same_channel(claims["c"], channel_id), + true <- State.spend_nonce(claims["n"], claims["e"]) do + answer(claims, user_id, get_in(params, ["submission", "decision"])) + else + _ -> {:error, :forbidden} + end + end + + defp dialog(_params), do: {:error, :forbidden} + + # The answer is one the dialog's own signed state names, by its place in the list; nothing the + # submission says is used as text. The command goes to the router as if this person typed it. + defp answer(claims, user_id, choice) do + with true <- is_binary(choice), + {index, ""} <- Integer.parse(choice), + [_label, command] <- Enum.at(claims["b"], index) do + _ = Router.inbound(Mattermost, claims["c"], user_id, command) + {:ok, %{}} + else + _ -> + # The nonce is spent by now, so the person reopens the dialog from the button; the server + # shows the error beside the field. + {:ok, %{"errors" => %{"decision" => "Choose an answer, then press the button again."}}} + end + end + + ## The slash command + + defp command(%{"token" => given, "user_id" => user_id, "channel_id" => channel_id} = params) + when is_binary(given) do + with {:ok, expected} <- Trinity.Config.secret(State.option(:command_token_env)), + true <- Plug.Crypto.secure_compare(given, expected), + true <- Conversation.id?(user_id) and Conversation.id?(channel_id) do + conversation = Conversation.of(channel_id, thread(params)) + text = "/" <> command_text(params["text"]) + + _ = + Router.inbound(Mattermost, conversation, user_id, text, display_name: params["user_name"]) + + {:ok, %{}} + else + _ -> {:error, :forbidden} + end + end + + defp command(_params), do: {:error, :forbidden} + + defp command_text(text) when is_binary(text) do + case String.trim(text) do + "" -> "help" + text -> text + end + end + + defp command_text(_text), do: "help" + + defp thread(%{"root_id" => root}) when is_binary(root) and root != "" do + if Conversation.id?(root), do: root, else: nil + end + + defp thread(_params), do: nil + + defp same_channel(conversation, channel_id) when is_binary(conversation) do + case Conversation.parse(conversation) do + {:ok, %{channel_id: ^channel_id}} -> :ok + _ -> :error + end + end + + defp same_channel(_conversation, _channel_id), do: :error + + defp describe({:http, status, id}), do: "HTTP #{status} #{id}" + defp describe({:transport, reason}), do: "transport #{reason}" + defp describe(other) when is_atom(other), do: Atom.to_string(other) + defp describe({:missing_secret, env}), do: "#{env} is not set" + defp describe(_other), do: "unexpected error" +end diff --git a/lib/trinity/gateways/mattermost/client.ex b/lib/trinity/gateways/mattermost/client.ex new file mode 100644 index 0000000..41e5560 --- /dev/null +++ b/lib/trinity/gateways/mattermost/client.ex @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Client do + @moduledoc """ + The Mattermost REST calls the adapter makes (slice 072), and nothing else. + + **The token.** Each call reads it through `Trinity.Config.secret/1` and hands it to the request + as a bearer credential; it is never stored, returned, or put in an error. An error says which + call failed and how (`{:http, status, server_error_id}`, `{:transport, reason}`), never with the + request attached, because a request carries its headers and a header is where the token is. + A raise inside the HTTP client is caught for the same reason: its message or its arguments could + carry the request. + """ + + alias Trinity.Gateways.Mattermost.State + + @receive_timeout 15_000 + + @typedoc "Why a call failed, with nothing in it that came from the request." + @type error :: + {:missing_secret, String.t()} + | {:http, pos_integer(), String.t() | nil} + | {:transport, atom() | String.t()} + | :not_running + + @doc "The bot's own user." + @spec me() :: {:ok, map()} | {:error, error()} + def me, do: request(:get, "/api/v4/users/me") + + @doc "The client configuration, the old format, which is where `MaxPostSize` is." + @spec client_config() :: {:ok, map()} | {:error, error()} + def client_config, do: request(:get, "/api/v4/config/client", params: [format: "old"]) + + @doc "Creates a post." + @spec create_post(map()) :: {:ok, map()} | {:error, error()} + def create_post(body), do: request(:post, "/api/v4/posts", json: body) + + @doc "Patches a post the bot made (its message). The id must already have been checked." + @spec patch_post(String.t(), map()) :: {:ok, map()} | {:error, error()} + def patch_post(post_id, body), do: request(:put, "/api/v4/posts/#{post_id}/patch", json: body) + + @doc "Opens an interactive dialog for the person who pressed a button (`trigger_id`)." + @spec open_dialog(map()) :: {:ok, map()} | {:error, error()} + def open_dialog(body), do: request(:post, "/api/v4/actions/dialogs/open", json: body) + + @doc "Shows the bot as typing in a channel, or a thread in it." + @spec typing(String.t(), String.t() | nil) :: {:ok, map()} | {:error, error()} + def typing(channel_id, root_id) do + request(:post, "/api/v4/users/me/typing", + json: %{"channel_id" => channel_id, "parent_id" => root_id || ""} + ) + end + + @doc "The token, read now and from nowhere else. Public so the socket uses the same read." + @spec token() :: {:ok, String.t()} | {:error, error()} + def token do + case State.options() do + nil -> {:error, :not_running} + options -> Trinity.Config.secret(options.token_env) + end + end + + defp request(method, path, opts \\ []) do + with {:ok, token} <- token() do + State.options() + |> base(token) + |> Req.request([method: method, url: path] ++ opts) + |> answer() + end + rescue + error -> {:error, {:transport, error.__struct__ |> Module.split() |> Enum.join(".")}} + end + + defp base(options, token) do + Req.new( + base_url: options.url, + auth: {:bearer, token}, + retry: false, + receive_timeout: @receive_timeout, + connect_options: connect_options(options) + ) + end + + defp connect_options(%{cacertfile: path}) when is_binary(path), + do: [transport_opts: [cacertfile: path]] + + defp connect_options(_options), do: [] + + defp answer({:ok, %Req.Response{status: status, body: body}}) when status in 200..299, + do: {:ok, if(is_map(body), do: body, else: %{})} + + defp answer({:ok, %Req.Response{status: status, body: body}}), + do: {:error, {:http, status, server_error_id(body)}} + + defp answer({:error, %{reason: reason}}) when is_atom(reason), + do: {:error, {:transport, reason}} + + defp answer({:error, exception}), + do: {:error, {:transport, exception.__struct__ |> Module.split() |> Enum.join(".")}} + + # The server's error id (`api.context.session_expired.app_error`) names the failure without + # repeating anything sent; its `message` can quote the request, so it is not kept. + defp server_error_id(%{"id" => id}) when is_binary(id), do: id + defp server_error_id(_body), do: nil +end diff --git a/lib/trinity/gateways/mattermost/conversation.ex b/lib/trinity/gateways/mattermost/conversation.ex new file mode 100644 index 0000000..dc62af1 --- /dev/null +++ b/lib/trinity/gateways/mattermost/conversation.ex @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Conversation do + @moduledoc """ + A conversation as the router knows it, and where it is on the server (slice 072, decision D7). + + `` is a channel's top level: a direct message, or a cron delivery to a channel. + `:` is a thread in it: a reply inside a direct message, or a thread in a + channel that began with a mention of the bot. Each is one session. + + A conversation string reaches `deliver/2` from the router and also from a scheduled task's row, + which is data anyone with the database could write, and it ends up in a URL path. So it is + parsed against the shape the server issues ids in (26 lower-case letters and digits) and + refused otherwise, rather than interpolated. + """ + + @id ~r/\A[a-z0-9]{26}\z/ + + @typedoc "Where a conversation lives on the server." + @type where :: %{channel_id: String.t(), root_id: String.t() | nil} + + @doc "The conversation for a channel and an optional thread root." + @spec of(String.t(), String.t() | nil) :: String.t() + def of(channel_id, root) when root in [nil, ""], do: channel_id + def of(channel_id, root_id), do: channel_id <> ":" <> root_id + + @doc "Parses a conversation, refusing anything that is not a server id." + @spec parse(term()) :: {:ok, where()} | {:error, :bad_conversation} + def parse(conversation) when is_binary(conversation) do + case String.split(conversation, ":") do + [channel] -> + if id?(channel), do: {:ok, %{channel_id: channel, root_id: nil}}, else: bad() + + [channel, root] -> + if id?(channel) and id?(root), + do: {:ok, %{channel_id: channel, root_id: root}}, + else: bad() + + _ -> + bad() + end + end + + def parse(_other), do: bad() + + @doc "Whether a value is shaped like a server id." + @spec id?(term()) :: boolean() + def id?(value), do: is_binary(value) and Regex.match?(@id, value) + + @doc "`:ok` for a server id, an error otherwise." + @spec check_id(term()) :: :ok | {:error, :bad_id} + def check_id(value), do: if(id?(value), do: :ok, else: {:error, :bad_id}) + + defp bad, do: {:error, :bad_conversation} +end diff --git a/lib/trinity/gateways/mattermost/events.ex b/lib/trinity/gateways/mattermost/events.ex new file mode 100644 index 0000000..72b312f --- /dev/null +++ b/lib/trinity/gateways/mattermost/events.ex @@ -0,0 +1,163 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Events do + @moduledoc """ + A server event, read (slice 072): what the socket should do with one text frame. Pure, so it is + tested against frames recorded from a real server rather than against frames written from + memory of what a server sends. + + The rules (decision D7 in the slice's notes): + + - a `posted` event in a direct message (`channel_type` `"D"`) is for the bot; its conversation + is the DM channel, or the thread when the post is a reply; + - anywhere else (`"O"`, `"P"`, `"G"`) a post is for the bot only when it mentions the bot, or + continues a thread the bot was mentioned in; each such thread is one conversation, and the + mention itself is taken out of the text; + - the bot's own posts, other bots' and webhooks' posts, and system posts (any post with a + `type`) are never for the bot, which is what stops two bots answering each other forever. + """ + + alias Trinity.Gateways.Mattermost.Conversation + alias Trinity.Gateways.Mattermost.State + + @typedoc "A message for the router, and what the socket needs besides." + @type inbound :: %{ + post_id: String.t(), + conversation: String.t(), + user_id: String.t(), + text: String.t(), + display_name: String.t() | nil, + files: non_neg_integer(), + follow: String.t() | nil + } + + @typedoc "What a frame is." + @type t :: + {:hello, String.t(), non_neg_integer()} + | {:auth, :ok | {:failed, term()}} + | {:post, non_neg_integer(), inbound()} + | {:event, non_neg_integer() | nil, ignored :: atom()} + | {:unreadable, atom()} + + @doc """ + Reads one text frame. `facts` are the server's (the bot's id and name); `following?` answers + whether a channel thread is one the bot already follows. + """ + @spec read(String.t(), State.facts() | nil, (String.t() -> boolean())) :: t() + def read(frame, facts, following?) when is_binary(frame) do + case Jason.decode(frame) do + {:ok, decoded} when is_map(decoded) -> classify(decoded, facts, following?) + _ -> {:unreadable, :not_json} + end + end + + defp classify(%{"event" => "hello", "data" => %{"connection_id" => id}, "seq" => seq}, _f, _p) + when is_binary(id) and is_integer(seq), + do: {:hello, id, seq} + + defp classify(%{"seq_reply" => 1, "status" => "OK"}, _facts, _p), do: {:auth, :ok} + + defp classify(%{"seq_reply" => 1, "status" => "FAIL"} = reply, _facts, _p), + do: {:auth, {:failed, error_id(reply)}} + + defp classify(%{"event" => "posted", "seq" => seq, "data" => data}, facts, following?) + when is_integer(seq) do + case post(data, facts, following?) do + {:ok, inbound} -> {:post, seq, inbound} + {:ignore, why} -> {:event, seq, why} + end + end + + defp classify(%{"event" => _other, "seq" => seq}, _facts, _p), do: {:event, seq, :not_a_post} + defp classify(_other, _facts, _p), do: {:event, nil, :not_an_event} + + defp error_id(%{"error" => %{"id" => id}}) when is_binary(id), do: id + defp error_id(_reply), do: :unknown + + defp post(_data, nil, _following?), do: {:ignore, :no_facts_yet} + + defp post(%{"post" => raw} = data, facts, following?) when is_binary(raw) do + with {:ok, %{} = post} <- Jason.decode(raw), + :ok <- addressed_by_someone(post, facts) do + where(post, data, facts, following?) + else + {:ignore, _} = ignore -> ignore + _ -> {:ignore, :unreadable_post} + end + end + + defp post(_data, _facts, _following?), do: {:ignore, :unreadable_post} + + # The first reason a post is not for the bot, or `:ok`. + defp addressed_by_someone(post, facts) do + props = Map.get(post, "props") || %{} + + [ + {post["user_id"] == facts.bot_user_id, :own_post}, + {(post["type"] || "") != "", :system_post}, + {props["from_bot"] in ["true", true], :bot_post}, + {props["from_webhook"] in ["true", true], :webhook_post}, + {not Enum.all?([post["id"], post["channel_id"], post["user_id"]], &Conversation.id?/1), + :unreadable_post} + ] + |> Enum.find_value(:ok, fn {refused?, why} -> if refused?, do: {:ignore, why} end) + end + + defp where(post, %{"channel_type" => "D"} = data, _facts, _following?) do + {:ok, + inbound(post, data, Conversation.of(post["channel_id"], root(post)), post["message"], nil)} + end + + defp where(post, data, facts, following?) do + thread = Conversation.of(post["channel_id"], root(post) || post["id"]) + + cond do + mentioned?(data, facts) -> + text = strip_mention(post["message"], facts.bot_username) + {:ok, inbound(post, data, thread, text, thread)} + + root(post) != nil and following?.(thread) -> + {:ok, inbound(post, data, thread, post["message"], nil)} + + true -> + {:ignore, :not_addressed} + end + end + + defp inbound(post, data, conversation, text, follow) do + %{ + post_id: post["id"], + conversation: conversation, + user_id: post["user_id"], + text: String.trim(text || ""), + display_name: display_name(data), + files: length(post["file_ids"] || []), + follow: follow + } + end + + defp root(%{"root_id" => root}) when is_binary(root) and root != "", do: root + defp root(_post), do: nil + + # `mentions` is a JSON array of user ids, itself carried as a string, and absent when the post + # mentions nobody. + defp mentioned?(%{"mentions" => raw}, facts) when is_binary(raw) do + case Jason.decode(raw) do + {:ok, ids} when is_list(ids) -> facts.bot_user_id in ids + _ -> false + end + end + + defp mentioned?(_data, _facts), do: false + + defp strip_mention(text, username) when is_binary(text) do + Regex.replace(~r/(? String.replace(~r/[ \t]{2,}/, " ") + end + + defp strip_mention(_text, _username), do: "" + + defp display_name(%{"sender_name" => "@" <> name}), do: name + defp display_name(%{"sender_name" => name}) when is_binary(name), do: name + defp display_name(_data), do: nil +end diff --git a/lib/trinity/gateways/mattermost/format.ex b/lib/trinity/gateways/mattermost/format.ex new file mode 100644 index 0000000..7b635f3 --- /dev/null +++ b/lib/trinity/gateways/mattermost/format.ex @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Format do + @moduledoc """ + Trinity's text as Mattermost posts (slice 072). + + **Chunks fit the server's limit as the server counts it.** Mattermost renders markdown, so the + shared `Trinity.Gateways.Format` does the splitting; but the server measures a post in code + points (runes) and the shared chunker in graphemes, and a grapheme can be several code points + (a flag is two, a family emoji seven). The shared chunker also closes and re-opens a code fence + around a split, which adds eight characters after the split was measured. So it is asked for + chunks eight shorter than the limit, and any chunk still over the limit in code points is cut + again at grapheme boundaries. Every post this module produces is within `max_length` code points; + `test/trinity/gateways/mattermost/format_test.exs` holds that as a property. + + **Approvals** are rendered as the shared text with the slash command a Mattermost user can + actually type (`/trinity approve …`: a bare `/approve` is looked up by the server as a command of + its own and never reaches the bot), and, when the router says the channel may answer and the + server can reach Trinity, a message attachment carrying one button that opens the dialog. + """ + + alias Trinity.Gateways.Adapter + alias Trinity.Gateways.Format, as: Shared + alias Trinity.Gateways.Mattermost.Signing + alias Trinity.Permissions.Approval + + # "```\n" before a re-opened chunk and "\n```" after one left open. + @fence_overhead 8 + + @doc "An assistant message as posts, each within the server's limit in code points." + @spec format(String.t(), Adapter.capabilities()) :: [String.t()] + def format(text, %{max_length: max} = capabilities) when is_binary(text) do + # The fence allowance is only taken when there is a fence to re-open. + budget = if String.contains?(text, "```"), do: max(max - @fence_overhead, 1), else: max + + text + |> Shared.format(%{capabilities | max_length: budget}) + |> Enum.flat_map(&fit(&1, max)) + end + + @doc """ + Cuts a chunk that is over `max` code points at grapheme boundaries, and a single grapheme + longer than `max` at code points: the last resort, which no ordinary text reaches. + """ + @spec fit(String.t(), pos_integer()) :: [String.t()] + def fit(chunk, max) do + if codepoints(chunk) <= max do + [chunk] + else + chunk + |> String.graphemes() + |> Enum.flat_map(&pieces(&1, max)) + |> Enum.chunk_while({[], 0}, &pack(&1, &2, max), &flush/1) + end + end + + # A grapheme is a piece unless it alone is over the limit, when its code points are. + defp pieces(grapheme, max) do + if codepoints(grapheme) > max, do: String.codepoints(grapheme), else: [grapheme] + end + + defp pack(piece, {acc, n}, max) do + size = codepoints(piece) + + if n + size > max, + do: {:cont, join(acc), {[piece], size}}, + else: {:cont, {[piece | acc], n + size}} + end + + defp flush({[], _n}), do: {:cont, {[], 0}} + defp flush({acc, _n}), do: {:cont, join(acc), {[], 0}} + + defp join(reversed), do: reversed |> Enum.reverse() |> Enum.join() + + @doc "How the server measures a post: in code points." + @spec codepoints(String.t()) :: non_neg_integer() + def codepoints(text), do: text |> String.codepoints() |> length() + + @doc "An approval as a post's text. Mattermost's command form, not the router's bare one." + @spec render_approval(Approval.t(), Adapter.capabilities()) :: Adapter.outbound() + def render_approval(%Approval{} = approval, capabilities) do + {:message, text} = Shared.render_approval(approval, capabilities) + short = Shared.short(approval.id) + + text = + String.replace( + text, + "Answer with /approve #{short} or /deny #{short}", + "Answer with /trinity approve #{short} or /trinity deny #{short}" + ) + + {:message, text |> format(capabilities) |> List.first()} + end + + @doc """ + The props of a post with buttons (slice 071's `{:message, text, buttons}`), when the server can + call back: one attachment with one button, "Answer", which opens a dialog offering the buttons' + labels. The button's context is signed (`Signing`) over the answers and the conversation; the + server keeps an action's context to itself and sends it back only with the press. + """ + @spec button_props([{String.t(), String.t()}], map(), String.t(), String.t()) :: map() + def button_props(buttons, where, text, callback_url) do + conversation = + Trinity.Gateways.Mattermost.Conversation.of(where.channel_id, where.root_id) + + context = %{"token" => Signing.button(buttons, conversation, summary(text))} + + %{ + "attachments" => [ + %{ + "fallback" => "Answer", + "actions" => [ + %{ + "id" => "answer", + "name" => "Answer", + "type" => "button", + "style" => "primary", + "integration" => %{ + "url" => callback_url <> "/gateways/callback/mattermost/action", + "context" => context + } + } + ] + } + ] + } + end + + # The dialog repeats what is being decided; the first line is "Approval needed: tool (risk)". + defp summary(text), do: text |> String.split("\n", parts: 3) |> Enum.take(2) |> Enum.join("\n") +end diff --git a/lib/trinity/gateways/mattermost/options.ex b/lib/trinity/gateways/mattermost/options.ex new file mode 100644 index 0000000..b8fe52f --- /dev/null +++ b/lib/trinity/gateways/mattermost/options.ex @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Options do + @moduledoc """ + The Mattermost adapter's options (slice 072). The schema is the documentation (docs/03). + + None of them is a secret. The bot token and the slash command's token are named here by the + environment variable that holds them, and read through `Trinity.Config.secret/1` when used. + """ + + @schema [ + url: [ + type: {:or, [{:custom, __MODULE__, :http_url, []}, nil]}, + default: nil, + doc: + "The server's base URL, as a browser would open it (`https://chat.example.org`). The " <> + "WebSocket is derived from it (`wss://…/api/v4/websocket`). Unset, the adapter is idle." + ], + token_env: [ + type: :string, + default: "MATTERMOST_BOT_TOKEN", + doc: "The environment variable holding the bot account's access token." + ], + command_token_env: [ + type: :string, + default: "MATTERMOST_COMMAND_TOKEN", + doc: + "The environment variable holding the token the server sends with the `/trinity` " <> + "slash command. Unset, the command callback refuses every request." + ], + callback_url: [ + type: {:or, [{:custom, __MODULE__, :http_url, []}, nil]}, + default: nil, + doc: + "The base URL at which the server reaches Trinity, for the approval dialog. Unset, " <> + "approvals are rendered as text and the capabilities say `buttons: false`." + ], + cacertfile: [ + type: {:or, [:string, nil]}, + default: nil, + doc: "A CA bundle to verify the server against, for a server on a private PKI." + ], + backoff_ms: [ + type: :pos_integer, + default: 1_000, + doc: "The first reconnect delay; it doubles to `max_backoff_ms`." + ], + max_backoff_ms: [type: :pos_integer, default: 30_000, doc: "The longest reconnect delay."] + ] + + @doc "Validates the options, raising `NimbleOptions.ValidationError` on a bad one." + @spec validate!(keyword()) :: keyword() + def validate!(opts) do + opts + |> NimbleOptions.validate!(@schema) + |> Keyword.update!(:url, &trim/1) + |> Keyword.update!(:callback_url, &trim/1) + end + + defp trim(nil), do: nil + defp trim(url), do: String.trim_trailing(url, "/") + + @doc false + @spec http_url(term()) :: {:ok, String.t()} | {:error, String.t()} + def http_url(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + {:ok, value} + + _ -> + {:error, "expected an http or https URL with a host, got: #{inspect(value)}"} + end + end + + def http_url(value), do: {:error, "expected an http or https URL, got: #{inspect(value)}"} + + @doc "The schema's documentation, for the moduledoc of anything that takes these options." + @spec docs() :: String.t() + def docs, do: NimbleOptions.docs(@schema) +end diff --git a/lib/trinity/gateways/mattermost/signing.ex b/lib/trinity/gateways/mattermost/signing.ex new file mode 100644 index 0000000..c337133 --- /dev/null +++ b/lib/trinity/gateways/mattermost/signing.ex @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Signing do + @moduledoc """ + The proof that a button press or a dialog submission came from a control this adapter made + (slice 072, decision D5). + + Anyone who can reach Trinity's callback URL can send it a request claiming to be any user, so a + request is believed only when it carries a value only this adapter could have produced: an + HMAC-SHA-256 over what the control is about, under a key `State` generates at start and keeps + in memory. A restart makes every earlier control stale, which is the price of never writing the + key down; the text form (`/trinity approve `) still works. + + Two kinds, signed under separate derived keys so one can never be replayed as the other: + + - **button**: the answers offered (each a label and the command it sends, slice 071's button + shape), the conversation the post is in, a short summary for the dialog, and an expiry of a + day. The server keeps it in the post's action context, off the client. + - **dialog**: the same answers, the conversation, *the user who pressed the button*, a nonce and + an expiry of ten minutes. Only that user can submit it, only once (`State.spend_nonce/2`), and + only with one of the answers it names. + + Payloads are JSON. Nothing that arrives is ever turned back into a term. + """ + + alias Trinity.Gateways.Mattermost.State + + @button_ttl_ms :timer.hours(24) + @dialog_ttl_ms :timer.minutes(10) + + @doc """ + Signs a button's context: the answers it offers (label and command, 071's button shape), the + conversation it was posted in, and a summary for the dialog. + """ + @spec button([{String.t(), String.t()}], String.t(), String.t()) :: String.t() + def button(buttons, conversation, summary) do + sign("button", %{ + "b" => Enum.map(buttons, fn {label, command} -> [label, command] end), + "c" => conversation, + "s" => summary, + "e" => now() + @button_ttl_ms + }) + end + + @doc """ + Signs a dialog's state for the user who opened it: the same answers, the conversation, that + user, and a nonce. Only an answer named here can come back from it. + """ + @spec dialog([{String.t(), String.t()}], String.t(), String.t()) :: String.t() + def dialog(buttons, conversation, user_id) do + sign("dialog", %{ + "b" => Enum.map(buttons, fn {label, command} -> [label, command] end), + "c" => conversation, + "u" => user_id, + "n" => Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false), + "e" => now() + @dialog_ttl_ms + }) + end + + @doc """ + Verifies a signed value of `kind` and answers its payload, or `:error` for anything that is not + a current value this adapter signed: a bad signature, the wrong kind, an expired one, garbage. + """ + @spec verify(String.t(), term()) :: {:ok, map()} | :error + def verify(kind, signed) when kind in ["button", "dialog"] and is_binary(signed) do + with [payload, mac] <- String.split(signed, ".", parts: 2), + {:ok, given} <- Base.url_decode64(mac, padding: false), + true <- Plug.Crypto.secure_compare(given, mac(kind, payload)), + {:ok, json} <- Base.url_decode64(payload, padding: false), + {:ok, %{"e" => expires} = claims} when is_integer(expires) <- Jason.decode(json), + true <- expires > now() do + {:ok, claims} + else + _ -> :error + end + end + + def verify(_kind, _signed), do: :error + + defp sign(kind, claims) do + payload = claims |> Jason.encode!() |> Base.url_encode64(padding: false) + payload <> "." <> Base.url_encode64(mac(kind, payload), padding: false) + end + + defp mac(kind, payload) do + key = :crypto.mac(:hmac, :sha256, State.key(), "trinity.mattermost." <> kind) + :crypto.mac(:hmac, :sha256, key, payload) + end + + defp now, do: System.system_time(:millisecond) +end diff --git a/lib/trinity/gateways/mattermost/socket.ex b/lib/trinity/gateways/mattermost/socket.ex new file mode 100644 index 0000000..53e9ddc --- /dev/null +++ b/lib/trinity/gateways/mattermost/socket.ex @@ -0,0 +1,260 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.Socket do + @moduledoc """ + The Mattermost adapter's connection to its server (slice 072): the server's WebSocket, which + Trinity opens, so nothing here needs the server to reach Trinity. + + **Authenticating.** The token is read through `Trinity.Config.secret/1` each time a connection + is built and sent as the upgrade request's bearer credential. It could instead be sent after the + upgrade, in the server's `authentication_challenge`, and never sit in the connection struct; that + was the first design and it was measured against a live server (slice 072 NOTES, D11): a socket + authenticated by challenge is not resumable, the server answers a reconnect with a new connection + id and the messages sent during the gap are lost. So the header it is, and what the token must + never reach is the log: this module never logs a connection, a request or a raw disconnect + reason, and AC6's tests crash this process with the token configured and read every line. + + **What it does with an event** is `Events.read/3`'s answer: a post for the bot is marked handled + in `State` (decision D8: at most once), and handed to `Trinity.Gateways.Router.inbound/5`. Every + event's sequence number is recorded, so a socket that dies or loses its connection reconnects + offering the server its connection id and the next number it expects, and the server replays + what was missed if it still holds it. A replay that overlaps what was already handled is + dropped by the mark, which is what keeps a consumer crash from answering a message twice. + + **When the server is not there** the socket backs off, doubling from `backoff_ms` to + `max_backoff_ms`, and keeps trying. It never fails to start: a server that is down must not + exhaust the gateway supervisor's restart budget and take the node with it. + + **TLS** verifies the server (OTP's trust store, or `cacertfile:`), with TLS 1.2 as the floor. + `websockex`'s own default is `verify: :verify_none`; it is never used. + """ + use WebSockex + + alias Trinity.Gateways.Mattermost + alias Trinity.Gateways.Mattermost.{Client, Events, State} + alias Trinity.Gateways.Router + + require Logger + + @not_read "Trinity cannot read files sent here yet. Only the text of your message was read." + + @doc "Starts the socket. It answers at once and connects in the background." + @spec start_link(keyword()) :: {:ok, pid()} | {:error, term()} + def start_link(opts) do + state = %{ + backoff: Keyword.fetch!(opts, :backoff_ms), + initial_backoff: Keyword.fetch!(opts, :backoff_ms), + max_backoff: Keyword.fetch!(opts, :max_backoff_ms) + } + + WebSockex.start_link(conn(), __MODULE__, state, + name: __MODULE__, + async: true, + handle_initial_conn_failure: true + ) + end + + @doc false + @spec child_spec(keyword()) :: Supervisor.child_spec() + def child_spec(opts), do: %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}} + + # The connection, with the resume point in its query when there is one and the token in its + # header when there is one. Built afresh on every (re)connect, because the resume point moves + # and the token is read when used. With no token the upgrade is made without one and + # `handle_connect/2` closes it once the server has said so. + defp conn do + options = State.options() + uri = URI.parse(options.url) + scheme = if uri.scheme == "https", do: "wss", else: "ws" + + query = + case State.resume() do + {id, next} -> URI.encode_query(connection_id: id, sequence_number: next) + nil -> nil + end + + url = + URI.to_string(%URI{ + uri + | scheme: scheme, + path: String.trim_trailing(uri.path || "", "/") <> "/api/v4/websocket", + query: query + }) + + headers = + case Client.token() do + {:ok, token} -> [{"Authorization", "Bearer " <> token}] + {:error, _} -> [] + end + + WebSockex.Conn.new(url, [extra_headers: headers] ++ tls(uri, options)) + end + + defp tls(%URI{scheme: "https", host: host}, options) do + trust = + case options.cacertfile do + nil -> [cacerts: :public_key.cacerts_get()] + path -> [cacertfile: String.to_charlist(path)] + end + + [ + insecure: false, + ssl_options: + trust ++ + [ + verify: :verify_peer, + server_name_indication: String.to_charlist(host), + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ], + versions: [:"tlsv1.3", :"tlsv1.2"] + ] + ] + end + + defp tls(_uri, _options), do: [] + + ## Connecting + + # The server's facts are learned before any frame is read, so the first post of a connection is + # never read against no facts at all. A server that will not answer is closed on, and the + # reconnect backs off. + @impl WebSockex + def handle_connect(_conn, state) do + case learn_server() do + :ok -> + Logger.info("mattermost: connected") + State.put_connection({:connected, State.facts().bot_username}) + {:ok, %{state | backoff: state.initial_backoff}} + + {:error, reason} -> + why = describe(reason) + Logger.warning("mattermost: connected, but not to a server that will talk: #{why}") + State.put_connection({:error, why}) + + send(self(), :give_up) + {:ok, state} + end + end + + @impl WebSockex + def handle_info(:give_up, state), do: {:close, state} + def handle_info(_other, state), do: {:ok, state} + + # Who the bot is and how long a post may be, asked on every connect: a server can be upgraded + # or reconfigured while Trinity runs, and the limit is the server's, not this module's. + defp learn_server do + with {:ok, %{"id" => id, "username" => username}} <- Client.me(), + {:ok, config} <- Client.client_config(), + {:ok, size} <- max_post_size(config) do + State.put_facts(%{bot_user_id: id, bot_username: username, max_post_size: size}) + else + {:ok, _unexpected} -> {:error, :unexpected_response} + {:error, _} = error -> error + end + end + + defp max_post_size(%{"MaxPostSize" => raw}) when is_binary(raw) do + case Integer.parse(raw) do + {size, ""} when size > 0 -> {:ok, size} + _ -> {:error, :no_max_post_size} + end + end + + defp max_post_size(_config), do: {:error, :no_max_post_size} + + ## Frames + + @impl WebSockex + def handle_frame({:text, frame}, state) do + frame + |> Events.read(State.facts(), &State.thread?/1) + |> act(state) + end + + def handle_frame(_other, state), do: {:ok, state} + + defp act({:hello, connection_id, seq}, state) do + :ok = State.put_resume(connection_id, seq + 1) + {:ok, state} + end + + defp act({:auth, _answer}, state), do: {:ok, state} + + defp act({:post, seq, inbound}, state) do + advance(seq) + if State.mark_handled(inbound.post_id), do: hand_on(inbound) + {:ok, state} + end + + defp act({:event, seq, _why}, state) do + advance(seq) + {:ok, state} + end + + defp act({:unreadable, _why}, state), do: {:ok, state} + + defp advance(nil), do: :ok + + defp advance(seq) do + case State.resume() do + {id, _next} -> State.put_resume(id, seq + 1) + nil -> :ok + end + end + + defp hand_on(inbound) do + if inbound.follow, do: State.follow_thread(inbound.follow) + + result = + if inbound.text == "" do + :no_text + else + Router.inbound(Mattermost, inbound.conversation, inbound.user_id, inbound.text, + display_name: inbound.display_name + ) + end + + # Files are not carried (decision D9). An admitted sender is told, rather than left to + # think the file was read; a stranger is not told anything beyond the pairing prompt. + if inbound.files > 0 and result in [{:ok, :placed}, :no_text] do + Router.deliver(Mattermost, inbound.conversation, {:message, @not_read}) + end + + :ok + end + + ## Losing the server + + @impl WebSockex + def handle_disconnect(%{reason: reason}, state) do + why = describe(reason) + Logger.warning("mattermost: connection lost (#{why}); retrying in #{state.backoff} ms") + State.put_connection({:error, "connection lost (#{why})"}) + + Process.sleep(state.backoff) + next = min(state.backoff * 2, state.max_backoff) + {:reconnect, conn(), %{state | backoff: next}} + end + + # A reason as a short phrase. Never `inspect/1` of a connection or a request: those are + # structs a later change could put a credential in, and the log is not the place to find out. + defp describe({:missing_secret, env}), do: "#{env} is not set" + defp describe({:http, status, nil}), do: "HTTP #{status}" + defp describe({:http, status, id}), do: "HTTP #{status} #{id}" + defp describe({:transport, reason}), do: "transport #{reason}" + defp describe({:local, :normal}), do: "closed here" + defp describe({:remote, :normal}), do: "closed by the server" + defp describe({:remote, code, _message}), do: "closed by the server, code #{code}" + + defp describe(%WebSockex.RequestError{code: code}), + do: "the server answered the upgrade with #{code}" + + defp describe(%WebSockex.ConnError{original: original}) when is_atom(original), + do: Atom.to_string(original) + + defp describe({:local, _code, _message}), do: "closed here" + defp describe(%{__exception__: true} = e), do: e.__struct__ |> Module.split() |> Enum.join(".") + defp describe(reason) when is_atom(reason), do: Atom.to_string(reason) + defp describe(_reason), do: "unexpected" +end diff --git a/lib/trinity/gateways/mattermost/state.ex b/lib/trinity/gateways/mattermost/state.ex new file mode 100644 index 0000000..e0faed5 --- /dev/null +++ b/lib/trinity/gateways/mattermost/state.ex @@ -0,0 +1,203 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.State do + @moduledoc """ + What the Mattermost adapter has to remember across a dropped connection (slice 072), in an ETS + table this process owns and the socket does not: + + - the adapter's options (none of them secret) and the server's facts, read on every connect: + the bot's own user id and name, and `MaxPostSize`; + - the resume point, the server's connection id and the next sequence number expected, which a + reconnecting socket offers so the server can replay what it missed; + - the posts already handed to the router, so a replay that overlaps what was handled is not + answered twice (decision D8: at most once); + - the channel threads the bot was mentioned in, which it then follows without a mention; + - the key the approval controls are signed with, generated here and never written anywhere, + and the dialog nonces already spent. + + The table is public so the router's process (delivering) and a web request (a callback) can + read it without calling this process; it is written only through the functions here. + """ + use GenServer + + @table __MODULE__ + + # Used only until the server has answered once. It is the server's own historical limit + # (`PostMessageMaxRunesV1 = 4000` in `server/public/model/post.go`), and a value below every + # limit a server has advertised, so a message sent before the first answer is never refused + # for its length. After the first answer the server's `MaxPostSize` is what counts. + @fallback_max_post_size 4_000 + + @handled_ttl_ms :timer.hours(1) + @thread_ttl_ms :timer.hours(24 * 7) + @prune_every_ms :timer.minutes(5) + + @typedoc "What the server says about itself and the bot, on every connect." + @type facts :: %{ + bot_user_id: String.t(), + bot_username: String.t(), + max_post_size: pos_integer() + } + + @doc "Starts the table's owner." + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @impl GenServer + def init(opts) do + table = :ets.new(@table, [:named_table, :public, :set, read_concurrency: true]) + + :ets.insert(table, [ + {:options, Map.new(opts)}, + {:key, :crypto.strong_rand_bytes(32)} + ]) + + schedule_prune() + {:ok, table} + end + + @impl GenServer + def handle_info(:prune, table) do + now = now() + + :ets.select_delete(table, [ + {{{:handled, :_}, :"$1"}, [{:<, :"$1", now - @handled_ttl_ms}], [true]} + ]) + + :ets.select_delete(table, [ + {{{:thread, :_}, :"$1"}, [{:<, :"$1", now - @thread_ttl_ms}], [true]} + ]) + + :ets.select_delete(table, [{{{:nonce, :_}, :"$1"}, [{:<, :"$1", now}], [true]}]) + schedule_prune() + {:noreply, table} + end + + defp schedule_prune, do: Process.send_after(self(), :prune, @prune_every_ms) + + ## Reading + + @doc "The adapter's options, or nil when the adapter is not running." + @spec options() :: map() | nil + def options, do: lookup(:options) + + @doc "An option's value, or `default` when the adapter is not running." + @spec option(atom(), term()) :: term() + def option(name, default \\ nil) do + case options() do + nil -> default + options -> Map.get(options, name, default) + end + end + + @doc "The base URL the server reaches Trinity at, or nil when there is none." + @spec callback_url() :: String.t() | nil + def callback_url, do: option(:callback_url) + + @doc "The server's facts, or nil before it has answered." + @spec facts() :: facts() | nil + def facts, do: lookup(:facts) + + @doc "The server's message limit, in code points, as it last advertised it." + @spec max_post_size() :: pos_integer() + def max_post_size do + case facts() do + %{max_post_size: size} -> size + nil -> @fallback_max_post_size + end + end + + @doc "The value used before the server has answered, for a test to compare against." + @spec fallback_max_post_size() :: pos_integer() + def fallback_max_post_size, do: @fallback_max_post_size + + @doc """ + The connection as the socket last reported it: `{:connected, bot_username}`, `{:error, why}` (a + phrase with no credential in it), or nil before the first attempt. + """ + @spec connection() :: {:connected, String.t()} | {:error, String.t()} | nil + def connection, do: lookup(:connection) + + @doc "Records the connection's state for `status/0`." + @spec put_connection({:connected, String.t()} | {:error, String.t()}) :: :ok + def put_connection(connection) do + if table?(), do: :ets.insert(@table, {:connection, connection}) + :ok + end + + @doc "Where a reconnecting socket resumes: `{connection_id, next_sequence}`, or nil." + @spec resume() :: {String.t(), non_neg_integer()} | nil + def resume, do: lookup(:resume) + + @doc "The key approval controls are signed with. Raises when the adapter is not running." + @spec key() :: binary() + def key do + case lookup(:key) do + nil -> raise ArgumentError, "the Mattermost adapter is not running" + key -> key + end + end + + @doc "Whether the bot is following a channel thread (it was mentioned there)." + @spec thread?(String.t()) :: boolean() + def thread?(conversation), do: table?() and :ets.member(@table, {:thread, conversation}) + + ## Writing + + @doc "Records the server's facts." + @spec put_facts(facts()) :: :ok + def put_facts(%{bot_user_id: _, bot_username: _, max_post_size: size} = facts) + when is_integer(size) and size > 0 do + true = :ets.insert(@table, {:facts, facts}) + :ok + end + + @doc "Records the resume point." + @spec put_resume(String.t(), non_neg_integer()) :: :ok + def put_resume(connection_id, next_seq) + when is_binary(connection_id) and is_integer(next_seq) do + true = :ets.insert(@table, {:resume, {connection_id, next_seq}}) + :ok + end + + @doc "Forgets the resume point, so the next connection starts fresh." + @spec forget_resume() :: :ok + def forget_resume do + true = :ets.delete(@table, :resume) + :ok + end + + @doc """ + Marks a post handled. True the first time for a post id and false every time after, atomically: + two deliveries of one post cannot both be told they are first. + """ + @spec mark_handled(String.t()) :: boolean() + def mark_handled(post_id) when is_binary(post_id), + do: :ets.insert_new(@table, {{:handled, post_id}, now()}) + + @doc "Follows a channel thread from now on." + @spec follow_thread(String.t()) :: :ok + def follow_thread(conversation) when is_binary(conversation) do + true = :ets.insert(@table, {{:thread, conversation}, now()}) + :ok + end + + @doc """ + Spends a dialog nonce. True the first time, false for a nonce already spent: a dialog state can + decide one approval once. + """ + @spec spend_nonce(String.t(), integer()) :: boolean() + def spend_nonce(nonce, expires_at_ms) when is_binary(nonce) and is_integer(expires_at_ms), + do: table?() and :ets.insert_new(@table, {{:nonce, nonce}, expires_at_ms}) + + defp lookup(key) do + case table?() && :ets.lookup(@table, key) do + [{^key, value}] -> value + _ -> nil + end + end + + defp table?, do: :ets.whereis(@table) != :undefined + + defp now, do: System.system_time(:millisecond) +end diff --git a/lib/trinity/scheduler/delivery/gateway.ex b/lib/trinity/scheduler/delivery/gateway.ex index 9df85bc..94c17f5 100644 --- a/lib/trinity/scheduler/delivery/gateway.ex +++ b/lib/trinity/scheduler/delivery/gateway.ex @@ -25,18 +25,27 @@ defmodule Trinity.Scheduler.Delivery.Gateway do @impl true def deliver(%Run{} = run, %Task{deliver_to: destination} = task) do with {:ok, adapter} <- adapter(destination), - {:ok, conversation} <- fetch(destination, "conversation") do - send_summary(adapter, conversation, run, task) + {:ok, conversation} <- fetch(destination, "conversation"), + :ok <- send_summary(adapter, conversation, run, task) do mark(run) end end + # Slice 072: the adapter's answer is read. A platform adapter can be refused (a server down, a + # token revoked, a channel id that is not one), and a run marked delivered when nothing arrived + # is a silent drop with a timestamp on it. The first refusal stops the delivery and is the + # answer; the run keeps its undelivered state for the page to show. defp send_summary(adapter, conversation, run, task) do text = "#{task.name}: #{run.summary || "(no summary)"}" - for chunk <- adapter.format(text, adapter.capabilities()) do - adapter.deliver(conversation, {:message, chunk}) - end + text + |> adapter.format(adapter.capabilities()) + |> Enum.reduce_while(:ok, fn chunk, :ok -> + case adapter.deliver(conversation, {:message, chunk}) do + {:error, reason} -> {:halt, {:error, {:not_delivered, reason}}} + _ok -> {:cont, :ok} + end + end) end defp mark(run) do diff --git a/lib/trinity/sessions/session_row.ex b/lib/trinity/sessions/session_row.ex index 1dcbb5a..14bde47 100644 --- a/lib/trinity/sessions/session_row.ex +++ b/lib/trinity/sessions/session_row.ex @@ -14,7 +14,9 @@ defmodule Trinity.Sessions.SessionRow do @foreign_key_type Trinity.UUID @timestamps_opts [type: :utc_datetime_usec] - @origins ~w(desktop telegram discord console cron subagent mcp a2a) + # Slice 072: `mattermost` replaces `discord`. The Discord slot was retargeted to Mattermost on + # 2026-10-07 and dropped rather than deferred, and no code path ever produced that origin. + @origins ~w(desktop telegram mattermost console cron subagent mcp a2a) @statuses ~w(active archived compacted) @type t :: %__MODULE__{} diff --git a/lib/trinity/telemetry/redaction.ex b/lib/trinity/telemetry/redaction.ex index f3d88b5..f708bc5 100644 --- a/lib/trinity/telemetry/redaction.ex +++ b/lib/trinity/telemetry/redaction.ex @@ -58,6 +58,13 @@ defmodule Trinity.Telemetry.Redaction do # URL path (`/bot/`) where no key name precedes it. Not anchored on `\b`, since # in that path the token follows the letters "bot" with no boundary between them. {~r/(?`, its header form, is the first pattern.) + {~r/(?i)\b(MMAUTHTOKEN|mattermost[_-]?(?:bot[_-]?|command[_-]?)?token)(\s*[:=]\s*"?)[a-z0-9]{26}\b/, + "\\1\\2[REDACTED]"}, + {~r/("token"\s*:\s*")[a-z0-9]{26}(")/, "\\1[REDACTED]\\2"}, {~r/(?i)\b(password|passwd|secret|api[_-]?key|access[_-]?token|private[_-]?key)(\s*[:=]\s*)\S+/, "\\1\\2[REDACTED]"} ] diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index a742ffe..ec2f509 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -321,10 +321,17 @@ defmodule Trinity.Versions do }, %{ name: "nostrum", - pin: "~> 0.10", - lock: "nostrum", + pin: "not used (Slice 072 retargeted to Mattermost, 2026-10-07)", + lock: nil, + note: + "Discord. Was the planned library for Slice 072, which the owner retargeted to Mattermost on 2026-10-07: Discord has no authorization path for a regulated deployment, and the work is dropped rather than deferred. The row stays so the decision is visible where a reader would look for the package." + }, + %{ + name: "websockex", + pin: "~> 0.5.1", + lock: "websockex", note: - "Discord. ⚠️ No release in roughly 18 months. R11's trigger already fires. Check intents and components against the current gateway before Slice 072." + "The Mattermost gateway's connection to its server's WebSocket (`Trinity.Gateways.Mattermost.Socket`, Slice 072). Was transitive through req_llm and already in every release; direct since Slice 072 because a module of ours calls it. ⚠️ Its own default is `insecure: true` (`verify: :verify_none`); the socket always passes verifying `ssl_options` and never relies on the default." }, %{ name: "telegex", diff --git a/lib/trinity_web/controllers/gateway_callback_controller.ex b/lib/trinity_web/controllers/gateway_callback_controller.ex new file mode 100644 index 0000000..d1cbfd6 --- /dev/null +++ b/lib/trinity_web/controllers/gateway_callback_controller.ex @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.GatewayCallbackController do + @moduledoc """ + `POST /gateways/callback/:adapter/:kind` (slice 072): a messaging platform calling Trinity back, + for a button pressed, a dialog submitted or a slash command typed. + + It knows no platform. The body is handed to `Trinity.Gateways.callback/3`, which finds the + configured adapter by name and lets it verify the request; an adapter that is not configured + answers 404, as a path that does not exist would. Only the decoded body is passed on, not the + path parameters, so a body cannot pretend to be a different adapter or kind. + + Not behind the browser pipeline: the caller is a server, with no session and no CSRF token, and + the proof that a request is genuine is the adapter's own (a signed context, a command token). + """ + use TrinityWeb, :controller + + @doc "Hands the callback to its adapter and answers what the adapter says." + @spec create(Plug.Conn.t(), map()) :: Plug.Conn.t() + def create(conn, %{"adapter" => adapter, "kind" => kind}) do + case Trinity.Gateways.callback(adapter, kind, conn.body_params) do + {:ok, body} -> json(conn, body) + {:error, :not_found} -> conn |> put_status(404) |> json(%{}) + {:error, :forbidden} -> conn |> put_status(403) |> json(%{}) + {:error, :bad_request} -> conn |> put_status(400) |> json(%{}) + end + end +end diff --git a/lib/trinity_web/router.ex b/lib/trinity_web/router.ex index 534a4c5..522fe31 100644 --- a/lib/trinity_web/router.ex +++ b/lib/trinity_web/router.ex @@ -35,6 +35,12 @@ defmodule TrinityWeb.Router do post "/oauth/register", OAuthController, :register end + # Slice 072: a messaging platform calling back (a button, a dialog, a slash command). No session + # and no CSRF: the caller is a server, and the adapter verifies what it sent. + scope "/gateways/callback", TrinityWeb do + post "/:adapter/:kind", GatewayCallbackController, :create + end + # Slice 013: the chat. One local user, so no scope is fetched; the session id is the URL. scope "/", TrinityWeb do pipe_through :browser diff --git a/mix.exs b/mix.exs index f68e239..c57e515 100644 --- a/mix.exs +++ b/mix.exs @@ -192,6 +192,9 @@ defmodule Trinity.MixProject do {:oban_web, "~> 2.13"}, # Slice 062: JWT validation and issuance (JWK, JWS, JWKS) over OTP's crypto. {:jose, "~> 1.11"}, + # Slice 072: the Mattermost gateway's connection to its server's WebSocket. Already in + # the lock and the release through req_llm; direct because a module of ours calls it. + {:websockex, "~> 0.5.1"}, # Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from # source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs), # because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13). diff --git a/scripts/dev_mattermost.sh b/scripts/dev_mattermost.sh new file mode 100755 index 0000000..e19e420 --- /dev/null +++ b/scripts/dev_mattermost.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# Slice 072's manual queue (AC7): Trinity talking to a real Mattermost server the operator names, +# with the fake provider answering so the run needs no model key. A direct message gets a reply +# streamed by editing one post; the second message raises a write approval answered through the +# dialog; a scheduled task is delivered to a channel; the last message raises an exec approval, +# which the channel cap refuses. +# +# The environment names the server and the people, and holds the tokens; nothing here does: +# +# MATTERMOST_URL the server, as a browser opens it +# MATTERMOST_CALLBACK_URL where the server reaches this node (the dialog, the slash command) +# MATTERMOST_BOT_TOKEN the bot account's access token +# MATTERMOST_COMMAND_TOKEN the /trinity slash command's token +# MATTERMOST_TESTER_ID the Mattermost user id let in without pairing (the allowlist) +# TRINITY_DEV_BIND the address to listen on, default 127.0.0.1 (a server in a container +# reaches the host at the bridge address, 172.17.0.1 by default) +# PORT default 4072 +# +# The test environment's tree on its own databases (trinity_mattermost_dev*.db, never the suite's), +# with both repos off the sandbox pool, which a script that runs tools through the membrane must +# do or every tool call waits on a checkout nobody owns. Oban runs its queues here, so a task run +# is a real job. The node is named mm072 so a second node can reach it: +# +# elixir --sname drv -e ':rpc.call(:"mm072@$(hostname -s)", ...)' +# +# Prints READY when the adapter has connected. Kill it by the pid `pgrep -af beam.smp` prints. +cd "$(dirname "$0")/.." +export MIX_ENV=test +exec systemd-run --user --scope -p MemoryMax=24G --quiet -- elixir --sname mm072 -S mix run --no-start --no-halt -e ' +for {repo, db} <- [{Trinity.Repo, "trinity_mattermost_dev.db"}, {Trinity.Repo.Receipts, "trinity_mattermost_dev_receipts.db"}] do + config = Application.get_env(:trinity, repo) |> Keyword.delete(:pool) |> Keyword.put(:database, db) + Application.put_env(:trinity, repo, config) +end +{:ok, ip} = :inet.parse_address(String.to_charlist(System.get_env("TRINITY_DEV_BIND", "127.0.0.1"))) +port = String.to_integer(System.get_env("PORT", "4072")) +endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false) |> Keyword.put(:http, ip: ip, port: port) |> Keyword.put(:server, true) +Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint) +Application.put_env(:trinity, :mcp_boot, false) +Application.put_env(:trinity, :permissions, expiry_ms: 1_800_000, session_grant_ms: 3_600_000) +tools = Application.get_env(:trinity, :tools, []) +Application.put_env(:trinity, :tools, Keyword.put(tools, :timeout_ms, 600_000)) +oban = Application.get_env(:trinity, Oban) |> Keyword.put(:testing, :disabled) |> Keyword.put(:plugins, false) +Application.put_env(:trinity, Oban, oban) +Application.put_env(:trinity, :mattermost, url: System.fetch_env!("MATTERMOST_URL"), callback_url: System.get_env("MATTERMOST_CALLBACK_URL")) +Application.put_env(:trinity, :gateways, + available: [Trinity.Gateways.Console, Trinity.Gateways.Mattermost], + enabled: ["mattermost"], + allowlist: [{"mattermost", System.fetch_env!("MATTERMOST_TESTER_ID")}] +) +for repo <- [Trinity.Repo, Trinity.Repo.Receipts] do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) +end +{:ok, _} = Application.ensure_all_started(:trinity) + +words = fn text, pause -> Enum.flat_map(String.split(text), &[{:text_delta, &1 <> " "}, {:sleep, pause}]) end +done = [{:usage, %{input_tokens: 20, output_tokens: 30}}, {:done, :stop}] +call = fn id, tool, args, lead -> words.(lead, 60) ++ [{:tool_call_start, id, tool}, {:tool_call_end, id, args}, {:usage, %{input_tokens: 20, output_tokens: 20}}, {:done, :tool_calls}] end + +Trinity.LLM.Providers.Fake.scripts([ + # 1. The first direct message: a reply long enough to watch it stream. + words.("Hello. I am reachable here now, and I answer by editing this one message as the words arrive, rather than by sending a message for every few words.", 250) ++ done, + # 2. A write, which asks first. + call.("c1", "write_note", %{"path" => "notes/today.md", "text" => "Oat milk, the dentist, the slice."}, "I will save that as a note."), + # 3. Its answer, once approved. + words.("Saved. The note is at **notes/today.md** with three items.", 120) ++ done, + # 4. The scheduled task run, delivered to the channel. + words.("Three tasks are due today, and the notes directory changed overnight.", 20) ++ done, + # 5. An exec request, which this channel may not approve. + call.("c2", "shell", %{"command" => "make deploy"}, "That needs the shell.") +]) + +deadline = System.monotonic_time(:millisecond) + 30_000 +Stream.repeatedly(fn -> Process.sleep(200); Trinity.Gateways.Mattermost.State.facts() end) +|> Enum.find(fn facts -> facts != nil or System.monotonic_time(:millisecond) > deadline end) +IO.puts("READY bot=#{inspect(Trinity.Gateways.Mattermost.State.facts())} port=#{port}") +' diff --git a/test/secrets_scan_test.exs b/test/secrets_scan_test.exs index 073998a..914198b 100644 --- a/test/secrets_scan_test.exs +++ b/test/secrets_scan_test.exs @@ -26,6 +26,14 @@ defmodule SecretsScanTest do assert Scan.findings("071000001:TEST_fake_token_for_the_suite_only") == [] end + # Slice 072. A Mattermost token is 26 lower-case letters and digits, the shape of every id the + # server issues, so only a named assignment is a finding. Composed, as above. + test "RED: a Mattermost token assigned by name is found; an id of the same shape is not" do + token = "abc" <> String.duplicate("7", 23) + assert [{"Mattermost token assignment", 1}] = Scan.findings("MATTERMOST_BOT_TOKEN=" <> token) + assert Scan.findings(~s|channel_id: "#{token}"|) == [] + end + test "GREEN: ordinary source is clean" do assert Scan.findings("def hello, do: :world\n# AKIA is mentioned but not a key\n") == [] end diff --git a/test/support/mattermost/fake_server.ex b/test/support/mattermost/fake_server.ex new file mode 100644 index 0000000..0baffb0 --- /dev/null +++ b/test/support/mattermost/fake_server.ex @@ -0,0 +1,259 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.FakeServer do + @moduledoc """ + A Mattermost server for the suite (slice 072), behind Bandit on a loopback port: the REST calls + the adapter makes and the WebSocket it listens on. What it answers is what a real server + answered, recorded (`fixtures.json` beside this file); what it does on a reconnect is what a + real server did when measured (NOTES D11): a socket that offers a known connection id and a + sequence number gets no new `hello` and a replay from that number. + + A test drives it with `push/2` (a frame to every connected socket, numbered), reads what the + adapter asked of it with `requests/1` and `posts/1`, and can make it misbehave: refuse the + token, replay from further back than asked (`replay_from/2`). + """ + use Agent + + @behaviour Plug + + import Plug.Conn + + @fixtures Path.join(__DIR__, "fixtures.json") + + @doc "The recorded fixtures, decoded." + def fixtures, do: @fixtures |> File.read!() |> Jason.decode!() + + @doc "The recorded frames with this `event`, in order." + def frames(event) do + for raw <- fixtures()["frames"] ++ fixtures()["system_frames"], + decoded = Jason.decode!(raw), + decoded["event"] == event, + do: raw + end + + @doc """ + Starts a server expecting `token`. Options: `max_post_size:` (a string, as the server sends it). + """ + def start!(token, opts \\ []) do + me = fixtures()["rest"]["users_me"] + + {:ok, agent} = + Agent.start_link(fn -> + %{ + token: token, + me: me, + max_post_size: Keyword.get(opts, :max_post_size, "16383"), + requests: [], + posts: %{}, + sockets: [], + connects: [], + sent: [], + seq: 0, + connection_id: random_id(), + replay_from: nil, + next_post: 1 + } + end) + + {:ok, server} = + Bandit.start_link( + plug: {__MODULE__, agent}, + ip: {127, 0, 0, 1}, + port: 0, + startup_log: false + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(server) + + ExUnit.Callbacks.on_exit(fn -> + try do + GenServer.stop(server) + catch + :exit, _ -> :ok + end + end) + + %{agent: agent, url: "http://127.0.0.1:#{port}", bot_user_id: me["id"]} + end + + @doc "Every REST request the adapter made: `{method, path, body}`, oldest first." + def requests(%{agent: agent}), do: Agent.get(agent, &Enum.reverse(&1.requests)) + + @doc "The posts the adapter created or patched: `%{id => message}`, and their creation order." + def posts(%{agent: agent}), do: Agent.get(agent, & &1.posts) + + @doc "The created posts' bodies, oldest first." + def created(server) do + for {"POST", "/api/v4/posts", body} <- requests(server), do: body + end + + @doc "The query strings of every WebSocket connection made, oldest first." + def connects(%{agent: agent}), do: Agent.get(agent, &Enum.reverse(&1.connects)) + + @doc "Makes the next resume replay from `seq` regardless of what was asked (an overlap)." + def replay_from(%{agent: agent}, seq), do: Agent.update(agent, &%{&1 | replay_from: seq}) + + @doc "Sends a recorded frame to every socket, renumbered as this server's next event." + def push(%{agent: agent}, raw_frame) do + frame = + Agent.get_and_update(agent, fn state -> + seq = state.seq + 1 + framed = raw_frame |> Jason.decode!() |> Map.put("seq", seq) |> Jason.encode!() + {{framed, state.sockets}, %{state | seq: seq, sent: [{seq, framed} | state.sent]}} + end) + + {framed, sockets} = frame + for pid <- sockets, do: send(pid, {:push, framed}) + framed + end + + @doc "The connected sockets' pids." + def sockets(%{agent: agent}), do: Agent.get(agent, & &1.sockets) + + @doc "Closes every socket from the server's side." + def drop_sockets(%{agent: agent} = server) do + for pid <- sockets(server), do: send(pid, :drop) + Agent.update(agent, &%{&1 | sockets: []}) + end + + ## Plug + + @impl Plug + def init(agent), do: agent + + @impl Plug + def call(conn, agent) do + conn = fetch_query_params(conn) + {:ok, raw, conn} = read_body(conn) + body = if raw == "", do: nil, else: Jason.decode!(raw) + path = conn.request_path + + cond do + not Process.alive?(agent) -> + conn |> send_resp(503, "") |> halt() + + path == "/api/v4/websocket" -> + websocket(conn, agent) + + true -> + rest(conn, path, body, agent) + end + end + + defp rest(conn, path, body, agent) do + Agent.update(agent, &%{&1 | requests: [{conn.method, path, body} | &1.requests]}) + + if authorised?(conn, agent), + do: route(conn, conn.method, path, body, agent), + else: reply(conn, 401, %{"id" => "api.context.session_expired.app_error"}) + end + + defp authorised?(conn, agent) do + expected = "Bearer " <> Agent.get(agent, & &1.token) + get_req_header(conn, "authorization") == [expected] + end + + defp route(conn, "GET", "/api/v4/users/me", _body, agent), + do: reply(conn, 200, Agent.get(agent, & &1.me)) + + defp route(conn, "GET", "/api/v4/config/client", _body, agent), + do: reply(conn, 200, %{"MaxPostSize" => Agent.get(agent, & &1.max_post_size)}) + + defp route(conn, "POST", "/api/v4/posts", body, agent) do + id = random_id() + Agent.update(agent, &%{&1 | posts: Map.put(&1.posts, id, body["message"])}) + reply(conn, 201, Map.merge(body, %{"id" => id})) + end + + defp route(conn, "PUT", "/api/v4/posts/" <> rest, body, agent) do + [id, "patch"] = String.split(rest, "/") + Agent.update(agent, &%{&1 | posts: Map.put(&1.posts, id, body["message"])}) + reply(conn, 200, %{"id" => id, "message" => body["message"]}) + end + + defp route(conn, "POST", "/api/v4/actions/dialogs/open", _body, _agent), + do: reply(conn, 200, %{"status" => "OK"}) + + defp route(conn, "POST", "/api/v4/users/me/typing", _body, _agent), + do: reply(conn, 200, %{"status" => "OK"}) + + defp route(conn, _method, _path, _body, _agent), do: reply(conn, 404, %{"id" => "not_found"}) + + defp reply(conn, status, body) do + conn |> put_resp_content_type("application/json") |> send_resp(status, Jason.encode!(body)) + end + + # A connection can arrive after the test that owned this server has ended (a socket's backoff + # outliving it); it is turned away rather than crashing on an agent that is gone. + defp websocket(conn, agent) do + if Process.alive?(agent), do: upgrade(conn, agent), else: conn |> send_resp(503, "") |> halt() + end + + defp upgrade(conn, agent) do + Agent.update(agent, &%{&1 | connects: [conn.query_string | &1.connects]}) + + if authorised?(conn, agent) do + conn + |> WebSockAdapter.upgrade(__MODULE__.Socket, {agent, conn.query_params}, timeout: 60_000) + |> halt() + else + conn |> send_resp(401, "") |> halt() + end + end + + @doc false + def random_id do + alphabet = ~c"abcdefghijklmnopqrstuvwxyz0123456789" + for _ <- 1..26, into: "", do: <> + end + + defmodule Socket do + @moduledoc false + @behaviour WebSock + + @impl WebSock + def init({agent, query}) do + me = self() + Agent.update(agent, &%{&1 | sockets: [me | &1.sockets]}) + state = Agent.get(agent, & &1) + + frames = + case query do + %{"connection_id" => id, "sequence_number" => next} when id == state.connection_id -> + from = state.replay_from || String.to_integer(next) + Agent.update(agent, &%{&1 | replay_from: nil}) + + for {seq, frame} <- Enum.reverse(state.sent), seq >= from, do: {:text, frame} + + _ -> + hello = %{ + "event" => "hello", + "data" => %{"connection_id" => state.connection_id}, + "seq" => 0 + } + + [{:text, Jason.encode!(hello)}] + end + + {:push, frames, agent} + end + + @impl WebSock + def handle_in(_frame, agent), do: {:ok, agent} + + @impl WebSock + def handle_info({:push, frame}, agent), do: {:push, {:text, frame}, agent} + def handle_info(:drop, agent), do: {:stop, :normal, agent} + def handle_info(_other, agent), do: {:ok, agent} + + @impl WebSock + def terminate(_reason, agent) do + me = self() + + if Process.alive?(agent), + do: Agent.update(agent, &%{&1 | sockets: List.delete(&1.sockets, me)}) + + :ok + end + end +end diff --git a/test/support/mattermost/fixtures.json b/test/support/mattermost/fixtures.json new file mode 100644 index 0000000..9c4b160 --- /dev/null +++ b/test/support/mattermost/fixtures.json @@ -0,0 +1,113 @@ +{ + "_about": "Recorded from a throwaway Mattermost Team Edition 11.11.1 server (mattermost/mattermost-preview pinned by digest in slice 072's proof) on 2026-10-08. Frames are the server's text frames to the bot's WebSocket, byte for byte and in arrival order; the second list is a later capture of a system post. The REST bodies are the server's answers to the bot. Every account, team and token was created for the capture and deleted with the server; no credential appears here.", + "frames": [ + "{\"event\":\"hello\",\"data\":{\"connection_id\":\"cm57pzd7xfrk3rhj8iaujc85da\",\"server_hostname\":\"26170425a35e\",\"server_version\":\"11.11.1.35958890150.28b84ae09199518c7c3fe29aed8bc8d26078084ad73d1caa1586f6ed3a8b5f08.false\"},\"broadcast\":{\"omit_users\":null,\"user_id\":\"e1ebrnrgupbh3jynty5wi5qwcr\",\"channel_id\":\"\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"},\"seq\":0}", + "{\"status\":\"OK\",\"seq_reply\":1}", + "{\"event\": \"status_change\", \"data\": {\"status\":\"online\",\"user_id\":\"e1ebrnrgupbh3jynty5wi5qwcr\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"e1ebrnrgupbh3jynty5wi5qwcr\",\"channel_id\":\"\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 1}", + "{\"event\": \"direct_added\", \"data\": {\"creator_id\":\"yuadbo77878m8myazrtkfyi5pw\",\"teammate_id\":\"e1ebrnrgupbh3jynty5wi5qwcr\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"rebfe39yetygpyn11ma3gbairy\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 2}", + "{\"event\":\"posted\",\"data\":{\"channel_display_name\":\"@tester\",\"channel_name\":\"e1ebrnrgupbh3jynty5wi5qwcr__yuadbo77878m8myazrtkfyi5pw\",\"channel_type\":\"D\",\"mentions\":\"[\\\"e1ebrnrgupbh3jynty5wi5qwcr\\\"]\",\"post\":\"{\\\"id\\\":\\\"ky337kc5c3rsipgxr6p16xuxrr\\\",\\\"create_at\\\":1791446993334,\\\"update_at\\\":1791446993334,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"rebfe39yetygpyn11ma3gbairy\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"hello from a direct message\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\",\"sender_name\":\"@tester\",\"set_online\":true,\"team_id\":\"\"},\"broadcast\":{\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"rebfe39yetygpyn11ma3gbairy\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"},\"seq\":3}", + "{\"event\":\"posted\",\"data\":{\"channel_display_name\":\"@tester\",\"channel_name\":\"e1ebrnrgupbh3jynty5wi5qwcr__yuadbo77878m8myazrtkfyi5pw\",\"channel_type\":\"D\",\"followers\":\"[\\\"e1ebrnrgupbh3jynty5wi5qwcr\\\"]\",\"mentions\":\"[\\\"e1ebrnrgupbh3jynty5wi5qwcr\\\"]\",\"post\":\"{\\\"id\\\":\\\"k8nueuesmpfd3qsaphjng855zh\\\",\\\"create_at\\\":1791446993840,\\\"update_at\\\":1791446993840,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"rebfe39yetygpyn11ma3gbairy\\\",\\\"root_id\\\":\\\"ky337kc5c3rsipgxr6p16xuxrr\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"a reply inside a DM thread\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":1,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\",\"sender_name\":\"@tester\",\"set_online\":true,\"team_id\":\"\"},\"broadcast\":{\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"rebfe39yetygpyn11ma3gbairy\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"},\"seq\":4}", + "{\"event\": \"thread_updated\", \"data\": {\"previous_unread_mentions\":0,\"previous_unread_replies\":0,\"thread\":\"{\\\"id\\\":\\\"ky337kc5c3rsipgxr6p16xuxrr\\\",\\\"reply_count\\\":1,\\\"last_reply_at\\\":1791446993840,\\\"last_viewed_at\\\":0,\\\"participants\\\":[{\\\"id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"create_at\\\":1791446964099,\\\"update_at\\\":1791446993327,\\\"delete_at\\\":0,\\\"username\\\":\\\"tester\\\",\\\"auth_data\\\":\\\"\\\",\\\"auth_service\\\":\\\"\\\",\\\"email\\\":\\\"tester@example.test\\\",\\\"nickname\\\":\\\"\\\",\\\"first_name\\\":\\\"Test\\\",\\\"last_name\\\":\\\"Operator\\\",\\\"position\\\":\\\"\\\",\\\"roles\\\":\\\"system_user\\\",\\\"locale\\\":\\\"en\\\",\\\"timezone\\\":{\\\"automaticTimezone\\\":\\\"\\\",\\\"manualTimezone\\\":\\\"\\\",\\\"useAutomaticTimezone\\\":\\\"true\\\"},\\\"remote_id\\\":\\\"\\\",\\\"disable_welcome_email\\\":false}],\\\"post\\\":{\\\"id\\\":\\\"ky337kc5c3rsipgxr6p16xuxrr\\\",\\\"create_at\\\":1791446993334,\\\"update_at\\\":1791446993840,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"rebfe39yetygpyn11ma3gbairy\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"hello from a direct message\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null},\\\"unread_replies\\\":1,\\\"unread_mentions\\\":1,\\\"is_urgent\\\":false,\\\"delete_at\\\":0}\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"e1ebrnrgupbh3jynty5wi5qwcr\",\"channel_id\":\"\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 5}", + "{\"event\":\"posted\",\"data\":{\"channel_display_name\":\"ops\",\"channel_name\":\"ops\",\"channel_type\":\"O\",\"mentions\":\"[\\\"e1ebrnrgupbh3jynty5wi5qwcr\\\"]\",\"post\":\"{\\\"id\\\":\\\"6dmyi373n38txc4shos1j6qzse\\\",\\\"create_at\\\":1791446994352,\\\"update_at\\\":1791446994352,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"pxejam7xm3njbrsuo4zh6d897w\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"@trinity what is on the list today?\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\",\"sender_name\":\"@tester\",\"set_online\":true,\"team_id\":\"1pedc3q4ppdc8ext5xgwaunsgh\"},\"broadcast\":{\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"pxejam7xm3njbrsuo4zh6d897w\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"},\"seq\":6}", + "{\"event\": \"posted\", \"data\": {\"channel_display_name\":\"ops\",\"channel_name\":\"ops\",\"channel_type\":\"O\",\"post\":\"{\\\"id\\\":\\\"onafwng5b7nbtxjk8tborpc84e\\\",\\\"create_at\\\":1791446994864,\\\"update_at\\\":1791446994864,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"pxejam7xm3njbrsuo4zh6d897w\\\",\\\"root_id\\\":\\\"6dmyi373n38txc4shos1j6qzse\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"and tomorrow?\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":1,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\",\"sender_name\":\"@tester\",\"set_online\":true,\"team_id\":\"1pedc3q4ppdc8ext5xgwaunsgh\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"pxejam7xm3njbrsuo4zh6d897w\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 7}", + "{\"event\": \"thread_updated\", \"data\": {\"previous_unread_mentions\":0,\"previous_unread_replies\":0,\"thread\":\"{\\\"id\\\":\\\"6dmyi373n38txc4shos1j6qzse\\\",\\\"reply_count\\\":1,\\\"last_reply_at\\\":1791446994864,\\\"last_viewed_at\\\":0,\\\"participants\\\":[{\\\"id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"create_at\\\":1791446964099,\\\"update_at\\\":1791446993327,\\\"delete_at\\\":0,\\\"username\\\":\\\"tester\\\",\\\"auth_data\\\":\\\"\\\",\\\"auth_service\\\":\\\"\\\",\\\"email\\\":\\\"tester@example.test\\\",\\\"nickname\\\":\\\"\\\",\\\"first_name\\\":\\\"Test\\\",\\\"last_name\\\":\\\"Operator\\\",\\\"position\\\":\\\"\\\",\\\"roles\\\":\\\"system_user\\\",\\\"locale\\\":\\\"en\\\",\\\"timezone\\\":{\\\"automaticTimezone\\\":\\\"\\\",\\\"manualTimezone\\\":\\\"\\\",\\\"useAutomaticTimezone\\\":\\\"true\\\"},\\\"remote_id\\\":\\\"\\\",\\\"disable_welcome_email\\\":false}],\\\"post\\\":{\\\"id\\\":\\\"6dmyi373n38txc4shos1j6qzse\\\",\\\"create_at\\\":1791446994352,\\\"update_at\\\":1791446994864,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"pxejam7xm3njbrsuo4zh6d897w\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"@trinity what is on the list today?\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null},\\\"unread_replies\\\":1,\\\"unread_mentions\\\":0,\\\"is_urgent\\\":false,\\\"delete_at\\\":0}\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"e1ebrnrgupbh3jynty5wi5qwcr\",\"channel_id\":\"\",\"team_id\":\"1pedc3q4ppdc8ext5xgwaunsgh\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 8}", + "{\"event\": \"posted\", \"data\": {\"channel_display_name\":\"ops\",\"channel_name\":\"ops\",\"channel_type\":\"O\",\"post\":\"{\\\"id\\\":\\\"j3iy6w138jbrxyjt4guccf9tpc\\\",\\\"create_at\\\":1791446995375,\\\"update_at\\\":1791446995375,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"pxejam7xm3njbrsuo4zh6d897w\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"people talking among themselves\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\",\"sender_name\":\"@tester\",\"set_online\":true,\"team_id\":\"1pedc3q4ppdc8ext5xgwaunsgh\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"pxejam7xm3njbrsuo4zh6d897w\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 9}", + "{\"event\":\"posted\",\"data\":{\"channel_display_name\":\"@tester\",\"channel_name\":\"e1ebrnrgupbh3jynty5wi5qwcr__yuadbo77878m8myazrtkfyi5pw\",\"channel_type\":\"D\",\"mentions\":\"[\\\"e1ebrnrgupbh3jynty5wi5qwcr\\\"]\",\"otherFile\":\"true\",\"post\":\"{\\\"id\\\":\\\"psyny8jiw3dqdr8dzhjm37gegy\\\",\\\"create_at\\\":1791446995912,\\\"update_at\\\":1791446995912,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"rebfe39yetygpyn11ma3gbairy\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"here is a file\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[\\\"mwrcx9pdu3n68cz5sm4uiky3jh\\\"],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{\\\"files\\\":[{\\\"id\\\":\\\"mwrcx9pdu3n68cz5sm4uiky3jh\\\",\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"post_id\\\":\\\"psyny8jiw3dqdr8dzhjm37gegy\\\",\\\"channel_id\\\":\\\"rebfe39yetygpyn11ma3gbairy\\\",\\\"create_at\\\":1791446995895,\\\"update_at\\\":1791446995895,\\\"delete_at\\\":0,\\\"name\\\":\\\"notes.txt\\\",\\\"extension\\\":\\\"txt\\\",\\\"size\\\":10,\\\"mime_type\\\":\\\"text/plain; charset=utf-8\\\",\\\"mini_preview\\\":null,\\\"remote_id\\\":\\\"\\\",\\\"archived\\\":false}]}}\",\"sender_name\":\"@tester\",\"set_online\":true,\"team_id\":\"\"},\"broadcast\":{\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"rebfe39yetygpyn11ma3gbairy\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"},\"seq\":10}", + "{\"event\": \"posted\", \"data\": {\"channel_display_name\":\"@trinity\",\"channel_name\":\"e1ebrnrgupbh3jynty5wi5qwcr__yuadbo77878m8myazrtkfyi5pw\",\"channel_type\":\"D\",\"post\":\"{\\\"id\\\":\\\"jrhkkj65hibt3fespfdprjuc3c\\\",\\\"create_at\\\":1791446996421,\\\"update_at\\\":1791446996421,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"e1ebrnrgupbh3jynty5wi5qwcr\\\",\\\"channel_id\\\":\\\"rebfe39yetygpyn11ma3gbairy\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"partial\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{\\\"from_bot\\\":\\\"true\\\"},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"remote_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\",\"sender_name\":\"@trinity\",\"set_online\":true,\"team_id\":\"\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"rebfe39yetygpyn11ma3gbairy\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 11}", + "{\"event\": \"post_edited\", \"data\": {\"post\":\"{\\\"id\\\":\\\"jrhkkj65hibt3fespfdprjuc3c\\\",\\\"create_at\\\":1791446996421,\\\"update_at\\\":1791446996743,\\\"edit_at\\\":1791446996742,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"e1ebrnrgupbh3jynty5wi5qwcr\\\",\\\"channel_id\\\":\\\"rebfe39yetygpyn11ma3gbairy\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"partial, then whole\\\",\\\"type\\\":\\\"\\\",\\\"props\\\":{\\\"from_bot\\\":\\\"true\\\"},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"rebfe39yetygpyn11ma3gbairy\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 12}" + ], + "system_frames": [ + "{\"event\": \"posted\", \"data\": {\"channel_display_name\":\"ops\",\"channel_name\":\"ops\",\"channel_type\":\"O\",\"post\":\"{\\\"id\\\":\\\"gh7ciuy5upbw5kptnhkpepr5xo\\\",\\\"create_at\\\":1791447061724,\\\"update_at\\\":1791447061724,\\\"edit_at\\\":0,\\\"delete_at\\\":0,\\\"is_pinned\\\":false,\\\"user_id\\\":\\\"yuadbo77878m8myazrtkfyi5pw\\\",\\\"channel_id\\\":\\\"pxejam7xm3njbrsuo4zh6d897w\\\",\\\"root_id\\\":\\\"\\\",\\\"original_id\\\":\\\"\\\",\\\"message\\\":\\\"tester updated the channel header to: Where Trinity is tested\\\",\\\"type\\\":\\\"system_header_change\\\",\\\"props\\\":{\\\"new_header\\\":\\\"Where Trinity is tested\\\",\\\"old_header\\\":\\\"\\\",\\\"username\\\":\\\"tester\\\"},\\\"hashtags\\\":\\\"\\\",\\\"file_ids\\\":[],\\\"pending_post_id\\\":\\\"\\\",\\\"reply_count\\\":0,\\\"last_reply_at\\\":0,\\\"participants\\\":null,\\\"metadata\\\":{}}\",\"sender_name\":\"System\",\"set_online\":true,\"team_id\":\"1pedc3q4ppdc8ext5xgwaunsgh\"}, \"broadcast\": {\"omit_users\":null,\"user_id\":\"\",\"channel_id\":\"pxejam7xm3njbrsuo4zh6d897w\",\"team_id\":\"\",\"connection_id\":\"\",\"omit_connection_id\":\"\"}, \"seq\": 3}" + ], + "rest": { + "users_me": { + "id": "e1ebrnrgupbh3jynty5wi5qwcr", + "create_at": 1791446964188, + "update_at": 1791446964197, + "delete_at": 0, + "username": "trinity", + "auth_service": "", + "email": "trinity@localhost", + "nickname": "", + "first_name": "Trinity", + "last_name": "", + "position": "", + "roles": "system_user", + "notify_props": { + "channel": "true", + "channel_mention_auto_follow_threads": "true", + "comments": "never", + "desktop": "mention", + "desktop_sound": "true", + "desktop_threads": "all", + "email": "true", + "email_threads": "all", + "first_name": "false", + "mention_keys": "", + "push": "mention", + "push_status": "online", + "push_threads": "all" + }, + "last_password_update": 1791446964188, + "locale": "en", + "timezone": { + "automaticTimezone": "", + "manualTimezone": "", + "useAutomaticTimezone": "true" + }, + "is_bot": true, + "bot_description": "throwaway test bot", + "disable_welcome_email": false + }, + "create_post": { + "id": "jrhkkj65hibt3fespfdprjuc3c", + "create_at": 1791446996421, + "update_at": 1791446996421, + "edit_at": 0, + "delete_at": 0, + "is_pinned": false, + "user_id": "e1ebrnrgupbh3jynty5wi5qwcr", + "channel_id": "rebfe39yetygpyn11ma3gbairy", + "root_id": "", + "original_id": "", + "message": "partial", + "type": "", + "props": { + "from_bot": "true" + }, + "hashtags": "", + "file_ids": [], + "pending_post_id": "", + "remote_id": "", + "reply_count": 0, + "last_reply_at": 0, + "participants": null, + "metadata": {} + }, + "patch_post": { + "id": "jrhkkj65hibt3fespfdprjuc3c", + "create_at": 1791446996421, + "update_at": 1791446996743, + "edit_at": 1791446996742, + "delete_at": 0, + "is_pinned": false, + "user_id": "e1ebrnrgupbh3jynty5wi5qwcr", + "channel_id": "rebfe39yetygpyn11ma3gbairy", + "root_id": "", + "original_id": "", + "message": "partial, then whole", + "type": "", + "props": { + "from_bot": "true" + }, + "hashtags": "", + "file_ids": [], + "pending_post_id": "", + "reply_count": 0, + "last_reply_at": 0, + "participants": null, + "metadata": {} + }, + "client_config_MaxPostSize": "16383" + } +} diff --git a/test/support/mattermost/helpers.ex b/test/support/mattermost/helpers.ex new file mode 100644 index 0000000..8a3d0f6 --- /dev/null +++ b/test/support/mattermost/helpers.ex @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.TestHelpers do + @moduledoc """ + Starting the Mattermost adapter against `FakeServer` in a test (slice 072), and waiting on what + the server is shown. The token is a fresh random value in a variable of its own per test, so a + test asserting it never reaches a log line is looking for a value nothing else in the run holds. + """ + import ExUnit.Assertions + + alias Trinity.Gateways.Mattermost + alias Trinity.Gateways.Mattermost.{FakeServer, State} + + @doc "The recorded tester's user id, the person writing in every recorded frame." + def tester_id, do: "yuadbo77878m8myazrtkfyi5pw" + + @doc """ + A token shaped like a Mattermost one (26 lower-case letters and digits), in an environment + variable of its own. Answers `{env_var, token}`. + """ + def token! do + env = "MATTERMOST_TEST_TOKEN_#{System.unique_integer([:positive])}" + token = FakeServer.random_id() + System.put_env(env, token) + ExUnit.Callbacks.on_exit(fn -> System.delete_env(env) end) + {env, token} + end + + @doc """ + A fake server and the adapter connected to it. Options go to the adapter (`callback_url:`) or + the server (`max_post_size:`). Waits until the adapter has learned the server and its socket + is connected. + """ + def start_adapter!(opts \\ []) do + {env, token} = token!() + server = FakeServer.start!(token, Keyword.take(opts, [:max_post_size])) + + adapter_opts = + [url: server.url, token_env: env, backoff_ms: 20, max_backoff_ms: 100] + |> Keyword.merge(Keyword.drop(opts, [:max_post_size])) + + ExUnit.Callbacks.start_supervised!({Mattermost, adapter_opts}) + await(fn -> State.facts() != nil and FakeServer.sockets(server) != [] end) + Map.merge(server, %{token: token, token_env: env}) + end + + @doc "Pairs the recorded tester with the adapter, as the desktop would." + def pair_tester! do + {:ok, identity, :pending} = Trinity.Gateways.Identities.admit("mattermost", tester_id()) + {:ok, _} = Trinity.Gateways.Identities.pair("mattermost", tester_id(), identity.code) + :ok + end + + @doc "Waits until `fun` is truthy, flunking with `what` after `timeout`." + def await(fun, what \\ "the condition", timeout \\ 5_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await(fun, what, deadline) + end + + defp do_await(fun, what, deadline) do + case fun.() do + falsy when falsy in [nil, false] -> + if System.monotonic_time(:millisecond) > deadline do + flunk("waited for #{what} and it never held") + else + Process.sleep(20) + do_await(fun, what, deadline) + end + + value -> + value + end + end + + @doc "Every message the server holds now, joined, for a wait on what a person would see." + def shown(server), do: server |> FakeServer.posts() |> Map.values() |> Enum.join("\n") + + @doc "Waits until a post the server holds contains `fragment`." + def await_shown(server, fragment), + do: await(fn -> shown(server) =~ fragment end, "a post containing #{inspect(fragment)}") +end diff --git a/test/support/mattermost/log_tap.ex b/test/support/mattermost/log_tap.ex new file mode 100644 index 0000000..48dbbcd --- /dev/null +++ b/test/support/mattermost/log_tap.ex @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.LogTap do + @moduledoc """ + Every log event, the SASL domain included, formatted in full and sent to a test process (slice + 072, AC6). + + `ExUnit.CaptureLog` sees what Elixir's default handler sees, and that handler drops OTP's SASL + reports (crash reports, supervisor reports) unless `handle_sasl_reports` is set, which this tree + does not set. A process that is not a GenServer, such as a WebSockex socket, crashes with only a + SASL crash report, so a capture would read nothing and a "the token is not in the log" assertion + would pass by reading nothing. This handler sits beside the default one, receives events after + the primary filters (the redaction filter among them) as any handler does, and formats each with + OTP's own formatter, no depth or size limit, so a crash report is read with its error, its + stacktrace and its process information. + """ + + @doc "Attaches a tap sending `{:log_tap, text}` to `pid`; answers its handler id." + def attach(pid) do + id = :"log_tap_#{System.unique_integer([:positive])}" + :ok = :logger.add_handler(id, __MODULE__, %{config: %{pid: pid}, level: :all}) + ExUnit.Callbacks.on_exit(fn -> :logger.remove_handler(id) end) + id + end + + @doc "Detaches a tap and answers everything it sent, joined. (Not `collect`: the session census reserves that name.)" + def read(id) do + :logger.remove_handler(id) + drain([]) |> Enum.reverse() |> Enum.join() + end + + defp drain(acc) do + receive do + {:log_tap, text} -> drain([text | acc]) + after + 0 -> acc + end + end + + @doc false + def log(event, %{config: %{pid: pid}}) do + text = + event + |> :logger_formatter.format(%{single_line: false, template: [:level, " ", :msg, "\n"]}) + |> IO.chardata_to_string() + + send(pid, {:log_tap, text}) + end +end diff --git a/test/trinity/gateways/census_test.exs b/test/trinity/gateways/census_test.exs new file mode 100644 index 0000000..1f51c56 --- /dev/null +++ b/test/trinity/gateways/census_test.exs @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.CensusTest do + @moduledoc """ + Slice 072, AC1: an adapter carries text and nothing else. It calls no LLM, touches no session + directly, and decides no approval (docs/07, "Gateways"; slice 070 states the rule and this is + the test that holds the layer to it). + + **The population comes from the tree.** Every module the `:trinity` application compiled that + declares `@behaviour Trinity.Gateways.Adapter` is an adapter, and every module nested under one + is part of it. Each one's compiled import table (every remote call the compiler emitted) is read + and checked against the forbidden namespaces. A new adapter is covered the day it is compiled, + without anyone remembering to add it here. + + **Stated limit.** An import table records the calls the compiler can see. A call made through + `apply/3` with a module computed at run time is not in it; nor is a message sent to a process. + The router is the one road to a session (`Trinity.Gateways.Router`), and an adapter's calls to it + are allowed: that is how it hands text on. + """ + use ExUnit.Case, async: true + + @forbidden [ + {"Trinity.LLM", "calls no LLM"}, + {"Trinity.Sessions", "touches no session directly"}, + {"Trinity.Permissions", "decides no approval"}, + {"Trinity.Effects", "runs no effect"}, + {"Trinity.Tools", "runs no tool"} + ] + + defp adapters do + {:ok, modules} = :application.get_key(:trinity, :modules) + + Enum.filter(modules, fn module -> + behaviours = + if Code.ensure_loaded?(module), + do: module.module_info(:attributes) |> Keyword.get_values(:behaviour) |> List.flatten(), + else: [] + + Trinity.Gateways.Adapter in behaviours + end) + end + + defp parts(adapter) do + {:ok, modules} = :application.get_key(:trinity, :modules) + prefix = Atom.to_string(adapter) <> "." + [adapter | Enum.filter(modules, &String.starts_with?(Atom.to_string(&1), prefix))] + end + + # The compiled file, read from the application's ebin: under `mix test --cover` the loaded + # module is cover-compiled and `:code.which/1` answers `:cover_compiled`, not a path. + defp imports(module) do + beam = Path.join(Application.app_dir(:trinity, "ebin"), "#{module}.beam") + {:ok, {^module, [imports: imports]}} = :beam_lib.chunks(String.to_charlist(beam), [:imports]) + imports + end + + test "the population is not empty, and holds the adapters this tree ships" do + found = adapters() + assert Trinity.Gateways.Console in found + assert Trinity.Gateways.Mattermost in found + assert length(parts(Trinity.Gateways.Mattermost)) > 5 + end + + test "AC1: no adapter, nor any module of one, calls the LLM, a session, the gate, an effect or a tool" do + violations = + for adapter <- adapters(), + module <- parts(adapter), + {callee, function, arity} <- imports(module), + name = Atom.to_string(callee) |> String.replace_prefix("Elixir.", ""), + {namespace, rule} <- @forbidden, + name == namespace or String.starts_with?(name, namespace <> "."), + do: "#{inspect(module)} calls #{name}.#{function}/#{arity}: an adapter #{rule}" + + assert violations == [], Enum.join(violations, "\n") + end + + test "AC1: an adapter hands text to the router, which is how it reaches a session at all" do + calls = + for module <- parts(Trinity.Gateways.Mattermost), + {Trinity.Gateways.Router, function, _} <- imports(module), + do: function + + assert :inbound in calls + end +end diff --git a/test/trinity/gateways/mattermost/adapter_test.exs b/test/trinity/gateways/mattermost/adapter_test.exs new file mode 100644 index 0000000..8d10681 --- /dev/null +++ b/test/trinity/gateways/mattermost/adapter_test.exs @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.AdapterTest do + @moduledoc """ + Slice 072, AC2 and AC5, end to end: frames recorded from a real server are pushed by + `FakeServer` to the adapter's socket, through the real router and a real session answered by the + fake provider, and back to the server as posts and edits. + + AC2: which conversation (and so which session) each recorded post belongs to, and that the + reply streams into one post by editing it. AC5: the mapping against the recorded frames, and a + consumer killed mid-run that reconnects, is replayed what it already handled, and answers it + once. + """ + use Trinity.SessionCase + + import Trinity.Gateways.Mattermost.TestHelpers + + alias Trinity.Gateways.{Mattermost, Router} + alias Trinity.Gateways.Mattermost.{FakeServer, Socket} + alias Trinity.LLM.Providers.Fake + + # The recorded frames, by what they are (fixtures.json, captured 2026-10-08). + defp frame(n), do: Enum.at(FakeServer.frames("posted"), n) + defp dm_top, do: frame(0) + defp dm_thread_reply, do: frame(1) + defp channel_mention, do: frame(2) + defp channel_thread_reply, do: frame(3) + defp channel_chatter, do: frame(4) + defp dm_with_file, do: frame(5) + defp own_post, do: frame(6) + defp system_post, do: frame(7) + + defp post_of(raw), do: raw |> Jason.decode!() |> get_in(["data", "post"]) |> Jason.decode!() + + setup do + start_supervised!(Router) + pair_tester!() + :ok + end + + defp streamed(text, pause \\ 0) do + deltas = + text + |> String.split(" ") + |> Enum.flat_map(&[{:text_delta, &1 <> " "}, {:sleep, pause}]) + + deltas ++ [{:usage, %{input_tokens: 3, output_tokens: 3}}, {:done, :stop}] + end + + test "AC2: a direct message is answered in the DM, streamed into one post by editing it" do + server = start_adapter!() + Fake.scripts([streamed("one two three four five six", 300)]) + + FakeServer.push(server, dm_top()) + await_shown(server, "one two three four five six") + + dm = post_of(dm_top())["channel_id"] + + # One post, then edits to it: the stream, not a post per delta. + created = FakeServer.created(server) + assert [%{"channel_id" => ^dm, "root_id" => ""}] = created + + edits = for {"PUT", "/api/v4/posts/" <> _, _} <- FakeServer.requests(server), do: :edit + assert edits != [], "the reply was posted once and never edited, so it did not stream" + + # A DM's top level is one conversation and one session, which came from this channel. + session_id = Router.session_of(Mattermost, dm) + assert %{origin: "mattermost"} = Sessions.get_session(session_id) + end + + test "AC2: a reply inside a DM thread is a conversation of its own, answered in that thread" do + server = start_adapter!() + Fake.scripts([streamed("threaded answer")]) + + FakeServer.push(server, dm_thread_reply()) + await_shown(server, "threaded answer") + + post = post_of(dm_thread_reply()) + assert [%{"root_id" => root}] = FakeServer.created(server) + assert root == post["root_id"] + assert Router.session_of(Mattermost, post["channel_id"] <> ":" <> root) + assert Router.session_of(Mattermost, post["channel_id"]) == nil + end + + test "AC2: a channel mention starts a thread; a reply there continues it unmentioned; chatter is not read" do + server = start_adapter!() + Fake.scripts([streamed("first in the thread"), streamed("second in the thread")]) + + FakeServer.push(server, channel_mention()) + await_shown(server, "first in the thread") + + mention = post_of(channel_mention()) + thread = mention["channel_id"] <> ":" <> mention["id"] + session_id = Router.session_of(Mattermost, thread) + assert session_id + + # The mention is taken out of what the session is told. + assert [%{content: "what is on the list today?"} | _] = + Enum.filter(Sessions.history(session_id), &(&1.role == "user")) + + FakeServer.push(server, channel_thread_reply()) + await_shown(server, "second in the thread") + assert Router.session_of(Mattermost, thread) == session_id + + # Both answers went into the thread, under the mention. + assert Enum.all?(FakeServer.created(server), &(&1["root_id"] == mention["id"])) + + # Talk in the channel that does not mention the bot is not read: no call, no post, no session. + calls = Fake.calls() + before = FakeServer.created(server) + FakeServer.push(server, channel_chatter()) + chatter = post_of(channel_chatter()) + Process.sleep(300) + assert Fake.calls() == calls + assert FakeServer.created(server) == before + assert Router.session_of(Mattermost, chatter["channel_id"] <> ":" <> chatter["id"]) == nil + end + + test "AC5: the bot's own post and a system post are never answered" do + server = start_adapter!() + + FakeServer.push(server, own_post()) + FakeServer.push(server, system_post()) + Process.sleep(300) + + assert Fake.calls() == 0 + assert FakeServer.created(server) == [] + end + + test "AC5: a file is not carried, and the paired sender is told so" do + server = start_adapter!() + Fake.scripts([streamed("I read your text")]) + + FakeServer.push(server, dm_with_file()) + await_shown(server, "I read your text") + await_shown(server, "cannot read files") + end + + test "AC5: a consumer killed mid-run reconnects, resumes, and answers a replayed post once" do + server = start_adapter!() + Fake.scripts([streamed("answer to the first"), streamed("answer to the second")]) + + FakeServer.push(server, dm_top()) + await_shown(server, "answer to the first") + assert Fake.calls() == 1 + + # The next resume replays from the very first event, so it includes the post already + # answered: the overlap a consumer that died after handling and before acknowledging sees. + FakeServer.replay_from(server, 1) + old = Process.whereis(Socket) + Process.exit(old, :kill) + + await(fn -> (pid = Process.whereis(Socket)) && pid != old end, "the socket to be restarted") + await(fn -> length(FakeServer.connects(server)) == 2 end, "the socket to reconnect", 15_000) + + # It resumed rather than starting over: the server's own connection id and the next number. + [_first, resumed] = FakeServer.connects(server) + assert resumed =~ "connection_id=" + assert resumed =~ "sequence_number=" + + # A new post after the replay. The socket hands posts to the router one at a time and in + # order, so once this one has been answered the replay has been read; and once every session + # is idle again, any turn the replay started has finished and been counted. Waiting on the + # answer's text alone is not enough: under a planted double-handling the replay is answered + # with the second script, and a wait on that text passes before the third call is made (seen + # once while demonstrating this test red; NOTES, "Red evidence", R2). + await(fn -> FakeServer.sockets(server) != [] end, "the server to see the new socket") + FakeServer.push(server, dm_thread_reply()) + thread_root = post_of(dm_thread_reply())["root_id"] + + await( + fn -> Enum.any?(FakeServer.created(server), &(&1["root_id"] == thread_root)) end, + "the thread reply to be answered in its thread" + ) + + await(&all_idle?/0, "every session to be idle") + + # The model was asked twice, once per post, and the DM's top level was answered once. + assert Fake.calls() == 2 + top_level = Enum.filter(FakeServer.created(server), &(&1["root_id"] == "")) + assert length(top_level) == 1 + end + + defp all_idle? do + Enum.all?(Sessions.list_sessions(), fn session -> + case Sessions.state(session.id) do + %{state: :idle} -> true + {:error, :not_running} -> true + _ -> false + end + end) + end + + test "a conversation that is not a server id is refused before any request is made" do + server = start_adapter!() + + for bad <- ["../../users/me", "abc", "pxejam7xm3njbrsuo4zh6d897w:../x", nil, 42] do + assert {:error, :bad_conversation} = Mattermost.deliver(bad, {:message, "x"}) + end + + assert {:error, :bad_id} = + Mattermost.deliver(post_of(dm_top())["channel_id"], {:edit, "../x", "y"}) + + assert FakeServer.requests(server) |> Enum.filter(&match?({"POST", "/api/v4/posts", _}, &1)) == + [] + end +end diff --git a/test/trinity/gateways/mattermost/approvals_test.exs b/test/trinity/gateways/mattermost/approvals_test.exs new file mode 100644 index 0000000..83a4a37 --- /dev/null +++ b/test/trinity/gateways/mattermost/approvals_test.exs @@ -0,0 +1,390 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.ApprovalsTest do + @moduledoc """ + Slice 072, AC3: an approval raised by a turn that started in Mattermost round-trips through an + interactive dialog to `Trinity.Permissions`, over the real HTTP route + (`POST /gateways/callback/mattermost/`); and the channel cap still refuses `:exec` from + this adapter, demonstrated by attempting one both ways a person could (the slash command, and a + dialog submission whose state is validly signed), each refused, receipted, and left pending. + """ + use Trinity.SessionCase + + import Phoenix.ConnTest + import Trinity.Gateways.Mattermost.TestHelpers + + alias Trinity.Gateways.{Cap, Mattermost, Router} + alias Trinity.Gateways.Mattermost.{FakeServer, Signing} + alias Trinity.LLM.Providers.Fake + alias Trinity.Permissions + alias Trinity.Permissions.Approval + alias Trinity.Receipts + + @endpoint TrinityWeb.Endpoint + @callback_url "http://127.0.0.1:4072" + + setup do + start_supervised!(Router) + pair_tester!() + + {command_env, command_token} = token!() + + server = + start_adapter!(callback_url: @callback_url, command_token_env: command_env) + + # The route finds the adapter among the configured ones, as it would in a running node. + previous = Application.get_env(:trinity, :gateways) + Application.put_env(:trinity, :gateways, adapters: [Mattermost]) + on_exit(fn -> restore(previous) end) + + Fake.scripts([ + [ + {:text_delta, "hello there"}, + {:usage, %{input_tokens: 1, output_tokens: 1}}, + {:done, :stop} + ] + ]) + + FakeServer.push(server, hd(FakeServer.frames("posted"))) + await_shown(server, "hello there") + + dm = + hd(FakeServer.frames("posted")) + |> Jason.decode!() + |> get_in(["data", "post"]) + |> Jason.decode!() + |> Map.fetch!("channel_id") + + session_id = Router.session_of(Mattermost, dm) + + # Every decision here is receipted on the session's chain; its writer is stopped with the test. + on_exit(fn -> Receipts.stop_writer(Receipts.session_scope(session_id)) end) + %{server: server, dm: dm, session_id: session_id, command_token: command_token} + end + + defp restore(nil), do: Application.delete_env(:trinity, :gateways) + defp restore(previous), do: Application.put_env(:trinity, :gateways, previous) + + defp callback(kind, params) do + build_conn() + |> Plug.Conn.put_req_header("content-type", "application/json") + |> post("/gateways/callback/mattermost/#{kind}", Jason.encode!(params)) + end + + defp approval_post(server, tool) do + await( + fn -> + Enum.find(FakeServer.created(server), &(&1["message"] =~ "Approval needed: #{tool}")) + end, + "the approval post for #{tool}" + ) + end + + defp button(post) do + [%{"actions" => [action]}] = post["props"]["attachments"] + action["integration"] + end + + defp dialog_opened(server) do + await( + fn -> + for( + {"POST", "/api/v4/actions/dialogs/open", body} <- FakeServer.requests(server), + do: body + ) + |> List.last() + end, + "a dialog to be opened" + ) + end + + test "AC3: a write request is posted with a button, the button opens a dialog, and the answer is decided by Permissions", + %{server: server, dm: dm, session_id: session_id} do + {:ok, %Approval{id: id}} = + Permissions.request_approval(session_id, "write_note", %{"path" => "notes/a.md"}, + risk: :write + ) + + post = approval_post(server, "write_note") + assert post["channel_id"] == dm + integration = button(post) + assert integration["url"] == @callback_url <> "/gateways/callback/mattermost/action" + + # The press, as the server sends it: who pressed, where, and the context it kept. + conn = + callback("action", %{ + "user_id" => tester_id(), + "channel_id" => dm, + "post_id" => "x", + "trigger_id" => "trigger-1", + "context" => integration["context"] + }) + + assert json_response(conn, 200) == %{} + + opened = dialog_opened(server) + assert opened["trigger_id"] == "trigger-1" + assert opened["url"] == @callback_url <> "/gateways/callback/mattermost/dialog" + assert opened["dialog"]["introduction_text"] =~ "Approval needed: write_note (write)" + + # The dialog offers the post's buttons (slice 071's shape), by label; the commands stay in + # the signed state and are never sent to the client. + [element] = opened["dialog"]["elements"] + assert Enum.map(element["options"], & &1["text"]) == ["Approve once", "Deny"] + refute Jason.encode!(opened["dialog"]["elements"]) =~ "/approve" + + conn = + callback("dialog", %{ + "type" => "dialog_submission", + "callback_id" => "trinity_answer", + "state" => opened["dialog"]["state"], + "user_id" => tester_id(), + "channel_id" => dm, + "submission" => %{"decision" => "0"}, + "cancelled" => false + }) + + assert json_response(conn, 200) == %{} + + # Decided by the gate, as the channel and the account, never by the adapter. + assert %Approval{status: "allowed", decided_by: decided_by} = Permissions.get_approval(id) + assert decided_by == "gateway:mattermost:" <> tester_id() + await_shown(server, "Approved #{String.slice(id, 0, 8)}") + + # The same dialog cannot decide twice. + conn = + callback("dialog", %{ + "state" => opened["dialog"]["state"], + "user_id" => tester_id(), + "channel_id" => dm, + "submission" => %{"decision" => "1"} + }) + + assert conn.status == 403 + end + + test "AC3: deny through the dialog denies it", %{server: server, dm: dm, session_id: session_id} do + {:ok, %Approval{id: id}} = + Permissions.request_approval(session_id, "write_note", %{}, risk: :write) + + integration = server |> approval_post("write_note") |> button() + + callback("action", %{ + "user_id" => tester_id(), + "channel_id" => dm, + "trigger_id" => "t", + "context" => integration["context"] + }) + + opened = dialog_opened(server) + + callback("dialog", %{ + "state" => opened["dialog"]["state"], + "user_id" => tester_id(), + "channel_id" => dm, + "submission" => %{"decision" => "1"} + }) + + assert %Approval{status: "denied"} = Permissions.get_approval(id) + end + + test "AC3: the cap refuses :exec from this adapter, by command and by dialog, receipted and left for the desktop", + %{server: server, dm: dm, session_id: session_id, command_token: command_token} do + refute Cap.allows?(Mattermost, :exec) + + {:ok, %Approval{id: id}} = + Permissions.request_approval(session_id, "shell", %{"command" => "make deploy"}, + risk: :exec + ) + + # Rendered, saying where it has to be decided, and with no control that could only be refused. + post = approval_post(server, "shell") + assert post["message"] =~ "may approve up to write" + refute Map.has_key?(post, "props") + + # Attempt 1: the slash command, form-encoded as the server sends it. + conn = + build_conn() + |> post("/gateways/callback/mattermost/command", %{ + "token" => command_token, + "user_id" => tester_id(), + "channel_id" => dm, + "command" => "/trinity", + "text" => "approve #{String.slice(id, 0, 8)}" + }) + + assert json_response(conn, 200) == %{} + + # The render already carries the cap's words, so the refusal is counted, not just looked for: + # a wait on the text alone is satisfied by the approval post itself (NOTES, R4). + await(fn -> refusals_shown(server) == 2 end, "the command's refusal in the channel") + + # Attempt 2: a dialog submission whose state is genuinely signed for this person and this + # request, which is as strong a claim as this channel can make. The cap refuses it the same. + conn = + callback("dialog", %{ + "state" => Signing.dialog(answers(id), dm, tester_id()), + "user_id" => tester_id(), + "channel_id" => dm, + "submission" => %{"decision" => "0"} + }) + + assert json_response(conn, 200) == %{} + await(fn -> refusals_shown(server) == 3 end, "the dialog's refusal in the channel") + + # The gate was never asked: still pending, for the desktop. + assert %Approval{status: "pending", decided_by: nil} = Permissions.get_approval(id) + + # Both refusals are receipts on the session's chain, naming this adapter and the account. + scope = Receipts.session_scope(session_id) + + refusals = + await( + fn -> + found = Enum.filter(Receipts.list(scope), &(&1.subject["phase"] == "gateway_cap")) + if length(found) == 2, do: found + end, + "two cap refusals on the chain" + ) + + for refusal <- refusals do + assert refusal.subject["adapter"] == "mattermost" + assert refusal.subject["external_user_id"] == tester_id() + assert refusal.subject["risk"] == "exec" + assert Jason.decode!(refusal.signed_payload)["decision"]["basis"] == "channel_cap" + end + end + + test "a request that cannot prove where it came from is refused before anything is read", + %{server: server, dm: dm, session_id: session_id, command_token: command_token} do + {:ok, %Approval{id: id}} = + Permissions.request_approval(session_id, "write_note", %{}, risk: :write) + + integration = server |> approval_post("write_note") |> button() + good_state = Signing.dialog(answers(id), dm, tester_id()) + + forged = [ + {"action", + %{ + "user_id" => tester_id(), + "channel_id" => dm, + "trigger_id" => "t", + "context" => %{"token" => "x.y"} + }}, + # A genuine button pressed from a different channel than the one it was posted in. + {"action", + %{ + "user_id" => tester_id(), + "channel_id" => "pxejam7xm3njbrsuo4zh6d897w", + "trigger_id" => "t", + "context" => integration["context"] + }}, + # A button's context offered as a dialog's state: the two kinds are keyed apart. + {"dialog", + %{ + "state" => integration["context"]["token"], + "user_id" => tester_id(), + "channel_id" => dm, + "submission" => %{"decision" => "0"} + }}, + # Someone else submitting the tester's dialog. + {"dialog", + %{ + "state" => good_state, + "user_id" => "e1ebrnrgupbh3jynty5wi5qwcr", + "channel_id" => dm, + "submission" => %{"decision" => "0"} + }}, + # A tampered payload under the original signature. + {"dialog", + %{ + "state" => tamper(good_state), + "user_id" => tester_id(), + "channel_id" => dm, + "submission" => %{"decision" => "0"} + }}, + {"command", + %{ + "token" => command_token <> "x", + "user_id" => tester_id(), + "channel_id" => dm, + "text" => "approve #{id}" + }}, + {"command", %{"user_id" => tester_id(), "channel_id" => dm, "text" => "approve #{id}"}} + ] + + for {kind, params} <- forged do + assert callback(kind, params).status == 403, "#{kind} was not refused: #{inspect(params)}" + end + + # A genuine state with an answer it does not name: the dialog says so, and nothing is decided. + conn = + callback("dialog", %{ + "state" => Signing.dialog(answers(id), dm, tester_id()), + "user_id" => tester_id(), + "channel_id" => dm, + "submission" => %{"decision" => "/approve " <> id} + }) + + assert %{"errors" => %{"decision" => _}} = json_response(conn, 200) + + assert callback("nonsense", %{}).status == 404 + + assert build_conn() |> post("/gateways/callback/telegram/action", %{}) |> Map.get(:status) == + 404 + + assert %Approval{status: "pending"} = Permissions.get_approval(id) + assert dialog_opened_count(server) == 0 + end + + test "with no callback URL the adapter offers no control and says so in its capabilities", %{ + server: server + } do + assert Mattermost.capabilities().buttons + stop_supervised!(Mattermost) + + {env, token} = token!() + plain = FakeServer.start!(token) + start_supervised!({Mattermost, url: plain.url, token_env: env, backoff_ms: 20}) + refute Mattermost.capabilities().buttons + + assert {:ok, _} = + Mattermost.deliver( + "rebfe39yetygpyn11ma3gbairy", + {:message, "Approval needed", [{"Approve once", "/approve a"}]} + ) + + assert [%{"message" => "Approval needed"} = body] = FakeServer.created(plain) + refute Map.has_key?(body, "props") + _ = server + end + + # The buttons an approval carries (slice 071's shape), as a signed dialog names them. + defp answers(id), do: [{"Approve once", "/approve " <> id}, {"Deny", "/deny " <> id}] + + defp refusals_shown(server) do + server + |> FakeServer.posts() + |> Map.values() + |> Enum.count(&(&1 =~ "That is a exec request")) + end + + defp dialog_opened_count(server), + do: + Enum.count( + FakeServer.requests(server), + &match?({"POST", "/api/v4/actions/dialogs/open", _}, &1) + ) + + defp tamper(signed) do + [payload, mac] = String.split(signed, ".") + + claims = + payload + |> Base.url_decode64!(padding: false) + |> Jason.decode!() + |> Map.put("a", "someone-else") + + (claims |> Jason.encode!() |> Base.url_encode64(padding: false)) <> "." <> mac + end +end diff --git a/test/trinity/gateways/mattermost/delivery_test.exs b/test/trinity/gateways/mattermost/delivery_test.exs new file mode 100644 index 0000000..e401c3a --- /dev/null +++ b/test/trinity/gateways/mattermost/delivery_test.exs @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.DeliveryTest do + @moduledoc """ + Slice 072: Mattermost as the scheduler's delivery target (the Goal's "cron delivery target"). A + task names the adapter and a channel; the run's summary arrives in that channel as posts within + the server's limit, and the run is marked delivered. A post the server refused is not delivered, + and the run must not say it was. + """ + use Trinity.DataCase, async: false + + import Trinity.Gateways.Mattermost.TestHelpers + + alias Trinity.Gateways.Mattermost + alias Trinity.Gateways.Mattermost.{FakeServer, Format} + alias Trinity.Scheduler + alias Trinity.Scheduler.Delivery + + @ops "pxejam7xm3njbrsuo4zh6d897w" + + setup do + server = start_adapter!(max_post_size: "200") + previous = Application.get_env(:trinity, :gateways) + Application.put_env(:trinity, :gateways, adapters: [Mattermost]) + + on_exit(fn -> + if previous, + do: Application.put_env(:trinity, :gateways, previous), + else: Application.delete_env(:trinity, :gateways) + end) + + %{server: server} + end + + defp run_for(conversation, summary) do + {:ok, task} = + Scheduler.create_task(%{ + name: "morning digest", + kind: "cron", + schedule: "0 9 * * *", + prompt: "summarise", + deliver_to: %{ + "kind" => "gateway", + "adapter" => "mattermost", + "conversation" => conversation + } + }) + + {:ok, run} = Scheduler.run_now(task) + {:ok, run} = Scheduler.update_run(run, %{status: "ok", summary: summary}) + {task, run} + end + + test "a run's summary arrives in the channel, within the server's limit, and the run is delivered", + %{server: server} do + summary = Enum.map_join(1..80, " ", &"item#{&1}") + {task, run} = run_for(@ops, summary) + + assert Delivery.for(task) == Delivery.Gateway + assert {:ok, delivered} = Delivery.for(task).deliver(run, task) + assert delivered.delivered_at != nil + + posts = FakeServer.created(server) + assert length(posts) > 1 + assert Enum.all?(posts, &(&1["channel_id"] == @ops and &1["root_id"] == "")) + assert Enum.all?(posts, &(Format.codepoints(&1["message"]) <= 200)) + assert hd(posts)["message"] =~ "morning digest: item1" + end + + test "a summary the server refuses is not marked delivered", %{server: server} do + # The server stops accepting the bot's token. + System.put_env(server.token_env, FakeServer.random_id()) + {task, run} = run_for(@ops, "three things happened") + + assert {:error, _} = Delivery.Gateway.deliver(run, task) + assert Scheduler.get_run(run.id).delivered_at == nil + end + + test "a destination that is not a channel id is refused, and nothing is marked", %{ + server: server + } do + {task, run} = run_for("../../users/me", "x") + assert {:error, _} = Delivery.Gateway.deliver(run, task) + assert Scheduler.get_run(run.id).delivered_at == nil + assert FakeServer.created(server) == [] + end +end diff --git a/test/trinity/gateways/mattermost/events_test.exs b/test/trinity/gateways/mattermost/events_test.exs new file mode 100644 index 0000000..478546b --- /dev/null +++ b/test/trinity/gateways/mattermost/events_test.exs @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.EventsTest do + @moduledoc """ + Slice 072, AC5: every frame a real server sent the bot, read. The population is the recorded + capture (`test/support/mattermost/fixtures.json`), not a list written here: each frame in it is + classified, and the test fails on a frame it has no expectation for, so a recapture that brings + a new kind of frame cannot pass unread. + """ + use ExUnit.Case, async: true + + alias Trinity.Gateways.Mattermost.{Events, FakeServer} + + @me FakeServer.fixtures()["rest"]["users_me"] + @facts %{bot_user_id: @me["id"], bot_username: @me["username"], max_post_size: 16_383} + @tester "yuadbo77878m8myazrtkfyi5pw" + @dm "rebfe39yetygpyn11ma3gbairy" + @ops "pxejam7xm3njbrsuo4zh6d897w" + + defp all_frames, do: FakeServer.fixtures()["frames"] ++ FakeServer.fixtures()["system_frames"] + defp read(frame, following \\ fn _ -> false end), do: Events.read(frame, @facts, following) + + test "the capture is what the tests below assume it is" do + assert @me["is_bot"] == true + assert length(all_frames()) == 15 + end + + test "every recorded frame is classified, and nothing in the capture is unreadable" do + for frame <- all_frames() do + result = read(frame) + refute match?({:unreadable, _}, result), "unreadable: #{frame}" + end + end + + test "the handshake: hello with the connection id, and the authentication reply" do + [hello, auth | _] = FakeServer.fixtures()["frames"] + assert {:hello, id, 0} = read(hello) + assert id =~ ~r/\A[a-z0-9]{26}\z/ + assert {:auth, :ok} = read(auth) + end + + test "posts, by what they are, against the recorded frames" do + results = for frame <- FakeServer.frames("posted"), do: read(frame) + + assert [ + {:post, _, dm_top}, + {:post, _, dm_reply}, + {:post, _, mention}, + {:event, _, :not_addressed}, + {:event, _, :not_addressed}, + {:post, _, with_file}, + {:event, _, :own_post}, + {:event, _, :system_post} + ] = results + + assert %{conversation: @dm, user_id: @tester, text: "hello from a direct message"} = dm_top + assert dm_top.display_name == "tester" + assert dm_top.follow == nil + + assert %{conversation: @dm <> ":" <> root, text: "a reply inside a DM thread"} = dm_reply + assert root == dm_top.post_id + + # A mention starts a thread named by the post itself, and the mention leaves the text. + assert mention.conversation == @ops <> ":" <> mention.post_id + assert mention.text == "what is on the list today?" + assert mention.follow == mention.conversation + + assert with_file.files == 1 + assert with_file.text == "here is a file" + end + + test "a reply in a thread the bot follows is read without a mention; chatter never is" do + [_, _, mention, thread_reply, chatter | _] = FakeServer.frames("posted") + {:post, _, %{follow: thread}} = read(mention) + + following = &(&1 == thread) + + assert {:post, _, %{conversation: ^thread, text: "and tomorrow?"}} = + read(thread_reply, following) + + assert {:event, _, :not_addressed} = read(chatter, following) + end + + test "every numbered event advances the sequence, posts or not" do + seqs = + for frame <- FakeServer.fixtures()["frames"], + seq = + (case read(frame) do + {:post, seq, _} -> seq + {:event, seq, _} -> seq + {:hello, _, seq} -> seq + _ -> nil + end), + is_integer(seq), + do: seq + + assert seqs == Enum.sort(seqs) + assert seqs == Enum.to_list(0..12) + end + + test "before the server's facts are known, a post is not read against nothing" do + [dm_top | _] = FakeServer.frames("posted") + assert {:event, _, :no_facts_yet} = Events.read(dm_top, nil, fn _ -> false end) + end + + test "a frame that is not JSON, or not an event, is said to be so rather than raised on" do + assert {:unreadable, :not_json} = read("{") + assert {:event, nil, :not_an_event} = read(~s({"hello": 1})) + end + + test "a post carrying a malformed id is not read" do + [dm_top | _] = FakeServer.frames("posted") + decoded = Jason.decode!(dm_top) + post = decoded["data"]["post"] |> Jason.decode!() |> Map.put("channel_id", "../../x") + frame = put_in(decoded, ["data", "post"], Jason.encode!(post)) |> Jason.encode!() + assert {:event, _, :unreadable_post} = read(frame) + end +end diff --git a/test/trinity/gateways/mattermost/format_test.exs b/test/trinity/gateways/mattermost/format_test.exs new file mode 100644 index 0000000..e62786b --- /dev/null +++ b/test/trinity/gateways/mattermost/format_test.exs @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.FormatTest do + @moduledoc """ + Slice 072, AC2: posts are cut to the size the server advertises, as the server counts it. + + The property is the claim: for any text and any limit, no post is longer than the limit in code + points. The generator leans on what breaks a grapheme-counting chunker: code fences (the shared + chunker re-opens one after measuring) and graphemes of several code points. The end-to-end cases + read `MaxPostSize` from a server and show the cut following it. + """ + use Trinity.SessionCase + use ExUnitProperties + + import Trinity.Gateways.Mattermost.TestHelpers + + alias Trinity.Gateways.{Mattermost, Router} + alias Trinity.Gateways.Mattermost.{FakeServer, Format, State} + alias Trinity.LLM.Providers.Fake + + # A flag (two code points), a family (seven), a combining accent (two), and plain words. + @pieces ["word", "longerword", " ", "\n", "\n\n", "```", "```elixir\n", "πŸ‡ΊπŸ‡³", "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", "Γ©", "x"] + + defp text_gen do + @pieces + |> StreamData.member_of() + |> StreamData.list_of(max_length: 400) + |> StreamData.map(&Enum.join/1) + end + + defp caps(max), + do: %{markdown: true, images: false, buttons: false, edits: true, max_length: max} + + property "no post is longer than the limit, in code points, whatever the text" do + check all(text <- text_gen(), max <- StreamData.integer(12..300)) do + for post <- Format.format(text, caps(max)) do + assert Format.codepoints(post) <= max, + "#{Format.codepoints(post)} code points against a limit of #{max}: #{inspect(post)}" + end + end + end + + property "a cut never splits a grapheme unless the grapheme alone is over the limit" do + check all(text <- text_gen(), max <- StreamData.integer(12..300)) do + for post <- Format.format(text, caps(max)) do + assert String.valid?(post) + end + end + end + + test "a single grapheme longer than the limit is the one case cut at code points" do + family = "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦" + assert Format.codepoints(family) == 7 + assert Format.fit(family, 3) |> Enum.all?(&(Format.codepoints(&1) <= 3)) + assert Format.fit(family, 3) |> Enum.join() == family + end + + test "an approval's text names the command a Mattermost user can type" do + approval = %Trinity.Permissions.Approval{ + id: "0123456789abcdef", + tool: "write_note", + risk: "write", + args: %{"path" => "notes/a.md"} + } + + {:message, text} = Format.render_approval(approval, caps(4_000)) + assert text =~ "/trinity approve 01234567" + assert text =~ "/trinity deny 01234567" + refute text =~ "with /approve" + end + + describe "the server's own limit" do + setup do + start_supervised!(Router) + pair_tester!() + :ok + end + + defp reply_to_dm(server, text) do + Fake.scripts([ + [{:text_delta, text}, {:usage, %{input_tokens: 1, output_tokens: 1}}, {:done, :stop}] + ]) + + FakeServer.push(server, hd(FakeServer.frames("posted"))) + end + + test "AC2: a server advertising 120 gets posts of at most 120 code points, and the whole answer" do + server = start_adapter!(max_post_size: "120") + assert State.max_post_size() == 120 + assert Mattermost.capabilities().max_length == 120 + + answer = Enum.map_join(1..60, " ", &"word#{&1}") + reply_to_dm(server, answer) + await_shown(server, "word60") + + posts = server |> FakeServer.created() |> Enum.map(& &1["message"]) + assert length(posts) > 1 + assert Enum.all?(posts, &(Format.codepoints(&1) <= 120)) + + # And every post as it stands after the stream's edits. + assert server + |> FakeServer.posts() + |> Map.values() + |> Enum.all?(&(Format.codepoints(&1) <= 120)) + + # Nothing was lost to the cut: every word arrived, in order. + words = server |> FakeServer.posts() |> Map.values() |> Enum.join(" ") |> String.split() + assert Enum.filter(words, &String.starts_with?(&1, "word")) |> length() == 60 + end + + test "AC2: the same answer to a server advertising 16383 is one post" do + server = start_adapter!(max_post_size: "16383") + assert Mattermost.capabilities().max_length == 16_383 + + reply_to_dm(server, Enum.map_join(1..60, " ", &"word#{&1}")) + await_shown(server, "word60") + assert [_one] = FakeServer.created(server) + end + + test "the limit is not a constant of this module: it is whatever the server last said" do + refute State.fallback_max_post_size() in [120, 16_383] + server = start_adapter!(max_post_size: "777") + assert Mattermost.capabilities().max_length == 777 + + assert FakeServer.requests(server) + |> Enum.any?(&match?({"GET", "/api/v4/config/client", _}, &1)) + end + end +end diff --git a/test/trinity/gateways/mattermost/regulated_boot_test.exs b/test/trinity/gateways/mattermost/regulated_boot_test.exs new file mode 100644 index 0000000..dac1e1a --- /dev/null +++ b/test/trinity/gateways/mattermost/regulated_boot_test.exs @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.RegulatedBootTest do + @moduledoc """ + Slice 072, AC4: a regulated node **refuses to boot** with the Mattermost adapter configured + unless it is named on `TRINITY_REGULATED_GATEWAYS`. The check is the existing one + (`Trinity.Profile.check_gateways/3`, asked by `Trinity.Application` before any child starts); + `test/trinity/regulated_boot_node_test.exs` case 7 asserts it for `Console`. This asserts it for + this adapter, configured the way an operator configures it (named by `TRINITY_GATEWAYS` from the + `available:` list, its server in `config :trinity, :mattermost`), in a boot rather than in a + function call. + + Each case is a separate OS process, isolated as that file's are (`XDG_DATA_HOME`, + `Trinity.BootIsolation`, `MIX_ENV=test`), and asserts on the one line the child printed. Three + cases: refused with no allow-list, refused with an allow-list that names another adapter, and + booted with it named, where the child also reports the adapter running under the gateway + supervisor, so the positive case is the adapter actually starting and not only the check passing. + """ + use ExUnit.Case, async: false + + @moduletag timeout: 300_000 + + @isolate """ + Trinity.BootIsolation.isolate!(System.fetch_env!("TRINITY_BOOT_TAG")) + """ + + # Everything else a regulated node needs, as case 4 of the generic file sets it, so the gateway + # allow-list is the only thing that differs between these cases. + @regulated """ + defmodule RegulatedBootFixture do + @behaviour Trinity.Authority + def stage(staged, _ctx), do: {:ok, staged} + def decide(_staged, decision, _ctx), do: {:ok, decision, "fixture"} + def execute(_staged, _decision, _ctx), do: {:error, :not_used} + def receipt(_kind, _attrs), do: {:error, :not_used} + end + Application.put_env(:trinity, :mcp_auth, profile: :production) + llm = Application.get_env(:trinity, :llm, []) + Application.put_env(:trinity, :llm, Keyword.put(llm, :models, [ + %{id: "allowed", provider: :req_llm, model: "openai:x", + base_url: "https://models.internal/v1", api_key_env: "NONE", + caps: [:stream], price: %{input: 0.0, output: 0.0}} + ])) + # The adapter as an operator configures it: a server that is not there, and a token variable + # that is not set. Neither may stop a boot the profile admits. + Application.put_env(:trinity, :gateways, + available: [Trinity.Gateways.Console, Trinity.Gateways.Mattermost], enabled: ["mattermost"]) + Application.put_env(:trinity, :mattermost, + url: "http://127.0.0.1:9", token_env: "MATTERMOST_BOOT_TEST_UNSET") + """ + + @report """ + case Application.ensure_all_started(:trinity) do + {:ok, _} -> + running = is_pid(Process.whereis(Trinity.Gateways.Mattermost)) + under = Supervisor.which_children(Trinity.Gateways.Supervisor) |> Enum.map(&elem(&1, 0)) + IO.puts("BOOT_OK mattermost_running=" <> inspect(running) <> " under=" <> inspect(under)) + System.halt(0) + + {:error, reason} -> + IO.puts("BOOT_REFUSED " <> inspect(reason, limit: :infinity, printable_limit: :infinity)) + System.halt(3) + end + """ + + defp boot(name, allow_list) do + tag = Trinity.BootIsolation.tag(name) + on_exit(fn -> Trinity.BootIsolation.drop!(tag) end) + root = Path.join(System.tmp_dir!(), "mmboot-#{name}-#{System.unique_integer([:positive])}") + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf(root) end) + + env = [ + {"MIX_ENV", "test"}, + {"XDG_DATA_HOME", root}, + {"TRINITY_BOOT_TAG", tag}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_AUTHORITY", "RegulatedBootFixture"}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"}, + {"TRINITY_MCP_AUTH_PROFILE", nil}, + {"TRINITY_REGULATED_GATEWAYS", allow_list} + ] + + {out, status} = + System.cmd("mix", ["run", "--no-start", "-e", @isolate <> @regulated <> @report], + env: env, + stderr_to_stdout: true + ) + + line = + out + |> String.split("\n") + |> Enum.find( + "", + &(String.starts_with?(&1, "BOOT_OK") or String.starts_with?(&1, "BOOT_REFUSED")) + ) + + {line, status, out} + end + + test "AC4: with Mattermost configured and no allow-list, a regulated node refuses to boot" do + {line, status, out} = boot("mm_unset", nil) + assert status == 3, "the node started:\n#{String.slice(out, -3000, 3000)}" + assert line =~ "regulated_gateways_unset" + assert line =~ "TRINITY_REGULATED_GATEWAYS" + end + + test "AC4: an allow-list naming another adapter does not admit Mattermost, and says which" do + {line, status, out} = boot("mm_other", "Trinity.Gateways.Console") + assert status == 3, "the node started:\n#{String.slice(out, -3000, 3000)}" + assert line =~ "regulated_gateway_not_allowed" + assert line =~ "Trinity.Gateways.Mattermost" + end + + test "AC4: named on the allow-list, the node boots and the adapter runs under the gateway supervisor" do + {line, status, out} = boot("mm_named", "Trinity.Gateways.Mattermost") + + assert status == 0, """ + the allow-list named the adapter and the node still refused. If this fails while the two + above pass, the profile refuses Mattermost outright rather than an unnamed gateway. + + #{String.slice(out, -4000, 4000)} + """ + + assert line =~ "mattermost_running=true" + assert line =~ "Trinity.Gateways.Mattermost" + assert line =~ "Trinity.Gateways.Router" + end +end diff --git a/test/trinity/gateways/mattermost/secrets_test.exs b/test/trinity/gateways/mattermost/secrets_test.exs new file mode 100644 index 0000000..5d7d091 --- /dev/null +++ b/test/trinity/gateways/mattermost/secrets_test.exs @@ -0,0 +1,272 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.SecretsTest do + @moduledoc """ + Slice 072, AC6: the bot token is read through `Trinity.Config.secret/1` and appears in no log + line, on a success path and on every error path the adapter has: the server refusing the token + at the upgrade and at the REST API, the token missing, a dialog that will not open, and the socket + process crashing with the token in its connection. + + Every case captures at `:debug`, with the logger's own level lowered for the duration, so a + line the adapter emits below the suite's usual level is read too. Each case also asserts that + the path it names was taken (its own log line is there), because "nothing was logged" passes + just as well when nothing happened. And each asserts that the redaction filter masked nothing: + the claim is that the adapter never hands the token to the logger, not that the filter caught + it when it did. + """ + use Trinity.SessionCase + + import ExUnit.CaptureLog + import Phoenix.ConnTest + import Trinity.Gateways.Mattermost.TestHelpers + + alias Trinity.Gateways.{Mattermost, Router} + alias Trinity.Gateways.Mattermost.{FakeServer, LogTap, Signing, Socket, State} + alias Trinity.LLM.Providers.Fake + + @endpoint TrinityWeb.Endpoint + + setup do + level = Logger.level() + Logger.configure(level: :debug) + on_exit(fn -> Logger.configure(level: level) end) + :ok + end + + # What `capture_log/1` sees, and what the tap sees beside it (SASL reports included). + defp logged(fun) do + tap = LogTap.attach(self()) + captured = capture_log(fun) + Process.sleep(50) + captured <> LogTap.read(tap) + end + + defp assert_clean(log, token) do + refute log =~ token, "the token is in the log:\n#{log}" + refute log =~ "[REDACTED", "the redaction filter had to mask something:\n#{log}" + end + + test "AC6: the token is read through Trinity.Config.secret/1" do + {env, token} = token!() + server = FakeServer.start!(token) + start_supervised!({Mattermost, url: server.url, token_env: env, backoff_ms: 20}) + assert Trinity.Gateways.Mattermost.Client.token() == Trinity.Config.secret(env) + assert {:ok, ^token} = Trinity.Config.secret(env) + end + + test "AC6, success path: a message in and a streamed reply out, and the token is in no line" do + start_supervised!(Router) + pair_tester!() + + tap = LogTap.attach(self()) + + {server, log} = + with_log(fn -> + server = start_adapter!() + + Fake.scripts([ + [ + {:text_delta, "fine "}, + {:sleep, 800}, + {:text_delta, "thanks"}, + {:usage, %{input_tokens: 1, output_tokens: 1}}, + {:done, :stop} + ] + ]) + + FakeServer.push(server, hd(FakeServer.frames("posted"))) + await_shown(server, "fine thanks") + server + end) + + log = log <> LogTap.read(tap) + assert log =~ "mattermost: connected" + assert_clean(log, server.token) + end + + test "AC6, error path: the server refuses the token at the upgrade" do + {env, token} = token!() + # The server expects a different token, so the adapter's is refused everywhere. + server = FakeServer.start!(FakeServer.random_id()) + + log = + logged(fn -> + start_supervised!( + {Mattermost, url: server.url, token_env: env, backoff_ms: 20, max_backoff_ms: 40} + ) + + await( + fn -> length(FakeServer.connects(server)) >= 2 end, + "a refused upgrade, and a retry" + ) + end) + + assert log =~ "the server answered the upgrade with 401" + assert_clean(log, token) + end + + test "AC6, error path: the upgrade is accepted but the REST API refuses the token" do + {env, token} = token!() + server = FakeServer.start!(token) + + start_supervised!( + {Mattermost, url: server.url, token_env: env, backoff_ms: 20, max_backoff_ms: 40} + ) + + await(fn -> State.facts() end, "the first connection") + + # The token changes under the adapter: the socket's next connection is refused by REST too. + {_other_env, other} = token!() + System.put_env(env, other) + + log = + logged(fn -> + FakeServer.drop_sockets(server) + + await( + fn -> length(FakeServer.connects(server)) >= 3 end, + "reconnects with the new token", + 15_000 + ) + end) + + assert log =~ "401" + assert_clean(log, token) + assert_clean(log, other) + end + + test "AC6, error path: no token is set, and the variable is named, not a value" do + env = "MATTERMOST_TEST_TOKEN_UNSET_#{System.unique_integer([:positive])}" + System.delete_env(env) + server = FakeServer.start!(FakeServer.random_id()) + + log = + logged(fn -> + start_supervised!( + {Mattermost, url: server.url, token_env: env, backoff_ms: 20, max_backoff_ms: 40} + ) + + await(fn -> length(FakeServer.connects(server)) >= 2 end, "the retries", 15_000) + end) + + assert log =~ "mattermost:" + refute log =~ "[REDACTED" + end + + # This tree logs no SASL report: Elixir's primary `logger_translator` filter carries + # `sasl: false` (`handle_sasl_reports` is unset), so a crash report from a process that is not a + # GenServer reaches no handler at all, and a test reading the log would pass by reading nothing. + # An operator can turn them on. So for this case they are on, in the filter's own place in the + # primary list (the redaction filter stays after it, as it is in a running node). + defp with_sasl_reports do + %{filters: filters} = :logger.get_primary_config() + + on = + Enum.map(filters, fn + {:logger_translator, {fun, config}} -> {:logger_translator, {fun, %{config | sasl: true}}} + other -> other + end) + + :ok = :logger.update_primary_config(%{filters: on}) + on_exit(fn -> :logger.update_primary_config(%{filters: filters}) end) + end + + test "AC6, error path: the socket crashes with the token in its connection" do + with_sasl_reports() + server = start_adapter!() + old = Process.whereis(Socket) + + log = + logged(fn -> + # `use WebSockex`'s default `handle_cast/2` raises: a crash inside the process that holds + # the connection struct, with its crash report written. + WebSockex.cast(Socket, :no_such_cast) + await(fn -> (pid = Process.whereis(Socket)) && pid != old end, "the socket to restart") + await(fn -> length(FakeServer.connects(server)) >= 2 end, "the reconnect", 15_000) + Process.sleep(100) + end) + + # The crash report itself was read, not just the reconnect. + assert log =~ "No handle_cast/2 clause" + assert log =~ "Process Trinity.Gateways.Mattermost.Socket" + assert log =~ "terminating" + assert_clean(log, server.token) + end + + test "AC6, error path: a dialog that will not open is logged without the request" do + {command_env, _} = token!() + server = start_adapter!(callback_url: "http://127.0.0.1:4072", command_token_env: command_env) + previous = Application.get_env(:trinity, :gateways) + Application.put_env(:trinity, :gateways, adapters: [Mattermost]) + + on_exit(fn -> + if previous, + do: Application.put_env(:trinity, :gateways, previous), + else: Application.delete_env(:trinity, :gateways) + end) + + token = + Signing.button( + [{"Approve once", "/approve 01a11aa0-0000-7000-8000-000000000000"}], + "rebfe39yetygpyn11ma3gbairy", + "Approval needed" + ) + + # The server will now refuse the adapter's token, so opening the dialog fails. + System.put_env(server.token_env, FakeServer.random_id()) + + log = + logged(fn -> + conn = + build_conn() + |> Plug.Conn.put_req_header("content-type", "application/json") + |> post( + "/gateways/callback/mattermost/action", + Jason.encode!(%{ + "user_id" => tester_id(), + "channel_id" => "rebfe39yetygpyn11ma3gbairy", + "trigger_id" => "t", + "context" => %{"token" => token} + }) + ) + + assert json_response(conn, 200)["ephemeral_text"] =~ "did not open" + end) + + assert log =~ "the dialog did not open: HTTP 401" + assert_clean(log, server.token) + end + + test "the slash command's token and its response URL are filtered from the request log" do + {command_env, command_token} = token!() + response_hook = FakeServer.random_id() + server = start_adapter!(command_token_env: command_env) + start_supervised!(Router) + previous = Application.get_env(:trinity, :gateways) + Application.put_env(:trinity, :gateways, adapters: [Mattermost]) + + on_exit(fn -> + if previous, + do: Application.put_env(:trinity, :gateways, previous), + else: Application.delete_env(:trinity, :gateways) + end) + + log = + logged(fn -> + build_conn() + |> post("/gateways/callback/mattermost/command", %{ + "token" => command_token, + "user_id" => tester_id(), + "channel_id" => "rebfe39yetygpyn11ma3gbairy", + "text" => "help", + "response_url" => "http://127.0.0.1:8065/hooks/commands/" <> response_hook + }) + end) + + assert log =~ "GatewayCallbackController" + assert log =~ "[FILTERED]" + refute log =~ command_token + refute log =~ response_hook + refute log =~ server.token + end +end diff --git a/test/trinity/gateways/mattermost/supervisor_test.exs b/test/trinity/gateways/mattermost/supervisor_test.exs new file mode 100644 index 0000000..568cb97 --- /dev/null +++ b/test/trinity/gateways/mattermost/supervisor_test.exs @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Mattermost.SupervisorTest do + @moduledoc """ + Slice 072, AC1, the supervision half: Mattermost is a configured module, run under + `Trinity.Gateways.Supervisor` (slice 071's), which is a child of the application's own tree. + Asserted on the running tree, not on a second one: the gateway supervisor is restarted in place + with Mattermost named the way an operator names it (`TRINITY_GATEWAYS=mattermost` becomes + `enabled: ["mattermost"]` over the `available:` list, and the server is + `config :trinity, :mattermost`), inspected, and restarted again with the suite's configuration. + """ + use ExUnit.Case, async: false + + import Trinity.Gateways.Mattermost.TestHelpers + + alias Trinity.Gateways + alias Trinity.Gateways.{Console, Mattermost} + alias Trinity.Gateways.Mattermost.FakeServer + + setup do + previous = + {Application.get_env(:trinity, :gateways), Application.get_env(:trinity, :mattermost)} + + on_exit(fn -> + {gateways, mattermost} = previous + restore(:gateways, gateways) + restore(:mattermost, mattermost) + restart_gateways() + end) + + :ok + end + + defp restore(key, nil), do: Application.delete_env(:trinity, key) + defp restore(key, value), do: Application.put_env(:trinity, key, value) + + defp restart_gateways do + :ok = Supervisor.terminate_child(Trinity.Supervisor, Gateways.Supervisor) + {:ok, _} = Supervisor.restart_child(Trinity.Supervisor, Gateways.Supervisor) + end + + defp ids(supervisor), do: supervisor |> Supervisor.which_children() |> Enum.map(&elem(&1, 0)) + + defp name_mattermost do + Application.put_env(:trinity, :gateways, + available: [Console, Mattermost], + enabled: ["mattermost"] + ) + end + + test "the build offers Mattermost and runs it only when it is named" do + {config, _} = Config.Reader.read_imports!("config/config.exs", env: :prod, target: :host) + gateways = config |> Keyword.fetch!(:trinity) |> Keyword.fetch!(:gateways) + assert Mattermost in Keyword.fetch!(gateways, :available) + refute Keyword.has_key?(gateways, :enabled) + end + + test "AC1: named, Mattermost runs under Trinity.Gateways.Supervisor and is restarted there" do + {env, token} = token!() + server = FakeServer.start!(token) + Application.put_env(:trinity, :mattermost, url: server.url, token_env: env, backoff_ms: 20) + name_mattermost() + restart_gateways() + + assert Gateways.adapters() == [Mattermost] + assert Enum.sort(ids(Gateways.Supervisor)) == Enum.sort([Gateways.Router, Mattermost]) + assert is_pid(Process.whereis(Mattermost)) + assert ids(Mattermost) |> Enum.sort() == [Mattermost.Socket, Mattermost.State] + + # It connects, its capabilities are the server's, and /gateways would say so. + await(fn -> Mattermost.State.facts() end, "the adapter to connect") + assert Mattermost.capabilities().max_length == 16_383 + await(fn -> Mattermost.status().state == :connected end, "the status to say connected") + assert Mattermost.status().detail =~ "as @trinity" + + # Killed, it is restarted by the gateway supervisor. + old = Process.whereis(Mattermost) + Process.exit(old, :kill) + await(fn -> (pid = Process.whereis(Mattermost)) && pid != old end, "a restart") + assert Mattermost in ids(Gateways.Supervisor) + end + + test "named with no server, it starts idle and says why, and the node is unaffected" do + Application.delete_env(:trinity, :mattermost) + name_mattermost() + restart_gateways() + + assert is_pid(Process.whereis(Mattermost)) + assert ids(Mattermost) == [Mattermost.State] + assert Mattermost.status() == %{state: :idle, detail: "idle: MATTERMOST_URL is not set"} + end +end diff --git a/test/trinity/telemetry/redaction_test.exs b/test/trinity/telemetry/redaction_test.exs index 5f26b4d..3409db1 100644 --- a/test/trinity/telemetry/redaction_test.exs +++ b/test/trinity/telemetry/redaction_test.exs @@ -52,6 +52,24 @@ defmodule Trinity.Telemetry.RedactionTest do end end + test "a Mattermost token where it is named, and an id of the same shape left alone" do + token = "mm072" <> String.duplicate("q", 21) + + for line <- [ + "MATTERMOST_BOT_TOKEN=#{token}", + "cookie MMAUTHTOKEN=#{token}; MMUSERID=x", + ~s({"seq":1,"action":"authentication_challenge","data":{"token":"#{token}"}}) + ] do + out = Redaction.redact(line) + refute out =~ token, "not masked: #{line} -> #{out}" + assert out =~ "[REDACTED" + end + + # The same shape as a post id is not a credential, and a log full of ids stays readable. + line = "mattermost: post #{token} handed to the router" + assert Redaction.redact(line) == line + end + test "a named secret in a key/value position" do for line <- [ "password: hunter2-and-then-some",