Skip to content

A remedy is data until the door that prints it renders it - #334

Merged
udaychandra merged 17 commits into
mainfrom
agent/sail-remedy-rendered-at-the-door
Oct 22, 2026
Merged

udaychandra merged 17 commits into
mainfrom
agent/sail-remedy-rendered-at-the-door

Conversation

@udaychandra

@udaychandra udaychandra commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Spec: sail-remedy-rendered-at-the-door (takes over the gaps PR #325 specced).

R1: a root-only refusal names the command as typed

  • commands/RootRequired builds every root-only refusal from the caller's own words: picocli's expandedArgs() after the verb path found in the Sail tree, each word through Strings.shellWord, sudo sail in front. Main.refusal uses the same rerun.
  • The twelve "Root privileges required" sites use it, and so do host keys sync and host ssh-identity. JoinCommand checks writability before it prompts.
  • host sync and host update refuse a non-root caller before they read host.yaml. On a fresh box (scenario 3) the refusal now names root, not "not initialized".
  • shellWord leaves @, , and + bare, so the re-run reads sudo sail init --main sail@m.
  • RootRequiredTest covers scenarios 1–4 with exact sentences. It also runs every root-only verb with every option as a non-root caller, and asserts the re-run's words equal the typed words and parse to the same options.

R2: a remedy is data until the door renders it

  • The type. common.Remedy holds words and commands; a command is {runs: SAIL | CLIENT | PROGRAM, verb, args}. common.Remedied (reason + remedy) is carried by ISE/IAE/IOException flavours, SyncTransportException, ApiException and SailApiClient.Refused. A raw getMessage() is the plain reading.
  • Where the type is used.
    • Result.Failure.remedy() replaces the String action.
    • Refusal.fix and DispatchDecision.Refused.fix are Remedies.
    • Migration notes are Remedies.
    • A node's sync health keeps last_error as encoded data; plain rows from before read back as words.
    • Main's session Refuse sends its remedy beside the plain reason older nodes read.
  • Doors. Main.report, CliCommand.run/describe/lines and sync status render with OperatorCommand.here(). The API body keeps action, with each command named by its verb only, and adds remedy: [{text}|{run,verb,args}] when it names a command. SailApiClient turns the body back into a Remedy.
  • Readers that cannot be seen. A sync lane's notices go to a Consumer<Remedy> chosen at the door: sail sync renders, sail-api prints Remedy.plain. Daemon log lines, webhook notices and agent prompts print the plain reading.
  • Gone. OperatorCommand.elsewhere(), unknownReader(), and the SUDO constants in SyncDatabase and TokenAuth. The operator parameters on FdeStore, NodeRound, SyncSession/PagedSyncSession (whose System.err line is now a notice), the migrations, SyncDatabase, every api class, OperationsFactory, SyncOperations, MainHostKey, ConflictOperations, HostAccess, CliOperator, ConnectEnvironment, WorkstationIdentity, ContainerStateGuard, CurrentProject, the pty host classes, the watcher and server start.
  • Enforcement. OperatorCommand moved to sail-infra, so core and harness cannot compile against it. OperatorHintsTest.codeThatCannotSeeItsReaderNeverRendersACommand scans core, harness, api/ and the daemons' classes. The rendered-command scan now parses Remedy.sail(verb, args…) too.
  • CLI-only classes. These keep taking here()'s operator, as the spec allows: LocalIdentity, HandleChange, DaemonSecretInstaller, ProjectProvisioner and others. Hints for another box's operator in CLI code use OperatorCommand.SUDO.sail directly.

R3: one file is the box's workstation key

  • SailPaths.workstationPublicKeyPath() = workstationPublicKeyPath(identityHome()). It is the one resolver behind ${SSH_PUBLIC_KEY}, registered(), fde pair, host config set ssh-public-key and connect's warning.
  • host config set ssh-public-key writes under PairingTrust (installWorkstationKey).

Scenarios

# Test
1–4 RootRequiredTest
5, 6, 7, 10 RemedyOverHttpTest (real HTTP: the body as data with no sail sentence; rendered for SUDO, the gateway's UNPRIVILEGED and PLAIN)
8, 9 SyncCommandTest (a stored host-key failure read through sudo and by root); SyncDiagnosticTest
11 SailPathsTest.theBoxsWorkstationKeyIsTheOneFileInItsOwnersHome, FdeCommandTest (pairing path == ${SSH_PUBLIC_KEY} path)
12 HostConfigSetCommandTest.aKeyChangeWaitsWhileAnApplyOrAPairingHoldsTheTrust
13 MainRebuiltRoundTest (version refusal), FdeCommandTest (scoped grant), each through SUDO then PLAIN

Notes

  • A root-only refusal echoes every typed value, --git-token included, back to the same terminal, because the spec asks for the whole argv.
  • Row-level denial reasons on the sync wire stay text (main renders them plain), as before. Only the session-level Refuse carries a remedy.
  • The field check on a fresh GCP VM (scenarios 1, 2, 5, 7, 8) is still to do.
  • NativeFleetIT now expects a scoped node's refusal to read sail fde grant nia beta. The fleet's boxes run as root logged in directly, the reader scenario 7 describes.
  • Red before green: RootRequiredTest run on main fails 5 of 6; the sixth only checks that the verb table is complete. The full -Pintegration verify is green locally, and all five CI checks pass.

Takeover (after review iteration 6)

  • Iteration 6 finding fixed. ProjectEditCommand.validate wrapped a definition's refusal as "not valid YAML: " + getMessage(), so a deleted key's sail project skills add reached a sudo reader plain. It now throws Remedied.argument with the cause's reason and remedy kept apart. Remedied gained reasonOf, remedyOf and joined, and ProjectReader.Unreadable's hand-rolled copy of the same logic uses them. ProjectEditCommandTest asserts the sentence through CliCommand.lines for SUDO and UNPRIVILEGED; red before the fix.
  • Independent delta review (no HIGH): PtyHostYield.end joined per-session refusals by getMessage() and PagedSyncSession quoted a broken channel's first failure by getMessage(); both now carry the failure as a Remedy (text unchanged, existing tests prove it). The review's suggestion to replace RootRequired's tree walk with picocli's qualified name was tried and reverted: a command built outside the Sail tree (as InitCommandTest and ProjectApplyCommandTest build theirs) has no path, so the walk stays. A stacked javadoc in SyncCommand removed. CHANGELOG names the pty refusal frame, the stream envelopes and the local lane's POST /v1/events envelope.
  • Left by decision. action stays each command's verb and arguments (what a reader who types nothing reads), as the spec's scenario 10 asks; the sync_degraded event's detail and the run-log stream's foreign-run body keep their plain text, as narration for readers the daemon cannot see. Scenario 11 is proven at the resolver (SailPathsTest, FdeCommandTest); the end-to-end check on a fresh VM joins the field check list.
  • Mutants (each reverted alone, branch tests run): RootRequired dropping the flags → 5 red; the door printing plain → 5 red; ApiException.of dropping the remedy → 5 red; the sync wire dropping its remedy array → 3 red; the pty wire dropping its remedy tail → 5 red.

R1: every root-only refusal names the caller's own command, every word as
typed and shell-quoted, with sudo in front (RootRequired); host sync and
host update refuse a non-root caller before reading host.yaml.

R2: core, harness, the api package and the daemons name commands as a
Remedy (words, and commands each a verb and its arguments) carried through
Remedied failures, Result.Failure.remedy(), Refusal.fix, stored sync
errors and main's session refusal. The CLI's doors render it with
OperatorCommand.here(); the API serialises it beside a verb-only action.
OperatorCommand moves to sail-infra; elsewhere() and unknownReader() go.

R3: SailPaths.workstationPublicKeyPath() resolves the one workstation key
file from the box owner's home; host config set ssh-public-key writes it
under PairingTrust.
…ry wrapped failure keeps its reason and remedy apart

ProjectEditCommand.validate wrapped SailYaml's refusal as a sentence, so a deleted key's
'sail project skills add' reached a sudo reader plain. It now throws Remedied.argument with the
cause's reason and remedy kept apart, through Remedied.reasonOf/remedyOf/joined, which
ProjectReader.Unreadable uses in place of its own copy. PtyHostYield joins per-session refusals
as a Remedy, and PagedSyncSession quotes a broken channel's first failure as one. The changelog
names the pty refusal frame, the stream envelopes and the local lane's event refusal.
@udaychandra
udaychandra merged commit 7f5f52b into main Oct 22, 2026
5 checks passed
@udaychandra
udaychandra deleted the agent/sail-remedy-rendered-at-the-door branch October 22, 2026 16:48
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