Files
werkrevisor/docs/prs/2026-10-20-PR#128-rotate-secrets.md
claude-code 4d8e4e2276
werkator build succeeded after 00:32
werkator/werkrevisor-statistics build succeeded after 01:28
werkator/release build succeeded after 00:39
werkrevisor 0.56.0: rotate the secrets of the review builds without a stop (#128)
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-21 22:33:42 +02:00

227 lines
13 KiB
Markdown

# PR#128: werkrevisor Rotates the Secrets of the Review Builds without a Stop
Version: minor
> **WARNING:** This document describes only the change applied in this PR.
> It may already be outdated once the next PR is merged.
> Historic PR documentation is not maintained along with new PRs; treat it as a snapshot.
## Problem
On 2026-10-20, the values of `GITEA_BOT_TOKEN`, `OPENROUTER_API_KEY`, `TYPESAFE_API_KEY` and `WERKREVISOR_STATS_TOKEN`
appeared in a Claude Code transcript, since Werkator passed them to `werkdock run` on the command line.
All four must get new values.
The machine configs of eight repositories on mih09 and `~/.config/ai-coding-agents/secrets.env` hold them.
The rotation is done by hand so far, without a check that each copy has the new value,
and 137 backups of the machine configs keep the old values.
## Non-Goals
- No change of Werkator or werkdock; the session "werkator-env-secrets" takes the secrets off the command line.
- No automatic creation or revocation of keys at the services; this PR documents what an automation needs.
- No tool for the environment of the statistics service on mih09; the human changes its list of source tokens.
## Specification
### Feature: rotate one secret of the review builds
#### Scenario#128.01: set replaces the value in each file that has it
&hellip; so that each review build and the local tools get the new value without a restart and without a stop.
- **Given** machine configs on the host and a secrets file that hold the same old value of a key
- **When** the human runs `tools/rotate-secret set <host> <KEY>` with the new value on stdin
- **Then** each file that has the key holds the new value, with its quotes and its mode
- **And** a backup of each changed file holds the old one
- **And** the output names the files and the SHA-256 of the old value, never a value
##### Verified by
- [test_rotate_secret.RotateSecretScriptTest.test_set_then_check_then_purge](../../tests/test_rotate_secret.py)
- [test_rotate_secret.HostSecretTest.test_set_replaces_only_the_value_and_keeps_quotes_and_mode](../../tests/test_rotate_secret.py)
- [test_rotate_secret.HostSecretTest.test_set_adds_a_missing_key_to_an_env_file_only](../../tests/test_rotate_secret.py)
- [test_rotate_secret.RotateSecretScriptTest.test_set_takes_the_old_value_from_the_host_if_the_secrets_file_lacks_the_key](../../tests/test_rotate_secret.py)
#### Scenario#128.02: set refuses an unclear old value
&hellip; so that a rotation that stopped half-way or a drifted file does not lose a value that is still in use.
- **Given** a file that holds a value other than the old one of the secrets file, or files with two old values
- **When** the human runs `set`
- **Then** no file changes, and the error names the cause without a value
##### Verified by
- [test_rotate_secret.HostSecretTest.test_set_refuses_a_third_value](../../tests/test_rotate_secret.py)
- [test_rotate_secret.RotateSecretScriptTest.test_set_refuses_if_the_host_holds_another_value_than_the_secrets_file](../../tests/test_rotate_secret.py)
- [test_rotate_secret.HostSecretTest.test_a_file_with_two_lines_for_the_key_stops_set_before_any_change](../../tests/test_rotate_secret.py)
- [test_rotate_secret.HostSecretTest.test_set_continues_a_rotation_that_stopped_half_way](../../tests/test_rotate_secret.py)
- [test_rotate_secret.RotateSecretScriptTest.test_set_stops_before_the_host_if_the_secrets_file_cannot_be_parsed](../../tests/test_rotate_secret.py)
#### Scenario#128.03: probe tells an accepted value from a refused one
&hellip; so that the human knows a new value works before a review depends on it, and an old one is revoked.
- **Given** a value of one of the four keys
- **When** the human runs `tools/rotate-secret probe <KEY>`
- **Then** the output says whether the service accepts it, and an outage differs from a refusal by its exit code
- **And** the probe costs nothing and starts no review
##### Verified by
- [test_secretprobe](../../tests/test_secretprobe.py)
#### Scenario#128.04: the statistics service takes two source tokens at once
&hellip; so that no usage record is lost while the runs change from the old to the new token.
- **Given** the service knows two source tokens
- **When** a run sends an empty record with either of them, or with an unknown token
- **Then** the service refuses the record with HTTP 400 for both known tokens, with HTTP 401 for the unknown one,
and stores nothing
##### Verified by
- [UsageApiIntegrationTest "an empty record tells each of two source tokens from an unknown one and stores nothing"](../../statistics/src/test/kotlin/de/javagil/werkrevisor/statistics/UsageApiIntegrationTest.kt)
#### Scenario#128.05: purge removes only the backups with the old value
&hellip; so that the revoked value leaves the host, and backups with other values and live files stay.
- **Given** a revoked old value, backups with it, backups with other values, and a live file with the same name prefix
- **When** the human runs `tools/rotate-secret purge <host> <KEY> <sha256>`
- **Then** only the backups with the old value are gone, on the host and next to the secrets file
##### Verified by
- [test_rotate_secret.HostSecretTest.test_purge_removes_the_backups_with_the_old_value_only](../../tests/test_rotate_secret.py)
- [test_rotate_secret.HostSecretTest.test_purge_keeps_a_file_that_only_shares_the_prefix](../../tests/test_rotate_secret.py)
- [test_rotate_secret.HostSecretTest.test_a_backup_it_cannot_parse_may_hold_the_value](../../tests/test_rotate_secret.py)
#### Scenario#128.06: check compares each copy with a hash
&hellip; so that the human sees which copy still has another value, without seeing a value.
- **Given** machine configs and a secrets file
- **When** the human runs `tools/rotate-secret check <host> <KEY> [<sha256>]`
- **Then** each file with the key is named as equal or differing, with the number of backups that hold that value
##### Verified by
- [test_rotate_secret.HostSecretTest.test_check_names_each_repository_with_the_key](../../tests/test_rotate_secret.py)
- [test_rotate_secret.RotateSecretScriptTest.test_check_reports_another_value](../../tests/test_rotate_secret.py)
#### Scenario#128.07: purge waits until no machine config holds the old value
&hellip; so that a backup is never the last copy of a value that a review build still uses.
- **Given** a machine config that still holds the old value
- **When** the human runs `purge` for that value
- **Then** purge refuses and removes nothing
##### Verified by
- [test_rotate_secret.HostSecretTest.test_purge_removes_the_backups_with_the_old_value_only](../../tests/test_rotate_secret.py)
#### Scenario#128.08: a machine config that cannot be read is an error
&hellip; so that check does not report success while a file keeps the old value.
- **Given** a machine config that cannot be read or decoded
- **When** the human runs `check`, `set` or `purge`
- **Then** the tool names the file and exits with 2, and changes nothing
##### Verified by
- [test_rotate_secret.HostSecretTest.test_an_unreadable_machine_config_is_an_error](../../tests/test_rotate_secret.py)
#### Scenario#128.09: probe refuses a malformed value without showing it
&hellip; so that a value pasted with a line break does not reach a header and an error message.
- **Given** a value with a line break, a blank or too few characters
- **When** the human runs `tools/rotate-secret probe <KEY> -`
- **Then** the probe exits with 2 and names the allowed characters, but no part of the value
##### Verified by
- [test_secretprobe.MalformedValueTest](../../tests/test_secretprobe.py)
#### Scenario#128.10: fetch copies the value of the host into a secrets file without it
&hellip; so that the backup of the secrets file holds the old value for the probe after the revocation.
- **Given** machine configs that hold one value of a key, and a secrets file without that key
- **When** the human runs `tools/rotate-secret fetch <host> <KEY>`
- **Then** the secrets file holds that value, and the output names the file and its backup, never the value
##### Verified by
- [test_rotate_secret.RotateSecretScriptTest.test_fetch_adds_the_value_of_the_host_to_the_secrets_file](../../tests/test_rotate_secret.py)
- [test_rotate_secret.HostSecretTest.test_export_writes_the_one_value_as_json_only_into_a_pipe](../../tests/test_rotate_secret.py)
- [test_rotate_secret.RotateSecretScriptTest.test_fetch_stops_if_the_host_holds_no_single_value](../../tests/test_rotate_secret.py)
## Solution
- `tools/rotate-secret` follows `tools/sync-host-config`: one ssh connection per command, retries of a refused one,
and a host part `tools/host_secret.py` that it sends to `python3 -c`.
It handles every `~/<repo>/.git/werkator/.werkator.yml` that sets the key, and the local secrets file.
- A value goes only through stdin, variables and pipes; `printf` is a builtin, so no process list shows it.
On a terminal, `set` and `probe -` ask for it without showing it.
- `set` writes each file through a renamed temporary file, so Werkator never reads half a file.
It checks every file before it changes one.
It accepts only values of `A-Z a-z 0-9 . _ ~ + / = -`, which need no quotes in YAML or in the env file.
- `werkrevisor/secretprobe.py` asks each service without costs:
Gitea `/api/v1/user` and the write access of `ai-reviewer` to `mi/werkprobe`, OpenRouter `/api/v1/key`
without its label, TypeSafe `/v1/models`, and the statistics service with an empty record.
- The write access of the account says nothing about the scopes of the token (found by the Codex review).
So the Gitea probe also writes a review to pull request 0 and a comment to issue 0:
Gitea checks the scope first and answers 403 without it, else 404 or 422, and creates nothing.
- `purge` removes only backups by their names on the hosts, `<file>.bak…`, `.pre…` and `.before…`;
`secrets.env.production` would be a live file (found by the Codex review).
- A file that cannot be read is an error, not a file without the key (found by the Codex review).
- `set` reads the secrets file before it changes the host; only a file without the key lets the host name the old value.
Before, a file it could not read or parse let the host change and failed only after it (found by the Codex review).
- A backup that `check` or `purge` cannot read or parse may hold the value:
`check` names it and exits with 2, `purge` refuses (found by the review of werkrevisor).
- `fetch` copies the value that all machine configs hold into a `secrets.env` without the key, through pipes only;
the host part `export` refuses a terminal.
A failed export stops `fetch` with its exit code (found by the Codex review).
So the backup of `secrets.env` holds the old `WERKREVISOR_STATS_TOKEN` for the probe after the revocation
(found by the review of werkrevisor).
- Six refused connections exit with 2, not with 1, since no file was read (found by the Codex review).
- An error in the secrets file exits with 2, not with 1, which means a refused or differing value
(found by the review of werkrevisor).
- `probe` accepts only the characters that `set` accepts (found by the Codex review).
Before, `http.client` refused a value with a line break in a header and quoted it in a traceback;
`MalformedValueTest` failed so without the fix and passes with it.
- The statistics service took a comma-separated list of source tokens already; a test now fixes it.
- `docs/betrieb-git.javagil.de.md` has one procedure for all four secrets, the missing row of `WERKREVISOR_STATS_TOKEN`,
and what an automation needs per service.
Findings on the way:
- `~/.config/ai-coding-agents/secrets.env` lacks `WERKREVISOR_STATS_TOKEN`; `set` adds it.
- On 2026-10-20, all eight machine configs and `secrets.env` hold the same values,
and 137 backups on mih09 hold the current values.
- OpenRouter has management keys: `POST /api/v1/keys` and `DELETE /api/v1/keys/{hash}`.
- Gitea creates and deletes a token of a user only with the password of that user (basic auth),
or with `gitea admin user generate-access-token` on the Gitea host.
- The public API of TypeSafe has `/v1/models` and `/v1/systemone` only.
Its console at https://console.typesafe.ai/keys creates, renames and deletes several keys,
through Next.js server actions of a logged-in session, not through a documented API.
- werkdock before 0.13.2 wrote the environment of each build to `$WERKDOCK_HOME/instances/<id>/config.json`, mode 0644
(werkdock PR#17); the procedure removes the directories of finished builds after the last secret.
## Open Questions
None.
## Follow-up PRs
- The human changes the source tokens of the statistics service by hand, since this PR does not know the file
that holds them.
A tool for it can follow once the human names that file.
- Each of the four secrets is created and revoked by hand.
For `WERKREVISOR_STATS_TOKEN` and `OPENROUTER_API_KEY`, a tool can do it by API (Werkbaum `#ops.rotate2.auto`).