feat(cli): stdin json protocol and strict exit code semantics #74
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-32-protocol"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Agents drive the Life service through
life … --jsonin a child process, so theprocess boundary is the interface. Three things made it unsafe:
private reference, a credential — where every process on the host can read it
and shell history retains it;
answer could not be told from success, and an error envelope whose code the map
did not carry collapsed to exit 1;
clarification_required,operation_unknown,needs_reconciliation,partial_apply,context_pendingandlimit_exceededall fell through toexit 1, so an agent could not branch on them.
Issue #32 asks for the documented transport contract to be real rather than
aspirational:
--input -for every body-taking command, one envelope on stdoutwith diagnostics on stderr, and exit codes that mean what
docs/technical/cli-api.mdsays.Changes
Stdin is the structured-input path (
src/cli.rs)read_inputis bounded: stdin or--input FILEis read up to 1 MiB andrefused beyond it with
invalid_input, exit 2, before anything is dispatched.read_body(a body is required) andread_optional_body(an absent body is{}) — replaces the ten hand-rolledcopies. A body that is empty, malformed or not a JSON object is exit 2 with
nothing dispatched, uniformly.
operation_keyhelper: acaller's
--operation-idis used verbatim, and an absent one is generated(high entropy) before transmission.
No payload and no credential in argv
--tokenandLIFE_TOKENare removed. The bearer token is read only from thesecret file the client configuration names, which is what the skill and
operations-security.mdalready documented;Endpoint::resolveno longeraccepts a token at all.
task add/event addno longer accept--title/--collection-id.Those were undocumented shortcuts that put private payload fields in the
argument list; the body is the only way, and passing them is a usage error
(exit 2).
Exit codes: semantic outcome before transport (
src/client.rs)exit_code_for(envelope, http_status)decides on the envelope's own outcomefirst:
partial→ 6 even when a step carriedetag_conflict,needs_clarification→ 3,okarriving as 202 → 6. Only anerrorenvelopewhose code the map does not carry falls back to the documented HTTP mapping,
and an error envelope is never exit 0.
ApiFailureconstructor and every code a routewrites into an envelope.
envelope — is
status: partial/error.code: operation_unknownwith exit 6,the operation ID the client already knows, and the client-generated request ID
the request carried as
X-Request-ID. A server request ID is never invented.request_pendingis gone: it was not in the documented vocabulary.Channel separation
--json) path, warnings and the incomplete-coverage noticemoved from stdout to stderr, so stdout carries the result and stderr the
diagnostics in both modes.
The skill stays true (
skills/life/references/)errors.md:limit_exceededis exit 2 (it was documented as a known gap inthe client's map), new rows for
configuration_invalid,clarification_requiredandneeds_reconciliation, and a timeout now reportsoperation_unknown.commands.md: no--token; the 1 MiB bound and the JSON-object rule;life event cancelmoved to the not-implemented table — it parses but has noroute in this client and exits 5, which the Writes table previously
contradicted.
Verification
cargo test— 714 tests across every suite, all green (rebased onmainat the append-only timeline corrections merge).cargo clippy --all-targets -- -D warnings— clean;cargo fmt --check—clean.
tests/cli_protocol_tests.rs(10 tests, all through the real binary):ApiFailureconstructor twice — throughthe code map and through the envelope at its own HTTP status — plus the
route-level codes;
etag_conflictat 412 → 6,okat 202 → 6, unmapped code at 200 → 1, never 0);and nothing dispatched, and read the same body from a file;
just under it is dispatched;
warnings and the partial-coverage notice on stderr;
neither the secret-file token nor the stdin body;
Idempotency-Keyis sent verbatim by 13 state-changing commands and by noneof the read-only POSTs (
resolve,next,context search).tests/cli_creation_tests.rs(timeout envelope),tests/crash_recovery_tests.rs(read timeout envelope),tests/update_tests.rs/tests/skill_package_tests.rs(the resolver nowtakes the HTTP status).
Related Issues
Closes #32
6c28e00ed6to64875503c7Coordinator review — APPROVED
Verified
6487550myself:cargo test714 passed / 0 failed (10 new protocol tests),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean, and--token/LIFE_TOKENare genuinely gone from the CLI (grep -c→ 0).This was the right issue to run as an audit, and the findings are real defects rather than cosmetic ones. The three that mattered most:
clarification_required,operation_unknown,needs_reconciliation,partial_apply,context_pendingandlimit_exceededall fell through to exit 1. A caller branching on exit codes — which is exactly what the shipped skill tells an agent to do — would have read a clarification as a generic failure and a partial apply as a plain error. The map is now total over everyApiFailureconstructor plus the route-level codes, andthe_exit_code_map_covers_every_code_the_service_can_emitenforces totality rather than sampling.status: errorwith a code (request_pending) the contract does not define. It now returnsstatus: partial+operation_unknown+data{operation_id, operation_state: unknown}at exit 6 — matching the normative envelope inverification.md— and a write whose answer is not an envelope is an unknown outcome rather than exit 1. That is the difference between an agent stopping to inspect an operation and an agent assuming a failure.argv/env. Removing--token/LIFE_TOKENso the token comes only from the configured secret file closes a leak the log-redaction work in #35 could not cover, sincepsis not a log. Dropping the undocumented--title/--collection-idargv payloads is the same class of fix.Also verified as correct:
exit_code_foris now status-aware (ok@202 → 6, and an unmapped error falls back to the documented HTTP map rather than 0), the human path no longer prints warnings to stdout, stdin bodies are bounded at 1 MiB, ten hand-rolled body readers and eight operation-key generators collapsed into shared helpers, and the skill's incorrectlimit_exceeded/request_pending/event cancelentries were corrected in the same commit.One gap you correctly documented instead of hiding:
life event cancelhas noEventSubcommands::Canceland noPOST /v1/events/{id}/cancelroute, althoughcli-api.md's command-to-route table lists it. Moving it to the skill's not-implemented table is honest, but the contract still advertises it — I am filing that as a follow-up so it gets implemented rather than silently dropped from the docs.Merging.
life event cancel/ POST /v1/events/{id}/cancel #75feat(cli): stdin json protocol and strict exit code semantics (#32)to feat(cli): stdin json protocol and strict exit code semantics