From 13287fd9d89c67aac64cd41d6d0cff1791132fa0 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:46:36 +0000 Subject: [PATCH] ci: long-press and right-click for app tests, safer ui arguments, desktop Kotlin 4 GB, desktop session limits from claude-scope Message menus open only on long-press (iOS) or right-click (desktop), so Claude sessions couldn't reach Moderate, Info or Delete. Ported from f/directory-search unchanged, with its hardening: the guards refuse unquoted glob, brace and tilde expansion, and desktop ui checks numbers and sends one key per call so a key argument can't run an xdotool command. The desktop app job's Kotlin compiler gets 4 GB, as f/directory-search needed after merging master. Desktop sessions take max-turns and max-budget-usd from claude-scope like iOS (60 and 3 when unset, as before), and the Claude job may run 120 minutes. Run: 001-channel-signing-moderators, slice 5 --- .github/workflows/desktop-linux.yml | 7 +++--- scripts/ci/desktop-linux/README.md | 6 ++--- scripts/ci/desktop-linux/claude-guard.py | 10 ++++++-- scripts/ci/desktop-linux/claude-scope | 1 + scripts/ci/desktop-linux/claude-test.sh | 11 ++++++-- scripts/ci/desktop-linux/ui | 32 ++++++++++++++++++++++-- scripts/ci/ios-sim/README.md | 2 +- scripts/ci/ios-sim/claude-guard.py | 10 ++++++-- scripts/ci/ios-sim/ui | 10 +++++++- 9 files changed, 73 insertions(+), 16 deletions(-) diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index 82725791f5..a90a84e833 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -242,12 +242,13 @@ jobs: distribution: corretto java-version: ${{ env.JAVA_VER }} + # the Kotlin compiler daemon otherwise gets gradle.properties' 2 GB, which compiling :common runs out of - name: Build app id: build if: ${{ !cancelled() && steps.core.outcome == 'success' }} working-directory: apps/multiplatform run: | - ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable + ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :desktop:createDistributable app=release/main/app/simplex ls -la "$app/bin" "$app/lib/app/resources" du -sh "$app" @@ -271,7 +272,7 @@ jobs: - name: Run desktop tests if: ${{ !cancelled() && steps.build.outcome == 'success' }} working-directory: apps/multiplatform - run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest + run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :common:desktopTest - name: Report desktop tests if: ${{ !cancelled() && steps.build.outcome == 'success' }} @@ -329,7 +330,7 @@ jobs: needs: [plan, app] if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 120 environment: name: app-sim-claude deployment: false diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index 2363068a63..0ce922064c 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -23,10 +23,10 @@ Queueing for a runner adds from under a minute to 20 minutes. Results are artifa ## Scripts -- `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. +- `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, right-click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. - `smoke-test.sh [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts. - `backend.sh start ` / `stop `: the test backend, below. -- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend), `claude-guard.py`, `claude-usage.py`: the Claude job. +- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend, and optionally the session's turns and budget), `claude-guard.py`, `claude-usage.py`: the Claude job. - `build-core.sh`, `install-core.sh`, `build-backend.sh`, `earlier-run.sh`, `release-app.sh`, `test-summary.py`: builds, reuse and reports. ## The test backend @@ -41,7 +41,7 @@ It writes the directory's address to `/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`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 60`, `--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`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with the turns and budget `claude-scope` sets (`--max-turns 60` and `--max-budget-usd 3` unless it sets `max-turns:` or `max-budget-usd:`) and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`); the job's time limit is 120 minutes. 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 diff --git a/scripts/ci/desktop-linux/claude-guard.py b/scripts/ci/desktop-linux/claude-guard.py index 7b004584b2..fb0099c448 100755 --- a/scripts/ci/desktop-linux/claude-guard.py +++ b/scripts/ci/desktop-linux/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") call = json.load(sys.stdin) command = (call.get("tool_input") or {}).get("command", "").strip() -allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", command)) +) if os.environ.get("CLAUDE_GUARD_LOG"): with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log: log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n") diff --git a/scripts/ci/desktop-linux/claude-scope b/scripts/ci/desktop-linux/claude-scope index ca4c2da0f9..9a93a86a4f 100644 --- a/scripts/ci/desktop-linux/claude-scope +++ b/scripts/ci/desktop-linux/claude-scope @@ -1,3 +1,4 @@ # What the Claude job needs for the session claude-test.md describes. # backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. +# max-turns and max-budget-usd (optional) bound the session; claude-test.sh uses 60 and 3 when they're absent. backend: no diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh index 6f70e6400f..730a374c72 100755 --- a/scripts/ci/desktop-linux/claude-test.sh +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -23,6 +23,13 @@ settings=$(mktemp) export CLAUDE_GUARD_LOG=$transcript.guard.log : > "$CLAUDE_GUARD_LOG" +scope() { + sed -n "s/^$1: *//p" "$here/claude-scope" +} + +max_turns=$(scope max-turns) +max_budget=$(scope max-budget-usd) + { cat "$prompt" echo @@ -47,8 +54,8 @@ CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \ --permission-mode dontAsk \ --permission-prompts none \ --strict-mcp-config \ - --max-turns 60 \ - --max-budget-usd 3 \ + --max-turns "${max_turns:-60}" \ + --max-budget-usd "${max_budget:-3}" \ --autocompact 200k \ --no-session-persistence \ --settings "$settings" \ diff --git a/scripts/ci/desktop-linux/ui b/scripts/ci/desktop-linux/ui index 83c6f74bc0..1ef9c21940 100755 --- a/scripts/ci/desktop-linux/ui +++ b/scripts/ci/desktop-linux/ui @@ -14,6 +14,7 @@ # ui ink print the height in pixels of the text's ink, to compare text sizes # ui wait [secs] wait up to secs (30) until the text is on screen # ui click click at x,y +# ui right-click right-click at x,y, e.g. to open a list row's menu # ui click-text [secs] wait up to secs (20) for the text and click its centre # ui scroll [clicks] # turn the mouse wheel over x,y, 3 clicks unless given @@ -155,8 +156,17 @@ shot() { log "shot $(basename "$f").png" } +number() { + if ! [[ "$1" =~ ^[0-9]+$ ]]; then + echo "error: \"$1\" is not a number" + exit 1 + fi +} + click() { - xdotool mousemove --sync "$1" "$2" click 1 + number "$1" + number "$2" + xdotool mousemove --sync "$1" "$2" click "${3:-1}" sleep 1 } @@ -171,6 +181,9 @@ scroll() { return 1 ;; esac + number "$1" + number "$2" + number "$4" xdotool mousemove --sync "$1" "$2" click --repeat "$4" --delay 100 "$button" sleep 1 } @@ -223,6 +236,7 @@ case "${1:-}" in ;; wait) secs=${3:-30} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "wait \"$2\": not on screen after $secs s" exit 1 @@ -234,8 +248,13 @@ case "${1:-}" in click "$2" "$3" log "click $2,$3" ;; + right-click) + click "$2" "$3" 3 + log "right-click $2,$3" + ;; click-text) secs=${3:-20} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "click-text \"$2\": not on screen after $secs s" exit 1 @@ -255,7 +274,16 @@ case "${1:-}" in ;; key) shift - xdotool key --delay 100 -- "$@" + for k in "$@"; do + if ! [[ "$k" =~ ^[A-Za-z0-9_]+(\+[A-Za-z0-9_]+)*$ ]]; then + echo "error: \"$k\" is not a key name" + exit 1 + fi + done + # one key per call: xdotool key runs any later argument that names one of its commands, such as exec + for k in "$@"; do + xdotool key --delay 100 -- "$k" + done log "key $*" ;; do) diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index f72c6cb0b2..e45db66c93 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -36,7 +36,7 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; ## Driving the app: `ui` -`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen. +`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, long-press, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen. ## The test backend diff --git a/scripts/ci/ios-sim/claude-guard.py b/scripts/ci/ios-sim/claude-guard.py index ab131ae997..c335b9836a 100755 --- a/scripts/ci/ios-sim/claude-guard.py +++ b/scripts/ci/ios-sim/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") call = json.load(sys.stdin) command = (call.get("tool_input") or {}).get("command", "").strip() -allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", command)) +) if os.environ.get("CLAUDE_GUARD_LOG"): with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log: log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n") diff --git a/scripts/ci/ios-sim/ui b/scripts/ci/ios-sim/ui index 6738dac5c2..a705155388 100755 --- a/scripts/ci/ios-sim/ui +++ b/scripts/ci/ios-sim/ui @@ -6,6 +6,7 @@ # ui tap