Design: tiny-harness tui never asserts a participant, so it cannot send messages
Derived from
bugfix.md. Thedesign-approvalgate was skipped by declaration at phase-selection; this design and the testing plan are reviewed on PR #21.
Overview
The fix threads one string from the command line to the A2A client. The CLI resolves the participant once, before anything starts: --participant if it was given, otherwise the OS user name. It refuses with status 2 when neither yields an id. commands.tui then passes the participant to run_tui on both of its paths. run_tui makes the participant a required argument, so no future caller can drop it again. Below run_tui nothing changes: SdkClient already sets the header and the metadata when it is given an id.
Architecture
Changed: cli.py, commands.py, interaction/tui/app.py. Unchanged: SdkClient, the executor, the access policy and the web renderer.
Components & interfaces
tiny_harness/service/cli.py
build_parser: thetuisub-parser gains--participant ID(default=None, help: "the participant id to assert (default: the OS user name)").- New
tui_participant(args: argparse.Namespace) -> str | None. It takesargs.participantwhen the flag was given, elsegetpass.getuser(), stripped either way, since HTTP forbids leading or trailing whitespace in a header (Nonewhen that raisesOSError, the Python 3.14 contract when no user name can be found). It returnsNoneunless the id is non-empty printable ASCII (R1.8). The id also rides in theX-Participant-Idheader: httpx sends it as UTF-8, Starlette decodes it as latin-1, and h11 refuses CR/LF only at send time. main: for thetuicommand, afterrefusal, callstui_participant. OnNoneit printsconfiguration error: no participant to assert; pass --participant <id> (--participant)and returnsCONFIG_EXIT(2). This happens beforeasyncio.run, so nothing connects or starts (R1.4, R1.5).dispatch: passesparticipant=args.participanttocommands.tui.mainwrites the resolved value back ontoargs.participant, sodispatchkeeps taking one namespace.
tiny_harness/service/commands.py
tui(settings, *, url, participant: str): the cast becomesCallable[..., Awaitable[int]], and bothrun_tuicalls passparticipant=participant(R1.6).
tiny_harness/interaction/tui/app.py
run_tui(url: str, *, participant: str) -> int:participantbecomes required and non-optional.HarnessApp(..., participant=participant)drops theor "you"placeholder.HarnessApp.composesets the composer placeholder toEnter to send as {participant}(R1.7).HarnessApp.__init__keeps its"you"default: the UI snapshot tests and the prototype construct it without a client and send nothing.
UI/UX design
One text change, no new layout. Today HarnessApp.participant is stored but never rendered, so a person cannot see who they are acting as. The composer's placeholder becomes Enter to send as <participant>:
▊Enter to send as alice▎This matters more now that the id can be defaulted silently from the OS user name. The first draft appended as <participant> to the top bar instead. The regenerated snapshots showed it pushing the task state (INPUT_REQUIRED) off the right edge at 110 columns, so it moved to the composer, which has room. No widget, key or layout is added, so there are no artifacts under design/. The four TUI SVG snapshots change by exactly this text and are regenerated.
Data models
None changed. The participant is a plain str on the existing wire fields (X-Participant-Id, metadata.participant_id).
Error handling
| Condition | Where | Result |
|---|---|---|
--participant "" or whitespace only | cli.main | stderr configuration error: … (--participant), exit 2, nothing started |
no flag, getpass.getuser() raises OSError | cli.main | same as above |
id (flag or OS user) not printable ASCII, for example josé or one containing \n | cli.main | same as above; the user passes an ASCII --participant |
| server still refuses (for example a perimeter strips the header and metadata) | executor → TUI | unchanged behaviour of the server; out of scope |
Security design
Boundary (from bugfix.md) | Enforcement |
|---|---|
| CLI → server, participant id | Unchanged server enforcement: executor.py still refuses a message that asserts nobody, and AccessPolicy still checks membership against the asserted id. The fix adds no server-side default; abuse case 1 is proved by the existing refusal test, which still passes. |
| Self-asserted identity (decision-003) | The TUI uses the same two channels every other client uses (X-Participant-Id, participant_id). The value the TUI sends gets no extra trust; abuse case 2 is the existing tests/integration/a2a/test_access.py behaviour. |
| Fail closed on missing identity | cli.main exits 2 on a blank flag, an undeterminable user name, or an id outside printable ASCII. It never falls back to a shared placeholder such as you. |
No secret is read, logged or stored. The participant id is not logged by the CLI.
Testing strategy
Test-first. The regression test goes red on today's code with no participant asserted.
- Unit (
tests/unit/service/test_cli.py): parsing--participant;tui_participantfor flag, blank flag, OS default andOSError;mainexits 2 naming--participantin the blank andOSErrorcases;commands.tuipasses the participant on the--urlpath (fakerun_tuirecords its kwargs). - Integration (
tests/integration/embedded/test_cli_embedded.py): the existing embedded scenario's stand-in now receives the participant fromcommands.tuiinstead of choosing one. A new regression scenario drives the realrun_tui→SdkClient.connect→HarnessApppath. OnlyHarnessApp.run_asyncis patched to run under Textual's pilot, which sends a message to the embedded harness and expectsCOMPLETED. - Manual: the ticket's reproduction against the demo, recorded as evidence.
Details and commands: testing-plan.md.
Trade-offs & decisions
- Default to the OS user name rather than require the flag. The ticket names it as the candidate default. It keeps
tiny-harness tuia zero-argument command, and two terminals still get distinct ids. Requiring the flag would be safer only if the identity were trusted, and it is not (decision-003). Not a durable project decision; recorded here and inbugfix.md. - No config key or env var. Minimalism ladder, YAGNI rung: nothing asks for one.
- Resolve in
cli.main, not incommands.tui.mainalready owns the exit-2 configuration refusals (refusal), so the new refusal sits beside them and runs before the event loop and any embedded Temporal start. - No new dependency.
getpassis in the standard library.
Open questions
None open. The default-versus-require choice is resolved above and open to PR review.