feat(cli): Phase 0 control plane, list/status/open, bundling#132
Conversation
04fc600 to
63bbb6a
Compare
Introduce a cargo workspace (src-tauri was a single crate) with two net-new members beside it: - termic-proto: the CLI <-> app wire protocol. Request/response types, protocol version, NDJSON framing (compact-only, 1 MB line cap), and the pinned exit-code contract. Deserializers tolerate unknown fields so the public shapes evolve additively. - termic-cli: the thin `termic` client. Verbs list/ls, status, open, with --output-format text|json and exit codes in --help. `list` shows project then task, matching how tasks are addressed (`status project/task`, the ambiguity errors). Links ONLY termic-proto, never the app's lib.rs. Socket discovery honors TERMIC_SOCKET; a debug build targets the termic_dev data dir and never auto-launches the release app. `list -q` skips the server work-state query + per-task git for TEXT output only (JSON keeps the full data); `open --project` requires a task rather than silently ignoring the filter. `termic --version` reports the app version, injected at build time via TERMIC_APP_VERSION (crate-version fallback for a bare cargo build). No em dashes in output or help. Per docs/plans/cli.md (simion#125).
Serve the termic control plane from the app (the daemon): - cli_server.rs: a Unix socket at <data_dir>/termic.sock (mode 0600, sun_path guard, always binds) on its OWN thread, never the IPC/main thread. The unauthenticated surface is hello + raise only; every verb needs the "Enable CLI" setting (default off) AND the per-boot token from <data_dir>/cli-token (0600, written only after the socket binds, in the server's memory only, never in the app process env). getpeereid same-uid check per connection. The accept loop survives transient errors instead of dying, and a malformed work-state entry is skipped rather than collapsing the whole map. - Work-state comes from the webview over a new typed correlation-id channel (cli-rpc://request + cli_rpc_result, src/lib/cliRpc.ts): new hardened code that borrows the automation bridge's pattern but never reuses /eval or arms it, and runs in release builds. The aggregation precedence (working > attention > done > idle, inactive for zero tabs) is unit tested. cwd resolution is worktree-first then longest project-path prefix, ambiguity lists candidates. - Enabling the CLI auto-installs the command (no prompt) into ~/.local/bin and reflects whether that is on the login PATH; the "Install system-wide" action (an optional upgrade to /usr/local/bin, shown prominently only when the auto-install did not land on PATH) uses an admin prompt. Command name is build-aware (termic / termic-dev / termic-beta) so dev/beta/prod coexist. The toggle reverts on a failed save. - Single instance per data dir (release): the socket doubles as the lock, so a second launch on the same data dir raises the running one and exits. Debug is newest-wins. The app builds and behaves identically for anyone who has not enabled the CLI. Per docs/plans/cli.md (simion#125).
The control socket is a privilege boundary: a caged agent that reaches it can spawn an uncaged task and escape. Close both halves as the FINAL rules of the enforcing profiles (Enforce and EnforceFs, never Monitor), where SBPL last-match-wins makes them beat any ancestor allow above: - (deny network-outbound (remote unix-socket (path-literal <sock>))) as the last network rule, after the broad unix-socket allow, EnforceFs's blanket (allow network*), and the agy special-case outbound allow. - (deny file-read*/file-write* (subpath <data_dir>)) as the last filesystem rules, so the CLI token, projects.json and tasks/ stay unreachable even when a broad ancestor (~, ~/Library) is allow-listed. render_profile is refactored so render_profile_with is a pure function of its inputs, letting tests inject a hostile allow-list through the same seam the real layers flow through. Verified behaviorally with real sandbox-exec: in-cage connect() refused, in-cage token read refused (with ~/Library allow-listed via the task and agent layers, a sibling file proving the allow-list is live), and a caged spawn's env carries no token. Each behavioral test writes its SBPL profile into its OWN temp dir, so a security test can never race a sibling's profile file and false-alarm. Monitor mode's deliberate exemption is documented in docs/sandbox.md. Per docs/plans/cli.md (simion#125), Security + Testing.
- externalBin sidecar (binaries/termic-cli) so the CLI ships inside the .app and updates in lockstep with the app updater. src-tauri/build.rs builds + stages the sidecar for the current triple BEFORE tauri_build::build() runs, in the SAME profile as the app (a debug check does not trigger a release codegen of the CLI), and lipos the universal binary when both mac arch files exist. - build.rs and scripts/build-cli.mjs inject TERMIC_APP_VERSION so `termic --version` matches the app the CLI ships in; make cli-dev does the same for the dev symlink. - scripts/build-cli.mjs runs from beforeBuildCommand as the belt-and- braces for `tauri build`, staging every installed macOS arch plus the universal binary. - CI runs cargo test --workspace --lib so the proto round-trips and cli goldens run alongside the app's tests. - binaries/ is gitignored (arch-specific staging, never committed). Per docs/plans/cli.md (simion#125), Distribution.
Record where the implementation refined the original design so Phase 1+ builds on an accurate spec: single-instance per data dir (the socket as the lock, the new unauthenticated `raise` verb, debug newest-wins vs release enforcement), build-aware install names (termic / termic-dev / termic-beta) so dev/beta/prod coexist, auto-install on enable into ~/.local/bin with the system-wide upgrade, release-only auto-launch, and build-time version injection so the bundled CLI is versioned with the app. Updates the status line and the transport / launch / distribution / landing sections.
63bbb6a to
0e13020
Compare
simion
left a comment
There was a problem hiding this comment.
Approving. The security model is the standout: 244-bit per-boot token in a create_new 0600 file, never exported to the process env (asserted by an absence test), peer same-uid check before any read, and SBPL denies placed as the final fs/network rules so last-match-wins can't invert them. Really appreciate that the containment claim is proven behaviorally with real sandbox-exec runs rather than a textual profile check, including a positive control that the allow-listed ancestor is otherwise readable. Resolution logic (worktree-vs-main-checkout precedence, composition members, shared-checkout ambiguity) and the NDJSON framing edge cases are all well covered, and the build.rs nested-cargo lock/env handling plus the universal-bundle belt-and-braces are thoughtful. Only nits, none blocking: per-connection threads are uncapped (bounded by same-uid + timeouts), and every app cargo check now recompiles the CLI (mitigated by profile-matching). Ship it.
|
Will be shipped in the next release, probably this weekend. I manually re-test the entire app, and each new feature. before releasing :D Thanks for the effort! |
This PR is Phase 0 of the termic CLI (design: #125): a
termiccommand that talks to the running app over a local Unix socket.What it does now: three read-only commands (
list,status,open) plus all the machinery underneath them (socket, token auth, connect-or-launch, the bundled binary, and PATH install). It cannot create or change anything.What it does not do yet: create or drive tasks. Those verbs land in later PRs.
This is a self-contained phase that merges on its own, following the repo's "one phase, one PR into main" shape (#62, #84, #113). Later phases follow as separate PRs with no ordering constraint between them, and every phase leaves the app's build green and behaviorally unchanged for anyone who has not opted in. Opened as a draft for now; ready to flip once reviewed.
Commands
list/ls-q= ids only,--projectfilterstatus <task>open [<task>]new <name> [-p …]wait <task>archive <task>project add / list / removesend <task> -p …apply <task>attach <task>path/diff <task>Everything in Phase 0 is read-only or window-focus. Unimplemented verbs are simply absent (clap prints "unrecognized subcommand"), never stubbed or half-working. Global flags:
--output-format text|json,--json,--no-launch.Command name per build, and instances
The installed command name is build-aware, and the app permits one instance per data dir (the socket doubles as the lock). Because prod and beta share the release data dir, they are mutually exclusive by design: launching one while the other runs raises the running one and exits. Beta is long-term testing of prod, so you run one or the other, never both.
termictermictermic-betatermic(shared with prod)make dev)termic-devtermic_devTERMIC_DATA_DIRThe on-disk sidecar is always
termic-cli; only the symlink name varies, so all three coexist on PATH.Under the hood
termic-proto(wire types, NDJSON framing, protocol version, pinned exit codes) andtermic-cli(thetermicbinary), which links onlytermic-proto, never the app'slib.rs.<data_dir>/termic.sock(0600, sun_path guard, always binds). Unauthenticated surface is hello/raise only; every verb needs the "Enable CLI" setting (default off) AND the per-boot token from<data_dir>/cli-token(0600, in memory only, never in the app env thatpty_spawncopies to children).getpeereidsame-uid check per connection.cli-rpc://request+cli_rpc_result), not the debug/evalbridge, which is never armed or reused.sandbox-exec.externalBinsidecar so the binary ships in the.app; enabling the CLI auto-installs it (no prompt) into~/.local/bin, with a system-wide/usr/local/binoption (admin prompt on macOS).termic --versionreports the app version, injected at build time, so the bundled CLI is always versioned with the app.Off by default
There is a single Settings → General → "Enable CLI" toggle, off by default. While it is off, every command is refused and the app behaves exactly as it does today, so this is invisible to anyone who does not turn it on. (Exit codes and
--jsonoutput are a stable, additive-only contract, so scripts can rely on them once shipped.)Try it out
In the running app: Settings → General → "Enable CLI" (toggle on). Enabling auto-installs the command as
termic-devinto~/.local/bin(no password). Then from any shell:The
-devname is deliberate so it never clashes with a realtermicinstall. If~/.local/binis not on your PATH, the Settings row says so, andmake cli-devis an alternative that builds and symlinks it for you. Toggling the setting off refuses every command immediately.Testing
sandbox-execcontainment tests (in-cage connect refused, token read refused with~/Libraryallow-listed through each extension layer, caged env carries no token). Plus the existing frontend suite.cargo test --workspace --lib. A full release compile succeeds and stages the sidecar.list/status/open, and every error path (disabled, bad/missing token, no-launch, in-cage refusal).Known / deferred
sandbox-exectests self-skip on the Ubuntu CI (macOS-only). A gated macOS CI job to run them for real is a follow-up.~/.local/bin(no privilege prompt, since the/usr/local/binelevation is macOS-only), and auto-launch is macOS-only; the sandbox boundary is macOS-only regardless (tasks are never caged on Linux).