Add pi-provider named-token lifecycle and forward migration #30
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/nexus!30
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/named-tokens-lifecycle"
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?
Nothing changes on a running machine until someone runs one of the new commands by hand: this touches the host-side
pi-providertool and its tests, not any VM configuration.A Pi credential can now hold several named bearer tokens instead of one. Each token is encrypted into its own file at
secrets/pi-credentials/<credential>/<token>.age, and at most one of them is marked as the deployment default.New commands manage them:
pi-provider token list|add|rotate|remove|default <credential> [<name>], pluspi-provider migrate, which converts an existing checkout to the new record shape and file layout.Migration never asks for or decrypts a bearer. It rewrites registry records and moves already-encrypted files, and it refuses a checkout that mixes old and new records, a destination that already exists, or a dirty working tree.
The old provider-scoped
pi-provider rotateand the--rotation-strategyflag are gone; both now print the command that replaced them rather than failing obscurely.What stays identical: a bearer is still accepted only from a hidden prompt and piped straight into Age, never into argv, environment, regular staging, logs, diagnostics, or output.
What stays identical: the clean-tree, tmpfs staging, lock, journal, rollback, and
pi-provider recoverguarantees, now extended to every per-token path, the token directory, and the migration moves.What proves it:
nix build .#checks.x86_64-linux.provisioning-contract, which runs shellcheck plus 192 assertions, including crash-and-recover sweeps of every new mutation boundary and a leak scan of runtime-generated synthetic bearers.If this is wrong, revert the commit. Nothing deployed depends on it until the secrets and archetypes slices land, and
migratechanges data only when a human runs it.Risk
Residual risk is R3 High, matching the plan's slice table: recoverable multi-file moves, prompt-only plaintext, no accidental activation or removal. Migration now moves several files and creates directories in one transaction, so its failure modes are broader than any previous single-file operation; the journal records the token directory as a first-class object with its pre-run absence, and rollback restores or prunes it accordingly. Every prune is empty-directory-only
rmdirafter the managed files are gone, so an operator or concurrent writer that populated the directory is preserved and reported rather than removed recursively.token addnever activates its token or moves the default, andtoken removerefuses both the current default and the last token of a credential that still serves providers — that last refusal makes a directory prune ontoken removeunreachable in practice, since registry validation keepsprovidersnon-empty; the branch stays so the directory can never be orphaned, and the test witnesses the refusal and the preserved directory instead. Retarget prompts for every token before the journal exists, so an incomplete set of bearers commits nothing. Synthetic fixtures bound public behavior only; no private registry, ciphertext, or live token was read, migrated, or tested.Validation
nix build .#checks.x86_64-linux.provisioning-contractfrom this worktree: succeeds, buildingprovisioning-contractandnexus-provisioning-scripts. Its log ends withpi-provider tests passed: 192 assertions.bash -nandshellcheck -xoverscripts/pi-providerandtests/pi-provider.sh(run by that check, and directly): clean, with no findings at any severity.Four deliberate sabotages of the implementation, each run against the unmodified test suite and reverted afterwards, confirm the new assertions can fail: making
token addsetdefaultTokenfails "token add never activates the new token or moves the deployment default"; adding a recursiverm -rffallback to the prune fails "retirement reports a token directory a concurrent writer populated"; dropping migrate's delete of the pre-named-token ciphertext fails "migrate kept the flat ciphertext"; and leaving directory creation off the journal fails "ordinary rollback kept the created token directory".The witness covers migration with no bearer input and no
ageinvocation at all; independenttoken add,rotate,remove,default, andlist; no activation on add; all-token retarget including a partial-bearer run that commits nothing; shared-provider impact reporting; last-token and current-default removal refusals; provider retirement removing the record and every named ciphertext; token-directory creation and empty-only pruning across migrate, rollback,recover, and retire, including a populated-directory race driven by thermwrapper; dry runs; interrupted mutation, migration, and retirement at twelve, ten, and four kill boundaries respectively, each recovered exactly; and a final scan proving no runtime-generated synthetic bearer reaches any file, child argv, child environment, or command output.Refs allod/strategy#36
Review pass (required post-open review): read-only diff review by gpt-5.6-sol at reasoning xhigh against the plan contract, with three findings reproduced by execution in the review sandbox. Findings below; fixes will land as follow-up commits on this branch. Two findings expose plan-level defects; the plan is being amended in allod/strategy to reserve the literal token name none (finding 5) and to restate the last-token-remove witness as refusal semantics (finding 6).
[BLOCKER]
scripts/pi-provider:630,scripts/pi-provider:641,scripts/pi-provider:650— The bearer can enter Age’s environment. Bash’slocal tokeninherits the export attribute from an existing exportedtoken; afterreadassigns the bearer,agereceivestoken=<bearer>. Running the witness underenv token=outsidefails attests/pi-provider.sh:473with “token reached child argv or environment.” Additionally,printfis not forced to the builtin, so an importedprintffunction can shadow it and receive the bearer as an argument.[BLOCKER]
scripts/pi-provider:990,scripts/pi-provider:1017,scripts/pi-provider:1027— Recovery follows a symlink standing in for a directory recorded as previously absent. Such directories skip the initial occupant check; recovery first runsrm -fon child paths and checks the directory type only afterward while pruning. After interrupting add beforemkdir, replacing the planned directory with a symlink to a directory containingdefault.age, and running recover, I observed the outside file deleted, recovery returning success, and the journal removed.assert_dirt_confinedaccepts the symlink because its path is journaled.[BLOCKER]
scripts/pi-provider:982-990— Recovery is not idempotent across a crash while recreating a pruned directory. If killed aftermkdirbut beforechmod, the next recovery sees the directory and immediately continues, never restoring its recorded mode. I reproduced retire being interrupted afterrmdir, then recovery being killed after its restoringmkdir; the directory remained0755after the second successful recovery instead of its original0700, while the journal was removed.[GAP]
scripts/pi-provider:402-415,scripts/pi-provider:1250-1259—migrateexits successfully as soon as every record has eithertokensordefaultToken, before validating the new schema or checking repository cleanliness. A dirty already-migrated checkout therefore reports “nothing to migrate” instead of refusing. Likewise, a checkout where every record combines legacyrotationStrategywith named-token fields is treated as complete rather than refused as mixed state, even though every ordinary command subsequently rejects it.[GAP]
scripts/pi-provider:274-275,scripts/pi-provider:495-498,docs/credentials.md:23-27—nonematches the contract’s opaque token-name regex andtoken add ... noneaccepts it, buttoken default ... nonethen refuses both selecting that token and clearing the default. A schema-valid token is therefore unusable as a deployment default, and while it exists the documented clearing spelling is unavailable.docs/credentials.md:60records this narrowing, but the controlling contract does not excludenonefrom valid names.[GAP]
scripts/pi-provider:357-359,scripts/pi-provider:487-488,scripts/pi-provider:776-778,tests/pi-provider.sh:567-578— The required last-token-remove pruning witness is absent. Registry validation requires both providers and tokens to be non-empty, and the command refuses the last token while providers remain, so the prune branch is unreachable. The test witnesses refusal and directory preservation, not empty-only pruning; deleting the deadPRUNE_DIRSbranch would still pass all 192 assertions. This does not discharge the acceptance sentence requiring pruning across last-token remove.[GAP]
scripts/pi-provider:734-741,tests/pi-provider.sh:600-619,tests/pi-provider.sh:661-702— No crash-and-recover test interrupts a multi-token retarget installation. The only mutation sweep creates one new token throughadd; retarget is tested only for pre-journal incomplete input and uninterrupted success. A kill after replacing the first existing ciphertext but before replacing the second—precisely the new per-token atomicity boundary—is never exercised, so the PR claim that every new mutation boundary is swept is false.[GAP]
tests/pi-provider.sh:499-520,tests/pi-provider.sh:604-619,tests/pi-provider.sh:952-958,tests/pi-provider.sh:995-1000— The bearer witness does not actually cover labeled prompts or every command’s output. Inputs are piped, and Bash suppressesread -pprompts on non-terminal stdin, so unlabeled retarget prompts would pass. Only the initial add and token-add outputs are checked for their bearer; the finalgrepscans fixture files, not shell variables or discarded stdout. A rotate- or retarget-specific bearer print would therefore pass, contrary to the PR’s final-output-scan claim.[GAP]
scripts/pi-provider:827-830,scripts/pi-provider:866-889,tests/pi-provider.sh:926-960— The new grandparent-walk and kind-aware journal allowlist lack negative sabotage witnesses. The sole token-directory symlink test places the symlink at the credential directory itself, which is rejected by the separate directory-kind check; removing the realpath comparison still passes the suite and would allow a symlinkedsecrets/pi-credentialsancestor to redirect creation outside the repository. No malformed journal test proves that invalidkindvalues or unsafe two-segment/sibling paths are rejected.Validation: the sandbox denied access to the Nix daemon socket, so I could not rerun
nix build .#checks.x86_64-linux.provisioning-contract.bash -nand the directtests/pi-provider.shwitness succeeded with 192 assertions;shellcheckis unavailable in this environment.All nine findings are addressed in
0a9b30e(authored by gpt-5.6-sol; the shellcheck directives on the hostile-printf shim were added by the managing agent). Blockers 1-3: the bearer variable can no longer inherit an export attribute, the secret pipe forces the printf builtin, recovery type-checks previously absent directories before touching children, and mode restoration is idempotent across recovery crashes — each now has a reproducing witness. Gaps 4-9: migrate refuses dirty/mixed/invalid-migrated states; the name none is rejected per the amended plan (46d074e); the unreachable prune branch is deleted with refusal witnessed; the crash sweep covers multi-token retarget ciphertext boundaries; the leak witness covers labeled prompts and per-command output; the allowlist and planned-parent walk have negative witnesses including a symlinked ancestor. Suite is 221 assertions; nix build .#checks.x86_64-linux.provisioning-contract re-run green by the managing agent from this worktree before push.