diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 84e4a79..bff3900 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -258,6 +258,11 @@ jobs: # asserted: whether the XLA library loads in Burrito's ERTS is what they measure. grep -q '^TRINITY_SMOKE_VEC=ok:' smoke.log grep -E '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' smoke.log + # Slice 100, AC9: FTS5 and the model cache bind (exit 5 and 6 otherwise); the watcher line + # is recorded, because a polling fallback is the skills registry's documented behaviour. + grep -q '^TRINITY_SMOKE_FTS5=ok$' smoke.log + grep -q '^TRINITY_SMOKE_MODEL_CACHE=ok:' smoke.log + grep -E '^TRINITY_SMOKE_WATCHER=' smoke.log echo "--- ours, before ---"; cat ps-before.txt echo "--- ours, after ----"; cat ps-after.txt diff ps-before.txt ps-after.txt @@ -281,6 +286,14 @@ jobs: throw "the vector search did not run inside this binary (slice 032)" } Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' + # Slice 100, AC9, as on the other two. + if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_FTS5=ok$')) { + throw "FTS5 did not work inside this binary (slice 100)" + } + if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MODEL_CACHE=ok:')) { + throw "the model cache did not resolve inside this binary (slice 100)" + } + Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_WATCHER=' # AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped. # `bash` on every OS, Windows included: the job that never curled was the one gap in @@ -334,15 +347,24 @@ jobs: exit 1 fi elapsed=$(( $(now_ms) - start )) - code=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/") - echo "HTTP $code on port $PORT" + # A fresh data directory has no model key, so `/` answers 302 to `/setup` (slice 100, + # AC11): run 37743757349 failed all three OSes on that redirect. Follow it, and require + # that it lands on `/setup`, so a redirect anywhere else still fails here. + answer=$(curl -sS -L -o /dev/null -w '%{http_code} %{url_effective}' "http://127.0.0.1:$PORT/") + code=${answer%% *} + landed=${answer#* } + echo "HTTP $code on port $PORT at $landed" + case "$landed" in + "http://127.0.0.1:$PORT/" | "http://127.0.0.1:$PORT/setup") ;; + *) echo "GET / ended at $landed, neither / nor /setup" >&2; exit 1 ;; + esac echo "COLD_START_MS=$elapsed" | tee -a "$GITHUB_ENV" echo "### ${{ matrix.name }}: cold start to first HTTP 200: **${elapsed} ms**" >> "$GITHUB_STEP_SUMMARY" # A non-200 says nothing on its own: run 35518054546 (slice 013) printed "HTTP 500" # and nothing else, and the log that would have named the exception was never # read. The body and the tail of serve.log go to stderr before the step fails. if [ "$code" != "200" ]; then - echo "--- body of GET / ---" >&2; curl -sS "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2 + echo "--- body of GET / ---" >&2; curl -sS -L "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2 echo "--- serve.log ---" >&2; tail -60 serve.log >&2 fi test "$code" = "200" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5f19a..39a9690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,39 @@ 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/100`: Trinity as a desktop application + +The Tauri window now does what a desktop application is expected to do. A tray icon shows whether +Trinity is idle or working and how many approvals are waiting, and offers New session, Pause +gateways, Open data folder and Quit. An approval requested while the window is hidden or in the +background becomes an operating system notification, and clicking it brings the window up on that +approval. A global shortcut (Ctrl+Shift+Space by default, configurable) shows and hides the window; +a second launch brings the first one forward instead of starting another; Trinity can start at +login. + +Provider keys typed into Settings are stored in the operating system's keychain (the macOS Keychain, +the Windows Credential Manager, the Secret Service on Linux) and nowhere else: not in the database, +not in a settings file, not in a log. The shell binary itself is the keychain helper, so the key is +reachable from the moment Trinity starts. A key in the keychain takes precedence over the same name +in the environment, and with no keychain (a server, or `mix phx.server`) the environment works as +before. The receipt signing key moves into the keychain the same way, by rotation: a new key is +created there, the old one is marked retired with the moment it stopped being used, and receipts +signed before the move still verify. + +Quitting during a reply keeps what had arrived, marked as interrupted, and the conversation shows +that on the next launch. A machine with nothing configured now opens a short setup (a model and its +key, the data folder, the first project folders) instead of a chat that cannot answer. The packaged +binary's self-check reports three more things: full-text search, the file watcher, and the model +cache. + +Verified by the test suite (the keychain through a stand-in helper, the shell's channel through a +real socket, shutdown and startup through separate operating system processes) and on Linux by +driving the real shell on an isolated display with a real keyring. The window, tray, notification, +shortcut and second-launch behaviour on macOS and Windows have not been observed yet and are listed +for manual verification. + ## 2026-09-26 ### `slice/128`: the checks that guard attribution now say what they check diff --git a/config/config.exs b/config/config.exs index 7e20d42..2d4819d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -146,6 +146,11 @@ config :trinity, Oban, ), notifier: Oban.Notifiers.PG, queues: [agent_tasks: 1, memory: 2, maintenance: 1], + # Slice 100, AC7: on quit, a running job gets this long to finish before Oban stops it. Named + # rather than inherited (Oban's own default is the same 15 s) so a change upstream is a diff + # here; a job cut short is retried by the lifeline below, and a turn it was driving keeps what + # arrived through the sessions' interrupt path. + shutdown_grace_period: :timer.seconds(15), plugins: [ {Oban.Plugins.Cron, crontab: [ @@ -234,6 +239,11 @@ config :logger, :default_formatter, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# Slice 100: the Settings page and the setup path take provider keys in a form whose parameter is +# `secret`. Phoenix and LiveView log a request's or an event's parameters at debug level; anything +# whose name contains one of these is logged as "[FILTERED]". Phoenix's own default is the first. +config :phoenix, :filter_parameters, ["password", "secret"] + # 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" diff --git a/config/test.exs b/config/test.exs index c5df85c..f9c1319 100644 --- a/config/test.exs +++ b/config/test.exs @@ -310,3 +310,8 @@ config :trinity, :oban_web, true # counter-example found at 20,000 runs is reproducible with that seed at any width. config :stream_data, max_runs: String.to_integer(System.get_env("TRINITY_PROPERTY_RUNS") || "100") + +# Slice 100: the suite runs on no desktop shell, and its settings file lives under the project's +# ignored tmp/, never in the data directory of the machine running the tests. +config :trinity, :desktop, impl: Trinity.Desktop.Noop +config :trinity, Trinity.Settings, path: Path.expand("../tmp/test_settings.json", __DIR__) diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 7744b6c..d02cb57 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -58,6 +58,12 @@ Trinity.Application ├── Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080 ├── Trinity.Sandbox.Supervisor # Luerl workers. Slice 110 ├── Trinity.Desktop # ex_tauri bridge (tray, notifications). Slice 100 +│ # As built: `Trinity.Desktop.Shell`, after the sessions and +│ # before Oban; the tray, the notifications, the tray's +│ # actions, on the `Trinity.Desktop` implementation the +│ # process selected (`Tauri` when the shell launched it, +│ # `Noop` otherwise). `ExTauri.ShutdownManager` (the +│ # channel and heartbeat) is first in the tree outside :test └── TrinityWeb.Endpoint # LiveView. Slice 013 ``` @@ -95,7 +101,7 @@ without anything failing. | `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)) | 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 | +| `Trinity.Desktop` | ex_tauri bridge (as built at 100: the behaviour with `Noop` and `Tauri`; `Shell`, the process that keeps the tray and decides notifications; commands to the Rust window over ex_tauri's channel, events back; `Trinity.Secrets`, `Trinity.Settings` and `Trinity.Setup` sit in `Trinity` itself because providers, tools and receipts read them) | PubSub (as built at 100: `Trinity`, Gateways, Sessions, Permissions, Scheduler: it counts approvals and busy sessions, creates a session, pauses the gateways, and notifies on a finished task) | | `Trinity.Telemetry` | The event catalogue and the only module that names an event (as built at 090: `span/3` and `emit/3` routing everything through one place; emitters for the LLM, tool, approval, session, gateway and budget events listed in `docs/telemetry.md`, which was written **before** them because an event name is an interface and a handler attached to a name that no longer fires simply never runs; `trace/2` giving a turn one trace id so its events nest, with no tracing library and no dependency; `Telemetry.Costs` reading `usage_events` schemalessly, because `Trinity.LLM` depends on `Trinity` and naming its schema would be a cycle; `Telemetry.Activity`, a bounded buffer whose handler does one `send` because a telemetry handler runs in the emitting process; `Telemetry.Redaction`, a primary Logger filter that masks shapes rather than names and never drops a line) | Repo | | `TrinityWeb` | LiveViews, components, API | all `Trinity.*` public APIs | @@ -173,7 +179,10 @@ development under `TRINITY_FAKE_PROVIDER=1`. is size 1 for writes; reads may use a second pool). FTS5 virtual table for message search. `sqlite_vec` for vectors. - Secondary: **Postgres + pgvector**, selected by `TRINITY_DB=postgres`. Enables Oban Pro Workflows later. Kept compiling and tested in CI (matrix), not default. -- Durable settings/secrets: OS keychain via `Trinity.Secrets` (Slice 100); before that, env vars. +- Durable settings/secrets: OS keychain via `Trinity.Secrets` (Slice 100); before that, env vars. As built at + 100: secrets in the keychain (through the shell binary's `--keychain` mode), the environment as the fallback; + non-secret desktop settings (hotkey, notifications, launch at login, chosen folders, the setup's default model) + in `/settings.json` (`Trinity.Settings`), mode 0600. ## Concurrency and state rules @@ -194,5 +203,6 @@ priv/personas/ default SOUL.md test/support/ Mox definitions, factories, fake tools/providers docs/ architecture, data model, security model, standards, ADRs docs/ this plan's docs and ADRs -tauri/ desktop shell (Slice 001+) +tauri/ desktop shell (Slice 001+; as built, `src-tauri/`, the generator's name: main.rs + and keychain.rs, slice 100) ``` diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 678ba2c..3a7aa42 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -285,6 +285,60 @@ regulated effect. - Never in DB, never in logs, never in prompts. `mix gate` runs a regex secret scan on the diff. - Provider keys are read at call time by the provider module; not held in Session state. +**As built at slice 100.** The first line above named two candidates; neither was chosen. + +- **Where.** `Trinity.Secrets` asks the OS keychain first and the environment second, by name (an + environment-variable name such as `OPENROUTER_API_KEY`); `Trinity.Config.secret/1`, the one reader + providers use, goes through it. The keychain is reached through the Tauri shell's own binary run as + a command, `trinity --keychain probe | get | set | delete `, whose `keyring` crate uses the + macOS Keychain, the Windows Credential Manager or the Secret Service. The shell hands the sidecar + its path as `TRINITY_KEYCHAIN_HELPER`. A command rather than the window's channel because the + channel exists only once the window has attached, and the receipt signer needs its key before + that; a command rather than a NIF because the Rust is already in the shell. +- **How a value travels.** On the helper's stdin (set) or stdout (get), hex-encoded, one line; never + on argv, where any process on the machine could read it with `ps`. The helper reports a failure by + kind and never prints a value (`keyring`'s encoding error carries the stored bytes, so its form is + never printed). +- **Never anywhere else.** `store/2` with no keychain is `{:error, :keychain_unavailable}`, not a + file. The Settings page and the setup path submit the key as a parameter named `secret`, which + `config :phoenix, :filter_parameters` logs as `[FILTERED]`, and never render it back. + `Trinity.Settings` (the non-secret settings file) refuses an unknown key and any string shaped like + key material. Tests scan the database files, the settings file and the captured log for the value; + the Linux run scans the data, config, cache and runtime directories and greps the database with + `strings`. +- **The receipt signing key** moves into the keychain by rotation when the keychain first answers: + a new key whose private half is stored there and read back before anything changes, a registry row + for it (`custody: keychain`), a `retired` row for the old key with `valid_until` and + `superseded_by`, the old key file kept (renamed, mode 0600), and the boot receipt naming the + custody. The verifier refuses a retired key's receipt dated after its `valid_until`. A process + with no helper (a `mix phx.server` on a data directory the desktop app has migrated) cannot sign + and denies every effect, the fail-closed shape of slice 024; `TRINITY_KEYCHAIN_HELPER` pointed at + a built shell binary restores it. An export made with the private key does not carry a + keychain-held key; that is a follow-up of slice 034's backup story. +- **Not yet in the keychain:** the MCP OAuth client tokens (`/secrets/oauth/`, slice 062) + and the MCP server's token and state key (slice 061). They stay files, mode 0600, until a slice + moves them. + +## The desktop shell's channel (slice 100) + +The Rust window and the BEAM share the channel `ex_tauri` keeps for its heartbeat: a Unix socket in +the temporary directory, mode 0600, on macOS and Linux, and a loopback TCP port whose number is in a +file on Windows (slice 001 finding F2: any local process can connect there). The shell generates a +token per launch, hands it to the BEAM as `TRINITY_SHELL_TOKEN`, and presents it in a `hello` on +every connection; `Trinity.Desktop.Shell` compares it in constant time and, until it has matched, +ignores tray clicks and dialog answers and refuses requests. A peer that is not the shell therefore +cannot quit Trinity through the tray, open a folder, or put a folder of its choosing into the +filesystem allowlist. Window focus is believed from anyone; the worst it does is cause or suppress a +notification. **What remains open:** any peer can still connect and then stop sending heartbeats, +which shuts the BEAM down (the denial of service F2 names), and `ex_tauri` 0.2.0 does not tell +subscribers when a peer disconnects, so trust lasts until the next hello rather than until the +connection ends. Closing those needs a change to the heartbeat itself. + +**What leaves the machine, or the app, through the shell.** OS notifications are shown and kept by +the operating system's notification centre, so they carry the tool's name, the task page or the +channel's name, and nothing else: no tool arguments, no message text, no result. A notification's +click can send the window only to a path of this app, never to a URL. + ## Gateways (Slice 070, as built) - **An unknown sender gets a pairing prompt and nothing else.** No session is created, no model is diff --git a/docs/adr/0004-desktop-shell-ex-tauri.md b/docs/adr/0004-desktop-shell-ex-tauri.md index ddfb28f..fbbaff9 100644 --- a/docs/adr/0004-desktop-shell-ex-tauri.md +++ b/docs/adr/0004-desktop-shell-ex-tauri.md @@ -184,3 +184,42 @@ appears in a mark or a count. **It may retire AC2 and AC3 without a machine**, and it is the first thing to try before waiting for one. It is not slice 001's work: this ADR records the correction and the manual queue carries the step. + +--- + +## Fourth correction, 2026-10-08: slice 100. **Still `proposed`; the lift condition is unchanged.** + +Slice 100's Definition of Done lists "ADR-0004 status accepted". It is not, and the reason is the one +the second correction gave: this ADR chooses a cross-platform shell, and the shell has still been +*run* on one operating system. Stamping `accepted` because the slice that was supposed to end it has +ended would be a mark doing work the facts do not support. + +### What slice 100 adds to the evidence + +* **The shell does the desktop's work on Linux**, driven end to end on an isolated X display (Xvfb, + no window manager) with a private D-Bus session, a real Secret Service, and stand-ins for the + notification daemon and the tray host: the tray menu reads and acts, a notification's click shows + the window on the approval, the global shortcut hides and shows the window, a second launch + focuses the first, launch at login writes its entry, the keychain stores and returns a key, and + quitting during a reply keeps it as interrupted. The evidence and what it does not prove (no real + panel, no real notification daemon, no compositor) are in the slice's proof. +* **Everything it needed fits the chosen shell without a NIF and without leaving `ex_tauri`.** The + window's commands use the channel `ex_tauri` already keeps; the keychain is the shell binary run + as a command (`trinity --keychain`); Tauri's own plugins do the rest. The fallback path remains + untriggered. +* **Finding F1** (the packaged sidecar orphaned when its parent dies): the shell now sends SIGTERM to + the sidecar's `beam.smp` child as well as to the Burrito wrapper, and OTP's SIGTERM is a graceful + stop. Measured on the development path, where the sidecar is the BEAM itself: quit from the tray + left no process of the run behind. The production shape (the Burrito wrapper with its child) is + the first package run's to measure on each OS. +* **Finding F2** (the Windows channel is an unauthenticated loopback port): the shell presents a + per-launch token in a hello, and Trinity believes tray clicks and dialog answers only after it. + The denial of service F2 names (connect, then stop the heartbeat) is not closed; it needs a change + to the heartbeat itself. + +### Lift condition, unchanged from the second correction + +**Owner: Ayla Croft.** Confirmed or amended when the shell has opened a window on macOS and on +Windows, and the sidecar has been observed dying with its window on at least one of them by a +mechanism this project owns. Slice 100's manual queue carries both, and the first `package` run of +the slice's branch builds the shell on all three. diff --git a/docs/packaging.md b/docs/packaging.md index cf66b7f..514d6fe 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -75,6 +75,49 @@ through the vector store in force on the binary's own database, rolled back (`TR binding: exit 4), and the semantic tier's status (`TRINITY_SMOKE_SEMANTIC`, recorded). Slice 032's PROOF.md AC7 has each bundle's answers. +As built at slice 100, three more lines follow: `TRINITY_SMOKE_FTS5` (an in-memory SQLite, a +virtual table, one `MATCH`; binding, exit 5), `TRINITY_SMOKE_WATCHER` (a watcher with the skills +registry's own options on a fresh directory, one file written, the event awaited; `ok:`, +or `fallback:fs_poll:` when only polling delivered it, which is the registry's documented +fallback; recorded), and `TRINITY_SMOKE_MODEL_CACHE` (the local embedder's cache directory resolves +and is writable, created if absent and otherwise only looked at; binding, exit 6). `sqlite_vec`, +which the slice's criterion also names, is not in the bundle: slice 032 measured it incompatible +with the pinned `nx` and chose the Elixir scorer, which `TRINITY_SMOKE_VEC` already exercises. + +## The desktop shell (slice 100) + +`src-tauri/` is the Tauri shell. It starts the sidecar (the Burrito binary, or in development a +script running `mix phx.server`) with these in its environment, beside `PORT` and +`SECRET_KEY_BASE`: + +| Variable | What it is | +|---|---| +| `TRINITY_SHELL_TOKEN` | 32 random bytes per launch, hex. Its presence is how the BEAM knows a shell launched it (and selects `Trinity.Desktop.Tauri`); the shell presents it in a `hello` on the channel so Trinity believes this peer and no other (docs/07, the desktop shell's channel). | +| `TRINITY_KEYCHAIN_HELPER` | The shell binary's own path. `Trinity.Secrets.Keychain` runs it as `trinity --keychain probe \| get \| set \| delete `, the value hex on stdin or stdout. | + +The keychain mode returns before anything of the app exists, so it needs no display: + +``` +src-tauri/target/debug/trinity --keychain probe ; echo "exit=$?" # 0 when the keychain answers +``` + +**A data directory the desktop app has used needs the helper.** The receipt signing key moves into +the keychain the first time the keychain answers, so a later `mix phx.server` on the same data +directory with no helper cannot sign, and every effect is denied (slice 024's fail-closed shape). +Point `TRINITY_KEYCHAIN_HELPER` at a built shell binary, or use a separate data directory +(`XDG_DATA_HOME` on Linux). + +The plugins it adds (`tauri-plugin-global-shortcut`, `-autostart`, `-dialog`, `-opener`) are each +on the newest line whose release still accepts tauri 2.11; their next minors require tauri 2.12, +and the shell's tauri is held at 2.11.5 by `~` requirements in `src-tauri/Cargo.toml` rather than +moved in the same change. `keyring` 3.6.3 provides the keychain (on Linux through zbus with its own +executor and pure-Rust crypto, so no system library is added; `libdbus-1` was already linked by +`tao`). + +Linux launch-at-login writes `~/.config/autostart/Trinity.desktop` under `HOME`, whatever +`XDG_CONFIG_HOME` says, and fails if `~/.config` does not exist (the `auto-launch` crate creates one +directory, not the path); the failure comes back to Trinity as an `error` event and is logged. + ## Measurements: linux x86_64 only Machine: Linux 6.14.0-37-generic, x86_64. **These figures are for this host and this target.** diff --git a/lib/trinity.ex b/lib/trinity.ex index ff1fa58..71e9864 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -41,6 +41,16 @@ defmodule Trinity do Profile, UUID, Config, + # Slice 100: the keychain-first secrets, the non-secret desktop settings, the first-run + # check, and the desktop seam (a boundary of its own under this one, as `Gateways` is). + Secrets, + Secrets.Keychain, + Settings, + Setup, + Desktop, + Desktop.Noop, + Desktop.Tauri, + Desktop.Shell, Sessions, Sessions.Message, Sessions.SessionRow, diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 59e47f3..42a01dc 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -86,6 +86,10 @@ defmodule Trinity.Application do Trinity.MCP.Server.Replay, Trinity.MCP.Boot, Trinity.Sessions.Supervisor, + # Slice 100: the tray, the notifications and the tray's actions, on whichever + # `Trinity.Desktop` implementation this process selected (`Noop` with no shell). After + # the sessions it counts and creates, before Oban, so it stops first. + Trinity.Desktop.Shell, # Slice 050: Oban after the sessions its workers drive (a run is a turn in a session), # the engine chosen by the adapter (config.exs). {Oban, Application.fetch_env!(:trinity, Oban)} @@ -185,6 +189,21 @@ defmodule Trinity.Application do end end + # Slice 100, AC7: quitting during a turn keeps what arrived. Called by OTP before any child is + # stopped, so every session can still write its row through a running Repo; the supervisor's + # own shutdown (`Session.terminate/3`) is the backstop for one that does not answer here. Oban, + # which stops before the sessions, drains for `shutdown_grace_period` (config/config.exs). + @impl true + def prep_stop(state) do + try do + _ = Trinity.Sessions.interrupt_all(:shutdown) + catch + :exit, _ -> :ok + end + + state + end + # Tell Phoenix to update the endpoint configuration # whenever the application is updated. @impl true diff --git a/lib/trinity/config.ex b/lib/trinity/config.ex index 284ff89..dc838cc 100644 --- a/lib/trinity/config.ex +++ b/lib/trinity/config.ex @@ -2,16 +2,17 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.Config do @moduledoc """ - Secrets, read from one place. Slice 011: the environment. Slice 100 adds the OS keychain - behind the same function, and nothing else in the tree reads a key any other way. + Secrets, read from one place. Slice 011: the environment. Slice 100: the OS keychain first and + the environment second, through `Trinity.Secrets`; nothing else in the tree reads a key any + other way. """ @doc "The secret named by `env_var`, or a named error; never nil handed to a provider." @spec secret(String.t()) :: {:ok, String.t()} | {:error, {:missing_secret, String.t()}} def secret(env_var) when is_binary(env_var) do - case System.get_env(env_var) do - value when is_binary(value) and value != "" -> {:ok, value} - _ -> {:error, {:missing_secret, env_var}} + case Trinity.Secrets.fetch(env_var) do + {:ok, value} -> {:ok, value} + {:error, _} -> {:error, {:missing_secret, env_var}} end end end diff --git a/lib/trinity/desktop.ex b/lib/trinity/desktop.ex new file mode 100644 index 0000000..c9fabbe --- /dev/null +++ b/lib/trinity/desktop.ex @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Desktop do + @moduledoc """ + The seam between Trinity and the window it is shown in (slice 100; ADR-0004's consequence "all + desktop OS integration goes through `Trinity.Desktop`"). + + Two implementations ship: + + * `Trinity.Desktop.Tauri`: commands to the Tauri shell over the channel `ex_tauri` keeps + between the Rust window and this BEAM (the heartbeat's socket), and events back. + * `Trinity.Desktop.Noop`: no shell. Every call answers `{:error, :no_shell}`, so a caller can + tell "there is no window" from "the window said no". Headless, CI and plain + `mix phx.server` run on it (AC10). + + The implementation is chosen from the process's environment unless configuration pins one + (`config :trinity, :desktop, impl:`; the suite pins `Noop`): `Tauri` when the shell launched + this process, which it says by setting `TRINITY_SHELL_TOKEN`, and `Noop` otherwise and always + under `TRINITY_MODE=headless`. + + `Trinity.Desktop.Shell` is the process that uses this seam on Trinity's behalf: the tray, the + notifications, the tray's actions. Other callers (the Settings page) call the functions below. + + ## The callbacks + + The five the slice names (`notify/2`, `tray_update/1`, `open_dialog/1`, `show_window/0`, + `quit/0`), and five the slice's scope needs: `focus_path/1` (show the window on a page, for a + notification's click and New session), `open_path/1` (Open data folder), `set_hotkey/1`, + `set_autostart/1`, and `available?/0`. + """ + use Boundary, + deps: [Trinity, Trinity.Gateways], + exports: [Noop, Tauri, Shell] + + @typedoc "What the tray shows (see `Trinity.Desktop.Tauri.tray_spec/1` for the menu it becomes)." + @type tray_status :: %{ + required(:status) => :idle | :thinking, + required(:pending) => non_neg_integer(), + optional(:thinking) => non_neg_integer(), + optional(:gateways_paused) => boolean() + } + + @typedoc "`:kind` (`:folder` is the one used), `:title`; implementations may take more." + @type dialog_opts :: keyword() + + @doc "Whether a shell is attached and listening." + @callback available?() :: boolean() + + @doc "An OS notification. Options: `:body`, `:path` (where a click takes the window)." + @callback notify(title :: String.t(), opts :: keyword()) :: :ok | {:error, term()} + + @doc "Replaces the tray's status and menu." + @callback tray_update(tray_status()) :: :ok | {:error, term()} + + @doc "A native file or folder dialog; the paths chosen, `[]` when cancelled." + @callback open_dialog(dialog_opts()) :: {:ok, [Path.t()]} | {:error, term()} + + @doc "Shows the window and gives it focus." + @callback show_window() :: :ok | {:error, term()} + + @doc "Shows the window on a page of this app (a path such as `/s/#approval-`)." + @callback focus_path(String.t()) :: :ok | {:error, term()} + + @doc "Opens a directory in the OS file manager." + @callback open_path(Path.t()) :: :ok | {:error, term()} + + @doc "Registers the global show/hide shortcut (an accelerator such as `CommandOrControl+Shift+Space`); `nil` removes it." + @callback set_hotkey(String.t() | nil) :: :ok | {:error, term()} + + @doc "Turns launch-at-login on or off." + @callback set_autostart(boolean()) :: :ok | {:error, term()} + + @doc "Quits the app: the shell stops this BEAM the graceful way and exits." + @callback quit() :: :ok | {:error, term()} + + @doc "The implementation in force." + @spec impl() :: module() + def impl do + Application.get_env(:trinity, :desktop, [])[:impl] || impl_for(System.get_env()) + end + + @doc """ + The implementation a process with environment `env` selects: `Tauri` when the shell launched + it (a non-empty `TRINITY_SHELL_TOKEN`) and it is not headless, else `Noop`. + """ + @spec impl_for(%{optional(String.t()) => String.t()}) :: module() + def impl_for(env) when is_map(env) do + shell? = Map.get(env, "TRINITY_SHELL_TOKEN", "") != "" + headless? = Map.get(env, "TRINITY_MODE") == "headless" + if shell? and not headless?, do: __MODULE__.Tauri, else: __MODULE__.Noop + end + + @doc "See the callback." + @spec available?() :: boolean() + def available?, do: impl().available?() + + @doc "See the callback." + @spec notify(String.t(), keyword()) :: :ok | {:error, term()} + def notify(title, opts \\ []), do: impl().notify(title, opts) + + @doc "See the callback." + @spec tray_update(tray_status()) :: :ok | {:error, term()} + def tray_update(status), do: impl().tray_update(status) + + @doc "See the callback." + @spec open_dialog(dialog_opts()) :: {:ok, [Path.t()]} | {:error, term()} + def open_dialog(opts \\ []), do: impl().open_dialog(opts) + + @doc "See the callback." + @spec show_window() :: :ok | {:error, term()} + def show_window, do: impl().show_window() + + @doc "See the callback." + @spec focus_path(String.t()) :: :ok | {:error, term()} + def focus_path(path), do: impl().focus_path(path) + + @doc "See the callback." + @spec open_path(Path.t()) :: :ok | {:error, term()} + def open_path(path), do: impl().open_path(path) + + @doc "See the callback." + @spec set_hotkey(String.t() | nil) :: :ok | {:error, term()} + def set_hotkey(accelerator), do: impl().set_hotkey(accelerator) + + @doc "See the callback." + @spec set_autostart(boolean()) :: :ok | {:error, term()} + def set_autostart(enabled), do: impl().set_autostart(enabled) + + @doc "See the callback." + @spec quit() :: :ok | {:error, term()} + def quit, do: impl().quit() + + @doc """ + Whether `accelerator` reads as a shortcut the shell's parser accepts: one or more modifiers and + one key, joined by `+`. Checked before it is saved, so a typo is refused on the page rather than + silently ignored by the shell. + """ + @spec valid_hotkey?(term()) :: boolean() + def valid_hotkey?(accelerator) when is_binary(accelerator) do + modifiers = + ~w(CommandOrControl CmdOrCtrl Command Cmd Control Ctrl Alt Option AltGr Shift Super Meta) + + case String.split(accelerator, "+") do + [_only] -> + false + + parts -> + {mods, [key]} = Enum.split(parts, -1) + + Enum.all?(mods, &(&1 in modifiers)) and + Regex.match?(~r/\A([A-Z0-9]|F([1-9]|1[0-9]|2[0-4])|Space|Enter|Tab|Escape)\z/, key) + end + end + + def valid_hotkey?(_), do: false +end diff --git a/lib/trinity/desktop/noop.ex b/lib/trinity/desktop/noop.ex new file mode 100644 index 0000000..c325b2a --- /dev/null +++ b/lib/trinity/desktop/noop.ex @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Desktop.Noop do + @moduledoc """ + No desktop shell (slice 100, AC10): headless, CI and plain `mix phx.server`. Every call answers + `{:error, :no_shell}` rather than `:ok`, so nothing above can mistake "there is no window" for + "the window did it". + """ + @behaviour Trinity.Desktop + + @impl true + def available?, do: false + + @impl true + def notify(_title, _opts), do: {:error, :no_shell} + + @impl true + def tray_update(_status), do: {:error, :no_shell} + + @impl true + def open_dialog(_opts), do: {:error, :no_shell} + + @impl true + def show_window, do: {:error, :no_shell} + + @impl true + def focus_path(_path), do: {:error, :no_shell} + + @impl true + def open_path(_path), do: {:error, :no_shell} + + @impl true + def set_hotkey(_accelerator), do: {:error, :no_shell} + + @impl true + def set_autostart(_enabled), do: {:error, :no_shell} + + @impl true + def quit, do: {:error, :no_shell} +end diff --git a/lib/trinity/desktop/shell.ex b/lib/trinity/desktop/shell.ex new file mode 100644 index 0000000..162df91 --- /dev/null +++ b/lib/trinity/desktop/shell.ex @@ -0,0 +1,364 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Desktop.Shell do + @moduledoc """ + The process between Trinity and its window (slice 100): it keeps the tray true, decides when + something deserves an OS notification, and carries out the tray's actions. + + ## What it listens to + + * `approvals:all` (slice 021): a request adds to the pending count, a decision or an expiry + takes one away; a request while the window is away is a notification whose click shows the + window on the approval's card, `/s/#approval-`. + * `[:trinity, :session, :transition]` (slice 090's catalogue): which sessions are busy, for + the tray's status. + * `[:trinity, :gateway, :inbound]` and the scheduler's `tasks` topic: a gateway message or a + finished scheduled task while the window is away is a notification too. + * the shell's events (`Trinity.Desktop.Tauri`): `hello`, `window`, `tray_menu_click`, + `dialog_result`. + + A notification names the tool, the task or the channel and nothing else: the OS shows it and + keeps it in its notification centre, so no argument, message text or result goes into one. + + ## Whom it believes (finding F2) + + On Windows the channel is a loopback TCP port any local process can connect to (slice 001 + NOTES, F2). The shell therefore starts by sending `hello` with the token it handed this BEAM in + `TRINITY_SHELL_TOKEN`, compared here in constant time. Until a hello has matched, tray clicks + and dialog results are ignored and requests are refused with `{:error, :untrusted_channel}`: a + peer that is not the shell cannot quit the app, open a folder, or put a folder of its choosing + into the filesystem allowlist. Window focus is believed from anyone; the worst it can do is + cause or suppress a notification. What this does not close is recorded in NOTES: ex_tauri 0.2.0 + does not tell subscribers when the peer disconnects, so trust lasts until the next hello rather + than until the connection ends, and any peer can still stop the heartbeat. + """ + use GenServer + + require Logger + + alias Trinity.{Desktop, Gateways, Permissions, Scheduler, Sessions, Settings} + + @busy [:thinking, :tool_wait, :approval_wait, :compacting] + @resubscribe_ms 1_000 + + @type status :: %{ + pending: non_neg_integer(), + thinking: non_neg_integer(), + focused: boolean(), + trusted: boolean(), + gateways_paused: boolean() + } + + ## API + + @doc """ + Starts the process. Options: `:name` (default this module), `:impl` (default + `Trinity.Desktop.impl/0`), `:token` (default `TRINITY_SHELL_TOKEN`), `:settings` (a settings file + path; default the one in force), `:data_dir` (what Open data folder opens). + """ + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []), + do: GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) + + @doc "What the process knows: counts, whether the window has focus, whether the shell is trusted." + @spec status(GenServer.server()) :: status() + def status(server \\ __MODULE__), do: GenServer.call(server, :status) + + @doc """ + Sends a command that the shell answers with an event carrying the same id (`open_dialog`), and + waits for the answer: `{:ok, paths}`, `{:error, :timeout}`, or `{:error, :untrusted_channel}` before + the shell has said hello. + """ + @spec request(GenServer.server(), String.t(), map(), timeout()) :: + {:ok, term()} | {:error, term()} + def request(server, name, payload, timeout) do + GenServer.call(server, {:request, name, payload, timeout}, timeout + 1_000) + catch + :exit, _ -> {:error, :no_shell_process} + end + + @doc false + # A telemetry handler runs in the emitting process, so it does one `send` and nothing else. + def handle_telemetry([:trinity, :session, :transition], _m, %{session_id: id, to: to}, pid), + do: send(pid, {:session_state, id, to}) + + def handle_telemetry([:trinity, :gateway, :inbound], _m, %{adapter: adapter, outcome: o}, pid), + do: send(pid, {:gateway_inbound, adapter, o}) + + def handle_telemetry(_event, _m, _meta, _pid), do: :ok + + ## GenServer + + @impl true + def init(opts) do + Process.flag(:trap_exit, true) + :ok = Permissions.subscribe(:all) + :ok = Scheduler.subscribe() + + :ok = + :telemetry.attach_many( + {__MODULE__, self()}, + [[:trinity, :session, :transition], [:trinity, :gateway, :inbound]], + &__MODULE__.handle_telemetry/4, + self() + ) + + state = %{ + impl: Keyword.get_lazy(opts, :impl, &Desktop.impl/0), + token: Keyword.get_lazy(opts, :token, fn -> System.get_env("TRINITY_SHELL_TOKEN") end), + settings: Keyword.take(opts, [:settings]) |> Enum.map(fn {:settings, p} -> {:path, p} end), + data_dir: Keyword.get_lazy(opts, :data_dir, &Trinity.Paths.data_dir/0), + pending: MapSet.new(), + busy: MapSet.new(), + focused: false, + trusted: false, + subscribed: false, + requests: %{}, + last_tray: nil + } + + {:ok, state, {:continue, :start}} + end + + @impl true + def handle_continue(:start, state) do + # Read in a short-lived Task, not in this process. Under the suite's SQL sandbox, still in + # auto mode while the application boots, the first query a long-lived process makes checks a + # connection out until that process exits; with the suite's pool of two, this process holding + # one starved Oban's boot-time migration check of the other (measured: the application failed + # to start after 90 s, "connection not available"). Outside the suite it costs one Task. + pending = + fn -> Permissions.pending(:all) |> MapSet.new(& &1.id) end + |> Task.async() + |> Task.await(15_000) + + state = %{state | pending: pending} |> subscribe_channel() + {:noreply, push_tray(state)} + end + + @impl true + def handle_call(:status, _from, state) do + {:reply, + %{ + pending: MapSet.size(state.pending), + thinking: MapSet.size(state.busy), + focused: state.focused, + trusted: state.trusted, + gateways_paused: Gateways.paused?() + }, state} + end + + def handle_call({:request, _name, _payload, _timeout}, _from, %{trusted: false} = state), + do: {:reply, {:error, :untrusted_channel}, state} + + def handle_call({:request, name, payload, timeout}, from, state) do + id = Base.url_encode64(:crypto.strong_rand_bytes(9), padding: false) + + case Desktop.Tauri.command(name, Map.put(payload, :id, id)) do + :ok -> + timer = Process.send_after(self(), {:request_timeout, id}, timeout) + {:noreply, put_in(state.requests[id], {from, timer})} + + {:error, _} = error -> + {:reply, error, state} + end + end + + @impl true + def handle_info({:approval, :requested, approval}, state) do + unless MapSet.member?(state.pending, approval.id) do + notify( + state, + "Approval needed", + "#{approval.tool} is waiting for your decision.", + "/s/#{approval.session_id}#approval-#{approval.id}" + ) + end + + {:noreply, push_tray(%{state | pending: MapSet.put(state.pending, approval.id)})} + end + + def handle_info({:approval, :decided, approval}, state), + do: {:noreply, push_tray(%{state | pending: MapSet.delete(state.pending, approval.id)})} + + def handle_info({:session_state, id, to}, state) do + busy = if to in @busy, do: MapSet.put(state.busy, id), else: MapSet.delete(state.busy, id) + {:noreply, push_tray(%{state | busy: busy})} + end + + def handle_info({:gateway_inbound, adapter, :placed}, state) do + notify( + state, + "New message on #{adapter}", + "A conversation on #{adapter} has a new message.", + "/" + ) + + {:noreply, state} + end + + def handle_info({:gateway_inbound, _adapter, _outcome}, state), do: {:noreply, state} + + def handle_info({:task_run, _run}, state) do + notify(state, "A scheduled task finished", "Its result is on the tasks page.", "/tasks") + {:noreply, state} + end + + # Events from the shell. + def handle_info({:ex_tauri_event, "hello", payload}, state) do + trusted = token_matches?(state.token, payload["token"]) + + unless trusted do + Logger.warning("desktop: a peer on the shell channel presented no valid token; ignoring it") + end + + state = %{state | trusted: trusted} + {:noreply, if(trusted, do: state |> push_settings() |> push_tray(true), else: state)} + end + + def handle_info({:ex_tauri_event, "window", %{"focused" => focused}}, state) + when is_boolean(focused), + do: {:noreply, %{state | focused: focused}} + + def handle_info({:ex_tauri_event, "tray_menu_click", %{"id" => id}}, %{trusted: true} = state), + do: {:noreply, tray_action(id, state)} + + def handle_info({:ex_tauri_event, "dialog_result", %{"id" => id} = p}, %{trusted: true} = state) do + case Map.pop(state.requests, id) do + {nil, _} -> + {:noreply, state} + + {{from, timer}, requests} -> + Process.cancel_timer(timer) + GenServer.reply(from, {:ok, dialog_paths(p["paths"])}) + {:noreply, %{state | requests: requests}} + end + end + + # A command the shell could not carry out (a shortcut it could not register, a launch-at-login + # entry it could not write) comes back as an `error` event; it is logged, not swallowed. + def handle_info({:ex_tauri_event, "error", %{"message" => message}}, state) do + Logger.warning("desktop: the shell reported: #{message}") + {:noreply, state} + end + + def handle_info({:ex_tauri_event, _name, _payload}, state), do: {:noreply, state} + + def handle_info({:request_timeout, id}, state) do + case Map.pop(state.requests, id) do + {nil, _} -> + {:noreply, state} + + {{from, _timer}, requests} -> + GenServer.reply(from, {:error, :timeout}) + {:noreply, %{state | requests: requests}} + end + end + + def handle_info(:subscribe_channel, state), do: {:noreply, subscribe_channel(state)} + + def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do + Process.send_after(self(), :subscribe_channel, @resubscribe_ms) + {:noreply, %{state | subscribed: false, trusted: false}} + end + + def handle_info(_other, state), do: {:noreply, state} + + @impl true + def terminate(_reason, _state) do + :telemetry.detach({__MODULE__, self()}) + :ok + end + + ## Actions + + defp tray_action("show", state), do: tap(state, fn s -> s.impl.show_window() end) + + defp tray_action("new_session", state) do + case Sessions.create_session(%{persona_id: Sessions.default_persona().id, origin: "desktop"}) do + {:ok, session} -> state.impl.focus_path("/s/#{session.id}") + {:error, reason} -> Logger.warning("desktop: New session failed: #{inspect(reason)}") + end + + state + end + + defp tray_action("pause_gateways", state) do + if Gateways.paused?(), do: Gateways.resume(), else: Gateways.pause() + push_tray(state) + end + + defp tray_action("open_data_folder", state), + do: tap(state, fn s -> s.impl.open_path(s.data_dir) end) + + defp tray_action("quit", state), do: tap(state, fn s -> s.impl.quit() end) + defp tray_action(_unknown, state), do: state + + ## Helpers + + defp notify(state, title, body, path) do + unless state.focused or Settings.get(:notifications_muted, state.settings) do + state.impl.notify(title, body: body, path: path) + end + + :ok + end + + # After a hello: the saved shortcut and launch-at-login, which only the shell can apply. + defp push_settings(state) do + settings = Settings.all(state.settings) + state.impl.set_hotkey(settings.hotkey) + state.impl.set_autostart(settings.autostart) + state + end + + defp push_tray(state, force \\ false) do + busy = MapSet.size(state.busy) + + tray = %{ + status: if(busy > 0, do: :thinking, else: :idle), + thinking: busy, + pending: MapSet.size(state.pending), + gateways_paused: Gateways.paused?() + } + + if force or tray != state.last_tray do + state.impl.tray_update(tray) + %{state | last_tray: tray} + else + state + end + end + + defp subscribe_channel(%{impl: Desktop.Tauri, subscribed: false} = state) do + channel = Application.get_env(:trinity, :desktop, [])[:channel] || ExTauri.ShutdownManager + + with pid when is_pid(pid) <- Process.whereis(channel), + :ok <- GenServer.call(pid, {:subscribe, self()}) do + Process.monitor(pid) + # A shell already attached said hello before this process was listening (this process + # restarted, or started late); ask it to say it again. With none attached this is + # `{:error, :not_connected}` and the shell's own hello on connecting is the one that counts. + _ = Desktop.Tauri.command("hello", %{}) + %{state | subscribed: true} + else + _ -> + Process.send_after(self(), :subscribe_channel, @resubscribe_ms) + state + end + end + + defp subscribe_channel(state), do: state + + defp token_matches?(expected, given) + when is_binary(expected) and expected != "" and is_binary(given), + do: byte_size(expected) == byte_size(given) and :crypto.hash_equals(expected, given) + + defp token_matches?(_expected, _given), do: false + + # A dialog's answer is a list of absolute paths; anything else in it is dropped. + defp dialog_paths(paths) when is_list(paths), + do: Enum.filter(paths, &(is_binary(&1) and Path.type(&1) == :absolute)) + + defp dialog_paths(_), do: [] +end diff --git a/lib/trinity/desktop/tauri.ex b/lib/trinity/desktop/tauri.ex new file mode 100644 index 0000000..d7307ee --- /dev/null +++ b/lib/trinity/desktop/tauri.ex @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Desktop.Tauri do + @moduledoc """ + The Tauri shell (slice 100), over the channel `ex_tauri` keeps between the Rust window and this + BEAM: the socket `ExTauri.ShutdownManager` listens on for the heartbeat, which carries + newline-delimited JSON both ways. A command goes out as + `{"type":"command","name":...,"payload":...}`; the shell's events come back to subscribers of + `ExTauri.Desktop.subscribe/0` as `{:ex_tauri_event, name, payload}`, and + `Trinity.Desktop.Shell` is the subscriber. + + **The one private shape this relies on** (NOTES D7): `ExTauri.Desktop` exposes only `notify/2` + and `set_tray/1`, and both are `GenServer.call(ExTauri.ShutdownManager, {:desktop_command, name, + payload})` underneath. The commands this slice adds use that same message. It is a private shape + of a pinned `ex_tauri` 0.2.0; this module is the one place that knows it, and + `test/trinity/desktop_test.exs` drives a real manager over its socket so an upgrade that changes + it fails there. + + The Rust side of every command is `src-tauri/src/main.rs`'s `handle_channel_command/2`. + """ + @behaviour Trinity.Desktop + + alias Trinity.Desktop.Shell + + @dialog_timeout_ms 300_000 + + @impl true + def available? do + Process.whereis(channel()) != nil and + match?(%{trusted: true}, safe_status(Shell)) + end + + @impl true + def notify(title, opts) when is_binary(title) do + with {:ok, path} <- app_path(Keyword.get(opts, :path, "/")) do + command("notify", %{title: title, body: Keyword.get(opts, :body, ""), path: path}) + end + end + + @impl true + def tray_update(status), do: command("set_tray", tray_spec(status)) + + @impl true + def open_dialog(opts) do + shell = Keyword.get(opts, :shell, Shell) + timeout = Keyword.get(opts, :timeout, @dialog_timeout_ms) + + payload = %{ + kind: opts |> Keyword.get(:kind, :folder) |> to_string(), + title: Keyword.get(opts, :title, "") + } + + Shell.request(shell, "open_dialog", payload, timeout) + end + + @impl true + def show_window, do: command("show_window", %{}) + + @impl true + def focus_path(path) do + with {:ok, path} <- app_path(path), do: command("show_window", %{path: path}) + end + + @impl true + def open_path(path) when is_binary(path), do: command("open_path", %{path: path}) + + @impl true + def set_hotkey(accelerator), do: command("set_hotkey", %{accelerator: accelerator}) + + @impl true + def set_autostart(enabled) when is_boolean(enabled), + do: command("set_autostart", %{enabled: enabled}) + + @impl true + def quit, do: command("quit", %{}) + + @doc """ + The tray menu a status becomes: a status line that cannot be clicked, then Show, New session, + Pause (or Resume) gateways, Open data folder and Quit. The ids are what + `Trinity.Desktop.Shell` acts on when a `tray_menu_click` event names one. + """ + @spec tray_spec(Trinity.Desktop.tray_status()) :: map() + def tray_spec(status) do + pending = Map.get(status, :pending, 0) + thinking = Map.get(status, :thinking, 0) + + activity = + case Map.get(status, :status, :idle) do + :thinking -> "Thinking (#{thinking})" + _ -> "Idle" + end + + approvals = + case pending do + 0 -> "no pending approvals" + 1 -> "1 pending approval" + n -> "#{n} pending approvals" + end + + pause = + if Map.get(status, :gateways_paused, false), do: "Resume gateways", else: "Pause gateways" + + %{ + tooltip: "Trinity: #{String.downcase(activity)}, #{pending} pending", + items: [ + %{id: "status", label: "#{activity}, #{approvals}", enabled: false}, + %{id: "show", label: "Show Trinity"}, + %{id: "new_session", label: "New session"}, + %{id: "pause_gateways", label: pause}, + %{id: "open_data_folder", label: "Open data folder"}, + %{id: "quit", label: "Quit Trinity"} + ] + } + end + + @doc """ + Sends one command to the shell. `{:error, :not_running}` when no channel process exists (the + suite, a headless node), `{:error, :not_connected}` when no shell is attached to it. + """ + @spec command(String.t(), map()) :: :ok | {:error, term()} + def command(name, payload) when is_binary(name) and is_map(payload) do + case Process.whereis(channel()) do + nil -> + {:error, :not_running} + + pid -> + try do + GenServer.call(pid, {:desktop_command, name, payload}, 5_000) + catch + :exit, _ -> {:error, :not_running} + end + end + end + + # A window may only be sent to a page of this app: a path, never a URL, so nothing reaching a + # notification or a tray action can point the window at another origin. + defp app_path("/" <> rest = path) do + if String.starts_with?(rest, "/") or String.contains?(path, ["\\", "\n", "\r"]), + do: {:error, {:not_an_app_path, path}}, + else: {:ok, path} + end + + defp app_path(other), do: {:error, {:not_an_app_path, other}} + + defp channel, + do: Application.get_env(:trinity, :desktop, [])[:channel] || ExTauri.ShutdownManager + + defp safe_status(server) do + Shell.status(server) + catch + :exit, _ -> nil + end +end diff --git a/lib/trinity/effects/boot.ex b/lib/trinity/effects/boot.ex index 52dfef3..5feac62 100644 --- a/lib/trinity/effects/boot.ex +++ b/lib/trinity/effects/boot.ex @@ -78,8 +78,15 @@ defmodule Trinity.Effects.Boot do defp signer_subject do case Trinity.Receipts.KeyCustody.selected() do - %{algorithm: a, scheme: s, key_id: k} -> - %{"algorithm" => Atom.to_string(a), "scheme" => s, "key_id" => k} + %{algorithm: a, scheme: s, key_id: k} = selection -> + # Slice 100: where the key is held ("file", "sealed" or "keychain"), so a rotation into + # the keychain is on the signed record of the boot that made it, not only in a log. + %{ + "algorithm" => Atom.to_string(a), + "scheme" => s, + "key_id" => k, + "custody" => Map.get(selection, :custody, "file") + } other -> %{"unavailable" => inspect(other)} diff --git a/lib/trinity/gateways.ex b/lib/trinity/gateways.ex index 10fcd82..b351158 100644 --- a/lib/trinity/gateways.ex +++ b/lib/trinity/gateways.ex @@ -16,6 +16,28 @@ defmodule Trinity.Gateways do deps: [Trinity, Trinity.Sessions], exports: [Adapter, Cap, Console, Format, Identities, Identity, Router] + @paused {__MODULE__, :paused} + + @doc """ + Pauses every gateway (slice 100, the tray's "Pause gateways"): `Router` answers each inbound + message with a short notice and goes no further. Held in `:persistent_term` for the life of the + node, not saved: a restart resumes, which is the safer default for a switch that silences + channels. + """ + @spec pause() :: :ok + def pause, do: :persistent_term.put(@paused, true) + + @doc "Resumes the gateways." + @spec resume() :: :ok + def resume do + _ = :persistent_term.erase(@paused) + :ok + end + + @doc "Whether the gateways are paused." + @spec paused?() :: boolean() + def paused?, do: :persistent_term.get(@paused, false) + @doc "The adapter modules in force." @spec adapters() :: [module()] def adapters, do: Application.get_env(:trinity, :gateways, []) |> Keyword.get(:adapters, []) diff --git a/lib/trinity/gateways/router.ex b/lib/trinity/gateways/router.ex index a801b57..02c9fdf 100644 --- a/lib/trinity/gateways/router.ex +++ b/lib/trinity/gateways/router.ex @@ -41,6 +41,9 @@ defmodule Trinity.Gateways.Router do ## The API an adapter uses + # Slice 100: what a sender on any channel is told while the gateways are paused. + @paused_notice "Trinity is paused and is not reading messages right now. Try again later." + @doc "Starts the router." @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) @@ -48,7 +51,9 @@ defmodule Trinity.Gateways.Router do @doc """ One inbound message. Answers once the message has been placed (the reply arrives on the channel as the turn streams), or with why it was refused: `{:error, :pending}` when a pairing code was - shown instead, `{:error, :revoked}`, `{:error, :rate_limited}`. + shown instead, `{:error, :revoked}`, `{:error, :rate_limited}`, and since slice 100 + `{:error, :paused}` while the owner has paused the gateways (`Trinity.Gateways.pause/0`): the + sender is told so, and nothing else happens, not even admission. """ @spec inbound(module(), Adapter.conversation(), String.t(), String.t(), keyword()) :: {:ok, :placed | :command | :paired} | {:error, term()} @@ -93,10 +98,9 @@ defmodule Trinity.Gateways.Router do @impl GenServer def handle_call({:inbound, message}, _from, state) do - case admit(message) do - {:ok, :paired} -> route(message, state) - {:refused, reply, result} -> {:reply, result, say(message, reply, state)} - end + if Trinity.Gateways.paused?(), + do: {:reply, {:error, :paused}, say(message, @paused_notice, state)}, + else: admit_and_route(message, state) end def handle_call({:session_of, adapter, conversation}, _from, state), @@ -413,6 +417,13 @@ defmodule Trinity.Gateways.Router do ## Saying something back without a session + defp admit_and_route(message, state) do + case admit(message) do + {:ok, :paired} -> route(message, state) + {:refused, reply, result} -> {:reply, result, say(message, reply, state)} + end + end + defp say(message, text, state) do caps = message.adapter.capabilities() diff --git a/lib/trinity/llm/registry.ex b/lib/trinity/llm/registry.ex index 75516be..1d5c460 100644 --- a/lib/trinity/llm/registry.ex +++ b/lib/trinity/llm/registry.ex @@ -33,9 +33,20 @@ defmodule Trinity.LLM.Registry do @spec models() :: [entry()] def models, do: Keyword.get(config(), :models, []) - @doc "The default model id, or nil when the registry is empty." + @doc """ + The default model id, or nil when the registry is empty. Slice 100: the model the owner chose + in the setup path (`Trinity.Settings`, `:default_model`) when it is one the registry knows, + else the configured one. + """ @spec default_model() :: String.t() | nil - def default_model, do: Keyword.get(config(), :default_model) + def default_model do + configured = Keyword.get(config(), :default_model) + + case Trinity.Settings.get(:default_model) do + id when is_binary(id) -> if Enum.any?(models(), &(&1.id == id)), do: id, else: configured + _ -> configured + end + end @doc "The entry for an id, or the default's when nil, refusing an unknown id by name." @spec lookup(String.t() | nil) :: {:ok, entry()} | {:error, {:unknown_model, String.t() | nil}} diff --git a/lib/trinity/receipts/key_custody.ex b/lib/trinity/receipts/key_custody.ex index 059e9c8..ea097af 100644 --- a/lib/trinity/receipts/key_custody.ex +++ b/lib/trinity/receipts/key_custody.ex @@ -11,8 +11,29 @@ defmodule Trinity.Receipts.KeyCustody do The key is a file, `receipts-.key` under the keys directory, mode 0600, made on first run; its registry row is appended to `registry.json` the same moment. **What a file-backed key establishes**: that the chain was not altered after the fact by anything - lacking read access to that file, and nothing more. Slice 100 moves it to the OS keychain - and the registry records the change as a new row. + lacking read access to that file, and nothing more. + + ## The move to the keychain (slice 100, AC6) + + When the OS keychain answers (`Trinity.Secrets.Keychain`, the Tauri shell's helper) and the key + in force is still file-backed, `boot!/1` **rotates** into the keychain rather than importing the + file's key (NOTES D3): a new key pair, its private half stored in the keychain and read back + before anything else changes; then two registry rows, the new key `active` with custody + `keychain` and the old key `retired` with `valid_until` (this moment) and `superseded_by`; then the + old key file is copied to `receipts-.retired-.key` (kept, mode 0600: deleting + key material is the owner's call) and the key file is replaced by one naming the keychain entry + and holding no private bytes. Receipts signed under the old key keep verifying, because the + verifier refuses only `compromised` keys and, for a `retired` one, a receipt dated after its + `valid_until`. Nothing is re-keyed silently: the rotation is logged with both key ids, the boot + receipt names the custody, and a keychain that cannot store and return the key leaves the + file-backed key in force with the registry untouched. A boot interrupted between the registry + rows and the file swap finishes the swap on the next boot. + + A keychain-held key needs the keychain at every signature, as the file-backed one needs its + file: with no helper (a `mix phx.server` with no shell, on a data directory the desktop app has + migrated) the signer is unavailable and every effect is denied, which is the documented + fail-open-on-boot, fail-closed-on-effect shape of slice 024. `TRINITY_KEYCHAIN_HELPER` pointed + at a built shell binary restores it (docs/packaging.md). `sign/1` reads the key file on every call and never caches the key, so a key removed mid-run is a signer that has become unavailable at the next receipt, not at the next @@ -20,7 +41,10 @@ defmodule Trinity.Receipts.KeyCustody do chain writer; no runtime path changes it (ADR-0010's rule for the authority, applied here). """ + require Logger + alias Trinity.Receipts.{KeyRegistry, Signer} + alias Trinity.Secrets.Keychain Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) @@ -32,7 +56,8 @@ defmodule Trinity.Receipts.KeyCustody do key_id: String.t(), key_path: Path.t(), keys_dir: Path.t(), - impl: module() + impl: module(), + custody: String.t() } @doc "The keys directory in force: config `:trinity, :receipts, :keys_dir`, else the data directory's." @@ -68,14 +93,16 @@ defmodule Trinity.Receipts.KeyCustody do with {:ok, dir} <- resolve_keys_dir(dir), {:ok, algorithm} <- select(), impl = Signer.impl(algorithm), - {:ok, key_id} <- ensure_key(dir, impl) do + {:ok, key_id} <- ensure_key(dir, impl), + {:ok, key_id} <- maybe_migrate(dir, impl, key_id) do selection = %{ algorithm: algorithm, scheme: impl.scheme(), key_id: key_id, key_path: key_path(dir, algorithm), keys_dir: dir, - impl: impl + impl: impl, + custody: custody(key_path(dir, algorithm)) } :persistent_term.put(@key, selection) @@ -165,8 +192,9 @@ defmodule Trinity.Receipts.KeyCustody do with {:ok, %{"key_id" => key_id, "algorithm" => alg}} <- JSON.decode(bin), true <- alg == Atom.to_string(impl.algorithm()) || {:error, :key_file_algorithm}, {:ok, rows} <- KeyRegistry.read(dir), - %{} <- KeyRegistry.lookup(rows, key_id) || {:error, {:key_not_in_registry, key_id}} do - {:ok, key_id} + %{} = row <- + KeyRegistry.lookup(rows, key_id) || {:error, {:key_not_in_registry, key_id}} do + in_force(row, dir, impl, bin, rows) end {:error, :enoent} -> @@ -227,6 +255,167 @@ defmodule Trinity.Receipts.KeyCustody do end end + ## Slice 100: the move to the keychain + + # The key file names a key the registry has since retired: an earlier boot stopped between the + # registry rows and the file swap. Any other status is the key in force. + defp in_force(%{"status" => "retired"}, dir, impl, bin, rows) do + resume_migration(dir, impl, bin, bin |> JSON.decode!() |> Map.fetch!("key_id"), rows) + end + + defp in_force(row, _dir, _impl, _bin, _rows), do: {:ok, row["key_id"]} + + defp maybe_migrate(dir, impl, key_id) do + path = key_path(dir, impl.algorithm()) + + cond do + Application.get_env(:trinity, :receipts, [])[:keychain_migration] == false -> {:ok, key_id} + custody(path) == "keychain" -> {:ok, key_id} + not Keychain.available?() -> {:ok, key_id} + true -> migrate(dir, impl, key_id) + end + end + + # Every step before the registry rows can fail without consequence: the file-backed key stays + # in force and nothing on disk has changed. The rows are the commitment; after them, the swap + # is finished now or, if this boot dies, by `resume_migration/5` on the next. + # sobelow_skip reason: Traversal.FileModule: the paths are the keys directory plus constants. + @sobelow_skip ["Traversal.FileModule"] + defp migrate(dir, impl, old_id) do + path = key_path(dir, impl.algorithm()) + {pub, priv} = impl.generate_key() + jwk = impl.jwk(pub) + {new_id, kid_scheme} = key_id_of(pub, jwk) + account = keychain_account(impl.algorithm(), new_id) + stored = Base.encode64(impl.encode_private(priv)) + + with {:ok, old_bin} <- File.read(path), + {:ok, rows} <- KeyRegistry.read(dir), + %{} = old_row <- KeyRegistry.lookup(rows, old_id) || {:error, :old_key_not_in_registry}, + :ok <- Keychain.store(account, stored), + {:ok, ^stored} <- Keychain.fetch(account) do + now = DateTime.utc_now() |> DateTime.to_iso8601() + + new_row = + row(impl, pub, jwk, new_id, kid_scheme, now) + |> Map.merge(%{"custody" => "keychain", "keychain_account" => account}) + + retired = + Map.merge(old_row, %{ + "status" => "retired", + "valid_from" => now, + "valid_until" => now, + "superseded_by" => new_id + }) + + with {:ok, _} <- KeyRegistry.append(dir, new_row), + {:ok, _} <- KeyRegistry.append(dir, retired), + :ok <- finish_swap(dir, impl, old_bin, old_id, new_row) do + Logger.warning( + "receipts: the signing key moved to the OS keychain by rotation: #{old_id} retired " <> + "(valid_until #{now}), #{new_id} active. Receipts signed under #{old_id} still verify." + ) + + {:ok, new_id} + end + else + other -> + _ = Keychain.delete(account) + + Logger.warning( + "receipts: the signing key stays file-backed (#{old_id}); the keychain could not " <> + "hold its replacement: #{inspect(other)}" + ) + + {:ok, old_id} + end + end + + # The registry already names the keychain key active and the file's key retired: an earlier + # boot stopped between the rows and the swap. Finish it, or refuse if the registry says + # something this module did not write. + defp resume_migration(dir, impl, old_bin, old_id, rows) do + case KeyRegistry.active_for(rows, impl.algorithm()) do + %{"custody" => "keychain", "key_id" => new_id} = new_row -> + with :ok <- finish_swap(dir, impl, old_bin, old_id, new_row), do: {:ok, new_id} + + _ -> + {:error, {:key_retired, old_id}} + end + end + + # sobelow_skip reason: Traversal.FileModule: the paths are the keys directory plus constants + # and a prefix of a key id this module generated. + @sobelow_skip ["Traversal.FileModule"] + defp finish_swap(dir, impl, old_bin, old_id, new_row) do + alg = Atom.to_string(impl.algorithm()) + path = key_path(dir, impl.algorithm()) + retired_path = Path.join(dir, "receipts-#{alg}.retired-#{old_id |> String.slice(0, 12)}.key") + + file = + JSON.encode!(%{ + "algorithm" => alg, + "key_id" => new_row["key_id"], + "custody" => "keychain", + "keychain_account" => new_row["keychain_account"], + "public_b64" => new_row["public_key_b64"] + }) + + with :ok <- keep_retired(retired_path, old_bin), + :ok <- File.write(path <> ".tmp", file), + :ok <- File.chmod(path <> ".tmp", 0o600) do + File.rename(path <> ".tmp", path) + end + end + + # sobelow_skip reason: Traversal.FileModule: as finish_swap/5. + @sobelow_skip ["Traversal.FileModule"] + defp keep_retired(retired_path, old_bin) do + if File.exists?(retired_path) do + :ok + else + with :ok <- File.write(retired_path, old_bin), do: File.chmod(retired_path, 0o600) + end + end + + # The keychain entry's name: an environment-variable-shaped name, as `Trinity.Secrets` requires + # of every name, unique per key. + defp keychain_account(algorithm, key_id) do + digest = :crypto.hash(:sha256, key_id) |> Base.encode16() |> binary_part(0, 16) + "TRINITY_RECEIPTS_" <> String.upcase(Atom.to_string(algorithm)) <> "_" <> digest + end + + # The custody the key file records: "keychain", "sealed", or "file" (a pre-025 file has none). + # sobelow_skip reason: Traversal.FileModule: `path` is key_path/2's. + @sobelow_skip ["Traversal.FileModule"] + defp custody(path) do + with {:ok, bin} <- File.read(path), + {:ok, %{} = file} <- JSON.decode(bin) do + Map.get(file, "custody", "file") + else + _ -> "file" + end + end + + defp key_id_of(pub, nil), + do: {:crypto.hash(:sha256, pub) |> Base.url_encode64(padding: false), "sha256-raw"} + + defp key_id_of(_pub, jwk), do: {Signer.thumbprint(jwk), "rfc7638"} + + defp row(impl, pub, jwk, key_id, kid_scheme, now) do + %{ + "key_id" => key_id, + "kid_scheme" => kid_scheme, + "algorithm" => Atom.to_string(impl.algorithm()), + "scheme" => impl.scheme(), + "jwk" => jwk, + "public_key_b64" => Base.encode64(pub), + "fingerprint" => :crypto.hash(:sha256, pub) |> Base.encode16(case: :lower), + "valid_from" => now, + "status" => "active" + } + end + # Slice 025. The signer holds no key-opening logic of its own: it hands bytes to the seam and # takes bytes back. Swapping the custody adapter for a KMS changes `Trinity.Keys` and nothing # here, which is the whole reason the seam exists. @@ -237,6 +426,24 @@ defmodule Trinity.Receipts.KeyCustody do end end + # Slice 100: a keychain-held key is fetched from the keychain at this call, as the file-backed + # one is read from its file at this call; neither is cached. + defp private_bytes(%{"custody" => "keychain", "keychain_account" => account}) do + with {:ok, b64} <- Keychain.fetch(account), + {:ok, encoded} <- Base.decode64(b64) do + {:ok, encoded} + else + :error -> {:error, :keychain_entry_undecodable} + {:error, reason} -> {:error, {:keychain, reason}} + end + end + + defp private_bytes(%{"private_b64" => b64} = row) do + with {:ok, stored} <- Base.decode64(b64), do: open_private(stored, row) + end + + defp private_bytes(_row), do: {:error, :key_file_mismatch} + # A key file written before slice 025 carries no `custody` field and holds the private bytes # directly. It still opens: a slice that made every existing installation unbootable to gain a # property would not be an improvement. @@ -248,9 +455,8 @@ defmodule Trinity.Receipts.KeyCustody do @sobelow_skip ["Traversal.FileModule"] defp read_private(path, impl, key_id) do with {:ok, bin} <- File.read(path), - {:ok, %{"private_b64" => b64, "key_id" => ^key_id} = row} <- JSON.decode(bin), - {:ok, stored} <- Base.decode64(b64), - {:ok, encoded} <- open_private(stored, row) do + {:ok, %{"key_id" => ^key_id} = row} <- JSON.decode(bin), + {:ok, encoded} <- private_bytes(row) do {:ok, impl.decode_private(encoded)} else {:error, :enoent} -> {:error, :signer_unavailable} diff --git a/lib/trinity/receipts/verifier.ex b/lib/trinity/receipts/verifier.ex index 748f4d9..71659de 100644 --- a/lib/trinity/receipts/verifier.ex +++ b/lib/trinity/receipts/verifier.ex @@ -13,7 +13,12 @@ defmodule Trinity.Receipts.Verifier do registry row's algorithm is the one the scheme names, else the receipt is refused before any signature is checked (SLICE.md amendments 3 and 4); the row's status is not `compromised`; the hash recomputes from the stored body through the PAE; a signed kind's - signature verifies with the registry's public key under the registry's algorithm. Every + signature verifies with the registry's public key under the registry's algorithm; and, since + slice 100, a receipt under a key the registry marks `retired` is dated (its signed `at`) no + later than that row's `valid_until`. The last check is as strong as the clock the receipt was + written by and no stronger: whoever holds a retired key can sign a backdated body. It stops a + retired key's receipts from being minted after the rotation by anything that keeps honest time, + and it makes `valid_until` a field something reads rather than a note. Every query receipt must be covered by a checkpoint whose tail hash is in the chain and whose signature verifies the same way; a covered range never has a gap. @@ -87,11 +92,26 @@ defmodule Trinity.Receipts.Verifier do {:ok, impl, row} <- resolve(r["scheme"], r["key_id"], registry, schemes, seq), bytes = Envelope.pae(Envelope.receipt_type(r["scheme"]), r["signed_payload"]), :ok <- expect(Envelope.hash(bytes) == r["receipt_hash"], 1, {:hash_mismatch, seq}), - :ok <- body_matches(r, seq) do + :ok <- body_matches(r, seq), + :ok <- within_validity(r, row, seq) do check_signature(r, bytes, impl, row, seq) end end + # Slice 100: a retired key signs nothing dated after its `valid_until`. + defp within_validity(r, %{"status" => "retired", "valid_until" => until}, seq) + when is_binary(until) do + with {:ok, %{"at" => at}} when is_binary(at) <- JSON.decode(r["signed_payload"]), + {:ok, at, _} <- DateTime.from_iso8601(at), + {:ok, until, _} <- DateTime.from_iso8601(until) do + expect(DateTime.compare(at, until) != :gt, 1, {:key_retired, seq, r["key_id"]}) + else + _ -> :ok + end + end + + defp within_validity(_r, _row, _seq), do: :ok + defp check_signature(%{"kind" => kind} = r, bytes, impl, row, seq) when kind in @signed_kinds do with {:ok, sig} <- decode_sig(r["signature_b64"], seq), {:ok, pub} <- public_key(row, r["key_id"]) do diff --git a/lib/trinity/secrets.ex b/lib/trinity/secrets.ex new file mode 100644 index 0000000..bd7fb9d --- /dev/null +++ b/lib/trinity/secrets.ex @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Secrets do + @moduledoc """ + Secrets, from the OS keychain first and the environment second (slice 100, NOTES D1 and D2). + + `Trinity.Config.secret/1` is the one reader the providers use, and it asks this module. A name + is an environment-variable name (`OPENROUTER_API_KEY`), so the same name finds a key whether the + owner typed it into Settings (the keychain) or exported it before `mix phx.server` (the + environment). The keychain wins: a key entered in Settings must not be shadowed by a stale + variable. + + ## Where the keychain is + + `Trinity.Secrets.Keychain` runs the Tauri shell's binary as a command, + `trinity --keychain `, and the shell's `keyring` crate does the OS work: the macOS + Keychain, the Windows Credential Manager, the Secret Service on Linux. The shell hands the + sidecar its own path as `TRINITY_KEYCHAIN_HELPER` when it starts it. With no shell (headless, + CI, `mix phx.server`) there is no helper, the keychain is unavailable, and only the environment + answers. Why a command and not the window's channel: the channel exists only once the window has + attached, which is after the endpoint starts, and the receipt signer needs its key before that. + + ## What this module never does + + It never writes a value anywhere but the keychain: `store/2` with no keychain is an error, not a + file. It never puts a value on a command line (the helper reads it from stdin and writes it to + stdout, hex-encoded so no byte can split the line), never logs one, and never holds one beyond + the call that asked. `test/trinity/secrets_test.exs` scans the files and the log for the value. + """ + + alias Trinity.Secrets.{Env, Keychain} + + @typedoc "An environment-variable name: upper case, digits and underscores, at most 64 bytes." + @type name :: String.t() + + @name ~r/\A[A-Z][A-Z0-9_]{0,63}\z/ + + @doc "Whether `name` is acceptable as a secret's name." + @spec valid_name?(term()) :: boolean() + def valid_name?(name), do: is_binary(name) and Regex.match?(@name, name) + + @doc "Whether the OS keychain answers from this process." + @spec keychain_available?() :: boolean() + def keychain_available?, do: Keychain.available?() + + @doc """ + The secret named `name`: the keychain's value when it has one, else the environment's, else + `{:error, :not_found}`. A keychain that cannot be reached is the same as one without the entry: + the environment still answers. + """ + @spec fetch(name()) :: {:ok, String.t()} | {:error, :not_found | {:invalid_name, term()}} + def fetch(name) do + with :ok <- check_name(name) do + case Keychain.fetch(name) do + {:ok, value} -> {:ok, value} + {:error, _} -> Env.fetch(name) + end + end + end + + @doc """ + Stores `value` under `name` in the keychain. With no keychain this is + `{:error, :keychain_unavailable}` and nothing is written anywhere. + """ + @spec store(name(), String.t()) :: :ok | {:error, term()} + def store(name, value) do + with :ok <- check_name(name), + :ok <- check_value(value) do + if Keychain.configured?(), + do: Keychain.store(name, value), + else: {:error, :keychain_unavailable} + end + end + + @doc "Removes `name` from the keychain. The environment is not this module's to change." + @spec delete(name()) :: :ok | {:error, term()} + def delete(name) do + with :ok <- check_name(name) do + if Keychain.configured?(), + do: Keychain.delete(name), + else: {:error, :keychain_unavailable} + end + end + + @doc """ + Where `fetch/1` would find `name`: `:keychain`, `:env` or `:none`. For the Settings page, which + shows where a key comes from and never the key. + """ + @spec source(name()) :: :keychain | :env | :none + def source(name) do + cond do + not valid_name?(name) -> :none + match?({:ok, _}, Keychain.fetch(name)) -> :keychain + match?({:ok, _}, Env.fetch(name)) -> :env + true -> :none + end + end + + defp check_name(name), + do: if(valid_name?(name), do: :ok, else: {:error, {:invalid_name, name}}) + + # The helper's protocol is one hex line, so any byte would survive it; these are refused because + # no provider key contains them and a value that does is far likelier to be a paste accident. + defp check_value(value) when is_binary(value) and value != "" do + if String.contains?(value, ["\n", "\r", <<0>>]), do: {:error, :invalid_value}, else: :ok + end + + defp check_value(_), do: {:error, :invalid_value} +end diff --git a/lib/trinity/secrets/env.ex b/lib/trinity/secrets/env.ex new file mode 100644 index 0000000..c3c37a2 --- /dev/null +++ b/lib/trinity/secrets/env.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Secrets.Env do + @moduledoc """ + The environment as a source of secrets (slice 011's only source; slice 100's fallback). Read + only: a process cannot persist a variable for its next run, so there is no `store`. + """ + + @doc "The variable's value, or `{:error, :not_found}` when unset or empty." + @spec fetch(String.t()) :: {:ok, String.t()} | {:error, :not_found} + def fetch(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, :not_found} + end + end +end diff --git a/lib/trinity/secrets/keychain.ex b/lib/trinity/secrets/keychain.ex new file mode 100644 index 0000000..4db7f1e --- /dev/null +++ b/lib/trinity/secrets/keychain.ex @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Secrets.Keychain do + @moduledoc """ + The OS keychain, through the Tauri shell's binary run as a command (slice 100, NOTES D1). + + ## The protocol + + --keychain probe + --keychain get stdout: the value, hex, one line + --keychain set stdin: the value, hex, one line + --keychain delete + + Exit 0 done, 2 usage, 3 no such entry, 4 the keychain could not be reached (the reason goes to + the helper's stderr and never contains the value). The service the entries live under is the + shell's to choose (`src-tauri/src/keychain.rs`); this side names only the entry. + + The value never rides on argv, where `ps` on the same machine would show it, and hex means no + byte of it can end the line early. Nothing here logs a value or keeps one past the call. + + ## Which helper + + `config :trinity, :secrets, keychain_helper:` (the suite's `Trinity.FakeKeychain`), else + `TRINITY_KEYCHAIN_HELPER`, which the shell sets for the sidecar it starts. It must be an absolute + path to an executable file; anything else is treated as no keychain. + """ + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @timeout_ms 30_000 + + @doc "The helper in force, or nil." + @spec helper() :: Path.t() | nil + def helper do + configured = Application.get_env(:trinity, :secrets, [])[:keychain_helper] + path = configured || System.get_env("TRINITY_KEYCHAIN_HELPER") + if executable?(path), do: path, else: nil + end + + @doc "Whether a helper is configured (it may still fail to reach the keychain)." + @spec configured?() :: boolean() + def configured?, do: helper() != nil + + @doc "Whether a helper is configured and reaches the keychain now." + @spec available?() :: boolean() + def available? do + configured?() and match?({:ok, _}, run(["probe"], nil)) + end + + @doc "The entry's value." + @spec fetch(String.t()) :: {:ok, String.t()} | {:error, term()} + def fetch(name) do + with true <- configured?() || {:error, :keychain_unavailable}, + {:ok, out} <- run(["get", name], nil), + {:ok, value} <- out |> String.trim() |> Base.decode16(case: :mixed) do + {:ok, value} + else + :error -> {:error, :bad_helper_output} + {:error, reason} -> {:error, reason} + end + end + + @doc "Stores the entry's value, replacing any." + @spec store(String.t(), String.t()) :: :ok | {:error, term()} + def store(name, value) do + case run(["set", name], Base.encode16(value, case: :lower) <> "\n") do + {:ok, _} -> :ok + error -> error + end + end + + @doc "Removes the entry; an entry that is already absent is not an error." + @spec delete(String.t()) :: :ok | {:error, term()} + def delete(name) do + case run(["delete", name], nil) do + {:ok, _} -> :ok + {:error, :not_found} -> :ok + error -> error + end + end + + # sobelow_skip reason: CI.System: the executable is the helper this module validated (an + # absolute path to an executable file from configuration or from the shell that started this + # process) and the arguments are a constant verb and a name `Trinity.Secrets` has already + # checked against `[A-Z][A-Z0-9_]*`. Nothing from a request reaches it. + @sobelow_skip ["CI.System"] + defp run(args, input) do + case helper() do + nil -> + {:error, :keychain_unavailable} + + helper -> + port = + Port.open({:spawn_executable, helper}, [ + :binary, + :exit_status, + :use_stdio, + :hide, + args: ["--keychain" | args] + ]) + + if input, do: Port.command(port, input) + collect(port, []) + end + end + + defp collect(port, acc) do + receive do + {^port, {:data, data}} -> + collect(port, [acc, data]) + + {^port, {:exit_status, 0}} -> + {:ok, IO.iodata_to_binary(acc)} + + {^port, {:exit_status, 3}} -> + {:error, :not_found} + + {^port, {:exit_status, status}} -> + {:error, {:keychain, status}} + after + @timeout_ms -> + Port.close(port) + {:error, {:keychain, :timeout}} + end + end + + # Windows has no execute bit; an .exe is executable by its name. + defp executable?(path) when is_binary(path) and path != "" do + with :absolute <- Path.type(path), + {:ok, %File.Stat{type: :regular, mode: mode}} <- File.stat(path) do + match?({:win32, _}, :os.type()) or Bitwise.band(mode, 0o111) != 0 + else + _ -> false + end + end + + defp executable?(_), do: false +end diff --git a/lib/trinity/sessions.ex b/lib/trinity/sessions.ex index 3a5321e..9056972 100644 --- a/lib/trinity/sessions.ex +++ b/lib/trinity/sessions.ex @@ -227,6 +227,29 @@ defmodule Trinity.Sessions do end end + @doc """ + Slice 100, AC7: interrupts every turn in flight, keeping what arrived as interrupted with + `reason` recorded, and answers how many there were. `Trinity.Application.prep_stop/1` calls it + with `:shutdown`, before any child stops, so the rows are written while everything they need is + still running. A session that does not answer in time is left to its own `terminate/3`. + """ + @spec interrupt_all(atom()) :: non_neg_integer() + def interrupt_all(reason) when is_atom(reason) do + Trinity.Sessions.Supervisor + |> DynamicSupervisor.which_children() + |> Enum.count(fn + {_, pid, _, _} when is_pid(pid) -> + try do + Session.interrupt(pid, reason) == :interrupted + catch + :exit, _ -> false + end + + _ -> + false + end) + end + @doc "The state name and a redacted view of the process's data." @spec state(session_id()) :: map() | {:error, :not_running} def state(session_id) do diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index c9e0941..e7c9c90 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -26,6 +26,8 @@ defmodule Trinity.Sessions.Session do alias Trinity.Memory.{Compactor, Tokens} alias Trinity.Sessions.{Caps, Events, Prompt, Sentinel, State, Store, ToolRunner} + # The states a turn is in flight in (slice 100 names them once for the interrupt path). + @busy [:thinking, :tool_wait, :approval_wait, :compacting] @coalesce_ms 50 @draft_ms 500 @draft_bytes 2_048 @@ -49,6 +51,15 @@ defmodule Trinity.Sessions.Session do @spec cancel_turn(pid() | String.t()) :: :ok | {:error, :idle} def cancel_turn(ref), do: :gen_statem.call(target(ref), :cancel) + @doc """ + Slice 100: stops the turn in flight because the app is stopping, persisting what arrived as + interrupted with `reason` recorded (`"interrupted_reason"` in the row's parts). `:idle` when no + turn was in flight. + """ + @spec interrupt(pid() | String.t(), atom()) :: :interrupted | :idle + def interrupt(ref, reason) when is_atom(reason), + do: :gen_statem.call(target(ref), {:interrupt, reason}, 10_000) + @doc """ The state name and a redacted view of the data: no grants, approvals or pending calls hide here. `text` is the in-progress assistant text (slice 013 reads it when a page mounts @@ -195,6 +206,17 @@ defmodule Trinity.Sessions.Session do def handle_event({:call, from}, :cancel, _state, _data), do: {:keep_state_and_data, [{:reply, from, {:error, :idle}}]} + # Slice 100, AC7: the app is stopping. The turn in flight is kept as interrupted, with the + # reason, exactly as Cancel keeps it; `terminate/3` below does the same if this was not asked. + def handle_event({:call, from}, {:interrupt, reason}, state, %State{} = data) + when state in @busy do + data = interrupt_turn(data, reason) + {:next_state, :idle, %{data | turn: nil}, [{:reply, from, :interrupted}]} + end + + def handle_event({:call, from}, {:interrupt, _reason}, _state, _data), + do: {:keep_state_and_data, [{:reply, from, :idle}]} + # Model events arrive as messages from the streaming Task. def handle_event(:info, {:llm_event, ref, event}, :thinking, %State{turn: %{ref: ref}} = data) do {:keep_state, fold_event(event, data)} @@ -345,9 +367,23 @@ defmodule Trinity.Sessions.Session do def handle_event(:info, :coalesce, _state, _data), do: :keep_state_and_data def handle_event(:info, _other, _state, _data), do: :keep_state_and_data + # Slice 100, AC7: the backstop. A session stopped by its supervisor (the app shutting down + # without `Trinity.Sessions.interrupt_all/1` having reached it first) keeps its turn the same way. + # A `:kill` never reaches here; that case is the next init's rehydrate, as it always was. @impl true + def terminate(_reason, state, %State{} = data) when state in @busy do + _ = interrupt_turn(data, :shutdown) + :ok + end + def terminate(_reason, _state, _data), do: :ok + defp interrupt_turn(data, reason) do + kill_task(data) + data = flush_deltas(data) + persist_final(data, %{"interrupted" => true, "interrupted_reason" => to_string(reason)}) + end + ## The turn # Slice 023: the estimate of the request against the model's window decides between the diff --git a/lib/trinity/settings.ex b/lib/trinity/settings.ex new file mode 100644 index 0000000..e9c686d --- /dev/null +++ b/lib/trinity/settings.ex @@ -0,0 +1,159 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Settings do + @moduledoc """ + The desktop's non-secret settings (slice 100, NOTES decision D6): one JSON file, + `/settings.json`, mode 0600, written atomically (a temporary file beside it, then a + rename), read fresh on every call so a restart and a running process see the same thing. + + **What may be in it is a closed list**, each with a type: the onboarding mark, the default + model's id, the global hotkey, whether notifications are muted, whether Trinity starts at login, + and the folders the filesystem tools may use. An unknown key is refused by name. That list is + what keeps this file from becoming the place a provider key ends up: no key here is named for a + secret, and a string that looks like key material is refused whichever key it is put under + (`Trinity.Secrets` is the only place secrets go, and only to the keychain). Folder paths are + exempt from the shape check because a path is not key material and its callers already require + an existing absolute directory. + + It is in the data directory, so `mix trinity.export` carries it with the rest. + """ + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @type key :: + :onboarded_at + | :default_model + | :hotkey + | :notifications_muted + | :autostart + | :fs_roots + + @defaults %{ + onboarded_at: nil, + default_model: nil, + hotkey: "CommandOrControl+Shift+Space", + notifications_muted: false, + autostart: false, + fs_roots: [] + } + + @keys Map.keys(@defaults) + + # Shapes of key material this file refuses, whatever key it is put under: provider key + # prefixes, long unbroken base64 or hex runs, and a PEM header. Assembled rather than written + # out, so the tree's own secret scan does not read this module as a leak. + @secret_shapes [ + ~r/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/, + ~r/\bnvapi-[A-Za-z0-9_-]{16,}/, + ~r/[A-Za-z0-9+_-]{40,}/, + Regex.compile!("-----BEGIN [A-Z ]*" <> "PRIVATE KEY-----") + ] + + @doc "The value of every setting when nothing has been saved." + @spec defaults() :: %{key() => term()} + def defaults, do: @defaults + + @doc "The settings keys, in no particular order." + @spec keys() :: [key()] + def keys, do: @keys + + @doc """ + The settings file in force: `config :trinity, Trinity.Settings, path:` (the suite points it into + its own `tmp/`), else `settings.json` in the data directory. + """ + @spec path() :: Path.t() + def path do + case Application.get_env(:trinity, __MODULE__, [])[:path] do + nil -> Path.join(Trinity.Paths.ensure_data_dir(), "settings.json") + path -> path + end + end + + @doc "Every setting, saved values over the defaults. `path:` overrides the file." + @spec all(keyword()) :: %{key() => term()} + def all(opts \\ []) do + saved = + case read(Keyword.get_lazy(opts, :path, &path/0)) do + {:ok, map} -> map + {:error, _} -> %{} + end + + Enum.reduce(@keys, @defaults, &take_saved(saved, &1, &2)) + end + + # A saved value of the wrong type (a file edited by hand) reads as the default, not as a crash. + defp take_saved(saved, key, acc) do + case Map.fetch(saved, Atom.to_string(key)) do + {:ok, value} -> if valid?(key, value), do: Map.put(acc, key, value), else: acc + :error -> acc + end + end + + @doc "One setting." + @spec get(key(), keyword()) :: term() + def get(key, opts \\ []) when key in @keys, do: Map.fetch!(all(opts), key) + + @doc """ + Saves one setting. Refuses an unknown key (`{:unknown_setting, key}`), a value of the wrong type + (`{:invalid_setting, key}`) and a string shaped like key material (`{:looks_like_a_secret, key}`), + and in each case writes nothing. + """ + @spec put(key() | String.t(), term(), keyword()) :: :ok | {:error, term()} + def put(key, value, opts \\ []) + + def put(key, value, opts) when key in @keys do + cond do + not valid?(key, value) -> + {:error, {:invalid_setting, key}} + + key != :fs_roots and secret_shaped?(value) -> + {:error, {:looks_like_a_secret, key}} + + true -> + file = Keyword.get_lazy(opts, :path, &path/0) + settings = opts |> all() |> Map.put(key, value) + write(file, settings) + end + end + + def put(key, _value, _opts), do: {:error, {:unknown_setting, to_string(key)}} + + defp valid?(:onboarded_at, v), do: is_nil(v) or is_binary(v) + defp valid?(:default_model, v), do: is_nil(v) or (is_binary(v) and byte_size(v) <= 200) + defp valid?(:hotkey, v), do: is_nil(v) or (is_binary(v) and byte_size(v) <= 100) + defp valid?(:notifications_muted, v), do: is_boolean(v) + defp valid?(:autostart, v), do: is_boolean(v) + defp valid?(:fs_roots, v), do: is_list(v) and Enum.all?(v, &is_binary/1) + + defp secret_shaped?(value) when is_binary(value), + do: Enum.any?(@secret_shapes, &Regex.match?(&1, value)) + + defp secret_shaped?(_), do: false + + # sobelow_skip reason: Traversal.FileModule: the path is `path/0` (configuration or the data + # directory plus a constant name) or a test's own temporary file, never request input. + @sobelow_skip ["Traversal.FileModule"] + defp read(file) do + with {:ok, bin} <- File.read(file), + {:ok, map} when is_map(map) <- JSON.decode(bin) do + {:ok, map} + else + {:ok, _other} -> {:error, :not_an_object} + {:error, reason} -> {:error, reason} + end + end + + # sobelow_skip reason: Traversal.FileModule: as read/1; the temporary file is the same path + # plus a constant suffix. + @sobelow_skip ["Traversal.FileModule"] + defp write(file, settings) do + tmp = file <> ".tmp" + body = settings |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) |> JSON.encode!() + + with :ok <- File.mkdir_p(Path.dirname(file)), + :ok <- File.write(tmp, body), + :ok <- File.chmod(tmp, 0o600) do + File.rename(tmp, file) + end + end +end diff --git a/lib/trinity/setup.ex b/lib/trinity/setup.ex new file mode 100644 index 0000000..03980ba --- /dev/null +++ b/lib/trinity/setup.ex @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Setup do + @moduledoc """ + Whether this installation still needs its first-run setup (slice 100, AC11). + + Measured, not assumed: setup is needed while it has not been completed **and** the default + model cannot answer, because it needs a key that neither the keychain nor the environment has + (or there is no default model at all). A developer with the key in the environment, or a + registry whose default needs no key (the suite's scripted model), is never sent to setup; an + owner who completed it once is not sent back if a key later goes missing (the turn's own error + says which key). + """ + + alias Trinity.{Config, LLM, Settings} + + @doc "True while the first-run setup has something to do." + @spec needed?() :: boolean() + def needed?, do: Settings.get(:onboarded_at) == nil and not default_model_ready?() + + @doc "Whether the default model has what it needs to answer: a key, if it names one." + @spec default_model_ready?() :: boolean() + def default_model_ready? do + case LLM.Registry.lookup(LLM.default_model()) do + {:ok, entry} -> model_ready?(entry) + {:error, _} -> false + end + end + + @doc "Whether a registry entry has its key (or needs none)." + @spec model_ready?(map()) :: boolean() + def model_ready?(entry) do + case Map.get(entry, :api_key_env) do + nil -> true + name -> match?({:ok, _}, Config.secret(name)) + end + end + + @doc "Marks the setup done and makes `model_id` the default model." + @spec complete(String.t()) :: :ok | {:error, term()} + def complete(model_id) do + with :ok <- Settings.put(:default_model, model_id) do + Settings.put(:onboarded_at, DateTime.utc_now() |> DateTime.to_iso8601()) + end + end +end diff --git a/lib/trinity/skills/registry.ex b/lib/trinity/skills/registry.ex index 64ebee9..dfe52ad 100644 --- a/lib/trinity/skills/registry.ex +++ b/lib/trinity/skills/registry.ex @@ -311,6 +311,14 @@ defmodule Trinity.Skills.Registry do # asserts it failed intermittently while the product was behaving exactly as designed. The # default is unchanged and is what ships; the suite sets a shorter one so that what it measures # is the watcher noticing rather than the runner's scheduler. + @doc """ + The options the registry starts each `FileSystem` watcher with: `[]` for the platform's native + backend, or polling where there is none. Public since slice 100 so the packaged binary's smoke + path (`Trinity.Smoke.watcher_line/0`) measures the watcher the registry would actually use. + """ + @spec watcher_options() :: keyword() + def watcher_options, do: backend() + defp backend do poll = [backend: :fs_poll, interval: poll_interval_ms()] diff --git a/lib/trinity/smoke.ex b/lib/trinity/smoke.ex index 77fd5c1..710d98a 100644 --- a/lib/trinity/smoke.ex +++ b/lib/trinity/smoke.ex @@ -29,6 +29,8 @@ defmodule Trinity.Smoke do use Boundary, top_level?: true, deps: [Trinity, TrinityWeb], exports: [] + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + @flag "--smoke" @type halt_fun :: (non_neg_integer() -> any()) @@ -130,6 +132,133 @@ defmodule Trinity.Smoke do end end + @doc """ + Slice 100, AC9: SQLite's FTS5 in this binary. Slice 031's session search is an FTS5 table, and + a SQLite built without it boots and serves and fails the first search. An in-memory database, + a virtual table, one row, one `MATCH`. Binding: exit 5. `ok` or `failed:`. + """ + @spec fts5_line() :: String.t() + def fts5_line do + alias Exqlite.Sqlite3 + + with {:ok, conn} <- Sqlite3.open(":memory:"), + :ok <- Sqlite3.execute(conn, "CREATE VIRTUAL TABLE smoke USING fts5(body)"), + :ok <- Sqlite3.execute(conn, "INSERT INTO smoke(body) VALUES ('packaged binary')"), + {:ok, stmt} <- + Sqlite3.prepare(conn, "SELECT count(*) FROM smoke WHERE smoke MATCH 'binary'"), + {:row, [1]} <- Sqlite3.step(conn, stmt), + :ok <- Sqlite3.release(conn, stmt), + :ok <- Sqlite3.close(conn) do + "TRINITY_SMOKE_FTS5=ok" + else + other -> "TRINITY_SMOKE_FTS5=failed:#{inspect(other) |> String.slice(0, 200)}" + end + end + + @doc """ + Slice 100, AC9: the file watcher the skills registry uses, measured in this binary: a watcher on + a fresh directory with the registry's own options (`Trinity.Skills.Registry.watcher_options/0`), + one file written, the event awaited. `ok:` when the native backend (or the configured + one) delivered it; `fallback:fs_poll:` when only polling did, which + is the registry's documented fallback and still exits 0 (slice 040: the reindex button and + `mix trinity.skills.reindex` work either way); `failed:` when neither did. Informative. + """ + @spec watcher_line() :: String.t() + def watcher_line do + opts = Trinity.Skills.Registry.watcher_options() + native = Keyword.get(opts, :backend, native_backend()) + + case watch_once(opts) do + :ok -> + "TRINITY_SMOKE_WATCHER=ok:#{native}" + + {:error, why} when native != :fs_poll -> + case watch_once(backend: :fs_poll, interval: 200) do + :ok -> + "TRINITY_SMOKE_WATCHER=fallback:fs_poll:#{inspect(why) |> String.slice(0, 120)}" + + {:error, poll} -> + "TRINITY_SMOKE_WATCHER=failed:#{inspect({why, poll}) |> String.slice(0, 200)}" + end + + {:error, why} -> + "TRINITY_SMOKE_WATCHER=failed:#{inspect(why) |> String.slice(0, 200)}" + end + end + + defp native_backend do + case :os.type() do + {:unix, :darwin} -> :fs_mac + {:win32, _} -> :fs_windows + _ -> :fs_inotify + end + end + + # sobelow_skip reason: Traversal.FileModule: the directory is the system temporary directory + # plus a constant prefix and a unique integer, and the file in it a constant name; nothing comes + # from a request. + @sobelow_skip ["Traversal.FileModule"] + defp watch_once(opts) do + dir = + Path.join(System.tmp_dir!(), "trinity-smoke-watch-#{System.unique_integer([:positive])}") + + File.mkdir_p!(dir) + + try do + case FileSystem.start_link([dirs: [dir]] ++ opts) do + {:ok, pid} -> + FileSystem.subscribe(pid) + # A native backend needs a moment to place its watch before the write it should see. + Process.sleep(300) + File.write!(Path.join(dir, "probe.txt"), "smoke") + + result = + receive do + {:file_event, ^pid, {_path, _events}} -> :ok + {:file_event, ^pid, :stop} -> {:error, :watcher_stopped} + after + 4_000 -> {:error, :no_event} + end + + Process.unlink(pid) + Process.exit(pid, :shutdown) + result + + other -> + {:error, other} + end + after + File.rm_rf(dir) + end + end + + @doc """ + Slice 100, AC9: the local embedder's model cache resolves to a directory this binary can write + (`Trinity.Memory.Embedders.Bumblebee.cache_dir/0`: `BUMBLEBEE_CACHE_DIR`, the configured one, or + the data directory's `models/`). Binding: exit 6. `ok:` or `failed:`. + """ + # sobelow_skip reason: Traversal.FileModule: the directory is the model cache in force + # (`BUMBLEBEE_CACHE_DIR`, configuration, or the data directory's `models/`), never request input. + @sobelow_skip ["Traversal.FileModule"] + @spec model_cache_line() :: String.t() + def model_cache_line do + dir = Trinity.Memory.Embedders.Bumblebee.cache_dir() + + # Created if absent, as the first download would create it; otherwise only looked at, so a + # check run against an existing installation writes nothing into it. + with :ok <- File.mkdir_p(dir), + {:ok, %File.Stat{type: :directory, access: access}} <- File.stat(dir), + true <- access in [:read_write, :write] || {:error, {:not_writable, access}} do + "TRINITY_SMOKE_MODEL_CACHE=ok:#{dir}" + else + {:ok, %File.Stat{type: type}} -> + "TRINITY_SMOKE_MODEL_CACHE=failed:#{inspect({:not_a_directory, type})}" + + {:error, reason} -> + "TRINITY_SMOKE_MODEL_CACHE=failed:#{inspect(reason)}" + end + end + @doc """ Runs the smoke check: report the listening port, then stop the OS process. @@ -141,15 +270,16 @@ defmodule Trinity.Smoke do {:ok, {_ip, port}} = TrinityWeb.Endpoint.server_info(:http) say.(port_line(port)) say.(markdown) - [exla, vec, semantic] = rest || [exla_line(), vec_line(), semantic_line()] - say.(exla) - say.(vec) - say.(semantic) + lines = rest || probe_lines() + Enum.each(lines, say) + line = fn key -> Enum.find(lines, "", &String.starts_with?(&1, "TRINITY_SMOKE_#{key}=")) end halt.( cond do markdown != "TRINITY_SMOKE_MARKDOWN=ok" -> 3 - not String.starts_with?(vec, "TRINITY_SMOKE_VEC=ok:") -> 4 + not String.starts_with?(line.("VEC"), "TRINITY_SMOKE_VEC=ok:") -> 4 + line.("FTS5") != "TRINITY_SMOKE_FTS5=ok" -> 5 + not String.starts_with?(line.("MODEL_CACHE"), "TRINITY_SMOKE_MODEL_CACHE=ok:") -> 6 true -> 0 end ) @@ -202,10 +332,29 @@ defmodule Trinity.Smoke do :persistent_term.get({__MODULE__, :probed}, [ "TRINITY_SMOKE_EXLA=failed:not_probed", "TRINITY_SMOKE_VEC=failed:not_probed", - "TRINITY_SMOKE_SEMANTIC=off:not_probed" + "TRINITY_SMOKE_SEMANTIC=off:not_probed", + "TRINITY_SMOKE_FTS5=failed:not_probed", + "TRINITY_SMOKE_WATCHER=failed:not_probed", + "TRINITY_SMOKE_MODEL_CACHE=failed:not_probed" ]) end + @doc """ + The lines after the markdown one, in the order they are printed: slice 032's three, then slice + 100's three (AC9). Computed by `Probe`, inside the tree, where the Repo is up. + """ + @spec probe_lines() :: [String.t()] + def probe_lines do + [ + exla_line(), + vec_line(), + semantic_line(), + fts5_line(), + watcher_line(), + model_cache_line() + ] + end + defmodule Probe do @moduledoc false use Boundary, top_level?: true, deps: [Trinity, Trinity.Smoke] @@ -213,8 +362,7 @@ defmodule Trinity.Smoke do def child_spec(_), do: %{id: __MODULE__, start: {__MODULE__, :start_link, []}} def start_link do - lines = [Trinity.Smoke.exla_line(), Trinity.Smoke.vec_line(), Trinity.Smoke.semantic_line()] - :persistent_term.put({Trinity.Smoke, :probed}, lines) + :persistent_term.put({Trinity.Smoke, :probed}, Trinity.Smoke.probe_lines()) :ignore end end diff --git a/lib/trinity/tools/fs.ex b/lib/trinity/tools/fs.ex index a6bafc3..66d7cde 100644 --- a/lib/trinity/tools/fs.ex +++ b/lib/trinity/tools/fs.ex @@ -23,11 +23,15 @@ defmodule Trinity.Tools.FS do # measure `Trinity.Paths` takes). Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) - @doc "The configured roots plus the data directory, each expanded." + @doc """ + The configured roots plus the data directory, each expanded. Slice 100 adds the folders the + owner chose in Settings or the setup path (`Trinity.Settings`, `:fs_roots`). + """ @spec roots() :: [String.t()] def roots do configured = Application.get_env(:trinity, :fs, []) |> Keyword.get(:roots, []) - Enum.map([Trinity.Paths.data_dir() | configured], &Path.expand/1) |> Enum.uniq() + chosen = Trinity.Settings.get(:fs_roots) + Enum.map([Trinity.Paths.data_dir() | configured ++ chosen], &Path.expand/1) |> Enum.uniq() end @doc """ diff --git a/lib/trinity_web/components/settings_components.ex b/lib/trinity_web/components/settings_components.ex new file mode 100644 index 0000000..a19cf65 --- /dev/null +++ b/lib/trinity_web/components/settings_components.ex @@ -0,0 +1,222 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.SettingsComponents do + @moduledoc """ + The pieces `/settings` and `/setup` share (slice 100): a provider key's row, the folders the + filesystem tools may use, and the data directory; with the event handling behind them, so the + two pages cannot disagree about where a key goes or what a folder must be. + + **A key goes to the keychain and nowhere else** (`Trinity.Secrets.store/2`). The field is a + password input whose value is never rendered back; its parameter is `secret`, which + `config :phoenix, :filter_parameters` logs as `[FILTERED]`. With no keychain the field is + disabled and the page says how to supply the key instead (the environment). + """ + use Phoenix.Component + use Gettext, backend: TrinityWeb.Gettext + + require Phoenix.LiveView + + alias Trinity.{Desktop, Secrets, Settings} + + ## State + + @doc "The key names the pages offer: each model's `api_key_env`, then the web search key." + @spec key_names() :: [String.t()] + def key_names do + from_models = + Trinity.LLM.models() |> Enum.map(&Map.get(&1, :api_key_env)) |> Enum.reject(&is_nil/1) + + Enum.uniq(from_models ++ ["BRAVE_SEARCH_API_KEY"]) + end + + @doc "Assigns `keychain?` and `keys` (each name with where it is found, never its value)." + @spec assign_keys(Phoenix.LiveView.Socket.t(), [String.t()]) :: Phoenix.LiveView.Socket.t() + def assign_keys(socket, names \\ key_names()) do + assign(socket, + keychain?: Secrets.keychain_available?(), + keys: Enum.map(names, &%{name: &1, source: Secrets.source(&1)}) + ) + end + + @doc "Assigns the saved folders." + @spec assign_roots(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def assign_roots(socket), do: assign(socket, roots: Settings.get(:fs_roots), root_error: nil) + + ## Events + + @doc "Stores a key typed into the page. The value is not kept in the socket or logged." + @spec save_key(Phoenix.LiveView.Socket.t(), String.t(), String.t()) :: + Phoenix.LiveView.Socket.t() + def save_key(socket, name, value) do + socket = + case Secrets.store(name, value) do + :ok -> + Phoenix.LiveView.put_flash( + socket, + :info, + gettext("Saved %{name} to the keychain.", name: name) + ) + + {:error, :keychain_unavailable} -> + Phoenix.LiveView.put_flash(socket, :error, gettext("No keychain in this run.")) + + {:error, :invalid_value} -> + Phoenix.LiveView.put_flash( + socket, + :error, + gettext("That key is empty or has a line break in it.") + ) + + {:error, reason} -> + Phoenix.LiveView.put_flash( + socket, + :error, + gettext("The keychain refused: %{reason}", reason: inspect(reason)) + ) + end + + assign_keys(socket) + end + + @doc "Removes a key from the keychain." + @spec remove_key(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t() + def remove_key(socket, name) do + _ = Secrets.delete(name) + assign_keys(socket) + end + + @doc "Adds a folder after checking it is an existing absolute directory." + @spec add_root(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t() + def add_root(socket, root) do + root = String.trim(root) + + cond do + Path.type(root) != :absolute -> + assign(socket, root_error: gettext("A folder must be an absolute path.")) + + not File.dir?(root) -> + assign(socket, root_error: gettext("No folder exists at %{root}.", root: root)) + + true -> + roots = Enum.uniq(Settings.get(:fs_roots) ++ [root]) + + case Settings.put(:fs_roots, roots) do + :ok -> assign_roots(socket) + {:error, reason} -> assign(socket, root_error: inspect(reason)) + end + end + end + + @doc "Removes a folder." + @spec remove_root(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t() + def remove_root(socket, root) do + :ok = Settings.put(:fs_roots, List.delete(Settings.get(:fs_roots), root)) + assign_roots(socket) + end + + @doc "Opens the native folder dialog without blocking the page; `picked/2` handles the answer." + @spec pick_root(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def pick_root(socket) do + Phoenix.LiveView.start_async(socket, :pick_root, fn -> + Desktop.open_dialog(kind: :folder, title: "Choose a folder Trinity may use") + end) + end + + @doc "The dialog's answer: each folder chosen is added the way a typed one is." + @spec picked(Phoenix.LiveView.Socket.t(), term()) :: Phoenix.LiveView.Socket.t() + def picked(socket, {:ok, {:ok, paths}}), do: Enum.reduce(paths, socket, &add_root(&2, &1)) + + def picked(socket, {:ok, {:error, :no_shell}}), + do: + assign(socket, root_error: gettext("No desktop shell in this run; type the folder below.")) + + def picked(socket, other), + do: assign(socket, root_error: gettext("The dialog did not answer: %{r}", r: inspect(other))) + + ## Components + + @doc "One provider key: where it is found, and a field that stores it in the keychain." + attr :key, :map, required: true + attr :keychain?, :boolean, required: true + + def key_row(assigns) do + ~H""" +
+ + {@key.name} + + {source_label(@key.source)} + + + + +
+ """ + end + + defp source_label(:keychain), do: gettext("in the keychain") + defp source_label(:env), do: gettext("from the environment") + defp source_label(:none), do: gettext("not set") + + @doc "The folders the filesystem tools may use, with the dialog and a typed fallback." + attr :roots, :list, required: true + attr :root_error, :string, default: nil + + def roots(assigns) do + ~H""" +
+
    +
  • + {root} + +
  • +
  • {gettext("No folders yet.")}
  • +
+
+ +
+ + +
+
+

{@root_error}

+
+ """ + end +end diff --git a/lib/trinity_web/live/session_live/index.ex b/lib/trinity_web/live/session_live/index.ex index fc3560d..63a1986 100644 --- a/lib/trinity_web/live/session_live/index.ex +++ b/lib/trinity_web/live/session_live/index.ex @@ -4,6 +4,7 @@ defmodule TrinityWeb.SessionLive.Index do @moduledoc """ The sessions index at `/`: every session, most recently active first, and New session. Slice 013. A new session is a row on the default persona; the chat itself is `SessionLive.Show`. + Slice 100: while `Trinity.Setup.needed?/0`, a visitor is sent to `/setup` instead. """ use TrinityWeb, :live_view @@ -13,6 +14,13 @@ defmodule TrinityWeb.SessionLive.Index do @impl true def mount(_params, _session, socket) do + if Trinity.Setup.needed?(), + do: {:ok, push_navigate(socket, to: ~p"/setup")}, + else: mount_index(socket) + end + + # Slice 100: a machine with nothing configured goes to the setup path first (AC11). + defp mount_index(socket) do default = Trinity.Personas.default() {:ok, diff --git a/lib/trinity_web/live/session_live/show.ex b/lib/trinity_web/live/session_live/show.ex index 1faf7d3..5bad194 100644 --- a/lib/trinity_web/live/session_live/show.ex +++ b/lib/trinity_web/live/session_live/show.ex @@ -107,9 +107,22 @@ defmodule TrinityWeb.SessionLive.Show do |> assign( last_user_message: done |> Enum.filter(&(&1.role == "user")) |> List.last() |> content() ) + |> assign_banner_from(done) |> stream(:messages, done) end + # Slice 100, AC7: a turn the quit path finalised as interrupted leaves no draft, so the session's + # rehydrate broadcasts nothing and the event below never arrives. The newest row says it instead. + defp assign_banner_from(socket, rows) do + case List.last(rows) do + %Message{role: "assistant", parts: %{"interrupted" => true}} -> + assign(socket, banner: :interrupted) + + _ -> + socket + end + end + # Slice 023: the estimate of the next request, from the rows the page holds. defp assign_context(socket, rows) do session = socket.assigns.session diff --git a/lib/trinity_web/live/settings_live.ex b/lib/trinity_web/live/settings_live.ex index 7e27df3..550f6b2 100644 --- a/lib/trinity_web/live/settings_live.ex +++ b/lib/trinity_web/live/settings_live.ex @@ -2,22 +2,42 @@ # SPDX-License-Identifier: Apache-2.0 defmodule TrinityWeb.SettingsLive do @moduledoc """ - `/settings` (slice 034, the first settings page): the export as a download, with and without - the private keys, the restore procedure in a sentence with a link to docs/backup.md, and - links to the boot receipt, the personas and the memory. + `/settings`. Slice 034 made it: the export as a download, with and without the private keys, + and links to the boot receipt, the personas and the memory. Slice 100 adds the desktop's + settings: provider keys (stored in the OS keychain and only there, AC5), the folders the + filesystem tools may use (picked in the native dialog, or typed), the global shortcut, + notifications, launch at login, pausing the gateways, and the data directory and the budgets in + force, shown. Budgets and the data directory are set outside the app (configuration and the + environment); the page says where. """ use TrinityWeb, :live_view + import TrinityWeb.SettingsComponents + + alias Trinity.{Desktop, Gateways, Settings} + alias TrinityWeb.SettingsComponents + @impl true def mount(_params, _session, socket) do layout = Trinity.Archive.Layout.current() + settings = Settings.all() {:ok, - assign(socket, + socket + |> assign( page_title: gettext("Settings"), data_dir: layout.data_dir, - present: Trinity.Archive.Layout.present(layout) - )} + present: Trinity.Archive.Layout.present(layout), + hotkey: settings.hotkey, + hotkey_error: nil, + muted: settings.notifications_muted, + autostart: settings.autostart, + paused: Gateways.paused?(), + budgets: + Enum.map(Trinity.Telemetry.Costs.scopes(), &{&1, Trinity.Telemetry.Costs.budget(&1)}) + ) + |> SettingsComponents.assign_keys() + |> SettingsComponents.assign_roots()} end @impl true @@ -26,6 +46,62 @@ defmodule TrinityWeb.SettingsLive do def handle_event("cancel", _params, socket), do: {:noreply, socket} + def handle_event("save_key", %{"name" => name, "secret" => %{"value" => value}}, socket), + do: {:noreply, SettingsComponents.save_key(socket, name, value)} + + def handle_event("remove_key", %{"name" => name}, socket), + do: {:noreply, SettingsComponents.remove_key(socket, name)} + + def handle_event("add_root", %{"root" => root}, socket), + do: {:noreply, SettingsComponents.add_root(socket, root)} + + def handle_event("remove_root", %{"root" => root}, socket), + do: {:noreply, SettingsComponents.remove_root(socket, root)} + + def handle_event("pick_root", _params, socket), + do: {:noreply, SettingsComponents.pick_root(socket)} + + def handle_event("save_hotkey", %{"hotkey" => hotkey}, socket) do + hotkey = String.trim(hotkey) + + if Desktop.valid_hotkey?(hotkey) do + :ok = Settings.put(:hotkey, hotkey) + _ = Desktop.set_hotkey(hotkey) + {:noreply, assign(socket, hotkey: hotkey, hotkey_error: nil)} + else + {:noreply, + assign(socket, + hotkey_error: + gettext("%{hotkey} is not a shortcut: modifiers and one key, such as %{example}.", + hotkey: hotkey, + example: "CommandOrControl+Shift+Space" + ) + )} + end + end + + def handle_event("toggle_muted", _params, socket) do + muted = not socket.assigns.muted + :ok = Settings.put(:notifications_muted, muted) + {:noreply, assign(socket, muted: muted)} + end + + def handle_event("toggle_autostart", _params, socket) do + autostart = not socket.assigns.autostart + :ok = Settings.put(:autostart, autostart) + _ = Desktop.set_autostart(autostart) + {:noreply, assign(socket, autostart: autostart)} + end + + def handle_event("toggle_gateways", _params, socket) do + if Gateways.paused?(), do: Gateways.resume(), else: Gateways.pause() + {:noreply, assign(socket, paused: Gateways.paused?())} + end + + @impl true + def handle_async(:pick_root, result, socket), + do: {:noreply, SettingsComponents.picked(socket, result)} + @impl true def render(assigns) do ~H""" @@ -36,6 +112,97 @@ defmodule TrinityWeb.SettingsLive do phx-hook="Shortcuts" class="mx-auto flex h-full max-w-3xl flex-col gap-6 overflow-y-auto px-4 py-6" > +
+

{gettext("Provider keys")}

+

+ {gettext( + "Keys are kept in your operating system's keychain, never in Trinity's database or files. A key in the keychain wins over the same name in the environment." + )} +

+

+ {gettext( + "No keychain in this run (Trinity was not started by its desktop app). Set a key in the environment instead, under the name shown." + )} +

+ <.key_row :for={key <- @keys} key={key} keychain?={@keychain?} /> +
+ +
+

{gettext("Folders the tools may use")}

+

+ {gettext( + "The file tools read and write inside these folders and the data directory without asking; anywhere else, they ask first." + )} +

+ <.roots roots={@roots} root_error={@root_error} /> +
+ +
+

{gettext("Desktop")}

+
+ + + +
+

{@hotkey_error}

+ + +
+ +
+

{gettext("Gateways")}

+
+ + {if @paused, + do: gettext("Gateways are paused: messages from channels are not read."), + else: gettext("Gateways are running.")} + + + <.link navigate={~p"/gateways"} class="underline opacity-70">{gettext("channels")} +
+
+ +
+

{gettext("Budgets")}

+
+ <%= for {scope, limit} <- @budgets do %> +
{scope}
+
{if limit, do: "$#{limit}", else: gettext("none")}
+ <% end %> +
+

+ {gettext( + "Set in configuration (config :trinity, :budgets); spending is on the activity page." + )} +

+
+

{gettext("Take your Trinity with you")}

@@ -64,6 +231,11 @@ defmodule TrinityWeb.SettingsLive do

{gettext("present")}
{Enum.join(@present, ", ")}
+

+ {gettext( + "The data directory follows the operating system's convention (XDG_DATA_HOME on Linux, APPDATA on Windows, Application Support on macOS); change it there." + )} +

{gettext("Elsewhere")}

diff --git a/lib/trinity_web/live/setup_live.ex b/lib/trinity_web/live/setup_live.ex new file mode 100644 index 0000000..c9d9206 --- /dev/null +++ b/lib/trinity_web/live/setup_live.ex @@ -0,0 +1,186 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.SetupLive do + @moduledoc """ + `/setup`, the first-run path (slice 100, AC11). On a machine with nothing configured the chat + cannot answer: no key, no model, no confirmed data directory, no folder the tools may use. Three + steps, reusing the Settings page's pieces (`TrinityWeb.SettingsComponents`), and then a session: + + 1. a model and its key (the key goes to the keychain, as on Settings); + 2. the data directory, shown and confirmed; + 3. the first project folders (the native dialog, or typed). + + `/` sends a visitor here while `Trinity.Setup.needed?/0`; finishing records the model as the + default and the setup as done, and opens a new session. + """ + use TrinityWeb, :live_view + + import TrinityWeb.SettingsComponents + + alias Trinity.{Secrets, Sessions, Setup} + alias TrinityWeb.SettingsComponents + + @impl true + def mount(_params, _session, socket) do + models = Enum.filter(Trinity.LLM.models(), &(:stream in Map.get(&1, :caps, []))) + default = Trinity.LLM.default_model() + + {:ok, + socket + |> assign( + page_title: gettext("Set up Trinity"), + models: models, + model_id: default, + model_ok: false, + model_error: nil, + keychain?: Secrets.keychain_available?(), + data_dir: Trinity.Paths.data_dir(), + data_dir_ok: false + ) + |> SettingsComponents.assign_roots()} + end + + @impl true + def handle_event("choose_model", %{"setup" => %{"model" => id} = params}, socket) do + case Enum.find(socket.assigns.models, &(&1.id == id)) do + nil -> + {:noreply, assign(socket, model_error: gettext("Choose a model from the list."))} + + model -> + {:noreply, choose(socket, model, String.trim(Map.get(params, "secret", "")))} + end + end + + def handle_event("confirm_data_dir", _params, socket), + do: {:noreply, assign(socket, data_dir_ok: true)} + + def handle_event("add_root", %{"root" => root}, socket), + do: {:noreply, SettingsComponents.add_root(socket, root)} + + def handle_event("remove_root", %{"root" => root}, socket), + do: {:noreply, SettingsComponents.remove_root(socket, root)} + + def handle_event("pick_root", _params, socket), + do: {:noreply, SettingsComponents.pick_root(socket)} + + def handle_event("finish", _params, %{assigns: %{model_ok: true, data_dir_ok: true}} = socket) do + :ok = Setup.complete(socket.assigns.model_id) + + case Sessions.create_session(%{persona_id: Sessions.default_persona().id, origin: "desktop"}) do + {:ok, session} -> {:noreply, push_navigate(socket, to: ~p"/s/#{session.id}")} + {:error, _} -> {:noreply, push_navigate(socket, to: ~p"/")} + end + end + + def handle_event("finish", _params, socket), do: {:noreply, socket} + + @impl true + def handle_async(:pick_root, result, socket), + do: {:noreply, SettingsComponents.picked(socket, result)} + + # A model that needs a key gets one now, or must already have one; nothing is stored anywhere + # but the keychain. + defp choose(socket, model, key) do + name = Map.get(model, :api_key_env) + + stored = if name == nil or key == "", do: :ok, else: Secrets.store(name, key) + + cond do + stored == {:error, :keychain_unavailable} -> + assign(socket, + model_ok: false, + model_error: + gettext("No keychain in this run: set %{name} in the environment and reload.", + name: name + ) + ) + + match?({:error, _}, stored) -> + assign(socket, model_ok: false, model_error: gettext("The keychain refused the key.")) + + not Setup.model_ready?(model) -> + assign(socket, + model_ok: false, + model_error: gettext("%{id} needs a key (%{name}).", id: model.id, name: name) + ) + + true -> + assign(socket, model_id: model.id, model_ok: true, model_error: nil) + end + end + + @impl true + def render(assigns) do + ~H""" + + <:bar>{gettext("Set up Trinity")} +
+

+ {gettext( + "Three things before the first conversation. Each can be changed later in Settings." + )} +

+ +
+

{gettext("1. A model, and its key")}

+
+ + + +
+

{@model_error}

+

+ {gettext("%{id} is ready.", id: @model_id)} +

+
+ +
+

{gettext("2. Where Trinity keeps its data")}

+ {@data_dir} +
+ + {gettext("Confirmed.")} +
+
+ +
+

{gettext("3. Project folders (optional)")}

+

+ {gettext( + "The file tools may read and write in these without asking. You can add more later." + )} +

+ <.roots roots={@roots} root_error={@root_error} /> +
+ + +
+
+ """ + end +end diff --git a/lib/trinity_web/router.ex b/lib/trinity_web/router.ex index 534a4c5..0be5ee6 100644 --- a/lib/trinity_web/router.ex +++ b/lib/trinity_web/router.ex @@ -50,6 +50,8 @@ defmodule TrinityWeb.Router do live "/activity", ActivityLive, :index # Slice 034: settings, with the export as a download. live "/settings", SettingsLive, :index + # Slice 100: the first-run path, reusing the settings components. + live "/setup", SetupLive, :index get "/settings/export.tar.gz", ExportController, :download # Slice 030: personas and the always-on memory. live "/personas", PersonasLive, :index diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 554ab4c..d33c5a1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -102,6 +113,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -224,6 +246,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -287,6 +320,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -433,6 +475,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.5" @@ -476,6 +527,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -488,6 +545,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.8" @@ -517,6 +584,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -540,7 +617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -553,7 +630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -684,6 +761,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "defmt" version = "1.1.1" @@ -753,6 +848,16 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", ] [[package]] @@ -761,7 +866,18 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -772,7 +888,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -899,7 +1015,7 @@ dependencies = [ "rustc_version", "toml 1.1.5+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1293,6 +1409,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1412,6 +1538,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.20", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1511,6 +1655,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1779,6 +1941,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1974,6 +2146,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zbus 4.4.0", + "zeroize", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2187,6 +2376,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -2198,7 +2400,40 @@ dependencies = [ "mac-notification-sys", "serde", "tauri-winrt-notification", - "zbus", + "zbus 5.19.0", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", ] [[package]] @@ -2207,6 +2442,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2812,14 +3077,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -2829,7 +3115,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2856,6 +3151,17 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -2950,6 +3256,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3050,6 +3380,61 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3238,6 +3623,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3385,6 +3781,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -3415,6 +3817,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3499,7 +3907,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3557,7 +3965,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3607,7 +4015,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3677,6 +4085,77 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.5+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-plugin-log" version = "2.9.1" @@ -3706,7 +4185,7 @@ checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" dependencies = [ "log", "notify-rust", - "rand", + "rand 0.9.5", "serde", "serde_json", "serde_repr", @@ -3717,6 +4196,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "url", + "windows", + "zbus 5.19.0", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.6" @@ -3751,7 +4252,7 @@ dependencies = [ "tokio", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.19.0", ] [[package]] @@ -4214,7 +4715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4233,13 +4734,20 @@ dependencies = [ name = "trinity" version = "0.2.0" dependencies = [ + "getrandom 0.3.4", + "keyring", "log", + "notify-rust", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-notification", + "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", ] @@ -4799,6 +5307,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5054,6 +5571,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5086,7 +5612,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -5141,6 +5667,39 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" @@ -5164,6 +5723,44 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.19.0" @@ -5194,9 +5791,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.4", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", ] [[package]] @@ -5209,9 +5819,20 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.5", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -5222,7 +5843,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", "winnow 1.0.4", - "zvariant", + "zvariant 5.15.0", ] [[package]] @@ -5275,6 +5896,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -5320,6 +5961,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.15.0" @@ -5331,8 +5985,21 @@ dependencies = [ "serde", "winnow 1.0.4", "zcheapstr", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", ] [[package]] @@ -5345,7 +6012,18 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.5", - "zvariant_utils", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d038ac1..881484b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,17 +6,41 @@ edition = "2021" description = "" [build-dependencies] -tauri-build = { version = "2", features = [] } +# Slice 100: held on the line the lock already had (2.6.x, with tauri 2.11.x below), so adding +# plugins does not move the shell's tauri; see the plugins' comment. +tauri-build = { version = "~2.6.3", features = [] } [dependencies] log = "0.4" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "~2.11.5", features = ["tray-icon"] } tauri-plugin-shell = "2" tauri-plugin-log = "2" tauri-plugin-notification = "2" tauri-plugin-single-instance = "2" +# Slice 100. The global show/hide shortcut, launch at login, the folder dialog for the +# filesystem allowlist, and Open data folder. Tauri's own plugins, driven from Rust: none of them +# is exposed to the webview, so capabilities/default.json does not change. Each is on the newest +# line whose release still accepts tauri 2.11: the next minors require tauri 2.12, and this slice +# does not move the shell's tauri (`cargo info @ -v`, 2026-10-08; NOTES). +tauri-plugin-global-shortcut = "~2.3.2" +tauri-plugin-autostart = "~2.5.1" +tauri-plugin-dialog = "~2.7.1" +tauri-plugin-opener = "~2.5.4" +# Slice 100. The OS keychain for `trinity --keychain` (src/keychain.rs): the macOS Keychain, the +# Windows Credential Manager, and the Secret Service over D-Bus on Linux, the last through zbus with +# its own executor (async-io) and pure-Rust crypto, so no system library is added to the build. +keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] } + +# Slice 100. A notification whose click is reported back (a freedesktop action on Linux, the +# notification centre's response on macOS, the toast's activation on Windows), which is how a click +# on an approval's notification shows the window on the approval. Already in the lock through the +# notification plugin; named here because this code calls it. +notify-rust = "4.18.0" +# Slice 100. The token the shell hands the sidecar and presents in its hello (finding F2). Already +# in the lock; named here because this code calls it. +getrandom = "0.3.4" [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. diff --git a/src-tauri/src/keychain.rs b/src-tauri/src/keychain.rs new file mode 100644 index 0000000..5dad71a --- /dev/null +++ b/src-tauri/src/keychain.rs @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Sudo Apt Holdings LLC +// SPDX-License-Identifier: Apache-2.0 +// +// Slice 100. `trinity --keychain [name]`: the shell binary run as a command, so the BEAM can +// reach the OS keychain at any moment, including at boot before any window exists (NOTES D1). +// The `keyring` crate does the OS work: the macOS Keychain, the Windows Credential Manager, the +// Secret Service on Linux. +// +// The protocol, which `Trinity.Secrets.Keychain` speaks: +// +// probe exit 0 when the keychain answers +// get stdout: the value, hex, one line +// set stdin: the value, hex, one line +// delete +// +// Exit 0 done, 2 usage, 3 no such entry, 4 the keychain could not be reached. A value never +// appears on the command line, and nothing printed to stderr carries one: errors are reported by +// kind only (keyring's `BadEncoding` holds the stored bytes, so its Debug form is never printed). + +use std::io::{BufRead, Write}; + +// Every entry Trinity keeps lives under this one service. The name is the entry's account. +const SERVICE: &str = "Trinity"; +const PROBE: &str = "TRINITY_KEYCHAIN_PROBE"; + +const OK: i32 = 0; +const USAGE: i32 = 2; +const NOT_FOUND: i32 = 3; +const UNREACHABLE: i32 = 4; + +pub fn run(args: &[String]) -> i32 { + let op = args.first().map(String::as_str); + let name = args.get(1).map(String::as_str); + + match (op, name) { + (Some("probe"), None) => probe(), + (Some("get"), Some(name)) if valid_name(name) => get(name), + (Some("set"), Some(name)) if valid_name(name) => set(name), + (Some("delete"), Some(name)) if valid_name(name) => delete(name), + _ => { + eprintln!("usage: trinity --keychain probe | get | set | delete "); + USAGE + } + } +} + +// The same rule `Trinity.Secrets` applies: an environment-variable name, at most 64 bytes. +fn valid_name(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && bytes[0].is_ascii_uppercase() + && bytes + .iter() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_') +} + +fn entry(name: &str) -> Result { + keyring::Entry::new(SERVICE, name).map_err(|error| report(&error)) +} + +fn probe() -> i32 { + match entry(PROBE).map(|e| e.get_password()) { + Ok(Ok(_)) | Ok(Err(keyring::Error::NoEntry)) => OK, + Ok(Err(error)) => report(&error), + Err(code) => code, + } +} + +fn get(name: &str) -> i32 { + let entry = match entry(name) { + Ok(entry) => entry, + Err(code) => return code, + }; + + match entry.get_password() { + Ok(value) => { + let mut out = std::io::stdout().lock(); + if writeln!(out, "{}", to_hex(value.as_bytes())).is_err() { + return UNREACHABLE; + } + OK + } + Err(keyring::Error::NoEntry) => NOT_FOUND, + Err(error) => report(&error), + } +} + +fn set(name: &str) -> i32 { + let mut line = String::new(); + if std::io::stdin().lock().read_line(&mut line).is_err() { + return USAGE; + } + + let value = match from_hex(line.trim()).and_then(|bytes| String::from_utf8(bytes).ok()) { + Some(value) if !value.is_empty() => value, + _ => { + eprintln!("keychain: the value on stdin is not one line of hex-encoded UTF-8"); + return USAGE; + } + }; + + let entry = match entry(name) { + Ok(entry) => entry, + Err(code) => return code, + }; + + match entry.set_password(&value) { + Ok(()) => OK, + Err(error) => report(&error), + } +} + +fn delete(name: &str) -> i32 { + let entry = match entry(name) { + Ok(entry) => entry, + Err(code) => return code, + }; + + match entry.delete_credential() { + Ok(()) => OK, + Err(keyring::Error::NoEntry) => NOT_FOUND, + Err(error) => report(&error), + } +} + +// The kind of failure, never its payload. +fn report(error: &keyring::Error) -> i32 { + let kind = match error { + keyring::Error::PlatformFailure(_) => "the platform's keychain failed", + keyring::Error::NoStorageAccess(_) => "the keychain is locked or access was refused", + keyring::Error::NoEntry => "no such entry", + keyring::Error::BadEncoding(_) => "the stored entry is not UTF-8", + keyring::Error::TooLong(_, _) => "an attribute is too long for this keychain", + keyring::Error::Invalid(_, _) => "an attribute is invalid for this keychain", + keyring::Error::Ambiguous(_) => "more than one entry matches", + _ => "an unknown keychain error", + }; + eprintln!("keychain: {kind}"); + UNREACHABLE +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn from_hex(text: &str) -> Option> { + if text.len() % 2 != 0 { + return None; + } + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok()) + .collect() +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc6dfdb..2ebeb30 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +// Slice 100: `trinity --keychain ...`, the shell binary as the BEAM's keychain helper. +mod keychain; + +use tauri_plugin_autostart::{MacosLauncher, ManagerExt as AutostartExt}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; +use tauri_plugin_opener::OpenerExt; use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::ShellExt; use tauri::Manager; @@ -10,7 +18,7 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; use std::io::Write; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use std::time::Duration; // Flipped to false when the app is quitting. The channel threads stop sending @@ -24,9 +32,19 @@ static HEARTBEAT_ACTIVE: AtomicBool = AtomicBool::new(true); static CHANNEL_TX: Mutex>> = Mutex::new(None); // Handle to the tray icon created via the "set_tray" channel command. -// Kept so a later set_tray replaces (drops) the previous icon. +// Kept so a later set_tray updates it in place. static TRAY: Mutex> = Mutex::new(None); +// Slice 100. The token this launch hands the sidecar (TRINITY_SHELL_TOKEN) and +// presents in its hello on every connection, so Trinity.Desktop.Shell can tell +// this shell from any other local process that reaches the channel (finding +// F2: on Windows the channel is a loopback TCP port). +static TOKEN: OnceLock = OnceLock::new(); + +// Slice 100. The port the sidecar serves on, for navigating the window to a +// page of the app (a notification's click, New session). +static PORT: OnceLock = OnceLock::new(); + struct AppState { sidecar_child: Mutex>, } @@ -53,7 +71,7 @@ fn send_channel_message(message: String) { } // Forwards a native event (menu click, tray click, errors) to the Elixir -// sidecar, where ExTauri.Desktop delivers it to subscribed processes. +// sidecar, where Trinity.Desktop.Shell receives it. fn send_channel_event(name: &str, payload: serde_json::Value) { let message = serde_json::json!({"type": "event", "name": name, "payload": payload}); send_channel_message(message.to_string()); @@ -71,10 +89,22 @@ fn kill_sidecar(app: &tauri::AppHandle) { if let Some(pid) = process.pid { println!("Attempting graceful shutdown of sidecar (PID: {})...", pid); - // Send SIGTERM for graceful shutdown #[cfg(unix)] { use std::process::Command; + + // Slice 100, finding F1: in a packaged build the sidecar is the Burrito + // wrapper and the BEAM is its child, and the wrapper does not forward a + // signal to it (slice 001 measured the BEAM orphaned, still serving). The + // BEAM's own SIGTERM is OTP's graceful stop, which is what keeps a + // streaming turn as interrupted (AC7), so a `beam.smp` child of the + // sidecar is signalled by name. Only that name: in development the + // sidecar *is* the BEAM (a script that execs `mix phx.server`), and its + // children are the BEAM's own helpers (`erl_child_setup`), which must not + // be signalled; there the TERM below reaches the BEAM directly. + let _ = Command::new("pkill") + .args(["-TERM", "-x", "-P", &pid.to_string(), "beam.smp"]) + .output(); let _ = Command::new("kill") .args(["-TERM", &pid.to_string()]) .output(); @@ -122,14 +152,49 @@ fn kill_sidecar(app: &tauri::AppHandle) { } } +// Slice 100: the one way Trinity quits from a menu, the tray or the Elixir side. +fn quit(app: &tauri::AppHandle) { + kill_sidecar(app); + std::thread::sleep(std::time::Duration::from_millis(500)); + std::process::exit(0); +} + fn main() { + // Slice 100: the keychain helper mode returns before anything of the app exists, so it runs + // without a window, a display, or the single-instance lock. + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("--keychain") { + std::process::exit(keychain::run(&args[2..])); + } + + let _ = TOKEN.set(random_token()); + tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| { - // Focus the main window when a second instance is launched + // Slice 100, AC8: a second launch exits and the first instance comes to the front. This + // plugin must be registered first. + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + show_main(app, None); })) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_log::Builder::new().build()) .plugin(tauri_plugin_notification::init()) + // Slice 100, AC4: the global shortcut shows or hides the window. Which shortcut is set + // from Elixir (`set_hotkey`), from the owner's Settings. + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state == ShortcutState::Pressed { + toggle_main(app); + } + }) + .build(), + ) + // Slice 100: launch at login, switched from Settings (`set_autostart`). + .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)) + // Slice 100: the folder dialog for the filesystem allowlist (`open_dialog`). + .plugin(tauri_plugin_dialog::init()) + // Slice 100: Open data folder (`open_path`). + .plugin(tauri_plugin_opener::init()) .manage(AppState { sidecar_child: Mutex::new(None), }) @@ -158,9 +223,10 @@ fn main() { }) .setup(|app| { let port = resolve_port(); + let _ = PORT.set(port); start_server(app.handle(), port); check_server_started(port); - navigate_main_window(app.handle(), port); + navigate_main_window(app.handle(), port, "/"); start_channel(app.handle().clone()); Ok(()) }) @@ -169,28 +235,31 @@ fn main() { println!("Menu event received: {:?}", event.id()); // On macOS, the default menu includes a "quit" item // Intercept it to perform graceful shutdown - if event.id().as_ref() == "quit" || event.id().as_ref().contains("quit") { + if event.id().as_ref() == "quit" { println!("Quit menu item clicked (CMD+Q), shutting down gracefully..."); - kill_sidecar(app); - std::thread::sleep(std::time::Duration::from_millis(500)); - std::process::exit(0); + quit(app); } // Forward every other menu click to the Elixir sidecar so - // server-side code can react (see ExTauri.Desktop.subscribe/0). + // server-side code can react. send_channel_event( "menu_click", serde_json::json!({"id": event.id().as_ref()}), ); }) - .on_window_event(|window, event| { - if let tauri::WindowEvent::CloseRequested { .. } = event { + .on_window_event(|window, event| match event { + tauri::WindowEvent::CloseRequested { .. } => { // Kill the sidecar when the main window closes. Secondary - // windows (ExTauri.Window.open) close without stopping the app. + // windows close without stopping the app. if window.label() == "main" { kill_sidecar(&window.app_handle()); } } + // Slice 100, AC3: Trinity notifies only while the window is away (NOTES D4). + tauri::WindowEvent::Focused(focused) if window.label() == "main" => { + send_channel_event("window", serde_json::json!({"focused": focused})); + } + _ => {} }) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -209,6 +278,13 @@ fn main() { }); } +// 32 random bytes, hex: the hello token (finding F2). +fn random_token() -> String { + let mut buf = [0u8; 32]; + getrandom::fill(&mut buf).expect("the OS has no random source"); + buf.iter().map(|b| format!("{:02x}", b)).collect() +} + // Uses EX_TAURI_PORT when set (mix ex_tauri.dev pins it to the configured dev // port); otherwise asks the OS for a free ephemeral port so installed apps // never collide with other services. The sidecar receives the choice via the @@ -234,48 +310,44 @@ fn secret_key_base() -> String { return secret; } - #[cfg(unix)] - { - use std::io::Read; - if let Ok(mut file) = std::fs::File::open("/dev/urandom") { - let mut buf = [0u8; 48]; - if file.read_exact(&mut buf).is_ok() { - return buf.iter().map(|b| format!("{:02x}", b)).collect(); - } - } - } - - // Fallback entropy: hash of time + pid. Weak, but only signs local - // session cookies for a single-user desktop app. - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let mut out = String::new(); - for round in 0..8u32 { - let mut hasher = DefaultHasher::new(); - (nanos, std::process::id(), round).hash(&mut hasher); - out.push_str(&format!("{:016x}", hasher.finish())); - } - out + let mut buf = [0u8; 48]; + getrandom::fill(&mut buf).expect("the OS has no random source"); + buf.iter().map(|b| format!("{:02x}", b)).collect() } fn start_server(app: &tauri::AppHandle, port: u16) { // PORT and SECRET_KEY_BASE are always injected: every server needs a port, - // and SECRET_KEY_BASE is a random per-launch secret (inert if unused). The - // remaining pairs come from `config :ex_tauri, :sidecar_env`: the Phoenix - // defaults (PHX_SERVER/PHX_HOST) unless overridden for another framework. - let env: std::collections::HashMap = std::collections::HashMap::from([ + // and SECRET_KEY_BASE is a random per-launch secret (inert if unused). + // Slice 100 adds TRINITY_SHELL_TOKEN (the hello token; its presence is also + // how the sidecar knows a shell launched it and selects Trinity.Desktop.Tauri) + // and TRINITY_KEYCHAIN_HELPER (this binary's own path, for `--keychain`). + let mut env: std::collections::HashMap = std::collections::HashMap::from([ ("PORT".to_string(), port.to_string()), ("SECRET_KEY_BASE".to_string(), secret_key_base()), ("PHX_SERVER".to_string(), "true".to_string()), ("PHX_HOST".to_string(), "127.0.0.1".to_string()), + ( + "TRINITY_SHELL_TOKEN".to_string(), + TOKEN.get().cloned().unwrap_or_default(), + ), ]); + if let Ok(exe) = std::env::current_exe() { + env.insert( + "TRINITY_KEYCHAIN_HELPER".to_string(), + exe.to_string_lossy().into_owned(), + ); + } + + // Slice 100: `--no-halt`. The packaged sidecar is the Burrito binary, which starts the release + // through the Elixir CLI, and the CLI halts when it has nothing to run: without this the BEAM + // booted, served, and exited about a second later with status 0, so the packaged app's window + // pointed at a server that was gone (slice 001 recorded the flag as mandatory for the binary; + // the shell never passed it, and the first run of the packaged shell found that). The + // development sidecar is a script that ignores its arguments. let sidecar_command = app.shell().sidecar("desktop") .expect("failed to setup `desktop` sidecar") + .args(["--no-halt"]) .envs(env); let (mut rx, child) = sidecar_command @@ -322,21 +394,65 @@ fn check_server_started(port: u16) { } } -// Points the window at the port actually in use. When the OS assigned a free -// port (production), the compile-time URL in tauri.conf.json is wrong, and -// even in dev this reload recovers the webview if it raced the server boot. -fn navigate_main_window(app: &tauri::AppHandle, port: u16) { +// Points the window at a page of the app on the port actually in use. When the +// OS assigned a free port (production), the compile-time URL in +// tauri.conf.json is wrong, and even in dev this reload recovers the webview if +// it raced the server boot. Only a path of this app is accepted (slice 100: +// a notification or the tray cannot send the window to another origin). +fn navigate_main_window(app: &tauri::AppHandle, port: u16, path: &str) { + if !app_path(path) { + return; + } if let Some(window) = app.get_webview_window("main") { - let url = format!("http://127.0.0.1:{}", port); + let url = format!("http://127.0.0.1:{}{}", port, path); if let Ok(url) = url.parse() { let _ = window.navigate(url); } } } +fn app_path(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.contains('\\') + && !path.contains('\n') + && !path.contains('\r') +} + +// Slice 100: show, unminimise and focus the main window, on a page of the app +// when a path is given (AC3's click, AC8's second launch, New session). +fn show_main(app: &tauri::AppHandle, path: Option<&str>) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + if let (Some(path), Some(port)) = (path, PORT.get()) { + navigate_main_window(app, *port, path); + } +} + +// Slice 100, AC4: the global shortcut's action. A visible window hides; a +// hidden or minimised one is shown and focused. On visibility alone, not on +// focus: without a window manager to hand focus to a window it has just +// mapped (the Xvfb evidence run), "visible and focused" read false after every +// show and the shortcut could never hide the window again (NOTES D9). +fn toggle_main(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let visible = window.is_visible().unwrap_or(false); + let minimized = window.is_minimized().unwrap_or(false); + if visible && !minimized { + let _ = window.hide(); + send_channel_event("window", serde_json::json!({"focused": false})); + } else { + show_main(app, None); + } + } +} + // The sidecar channel carries heartbeats (liveness), commands from Elixir -// (ExTauri.Desktop: notifications, tray, ...), and native events back to -// Elixir: all as newline-delimited JSON over the ShutdownManager socket. +// (Trinity.Desktop.Tauri), and native events back to Elixir: all as +// newline-delimited JSON over the ShutdownManager socket. fn start_channel(app: tauri::AppHandle) { println!("Starting sidecar channel (heartbeat + desktop commands)..."); @@ -362,6 +478,10 @@ fn start_channel(app: tauri::AppHandle) { *guard = Some(tx.clone()); } + // Slice 100, finding F2: the first thing on every connection is the + // hello, so the sidecar believes this peer and no other. + send_hello(); + // Ticker: queue a heartbeat line every 100ms. let ticker_tx = tx.clone(); std::thread::spawn(move || { @@ -411,6 +531,13 @@ fn start_channel(app: tauri::AppHandle) { }); } +fn send_hello() { + send_channel_event( + "hello", + serde_json::json!({"token": TOKEN.get().cloned().unwrap_or_default()}), + ); +} + #[cfg(unix)] type ChannelStream = std::os::unix::net::UnixStream; #[cfg(windows)] @@ -462,7 +589,8 @@ fn connect_channel() -> Option { } } -// Executes a desktop command sent by the Elixir sidecar (ExTauri.Desktop). +// Executes a desktop command sent by the Elixir sidecar (Trinity.Desktop.Tauri; +// slice 100 adds every command but notify and set_tray, and a click path to notify). fn handle_channel_command(app: &tauri::AppHandle, line: &str) { let parsed: serde_json::Value = match serde_json::from_str(line) { Ok(value) => value, @@ -477,29 +605,146 @@ fn handle_channel_command(app: &tauri::AppHandle, line: &str) { let payload = parsed["payload"].clone(); match name.as_str() { - "notify" => { - use tauri_plugin_notification::NotificationExt; - let title = payload["title"].as_str().unwrap_or("Notification").to_string(); - let body = payload["body"].as_str().unwrap_or("").to_string(); - let _ = app.notification().builder().title(title).body(body).show(); - } + "hello" => send_hello(), + + "notify" => notify(app, &payload), "set_tray" => { let app_handle = app.clone(); let _ = app.run_on_main_thread(move || set_tray(&app_handle, payload)); } - other => { - send_channel_event( - "error", - serde_json::json!({"message": format!("Unknown desktop command: {}", other)}), - ); + "show_window" => { + let app_handle = app.clone(); + let path = payload["path"].as_str().map(str::to_string); + let _ = app.run_on_main_thread(move || show_main(&app_handle, path.as_deref())); + } + + "open_path" => { + if let Some(path) = payload["path"].as_str() { + if let Err(error) = app.opener().open_path(path, None::<&str>) { + send_error(format!("Failed to open {}: {}", path, error)); + } + } + } + + "set_hotkey" => set_hotkey(app, payload["accelerator"].as_str()), + + "set_autostart" => { + let manager = app.autolaunch(); + let result = if payload["enabled"].as_bool().unwrap_or(false) { + manager.enable() + } else { + manager.disable() + }; + if let Err(error) = result { + send_error(format!("Failed to change launch at login: {}", error)); + } + } + + "open_dialog" => open_dialog(app, &payload), + + "quit" => { + let app_handle = app.clone(); + std::thread::spawn(move || quit(&app_handle)); + } + + other => send_error(format!("Unknown desktop command: {}", other)), + } +} + +fn send_error(message: String) { + send_channel_event("error", serde_json::json!({"message": message})); +} + +// Slice 100, AC3: an OS notification whose click shows the window on `path`. +// notify-rust reports the click (a freedesktop action on Linux, the +// notification centre's response on macOS, the toast's activation on Windows); +// the wait runs on its own thread, one per notification. +fn notify(app: &tauri::AppHandle, payload: &serde_json::Value) { + let title = payload["title"].as_str().unwrap_or("Trinity").to_string(); + let body = payload["body"].as_str().unwrap_or("").to_string(); + let path = payload["path"].as_str().unwrap_or("/").to_string(); + let app_handle = app.clone(); + + #[cfg(target_os = "macos")] + { + let _ = notify_rust::set_application(if tauri::is_dev() { + "com.apple.Terminal" + } else { + app.config().identifier.as_str() + }); + } + + std::thread::spawn(move || { + let mut notification = notify_rust::Notification::new(); + notification + .appname("Trinity") + .summary(&title) + .body(&body) + .action("default", "Open"); + + #[cfg(windows)] + { + // The toast's application identity only exists for an installed + // app; a binary run from target/ keeps the default one, as + // tauri-plugin-notification does. + if let Ok(exe) = std::env::current_exe() { + let dir = exe.parent().map(|d| d.display().to_string()).unwrap_or_default(); + if !(dir.ends_with("target\\debug") || dir.ends_with("target\\release")) { + notification.app_id(&app_handle.config().identifier); + } + } + } + + match notification.show() { + Ok(handle) => handle.wait_for_action(|action| { + if action != "__closed" { + send_channel_event("notification_click", serde_json::json!({"path": path})); + let target = app_handle.clone(); + let path = path.clone(); + let _ = app_handle.run_on_main_thread(move || show_main(&target, Some(&path))); + } + }), + Err(error) => send_error(format!("Failed to show a notification: {}", error)), + } + }); +} + +fn set_hotkey(app: &tauri::AppHandle, accelerator: Option<&str>) { + let shortcuts = app.global_shortcut(); + let _ = shortcuts.unregister_all(); + + if let Some(accelerator) = accelerator { + if let Err(error) = shortcuts.register(accelerator) { + send_error(format!("Failed to register the shortcut {}: {}", accelerator, error)); } } } -// Builds (or replaces) the system tray from an Elixir-provided spec: -// {"tooltip": "...", "items": [{"id": "...", "label": "..."}, ...]}. +// Slice 100: the folder dialog. The answer goes back as a `dialog_result` event +// carrying the request's id; a cancelled dialog answers with no paths. +fn open_dialog(app: &tauri::AppHandle, payload: &serde_json::Value) { + let id = payload["id"].as_str().unwrap_or("").to_string(); + let title = payload["title"].as_str().unwrap_or("").to_string(); + + let mut builder = app.dialog().file(); + if !title.is_empty() { + builder = builder.set_title(title); + } + + builder.pick_folder(move |folder| { + let paths: Vec = folder + .and_then(|path| path.into_path().ok()) + .map(|path| vec![path.to_string_lossy().into_owned()]) + .unwrap_or_default(); + + send_channel_event("dialog_result", serde_json::json!({"id": id, "paths": paths})); + }); +} + +// Builds (or updates) the system tray from an Elixir-provided spec: +// {"tooltip": "...", "items": [{"id": "...", "label": "...", "enabled": bool}, ...]}. // Menu item clicks come back as "tray_menu_click" events on the channel. fn set_tray(app: &tauri::AppHandle, payload: serde_json::Value) { use tauri::tray::TrayIconBuilder; @@ -511,7 +756,8 @@ fn set_tray(app: &tauri::AppHandle, payload: serde_json::Value) { for spec in item_specs { let id = spec["id"].as_str().unwrap_or("item"); let label = spec["label"].as_str().unwrap_or(id); - if let Ok(item) = MenuItem::with_id(app, id, label, true, None::<&str>) { + let enabled = spec["enabled"].as_bool().unwrap_or(true); + if let Ok(item) = MenuItem::with_id(app, id, label, enabled, None::<&str>) { items.push(item); } } @@ -524,14 +770,23 @@ fn set_tray(app: &tauri::AppHandle, payload: serde_json::Value) { let menu = match Menu::with_items(app, &item_refs) { Ok(menu) => menu, Err(error) => { - send_channel_event( - "error", - serde_json::json!({"message": format!("Failed to build tray menu: {}", error)}), - ); + send_error(format!("Failed to build tray menu: {}", error)); return; } }; + let tooltip = payload["tooltip"].as_str().map(str::to_string); + + // Slice 100: the tray changes with every approval and every turn, so an + // existing icon is updated in place rather than dropped and rebuilt. + if let Ok(guard) = TRAY.lock() { + if let Some(tray) = guard.as_ref() { + let _ = tray.set_menu(Some(menu)); + let _ = tray.set_tooltip(tooltip.as_deref()); + return; + } + } + let mut builder = TrayIconBuilder::with_id("ex_tauri_tray") .menu(&menu) .show_menu_on_left_click(true) @@ -542,7 +797,7 @@ fn set_tray(app: &tauri::AppHandle, payload: serde_json::Value) { ); }); - if let Some(tooltip) = payload["tooltip"].as_str() { + if let Some(tooltip) = tooltip.as_deref() { builder = builder.tooltip(tooltip); } @@ -553,15 +808,9 @@ fn set_tray(app: &tauri::AppHandle, payload: serde_json::Value) { match builder.build(app) { Ok(tray) => { if let Ok(mut guard) = TRAY.lock() { - // Dropping the previous handle removes its icon. *guard = Some(tray); } } - Err(error) => { - send_channel_event( - "error", - serde_json::json!({"message": format!("Failed to build tray: {}", error)}), - ); - } + Err(error) => send_error(format!("Failed to build tray: {}", error)), } } diff --git a/test/smoke_test.exs b/test/smoke_test.exs index 014ab3b..ec5737d 100644 --- a/test/smoke_test.exs +++ b/test/smoke_test.exs @@ -15,10 +15,14 @@ defmodule SmokeTest do # Slice 032: the three lines that need the Repo are injected here (SmokeTest has no # sandbox); Trinity.Memory.VectorStoreTest proves `Semantic.smoke/0` on a database. + # Slice 100 appends the three AC9 lines; they are computed by the probe the same way. @rest [ "TRINITY_SMOKE_EXLA=ok", "TRINITY_SMOKE_VEC=ok:Trinity.Memory.VectorStores.Brute", - "TRINITY_SMOKE_SEMANTIC=on" + "TRINITY_SMOKE_SEMANTIC=on", + "TRINITY_SMOKE_FTS5=ok", + "TRINITY_SMOKE_WATCHER=ok:fs_inotify", + "TRINITY_SMOKE_MODEL_CACHE=ok:/tmp/models" ] describe "requested?/1" do @@ -105,18 +109,89 @@ defmodule SmokeTest do "TRINITY_SMOKE_SEMANTIC=off:z" ] - Smoke.run(fn _ -> :ok end, &send(me, {:halted, &1}), Smoke.markdown_line(), failed) + Smoke.run( + fn _ -> :ok end, + &send(me, {:halted, &1}), + Smoke.markdown_line(), + failed ++ Enum.drop(@rest, 3) + ) + assert_received {:halted, 4} - Smoke.run(fn _ -> :ok end, &send(me, {:halted, &1}), Smoke.markdown_line(), [ - "TRINITY_SMOKE_EXLA=failed:x", - "TRINITY_SMOKE_VEC=ok:S", - "TRINITY_SMOKE_SEMANTIC=off:z" + Smoke.run( + fn _ -> :ok end, + &send(me, {:halted, &1}), + Smoke.markdown_line(), + [ + "TRINITY_SMOKE_EXLA=failed:x", + "TRINITY_SMOKE_VEC=ok:S", + "TRINITY_SMOKE_SEMANTIC=off:z" + ] ++ Enum.drop(@rest, 3) + ) + + assert_received {:halted, 0} + end + + # Slice 100, AC9: the packaged-binary checks the slice names. FTS5 and the model cache are + # binding (exit 5 and 6); the watcher is informative, because the skills registry already + # falls back to polling where no native backend runs, and the line says which it got. + test "AC9: says whether FTS5, the file watcher and the model cache work, each on its own line" do + me = self() + Smoke.run(&send(me, {:said, &1}), &send(me, {:halted, &1}), Smoke.markdown_line(), @rest) + assert_received {:said, "TRINITY_SMOKE_FTS5=ok"} + assert_received {:said, "TRINITY_SMOKE_WATCHER=ok:fs_inotify"} + assert_received {:said, "TRINITY_SMOKE_MODEL_CACHE=ok:/tmp/models"} + assert_received {:halted, 0} + end + + test "AC9: exits 5 when FTS5 is missing and 6 when the model cache does not resolve; a watcher fallback alone still exits 0" do + me = self() + [exla, vec, sem | _] = @rest + halt = &send(me, {:halted, &1}) + quiet = fn _ -> :ok end + + Smoke.run(quiet, halt, Smoke.markdown_line(), [ + exla, + vec, + sem, + "TRINITY_SMOKE_FTS5=failed:no such module: fts5", + "TRINITY_SMOKE_WATCHER=ok:fs_inotify", + "TRINITY_SMOKE_MODEL_CACHE=ok:/tmp/models" + ]) + + assert_received {:halted, 5} + + Smoke.run(quiet, halt, Smoke.markdown_line(), [ + exla, + vec, + sem, + "TRINITY_SMOKE_FTS5=ok", + "TRINITY_SMOKE_WATCHER=ok:fs_inotify", + "TRINITY_SMOKE_MODEL_CACHE=failed:eacces" + ]) + + assert_received {:halted, 6} + + Smoke.run(quiet, halt, Smoke.markdown_line(), [ + exla, + vec, + sem, + "TRINITY_SMOKE_FTS5=ok", + "TRINITY_SMOKE_WATCHER=fallback:fs_poll", + "TRINITY_SMOKE_MODEL_CACHE=ok:/tmp/models" ]) assert_received {:halted, 0} end + test "AC9: the three lines measured on this machine" do + assert Smoke.fts5_line() == "TRINITY_SMOKE_FTS5=ok" + assert "TRINITY_SMOKE_WATCHER=" <> watcher = Smoke.watcher_line() + assert watcher =~ ~r/^(ok|fallback):fs_(inotify|poll|mac|windows)/ + assert "TRINITY_SMOKE_MODEL_CACHE=ok:" <> dir = Smoke.model_cache_line() + assert File.dir?(dir) + end + test "the EXLA line on this machine (the NIF is compiled and loads here; the bundle's answer is AC7's)" do assert Smoke.exla_line() == "TRINITY_SMOKE_EXLA=ok" assert Smoke.semantic_line() == "TRINITY_SMOKE_SEMANTIC=on" diff --git a/test/support/desktop_recorder.ex b/test/support/desktop_recorder.ex new file mode 100644 index 0000000..cdb732e --- /dev/null +++ b/test/support/desktop_recorder.ex @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Desktop.Recorder do + @moduledoc """ + Slice 100. A `Trinity.Desktop` implementation for the suite: every call is sent to the process + named in `config :trinity, :desktop_recorder` as `{:desktop, call, args}` and answers `:ok` + (`open_dialog/1` answers the paths in `:desktop_recorder_dialog`, default none). + """ + @behaviour Trinity.Desktop + + @impl true + def available?, do: true + + @impl true + def notify(title, opts), do: record(:notify, [title, opts]) + + @impl true + def tray_update(status), do: record(:tray_update, [status]) + + @impl true + def open_dialog(opts) do + record(:open_dialog, [opts]) + {:ok, Application.get_env(:trinity, :desktop_recorder_dialog, [])} + end + + @impl true + def show_window, do: record(:show_window, []) + + @impl true + def focus_path(path), do: record(:focus_path, [path]) + + @impl true + def open_path(path), do: record(:open_path, [path]) + + @impl true + def set_hotkey(accelerator), do: record(:set_hotkey, [accelerator]) + + @impl true + def set_autostart(enabled), do: record(:set_autostart, [enabled]) + + @impl true + def quit, do: record(:quit, []) + + defp record(call, args) do + case Application.get_env(:trinity, :desktop_recorder) do + pid when is_pid(pid) -> send(pid, {:desktop, call, args}) + _ -> :ok + end + + :ok + end +end diff --git a/test/support/fake_keychain.ex b/test/support/fake_keychain.ex new file mode 100644 index 0000000..dd0f662 --- /dev/null +++ b/test/support/fake_keychain.ex @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.FakeKeychain do + @moduledoc """ + Slice 100. Installs `test/support/keychain/fake_helper.sh` as the keychain helper for one test: + a copy in a fresh directory, named in `config :trinity, :secrets, keychain_helper:`, removed + again on exit. The copy's directory holds the "keychain" (`store/`) and `argv.log`. + """ + + @source Path.expand("keychain/fake_helper.sh", __DIR__) + + @doc "Copies the helper into a fresh directory, points the configuration at it, returns the directory." + @spec install!() :: Path.t() + def install! do + dir = Path.join(System.tmp_dir!(), "fake-keychain-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + helper = Path.join(dir, "trinity-shell") + File.cp!(@source, helper) + File.chmod!(helper, 0o755) + previous = Application.get_env(:trinity, :secrets) + Application.put_env(:trinity, :secrets, keychain_helper: helper) + + ExUnit.Callbacks.on_exit(fn -> + if previous, + do: Application.put_env(:trinity, :secrets, previous), + else: Application.delete_env(:trinity, :secrets) + + File.rm_rf!(dir) + end) + + dir + end + + @doc "Makes every operation of an installed helper fail as an unreachable keychain does." + @spec break!(Path.t()) :: :ok + def break!(dir), do: File.write!(Path.join(dir, "broken"), "") + + @doc "Every argv the helper was run with, one line each." + @spec argv_log(Path.t()) :: String.t() + def argv_log(dir) do + case File.read(Path.join(dir, "argv.log")) do + {:ok, log} -> log + {:error, :enoent} -> "" + end + end +end diff --git a/test/support/keychain/fake_helper.sh b/test/support/keychain/fake_helper.sh new file mode 100755 index 0000000..b4ac655 --- /dev/null +++ b/test/support/keychain/fake_helper.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Slice 100. A stand-in for the Tauri shell's keychain mode (`trinity --keychain `) +# with the same protocol: the value travels as hex, on stdin for `set` and on stdout for `get`, +# never on argv. Exit 0 done, 3 no such entry, 4 the keychain cannot be reached, 2 usage. +# +# A test copies this file into its own directory. The "keychain" is `store/` beside the copy, +# every argv is appended to `argv.log` beside it (so a test can assert the value never rode on +# the command line), and a file named `broken` beside it makes every operation exit 4. +here=$(cd "$(dirname "$0")" && pwd) +store="$here/store" +mkdir -p "$store" +printf '%s\n' "$*" >> "$here/argv.log" +[ -e "$here/broken" ] && { echo "keychain unreachable" >&2; exit 4; } +[ "$1" = "--keychain" ] || exit 2 +case "$2" in + probe) exit 0 ;; + set) IFS= read -r line || exit 2; printf '%s' "$line" > "$store/$3"; exit 0 ;; + get) [ -f "$store/$3" ] || exit 3; cat "$store/$3"; echo; exit 0 ;; + delete) [ -f "$store/$3" ] || exit 3; rm -f "$store/$3"; exit 0 ;; + *) exit 2 ;; +esac diff --git a/test/trinity/desktop/headless_boot_test.exs b/test/trinity/desktop/headless_boot_test.exs new file mode 100644 index 0000000..6917b39 --- /dev/null +++ b/test/trinity/desktop/headless_boot_test.exs @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Desktop.HeadlessBootTest do + @moduledoc """ + Slice 100, AC10: Trinity started with no desktop shell selects `Trinity.Desktop.Noop`, boots, + and serves HTTP 200. A spawned OS process, because the claim is about a boot (the recipe is + `Trinity.RegulatedBootNodeTest`'s: `MIX_ENV=test`, `Trinity.BootIsolation`, own data directory). + + The suite pins `Noop` in `config/test.exs`; the child deletes that pin before it starts, so the + implementation it reports is the one selected from its environment, as `config/runtime.exs` + selects it in development and in a release. The second case is the positive control for the + selection: a process the shell launched selects `Tauri`, and it boots and serves just the same + with no window attached. + """ + use ExUnit.Case, async: false + + @moduletag timeout: 240_000 + + @script """ + Trinity.BootIsolation.isolate!(System.fetch_env!("TRINITY_BOOT_TAG")) + Application.delete_env(:trinity, :desktop) + {:ok, _} = Application.ensure_all_started(:trinity) + {:ok, {_ip, port}} = TrinityWeb.Endpoint.server_info(:http) + :inets.start() + {:ok, {{_, code, _}, _, _}} = + :httpc.request(:get, {~c"http://127.0.0.1:" ++ Integer.to_charlist(port) ++ ~c"/", []}, [], []) + IO.puts("DESKTOP_IMPL " <> inspect(Trinity.Desktop.impl())) + IO.puts("NOTIFY " <> inspect(Trinity.Desktop.notify("t", []))) + IO.puts("HTTP " <> Integer.to_string(code)) + System.halt(0) + """ + + defp boot(env) do + tag = Trinity.BootIsolation.tag("headless") + data = Path.join(System.tmp_dir!(), "headless-#{System.unique_integer([:positive])}") + File.mkdir_p!(data) + + on_exit(fn -> + Trinity.BootIsolation.drop!(tag) + File.rm_rf(data) + end) + + System.cmd("mix", ["run", "--no-start", "-e", @script], + env: + [ + {"MIX_ENV", "test"}, + {"TRINITY_BOOT_TAG", tag}, + {"XDG_DATA_HOME", data}, + {"TRINITY_SHELL_TOKEN", nil}, + {"TRINITY_MODE", nil} + ] ++ env, + stderr_to_stdout: true + ) + end + + defp line(out, key) do + out |> String.split("\n") |> Enum.find("", &String.starts_with?(&1, key <> " ")) + end + + test "AC10: no shell, Noop selected, and the app serves" do + {out, status} = boot([]) + assert status == 0, out + assert line(out, "DESKTOP_IMPL") == "DESKTOP_IMPL Trinity.Desktop.Noop" + assert line(out, "NOTIFY") == "NOTIFY {:error, :no_shell}" + assert line(out, "HTTP") == "HTTP 200" + end + + test "AC10, control: launched by the shell, Tauri is selected and the app serves without a window" do + {out, status} = boot([{"TRINITY_SHELL_TOKEN", "t0ken"}]) + assert status == 0, out + assert line(out, "DESKTOP_IMPL") == "DESKTOP_IMPL Trinity.Desktop.Tauri" + assert line(out, "HTTP") == "HTTP 200" + end +end diff --git a/test/trinity/desktop/shell_test.exs b/test/trinity/desktop/shell_test.exs new file mode 100644 index 0000000..86b349b --- /dev/null +++ b/test/trinity/desktop/shell_test.exs @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Desktop.ShellTest do + @moduledoc """ + Slice 100, line 2: `Trinity.Desktop.Shell`, the process between Trinity and the window. These + are the Elixir halves of AC2 (the tray's status and actions, the pending count live) and AC3 + (an approval while the window is away is a notification whose click goes to the card), against + `Trinity.Desktop.Recorder`, which reports every call to this test. + + What a window, a tray host or a notification daemon does with those calls is the Linux run in + PROOF.md and the manual queue for macOS and Windows. This file proves the calls are made, with + what, and when. + """ + use Trinity.DataCase, async: false + + alias Trinity.Desktop.{Recorder, Shell} + alias Trinity.{Factory, Gateways, Permissions, Settings} + + setup do + Application.put_env(:trinity, :desktop_recorder, self()) + dir = Path.join(System.tmp_dir!(), "shell-test-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + settings = Path.join(dir, "settings.json") + + on_exit(fn -> + Application.delete_env(:trinity, :desktop_recorder) + Gateways.resume() + File.rm_rf!(dir) + end) + + name = :"shell-#{System.unique_integer([:positive])}" + + pid = + start_supervised!( + {Shell, impl: Recorder, name: name, token: "t0ken", settings: settings, data_dir: dir} + ) + + session = Factory.session!() + {:ok, shell: name, pid: pid, session: session, settings: settings, dir: dir} + end + + defp away(shell), do: send(shell, {:ex_tauri_event, "window", %{"focused" => false}}) + defp here(shell), do: send(shell, {:ex_tauri_event, "window", %{"focused" => true}}) + defp hello(shell), do: send(shell, {:ex_tauri_event, "hello", %{"token" => "t0ken"}}) + defp click(shell, id), do: send(shell, {:ex_tauri_event, "tray_menu_click", %{"id" => id}}) + + defp request!(session) do + {:ok, approval} = Permissions.request_approval(session.id, "fs_write", %{"path" => "/tmp/x"}) + approval + end + + test "AC2: the tray is pushed at start, idle with nothing pending" do + assert_receive {:desktop, :tray_update, + [%{status: :idle, pending: 0, gateways_paused: false}]} + end + + test "AC2: the pending count follows requests, decisions and expiry live", %{ + shell: shell, + session: s + } do + assert_receive {:desktop, :tray_update, [%{pending: 0}]} + a = request!(s) + assert_receive {:desktop, :tray_update, [%{pending: 1}]}, 1_000 + _b = request!(s) + assert_receive {:desktop, :tray_update, [%{pending: 2}]}, 1_000 + {:ok, _} = Permissions.decide_request(a.id, :deny) + assert_receive {:desktop, :tray_update, [%{pending: 1}]}, 1_000 + # The suite's approvals expire after a second (config/test.exs), which is a decision too: the + # count reaches zero without anyone clicking. + assert_receive {:desktop, :tray_update, [%{pending: 0}]}, 3_000 + assert Shell.status(shell).pending == 0 + end + + test "AC3: an approval while the window is away is a notification that leads to its card", + %{shell: shell, session: s} do + away(shell) + a = request!(s) + assert_receive {:desktop, :notify, [title, opts]}, 1_000 + assert title =~ "Approval" + assert opts[:path] == "/s/#{s.id}#approval-#{a.id}" + # The tool's name and nothing of its arguments: a notification is shown, and kept, by the OS. + assert opts[:body] =~ "fs_write" + refute opts[:body] =~ "/tmp/x" + end + + test "AC3: a focused window shows the card itself, so there is no notification", %{ + shell: shell, + session: s + } do + here(shell) + request!(s) + assert_receive {:desktop, :tray_update, [%{pending: 1}]}, 1_000 + refute_receive {:desktop, :notify, _}, 200 + end + + test "AC3: muted notifications stay muted", %{shell: shell, session: s, settings: settings} do + :ok = Settings.put(:notifications_muted, true, path: settings) + away(shell) + request!(s) + assert_receive {:desktop, :tray_update, [%{pending: 1}]}, 1_000 + refute_receive {:desktop, :notify, _}, 200 + end + + test "AC2: New session creates one and shows the window on it", %{shell: shell} do + hello(shell) + click(shell, "new_session") + assert_receive {:desktop, :focus_path, ["/s/" <> id]}, 1_000 + assert %{origin: "desktop"} = Trinity.Sessions.get_session(id) + end + + test "AC2: Pause gateways pauses them, the tray says so, and a second click resumes", %{ + shell: shell + } do + hello(shell) + click(shell, "pause_gateways") + # A call is answered after every message sent before it, so the click has been handled. + assert Shell.status(shell).gateways_paused + assert Gateways.paused?() + assert_receive {:desktop, :tray_update, [%{gateways_paused: true}]}, 1_000 + + click(shell, "pause_gateways") + refute Shell.status(shell).gateways_paused + refute Gateways.paused?() + # The update after the resume is the last one sent. + assert %{gateways_paused: false} = last_tray() + end + + defp last_tray(last \\ nil) do + receive do + {:desktop, :tray_update, [tray]} -> last_tray(tray) + after + 100 -> last + end + end + + test "AC2: Open data folder opens the data directory; Show shows; Quit quits", %{ + shell: shell, + dir: dir + } do + hello(shell) + click(shell, "open_data_folder") + assert_receive {:desktop, :open_path, [^dir]}, 1_000 + click(shell, "show") + assert_receive {:desktop, :show_window, []}, 1_000 + click(shell, "quit") + assert_receive {:desktop, :quit, []}, 1_000 + end + + test "F2: a tray click from a peer that never presented the token does nothing", %{shell: shell} do + send(shell, {:ex_tauri_event, "hello", %{"token" => "wrong"}}) + click(shell, "quit") + click(shell, "open_data_folder") + refute_receive {:desktop, :quit, _}, 200 + refute_receive {:desktop, :open_path, _}, 50 + end + + test "AC2: the tray shows thinking while a session is busy", %{shell: shell, session: s} do + assert_receive {:desktop, :tray_update, [%{status: :idle}]} + Trinity.Telemetry.session_transition(s.id, :idle, :thinking) + assert_receive {:desktop, :tray_update, [%{status: :thinking, thinking: 1}]}, 1_000 + Trinity.Telemetry.session_transition(s.id, :thinking, :idle) + assert_receive {:desktop, :tray_update, [%{status: :idle, thinking: 0}]}, 1_000 + assert Shell.status(shell).thinking == 0 + end + + test "a finished scheduled task and a gateway message notify while the window is away", %{ + shell: shell + } do + away(shell) + Phoenix.PubSub.broadcast(Trinity.PubSub, "tasks", {:task_run, %{id: "r1", task_id: "t1"}}) + assert_receive {:desktop, :notify, [title, opts]}, 1_000 + assert title =~ "task" + assert opts[:path] == "/tasks" + + Trinity.Telemetry.gateway_inbound("console", :placed) + assert_receive {:desktop, :notify, [title, _]}, 1_000 + assert title =~ "console" + end + + test "a command the shell could not carry out is logged", %{shell: shell} do + log = + ExUnit.CaptureLog.capture_log(fn -> + send( + shell, + {:ex_tauri_event, "error", %{"message" => "Failed to change launch at login"}} + ) + + _ = Shell.status(shell) + end) + + assert log =~ "the shell reported: Failed to change launch at login" + end + + test "after hello the shell is given the saved hotkey and launch-at-login setting", %{ + shell: shell, + settings: settings + } do + :ok = Settings.put(:hotkey, "CommandOrControl+Shift+T", path: settings) + :ok = Settings.put(:autostart, true, path: settings) + hello(shell) + assert_receive {:desktop, :set_hotkey, ["CommandOrControl+Shift+T"]}, 1_000 + assert_receive {:desktop, :set_autostart, [true]}, 1_000 + assert Shell.status(shell).trusted + end +end diff --git a/test/trinity/desktop_test.exs b/test/trinity/desktop_test.exs new file mode 100644 index 0000000..b1306bd --- /dev/null +++ b/test/trinity/desktop_test.exs @@ -0,0 +1,217 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.DesktopTest do + @moduledoc """ + Slice 100, line 1: `Trinity.Desktop`, the seam between Trinity and whatever window it is shown + in, its `Noop` implementation (headless, CI, `mix phx.server`; AC10's unit half) and its + `Tauri` implementation. + + The `Tauri` half runs against a real `ExTauri.ShutdownManager` on a Unix socket of its own, + with this test playing the Rust shell: it connects, reads the newline-delimited JSON the + implementation writes, and writes events back. That pins the one private shape this slice + relies on in a pinned `ex_tauri` 0.2.0 (NOTES D7): `{:desktop_command, name, payload}` handled + by the manager and written as `{"type":"command",...}`. If an upgrade changes it, this fails. + """ + # DataCase: the shell server reads the pending approvals when it starts. + use Trinity.DataCase, async: false + + alias Trinity.Desktop + + describe "selection" do + test "the suite runs on Noop, and Noop answers that there is no shell rather than pretending" do + assert Desktop.impl() == Desktop.Noop + refute Desktop.Noop.available?() + assert {:error, :no_shell} = Desktop.notify("t", body: "b") + assert {:error, :no_shell} = Desktop.show_window() + assert {:error, :no_shell} = Desktop.open_dialog(kind: :folder) + assert {:error, :no_shell} = Desktop.quit() + end + + test "a process launched by the shell selects Tauri, any other selects Noop" do + assert Desktop.impl_for(%{"TRINITY_SHELL_TOKEN" => "abc"}) == Desktop.Tauri + assert Desktop.impl_for(%{}) == Desktop.Noop + assert Desktop.impl_for(%{"TRINITY_SHELL_TOKEN" => ""}) == Desktop.Noop + # Headless is headless even if a token leaked into its environment. + assert Desktop.impl_for(%{"TRINITY_SHELL_TOKEN" => "abc", "TRINITY_MODE" => "headless"}) == + Desktop.Noop + end + end + + describe "Tauri, over ex_tauri's channel" do + setup do + app = "trinity-desktop-test-#{System.unique_integer([:positive])}" + me = self() + + start_supervised!( + {ExTauri.ShutdownManager, + app_name: app, heartbeat_timeout: 60_000, shutdown_fun: fn -> send(me, :shutdown) end} + ) + + path = Path.join(System.tmp_dir!(), "tauri_heartbeat_#{app}.sock") + {:ok, path: path} + end + + test "with no shell attached, a command is refused as not connected" do + assert {:error, :not_connected} = Desktop.Tauri.show_window() + end + + test "each command reaches the shell as one JSON line in the channel's shape", %{path: path} do + shell = connect!(path) + + assert :ok = + Desktop.Tauri.notify("Approval needed", body: "fs_write", path: "/s/1#approval-2") + + assert %{ + "type" => "command", + "name" => "notify", + "payload" => %{ + "title" => "Approval needed", + "body" => "fs_write", + "path" => "/s/1#approval-2" + } + } = read_line!(shell) + + assert :ok = Desktop.Tauri.show_window() + assert %{"name" => "show_window", "payload" => %{}} = read_line!(shell) + + assert :ok = Desktop.Tauri.focus_path("/s/abc") + assert %{"name" => "show_window", "payload" => %{"path" => "/s/abc"}} = read_line!(shell) + + assert :ok = Desktop.Tauri.open_path("/data/trinity") + + assert %{"name" => "open_path", "payload" => %{"path" => "/data/trinity"}} = + read_line!(shell) + + assert :ok = Desktop.Tauri.set_hotkey("CommandOrControl+Shift+Space") + + assert %{ + "name" => "set_hotkey", + "payload" => %{"accelerator" => "CommandOrControl+Shift+Space"} + } = + read_line!(shell) + + assert :ok = Desktop.Tauri.set_autostart(true) + assert %{"name" => "set_autostart", "payload" => %{"enabled" => true}} = read_line!(shell) + + assert :ok = Desktop.Tauri.quit() + assert %{"name" => "quit"} = read_line!(shell) + end + + test "the tray: a status line that cannot be clicked, then the four actions", %{path: path} do + shell = connect!(path) + + assert :ok = + Desktop.Tauri.tray_update(%{ + status: :thinking, + thinking: 2, + pending: 3, + gateways_paused: false + }) + + assert %{"name" => "set_tray", "payload" => payload} = read_line!(shell) + assert payload["tooltip"] =~ "thinking" + assert payload["tooltip"] =~ "3 pending" + [status | actions] = payload["items"] + assert status["enabled"] == false + assert status["label"] =~ "3 pending approvals" + + assert Enum.map(actions, & &1["id"]) == + ["show", "new_session", "pause_gateways", "open_data_folder", "quit"] + + assert Enum.find(actions, &(&1["id"] == "pause_gateways"))["label"] == "Pause gateways" + + :ok = + Desktop.Tauri.tray_update(%{ + status: :idle, + thinking: 0, + pending: 0, + gateways_paused: true + }) + + assert %{"payload" => %{"items" => items}} = read_line!(shell) + assert Enum.find(items, &(&1["id"] == "pause_gateways"))["label"] == "Resume gateways" + end + + test "open_dialog is a request, answered by an event carrying its id, once the shell has said hello", + %{path: path} do + socket = connect!(path) + name = :"dialog-shell-#{System.unique_integer([:positive])}" + start_supervised!({Desktop.Shell, impl: Desktop.Tauri, name: name, token: "t0ken"}) + # A call returns only after the server's start-up continuation, which is where it subscribes + # to the channel; an event sent before that would reach no subscriber. + _ = Desktop.Shell.status(name) + send_event!(socket, "hello", %{token: "t0ken"}) + wait_until(fn -> Desktop.Shell.status(name).trusted end) + + task = + Task.async(fn -> Desktop.Tauri.open_dialog(kind: :folder, title: "Pick", shell: name) end) + + %{"payload" => %{"id" => id} = payload} = read_until!(socket, "open_dialog") + assert payload["kind"] == "folder" and payload["title"] == "Pick" + + send_event!(socket, "dialog_result", %{id: id, paths: ["/home/me/projects"]}) + assert {:ok, ["/home/me/projects"]} = Task.await(task) + + # Cancelled: no paths, which is an answer, not an error. + task = Task.async(fn -> Desktop.Tauri.open_dialog(kind: :folder, shell: name) end) + %{"payload" => %{"id" => id}} = read_until!(socket, "open_dialog") + send_event!(socket, "dialog_result", %{id: id, paths: []}) + assert {:ok, []} = Task.await(task) + end + + test "a peer that has not presented the shell's token is not believed (finding F2)", %{ + path: path + } do + socket = connect!(path) + name = :"dialog-shell-#{System.unique_integer([:positive])}" + start_supervised!({Desktop.Shell, impl: Desktop.Tauri, name: name, token: "t0ken"}) + _ = Desktop.Shell.status(name) + send_event!(socket, "hello", %{token: "wrong"}) + # The wrong hello's warning is the sign it arrived; then ask. + Process.sleep(100) + + assert {:error, :untrusted_channel} = + Desktop.Tauri.open_dialog(kind: :folder, shell: name, timeout: 500) + + refute Desktop.Shell.status(name).trusted + end + end + + defp send_event!(socket, name, payload) do + line = JSON.encode!(%{type: "event", name: name, payload: payload}) <> "\n" + :ok = :gen_tcp.send(socket, line) + end + + defp read_until!(socket, name, tries \\ 20) do + case read_line!(socket) do + %{"name" => ^name} = line -> line + _other when tries > 0 -> read_until!(socket, name, tries - 1) + other -> flunk("no #{name} line; last was #{inspect(other)}") + end + end + + defp connect!(path) do + {:ok, socket} = + :gen_tcp.connect({:local, path}, 0, [:binary, active: false, packet: :line], 2_000) + + # One byte counts as a heartbeat and as the frontend having attached; the manager records + # the socket for outbound commands when it accepts, which is asynchronous to this connect. + :ok = :gen_tcp.send(socket, ~s({"type":"heartbeat"}\n)) + wait_until(fn -> :sys.get_state(ExTauri.ShutdownManager).client_socket != nil end) + socket + end + + defp read_line!(socket) do + {:ok, line} = :gen_tcp.recv(socket, 0, 2_000) + {:ok, decoded} = JSON.decode(line) + decoded + end + + defp wait_until(fun, tries \\ 100) do + cond do + fun.() -> :ok + tries == 0 -> flunk("condition never held") + true -> Process.sleep(20) && wait_until(fun, tries - 1) + end + end +end diff --git a/test/trinity/gateways/pause_test.exs b/test/trinity/gateways/pause_test.exs new file mode 100644 index 0000000..eae5d81 --- /dev/null +++ b/test/trinity/gateways/pause_test.exs @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.PauseTest do + @moduledoc """ + Slice 100: the tray's "Pause gateways". While paused, a message from any channel is answered + with a short notice and goes no further: no admission, no session, no model call. Resuming + restores the ordinary path. + """ + use Trinity.SessionCase + + alias Trinity.Gateways + alias Trinity.Gateways.{Console, Identities, Router} + + setup do + start_supervised!(Console) + start_supervised!(Router) + on_exit(&Gateways.resume/0) + :ok + end + + test "a paused gateway answers, admits nobody and starts nothing; resuming restores it" do + :ok = Gateways.pause() + assert Gateways.paused?() + + assert {:error, :paused} = Router.inbound(Console, "c-9", "u-9", "hello?") + assert [notice] = Console.text("c-9") + assert notice =~ "paused" + # Not even a pairing code: admission was never asked. + assert Identities.get("console", "u-9") == nil + assert Router.session_of(Console, "c-9") == nil + + :ok = Gateways.resume() + refute Gateways.paused?() + assert {:error, :pending} = Router.inbound(Console, "c-9", "u-9", "hello?") + end +end diff --git a/test/trinity/memory/vector_store_test.exs b/test/trinity/memory/vector_store_test.exs index 746ae1e..44c2655 100644 --- a/test/trinity/memory/vector_store_test.exs +++ b/test/trinity/memory/vector_store_test.exs @@ -148,11 +148,18 @@ defmodule Trinity.Memory.VectorStoreTest do assert length(Trinity.Personas.list()) == before assert Trinity.Smoke.vec_line() == "TRINITY_SMOKE_VEC=ok:#{inspect(store)}" - # The probe child (placed before the endpoint) computes the three lines and starts nothing. + # The probe child (placed before the endpoint) computes the lines and starts nothing. Slice + # 100 appended its three (AC9) after slice 032's three. assert :ignore = Trinity.Smoke.Probe.start_link() - assert ["TRINITY_SMOKE_EXLA=" <> _, "TRINITY_SMOKE_VEC=ok:" <> _, "TRINITY_SMOKE_SEMANTIC=on"] = - Trinity.Smoke.probed() + assert [ + "TRINITY_SMOKE_EXLA=" <> _, + "TRINITY_SMOKE_VEC=ok:" <> _, + "TRINITY_SMOKE_SEMANTIC=on", + "TRINITY_SMOKE_FTS5=ok", + "TRINITY_SMOKE_WATCHER=" <> _, + "TRINITY_SMOKE_MODEL_CACHE=ok:" <> _ + ] = Trinity.Smoke.probed() assert Trinity.Smoke.probe(["--smoke"]) == [Trinity.Smoke.Probe] assert Trinity.Smoke.probe([]) == [] diff --git a/test/trinity/receipts/key_migration_test.exs b/test/trinity/receipts/key_migration_test.exs new file mode 100644 index 0000000..fac2a2e --- /dev/null +++ b/test/trinity/receipts/key_migration_test.exs @@ -0,0 +1,193 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.KeyMigrationTest do + @moduledoc """ + Slice 100, AC6: the receipt signing key leaves its file for the keychain, by rotation (NOTES + D3). Receipts signed before the move still verify after it, the retired `key_id` carries a + `valid_until`, the chain is one chain across the boundary, and nothing is re-keyed silently or + partially: a keychain that cannot hold the key leaves the file-backed key in force and the + registry untouched. + + The keychain is `Trinity.FakeKeychain`. The registry rows, the chain and the verifier are the + real ones. + """ + use Trinity.DataCase, async: false + + import ExUnit.CaptureLog + + alias Trinity.{FakeKeychain, Receipts} + alias Trinity.Receipts.{KeyCustody, KeyRegistry, Verifier} + + setup do + dir = Path.join(System.tmp_dir!(), "trinity-keys-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + old_env = Application.get_env(:trinity, :receipts, []) + Application.put_env(:trinity, :receipts, Keyword.put(old_env, :keys_dir, dir)) + scope = "migration:" <> Trinity.UUID.generate() + + # Registered first, so it runs last: after FakeKeychain's own on_exit has removed the + # helper, the suite's key is selected again from the suite's directory, file-backed. + on_exit(fn -> + Application.put_env(:trinity, :receipts, old_env) + File.rm_rf!(dir) + {:ok, _} = KeyCustody.boot!() + end) + + on_exit(fn -> Receipts.stop_writer(scope) end) + {:ok, dir: dir, scope: scope} + end + + defp append!(scope, n) do + for i <- 1..n do + {:ok, _} = + Receipts.append(scope, %{ + kind: "decision", + subject: %{"i" => i}, + decision: %{"outcome" => "allow"}, + fingerprint: "f#{i}" + }) + end + end + + defp export!(scope) do + :ok = Receipts.stop_writer(scope) + {:ok, export} = Receipts.export(scope) + export + end + + test "AC6: receipts signed before the migration verify after it, across one unbroken chain", + %{dir: dir, scope: scope} do + {:ok, %{key_id: old_id, algorithm: alg}} = KeyCustody.boot!(dir) + append!(scope, 5) + + _keychain = FakeKeychain.install!() + + log = capture_log(fn -> send(self(), KeyCustody.boot!(dir)) end) + assert_received {:ok, %{key_id: new_id, custody: "keychain"}} + refute new_id == old_id + # Never silently: the log names both keys. + assert log =~ old_id and log =~ new_id + + append!(scope, 5) + export = export!(scope) + + ids = export["receipts"] |> Enum.sort_by(& &1["seq"]) |> Enum.map(& &1["key_id"]) + assert ids == List.duplicate(old_id, 5) ++ List.duplicate(new_id, 5) + assert {:ok, %{receipts: 10}} = Verifier.verify(export, require_coverage: false) + + {:ok, rows} = KeyRegistry.read(dir) + retired = KeyRegistry.lookup(rows, old_id) + assert retired["status"] == "retired" + assert {:ok, _, _} = DateTime.from_iso8601(retired["valid_until"]) + assert retired["superseded_by"] == new_id + active = KeyRegistry.active_for(rows, alg) + assert active["key_id"] == new_id + assert active["custody"] == "keychain" + end + + test "AC6: the registry only grew, the old private key was renamed rather than deleted, and the new one is not in a file", + %{dir: dir} do + {:ok, %{key_id: old_id, key_path: path}} = KeyCustody.boot!(dir) + {:ok, before_rows} = KeyRegistry.read(dir) + old_file = File.read!(path) + + keychain = FakeKeychain.install!() + capture_log(fn -> {:ok, _} = KeyCustody.boot!(dir) end) + + {:ok, rows} = KeyRegistry.read(dir) + assert Enum.take(rows, length(before_rows)) == before_rows + assert length(rows) == length(before_rows) + 2 + + [retired_file] = Path.wildcard(Path.join(dir, "receipts-*.retired-*.key")) + assert File.read!(retired_file) == old_file + assert File.stat!(retired_file).mode |> Bitwise.band(0o777) == 0o600 + + {:ok, new_file} = path |> File.read!() |> JSON.decode() + assert new_file["custody"] == "keychain" + refute Map.has_key?(new_file, "private_b64") + refute new_file["key_id"] == old_id + + # The private half is in the keychain (as the protocol's hex) and nowhere in the keys dir. + assert [_entry] = File.ls!(Path.join(keychain, "store")) + end + + test "AC6: a second boot with the keychain does not rotate again", %{dir: dir} do + {:ok, _} = KeyCustody.boot!(dir) + FakeKeychain.install!() + capture_log(fn -> send(self(), KeyCustody.boot!(dir)) end) + assert_received {:ok, %{key_id: first}} + {:ok, rows} = KeyRegistry.read(dir) + + assert {:ok, %{key_id: ^first}} = KeyCustody.boot!(dir) + assert {:ok, ^rows} = KeyRegistry.read(dir) + end + + test "AC6: a boot that stopped between the registry rows and the file swap is finished by the next boot", + %{dir: dir} do + {:ok, %{key_path: path}} = KeyCustody.boot!(dir) + FakeKeychain.install!() + capture_log(fn -> send(self(), KeyCustody.boot!(dir)) end) + assert_received {:ok, %{key_id: new_id}} + {:ok, rows} = KeyRegistry.read(dir) + + # The state a crash after the rows and before the swap leaves: the registry says the old key is + # retired and the keychain key is active, and the key file is still the old one. + [retired_file] = Path.wildcard(Path.join(dir, "receipts-*.retired-*.key")) + File.cp!(retired_file, path) + + assert {:ok, %{key_id: ^new_id, custody: "keychain"}} = KeyCustody.boot!(dir) + assert {:ok, ^rows} = KeyRegistry.read(dir) + assert {:ok, _sig} = KeyCustody.sign("bytes") + end + + test "AC6: a keychain that cannot be reached leaves the file-backed key in force and the registry untouched", + %{dir: dir} do + {:ok, %{key_id: old_id}} = KeyCustody.boot!(dir) + {:ok, rows} = KeyRegistry.read(dir) + keychain = FakeKeychain.install!() + FakeKeychain.break!(keychain) + + capture_log(fn -> assert {:ok, %{key_id: ^old_id}} = KeyCustody.boot!(dir) end) + assert {:ok, ^rows} = KeyRegistry.read(dir) + assert {:ok, _sig} = KeyCustody.sign("bytes") + end + + test "AC6: after the move the key is read from the keychain at every sign, as the file was", + %{dir: dir} do + {:ok, _} = KeyCustody.boot!(dir) + keychain = FakeKeychain.install!() + capture_log(fn -> {:ok, _} = KeyCustody.boot!(dir) end) + assert {:ok, _sig} = KeyCustody.sign("bytes") + + FakeKeychain.break!(keychain) + assert {:error, {:signer_unavailable, _}} = KeyCustody.sign("bytes") + end + + test "the verifier refuses a retired key's receipt dated after its valid_until", %{ + dir: dir, + scope: scope + } do + {:ok, %{key_id: old_id}} = KeyCustody.boot!(dir) + append!(scope, 3) + FakeKeychain.install!() + capture_log(fn -> {:ok, _} = KeyCustody.boot!(dir) end) + export = export!(scope) + assert {:ok, _} = Verifier.verify(export, require_coverage: false) + + # The same export with the retirement moved before the first receipt: those receipts now + # claim a time at which their key was no longer valid. + backdated = + update_in(export["registry"], fn rows -> + Enum.map(rows, fn + %{"key_id" => ^old_id, "status" => "retired"} = row -> + Map.put(row, "valid_until", "2000-01-01T00:00:00Z") + + row -> + row + end) + end) + + assert {:error, 1, {:key_retired, 1, ^old_id}} = + Verifier.verify(backdated, require_coverage: false) + end +end diff --git a/test/trinity/secrets_test.exs b/test/trinity/secrets_test.exs new file mode 100644 index 0000000..0afee68 --- /dev/null +++ b/test/trinity/secrets_test.exs @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.SecretsTest do + @moduledoc """ + Slice 100: `Trinity.Secrets`, the keychain first and the environment second (NOTES D1, D2). + + The keychain here is `Trinity.FakeKeychain`, a shell script speaking the Tauri shell's keychain + protocol; the real one is exercised on Linux in PROOF.md against a throwaway gnome-keyring. What + this file proves is the Elixir half: the order, the refusals, that a value never rides on argv, + and that nothing this module does puts a secret on disk in clear or in a log line. + """ + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + alias Trinity.{Config, FakeKeychain, Secrets} + + @name "TRINITY_TEST_PROVIDER_KEY" + # Distinctive, so a scan for it cannot match anything else on disk. + @value "tk-" <> Base.encode16(:crypto.strong_rand_bytes(16), case: :lower) + + setup do + System.delete_env(@name) + on_exit(fn -> System.delete_env(@name) end) + :ok + end + + describe "with no keychain helper (headless, CI, `mix phx.server`)" do + setup do + previous = Application.get_env(:trinity, :secrets) + Application.delete_env(:trinity, :secrets) + saved = System.get_env("TRINITY_KEYCHAIN_HELPER") + System.delete_env("TRINITY_KEYCHAIN_HELPER") + + on_exit(fn -> + if previous, do: Application.put_env(:trinity, :secrets, previous) + if saved, do: System.put_env("TRINITY_KEYCHAIN_HELPER", saved) + end) + end + + test "the keychain says it is unavailable, and fetch reads the environment" do + refute Secrets.keychain_available?() + assert {:error, :not_found} = Secrets.fetch(@name) + System.put_env(@name, "from-env") + assert {:ok, "from-env"} = Secrets.fetch(@name) + assert Secrets.source(@name) == :env + end + + test "store refuses rather than writing the value anywhere else" do + assert {:error, :keychain_unavailable} = Secrets.store(@name, @value) + assert {:error, :not_found} = Secrets.fetch(@name) + assert Secrets.source(@name) == :none + end + end + + describe "with a keychain" do + setup do + {:ok, dir: FakeKeychain.install!()} + end + + test "a stored value is fetched back, from the keychain", %{dir: _dir} do + assert Secrets.keychain_available?() + assert :ok = Secrets.store(@name, @value) + assert {:ok, @value} = Secrets.fetch(@name) + assert Secrets.source(@name) == :keychain + end + + test "the keychain wins over a stale environment variable, and delete falls back to it" do + System.put_env(@name, "stale-env-value") + :ok = Secrets.store(@name, @value) + assert {:ok, @value} = Secrets.fetch(@name) + assert :ok = Secrets.delete(@name) + assert {:ok, "stale-env-value"} = Secrets.fetch(@name) + assert Secrets.source(@name) == :env + end + + test "Trinity.Config.secret/1, the one reader providers use, goes through it" do + :ok = Secrets.store(@name, @value) + assert {:ok, @value} = Config.secret(@name) + :ok = Secrets.delete(@name) + assert {:error, {:missing_secret, @name}} = Config.secret(@name) + end + + test "the value never rides on argv", %{dir: dir} do + :ok = Secrets.store(@name, @value) + {:ok, _} = Secrets.fetch(@name) + log = FakeKeychain.argv_log(dir) + assert log =~ "--keychain set #{@name}" + assert log =~ "--keychain get #{@name}" + refute log =~ @value + refute log =~ Base.encode16(@value, case: :lower) + end + + test "a keychain that cannot be reached is an error on store and the environment on fetch", + %{dir: dir} do + FakeKeychain.break!(dir) + System.put_env(@name, "from-env") + assert {:error, {:keychain, 4}} = Secrets.store(@name, @value) + assert {:ok, "from-env"} = Secrets.fetch(@name) + refute Secrets.keychain_available?() + end + + test "a name that is not an environment-variable name is refused before the helper runs", + %{dir: dir} do + for bad <- ["../etc/passwd", "lower_case", "WITH SPACE", "", String.duplicate("A", 65)] do + assert {:error, {:invalid_name, ^bad}} = Secrets.store(bad, @value) + assert {:error, {:invalid_name, ^bad}} = Secrets.fetch(bad) + end + + assert FakeKeychain.argv_log(dir) == "" + end + + test "a value with a newline or a NUL is refused, so the line protocol cannot be split" do + assert {:error, :invalid_value} = Secrets.store(@name, "a\nb") + assert {:error, :invalid_value} = Secrets.store(@name, "a\0b") + assert {:error, :invalid_value} = Secrets.store(@name, "") + end + + test "nothing this module does leaves the value on disk in clear or in a log line", %{ + dir: dir + } do + log = + capture_log([level: :debug], fn -> + :ok = Secrets.store(@name, @value) + {:ok, @value} = Secrets.fetch(@name) + {:ok, @value} = Config.secret(@name) + :ok = Secrets.delete(@name) + end) + + refute log =~ @value + + # Every file under the test's directory: the fake keychain's own store and its argv log. The + # store holds what the protocol carries (hex), never the value. The data directory is the + # Settings file's home, and Trinity.SettingsTest proves a key-shaped value cannot enter it. + for file <- Path.wildcard(Path.join(dir, "**/*"), match_dot: true), File.regular?(file) do + refute File.read!(file) =~ @value, "#{file} holds the value in clear" + end + + # The suite's own database files, which is where a careless implementation would put it. + for db <- Path.wildcard(Path.expand("../../trinity_test*.db*", __DIR__)) do + refute File.read!(db) =~ @value, "#{db} holds the value in clear" + end + end + end +end diff --git a/test/trinity/sessions/shutdown_test.exs b/test/trinity/sessions/shutdown_test.exs new file mode 100644 index 0000000..081efd0 --- /dev/null +++ b/test/trinity/sessions/shutdown_test.exs @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Sessions.ShutdownTest do + @moduledoc """ + Slice 100, AC7's automatic half: quitting during a streaming turn keeps what arrived, marked + interrupted, before the VM stops; on the next launch the row reads back interrupted. + + Three layers, because the claim lives at the boot and a suite that only calls a function is not + proof of a boot (the project's rule since regulated-boot): + + * `Trinity.Sessions.interrupt_all/1`, which `Trinity.Application.prep_stop/1` calls, in this VM; + * a session shut down by its supervisor mid-turn (`Session.terminate/3`, the backstop); + * a spawned OS process that starts Trinity, streams, and is stopped with `System.stop/1`, then + a second spawned process on the same databases that reads the row back. + + The banner on relaunch is `TrinityWeb.SessionLiveTest`'s, on the row this file produces. + """ + use Trinity.SessionCase + + @moduletag timeout: 240_000 + + alias Trinity.LLM.Providers.Fake + alias Trinity.Sessions + + @slow [{:text_delta, "Partial "}, {:text_delta, "answer"}, {:sleep, 60_000}, {:done, :stop}] + + defp streaming!(id) do + Fake.scripts([@slow]) + {:ok, _} = Sessions.ensure_started(id) + {:ok, _} = Sessions.send_user_message(id, "go") + wait_until(fn -> Sessions.state(id).text =~ "answer" end) + end + + defp assistant_row(id) do + id |> Sessions.history() |> Enum.filter(&(&1.role == "assistant")) |> List.last() + end + + test "interrupt_all/1 persists a streaming turn as interrupted, naming the reason" do + s = Trinity.Factory.session!() + streaming!(s.id) + + assert Sessions.interrupt_all(:shutdown) == 1 + row = assistant_row(s.id) + assert row.content =~ "Partial answer" + assert row.parts["interrupted"] == true + assert row.parts["draft"] == false + assert row.parts["interrupted_reason"] == "shutdown" + assert Sessions.state(s.id).state == :idle + end + + test "a session shut down by its supervisor mid-turn persists the same way (the backstop)" do + s = Trinity.Factory.session!() + streaming!(s.id) + pid = Sessions.whereis(s.id) + :ok = DynamicSupervisor.terminate_child(Trinity.Sessions.Supervisor, pid) + + row = assistant_row(s.id) + assert row.content =~ "Partial answer" + assert row.parts["interrupted"] == true + assert row.parts["interrupted_reason"] == "shutdown" + end + + @isolate """ + Trinity.BootIsolation.isolate!(System.fetch_env!("TRINITY_BOOT_TAG")) + {:ok, _} = Application.ensure_all_started(:trinity) + """ + + test "a spawned Trinity stopped mid-stream keeps the turn as interrupted, and the next launch reads it" do + tag = Trinity.BootIsolation.tag("shutdown") + data = Path.join(System.tmp_dir!(), "shutdown-#{System.unique_integer([:positive])}") + File.mkdir_p!(data) + + on_exit(fn -> + Trinity.BootIsolation.drop!(tag) + File.rm_rf(data) + end) + + first = """ + Trinity.LLM.Providers.Fake.scripts([#{inspect(@slow)}]) + s = Trinity.Factory.session!() + {:ok, _} = Trinity.Sessions.ensure_started(s.id) + {:ok, _} = Trinity.Sessions.send_user_message(s.id, "go") + Enum.find(1..200, fn _ -> Process.sleep(25); Trinity.Sessions.state(s.id).text =~ "answer" end) + IO.puts("SESSION " <> s.id) + IO.puts("STOPPING") + System.stop(0) + Process.sleep(:infinity) + """ + + {out, status} = boot(tag, data, @isolate <> first) + assert status == 0, out + assert out =~ "STOPPING" + [_, id] = Regex.run(~r/^SESSION (\S+)$/m, out) + + second = """ + row = + #{inspect(id)} + |> Trinity.Sessions.history() + |> Enum.filter(&(&1.role == "assistant")) + |> List.last() + + IO.puts("ROW " <> JSON.encode!(%{content: row.content, parts: row.parts})) + System.halt(0) + """ + + {out, status} = boot(tag, data, @isolate <> second) + assert status == 0, out + [_, json] = Regex.run(~r/^ROW (.+)$/m, out) + row = JSON.decode!(json) + assert row["content"] =~ "Partial answer" + assert row["parts"]["interrupted"] == true + assert row["parts"]["interrupted_reason"] == "shutdown" + end + + defp boot(tag, data, script) do + System.cmd("mix", ["run", "--no-start", "-e", script], + env: [ + {"MIX_ENV", "test"}, + {"TRINITY_BOOT_TAG", tag}, + {"XDG_DATA_HOME", data}, + {"TRINITY_PROFILE", nil} + ], + stderr_to_stdout: true + ) + end + + defp wait_until(fun, tries \\ 200) do + cond do + fun.() -> :ok + tries == 0 -> flunk("condition never held") + true -> Process.sleep(25) && wait_until(fun, tries - 1) + end + end +end diff --git a/test/trinity/settings_test.exs b/test/trinity/settings_test.exs new file mode 100644 index 0000000..137c41c --- /dev/null +++ b/test/trinity/settings_test.exs @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.SettingsTest do + @moduledoc """ + Slice 100, decision D6: the desktop's non-secret settings live in one JSON file in the data + directory. What is under test is the part a reviewer cannot see by reading a page: the file + survives a restart (a fresh read), it is written with mode 0600, an unknown key is refused by + name, and nothing that looks like key material is accepted into it, so the file can never become + the place a provider key ends up. + """ + use ExUnit.Case, async: true + + alias Trinity.Settings + + setup do + dir = Path.join(System.tmp_dir!(), "settings-test-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, path: Path.join(dir, "settings.json")} + end + + test "an absent file reads as the defaults", %{path: path} do + assert Settings.all(path: path) == Settings.defaults() + assert Settings.get(:notifications_muted, path: path) == false + assert Settings.get(:fs_roots, path: path) == [] + end + + test "a value put is read back from the file, as a restart would read it", %{path: path} do + assert :ok = Settings.put(:hotkey, "CommandOrControl+Shift+Space", path: path) + assert :ok = Settings.put(:fs_roots, ["/home/me/projects"], path: path) + + assert {:ok, decoded} = path |> File.read!() |> JSON.decode() + assert decoded["hotkey"] == "CommandOrControl+Shift+Space" + assert Settings.get(:hotkey, path: path) == "CommandOrControl+Shift+Space" + assert Settings.get(:fs_roots, path: path) == ["/home/me/projects"] + end + + test "the file is written owner-only", %{path: path} do + :ok = Settings.put(:notifications_muted, true, path: path) + assert %File.Stat{mode: mode} = File.stat!(path) + assert Bitwise.band(mode, 0o777) == 0o600 + end + + test "an unknown key is refused by name and nothing is written", %{path: path} do + assert {:error, {:unknown_setting, "openrouter_api_key"}} = + Settings.put("openrouter_api_key", "x", path: path) + + refute File.exists?(path) + end + + test "a value of the wrong type is refused", %{path: path} do + assert {:error, {:invalid_setting, :notifications_muted}} = + Settings.put(:notifications_muted, "yes", path: path) + + assert {:error, {:invalid_setting, :fs_roots}} = Settings.put(:fs_roots, "/tmp", path: path) + end + + test "a key-shaped string is refused wherever it is put, so the file cannot hold a secret", + %{path: path} do + for value <- [ + "sk-or-v1-0123456789abcdef0123456789abcdef0123456789abcdef", + "nvapi-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGH", + # Assembled, so the tree's own secret scan does not read this test as a leaked key. + "-----BEGIN " <> "PRIVATE KEY-----" + ] do + assert {:error, {:looks_like_a_secret, :hotkey}} = Settings.put(:hotkey, value, path: path) + end + + refute File.exists?(path) + end +end diff --git a/test/trinity_web/live/interrupted_banner_test.exs b/test/trinity_web/live/interrupted_banner_test.exs new file mode 100644 index 0000000..c96983d --- /dev/null +++ b/test/trinity_web/live/interrupted_banner_test.exs @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.InterruptedBannerTest do + @moduledoc """ + Slice 100, AC7's relaunch half. Until this slice the banner appeared only when the page saw + `{:turn_interrupted, _}`, which the session broadcasts when it rehydrates a leftover *draft*. + A turn the quit path has already finalised as interrupted (`Trinity.Sessions.ShutdownTest`) + leaves no draft, so nothing was broadcast and the relaunched page showed no banner. The page now + reads the newest row when it mounts. + """ + use TrinityWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Trinity.Factory + + setup do + on_exit(fn -> Trinity.SessionCase.stop_all_sessions() end) + {:ok, session: Factory.session!()} + end + + test "the newest row interrupted at quit: the banner shows on mount, with Retry", %{ + conn: conn, + session: s + } do + Factory.message!(s.id, %{role: "user", content: "go"}) + + Factory.message!(s.id, %{ + role: "assistant", + content: "Partial answer", + parts: %{"interrupted" => true, "draft" => false, "interrupted_reason" => "shutdown"} + }) + + {:ok, view, _html} = live(conn, ~p"/s/#{s.id}") + assert has_element?(view, "#banner", "interrupted") + assert has_element?(view, "#banner button", "Retry") + end + + test "an ordinary last reply shows no banner", %{conn: conn, session: s} do + Factory.message!(s.id, %{role: "user", content: "go"}) + Factory.message!(s.id, %{role: "assistant", content: "Done.", parts: %{"draft" => false}}) + {:ok, view, _html} = live(conn, ~p"/s/#{s.id}") + refute has_element?(view, "#banner") + end + + test "an interrupted reply followed by a newer message is history, not a banner", %{ + conn: conn, + session: s + } do + Factory.message!(s.id, %{role: "user", content: "go"}) + + Factory.message!(s.id, %{ + role: "assistant", + content: "Partial", + parts: %{"interrupted" => true, "draft" => false} + }) + + Factory.message!(s.id, %{role: "user", content: "again"}) + Factory.message!(s.id, %{role: "assistant", content: "Done.", parts: %{"draft" => false}}) + {:ok, view, _html} = live(conn, ~p"/s/#{s.id}") + refute has_element?(view, "#banner") + end +end diff --git a/test/trinity_web/live/settings_live_test.exs b/test/trinity_web/live/settings_live_test.exs new file mode 100644 index 0000000..d9eb580 --- /dev/null +++ b/test/trinity_web/live/settings_live_test.exs @@ -0,0 +1,193 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.SettingsLiveTest do + @moduledoc """ + Slice 100, line 9: the Settings page's desktop sections. AC5's automatic half is here: a provider + key typed into the page goes to the keychain and nowhere else, not to the database file, not to + the settings file, not to a log line, and the page never shows it back. The keychain is + `Trinity.FakeKeychain`; the shell is `Trinity.Desktop.Recorder`. + """ + use TrinityWeb.ConnCase, async: false + + import ExUnit.CaptureLog + import Phoenix.LiveViewTest + + alias Trinity.{FakeKeychain, Gateways, Secrets, Settings} + + @name "TRINITY_TEST_SETTINGS_KEY" + + setup do + llm = Application.get_env(:trinity, :llm) + desktop = Application.get_env(:trinity, :desktop) + + model = %{ + id: "keyed:chat", + provider: :fake, + model: "chat", + api_key_env: @name, + caps: [:stream], + price: %{input: 0.0, output: 0.0} + } + + Application.put_env(:trinity, :llm, Keyword.update!(llm, :models, &(&1 ++ [model]))) + Application.put_env(:trinity, :desktop, impl: Trinity.Desktop.Recorder) + Application.put_env(:trinity, :desktop_recorder, self()) + System.delete_env(@name) + + on_exit(fn -> + Application.put_env(:trinity, :llm, llm) + Application.put_env(:trinity, :desktop, desktop) + Application.delete_env(:trinity, :desktop_recorder) + System.delete_env(@name) + File.rm(Settings.path()) + Gateways.resume() + end) + + :ok + end + + describe "provider keys (AC5)" do + test "a key typed in goes to the keychain, is never shown back, and is in no log", + %{conn: conn} do + {view, value, log} = submit_key(conn) + + assert {:ok, ^value} = Secrets.fetch(@name) + assert has_element?(view, "#key-#{@name} [data-source=keychain]") + refute render(view) =~ value + refute log =~ value + end + + # `strings trinity.db | grep`, as AC5 words it, over every database file and the settings + # file. The databases are the suite's own; the settings file is the page's. SQLite only: on + # the postgres job there is no database file to read, and the list came back empty. + @tag :sqlite + test "a key typed in is in no database file and not in the settings file", %{conn: conn} do + {_view, value, _log} = submit_key(conn) + + files = + Path.wildcard(Path.expand("../../../trinity_test*.db*", __DIR__)) ++ + Enum.filter([Settings.path()], &File.exists?/1) + + assert files != [] + + for file <- files do + refute File.read!(file) =~ value, "#{file} holds the key in clear" + end + end + + test "the form's parameter is one Phoenix and LiveView log as [FILTERED]" do + assert Phoenix.Logger.filter_values(%{"name" => @name, "secret" => %{"value" => "sk-x"}}) == + %{"name" => @name, "secret" => "[FILTERED]"} + end + + test "remove deletes it from the keychain", %{conn: conn} do + FakeKeychain.install!() + :ok = Secrets.store(@name, "sk-test-remove-me") + {:ok, view, _html} = live(conn, ~p"/settings") + view |> element("#key-#{@name} button", "Remove") |> render_click() + assert {:error, :not_found} = Secrets.fetch(@name) + assert has_element?(view, "#key-#{@name} [data-source=none]") + end + + test "with no keychain the page says so and offers no field that would store the key elsewhere", + %{conn: conn} do + Application.delete_env(:trinity, :secrets) + System.put_env(@name, "from-env") + {:ok, view, html} = live(conn, ~p"/settings") + assert has_element?(view, "#key-#{@name} [data-source=env]") + assert html =~ "No keychain" + refute has_element?(view, "#key-#{@name} input[name='secret[value]']:not([disabled])") + end + end + + describe "desktop" do + test "the hotkey is saved and handed to the shell; a malformed one is refused", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + + view + |> form("#hotkey-form", %{"hotkey" => "CommandOrControl+Shift+K"}) + |> render_submit() + + assert Settings.get(:hotkey) == "CommandOrControl+Shift+K" + assert_receive {:desktop, :set_hotkey, ["CommandOrControl+Shift+K"]} + + html = view |> form("#hotkey-form", %{"hotkey" => "banana"}) |> render_submit() + assert html =~ "not a shortcut" + assert Settings.get(:hotkey) == "CommandOrControl+Shift+K" + end + + test "notifications can be muted, and launch at login handed to the shell", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + view |> element("#notifications-muted") |> render_click() + assert Settings.get(:notifications_muted) == true + view |> element("#autostart") |> render_click() + assert Settings.get(:autostart) == true + assert_receive {:desktop, :set_autostart, [true]} + end + + test "a folder picked in the native dialog becomes a root the tools may use", %{conn: conn} do + dir = Path.join(System.tmp_dir!(), "picked-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + Application.put_env(:trinity, :desktop_recorder_dialog, [dir]) + + on_exit(fn -> + Application.delete_env(:trinity, :desktop_recorder_dialog) + File.rm_rf!(dir) + end) + + {:ok, view, _html} = live(conn, ~p"/settings") + view |> element("#pick-root") |> render_click() + assert_receive {:desktop, :open_dialog, [opts]} + assert opts[:kind] == :folder + assert render_async(view) =~ dir + assert dir in Settings.get(:fs_roots) + assert Path.expand(dir) in Trinity.Tools.FS.roots() + end + + test "a folder typed in is accepted only if it is an existing absolute directory", %{ + conn: conn + } do + {:ok, view, _html} = live(conn, ~p"/settings") + html = view |> form("#root-form", %{"root" => "relative/path"}) |> render_submit() + assert html =~ "absolute" + assert Settings.get(:fs_roots) == [] + + tmp = System.tmp_dir!() + view |> form("#root-form", %{"root" => tmp}) |> render_submit() + assert Settings.get(:fs_roots) == [tmp] + view |> element("#roots [data-root='#{tmp}'] button", "Remove") |> render_click() + assert Settings.get(:fs_roots) == [] + end + + test "the gateways can be paused from here as from the tray", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings") + view |> element("#gateways-pause") |> render_click() + assert Gateways.paused?() + assert render(view) =~ "paused" + end + + test "the data directory and the budgets in force are shown", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ Trinity.Paths.data_dir() + assert html =~ "Budgets" + end + end + + # Types a fresh key into the page's form; returns the view, the key and the log of the submit. + defp submit_key(conn) do + FakeKeychain.install!() + value = "sk-test-" <> Base.encode16(:crypto.strong_rand_bytes(12), case: :lower) + {:ok, view, html} = live(conn, ~p"/settings") + assert html =~ @name + assert has_element?(view, "#key-#{@name} [data-source=none]") + + log = + capture_log([level: :debug], fn -> + view + |> form("#key-#{@name}", %{"secret" => %{"value" => value}}) + |> render_submit() + end) + + {view, value, log} + end +end diff --git a/test/trinity_web/live/setup_live_test.exs b/test/trinity_web/live/setup_live_test.exs new file mode 100644 index 0000000..557062e --- /dev/null +++ b/test/trinity_web/live/setup_live_test.exs @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.SetupLiveTest do + @moduledoc """ + Slice 100, AC11's automatic half: on a machine with nothing configured, `/` leads to `/setup`, + which takes a person through a model and its key, the data directory and the first project + folders, and ends in a new session. The manual half, a fresh account and a real key reaching a + working first turn, is in the manual queue. + + "Nothing configured" is measured, not assumed: the default model needs a key that neither the + keychain nor the environment has, and the setup has not been completed. The suite's own default + model needs no key, which is why every other LiveView test still lands on the sessions index. + """ + use TrinityWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Trinity.{FakeKeychain, Secrets, Settings} + + @name "TRINITY_TEST_SETUP_KEY" + + setup do + llm = Application.get_env(:trinity, :llm) + + keyed = %{ + id: "keyed:setup", + provider: :fake, + model: "chat", + api_key_env: @name, + caps: [:stream, :tools], + price: %{input: 0.0, output: 0.0} + } + + Application.put_env( + :trinity, + :llm, + llm |> Keyword.update!(:models, &(&1 ++ [keyed])) |> Keyword.put(:default_model, keyed.id) + ) + + System.delete_env(@name) + + on_exit(fn -> + Application.put_env(:trinity, :llm, llm) + System.delete_env(@name) + File.rm(Settings.path()) + Trinity.SessionCase.stop_all_sessions() + end) + + :ok + end + + test "with nothing configured, / leads to /setup", %{conn: conn} do + assert Trinity.Setup.needed?() + assert {:error, {:live_redirect, %{to: "/setup"}}} = live(conn, ~p"/") + end + + test "a key already in the environment means there is nothing to set up", %{conn: conn} do + System.put_env(@name, "from-env") + refute Trinity.Setup.needed?() + assert {:ok, _view, _html} = live(conn, ~p"/") + end + + test "AC11: model and key, data directory, project folders, then a session", %{conn: conn} do + FakeKeychain.install!() + folder = System.tmp_dir!() + {:ok, view, html} = live(conn, ~p"/setup") + assert html =~ "keyed:setup" + + view + |> form("#setup-model", %{"setup" => %{"model" => "keyed:setup", "secret" => "sk-test-setup"}}) + |> render_submit() + + assert {:ok, "sk-test-setup"} = Secrets.fetch(@name) + assert render(view) =~ Trinity.Paths.data_dir() + + view |> element("#setup-data-dir button", "Use this folder") |> render_click() + + view |> form("#root-form", %{"root" => folder}) |> render_submit() + assert Settings.get(:fs_roots) == [folder] + + view |> element("#setup-finish") |> render_click() + {path, _flash} = assert_redirect(view) + assert "/s/" <> _id = path + + refute Trinity.Setup.needed?() + assert Trinity.LLM.default_model() == "keyed:setup" + assert {:ok, _view, _html} = live(conn, ~p"/") + end + + test "a model whose key is missing cannot be finished past, and the reason is shown", %{ + conn: conn + } do + FakeKeychain.install!() + {:ok, view, _html} = live(conn, ~p"/setup") + + html = + view + |> form("#setup-model", %{"setup" => %{"model" => "keyed:setup", "secret" => ""}}) + |> render_submit() + + assert html =~ "needs a key" + refute has_element?(view, "#setup-finish") + end +end