ci: encrypt the test backend's keys that the Claude jobs carry over, to a key only those jobs hold

The iOS and desktop app jobs saved their stopped test backend (server keys and databases) as a public artifact, so that the Claude job could restart it with the same addresses: anyone signed in to GitHub could download it for a day and act as the test directory or relay while the Claude job ran.

backend-state.sh now packs it as CMS AuthEnvelopedData (AES-256-GCM, RSA-OAEP key transport) to the committed backend-state.crt; the Claude job decrypts it with the environment secret BACKEND_STATE_KEY, given only to that step, so the backend it restarts and the Claude session don't inherit it. Without the secret the Claude job stops at that step, before the Claude session.
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-21 18:11:26 +00:00
1 parent 57cde366ff
commit 65a8b7d2f5
7 files changed
+100 -14

No files matched your search

+10 -6
View File
@@ -306,14 +306,14 @@ jobs:
# the app points at this backend's directory, so the Claude job starts this backend again rather than a new one
- name: Pack backend state
if: ${{ !cancelled() && steps.backend.outcome == 'success' && steps.stop-backend.outcome == 'success' && needs.plan.outputs.claude-backend == 'yes' }}
run: tar -czf "$RUNNER_TEMP/backend-state.tar.gz" --exclude=./bin -C "$RUNNER_TEMP/backend" .
run: scripts/ci/backend-state.sh pack "$RUNNER_TEMP/backend" "$RUNNER_TEMP/backend-state.cms"
- name: Upload backend state
if: ${{ !cancelled() && steps.backend.outcome == 'success' && steps.stop-backend.outcome == 'success' && needs.plan.outputs.claude-backend == 'yes' }}
uses: actions/upload-artifact@v7
with:
name: desktop-backend-state
path: ${{ runner.temp }}/backend-state.tar.gz
path: ${{ runner.temp }}/backend-state.cms
compression-level: 0
retention-days: 1
if-no-files-found: error
@@ -405,14 +405,18 @@ jobs:
name: desktop-backend-state
path: ${{ runner.temp }}
# only this step gets the key, so nothing the backend or Claude starts later inherits it
- name: Unpack backend state
if: needs.plan.outputs.claude-backend == 'yes'
env:
BACKEND_STATE_KEY: ${{ secrets.BACKEND_STATE_KEY }}
run: scripts/ci/backend-state.sh unpack "$RUNNER_TEMP/backend-state.cms" "$RUNNER_TEMP/backend"
- name: Start the app job's test backend again
id: backend
if: needs.plan.outputs.claude-backend == 'yes'
timeout-minutes: 10
run: |
mkdir -p "$RUNNER_TEMP/backend"
tar -xzf "$RUNNER_TEMP/backend-state.tar.gz" -C "$RUNNER_TEMP/backend"
scripts/ci/desktop-linux/backend.sh resume ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
run: scripts/ci/desktop-linux/backend.sh resume ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
# ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds
- name: Install Claude Code and the ui command
+10 -6
View File
@@ -288,14 +288,14 @@ jobs:
# the app points at this backend's directory, so the Claude job starts this backend again rather than a new one
- name: Pack backend state
if: ${{ !cancelled() && steps.stop-backend.outcome == 'success' && needs.plan.outputs.claude-backend == 'yes' }}
run: tar -czf "$RUNNER_TEMP/backend-state.tar.gz" --exclude=./bin -C "$RUNNER_TEMP/backend" .
run: scripts/ci/backend-state.sh pack "$RUNNER_TEMP/backend" "$RUNNER_TEMP/backend-state.cms"
- name: Upload backend state
if: ${{ !cancelled() && steps.stop-backend.outcome == 'success' && needs.plan.outputs.claude-backend == 'yes' }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-backend-state
path: ${{ runner.temp }}/backend-state.tar.gz
path: ${{ runner.temp }}/backend-state.cms
compression-level: 0
retention-days: 1
if-no-files-found: error
@@ -377,14 +377,18 @@ jobs:
name: ios-sim-backend-state
path: ${{ runner.temp }}
# only this step gets the key, so nothing the backend or Claude starts later inherits it
- name: Unpack backend state
if: needs.plan.outputs.claude-backend == 'yes'
env:
BACKEND_STATE_KEY: ${{ secrets.BACKEND_STATE_KEY }}
run: scripts/ci/backend-state.sh unpack "$RUNNER_TEMP/backend-state.cms" "$RUNNER_TEMP/backend"
- name: Start the app job's test backend again
id: backend
if: needs.plan.outputs.claude-backend == 'yes'
timeout-minutes: 10
run: |
mkdir -p "$RUNNER_TEMP/backend"
tar -xzf "$RUNNER_TEMP/backend-state.tar.gz" -C "$RUNNER_TEMP/backend"
scripts/ci/ios-sim/backend.sh resume ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
run: scripts/ci/ios-sim/backend.sh resume ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
- name: Install AXe
run: scripts/ci/ios-sim/install-axe.sh
+8
View File
@@ -36,3 +36,11 @@ A newer run of a workflow on the same branch cancels the older one.
## The Claude job
It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. The environment's branch rule alone decides which branches may use it: on a branch it doesn't allow, a run that asks for Claude fails at the Claude job's environment check, and the scripted test runs as usual. Each run spends the subscription's usage limits; the job summary gives its tokens and cost estimated at API prices.
A Claude job that starts the app job's test backend again (iOS and desktop, when `claude-scope` says `backend: yes`) also needs the environment's secret `BACKEND_STATE_KEY`, the private key of `backend-state.crt`, to decrypt that backend's keys and databases (`backend-state.sh`). A new pair replaces both:
```sh
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 -subj /CN=simplex-chat-ci-backend-state -keyout key.pem -out scripts/ci/backend-state.crt
gh secret set BACKEND_STATE_KEY --env app-sim-claude --repo simplex-chat/simplex-chat < key.pem
rm key.pem
```
+30
View File
@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFMTCCAxmgAwIBAgIUQq06tqspTU+LJS98T6tnBvs5q5wwDQYJKoZIhvcNAQEL
BQAwKDEmMCQGA1UEAwwdc2ltcGxleC1jaGF0LWNpLWJhY2tlbmQtc3RhdGUwHhcN
MjYxMDIxMTgwODU3WhcNMzYxMDE4MTgwODU3WjAoMSYwJAYDVQQDDB1zaW1wbGV4
LWNoYXQtY2ktYmFja2VuZC1zdGF0ZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
AgoCggIBAL6pijVOLbEb/fkguIAQ73mfrnAYNVtxkODL7qqY4hZcM/n9cy8l6uAZ
7ymPhLjeaADz61Xo3QgPtstnGJyO6p6qf7FI7mJpV7rIso78HgS+iVC6KemX7OPr
3vZGw8NlZZm2PUdYBovBn41mceqxg1fzso9D6Xe6SeLfqO/h7StKdV2dLRZacXpR
JTdAu+JQeyXgM2ZfaiRGZ2jc3yHrQCl0ESJxTE+NKNPDwg2TcrmqvZ1GFQkMFfeC
0odxucScqefM+whzyz8xikbI1G4F0t/iBm+E4/2vko5uoHMKnVNV6UOIIUwg8RpY
/TN0U+JhaQSD1lrfMI6BisbpZcDNH9gAtKcFHUkSlJX51SQnYTEDr0wSHtp/NUmY
zjgKQMxDlB4lFYeXeSfQzxfbynfN7JnJMj/C2qtGZI3vjq1ebksMh21TjOUtnzZA
9selXzb9V6CloSqjfKcNn3nhz1FkN2zUTPhqZvQ0I59yL+wDyREyGkNLV1InuGSU
rAJfbrcv2L7R9f8L1Lp8BfwBLiy3I6661Mc7wOPWHUAur984BhugFt+WRTwJu52l
kxyu9xflV0qxEehYowPX9dPuw5tNvCgYR+wX9kPgRJqtS6WaWyHctTm9O8njO0cz
jnV3THJCeRLE8ZS1aAugaFI5blUqcuQNcDiAhSAOBJTvJxoCxdGfAgMBAAGjUzBR
MB0GA1UdDgQWBBSZidun4LCo4QVUhwtZCtDce3HdWDAfBgNVHSMEGDAWgBSZidun
4LCo4QVUhwtZCtDce3HdWDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA
A4ICAQBaZFRf82pJwTYvEoptWFWRFUOpQOKA2dCuey8nYVy4gNlhL4kaTI1maois
hzGU5mzmCwLlo14p+EPTPbpTvvwoz94MwPi+s0jPzXjsBUgdYt0hbZuGxwgd8vxa
yl8vc+/2EZQMu9JTwJ1PYosMo2lc8yj9licXjOerCDA9GH5/uLvvWWJJBQ2T4CpT
KEl224v0TCrGdKQcDt3QNZegODiKdzKug8TmsRhkDdB1kRheP2NblURsuUSePJ2z
L6BynP6fxuzCjtLZIN1QAVnrAsCS2m2UxdzaDZ4XyQnCNIeqaCWKMQGP+m78LexY
gpudqtzCw7Bl1zMuQaxhYDkAlKn4+AtCyWXb8Sb/xgHFopkVJYYavnDMeVxX47Kv
zh2okp5OzJJHiRDh73nooT8a5Tbxbk4QHcT4MMvUNUFu5CAbRsekqdz5B0cYXEdO
joPb/Cf+ZdgU9axNIGjnwqS3azw722QCaWwsJR65D/A4rGibOHh9cgGRqjo42IWM
m66KrRpdByoSw4n/odMy6QLT0PbiJMSRoMdcy2ffcKgls2jsTa72VC8/61ZGAHFC
5p3vgZKZ2pRFFE0mNBxLzHIJ5ptlve8QD3wozfNFijCJDH8bsKd2IlgOjAAEuGcn
71Tw3cU/BDyAievknYClr6Xkna9m6ClM+/TZIJFKtg6HiufabQ==
-----END CERTIFICATE-----
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Carries the test backend's stopped work dir, its server keys and databases, from the app job to the Claude job as an
# artifact, which anyone signed in to GitHub can download from this public repository, so it is encrypted to
# backend-state.crt, whose private key only the Claude job holds (the secret BACKEND_STATE_KEY in app-sim-claude).
# Usage:
# scripts/ci/backend-state.sh pack <work dir> <file> writes the work dir without its binaries, encrypted, to <file>
# scripts/ci/backend-state.sh unpack <file> <work dir> decrypts <file> into <work dir> with the key in BACKEND_STATE_KEY
set -euo pipefail
cert=$(dirname "$0")/backend-state.crt
# OpenSSL 3 on both runners: macOS's own openssl is LibreSSL
if [ "$(uname)" = Darwin ]; then
openssl=$(brew --prefix openssl@3)/bin/openssl
else
openssl=openssl
fi
pack() {
tar -czf - --exclude=./bin -C "$1" . |
"$openssl" cms -encrypt -binary -aes-256-gcm -recip "$cert" -keyopt rsa_padding_mode:oaep -outform DER -out "$2"
}
unpack() {
: "${BACKEND_STATE_KEY:?not set: the environment app-sim-claude needs this secret}"
mkdir -p "$2"
"$openssl" cms -decrypt -binary -inform DER -in "$1" -inkey <(printf '%s\n' "$BACKEND_STATE_KEY") |
tar -xzf - -C "$2"
}
case "${1:-}" in
pack) pack "$2" "$3" ;;
unpack) unpack "$2" "$3" ;;
*)
echo "usage: $0 pack <work dir> <file> | unpack <file> <work dir>"
exit 1
;;
esac
+1 -1
View File
@@ -41,7 +41,7 @@ It writes the directory's address to `<dir>/directory-link` and the SMP server's
## The Claude job
It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`, as it does on this branch; a run that asks for Claude must then ask for the backend too, or the plan job fails. The app job built the distributable pointing at its own backend's directory, which a new backend would not have, so the app job saves that backend's stopped work dir (the artifact `desktop-backend-state`, without the binaries) and the Claude job starts it again with `backend.sh resume`: the same servers' keys and databases, so the same addresses. That artifact holds the test backend's databases and server keys, unencrypted, and like every artifact of this public repository anyone signed in to GitHub can download it while it is kept (a day); with it, someone could act as the test directory or relay while the Claude job runs and show the session crafted results. This is an accepted risk: these are throwaway test identities, the session has no shell and no token in reach of its tools, so what is at stake is the test's integrity, and encrypting the state would need a key only the two jobs hold, a second secret, which this repository's rules for the Claude job don't allow. With the backend it sets the app up at the chat list (`smoke-test.sh … setup`) and gives Claude `claude-search.md`, the online search test, in place of the onboarding path in `claude-test.md`: the directory search with its consent, Open, the load row and the right-click menu (part A) and the Search key, which searches the directory only and keeps "Connect to …" (part B), in both layouts, then part C, which opens `ci_channel` with its row's Open, without an alert, joins it, and checks that later searches show it as a chat (no row and no note for a page of chats already listed, its chat row among the results of the chat list and of New chat, opening without an alert). The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 120`, `--max-budget-usd 3` and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`). Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`.
It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`, as it does on this branch; a run that asks for Claude must then ask for the backend too, or the plan job fails. The app job built the distributable pointing at its own backend's directory, which a new backend would not have, so the app job saves that backend's stopped work dir (the artifact `desktop-backend-state`, without the binaries) and the Claude job starts it again with `backend.sh resume`: the same servers' keys and databases, so the same addresses. Anyone signed in to GitHub can download this public repository's artifacts, and with those keys could act as the test directory or relay while the Claude job runs and show the session crafted results, so `scripts/ci/backend-state.sh` encrypts the work dir to `scripts/ci/backend-state.crt`. Its private key is the environment's second secret, `BACKEND_STATE_KEY`, which only the step that unpacks the state gets, so neither the backend it restarts nor the Claude session inherits it (`scripts/ci/README.md` says how to make a new one). With the backend it sets the app up at the chat list (`smoke-test.sh … setup`) and gives Claude `claude-search.md`, the online search test, in place of the onboarding path in `claude-test.md`: the directory search with its consent, Open, the load row and the right-click menu (part A) and the Search key, which searches the directory only and keeps "Connect to …" (part B), in both layouts, then part C, which opens `ci_channel` with its row's Open, without an alert, joins it, and checks that later searches show it as a chat (no row and no note for a page of chats already listed, its chat row among the results of the chat list and of New chat, opening without an alert). The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 120`, `--max-budget-usd 3` and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`). Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`.
## Cost
+1 -1
View File
@@ -22,7 +22,7 @@ The macOS jobs run on `macos-26`, the image with Xcode 26 and the iPhone 17 simu
## The Claude session
It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept a day) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. The app job built the app pointing at its own backend's directory, which a new backend would not have, so the app job saves that backend's stopped work dir (the artifact `ios-sim-backend-state`, without the binaries) and the Claude job starts it again with `backend.sh resume`: the same servers' keys and databases, so the same addresses. That artifact holds the test backend's databases and server keys, unencrypted, and like every artifact of this public repository anyone signed in to GitHub can download it while it is kept (a day); with it, someone could act as the test directory or relay while the Claude job runs and show the session crafted results. This is an accepted risk: these are throwaway test identities, the session has no shell and no token in reach of its tools, so what is at stake is the test's integrity, and encrypting the state would need a key only the two jobs hold, a second secret, which this repository's rules for the Claude job don't allow.
It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept a day) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. The app job built the app pointing at its own backend's directory, which a new backend would not have, so the app job saves that backend's stopped work dir (the artifact `ios-sim-backend-state`, without the binaries) and the Claude job starts it again with `backend.sh resume`: the same servers' keys and databases, so the same addresses. Anyone signed in to GitHub can download this public repository's artifacts, and with those keys could act as the test directory or relay while the Claude job runs and show the session crafted results, so `scripts/ci/backend-state.sh` encrypts the work dir to `scripts/ci/backend-state.crt`. Its private key is the environment's second secret, `BACKEND_STATE_KEY`, which only the step that unpacks the state gets, so neither the backend it restarts nor the Claude session inherits it (`scripts/ci/README.md` says how to make a new one).
Each session's final message ends with `CLAUDE-TEST: PASS`, or `CLAUDE-TEST: FAIL` and the failed cases; the step fails unless every session passes.