Skip to content

Let the CLI talk to taskd instead of a second task store - #42

Merged
oratis merged 5 commits into
mainfrom
feat/cli-taskd-client
Aug 3, 2026
Merged

Let the CLI talk to taskd instead of a second task store#42
oratis merged 5 commits into
mainfrom
feat/cli-taskd-client

Conversation

@oratis

@oratis oratis commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Resolves architecture review finding #5 — "CLI 与 taskd 是两套互不连通的前端".

The failure this removes

andromeda-cli opened a FileTaskStore in process, defaulting to .andromeda/state under the
working directory. The installed andromeda-taskd keeps its records in
/var/lib/andromeda-taskd/state under a systemd DynamicUser at mode 0700. On a real machine
those are two disjoint task universes, and the daemon's is not merely elsewhere — it is
unreadable. So a user running andromeda task list opened a different, empty store and got:

[]

which is indistinguishable from "there are no tasks". Both halves claimed to be the task list, and
nothing in the output said which one you were looking at.

What changed

1. The CLI can be a taskd client. --connect <URL> (ANDROMEDA_TASKD_URL) routes every task
subcommand through the HTTP API — create, list, show, evaluate, record-outcome, transition. Each
arm posts the same CreateTaskRequest / EvaluationRequest / RecordOutcomeRequest /
StateTransitionRequest the local path builds, which is exactly what taskd deserializes, so the two
modes cannot drift into two contracts.

2. The mode is a required choice, not a default. Naming neither store is an error that prints
both commands and why there is no default:

$ andromeda task list
error: choose which tasks to act on: --connect <URL> (ANDROMEDA_TASKD_URL) drives a running
andromeda-taskd, and --state-dir <PATH> (ANDROMEDA_STATE_DIR) opens a task store in this process.

There is no default. An installed system keeps its tasks in /var/lib/andromeda-taskd/state, owned
by the daemon's DynamicUser and readable by nobody else, so a defaulted local store answered
`task list` with an empty list that meant "you are looking at the wrong store", not "there are no
tasks".

On an installed system:   andromeda --connect http://127.0.0.1:7777 task list   (as root, for the token)
For local development:     andromeda --state-dir .andromeda/state task list

Removing the default is what makes the silent case unreachable rather than merely warned about,
which is how the rest of this repository turns conventions into mechanisms (Authenticator has no
anonymous variant; ensure_loopback_bind refuses at startup; hardware check refuses an
unverified tier gate outright).

Naming both is refused too, with no silent precedence — picking one would be the CLI deciding
which set of tasks you meant, which is the habit being broken. That check is not clap's
conflicts_with: clap counts an environment-supplied value as present, so a developer who exports
ANDROMEDA_STATE_DIR (which the developer docs suggest) would be told --connect "cannot be used
with --state-dir" after typing only --connect. The message now names both values and points at
the two variables as the likely source.

3. Whichever mode runs, the target is printed. One line on stderr before the output, so stdout
stays pure JSON and | jq is unaffected. An empty answer now always arrives with the identity of
the thing that was empty:

$ andromeda --state-dir .andromeda/state task list
andromeda task: reading the local task store at /home/you/project/.andromeda/state (in process;
this is NOT andromeda-taskd's store — pass --connect <URL> for the daemon's tasks)
[]

$ andromeda --connect http://127.0.0.1:7788 task list
andromeda task: reading andromeda-taskd at http://127.0.0.1:7788 (token .andromeda/taskd-token)
{ "tasks": [ ... ], "warnings": [] }
andromeda task: taskd lists summaries only — no plan and no events. Read one task with
`task show <TASK_ID>` for its history.

Both mechanisms are in place on purpose: the required choice makes the original failure structurally
impossible, and the banner keeps any remaining ambiguity visible even when the choice was made
correctly.

4. The wire contract is honored, not papered over. GET /v1/tasks returns summaries with no
event bodies and GET /v1/tasks/{id} returns a bounded window plus the true event_count, so the
CLI never implies it holds full history:

$ andromeda --connect http://127.0.0.1:7788 task show <ID>
andromeda task: showing 50 of 56 events; pass --events 56 for the rest (taskd's ceiling is 1000)

Past the daemon's ceiling it says the oldest N events are unreachable through the API rather than
suggesting an --events value that would be silently clamped. --events is refused by the local
path instead of ignored, because a local read has no window to set. The two constants the CLI
repeats (the image token path, the event ceiling) are asserted equal to
andromeda_taskd::auth::SYSTEM_TOKEN_PATH and andromeda_taskd::MAX_EVENT_LIMIT by a unit test —
the same anti-drift pattern the systemd-unit permission test already uses. Making those bounds
public is the one change to andromeda-taskd: they are contract, since only a client that can tell
the window from the total benefits from event_count being honest.

Authentication

Every taskd route requires the bearer token, /healthz included, so the CLI presents it on every
request.

  • File only. There is no --auth-token flag and no ANDROMEDA_AUTH_TOKEN. argv is readable
    by every local process through /proc, which is precisely the credential the daemon's 0700
    runtime directory exists to withhold. os/files/usr/libexec/andromeda-ci-verify does put a token
    on a command line and justifies it at length as acceptable only on a throwaway CI VM with SSH
    disabled and no interactive users; a command users run does not qualify for that exemption.
  • Resolution. --auth-token-file / ANDROMEDA_AUTH_TOKEN_FILE, else /run/andromeda-taskd/token
    (the image path) then .andromeda/taskd-token (a hand-started daemon's default). The file actually
    used is named in the banner and in every error.
  • 401 is actionable, not a status dump:
error: taskd at http://127.0.0.1:7788 rejected the token from ../stale-token (HTTP 401
unauthorized). The daemon does not say whether the token was missing, malformed, or simply wrong,
so check that this file is the one the running daemon uses: the shipped unit sets
ANDROMEDA_AUTH_TOKEN_FILE=/run/andromeda-taskd/token, and a daemon started by hand defaults to
.andromeda/taskd-token relative to its own working directory. A restarted daemon keeps its existing
token, so a stale copy elsewhere is the usual cause

A permission denial says the 0600-in-0700 layout is the boundary working as designed, not a
misconfiguration, and to re-run under sudo. A missing file lists every path searched. No token
value ever appears in an error, a log line, or the client's Debug output.

  • Loopback only. --connect http://example.com is refused before any byte is sent — the
    request would carry the local bearer token, which is a stronger reason to refuse than merely
    mirroring taskd's own Host check. Tunnelled endpoints still work, since they present as
    127.0.0.1 locally.

Dependency delta

No package added, removed, or changed version. The Cargo.lock diff is four dev-dependency
names on andromeda-cli:

 [[package]]
 name = "andromeda-cli"
 dependencies = [
   ...
+ "andromeda-taskd",
+ "axum",
   "chrono",
   ...
+ "tempfile",
+ "tokio",
 ]

All four already exist in the lockfile; they are test-only, so the shipped binary gains nothing.

I checked the HTTP-client options before writing one. hyper and hyper-util are already in the
graph via axum, but I measured what turning on the client half costs: enabling
hyper/{client,http1} + hyper-util/{client,client-legacy,tokio} adds want and try-lock to
Cargo.lock, and would make this one-shot CLI async for the sake of a single request against a
loopback socket. Given that this repository already rejected a change for adding a third major
version of getrandom, two new packages plus a runtime for one bounded request is not a trade worth
making. The client is therefore blocking and built on std::net: request framing, Content-Length
and chunked decoding, timeouts, and a response cap, in ~200 lines with unit tests for each piece.

Tests

cargo test --workspace --locked — 275 passing, up from 254. andromeda-cli goes from 17 tests to
38.

The connected-mode tests spin taskd's real router on a real loopback socket in-process (tokio
runtime + axum::serve on 127.0.0.1:0), so they exercise the bytes the client actually puts on the
wire — the Authorization header included — rather than a stub that could agree with a wrong client:

  • the_client_presents_the_token_from_the_token_file
  • a_rejected_token_names_the_file_and_says_what_to_check — asserts the 401 message contains the
    file used, both default paths, and the env var, and contains no token
  • list_parses_the_current_summary_shape — asserts no events and no plan key
  • show_parses_the_bounded_read_and_reports_the_true_total — 61 events, default window returns 50
    with event_count: 61, ?events=61 widens
  • a_refused_request_reports_the_wire_error_code — 404 surfaces not_found, not a bare number
  • the_token_search_path_matches_the_daemons_own — constants pinned to the daemon's

Plus mode resolution (neither_mode_is_refused_with_both_options_spelled_out,
naming_both_stores_is_refused_without_a_silent_precedence,
a_token_file_without_connect_is_refused_rather_than_ignored,
the_mode_flags_are_accepted_at_either_level), the banner, the truncation note including the
past-the-ceiling case, endpoint parsing, non-loopback refusal, and HTTP response parsing
(content-length, chunked, truncated, empty, request-target smuggling).

Verified end to end against a live daemon: created a task through --connect, listed it, grew its
history to 56 events and saw the truncation note appear and then disappear under --events 56,
drove a Ready -> Running transition, and confirmed the local store stayed empty and said so.

cargo fmt --all -- --check and cargo clippy --workspace --all-targets --locked -- -D warnings
are clean.

Judgement calls worth flagging

  • Required choice vs. keeping a default and only warning. I removed the default. A banner alone
    depends on someone reading stderr; removing the default makes the original failure impossible to
    reach. Cost: the documented andromeda task list form now needs a flag, and the docs are updated
    accordingly. Local-store mode itself is untouched and remains the development path.
  • --events is connected-mode only, and list output differs between modes. Local mode is a
    library view (whole records, unbounded); connected mode is the API view (summaries, bounded
    window). I did not mirror taskd's DTO layer into the CLI to force identical shapes — wire.rs
    itself argues that re-mirroring a contract duplicates rather than insulates it. The banner and the
    per-command notes make which view you got explicit instead.
  • --auth-token-file in local mode is an error, not ignored. Silently dropping an input the
    caller deliberately supplied is the same class of mistake as the silent default.
  • main now prints Display and walks source(). fn main() -> Result<_, _> renders Debug,
    which would deliver the multi-line explanation of the two stores as an escaped quoted string —
    the exact problem commit a141716 fixed for andromeda-taskd.
  • docs/reviews/ left untouched. Those are point-in-time artifacts pinned to the HEAD they were
    written against, and earlier fixes (findings docs: add Andromeda OS research and product plan #1, feat: add Andromeda core task and capability contracts #2, feat: add durable Andromeda task runtime and API #3) did not rewrite them either.

🤖 Generated with Claude Code

oratis and others added 5 commits August 3, 2026 10:06
`GET /v1/tasks/{id}` returns the most recent `DEFAULT_EVENT_LIMIT` events
and reports the true total in `event_count`, so a truncated read is
visible rather than silent. That guarantee only pays off for a client
that can tell the window from the total, and the ceiling on `?events=`
is the difference between "ask for more" and "this history is not
reachable through the API at all" -- so both numbers are contract, not
implementation detail.

Exporting them lets a client that has to repeat the values assert
equality against the daemon instead of drifting away from it, the same
anti-drift pattern the token permission constants already use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CLI opened a `FileTaskStore` in process, defaulting to
`.andromeda/state` under the working directory, while the installed
daemon keeps its records in `/var/lib/andromeda-taskd/state` under a
systemd `DynamicUser` at mode 0700. On a real machine those are two
disjoint task universes and the daemon's is not merely elsewhere, it is
unreadable -- so `andromeda task list` printed an empty list that meant
"you are looking at the wrong store", not "there are no tasks"
(architecture review #5).

Connected mode makes the daemon the single source of truth:
`--connect <URL>` (`ANDROMEDA_TASKD_URL`) routes every task subcommand
through the HTTP API, posting the same request types the local path
builds, so the two modes cannot drift into two contracts.

The mode is now a required choice. Removing the default is what makes
the silent failure unreachable rather than merely warned about, matching
how the rest of this repository turns conventions into mechanisms
(`Authenticator` has no anonymous variant, `ensure_loopback_bind`
refuses at startup, `hardware check` refuses an unverified tier gate).
Naming neither mode prints both commands and the reason there is no
default; every task subcommand then echoes its resolved target to stderr
-- absolute store path, or endpoint plus token file -- so an empty answer
always arrives with the identity of the thing that was empty. stdout
stays pure JSON.

Authentication is read from a file and from nowhere else. There is no
`--auth-token` flag and no `ANDROMEDA_AUTH_TOKEN`: `argv` is readable by
every local process through `/proc`, which is exactly the credential the
daemon's 0700 runtime directory exists to withhold.
`os/files/usr/libexec/andromeda-ci-verify` does put a token on a command
line and explains at length why that is acceptable only on a throwaway
CI VM with no interactive users. Absent `--auth-token-file`, the token is
looked up in `/run/andromeda-taskd/token` then `.andromeda/taskd-token`,
and the file that was used is reported. A 401 says which file was
presented, that the daemon deliberately does not distinguish missing
from wrong, and where each side's default lives; a permission denial
says that only the service account and root can read the token by
design, so re-run under sudo.

The wire contract is honored as it stands rather than papered over:
`list` prints taskd's summaries and says they carry no event bodies,
`show` prints the bounded window and reports `showing 50 of 137 events;
pass --events 137 for the rest`, and past the daemon's ceiling it says
the oldest events are unreachable instead of suggesting a value the
daemon would silently clamp. `--events` is refused by the local path
rather than ignored, because a local read has no window to set.

`--connect` accepts loopback endpoints only, refused before any byte is
sent: the request carries the local bearer token, so an external host
would be handed the credential, which is a stronger reason than merely
mirroring taskd's own Host check.

No HTTP crate is added. `hyper`'s `client` feature puts `want` and
`try-lock` in the lockfile and would make this one-shot CLI async for a
single bounded request against a loopback socket, so the client is
blocking and built on `std::net`. The only lockfile change is four
dev-dependency names on `andromeda-cli`; no package is added, removed,
or changed version.

`main` now prints `Display` and walks `source()` instead of returning
`Result`, whose `Debug` rendering would deliver the multi-line
explanation of the two stores as an escaped quoted string.

Tests run taskd's real router on a real loopback socket, so they
exercise the bytes this client puts on the wire: the token is read from
the file and accepted, a wrong one produces the actionable 401, and
`list`/`show` parse the current summary and bounded-read shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The docs described a single CLI that opened `.andromeda/state`, which is
the arrangement architecture review #5 objected to, and said nothing
about the daemon's store being a different and unreadable set of tasks.
Every place that documents a task command now states which store it
acts on, why there is no default, and what the connected mode returns:
summaries with no event bodies from `list`, a bounded event window with
the true total from `show`, and the fact that `--events` widens only the
API's window.

The token rules are stated once per document and identically: file only,
searched at the image path then the hand-started default, reported in
the banner, and never accepted as a value because `argv` is readable
through `/proc`. `getting-started.md` gains an end-to-end connected-mode
walkthrough against the daemon it has just told the reader to start,
which is the shortest path from "the API is running" to "the CLI reads
the same tasks".

The review documents under `docs/reviews/` are left untouched: they are
point-in-time artifacts pinned to the HEAD they were written against,
and earlier fixes did not rewrite them either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
clap counts an environment-supplied value as present for `conflicts_with`,
so a developer who exports `ANDROMEDA_STATE_DIR` -- which the developer
docs actively suggest -- was told that "the argument '--connect <URL>'
cannot be used with '--state-dir <PATH>'" after typing only `--connect`.
The message named a flag that never appeared on the command line, in the
one area of this CLI whose entire purpose is to stop leaving the reader
guessing which task store is in play.

The conflict now falls to `resolve_task_target`, alongside every other
rule about the two modes, and says what a caller can act on: both values,
that exactly one is wanted, and that either can have come from the
environment. Nothing silently wins; there is no precedence between them,
because picking one would be the CLI deciding which set of tasks the
caller meant, which is the habit this change exists to break.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three places still said the two modes "conflict", one of them naming
clap's `conflicts_with` as the mechanism. That stopped being true when
the check moved into `resolve_task_target` so the message could name the
environment variables the caller may not have typed. Each place now
states what actually happens: both-given is refused with no precedence,
and either value can have come from the environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis
oratis force-pushed the feat/cli-taskd-client branch from 4f018f8 to ab67e86 Compare August 3, 2026 02:14
@oratis
oratis merged commit 03aab0e into main Aug 3, 2026
7 checks passed
@oratis
oratis deleted the feat/cli-taskd-client branch August 3, 2026 03:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant