Skip to content

Commit 2a7b8b7

Browse files
authored
Merge pull request #118 from ScriptKittyOS/slice/100-desktop-shell
feat(s100): the desktop shell, proven on Linux
2 parents 3d118a0 + 8f587a6 commit 2a7b8b7

57 files changed

Lines changed: 5436 additions & 164 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/package.yml‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,11 @@ jobs:
258258
# asserted: whether the XLA library loads in Burrito's ERTS is what they measure.
259259
grep -q '^TRINITY_SMOKE_VEC=ok:' smoke.log
260260
grep -E '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' smoke.log
261+
# Slice 100, AC9: FTS5 and the model cache bind (exit 5 and 6 otherwise); the watcher line
262+
# is recorded, because a polling fallback is the skills registry's documented behaviour.
263+
grep -q '^TRINITY_SMOKE_FTS5=ok$' smoke.log
264+
grep -q '^TRINITY_SMOKE_MODEL_CACHE=ok:' smoke.log
265+
grep -E '^TRINITY_SMOKE_WATCHER=' smoke.log
261266
echo "--- ours, before ---"; cat ps-before.txt
262267
echo "--- ours, after ----"; cat ps-after.txt
263268
diff ps-before.txt ps-after.txt
@@ -281,6 +286,14 @@ jobs:
281286
throw "the vector search did not run inside this binary (slice 032)"
282287
}
283288
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_(EXLA|SEMANTIC)='
289+
# Slice 100, AC9, as on the other two.
290+
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_FTS5=ok$')) {
291+
throw "FTS5 did not work inside this binary (slice 100)"
292+
}
293+
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MODEL_CACHE=ok:')) {
294+
throw "the model cache did not resolve inside this binary (slice 100)"
295+
}
296+
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_WATCHER='
284297
285298
# AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped.
286299
# `bash` on every OS, Windows included: the job that never curled was the one gap in
@@ -334,15 +347,24 @@ jobs:
334347
exit 1
335348
fi
336349
elapsed=$(( $(now_ms) - start ))
337-
code=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/")
338-
echo "HTTP $code on port $PORT"
350+
# A fresh data directory has no model key, so `/` answers 302 to `/setup` (slice 100,
351+
# AC11): run 37743757349 failed all three OSes on that redirect. Follow it, and require
352+
# that it lands on `/setup`, so a redirect anywhere else still fails here.
353+
answer=$(curl -sS -L -o /dev/null -w '%{http_code} %{url_effective}' "http://127.0.0.1:$PORT/")
354+
code=${answer%% *}
355+
landed=${answer#* }
356+
echo "HTTP $code on port $PORT at $landed"
357+
case "$landed" in
358+
"http://127.0.0.1:$PORT/" | "http://127.0.0.1:$PORT/setup") ;;
359+
*) echo "GET / ended at $landed, neither / nor /setup" >&2; exit 1 ;;
360+
esac
339361
echo "COLD_START_MS=$elapsed" | tee -a "$GITHUB_ENV"
340362
echo "### ${{ matrix.name }}: cold start to first HTTP 200: **${elapsed} ms**" >> "$GITHUB_STEP_SUMMARY"
341363
# A non-200 says nothing on its own: run 35518054546 (slice 013) printed "HTTP 500"
342364
# and nothing else, and the log that would have named the exception was never
343365
# read. The body and the tail of serve.log go to stderr before the step fails.
344366
if [ "$code" != "200" ]; then
345-
echo "--- body of GET / ---" >&2; curl -sS "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2
367+
echo "--- body of GET / ---" >&2; curl -sS -L "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2
346368
echo "--- serve.log ---" >&2; tail -60 serve.log >&2
347369
fi
348370
test "$code" = "200"

‎CHANGELOG.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,39 @@ evidence for each increment is retained by the maintainers and summarised here.
2222
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
2323
the public record of delivered work.
2424

25+
## 2026-10-08
26+
27+
### `slice/100`: Trinity as a desktop application
28+
29+
The Tauri window now does what a desktop application is expected to do. A tray icon shows whether
30+
Trinity is idle or working and how many approvals are waiting, and offers New session, Pause
31+
gateways, Open data folder and Quit. An approval requested while the window is hidden or in the
32+
background becomes an operating system notification, and clicking it brings the window up on that
33+
approval. A global shortcut (Ctrl+Shift+Space by default, configurable) shows and hides the window;
34+
a second launch brings the first one forward instead of starting another; Trinity can start at
35+
login.
36+
37+
Provider keys typed into Settings are stored in the operating system's keychain (the macOS Keychain,
38+
the Windows Credential Manager, the Secret Service on Linux) and nowhere else: not in the database,
39+
not in a settings file, not in a log. The shell binary itself is the keychain helper, so the key is
40+
reachable from the moment Trinity starts. A key in the keychain takes precedence over the same name
41+
in the environment, and with no keychain (a server, or `mix phx.server`) the environment works as
42+
before. The receipt signing key moves into the keychain the same way, by rotation: a new key is
43+
created there, the old one is marked retired with the moment it stopped being used, and receipts
44+
signed before the move still verify.
45+
46+
Quitting during a reply keeps what had arrived, marked as interrupted, and the conversation shows
47+
that on the next launch. A machine with nothing configured now opens a short setup (a model and its
48+
key, the data folder, the first project folders) instead of a chat that cannot answer. The packaged
49+
binary's self-check reports three more things: full-text search, the file watcher, and the model
50+
cache.
51+
52+
Verified by the test suite (the keychain through a stand-in helper, the shell's channel through a
53+
real socket, shutdown and startup through separate operating system processes) and on Linux by
54+
driving the real shell on an isolated display with a real keyring. The window, tray, notification,
55+
shortcut and second-launch behaviour on macOS and Windows have not been observed yet and are listed
56+
for manual verification.
57+
2558
## 2026-09-26
2659

2760
### `slice/128`: the checks that guard attribution now say what they check

‎config/config.exs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ config :trinity, Oban,
146146
),
147147
notifier: Oban.Notifiers.PG,
148148
queues: [agent_tasks: 1, memory: 2, maintenance: 1],
149+
# Slice 100, AC7: on quit, a running job gets this long to finish before Oban stops it. Named
150+
# rather than inherited (Oban's own default is the same 15 s) so a change upstream is a diff
151+
# here; a job cut short is retried by the lifeline below, and a turn it was driving keeps what
152+
# arrived through the sessions' interrupt path.
153+
shutdown_grace_period: :timer.seconds(15),
149154
plugins: [
150155
{Oban.Plugins.Cron,
151156
crontab: [
@@ -234,6 +239,11 @@ config :logger, :default_formatter,
234239
# Use Jason for JSON parsing in Phoenix
235240
config :phoenix, :json_library, Jason
236241

242+
# Slice 100: the Settings page and the setup path take provider keys in a form whose parameter is
243+
# `secret`. Phoenix and LiveView log a request's or an event's parameters at debug level; anything
244+
# whose name contains one of these is logged as "[FILTERED]". Phoenix's own default is the first.
245+
config :phoenix, :filter_parameters, ["password", "secret"]
246+
237247
# Import environment specific config. This must remain at the bottom
238248
# of this file so it overrides the configuration defined above.
239249
import_config "#{config_env()}.exs"

‎config/test.exs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,8 @@ config :trinity, :oban_web, true
310310
# counter-example found at 20,000 runs is reproducible with that seed at any width.
311311
config :stream_data,
312312
max_runs: String.to_integer(System.get_env("TRINITY_PROPERTY_RUNS") || "100")
313+
314+
# Slice 100: the suite runs on no desktop shell, and its settings file lives under the project's
315+
# ignored tmp/, never in the data directory of the machine running the tests.
316+
config :trinity, :desktop, impl: Trinity.Desktop.Noop
317+
config :trinity, Trinity.Settings, path: Path.expand("../tmp/test_settings.json", __DIR__)

‎docs/01-architecture.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ Trinity.Application
5858
├── Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080
5959
├── Trinity.Sandbox.Supervisor # Luerl workers. Slice 110
6060
├── Trinity.Desktop # ex_tauri bridge (tray, notifications). Slice 100
61+
│ # As built: `Trinity.Desktop.Shell`, after the sessions and
62+
│ # before Oban; the tray, the notifications, the tray's
63+
│ # actions, on the `Trinity.Desktop` implementation the
64+
│ # process selected (`Tauri` when the shell launched it,
65+
│ # `Noop` otherwise). `ExTauri.ShutdownManager` (the
66+
│ # channel and heartbeat) is first in the tree outside :test
6167
└── TrinityWeb.Endpoint # LiveView. Slice 013
6268
```
6369

@@ -95,7 +101,7 @@ without anything failing.
95101
| `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:<id>` and `approvals:<id>` 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 |
96102
| `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 |
97103
| `Trinity.Sandbox` | Luerl runners, resource limits | none |
98-
| `Trinity.Desktop` | ex_tauri bridge | PubSub |
104+
| `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) |
99105
| `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 |
100106
| `TrinityWeb` | LiveViews, components, API | all `Trinity.*` public APIs |
101107

@@ -173,7 +179,10 @@ development under `TRINITY_FAKE_PROVIDER=1`.
173179
is size 1 for writes; reads may use a second pool). FTS5 virtual table for message search. `sqlite_vec` for vectors.
174180
- Secondary: **Postgres + pgvector**, selected by `TRINITY_DB=postgres`. Enables Oban Pro Workflows later. Kept
175181
compiling and tested in CI (matrix), not default.
176-
- Durable settings/secrets: OS keychain via `Trinity.Secrets` (Slice 100); before that, env vars.
182+
- Durable settings/secrets: OS keychain via `Trinity.Secrets` (Slice 100); before that, env vars. As built at
183+
100: secrets in the keychain (through the shell binary's `--keychain` mode), the environment as the fallback;
184+
non-secret desktop settings (hotkey, notifications, launch at login, chosen folders, the setup's default model)
185+
in `<data dir>/settings.json` (`Trinity.Settings`), mode 0600.
177186

178187
## Concurrency and state rules
179188

@@ -194,5 +203,6 @@ priv/personas/ default SOUL.md
194203
test/support/ Mox definitions, factories, fake tools/providers
195204
docs/ architecture, data model, security model, standards, ADRs
196205
docs/ this plan's docs and ADRs
197-
tauri/ desktop shell (Slice 001+)
206+
tauri/ desktop shell (Slice 001+; as built, `src-tauri/`, the generator's name: main.rs
207+
and keychain.rs, slice 100)
198208
```

‎docs/07-security-model.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,60 @@ regulated effect.
285285
- Never in DB, never in logs, never in prompts. `mix gate` runs a regex secret scan on the diff.
286286
- Provider keys are read at call time by the provider module; not held in Session state.
287287

288+
**As built at slice 100.** The first line above named two candidates; neither was chosen.
289+
290+
- **Where.** `Trinity.Secrets` asks the OS keychain first and the environment second, by name (an
291+
environment-variable name such as `OPENROUTER_API_KEY`); `Trinity.Config.secret/1`, the one reader
292+
providers use, goes through it. The keychain is reached through the Tauri shell's own binary run as
293+
a command, `trinity --keychain probe | get | set | delete <NAME>`, whose `keyring` crate uses the
294+
macOS Keychain, the Windows Credential Manager or the Secret Service. The shell hands the sidecar
295+
its path as `TRINITY_KEYCHAIN_HELPER`. A command rather than the window's channel because the
296+
channel exists only once the window has attached, and the receipt signer needs its key before
297+
that; a command rather than a NIF because the Rust is already in the shell.
298+
- **How a value travels.** On the helper's stdin (set) or stdout (get), hex-encoded, one line; never
299+
on argv, where any process on the machine could read it with `ps`. The helper reports a failure by
300+
kind and never prints a value (`keyring`'s encoding error carries the stored bytes, so its form is
301+
never printed).
302+
- **Never anywhere else.** `store/2` with no keychain is `{:error, :keychain_unavailable}`, not a
303+
file. The Settings page and the setup path submit the key as a parameter named `secret`, which
304+
`config :phoenix, :filter_parameters` logs as `[FILTERED]`, and never render it back.
305+
`Trinity.Settings` (the non-secret settings file) refuses an unknown key and any string shaped like
306+
key material. Tests scan the database files, the settings file and the captured log for the value;
307+
the Linux run scans the data, config, cache and runtime directories and greps the database with
308+
`strings`.
309+
- **The receipt signing key** moves into the keychain by rotation when the keychain first answers:
310+
a new key whose private half is stored there and read back before anything changes, a registry row
311+
for it (`custody: keychain`), a `retired` row for the old key with `valid_until` and
312+
`superseded_by`, the old key file kept (renamed, mode 0600), and the boot receipt naming the
313+
custody. The verifier refuses a retired key's receipt dated after its `valid_until`. A process
314+
with no helper (a `mix phx.server` on a data directory the desktop app has migrated) cannot sign
315+
and denies every effect, the fail-closed shape of slice 024; `TRINITY_KEYCHAIN_HELPER` pointed at
316+
a built shell binary restores it. An export made with the private key does not carry a
317+
keychain-held key; that is a follow-up of slice 034's backup story.
318+
- **Not yet in the keychain:** the MCP OAuth client tokens (`<data dir>/secrets/oauth/`, slice 062)
319+
and the MCP server's token and state key (slice 061). They stay files, mode 0600, until a slice
320+
moves them.
321+
322+
## The desktop shell's channel (slice 100)
323+
324+
The Rust window and the BEAM share the channel `ex_tauri` keeps for its heartbeat: a Unix socket in
325+
the temporary directory, mode 0600, on macOS and Linux, and a loopback TCP port whose number is in a
326+
file on Windows (slice 001 finding F2: any local process can connect there). The shell generates a
327+
token per launch, hands it to the BEAM as `TRINITY_SHELL_TOKEN`, and presents it in a `hello` on
328+
every connection; `Trinity.Desktop.Shell` compares it in constant time and, until it has matched,
329+
ignores tray clicks and dialog answers and refuses requests. A peer that is not the shell therefore
330+
cannot quit Trinity through the tray, open a folder, or put a folder of its choosing into the
331+
filesystem allowlist. Window focus is believed from anyone; the worst it does is cause or suppress a
332+
notification. **What remains open:** any peer can still connect and then stop sending heartbeats,
333+
which shuts the BEAM down (the denial of service F2 names), and `ex_tauri` 0.2.0 does not tell
334+
subscribers when a peer disconnects, so trust lasts until the next hello rather than until the
335+
connection ends. Closing those needs a change to the heartbeat itself.
336+
337+
**What leaves the machine, or the app, through the shell.** OS notifications are shown and kept by
338+
the operating system's notification centre, so they carry the tool's name, the task page or the
339+
channel's name, and nothing else: no tool arguments, no message text, no result. A notification's
340+
click can send the window only to a path of this app, never to a URL.
341+
288342
## Gateways (Slice 070, as built)
289343

290344
- **An unknown sender gets a pairing prompt and nothing else.** No session is created, no model is

0 commit comments

Comments
 (0)