From c6cda0bde1dbbc26650b18f0e4b5f6588213db26 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sat, 23 May 2026 11:02:18 +0200 Subject: [PATCH 001/227] security: verify PID belongs to claude before SIGKILL (#160) Add _is_claude_process() that reads /proc/{pid}/comm to confirm the process is "claude" or "node" before sending SIGKILL, preventing the kill from hitting an unrelated process if the original claude pid was recycled by the kernel between cron ticks. Co-Authored-By: Claude Sonnet 4.6 --- scripts/agent_loop.py | 13 ++++++++++++- scripts/test_agent_loop.py | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index 26c3845..8f05035 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -333,6 +333,15 @@ def _agent_alive(state: dict) -> bool: return True +def _is_claude_process(pid: int) -> bool: + """Return True if pid's comm name indicates it is a claude/node process.""" + try: + comm = Path(f"/proc/{pid}/comm").read_text().strip() + return comm in ("claude", "node") + except OSError: + return False + + def _agent_age_seconds(state: dict) -> float: """Seconds elapsed since the agent was launched, from the state file timestamp.""" try: @@ -367,11 +376,13 @@ def _git_summary() -> str: def _kill_agent(state: dict) -> None: """Forcefully stop the running agent.""" pid = state.get("pid") - if pid: + if pid and _is_claude_process(pid): try: os.kill(pid, 9) except ProcessLookupError: pass + elif pid: + print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID") # ── subcommands ─────────────────────────────────────────────────────────────── diff --git a/scripts/test_agent_loop.py b/scripts/test_agent_loop.py index cf51a75..362811d 100644 --- a/scripts/test_agent_loop.py +++ b/scripts/test_agent_loop.py @@ -88,21 +88,47 @@ class TestAgentAlive(unittest.TestCase): self.assertFalse(agent_loop._agent_alive({"pid": None})) +class TestIsClaudeProcess(unittest.TestCase): + def test_returns_true_for_claude_comm(self): + with patch.object(agent_loop.Path, "read_text", return_value="claude\n"): + self.assertTrue(agent_loop._is_claude_process(1234)) + + def test_returns_true_for_node_comm(self): + with patch.object(agent_loop.Path, "read_text", return_value="node\n"): + self.assertTrue(agent_loop._is_claude_process(1234)) + + def test_returns_false_for_other_process(self): + with patch.object(agent_loop.Path, "read_text", return_value="bash\n"): + self.assertFalse(agent_loop._is_claude_process(1234)) + + def test_returns_false_when_proc_missing(self): + with patch.object(agent_loop.Path, "read_text", side_effect=OSError): + self.assertFalse(agent_loop._is_claude_process(1234)) + + class TestKillAgent(unittest.TestCase): def test_kill_sends_sigkill(self): - with patch("agent_loop.os.kill") as mock_kill: - agent_loop._kill_agent({"pid": 1234}) - mock_kill.assert_called_once_with(1234, 9) + with patch("agent_loop._is_claude_process", return_value=True): + with patch("agent_loop.os.kill") as mock_kill: + agent_loop._kill_agent({"pid": 1234}) + mock_kill.assert_called_once_with(1234, 9) def test_kill_ignores_missing_process(self): - with patch("agent_loop.os.kill", side_effect=ProcessLookupError): - agent_loop._kill_agent({"pid": 1234}) # Should not raise. + with patch("agent_loop._is_claude_process", return_value=True): + with patch("agent_loop.os.kill", side_effect=ProcessLookupError): + agent_loop._kill_agent({"pid": 1234}) # Should not raise. def test_kill_noop_when_no_pid(self): with patch("agent_loop.os.kill") as mock_kill: agent_loop._kill_agent({}) mock_kill.assert_not_called() + def test_kill_skips_recycled_pid(self): + with patch("agent_loop._is_claude_process", return_value=False): + with patch("agent_loop.os.kill") as mock_kill: + agent_loop._kill_agent({"pid": 1234}) + mock_kill.assert_not_called() + class TestStartAgent(unittest.TestCase): def _make_mock_proc(self, pid=42): -- 2.52.0 From a8603edfc3e835330a2e68e58d4b59e07801ee88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 12:55:08 +0200 Subject: [PATCH 002/227] fix: verify PID belongs to claude before SIGKILL (#160) (#163) --- scripts/agent_loop.py | 13 ++++++++++++- scripts/test_agent_loop.py | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index 26c3845..8f05035 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -333,6 +333,15 @@ def _agent_alive(state: dict) -> bool: return True +def _is_claude_process(pid: int) -> bool: + """Return True if pid's comm name indicates it is a claude/node process.""" + try: + comm = Path(f"/proc/{pid}/comm").read_text().strip() + return comm in ("claude", "node") + except OSError: + return False + + def _agent_age_seconds(state: dict) -> float: """Seconds elapsed since the agent was launched, from the state file timestamp.""" try: @@ -367,11 +376,13 @@ def _git_summary() -> str: def _kill_agent(state: dict) -> None: """Forcefully stop the running agent.""" pid = state.get("pid") - if pid: + if pid and _is_claude_process(pid): try: os.kill(pid, 9) except ProcessLookupError: pass + elif pid: + print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID") # ── subcommands ─────────────────────────────────────────────────────────────── diff --git a/scripts/test_agent_loop.py b/scripts/test_agent_loop.py index cf51a75..362811d 100644 --- a/scripts/test_agent_loop.py +++ b/scripts/test_agent_loop.py @@ -88,21 +88,47 @@ class TestAgentAlive(unittest.TestCase): self.assertFalse(agent_loop._agent_alive({"pid": None})) +class TestIsClaudeProcess(unittest.TestCase): + def test_returns_true_for_claude_comm(self): + with patch.object(agent_loop.Path, "read_text", return_value="claude\n"): + self.assertTrue(agent_loop._is_claude_process(1234)) + + def test_returns_true_for_node_comm(self): + with patch.object(agent_loop.Path, "read_text", return_value="node\n"): + self.assertTrue(agent_loop._is_claude_process(1234)) + + def test_returns_false_for_other_process(self): + with patch.object(agent_loop.Path, "read_text", return_value="bash\n"): + self.assertFalse(agent_loop._is_claude_process(1234)) + + def test_returns_false_when_proc_missing(self): + with patch.object(agent_loop.Path, "read_text", side_effect=OSError): + self.assertFalse(agent_loop._is_claude_process(1234)) + + class TestKillAgent(unittest.TestCase): def test_kill_sends_sigkill(self): - with patch("agent_loop.os.kill") as mock_kill: - agent_loop._kill_agent({"pid": 1234}) - mock_kill.assert_called_once_with(1234, 9) + with patch("agent_loop._is_claude_process", return_value=True): + with patch("agent_loop.os.kill") as mock_kill: + agent_loop._kill_agent({"pid": 1234}) + mock_kill.assert_called_once_with(1234, 9) def test_kill_ignores_missing_process(self): - with patch("agent_loop.os.kill", side_effect=ProcessLookupError): - agent_loop._kill_agent({"pid": 1234}) # Should not raise. + with patch("agent_loop._is_claude_process", return_value=True): + with patch("agent_loop.os.kill", side_effect=ProcessLookupError): + agent_loop._kill_agent({"pid": 1234}) # Should not raise. def test_kill_noop_when_no_pid(self): with patch("agent_loop.os.kill") as mock_kill: agent_loop._kill_agent({}) mock_kill.assert_not_called() + def test_kill_skips_recycled_pid(self): + with patch("agent_loop._is_claude_process", return_value=False): + with patch("agent_loop.os.kill") as mock_kill: + agent_loop._kill_agent({"pid": 1234}) + mock_kill.assert_not_called() + class TestStartAgent(unittest.TestCase): def _make_mock_proc(self, pid=42): -- 2.52.0 From 5925cee4f29b7e6dcdade0a502c204fd6a6f8d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 12:56:27 +0200 Subject: [PATCH 003/227] fix: show git hash as clickable link above stacktrace (#201) (#211) --- lib/ui/screens/crash_screen.dart | 59 +++++++-------- scripts/agent_loop.py | 111 ++++++++++++++++++++++++----- test/widget/crash_screen_test.dart | 44 ++++++++++++ 3 files changed, 162 insertions(+), 52 deletions(-) diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 3e25078..02c49f3 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -10,12 +10,12 @@ class CrashScreen extends StatelessWidget { super.key, required this.exception, required this.stackTrace, + this.gitHash = const String.fromEnvironment('GIT_HASH'), }); final Object exception; final StackTrace? stackTrace; - - static const _gitHash = String.fromEnvironment('GIT_HASH'); + final String gitHash; Future _buildReport() async { String version = 'unknown'; @@ -25,8 +25,8 @@ class CrashScreen extends StatelessWidget { } catch (_) {} final platform = '${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; - final gitLine = _gitHash.isNotEmpty - ? 'Git Commit: [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)\n' + final gitLine = gitHash.isNotEmpty + ? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n' : ''; return 'App Version: $version\n' '$gitLine' @@ -56,12 +56,27 @@ class CrashScreen extends StatelessWidget { style: Theme.of(ctx).textTheme.titleMedium, textAlign: TextAlign.center, ), - if (_gitHash.isNotEmpty) ...[ + if (gitHash.isNotEmpty) ...[ const SizedBox(height: 8), - const Text( - 'Git Commit: $_gitHash', - style: TextStyle(fontSize: 12, color: Colors.grey), - textAlign: TextAlign.center, + GestureDetector( + onTap: () async { + final url = Uri.parse( + 'https://codeberg.org/guettli/sharedinbox/commit/$gitHash', + ); + await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + }, + child: Text( + 'Git Commit: $gitHash', + style: const TextStyle( + fontSize: 12, + color: Colors.blue, + decoration: TextDecoration.underline, + ), + textAlign: TextAlign.center, + ), ), ], const SizedBox(height: 24), @@ -106,32 +121,6 @@ class CrashScreen extends StatelessWidget { ), ), ], - if (_gitHash.isNotEmpty) ...[ - const SizedBox(height: 16), - const Text( - 'Git Commit:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - GestureDetector( - onTap: () async { - final url = Uri.parse( - 'https://codeberg.org/guettli/sharedinbox/commit/$_gitHash', - ); - await launchUrl( - url, - mode: LaunchMode.externalApplication, - ); - }, - child: const Text( - _gitHash, - style: TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), - ), - ), - ], const SizedBox(height: 24), FilledButton.icon( onPressed: () async { diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index 8f05035..3416b48 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -8,12 +8,15 @@ Flow a. Age > 1 h → kill it, set its issue to State/Question, exit 1 b. Age ≤ 1 h → print status, exit 0 (let it keep working) 2. No agent running → extract pending_issue from state (if any), then check CI - a. CI is running → save pending-ci state, exit 0 - b. Latest CI failed → start fix-CI agent (preserving pending_issue), exit 0 - c. CI ok + pending_issue → close the issue (CI passed), exit 0 - d. CI ok (or no run yet) → find oldest Ready issue, start issue agent, - save state, exit 0 - e. No Ready issues → print "nothing to do", exit 0 + a. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed + b. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them + c. Main CI running → save pending-ci state, exit 0 + d. Main CI failed → start fix-CI agent (pushes fix to main), exit 0 + e. Main CI ok + pending_issue → close the issue, exit 0 (dead code path — + section 2a always returns first) + f. Main CI ok (or no run yet) → find oldest Ready issue, start issue agent, + save state, exit 0 + g. No Ready issues → print "nothing to do", exit 0 Issue agents must NOT close the issue themselves; the loop closes it after CI passes. @@ -142,10 +145,19 @@ def _ready_issues() -> list[dict]: return ready -def _latest_ci_run() -> dict | None: - data = _tea_get(f"repos/{REPO}/actions/runs?limit=1") +def _latest_main_ci_run() -> dict | None: + """Return the latest CI run on the main branch (excludes PR runs). + + Using the global latest run (limit=1) is wrong: a passing or failing run + on a PR branch could mask the true state of main. We filter to non-PR + events on the 'main' prettyref so section-3 logic only reacts to main. + """ + data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") runs = (data or {}).get("workflow_runs", []) - return runs[0] if runs else None + for run in runs: + if run.get("event") != "pull_request" and run.get("prettyref") == "main": + return run + return None def _latest_ci_run_for_branch(branch: str) -> dict | None: @@ -520,6 +532,9 @@ def _run_loop() -> int: "Fetch the CI logs using the task ci-logs command or the Codeberg API. " "Identify the failure, fix it, commit, and push to the same branch. " "Do NOT push to main, do NOT close the issue, do NOT merge the PR. " + "Do NOT reference any issue numbers in commit messages " + "(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong " + "issue via a commit message would be a bug. " "Verify locally with 'task check' before pushing. " "When done, stop." ) @@ -558,7 +573,25 @@ def _run_loop() -> int: # CI passed on the PR branch — squash-merge and close. print(f"CI passed {_ci_run_url(pr_run['id'])} on branch {branch!r} — merging PR #{pr_number}.") - _merge_pr(pr_number) + try: + _merge_pr(pr_number) + except RuntimeError as e: + print(f"Merge of PR #{pr_number} failed: {e} — setting to State/Question.") + _set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS]) + _comment_issue( + pending_issue, + f"Automatic merge of PR #{pr_number} failed: {e}. Please merge manually.", + ) + return 0 + if _find_pr_for_branch(branch): + print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.") + _set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS]) + _comment_issue( + pending_issue, + f"Automatic merge of PR #{pr_number} failed (PR is still open after the " + "merge command). Please merge manually.", + ) + return 0 _close_issue(pending_issue) print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.") return 0 @@ -608,7 +641,26 @@ def _run_loop() -> int: if pr_run and pr_run.get("status") == "success": print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.") - _merge_pr(pr_number) + try: + _merge_pr(pr_number) + except RuntimeError as e: + print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.") + continue + # Verify the merge actually happened; fgj can exit 0 without merging + # (e.g. branch-protection rules not satisfied). + if _find_pr_for_branch(branch): + print( + f"Catch-up: PR #{pr_number} is still open after merge attempt " + "— skipping to avoid infinite retry." + ) + if issue_num: + _set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS]) + _comment_issue( + issue_num, + f"Automatic merge of PR #{pr_number} failed (PR is still open " + "after the merge command). Please merge manually.", + ) + continue if issue_num: _close_issue(issue_num) print(f"Merged PR #{pr_number} and closed issue #{issue_num}.") @@ -616,8 +668,8 @@ def _run_loop() -> int: print(f"Merged PR #{pr_number}.") return 0 - # ── 3. Global CI check (agent pushed to main, or no pending issue) ──────── - run = _latest_ci_run() + # ── 3. Global CI check (main branch only) ──────────────────────────────── + run = _latest_main_ci_run() if run and run.get("status") == "running": print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.") @@ -626,17 +678,39 @@ def _run_loop() -> int: return 0 if run and run.get("status") in ("failure", "error"): + # Guard: if the same main CI run has been failing since the last ci-fix + # agent started, that agent pushed to a branch instead of main. Before + # spawning another agent, check whether any CI run is currently in + # progress (the branch run) and wait if so. + if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start: + check = _tea_get(f"repos/{REPO}/actions/runs?limit=5") + in_flight = [ + r for r in (check or {}).get("workflow_runs", []) + if r.get("status") == "running" + ] + if in_flight: + print( + f"Main CI still shows the same failed run {run['id']}; " + f"{_ci_run_url(in_flight[0]['id'])} is running " + "(previous ci-fix pushed to a branch). Waiting." + ) + return 0 print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.") prompt = ( - "The Codeberg CI for guettli/sharedinbox just failed. " + "The Codeberg CI for guettli/sharedinbox just failed on the main branch. " f"The CI run ID is {run['id']}. " "Fetch the CI logs using the task ci-logs command or the Codeberg API. " - "Identify the failure, fix it, commit, and push. " + "Identify the failure, fix it, commit, and push directly to main. " "Verify locally with 'task check' before pushing. " + "Do NOT reference any issue numbers in commit messages " + "(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, " + "not an issue fix, and auto-closing an issue via a commit message would be a bug. " + "Do NOT close any issues. " "When done, stop." ) pid = _start_agent(prompt, "ci-fix") - _write_state(pid, pending_issue, "ci-fix", session_name="ci-fix") + _write_state(pid, pending_issue, "ci-fix", session_name="ci-fix", + ci_run_id=run["id"] if run else None) return 0 # CI is ok (or no run). @@ -695,7 +769,10 @@ Instructions: - Implement the required change, following the existing code style. - Write or update tests as appropriate. - Run 'task check' locally and fix any failures before committing. -- Commit with a descriptive message referencing the issue number (e.g. "feat: ... (#{issue_number})"). +- Commit with a descriptive message and include (#{issue_number}) in the title, + e.g. "feat: description (#{issue_number})". + Do NOT use "Closes #N" or "Fixes #N" keywords — the loop closes the issue + after CI passes; using those keywords would close it prematurely or wrongly. - Create a branch named `issue-{issue_number}-fix`, push your changes there, and open a PR against main: git checkout -b issue-{issue_number}-fix git push -u origin issue-{issue_number}-fix diff --git a/test/widget/crash_screen_test.dart b/test/widget/crash_screen_test.dart index 2f90866..80e5106 100644 --- a/test/widget/crash_screen_test.dart +++ b/test/widget/crash_screen_test.dart @@ -123,6 +123,50 @@ void main() { }, ); + testWidgets( + 'CrashScreen shows git hash as clickable link above stacktrace', + (tester) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final mock = MockUrlLauncher(); + UrlLauncherPlatform.instance = mock; + + const exception = 'TestException: git hash test'; + final stackTrace = StackTrace.current; + const testHash = 'abc1234'; + + await tester.pumpWidget( + CrashScreen( + exception: exception, + stackTrace: stackTrace, + gitHash: testHash, + ), + ); + + // Git hash link should be present + final gitLinkFinder = find.textContaining('Git Commit: abc1234'); + expect(gitLinkFinder, findsOneWidget); + + // Link must appear above the stack trace + final stackTraceFinder = find.text('Stack Trace:'); + expect( + tester.getTopLeft(gitLinkFinder).dy, + lessThan(tester.getTopLeft(stackTraceFinder).dy), + ); + + // Tapping the link should open the Codeberg commit URL + await tester.tap(gitLinkFinder); + await tester.pumpAndSettle(); + + expect( + mock.launchedUrl, + equals('https://codeberg.org/guettli/sharedinbox/commit/abc1234'), + ); + }, + ); + testWidgets( 'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash', (tester) async { -- 2.52.0 From 37eca207c68af30db5c56b9e9c655a29592445e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 13:00:04 +0200 Subject: [PATCH 004/227] fix: pin SSH host key via known_hosts instead of StrictHostKeyChecking=no (#161) (#181) --- .forgejo/workflows/deploy.yml | 3 ++ .github/workflows/ci.yml | 13 +++---- Taskfile.yml | 57 ++++++++++++++++++++++--------- ci/main.go | 30 +++++++++------- scripts/generate_build_history.py | 3 -- 5 files changed, 69 insertions(+), 37 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9e128dc..a7887b0 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -156,6 +156,7 @@ jobs: if: ${{ secrets.SSH_PRIVATE_KEY != '' }} env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} SSH_USER: ${{ secrets.SSH_USER }} SSH_HOST: ${{ secrets.SSH_HOST }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} @@ -197,6 +198,7 @@ jobs: if: ${{ secrets.SSH_PRIVATE_KEY != '' }} env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} SSH_USER: ${{ secrets.SSH_USER }} SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" @@ -238,6 +240,7 @@ jobs: if: ${{ secrets.SSH_PRIVATE_KEY != '' }} env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} SSH_USER: ${{ secrets.SSH_USER }} SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d4346f..d368d88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,8 @@ jobs: mkdir -p ~/.ssh printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts - name: Build Linux release run: | @@ -215,20 +217,20 @@ jobs: REMOTE_DIR="public_html/builds/$DATE_PATH" TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz" tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" - scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" + ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" + scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL" - EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" \ + EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \ "cat public_html/latest.json 2>/dev/null || echo '{}'") WINDOWS_URL=$(echo "$EXISTING" | \ python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \ 2>/dev/null || true) if [ -n "$WINDOWS_URL" ]; then echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \ - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" + ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" else echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \ - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" + ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" fi - name: Generate build history pages @@ -244,6 +246,5 @@ jobs: rsync -avz --delete \ --exclude='*.apk' \ --exclude='*.tar.gz' \ - -e "ssh -o StrictHostKeyChecking=no" \ website/public/ \ "$SSH_USER@$SSH_HOST:public_html/" diff --git a/Taskfile.yml b/Taskfile.yml index 9f28eab..afeeb77 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -215,8 +215,10 @@ tasks: preconditions: - sh: test -n "$SSH_PRIVATE_KEY" msg: "SSH_PRIVATE_KEY is not set" + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" cmds: - - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" + - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" build-android-bundle: desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally @@ -251,17 +253,24 @@ tasks: preconditions: - sh: test -n "$SSH_PRIVATE_KEY" msg: "SSH_PRIVATE_KEY is not set" + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" - sh: test -n "$ANDROID_KEYSTORE_BASE64" msg: "ANDROID_KEYSTORE_BASE64 is not set" - sh: test -n "$ANDROID_KEYSTORE_PASSWORD" msg: "ANDROID_KEYSTORE_PASSWORD is not set" cmds: - - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)" + - HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)" publish-website: desc: Build and publish website via Dagger + preconditions: + - sh: test -n "$SSH_PRIVATE_KEY" + msg: "SSH_PRIVATE_KEY is not set" + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" cmds: - - dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" + - dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" check-dagger: desc: Run full check suite via Dagger (with OTEL timing report if python3 is available) @@ -373,25 +382,29 @@ tasks: msg: "SSH_USER is not set" - sh: test -n "$SSH_HOST" msg: "SSH_HOST is not set" + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" cmds: - | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts HASH=$(git rev-parse --short HEAD) DATE_PATH=$(date -u +%Y/%m/%d) REMOTE_DIR="public_html/builds/$DATE_PATH" TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz" tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" - scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" + ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" + scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL" # Merge with any existing latest.json so we don't overwrite the windows key - EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'") + EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'") WINDOWS_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" 2>/dev/null || true) if [ -n "$WINDOWS_URL" ]; then echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \ - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" + ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" else echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \ - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" + ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" fi echo "Uploaded $TARBALL and updated latest.json" @@ -416,24 +429,28 @@ tasks: msg: "SSH_USER is not set" - sh: test -n "$SSH_HOST" msg: "SSH_HOST is not set" + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" cmds: - | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts HASH=$(git rev-parse --short HEAD) DATE_PATH=$(date -u +%Y/%m/%d) REMOTE_DIR="public_html/builds/$DATE_PATH" ZIPFILE="sharedinbox-windows-x64-$HASH.zip" cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd - - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" - scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE" + ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" + scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE" DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE" - EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'") + EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'") LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true) if [ -n "$LINUX_URL" ]; then echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \ - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" + ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" else echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \ - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" + ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" fi echo "Uploaded $ZIPFILE and updated latest.json" @@ -583,14 +600,18 @@ tasks: msg: "SSH_USER is not set" - sh: test -n "$SSH_HOST" msg: "SSH_HOST is not set" + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" cmds: - | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts HASH=$(git rev-parse --short HEAD) DATE_PATH=$(date -u +%Y/%m/%d) REMOTE_DIR="public_html/builds/$DATE_PATH" APK_NAME="sharedinbox-mua-$HASH.apk" - ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" - scp -o StrictHostKeyChecking=no \ + ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" + scp \ build/app/outputs/flutter-apk/app-release.apk \ "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME" echo "Uploaded $APK_NAME to $REMOTE_DIR" @@ -619,12 +640,16 @@ tasks: website-deploy: desc: Deploy the website via rsync to public_html deps: [website-build] + preconditions: + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" cmds: - | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts rsync -avz --delete \ --exclude='*.apk' \ --exclude='*.tar.gz' \ - -e "ssh -o StrictHostKeyChecking=no" \ website/public/ \ ${SSH_USER}@${SSH_HOST}:public_html/ diff --git a/ci/main.go b/ci/main.go index 5355ce7..f5aadcd 100644 --- a/ci/main.go +++ b/ci/main.go @@ -318,12 +318,13 @@ func (m *Ci) Hugo() *dagger.Container { } // Deploy container for rsync/ssh -func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container { +func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container { return dag.Container(). From("alpine:3.21"). WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}). WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). - WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519") + WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}). + WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519") } // Stalwart mail server service for backend and integration tests. @@ -514,6 +515,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) { func (m *Ci) GenerateBuildHistory( ctx context.Context, sshKey *dagger.Secret, + knownHosts *dagger.Secret, sshUser string, sshHost string, ) *dagger.Directory { @@ -525,7 +527,7 @@ func (m *Ci) GenerateBuildHistory( From("python:3.12-alpine"). WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}). WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}). - WithExec([]string{"chmod", "700", "/root/.ssh"}). + WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}). WithEnvVariable("SSH_USER", sshUser). WithEnvVariable("SSH_HOST", sshHost). WithDirectory("/src", scriptSource). @@ -538,10 +540,11 @@ func (m *Ci) GenerateBuildHistory( func (m *Ci) BuildWebsite( ctx context.Context, sshKey *dagger.Secret, + knownHosts *dagger.Secret, sshUser string, sshHost string, ) *dagger.Directory { - buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost) + buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost) websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{ Include: []string{"website/"}, @@ -558,12 +561,13 @@ func (m *Ci) BuildWebsite( func (m *Ci) PublishWebsite( ctx context.Context, sshKey *dagger.Secret, + knownHosts *dagger.Secret, sshUser string, sshHost string, ) (string, error) { - public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost) + public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost) - return m.Deployer(sshKey). + return m.Deployer(sshKey, knownHosts). WithDirectory("/public", public). WithExec([]string{"rsync", "-avz", "--delete", "--exclude=*.apk", "--exclude=*.tar.gz", @@ -589,6 +593,7 @@ func (m *Ci) BuildLinuxRelease() *dagger.Directory { func (m *Ci) DeployLinux( ctx context.Context, sshKey *dagger.Secret, + knownHosts *dagger.Secret, sshUser string, sshHost string, commitHash string, @@ -599,11 +604,11 @@ func (m *Ci) DeployLinux( remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash) - return m.Deployer(sshKey). + return m.Deployer(sshKey, knownHosts). WithDirectory("/bundle", bundle). WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}). - WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}). - WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}). + WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}). + WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}). Stdout(ctx) } @@ -626,6 +631,7 @@ func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *da func (m *Ci) DeployApk( ctx context.Context, sshKey *dagger.Secret, + knownHosts *dagger.Secret, sshUser string, sshHost string, commitHash string, @@ -639,10 +645,10 @@ func (m *Ci) DeployApk( remoteDir := fmt.Sprintf("public_html/builds/%s", datePath) apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash) - return m.Deployer(sshKey). + return m.Deployer(sshKey, knownHosts). WithFile("/tmp/app.apk", apk). - WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}). - WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}). + WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}). + WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}). Stdout(ctx) } diff --git a/scripts/generate_build_history.py b/scripts/generate_build_history.py index 5540b91..946b994 100644 --- a/scripts/generate_build_history.py +++ b/scripts/generate_build_history.py @@ -33,9 +33,6 @@ def list_remote_files(ssh_user: str, ssh_host: str, pattern: str) -> list[str]: result = subprocess.run( [ "ssh", - "-v", - "-o", "StrictHostKeyChecking=no", - "-i", "/root/.ssh/id_ed25519", f"{ssh_user}@{ssh_host}", f"find {REMOTE_BUILDS_DIR} -name '{pattern}' -type f | sort", ], -- 2.52.0 From 77e581299d22148773bd1dcb8dea23cc2d484663 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 24 May 2026 14:08:13 +0200 Subject: [PATCH 005/227] fix: filter out schedule/deploy workflow runs in CI checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _latest_main_ci_run() was using event != pull_request which still matched deploy.yml schedule runs when their prettyref == "main", blocking the loop from picking up new issues. _latest_ci_run_for_branch() had the same issue: the else branch matched any non-pull_request event including schedule runs. Both functions now explicitly filter for event == "push" only. Tests updated: rename _latest_ci_run → _latest_main_ci_run, mock _open_issue_prs to prevent real API calls in unit tests, and update _find_pr_for_branch side_effect to reflect the upstream post-merge PR-still-open verification check. Co-Authored-By: Claude Sonnet 4.6 --- scripts/agent_loop.py | 8 ++--- scripts/test_agent_loop.py | 63 ++++++++++++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index 3416b48..ca102bf 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -146,16 +146,16 @@ def _ready_issues() -> list[dict]: def _latest_main_ci_run() -> dict | None: - """Return the latest CI run on the main branch (excludes PR runs). + """Return the latest CI run on the main branch (excludes PR and schedule runs). Using the global latest run (limit=1) is wrong: a passing or failing run - on a PR branch could mask the true state of main. We filter to non-PR + on a PR branch could mask the true state of main. We filter to push events on the 'main' prettyref so section-3 logic only reacts to main. """ data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") runs = (data or {}).get("workflow_runs", []) for run in runs: - if run.get("event") != "pull_request" and run.get("prettyref") == "main": + if run.get("event") == "push" and run.get("prettyref") == "main": return run return None @@ -177,7 +177,7 @@ def _latest_ci_run_for_branch(branch: str) -> dict | None: return run except (json.JSONDecodeError, AttributeError): pass - else: + elif run.get("event") == "push": if run.get("prettyref") == branch: return run return None diff --git a/scripts/test_agent_loop.py b/scripts/test_agent_loop.py index 362811d..a9fa391 100644 --- a/scripts/test_agent_loop.py +++ b/scripts/test_agent_loop.py @@ -200,7 +200,8 @@ class TestMain(unittest.TestCase): return 55 with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=None), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._start_agent", side_effect=fake_start_agent), \ @@ -226,7 +227,8 @@ class TestMain(unittest.TestCase): captured["remove"] = remove with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=None), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._start_agent", return_value=99), \ @@ -239,7 +241,8 @@ class TestMain(unittest.TestCase): def test_no_ready_issues_does_nothing(self): """main() exits cleanly with 0 when there are no ready issues.""" with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=None), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._set_labels") as mock_labels, \ patch("agent_loop._start_agent") as mock_start: @@ -258,7 +261,8 @@ class TestMain(unittest.TestCase): return 77 with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=None), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \ patch("agent_loop._set_labels"), \ patch("agent_loop._start_agent", side_effect=fake_start_agent), \ @@ -292,8 +296,9 @@ class TestPendingCi(unittest.TestCase): def test_closes_issue_when_ci_passes_after_agent_finishes(self): """After issue agent finishes, loop merges the PR and closes the issue once CI is green.""" + # First call: PR found open. Second call (post-merge verification): PR closed. with patch("agent_loop._read_state", return_value=self._dead_state(10)), \ - patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \ + patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \ patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \ patch("agent_loop._merge_pr") as mock_merge, \ patch("agent_loop._close_issue") as mock_close, \ @@ -308,7 +313,7 @@ class TestPendingCi(unittest.TestCase): """'CI passed' line includes the CI run URL when a run is available.""" buf = io.StringIO() with patch("agent_loop._read_state", return_value=self._dead_state(10)), \ - patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \ + patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \ patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 4145144, "status": "success"}), \ patch("agent_loop._merge_pr"), \ patch("agent_loop._close_issue"), \ @@ -418,7 +423,7 @@ class TestPendingCi(unittest.TestCase): def test_closes_issue_after_ci_fix_and_ci_passes(self): """After ci-fix agent finishes and CI passes on PR branch, the pending issue is closed.""" with patch("agent_loop._read_state", return_value=self._dead_state(10, "ci-fix")), \ - patch("agent_loop._find_pr_for_branch", side_effect=self._find_pr_open), \ + patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), None]), \ patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \ patch("agent_loop._merge_pr") as mock_merge, \ patch("agent_loop._close_issue") as mock_close, \ @@ -435,7 +440,8 @@ class TestPendingCi(unittest.TestCase): "pid": 999999999, "issue": None, "started_at": "2026-01-01T00:00:00+00:00", "type": "ci-fix", }), \ - patch("agent_loop._latest_ci_run", return_value={"id": 1, "status": "success"}), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \ patch("agent_loop._close_issue") as mock_close, \ patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._clear_state"): @@ -451,7 +457,8 @@ class TestOutputFormat(unittest.TestCase): def test_output_starts_with_header(self): buf = io.StringIO() with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=None), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._ready_issues", return_value=[]), \ contextlib.redirect_stdout(buf): agent_loop._run_loop() @@ -462,7 +469,8 @@ class TestOutputFormat(unittest.TestCase): def test_no_agent_loop_prefix_in_output(self): buf = io.StringIO() with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=None), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._ready_issues", return_value=[]), \ contextlib.redirect_stdout(buf): agent_loop._run_loop() @@ -472,7 +480,8 @@ class TestOutputFormat(unittest.TestCase): run = {"id": 4145144, "status": "running"} buf = io.StringIO() with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=run), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=run), \ contextlib.redirect_stdout(buf): agent_loop._run_loop() self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144", @@ -482,7 +491,8 @@ class TestOutputFormat(unittest.TestCase): issue = {"number": 128, "title": "Fix something", "body": "", "labels": []} buf = io.StringIO() with patch("agent_loop._read_state", return_value=None), \ - patch("agent_loop._latest_ci_run", return_value=None), \ + patch("agent_loop._open_issue_prs", return_value=[]), \ + patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._ready_issues", return_value=[issue]), \ patch("agent_loop._set_labels"), \ patch("agent_loop._start_agent", return_value=99), \ @@ -494,6 +504,35 @@ class TestOutputFormat(unittest.TestCase): self.assertIn("Fix something", output) +class TestLatestMainCiRun(unittest.TestCase): + """_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows.""" + + def test_skips_schedule_runs_returns_push_to_main(self): + runs = [ + {"event": "schedule", "prettyref": "main", "status": "success", "id": 1}, + {"event": "push", "prettyref": "main", "status": "success", "id": 2}, + ] + with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): + result = agent_loop._latest_main_ci_run() + self.assertIsNotNone(result) + self.assertEqual(result["id"], 2) + + def test_returns_none_when_only_schedule_runs_exist(self): + runs = [ + {"event": "schedule", "prettyref": "main", "status": "success", "id": 1}, + ] + with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): + result = agent_loop._latest_main_ci_run() + self.assertIsNone(result) + + def test_returns_push_to_main_run(self): + runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}] + with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): + result = agent_loop._latest_main_ci_run() + self.assertIsNotNone(result) + self.assertEqual(result["id"], 42) + + class TestLatestCiRunForBranch(unittest.TestCase): """Tests for _latest_ci_run_for_branch — Forgejo API field mapping.""" -- 2.52.0 From 7dd58000642b3fde3aade4d912d40b81b8b38342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 14:30:07 +0200 Subject: [PATCH 006/227] perf: cache Linux engine artifacts via flutter precache --linux (#129) (#218) --- ci/main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ci/main.go b/ci/main.go index f5aadcd..94ceda1 100644 --- a/ci/main.go +++ b/ci/main.go @@ -195,7 +195,8 @@ func (m *Ci) toolchain() *dagger.Container { WithUser("ci"). WithExec([]string{"/bin/sh", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + - `yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}) + `yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}). + WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"}) } // Base is the Flutter toolchain container with mutable cache mounts attached. @@ -810,7 +811,7 @@ func (m *Ci) Graph() string { ` + "```" + `mermaid flowchart TD subgraph dagger ["Dagger · Check pipeline"] - toolchain["toolchain\nflutter:3.41.6 + NDK + apt"] + toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"] pubGet["pubGetLayer\nflutter pub get"] codegen["codegenBase\nbuild_runner build\n(shared cache)"] stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"]) -- 2.52.0 From 50ae7df8a3544dabde9d6abd52486fef8b36e464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 14:55:07 +0200 Subject: [PATCH 007/227] fix: fall back to text input when mobile_scanner plugin is unavailable (#202) (#219) --- lib/ui/screens/account_receive_screen.dart | 41 +++++++++++++++++++--- lib/ui/screens/account_send_screen.dart | 35 ++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index c67a263..c1fd035 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -37,6 +37,9 @@ class _AccountReceiveScreenState extends ConsumerState { bool _scannerActive = false; MobileScannerController? _scannerController; + // True when the scanner plugin fails to initialise at runtime (e.g. + // MissingPluginException on some Android builds). + bool _scannerFailed = false; @override void initState() { @@ -76,8 +79,35 @@ class _AccountReceiveScreenState extends ConsumerState { setState(() { _step = _Step.scanning; _scannerActive = true; - _scannerController = MobileScannerController(); }); + if (_cameraScanSupported()) { + unawaited(_initScanner()); + } + } + + // Pre-flight: start + stop the scanner to verify the plugin is available. + // Falls back to text entry on any exception (including MissingPluginException). + Future _initScanner() async { + MobileScannerController? ctrl; + bool available = false; + try { + ctrl = MobileScannerController(); + await ctrl.start(); + await ctrl.stop(); + available = true; + } catch (_) { + // Plugin not available on this device; text fallback will be shown. + } finally { + try { + await ctrl?.dispose(); + } catch (_) {} + } + if (!mounted) return; + if (available) { + setState(() => _scannerController = MobileScannerController()); + } else { + setState(() => _scannerFailed = true); + } } Future _onScanned(String rawValue) async { @@ -266,11 +296,14 @@ class _AccountReceiveScreenState extends ConsumerState { } Widget _buildScannerView(BuildContext context) { - // On platforms where the camera scanner is not available (Linux desktop), - // fall back to a text-input field. - if (!_cameraScanSupported()) { + // Fall back to text input when the platform has no camera support or when + // the scanner plugin fails to initialise at runtime (MissingPluginException). + if (!_cameraScanSupported() || _scannerFailed) { return _buildTextFallbackView(context); } + if (_scannerController == null) { + return const Center(child: CircularProgressIndicator()); + } return Stack( children: [ diff --git a/lib/ui/screens/account_send_screen.dart b/lib/ui/screens/account_send_screen.dart index 34c8620..59e3548 100644 --- a/lib/ui/screens/account_send_screen.dart +++ b/lib/ui/screens/account_send_screen.dart @@ -45,12 +45,40 @@ class _AccountSendScreenState extends ConsumerState { bool _scannerActive = true; MobileScannerController? _scannerController; + // True when the scanner plugin fails to initialise at runtime (e.g. + // MissingPluginException on some Android builds). + bool _scannerFailed = false; @override void initState() { super.initState(); if (_cameraScanSupported()) { - _scannerController = MobileScannerController(); + unawaited(_initScanner()); + } + } + + // Pre-flight: start + stop the scanner to verify the plugin is available. + // Falls back to text entry on any exception (including MissingPluginException). + Future _initScanner() async { + MobileScannerController? ctrl; + bool available = false; + try { + ctrl = MobileScannerController(); + await ctrl.start(); + await ctrl.stop(); + available = true; + } catch (_) { + // Plugin not available on this device; text fallback will be shown. + } finally { + try { + await ctrl?.dispose(); + } catch (_) {} + } + if (!mounted) return; + if (available) { + setState(() => _scannerController = MobileScannerController()); + } else { + setState(() => _scannerFailed = true); } } @@ -178,9 +206,12 @@ class _AccountSendScreenState extends ConsumerState { } Widget _buildScanStep(BuildContext context) { - if (!_cameraScanSupported()) { + if (!_cameraScanSupported() || _scannerFailed) { return _buildTextFallbackView(context); } + if (_scannerController == null) { + return const Center(child: CircularProgressIndicator()); + } return Stack( children: [ -- 2.52.0 From d9b874863178995eba0fd953f551f4f32cc2f3b0 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 24 May 2026 15:07:00 +0200 Subject: [PATCH 008/227] fix: filter _latest_main_ci_run by workflow_id == ci.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgejo reports deploy.yml (scheduled/dispatch) runs with event=push and prettyref=main, identical to ci.yml push runs. The event-only filter was insufficient — adding workflow_id == "ci.yml" prevents deploy.yml runs from blocking or triggering false CI fix agents. Co-Authored-By: Claude Sonnet 4.6 --- scripts/agent_loop.py | 12 +++++++----- scripts/test_agent_loop.py | 36 ++++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/scripts/agent_loop.py b/scripts/agent_loop.py index ca102bf..f9fd3c0 100755 --- a/scripts/agent_loop.py +++ b/scripts/agent_loop.py @@ -146,16 +146,18 @@ def _ready_issues() -> list[dict]: def _latest_main_ci_run() -> dict | None: - """Return the latest CI run on the main branch (excludes PR and schedule runs). + """Return the latest ci.yml run on the main branch. - Using the global latest run (limit=1) is wrong: a passing or failing run - on a PR branch could mask the true state of main. We filter to push - events on the 'main' prettyref so section-3 logic only reacts to main. + Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with + event=push and prettyref=main, so filtering by event alone is not enough. + We also require workflow_id == "ci.yml". """ data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") runs = (data or {}).get("workflow_runs", []) for run in runs: - if run.get("event") == "push" and run.get("prettyref") == "main": + if (run.get("event") == "push" + and run.get("prettyref") == "main" + and run.get("workflow_id") == "ci.yml"): return run return None diff --git a/scripts/test_agent_loop.py b/scripts/test_agent_loop.py index a9fa391..a3d4d07 100644 --- a/scripts/test_agent_loop.py +++ b/scripts/test_agent_loop.py @@ -505,28 +505,40 @@ class TestOutputFormat(unittest.TestCase): class TestLatestMainCiRun(unittest.TestCase): - """_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows.""" + """_latest_main_ci_run() must return only ci.yml push-to-main runs.""" - def test_skips_schedule_runs_returns_push_to_main(self): - runs = [ - {"event": "schedule", "prettyref": "main", "status": "success", "id": 1}, - {"event": "push", "prettyref": "main", "status": "success", "id": 2}, - ] + def _ci_run(self, run_id, status="success"): + return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml", + "status": status, "id": run_id} + + def _deploy_run(self, run_id, status="success"): + return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml", + "status": status, "id": run_id} + + def test_skips_deploy_run_returns_ci_run(self): + # Forgejo reports deploy.yml schedule runs as event=push/prettyref=main; + # must be excluded by workflow_id filter. + runs = [self._deploy_run(1), self._ci_run(2)] with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): result = agent_loop._latest_main_ci_run() self.assertIsNotNone(result) self.assertEqual(result["id"], 2) - def test_returns_none_when_only_schedule_runs_exist(self): - runs = [ - {"event": "schedule", "prettyref": "main", "status": "success", "id": 1}, - ] + def test_returns_none_when_only_deploy_runs_exist(self): + runs = [self._deploy_run(1)] with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): result = agent_loop._latest_main_ci_run() self.assertIsNone(result) - def test_returns_push_to_main_run(self): - runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}] + def test_returns_none_when_only_schedule_runs_exist(self): + runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml", + "status": "success", "id": 1}] + with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): + result = agent_loop._latest_main_ci_run() + self.assertIsNone(result) + + def test_returns_ci_push_to_main_run(self): + runs = [self._ci_run(42, status="running")] with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}): result = agent_loop._latest_main_ci_run() self.assertIsNotNone(result) -- 2.52.0 From 43068509d295b1d517bb4e32d17b6eb73b0a787c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 15:15:12 +0200 Subject: [PATCH 009/227] fix: show live countdown with seconds on receive account screen (#203) (#220) --- lib/ui/screens/account_receive_screen.dart | 39 ++++++++++++++++++--- test/widget/account_export_screen_test.dart | 4 +-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index c1fd035..44ac251 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -32,6 +32,7 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error } class _AccountReceiveScreenState extends ConsumerState { _Step _step = _Step.generatingKey; ShareKeyMaterial? _keyMaterial; + DateTime? _keyExpiresAt; String? _pubKeyQr; String? _errorMessage; bool _scannerActive = false; @@ -64,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState { ); setState(() { _keyMaterial = material; + _keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20)); _pubKeyQr = qr; _step = _Step.showingPubKey; }); @@ -274,7 +276,7 @@ class _AccountReceiveScreenState extends ConsumerState { }, ), const SizedBox(height: 8), - const _ExpiryHint(), + _ExpiryHint(expiresAt: _keyExpiresAt!), const SizedBox(height: 32), if (_errorMessage != null) ...[ Text( @@ -404,8 +406,37 @@ bool _cameraScanSupported() => Platform.isMacOS || Platform.isWindows; -class _ExpiryHint extends StatelessWidget { - const _ExpiryHint(); +class _ExpiryHint extends StatefulWidget { + const _ExpiryHint({required this.expiresAt}); + + final DateTime expiresAt; + + @override + State<_ExpiryHint> createState() => _ExpiryHintState(); +} + +class _ExpiryHintState extends State<_ExpiryHint> { + late Timer _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {})); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + String _formatRemaining() { + final remaining = widget.expiresAt.difference(DateTime.now().toUtc()); + if (remaining.isNegative) return 'expired'; + final minutes = remaining.inMinutes; + final seconds = remaining.inSeconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } @override Widget build(BuildContext context) { @@ -415,7 +446,7 @@ class _ExpiryHint extends StatelessWidget { Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]), const SizedBox(width: 4), Text( - 'This key expires in 20 minutes', + 'This key expires in ${_formatRemaining()}', style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index 35d2220..f8b5bfe 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -23,7 +23,7 @@ void main() { expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget); }); - testWidgets('shows 20-minute expiry hint', (tester) async { + testWidgets('shows expiry countdown hint', (tester) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/receive', @@ -32,7 +32,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.textContaining('20 minutes'), findsOneWidget); + expect(find.textContaining('expires in'), findsOneWidget); }); }); -- 2.52.0 From d51e67ddcc805482996d2307a24117b2819a1993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 15:55:08 +0200 Subject: [PATCH 010/227] fix: probe scanner method channel to detect MissingPluginException (#204) (#221) --- lib/ui/screens/account_receive_screen.dart | 24 ++--- lib/ui/screens/account_send_screen.dart | 24 ++--- test/widget/account_export_screen_test.dart | 98 +++++++++++++++++++++ test/widget/helpers.dart | 9 +- 4 files changed, 131 insertions(+), 24 deletions(-) diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart index 44ac251..0be5c89 100644 --- a/lib/ui/screens/account_receive_screen.dart +++ b/lib/ui/screens/account_receive_screen.dart @@ -87,22 +87,24 @@ class _AccountReceiveScreenState extends ConsumerState { } } - // Pre-flight: start + stop the scanner to verify the plugin is available. - // Falls back to text entry on any exception (including MissingPluginException). + // Pre-flight: probe the scanner's permission-state method to verify the + // plugin is registered. MissingPluginException is thrown on Android builds + // where the plugin is not linked (issue #204). All other exceptions mean + // the plugin exists but something else failed — the MobileScanner widget + // will surface those via its own error builder. Future _initScanner() async { - MobileScannerController? ctrl; bool available = false; try { - ctrl = MobileScannerController(); - await ctrl.start(); - await ctrl.stop(); + await const MethodChannel( + 'dev.steenbakker.mobile_scanner/scanner/method', + ).invokeMethod('state'); available = true; + } on MissingPluginException { + // Plugin not registered on this device; text fallback will be shown. } catch (_) { - // Plugin not available on this device; text fallback will be shown. - } finally { - try { - await ctrl?.dispose(); - } catch (_) {} + // Plugin registered but state check failed; let the scanner widget + // handle it via its errorBuilder. + available = true; } if (!mounted) return; if (available) { diff --git a/lib/ui/screens/account_send_screen.dart b/lib/ui/screens/account_send_screen.dart index 59e3548..9049fed 100644 --- a/lib/ui/screens/account_send_screen.dart +++ b/lib/ui/screens/account_send_screen.dart @@ -57,22 +57,24 @@ class _AccountSendScreenState extends ConsumerState { } } - // Pre-flight: start + stop the scanner to verify the plugin is available. - // Falls back to text entry on any exception (including MissingPluginException). + // Pre-flight: probe the scanner's permission-state method to verify the + // plugin is registered. MissingPluginException is thrown on Android builds + // where the plugin is not linked (issue #204). All other exceptions mean + // the plugin exists but something else failed — the MobileScanner widget + // will surface those via its own error builder. Future _initScanner() async { - MobileScannerController? ctrl; bool available = false; try { - ctrl = MobileScannerController(); - await ctrl.start(); - await ctrl.stop(); + await const MethodChannel( + 'dev.steenbakker.mobile_scanner/scanner/method', + ).invokeMethod('state'); available = true; + } on MissingPluginException { + // Plugin not registered on this device; text fallback will be shown. } catch (_) { - // Plugin not available on this device; text fallback will be shown. - } finally { - try { - await ctrl?.dispose(); - } catch (_) {} + // Plugin registered but state check failed; let the scanner widget + // handle it via its errorBuilder. + available = true; } if (!mounted) return; if (available) { diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index f8b5bfe..5f2259e 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -34,6 +34,104 @@ void main() { expect(find.textContaining('expires in'), findsOneWidget); }); + + testWidgets( + 'step 2 button shows text-input fallback on platforms without camera', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/receive', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('scanEncryptedButton'))); + await tester.pumpAndSettle(); + + // On Linux (desktop, no camera) the text fallback field must appear. + expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget); + }, + ); + + testWidgets( + 'step 2 — valid encrypted QR imports account via text fallback', + (tester) async { + // Pre-generate a key pair so we can encrypt a QR code with the same + // material the screen will use for decryption. + final material = await ShareEncryptionService.generateKeyPair(); + final repo = FakeShareKeyRepository(material: material); + + const account = Account( + id: 'src-1', + displayName: 'Alice', + email: 'alice@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ); + + final encryptedQr = await ShareEncryptionService.encryptAccounts( + recipientKeyId: material.keyId, + recipientPublicKeyBytes: material.publicKeyBytes, + accounts: [ + AccountPayload( + accountJson: account.toJson(), + password: 'secret', + ), + ], + ); + + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/receive', + overrides: baseOverrides(shareKeyRepository: repo), + ), + ); + await tester.pumpAndSettle(); // key generation completes + + await tester.tap(find.byKey(const Key('scanEncryptedButton'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('encryptedCodeField')), + encryptedQr, + ); + await tester.tap(find.text('Import')); + await tester.pumpAndSettle(); + + expect( + find.text('Imported 1 account successfully.'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'step 2 — invalid encrypted QR shows error and returns to pub-key step', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/receive', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('scanEncryptedButton'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('encryptedCodeField')), + 'not-a-valid-qr-code', + ); + await tester.tap(find.text('Import')); + await tester.pumpAndSettle(); + + // Screen returns to the pub-key step with an error message visible. + expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget); + expect(find.textContaining('Import failed:'), findsWidgets); + }, + ); }); group('AccountSendScreen', () { diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 89da3d4..74ef82f 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository { } class FakeShareKeyRepository implements ShareKeyRepository { + FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material; + ShareKeyMaterial? _material; @override Future createKeyPair() async { - _material = await ShareEncryptionService.generateKeyPair(); + _material ??= await ShareEncryptionService.generateKeyPair(); return _material!; } @@ -511,6 +513,7 @@ List baseOverrides({ List? mailboxes, DiscoveryResult? discovery, Exception? connectionError, + ShareKeyRepository? shareKeyRepository, }) => [ accountRepositoryProvider @@ -525,7 +528,9 @@ List baseOverrides({ connectionTestServiceProvider.overrideWithValue( FakeConnectionTestService(error: connectionError), ), - shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), + shareKeyRepositoryProvider.overrideWithValue( + shareKeyRepository ?? FakeShareKeyRepository(), + ), ]; // --------------------------------------------------------------------------- -- 2.52.0 From e7ff9243c9335994a92e81b1fe71286ffdb21878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 16:10:09 +0200 Subject: [PATCH 011/227] feat: add build mode, Dart version, timestamp to crash report (#205) (#222) --- lib/ui/screens/crash_screen.dart | 38 ++++++++++++++++++++++++++---- test/widget/crash_screen_test.dart | 32 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 02c49f3..0780c25 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -17,20 +18,35 @@ class CrashScreen extends StatelessWidget { final StackTrace? stackTrace; final String gitHash; - Future _buildReport() async { - String version = 'unknown'; + String get _buildMode { + if (kDebugMode) return 'debug'; + if (kProfileMode) return 'profile'; + return 'release'; + } + + Future _fetchVersion() async { try { final info = await PackageInfo.fromPlatform(); - version = '${info.version}+${info.buildNumber}'; - } catch (_) {} + return '${info.version}+${info.buildNumber}'; + } catch (_) { + return 'unknown'; + } + } + + Future _buildReport() async { + final version = await _fetchVersion(); final platform = '${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; final gitLine = gitHash.isNotEmpty ? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n' : ''; + final timestamp = DateTime.now().toUtc().toIso8601String(); return 'App Version: $version\n' + 'Build Mode: $_buildMode\n' '$gitLine' - 'Platform: $platform\n\n' + 'Platform: $platform\n' + 'Dart: ${Platform.version}\n' + 'Timestamp: $timestamp\n\n' 'Error:\n```\n$exception\n```\n\n' 'Stack Trace:\n```\n$stackTrace\n```'; } @@ -56,6 +72,18 @@ class CrashScreen extends StatelessWidget { style: Theme.of(ctx).textTheme.titleMedium, textAlign: TextAlign.center, ), + const SizedBox(height: 4), + FutureBuilder( + future: _fetchVersion(), + builder: (context, snapshot) => Text( + 'v${snapshot.data ?? '…'} • $_buildMode • ' + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ), if (gitHash.isNotEmpty) ...[ const SizedBox(height: 8), GestureDetector( diff --git a/test/widget/crash_screen_test.dart b/test/widget/crash_screen_test.dart index 80e5106..3925dbb 100644 --- a/test/widget/crash_screen_test.dart +++ b/test/widget/crash_screen_test.dart @@ -116,7 +116,10 @@ void main() { expect(clipboardText, isNotNull); expect(clipboardText, contains('App Version: 1.0.0+42')); + expect(clipboardText, contains('Build Mode:')); expect(clipboardText, contains('Platform:')); + expect(clipboardText, contains('Dart:')); + expect(clipboardText, contains('Timestamp:')); expect(clipboardText, contains('TestException: clipboard test')); // GIT_HASH is empty in test builds — no Git Commit line expected expect(clipboardText, isNot(contains('Git Commit:'))); @@ -167,6 +170,35 @@ void main() { }, ); + testWidgets( + 'CrashScreen shows version, build mode, and platform in the UI', + (tester) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + const exception = 'TestException: info row test'; + final stackTrace = StackTrace.current; + + await tester.pumpWidget( + MaterialApp( + home: CrashScreen(exception: exception, stackTrace: stackTrace), + ), + ); + await tester.pumpAndSettle(); + + // Info row shows app version (from mock), build mode, and platform OS. + expect(find.textContaining('1.0.0+42'), findsWidgets); + // In test builds kDebugMode is true. + expect(find.textContaining('debug'), findsOneWidget); + // Platform OS is always present (linux in CI, android/ios on device). + expect( + find.textContaining(RegExp(r'linux|android|ios|windows|macos')), + findsWidgets, + ); + }, + ); + testWidgets( 'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash', (tester) async { -- 2.52.0 From 96b1660b59b430880518d6c7732142623a591325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 24 May 2026 16:35:10 +0200 Subject: [PATCH 012/227] feat: keep secrets in sync via age-encrypted master key (#208) (#223) --- .forgejo/Dockerfile | 1 + .forgejo/workflows/deploy.yml | 74 +++++++++------- .gitignore | 1 + DAGGER.md | 66 ++++++++++++++- Taskfile.yml | 7 +- ci/main.go | 22 ++++- flake.nix | 3 + scripts/secrets-decrypt.sh | 85 +++++++++++++++++++ scripts/secrets-encrypt.sh | 42 ++++++++++ scripts/test_secrets.sh | 153 ++++++++++++++++++++++++++++++++++ secrets.env.example | 28 +++++++ 11 files changed, 448 insertions(+), 34 deletions(-) create mode 100755 scripts/secrets-decrypt.sh create mode 100755 scripts/secrets-encrypt.sh create mode 100755 scripts/test_secrets.sh create mode 100644 secrets.env.example diff --git a/.forgejo/Dockerfile b/.forgejo/Dockerfile index 73d5916..fed065b 100644 --- a/.forgejo/Dockerfile +++ b/.forgejo/Dockerfile @@ -10,6 +10,7 @@ FROM ghcr.io/catthehacker/ubuntu:go-24.04 RUN apt-get update && apt-get install -y --no-install-recommends \ stunnel4 \ netcat-openbsd \ + age \ && rm -rf /var/lib/apt/lists/* # Dagger CLI — pinned to match the engine version on the runner host diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a7887b0..418db7a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -65,6 +65,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -75,11 +76,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Run Android Tests on Firebase Test Lab - if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Run Android Tests on Firebase Test Lab + if: env.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' env: - FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }} - FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} DAGGER_NO_NAG: "1" run: task test-android-firebase @@ -103,6 +108,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -113,12 +119,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Publish Android to Play Store - if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Publish Android to Play Store + if: env.PLAY_STORE_CONFIG_JSON != '' env: - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }} DAGGER_NO_NAG: "1" run: task publish-android @@ -142,6 +151,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -152,15 +162,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Build & Deploy APK to server - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Build & Deploy APK to server + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} DAGGER_NO_NAG: "1" run: task deploy-apk @@ -184,6 +194,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -194,13 +205,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Build & Deploy Linux to server - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Build & Deploy Linux to server + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" run: task deploy-linux @@ -226,6 +239,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -236,13 +250,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Generate build history and deploy website - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Generate build history and deploy website + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" run: task publish-website diff --git a/.gitignore b/.gitignore index de47e6c..7608eac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ assets/changelog.txt .env.local .envrc .direnv/ +secrets.env # plaintext secrets — encrypted version (secrets.age) is committed # --- Android --- android/.gradle/ diff --git a/DAGGER.md b/DAGGER.md index 5f7f3de..e17cea1 100644 --- a/DAGGER.md +++ b/DAGGER.md @@ -174,10 +174,70 @@ Run a secret manager co-located with the Dagger host. The CI job authenticates w - Vault itself becomes a security-critical single point of failure. - Operational overhead likely disproportionate for a small single-developer project. +### Option 5: Encrypted secrets file (age) — **implemented** + +Store all production secrets in a file (`secrets.env`) that is encrypted with +[age](https://age-encryption.org/) into `secrets.age`. The encrypted file is +committed to the repository. Only the age private key — a single string — is +stored in Codeberg as `SECRETS_AGE_KEY`. Any CI job or developer with the key +can decrypt the file and obtain all secrets. + +**How it works:** + +1. Generate a key pair once: + ```bash + age-keygen -o ~/.config/age/sharedinbox.key + age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key + ``` +2. Copy `secrets.env.example` to `secrets.env`, fill in all values, then encrypt: + ```bash + scripts/secrets-encrypt.sh # reads public key from .age-public-key + git add secrets.age && git commit -m "chore: update encrypted secrets" + ``` +3. Add the private key content as `SECRETS_AGE_KEY` in Codeberg repository secrets. +4. CI jobs call `scripts/secrets-decrypt.sh` (with `SECRETS_AGE_KEY` set) before + any step that needs production credentials. The script writes each variable + to `$GITHUB_ENV` so subsequent steps see them automatically. + +**Keeping local and CI in sync:** +When you rotate a secret locally, update `secrets.env`, re-run +`scripts/secrets-encrypt.sh`, and commit the new `secrets.age`. CI will pick +up the fresh secrets on the next push — no manual CI variable updates needed. + +Multi-line values (SSH keys, certificates) must be stored as a single line +with `\n` escape sequences inside double quotes. Example: +``` +SSH_PRIVATE_KEY="
\n\n