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",