From 84a2b8ed168c2aab70a1bd413d7fc08ae502c60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 14:35:33 +0200 Subject: [PATCH 01/10] chore(mobile): raise subagent step limits --- apps/mobile/.kilo/agent/mobile-e2e-verifier.md | 2 +- apps/mobile/.kilo/agent/mobile-implementer.md | 2 +- apps/mobile/.kilo/agent/mobile-reviewer.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md index 5e2a264ffd..50f3ad03e0 100644 --- a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md +++ b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md @@ -2,7 +2,7 @@ description: Verifies an approved mobile-app change end to end against local services, simulator, CLI, and related repositories mode: subagent model: kilo/kilo-auto/efficient -steps: 60 +steps: 100 permission: edit: deny external_directory: allow diff --git a/apps/mobile/.kilo/agent/mobile-implementer.md b/apps/mobile/.kilo/agent/mobile-implementer.md index 3659fecb25..163cbe9d67 100644 --- a/apps/mobile/.kilo/agent/mobile-implementer.md +++ b/apps/mobile/.kilo/agent/mobile-implementer.md @@ -2,7 +2,7 @@ description: Implements an approved mobile-app plan, including required changes in cloud services, shared packages, or sibling repositories mode: subagent model: kilo/kilo-auto/efficient -steps: 50 +steps: 80 permission: edit: allow external_directory: allow diff --git a/apps/mobile/.kilo/agent/mobile-reviewer.md b/apps/mobile/.kilo/agent/mobile-reviewer.md index d6ad444b4f..7ea057bdca 100644 --- a/apps/mobile/.kilo/agent/mobile-reviewer.md +++ b/apps/mobile/.kilo/agent/mobile-reviewer.md @@ -2,7 +2,7 @@ description: Reviews an implementation produced for an approved mobile-app plan, including cross-repository changes mode: subagent model: kilo/kilo-auto/efficient -steps: 35 +steps: 50 permission: edit: deny external_directory: allow From c15bd4347d22ccb5a32d69465cbe29eccaceaa59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 18:03:47 +0200 Subject: [PATCH 02/10] fix(mobile): harden isolated E2E workflow --- .../mobile/.kilo/agent/mobile-e2e-verifier.md | 9 +- apps/mobile/e2e/AGENTS.md | 53 ++++--- apps/mobile/e2e/flows/login-request-code.yaml | 26 +--- apps/mobile/e2e/flows/login-verify-code.yaml | 7 +- apps/mobile/e2e/flows/logout.yaml | 8 +- apps/mobile/e2e/flows/open-app.yaml | 38 +++++ apps/mobile/e2e/login.sh | 10 +- apps/mobile/e2e/logout.sh | 1 + apps/mobile/e2e/preflight.sh | 80 ++++++++++ dev/local/cli.ts | 94 +++++++++++- dev/local/env-sync/output.ts | 19 +-- dev/local/env-sync/plan.test.ts | 35 ++++- dev/local/env-sync/plan.ts | 9 +- dev/local/mobile-android.ts | 143 ++++++++++++++++++ dev/local/mobile-env.test.ts | 76 +++++++++- dev/local/mobile-env.ts | 74 ++++++++- dev/local/mobile-simulator.test.ts | 41 +++++ dev/local/mobile-simulator.ts | 120 +++++++++++++++ dev/local/mobile-workflow.test.ts | 73 +++++++++ dev/local/runner.ts | 15 +- dev/local/services.test.ts | 34 ++++- dev/local/services.ts | 33 ++-- dev/local/tmux.test.ts | 46 +++++- dev/local/tmux.ts | 28 ++++ package.json | 3 + scripts/worktree-prepare.sh | 16 ++ 26 files changed, 965 insertions(+), 126 deletions(-) create mode 100644 apps/mobile/e2e/flows/open-app.yaml create mode 100755 apps/mobile/e2e/preflight.sh create mode 100644 dev/local/mobile-android.ts create mode 100644 dev/local/mobile-simulator.test.ts create mode 100644 dev/local/mobile-simulator.ts create mode 100644 dev/local/mobile-workflow.test.ts diff --git a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md index 50f3ad03e0..e4580d0f09 100644 --- a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md +++ b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md @@ -1,5 +1,5 @@ --- -description: Verifies an approved mobile-app change end to end against local services, simulator, CLI, and related repositories +description: Verifies an approved mobile change end to end mode: subagent model: kilo/kilo-auto/efficient steps: 100 @@ -21,12 +21,13 @@ Before testing: 1. Read `apps/mobile/e2e/AGENTS.md` and all instructions it references. 2. Translate the orchestrator's acceptance criteria into observable happy, retryable unhappy, non-retryable unhappy, and empty flows for every new user-facing feature. -3. Record pre-existing services, simulators, and tmux sessions so cleanup only removes resources you create. +3. Record pre-existing services, simulators, and tmux sessions so cleanup only removes resources you create. Claim a simulator with `pnpm dev:mobile:simulator claim`; never use one claimed by another worktree. During verification: -- Verify the app is running the bundle and services from the intended worktree. -- Inspect the current screen before selecting Maestro elements and re-inspect after UI changes. +- Run the login/logout helper preflight rather than manually inferring bundle provenance, ports, or simulator ownership. +- Run `pnpm dev:mobile:android doctor` before declaring Android tooling unavailable; do not rely on the inherited `PATH`. +- Inspect the current screen before selecting Maestro elements and re-inspect after UI changes. Copy exact hierarchy text; never infer selectors from screenshots or visible tab captions. - Exercise every applicable feature state that can be produced safely and deterministically. A skipped state requires an explicit rationale; do not silently omit it. - Confirm retryable and empty states show a meaningful message and actionable CTA, and that the CTA performs the expected recovery or next step. - Confirm non-retryable states show a meaningful message with no CTA at all. diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index 7b0bc49803..34401e6dcf 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -4,7 +4,7 @@ Use this runbook for interactive verification against a local backend. Commands ## Fresh Worktree Quickstart -If dependencies or local env files are missing, run this once: +If dependencies or local env files are missing, run this once. This also authorizes both worktree `.envrc` files and copies local env files from the primary checkout: ```bash node --version # must be v24; activate the root .nvmrc first if needed @@ -21,34 +21,32 @@ xcrun simctl list devices booted Reuse a complete stack already running for this worktree. Do not start a competing stack or stop an unrelated `kilo-dev-*` session. -When this worktree has no stack, start the complete mobile flow with worktree-safe ports: +When this worktree has no stack, start the complete mobile flow. Secondary worktrees automatically receive an isolated port offset, and mobile startup generates and explicitly injects this worktree's LAN URLs before Metro starts. Do not export `KILO_PORT_OFFSET` or source `apps/mobile/.env`; stale parent-shell values must not select the bundle endpoints: ```bash -export KILO_PORT_OFFSET=auto pnpm dev:env -y cloudflare-session-ingest pnpm dev:start --no-attach mobile cloud-agent-next kiloclaw event-service pnpm drizzle migrate -pnpm dev:env:mobile -pnpm dev:restart nextjs -pnpm dev:restart mobile -sleep 3 pnpm dev:status --json ``` `dev:env:mobile` writes this stack's LAN URLs. Restart Next.js for native auth callbacks and Metro for bundled Expo env values. The session-ingest env step creates the JWT Secrets Store binding; without it the worker can appear healthy while rejecting every session request. `event-service` is required for presence and notification behavior. +Secrets Store state is local to each Worker directory. `dev:start` runs env sync for its selected service graph and refreshes every source-backed secret before launching Workers; secret creation failures are fatal. Do not run bare `wrangler secrets-store` commands: use `pnpm dev:env -y ` from the repository root so values come from the canonical local source and Wrangler receives a complete non-interactive prompt. + Confirm `mobile`, `nextjs`, `cloudflare-session-ingest`, `cloud-agent-next`, `kiloclaw`, and `event-service` are `up`. Restarts are asynchronous; if `mobile` is still starting, inspect its log and rerun status instead of restarting the stack. Use reported ports; never assume defaults in a secondary worktree. ## Logs and tmux -Prefer the stable log files: +Prefer the stable log files. When pane output is required, let the runner find the service wherever the dashboard moved it: ```bash pnpm dev:status --json tail -n 200 dev/logs/.log +pnpm dev:capture mobile ``` -Use tmux when you need live pane state or an interactive process: +Do not guess a tmux window from `tmux ls` or use `:` directly; a service pane can be joined into the dashboard and no longer have a same-named window. Use `pnpm dev:capture ` for inspection. Use raw tmux only for an interactive process after reading the exact `session` from `pnpm dev:status --json` and resolving the pane with `tmux list-panes -a`. ```bash tmux ls @@ -60,13 +58,10 @@ Put any extra long-lived CLI, recorder, or log follower in a clearly named `kilo ## iOS Simulator -Reuse a booted simulator when possible, but always replace any installed Kilo app when validating a fresh worktree. This avoids stale native builds, most app data, and saved Metro URLs. iOS Keychain credentials can survive uninstall; the login helper handles an existing session. Otherwise boot one explicitly: +Never share a simulator with another worktree. Claim one before any build, install, login, Maestro, or MCP action; the command prefers an unclaimed shutdown device and boots it. Pass a UDID only when intentionally claiming that device: ```bash -xcrun simctl list devices available -xcrun simctl boot -open -a Simulator -xcrun simctl bootstatus -b +pnpm dev:mobile:simulator claim [udid] ``` Uninstall Kilo, then build from this worktree in a dedicated tmux session. A fresh checkout generates the gitignored `ios/` directory and installs pods, so the first build takes a few minutes and is noisy: @@ -93,14 +88,16 @@ Before testing, capture the `mobile` pane and verify `Starting project at [email] # default: e2e-mobile@example.com apps/mobile/e2e/logout.sh ``` -Login requests an email OTP, reads it from `dev/logs/emails/`, verifies it, and accepts first-account consent. Re-run login if a dev-build cold launch loses the session. +Login requests an email OTP, waits up to 30 seconds for the worktree-local outbox, verifies it, accepts first-account consent, and asserts Home. It retries only the known dev-client launch/request boundary once. Login and logout share `flows/open-app.yaml`, which waits for late tracking and Expo developer-menu prompts before continuing. + +Native prompts are states in the flow, not errors to tap through blindly. The shared launch flow recognizes the iOS tracking prompt (`Allow “Kilo” to track your activity across other companies’ apps and websites?`) and chooses `Ask App Not to Track`; login handles notification permission after authentication. Feature-triggered prompts such as speech recognition and microphone access must be handled only when the acceptance flow reaches that feature: inspect the hierarchy, copy the exact button accessibility text (`Allow` or `Don’t Allow`), and choose the state required by the test. Never use a generic `tapOn: 'Allow'` before identifying which prompt is visible. Maestro can emit a large interactive transcript. For agent-driven runs, keep successful output out of context and show only a bounded failure tail: @@ -132,7 +129,9 @@ One-time machine setup: brew install maestro ``` -For MCP use stdio command `maestro mcp`, then restart the agent session so its tools appear. Inspect the screen before selecting elements and re-inspect after UI changes. Flows use `appId: com.kilocode.kiloapp`; accessibility labels match with `text:`, not `id:`. +For MCP use stdio command `maestro mcp`, then restart the agent session so its tools appear. Inspect the screen before selecting elements and re-inspect after UI changes. Never guess a selector from the visible label or screenshot: copy the exact `txt` or `a11y` value from `maestro_inspect_screen`, mapping `a11y` to Maestro `text:`. Maestro text matching is full-string regex, not substring matching. + +Tab buttons are exposed through React Navigation's full accessibility labels, not the visible uppercase text. Current iOS labels are `Home, tab, 1 of 4`, `KiloClaw, tab, 2 of 4`, `Agents, tab, 3 of 4`, and `Profile, tab, 4 of 4`. `tapOn: 'Agents'` is wrong. Inspect again before using these examples because tab count or labels can change. CLI fallback: @@ -184,16 +183,21 @@ The mobile list updates after the CLI WebSocket connects and its first heartbeat ## Android Emulator -Follow the [Expo environment guide](https://docs.expo.dev/get-started/set-up-your-environment/) for the one-time Android SDK/JDK setup. This project needs JDK 17. With an AVD already created, keep the emulator in its own tmux session: +Do not conclude that Android is unavailable from `command -v adb` or the agent's inherited `PATH`. The repository resolves the SDK and JDK 17 from `ANDROID_HOME`, `~/Library/Android/sdk`, and standard Homebrew locations. Run the doctor first; it prints resolved absolute paths and available AVDs: + +```bash +pnpm dev:mobile:android doctor +``` + +Use the wrappers for all Android tooling so the resolved SDK/JDK environment is applied: ```bash -export JAVA_HOME=$(/usr/libexec/java_home -v 17) ANDROID_SESSION="kilo-e2e-android-$(basename "$PWD")" -tmux new-session -d -s "$ANDROID_SESSION" \ - "JAVA_HOME=$JAVA_HOME emulator -avd -no-snapshot-save -no-boot-anim -gpu swiftshader_indirect" -adb wait-for-device -adb reverse tcp: tcp: -adb reverse tcp: tcp: +tmux new-session -d -s "$ANDROID_SESSION" -c "$PWD" \ + "pnpm dev:mobile:android emulator -avd -no-snapshot-save -no-boot-anim -gpu swiftshader_indirect" +pnpm dev:mobile:android adb wait-for-device +pnpm dev:mobile:android adb reverse tcp: tcp: +pnpm dev:mobile:android adb reverse tcp: tcp: cd apps/mobile npx expo run:android --no-install --no-bundler ``` @@ -212,6 +216,7 @@ rm -rf "$CLI_SCRATCH" # if created rm -f "$LOGIN_LOG" # if created pnpm dev:stop # only if you started this worktree's stack xcrun simctl shutdown # only if you booted it +pnpm dev:mobile:simulator release # release every simulator you claimed ``` Also stop recorders, log followers, and emulator processes you created. Never use `tmux kill-server`, kill unrelated `kilo-dev-*` sessions, stop a simulator that was already booted, or use `pnpm dev:stop --force` while sibling worktrees are active. diff --git a/apps/mobile/e2e/flows/login-request-code.yaml b/apps/mobile/e2e/flows/login-request-code.yaml index 8d6f683a53..6012e5c610 100644 --- a/apps/mobile/e2e/flows/login-request-code.yaml +++ b/apps/mobile/e2e/flows/login-request-code.yaml @@ -14,33 +14,9 @@ # code for you. See e2e/AGENTS.md ("Login / logout helper flows"). appId: com.kilocode.kiloapp --- -# A clean iOS install can put system and Expo onboarding over the app. Clear -# both before returning to SpringBoard; otherwise the Kilo icon is not tappable. -- runFlow: - when: - visible: 'Ask App Not to Track' - commands: - - tapOn: 'Ask App Not to Track' -- runFlow: - when: - visible: 'This is the developer menu.*' - commands: - - tapOn: 'Continue' -- stopApp -- tapOn: - text: 'Kilo' +- runFlow: open-app.yaml # Three possible launch states: Home (signed in), the consent gate (signed in, # not yet consented), or the login page (signed out). -- extendedWaitUntil: - visible: - text: 'HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' - timeout: 30000 -# Grant the notification permission prompt if iOS shows one on first launch. -- runFlow: - when: - visible: 'Allow' - commands: - - tapOn: 'Allow' # Booted into the consent gate (already signed in) -> accept it. - runFlow: when: diff --git a/apps/mobile/e2e/flows/login-verify-code.yaml b/apps/mobile/e2e/flows/login-verify-code.yaml index 15c8cf43a1..4d9faef73e 100644 --- a/apps/mobile/e2e/flows/login-verify-code.yaml +++ b/apps/mobile/e2e/flows/login-verify-code.yaml @@ -24,12 +24,7 @@ appId: com.kilocode.kiloapp visible: 'Accept and continue' commands: - tapOn: 'Accept and continue' -# Grant the notification permission prompt if iOS shows one. -- runFlow: - when: - visible: 'Allow' - commands: - - tapOn: 'Allow' +- runFlow: open-app.yaml - extendedWaitUntil: visible: 'HOME|Home, tab, 1 of 4' timeout: 15000 diff --git a/apps/mobile/e2e/flows/logout.yaml b/apps/mobile/e2e/flows/logout.yaml index 80a317d47c..6bb086ece0 100644 --- a/apps/mobile/e2e/flows/logout.yaml +++ b/apps/mobile/e2e/flows/logout.yaml @@ -11,13 +11,7 @@ # - See e2e/AGENTS.md ("Login / logout helper flows") for the full workflow. appId: com.kilocode.kiloapp --- -- stopApp -- tapOn: - text: 'Kilo' -- extendedWaitUntil: - visible: - text: 'Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' - timeout: 30000 +- runFlow: open-app.yaml # Booted into the consent gate (signed in, not consented) -> accept it to reach # Home, then sign out below. - runFlow: diff --git a/apps/mobile/e2e/flows/open-app.yaml b/apps/mobile/e2e/flows/open-app.yaml new file mode 100644 index 0000000000..3ba3752e6f --- /dev/null +++ b/apps/mobile/e2e/flows/open-app.yaml @@ -0,0 +1,38 @@ +appId: com.kilocode.kiloapp +--- +- stopApp +- tapOn: + text: 'Kilo' +- extendedWaitUntil: + visible: 'Allow “Kilo” to track your activity across other companies’ apps and websites\?|Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 30000 +- extendedWaitUntil: + visible: 'Ask App Not to Track' + timeout: 15000 + optional: true +- runFlow: + when: + visible: 'Ask App Not to Track' + commands: + - tapOn: 'Ask App Not to Track' +- extendedWaitUntil: + visible: 'This is the developer menu.*' + timeout: 15000 + optional: true +- runFlow: + when: + visible: 'This is the developer menu.*' + commands: + - tapOn: 'Continue' +- extendedWaitUntil: + visible: '“Kilo” Would Like to Send You Notifications' + timeout: 5000 + optional: true +- runFlow: + when: + visible: '“Kilo” Would Like to Send You Notifications' + commands: + - tapOn: 'Allow' +- extendedWaitUntil: + visible: 'HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 30000 diff --git a/apps/mobile/e2e/login.sh b/apps/mobile/e2e/login.sh index e51b12463a..62b639c5f5 100755 --- a/apps/mobile/e2e/login.sh +++ b/apps/mobile/e2e/login.sh @@ -21,6 +21,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" OUTBOX="${OUTBOX:-$REPO_ROOT/dev/logs/emails}" +"$SCRIPT_DIR/preflight.sh" "$DEVICE" + # Newest sign-in-code email for EMAIL, or empty. latest_email() { [ -d "$OUTBOX" ] || return 0 @@ -39,11 +41,14 @@ latest_email() { before="$(latest_email)" echo "==> requesting sign-in code for $EMAIL" -maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml" +if ! maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml"; then + echo "==> retrying launch and sign-in request once after a dev-client startup failure" + maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml" +fi # Wait for a newer outbox email than we had before (the send is async). code="" -for _ in $(seq 1 10); do +for _ in $(seq 1 30); do after="$(latest_email)" if [ -n "$after" ] && [ "$after" != "$before" ]; then code="$(perl -0777 -ne 'print $1 if /letter-spacing:\s*8px.*?>\s*(\d{6})\s* verifying sign-in code" maestro --device "$DEVICE" test -e "OTP=$code" "$SCRIPT_DIR/flows/login-verify-code.yaml" +maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/login-assert-home.yaml" echo "==> signed in" diff --git a/apps/mobile/e2e/logout.sh b/apps/mobile/e2e/logout.sh index 7627cfd191..b85a405bed 100755 --- a/apps/mobile/e2e/logout.sh +++ b/apps/mobile/e2e/logout.sh @@ -11,4 +11,5 @@ set -euo pipefail DEVICE="${1:?usage: logout.sh }" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$SCRIPT_DIR/preflight.sh" "$DEVICE" maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/logout.yaml" diff --git a/apps/mobile/e2e/preflight.sh b/apps/mobile/e2e/preflight.sh new file mode 100755 index 0000000000..856ec42886 --- /dev/null +++ b/apps/mobile/e2e/preflight.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE="${1:?usage: preflight.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" + +STATUS="$(cd "$REPO_ROOT" && pnpm -s dev:status --json)" +node - "$STATUS" "$REPO_ROOT" <<'NODE' +const [statusJson, expectedRoot] = process.argv.slice(2); +const status = JSON.parse(statusJson); +const required = ['mobile', 'nextjs', 'cloudflare-session-ingest']; +const failed = required.filter(name => status.services.find(service => service.name === name)?.status !== 'up'); +if (failed.length) throw new Error(`Required services are not up in ${expectedRoot}: ${failed.join(', ')}`); +NODE + +SESSION_INGEST_PORT="$(node - "$STATUS" <<'NODE' +const status = JSON.parse(process.argv[2]); +const port = status.services.find(service => service.name === 'cloudflare-session-ingest')?.port; +if (!port) throw new Error('session-ingest port missing'); +process.stdout.write(String(port)); +NODE +)" +SESSION_PROBE="$(mktemp)" +trap 'rm -f "$SESSION_PROBE"' EXIT +SESSION_STATUS="$(curl -sS -o "$SESSION_PROBE" -w '%{http_code}' \ + -H 'Authorization: Bearer invalid' "http://127.0.0.1:${SESSION_INGEST_PORT}/api/sessions")" +if [ "$SESSION_STATUS" != "401" ] || ! grep -Fq 'Invalid or expired token' "$SESSION_PROBE"; then + printf 'session-ingest secret readiness probe failed with HTTP %s\n' "$SESSION_STATUS" >&2 + cat "$SESSION_PROBE" >&2 + exit 1 +fi + +CLAIM="$(cd "$REPO_ROOT" && pnpm -s dev:mobile:simulator claim "$DEVICE")" +node - "$CLAIM" "$REPO_ROOT" <<'NODE' +const [claimOutput, expectedRoot] = process.argv.slice(2); +const claim = JSON.parse(claimOutput.trim().split('\n').at(-1)); +if (claim.worktreeRoot !== expectedRoot) { + throw new Error(`Simulator belongs to ${claim.worktreeRoot}, expected ${expectedRoot}`); +} +NODE + +MOBILE_LOG="$(cd "$REPO_ROOT" && pnpm -s dev:capture mobile 300)" +if ! grep -Fq "Starting project at $REPO_ROOT/apps/mobile" <<<"$MOBILE_LOG"; then + printf 'Metro does not belong to this worktree: %s\n' "$REPO_ROOT" >&2 + exit 1 +fi +if ! grep -Eq 'iOS Bundled|Starting Metro Bundler' <<<"$MOBILE_LOG"; then + printf 'Metro has not reached a usable state for %s\n' "$REPO_ROOT" >&2 + exit 1 +fi + +EXPECTED_API_PORT="$(node - "$STATUS" <<'NODE' +const status = JSON.parse(process.argv[2]); +const port = status.services.find(service => service.name === 'nextjs')?.port; +if (!port) throw new Error('nextjs port missing'); +process.stdout.write(String(port)); +NODE +)" +if ! grep -Eq "^API_BASE_URL=http://[^:]+:${EXPECTED_API_PORT}$" "$REPO_ROOT/apps/mobile/.env.local"; then + printf 'apps/mobile/.env.local does not target this worktree nextjs port %s\n' "$EXPECTED_API_PORT" >&2 + exit 1 +fi + +MOBILE_HOST="$(perl -ne 'print $1 if /^API_BASE_URL=http:\/\/([^:]+):/' "$REPO_ROOT/apps/mobile/.env.local")" +METRO_PORT="$(node - "$STATUS" <<'NODE' +const status = JSON.parse(process.argv[2]); +const port = status.services.find(service => service.name === 'mobile')?.port; +if (!port) throw new Error('mobile port missing'); +process.stdout.write(String(port)); +NODE +)" +METRO_URL="http://${MOBILE_HOST}:${METRO_PORT}" +ENCODED_METRO_URL="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$METRO_URL")" +if xcrun simctl list devices available | grep -Fq "$DEVICE"; then + xcrun simctl openurl "$DEVICE" \ + "exp+kilo-app://expo-development-client/?url=${ENCODED_METRO_URL}" +fi + +echo "Mobile E2E preflight passed for $DEVICE in $REPO_ROOT ($METRO_URL)" diff --git a/dev/local/cli.ts b/dev/local/cli.ts index ef372da5b2..f5d075ec67 100644 --- a/dev/local/cli.ts +++ b/dev/local/cli.ts @@ -20,6 +20,7 @@ import { sessionExists, findOtherKiloDevSessions, createSession, + setSessionEnvironment, killSession, attachSession, sendKeys, @@ -34,7 +35,9 @@ import { isTmuxAvailable, findServicePane, isPaneRunningCommand, + captureServicePane, } from './tmux'; +import { detectLanIp, prepareMobileEnvironment } from './mobile-env'; import { findRepoRoot, startServiceInTmux, @@ -127,9 +130,50 @@ async function cmdUp(args: string[], repoRoot: string): Promise { console.log(`${DIM}Port offset: ${portOffset} (KILO_PORT_OFFSET)${RESET}`); } + // --- Resolve targets --- + // Always start core (always-on) groups; additional targets are merged in + const coreServices = resolveGroups(getAlwaysOnGroupIds()); + const extraServices = targets.length === 0 ? [] : resolveTargets(targets); + let serviceNames = topologicalSort([...new Set([...coreServices, ...extraServices])]); + + const mobileEnv: Record = {}; + if (serviceNames.includes('mobile')) { + const host = process.env.MOBILE_DEV_HOST || detectLanIp(); + if (!host) { + throw new Error('Could not detect LAN IP. Set MOBILE_DEV_HOST explicitly.'); + } + Object.assign(mobileEnv, prepareMobileEnvironment(repoRoot, host).sessionEnv); + } + + const envResult = await syncEnvVars({ repoRoot, yes: true, targets: serviceNames }); + if (!envResult.ok) { + throw new Error('Failed to prepare required local service environment'); + } + // --- Check for existing session --- const sessionName = getSessionName(); if (sessionExists(sessionName)) { + for (const name of serviceNames) { + const service = getService(name); + if (service.type !== 'worker' || !findServicePane(sessionName, name)) continue; + const outcome = await restartServiceInTmux(sessionName, name); + if (outcome === 'gave-up') throw new Error(`${name} did not restart with refreshed secrets`); + } + if (Object.keys(mobileEnv).length > 0) { + setSessionEnvironment(sessionName, mobileEnv); + const nextjsPane = findServicePane(sessionName, 'nextjs'); + if (nextjsPane) { + const outcome = await restartServiceInTmux(sessionName, 'nextjs'); + if (outcome === 'gave-up') + throw new Error('nextjs did not restart with refreshed mobile URLs'); + } + const mobilePane = findServicePane(sessionName, 'mobile'); + if (mobilePane) { + const outcome = await restartServiceInTmux(sessionName, 'mobile', mobileEnv); + if (outcome === 'gave-up') + throw new Error('mobile did not restart with refreshed mobile URLs'); + } + } console.log( noAttach ? `Session ${sessionName} already running.` @@ -139,11 +183,19 @@ async function cmdUp(args: string[], repoRoot: string): Promise { return; } - // --- Resolve targets --- - // Always start core (always-on) groups; additional targets are merged in - const coreServices = resolveGroups(getAlwaysOnGroupIds()); - const extraServices = targets.length === 0 ? [] : resolveTargets(targets); - let serviceNames = topologicalSort([...new Set([...coreServices, ...extraServices])]); + const conflictingPorts: string[] = []; + for (const name of serviceNames) { + const service = getService(name); + if (service.type !== 'infra' && service.port > 0 && (await probePort(service.port))) { + conflictingPorts.push(`${name}:${service.port}`); + } + } + if (conflictingPorts.length > 0) { + throw new Error( + `Refusing to share occupied worktree service ports: ${conflictingPorts.join(', ')}. ` + + 'Stop the owning worktree or set a distinct KILO_PORT_OFFSET.' + ); + } // --- Check for socat when kiloclaw-docker-tcp is requested --- if (serviceNames.includes('kiloclaw-docker-tcp')) { @@ -209,6 +261,7 @@ async function cmdUp(args: string[], repoRoot: string): Promise { ? `${pnpmHome}${path.delimiter}${processPath}` : processPath; const sessionEnv: Record = { + ...mobileEnv, KILO_PORT_OFFSET: String(portOffset), PATH: sessionPath, WRANGLER_REGISTRY_PATH: wranglerRegistryPath, @@ -623,7 +676,7 @@ async function cmdStatus(repoRoot: string, isJson = false): Promise { } } -async function cmdRestart(serviceName: string): Promise { +async function cmdRestart(serviceName: string, repoRoot: string): Promise { if (!services.has(serviceName)) { console.error(`Unknown service: ${serviceName}`); process.exit(1); @@ -647,8 +700,16 @@ async function cmdRestart(serviceName: string): Promise { process.exit(1); } + let restartEnv: Record | undefined; + if (serviceName === 'mobile') { + const host = process.env.MOBILE_DEV_HOST || detectLanIp(); + if (!host) throw new Error('Could not detect LAN IP. Set MOBILE_DEV_HOST explicitly.'); + restartEnv = prepareMobileEnvironment(repoRoot, host).sessionEnv; + setSessionEnvironment(sessionName, restartEnv); + } + console.log(`Restarting ${serviceName} (waiting for the old process to shut down)...`); - const outcome = await restartServiceInTmux(sessionName, serviceName); + const outcome = await restartServiceInTmux(sessionName, serviceName, restartEnv); if (outcome === 'gave-up') { console.error(`${serviceName} did not shut down in time; not relaunched`); process.exit(1); @@ -656,6 +717,15 @@ async function cmdRestart(serviceName: string): Promise { console.log(`Restarted ${serviceName}`); } +function cmdCapture(serviceName: string, linesArg: string | undefined): void { + if (!services.has(serviceName)) throw new Error(`Unknown service: ${serviceName}`); + const lines = linesArg === undefined ? 200 : Number(linesArg); + if (!Number.isInteger(lines) || lines < 1 || lines > 10_000) { + throw new Error(`Invalid line count: ${linesArg}`); + } + process.stdout.write(captureServicePane(getSessionName(), serviceName, lines)); +} + async function cmdStop(repoRoot: string, force: boolean): Promise { const sessionName = getSessionName(); @@ -717,6 +787,8 @@ Usage: other kilo-dev sessions are running; --force overrides) dev:status [--json] Show running services and their ports dev:restart Restart a running service + dev:capture [lines] + Capture a service pane wherever the dashboard moved it dev:env [targets...] Sync env vars (.dev.vars + .env.development.local) dev:env --check Validate env vars (CI mode) dev:env -y Sync without confirmation @@ -750,7 +822,13 @@ async function main() { console.error('Usage: dev:restart '); process.exit(1); } - await cmdRestart(serviceName); + await cmdRestart(serviceName, repoRoot); + break; + } + case 'capture': { + const serviceName = args[1]; + if (!serviceName) throw new Error('Usage: dev:capture [lines]'); + cmdCapture(serviceName, args[2]); break; } case 'env': diff --git a/dev/local/env-sync/output.ts b/dev/local/env-sync/output.ts index 0b586f61ee..7551a90ab9 100644 --- a/dev/local/env-sync/output.ts +++ b/dev/local/env-sync/output.ts @@ -267,7 +267,7 @@ function createSecretsStoreSecret( storeId: string, secretName: string, value: string -): boolean { +): void { const result = spawnSync( 'pnpm', [ @@ -284,11 +284,16 @@ function createSecretsStoreSecret( { cwd: path.join(repoRoot, workerDir), encoding: 'utf-8', - input: value, // Pass value via stdin for security + input: `${value}\n`, // Complete Wrangler's prompt without exposing the value in argv. stdio: ['pipe', 'pipe', 'pipe'], } ); - return result.status === 0; + if (result.status !== 0) { + const errorOutput = result.stderr.trim(); + throw new Error( + `Failed to create Secrets Store secret ${secretName}${errorOutput ? `: ${errorOutput}` : ''}` + ); + } } function applySecretsStoreAutoCreates(creates: SecretStoreAutoCreate[], repoRoot: string): void { @@ -296,18 +301,14 @@ function applySecretsStoreAutoCreates(creates: SecretStoreAutoCreate[], repoRoot console.log('\nCreating secrets store secrets...'); for (const create of creates) { - const success = createSecretsStoreSecret( + createSecretsStoreSecret( repoRoot, create.workerDir, create.binding.store_id, create.binding.secret_name, create.value ); - if (success) { - console.log(` ✓ ${create.binding.secret_name}`); - } else { - console.error(` ✗ ${create.binding.secret_name} (failed)`); - } + console.log(` ✓ ${create.binding.secret_name}`); } } diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index 08caef9bda..5f3e4b0438 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -544,7 +544,7 @@ test('rejects reserved generated-secret metadata in source Wrangler config', () } }); -test('preserves an existing annotated local Secrets Store secret', () => { +test('preserves an existing generated local Secrets Store secret', () => { const repo = createGitTokenServiceRepo({}); try { withFakePnpm('SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV\n', () => { @@ -557,6 +557,29 @@ test('preserves an existing annotated local Secrets Store secret', () => { } }); +test('refreshes an existing source-backed local Secrets Store secret', () => { + const repo = createRepo({ + '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', + 'services/event-service/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/event-service/wrangler.jsonc': `{ + "secrets_store_secrets": [{ + "binding": "NEXTAUTH_SECRET", + "store_id": "store-id", + "secret_name": "NEXTAUTH_SECRET_PROD" + }] + }`, + }); + try { + withFakePnpm('NEXTAUTH_SECRET_PROD\n', () => { + const plan = computePlan(repo.root, new Set(['event-service'])); + assert.equal(plan.secretStoreAutoCreates.length, 1); + assert.equal(plan.secretStoreAutoCreates[0]?.value, 'local-nextauth-secret'); + }); + } finally { + repo.cleanup(); + } +}); + test('auto-creates event-service NEXTAUTH Secrets Store binding from .env.local', () => { const repo = createRepo({ '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', @@ -618,7 +641,11 @@ test('auto-creates kilo-chat gateway Secrets Store binding from kiloclaw dev var const plan = computePlan(repo.root, new Set(['kilo-chat'])); assert.equal(plan.missingEnvLocal, false); assert.deepEqual(plan.secretStoreWarnings, []); - assert.deepEqual(plan.secretStoreAutoCreates, [ + assert.equal(plan.secretStoreAutoCreates.length, 2); + assert.deepEqual( + plan.secretStoreAutoCreates.find( + create => create.binding.secret_name === 'GATEWAY_TOKEN_SECRET' + ), { workerDir: 'services/kilo-chat', binding: { @@ -628,8 +655,8 @@ test('auto-creates kilo-chat gateway Secrets Store binding from kiloclaw dev var }, sourceKey: 'services/kiloclaw/.dev.vars.example:GATEWAY_TOKEN_SECRET', value: 'dev-gateway-secret-kiloclaw', - }, - ]); + } + ); }); } finally { repo.cleanup(); diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index aea3bfdc4d..572037b7b2 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -734,11 +734,8 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan storeOutputCache.set(cacheKey, output); } - if (output.includes(b.secret_name)) { - continue; // Secret exists, nothing to do - } - if (b.devGeneratedBase64Bytes) { + if (output.includes(b.secret_name)) continue; secretStoreAutoCreates.push({ workerDir: svc.dir, binding: b, @@ -750,14 +747,14 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan const source = resolveSecretStoreSource(b.secret_name, envLocal, localSecretSources); if (source) { - // Can auto-create from .env.local or another local worker's dev vars. + // Recreate source-backed secrets so metadata-only entries and stale values self-heal. secretStoreAutoCreates.push({ workerDir: svc.dir, binding: b, sourceKey: source.sourceKey, value: source.value, }); - } else { + } else if (!output.includes(b.secret_name)) { // Missing and no source value - warn missingBindings.push(b); } diff --git a/dev/local/mobile-android.ts b/dev/local/mobile-android.ts new file mode 100644 index 0000000000..864914adfa --- /dev/null +++ b/dev/local/mobile-android.ts @@ -0,0 +1,143 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +type AndroidEnvironment = { + adb: string; + emulator: string; + javaHome: string; + path: string; + sdkRoot: string; + sdkmanager: string; +}; + +type ResolveArgs = { + home: string; + path: string; + existingPaths?: ReadonlySet; +}; + +function firstExisting( + candidates: string[], + exists: (candidate: string) => boolean +): string | undefined { + return candidates.find(exists); +} + +function resolveAndroidEnvironment(args: ResolveArgs): AndroidEnvironment { + const exists = args.existingPaths + ? (candidate: string) => args.existingPaths!.has(candidate) + : fs.existsSync; + const sdkRoots = [ + process.env.ANDROID_HOME, + process.env.ANDROID_SDK_ROOT, + path.join(args.home, 'Library/Android/sdk'), + '/opt/homebrew/share/android-commandlinetools', + '/usr/local/share/android-commandlinetools', + ].filter((value): value is string => Boolean(value)); + const sdkRoot = sdkRoots.find(root => + firstExisting( + [path.join(root, 'platform-tools/adb'), path.join(root, 'emulator/emulator')], + exists + ) + ); + if (!sdkRoot) { + throw new Error( + 'Android SDK not found in ANDROID_HOME, ~/Library/Android/sdk, or Homebrew android-commandlinetools. Run: brew install --cask android-commandlinetools' + ); + } + + const adb = firstExisting([path.join(sdkRoot, 'platform-tools/adb')], exists); + const emulator = firstExisting([path.join(sdkRoot, 'emulator/emulator')], exists); + const sdkmanager = firstExisting( + [ + path.join(sdkRoot, 'cmdline-tools/latest/bin/sdkmanager'), + '/opt/homebrew/bin/sdkmanager', + '/usr/local/bin/sdkmanager', + ], + exists + ); + const javaHomes = [ + process.env.JAVA_HOME, + '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home', + '/opt/homebrew/opt/temurin@17/libexec/openjdk.jdk/Contents/Home', + ].filter((value): value is string => Boolean(value)); + const javaHome = javaHomes.find(candidate => exists(path.join(candidate, 'bin/java'))); + + const missing = [ + !adb && 'adb', + !emulator && 'emulator', + !sdkmanager && 'sdkmanager', + !javaHome && 'JDK 17', + ].filter(Boolean); + if (missing.length > 0) { + throw new Error(`Android tooling incomplete: missing ${missing.join(', ')}`); + } + + const toolPaths = [ + path.join(sdkRoot, 'platform-tools'), + path.join(sdkRoot, 'emulator'), + path.dirname(sdkmanager), + path.join(javaHome, 'bin'), + ]; + return { + adb, + emulator, + javaHome, + path: [...toolPaths, args.path].join(path.delimiter), + sdkRoot, + sdkmanager, + }; +} + +function environment(): AndroidEnvironment { + return resolveAndroidEnvironment({ home: os.homedir(), path: process.env.PATH ?? '' }); +} + +function run(command: string, args: string[], env: AndroidEnvironment): void { + execFileSync(command, args, { + stdio: 'inherit', + env: { + ...process.env, + ANDROID_HOME: env.sdkRoot, + ANDROID_SDK_ROOT: env.sdkRoot, + JAVA_HOME: env.javaHome, + PATH: env.path, + }, + }); +} + +function main(): void { + const [command, ...args] = process.argv.slice(2); + const env = environment(); + if (command === 'doctor' || command === undefined) { + const avds = execFileSync(env.emulator, ['-list-avds'], { + encoding: 'utf8', + env: { ...process.env, PATH: env.path, JAVA_HOME: env.javaHome }, + }) + .trim() + .split('\n') + .filter(Boolean); + console.log(JSON.stringify({ ...env, avds, worktree: path.basename(process.cwd()) }, null, 2)); + return; + } + if (command === 'adb') return run(env.adb, args, env); + if (command === 'emulator') return run(env.emulator, args, env); + if (command === 'sdkmanager') return run(env.sdkmanager, args, env); + throw new Error('Usage: pnpm dev:mobile:android [doctor|adb|emulator|sdkmanager] [args...]'); +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename); +if (isMain) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} + +export { resolveAndroidEnvironment }; +export type { AndroidEnvironment }; diff --git a/dev/local/mobile-env.test.ts b/dev/local/mobile-env.test.ts index 287039f73c..bc2aa88c3e 100644 --- a/dev/local/mobile-env.test.ts +++ b/dev/local/mobile-env.test.ts @@ -1,17 +1,40 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { applyEnvValues, buildMobileEnvValues, isUsableIpv4, upsertRootEnv } from './mobile-env'; +import { + applyEnvValues, + buildMobileEnvValues, + ensureRootEnv, + isUsableIpv4, + prepareMobileEnvironment, + upsertRootEnv, +} from './mobile-env'; +import { getService } from './services'; test('builds LAN URLs for every mobile-facing local service', () => { const values = buildMobileEnvValues('192.168.1.10'); - assert.equal(values.get('API_BASE_URL'), 'http://192.168.1.10:3000'); - assert.equal(values.get('WEB_BASE_URL'), 'http://192.168.1.10:3000'); - assert.equal(values.get('CLOUD_AGENT_WS_URL'), 'ws://192.168.1.10:8794'); - assert.equal(values.get('SESSION_INGEST_WS_URL'), 'ws://192.168.1.10:8800'); - assert.equal(values.get('KILO_CHAT_URL'), 'http://192.168.1.10:8808'); - assert.equal(values.get('EVENT_SERVICE_URL'), 'ws://192.168.1.10:8809'); - assert.equal(values.get('NOTIFICATIONS_URL'), 'http://192.168.1.10:8804'); + assert.equal(values.get('API_BASE_URL'), `http://192.168.1.10:${getService('nextjs').port}`); + assert.equal(values.get('WEB_BASE_URL'), `http://192.168.1.10:${getService('nextjs').port}`); + assert.equal( + values.get('CLOUD_AGENT_WS_URL'), + `ws://192.168.1.10:${getService('cloud-agent-next').port}` + ); + assert.equal( + values.get('SESSION_INGEST_WS_URL'), + `ws://192.168.1.10:${getService('cloudflare-session-ingest').port}` + ); + assert.equal(values.get('KILO_CHAT_URL'), `http://192.168.1.10:${getService('kilo-chat').port}`); + assert.equal( + values.get('EVENT_SERVICE_URL'), + `ws://192.168.1.10:${getService('event-service').port}` + ); + assert.equal( + values.get('NOTIFICATIONS_URL'), + `http://192.168.1.10:${getService('notifications').port}` + ); }); test('rewrites only requested env keys while preserving comments and other values', () => { @@ -70,3 +93,40 @@ test('validates IPv4-looking host values', () => { assert.equal(isUsableIpv4('localhost'), false); assert.equal(isUsableIpv4(undefined), false); }); + +test('copies the primary worktree root env when a secondary worktree is missing it', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-mobile-env-')); + const primary = path.join(root, 'primary'); + const secondary = path.join(root, 'secondary'); + fs.mkdirSync(primary); + fs.mkdirSync(secondary); + fs.writeFileSync(path.join(primary, '.env.local'), 'NEXTAUTH_SECRET=secret\n'); + + ensureRootEnv(secondary, [primary, secondary]); + + assert.equal( + fs.readFileSync(path.join(secondary, '.env.local'), 'utf8'), + 'NEXTAUTH_SECRET=secret\n' + ); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('prepares mobile URLs before returning values for the Metro tmux environment', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-mobile-env-')); + fs.mkdirSync(path.join(root, 'apps/mobile'), { recursive: true }); + fs.writeFileSync(path.join(root, '.env.local'), 'NEXTAUTH_URL="http://localhost:3000"\n'); + fs.writeFileSync( + path.join(root, 'apps/mobile/.env.local.example'), + 'API_BASE_URL=http://localhost:3000\nWEB_BASE_URL=http://localhost:3000\n' + ); + + const result = prepareMobileEnvironment(root, '192.168.1.10'); + + const appUrl = `http://192.168.1.10:${getService('nextjs').port}`; + assert.equal(result.appUrl, appUrl); + assert.equal(result.sessionEnv.API_BASE_URL, appUrl); + assert.equal(result.sessionEnv.WEB_BASE_URL, appUrl); + assert.equal(result.sessionEnv.NEXTAUTH_URL, appUrl); + assert.equal(result.sessionEnv.APP_URL_OVERRIDE, appUrl); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/dev/local/mobile-env.ts b/dev/local/mobile-env.ts index 333625a647..6b0d555de8 100644 --- a/dev/local/mobile-env.ts +++ b/dev/local/mobile-env.ts @@ -19,6 +19,11 @@ const URL_KEY_TO_SERVICE = new Map; +type PreparedMobileEnvironment = { + appUrl: string; + host: string; + sessionEnv: Record; +}; function parseArgs(args: string[]): { host: string | undefined } { let host = process.env.MOBILE_DEV_HOST; @@ -153,6 +158,59 @@ function writeRootEnv(repoRoot: string, host: string): void { fs.writeFileSync(envPath, upsertRootEnv(content, values), 'utf-8'); } +function getWorktreePaths(repoRoot: string): string[] { + const output = execFileSync('git', ['worktree', 'list', '--porcelain'], { + cwd: repoRoot, + encoding: 'utf8', + }); + return output + .split('\n') + .filter(line => line.startsWith('worktree ')) + .map(line => line.slice('worktree '.length)); +} + +function ensureRootEnv(repoRoot: string, worktreePaths?: string[]): void { + const envPath = path.join(repoRoot, ROOT_ENV_REL_PATH); + if (fs.existsSync(envPath)) return; + + const primaryWorktree = (worktreePaths ?? getWorktreePaths(repoRoot))[0]; + const sourcePath = primaryWorktree && path.join(primaryWorktree, ROOT_ENV_REL_PATH); + if ( + sourcePath && + path.resolve(primaryWorktree) !== path.resolve(repoRoot) && + fs.existsSync(sourcePath) + ) { + fs.copyFileSync(sourcePath, envPath); + console.log(`Copied ${ROOT_ENV_REL_PATH} from primary worktree`); + return; + } + + throw new Error( + `Missing ${ROOT_ENV_REL_PATH} in this worktree and the primary worktree. Run pnpm dev:worktree:prepare.` + ); +} + +function prepareMobileEnvironment(repoRoot: string, host: string): PreparedMobileEnvironment { + if (!isUsableIpv4(host)) { + throw new Error(`Invalid mobile development host: ${host}`); + } + ensureRootEnv(repoRoot); + writeMobileEnv(repoRoot, host); + writeRootEnv(repoRoot, host); + + const mobileValues = buildMobileEnvValues(host); + const appUrl = serviceUrl(host, 'nextjs', 'http'); + return { + appUrl, + host, + sessionEnv: { + ...Object.fromEntries(mobileValues), + APP_URL_OVERRIDE: appUrl, + NEXTAUTH_URL: appUrl, + }, + }; +} + function findRepoRoot(): string { let dir = import.meta.dirname; for (let i = 0; i < 20; i++) { @@ -182,10 +240,7 @@ function main(): void { } const repoRoot = findRepoRoot(); - writeMobileEnv(repoRoot, host); - writeRootEnv(repoRoot, host); - - const appUrl = serviceUrl(host, 'nextjs', 'http'); + const { appUrl } = prepareMobileEnvironment(repoRoot, host); console.log(`Wrote ${MOBILE_ENV_REL_PATH}`); console.log(`Updated ${ROOT_ENV_REL_PATH} APP_URL_OVERRIDE and NEXTAUTH_URL`); console.log(`Mobile web/API base URL: ${appUrl}`); @@ -206,4 +261,13 @@ if (isMain) { } } -export { applyEnvValues, buildMobileEnvValues, detectLanIp, isUsableIpv4, upsertRootEnv }; +export { + applyEnvValues, + buildMobileEnvValues, + detectLanIp, + ensureRootEnv, + isUsableIpv4, + prepareMobileEnvironment, + upsertRootEnv, +}; +export type { PreparedMobileEnvironment }; diff --git a/dev/local/mobile-simulator.test.ts b/dev/local/mobile-simulator.test.ts new file mode 100644 index 0000000000..aedcdc168b --- /dev/null +++ b/dev/local/mobile-simulator.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { claimSimulator, releaseSimulator, type SimulatorDevice } from './mobile-simulator'; + +const devices: SimulatorDevice[] = [ + { id: 'A', name: 'Kilo E2E-A', state: 'Booted' }, + { id: 'B', name: 'Kilo E2E-B', state: 'Shutdown' }, +]; + +test('claims an unowned simulator instead of sharing another worktree simulator', () => { + const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-simulator-locks-')); + const one = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-one-')); + const two = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-two-')); + claimSimulator({ devices, lockRoot, worktreeRoot: one, requestedId: 'A' }); + + const claim = claimSimulator({ devices, lockRoot, worktreeRoot: two }); + + assert.equal(claim.device.id, 'B'); + fs.rmSync(lockRoot, { recursive: true, force: true }); + fs.rmSync(one, { recursive: true, force: true }); + fs.rmSync(two, { recursive: true, force: true }); +}); + +test('refuses to release a simulator claimed by another worktree', () => { + const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-simulator-locks-')); + const one = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-one-')); + const two = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-worktree-two-')); + claimSimulator({ devices, lockRoot, worktreeRoot: one, requestedId: 'A' }); + + assert.throws( + () => releaseSimulator({ deviceId: 'A', lockRoot, worktreeRoot: two }), + new RegExp(`claimed by ${one}`) + ); + fs.rmSync(lockRoot, { recursive: true, force: true }); + fs.rmSync(one, { recursive: true, force: true }); + fs.rmSync(two, { recursive: true, force: true }); +}); diff --git a/dev/local/mobile-simulator.ts b/dev/local/mobile-simulator.ts new file mode 100644 index 0000000000..4381111ea8 --- /dev/null +++ b/dev/local/mobile-simulator.ts @@ -0,0 +1,120 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +type SimulatorDevice = { id: string; name: string; state: string }; +type ClaimArgs = { + devices: SimulatorDevice[]; + lockRoot: string; + worktreeRoot: string; + requestedId?: string; +}; + +function lockPath(lockRoot: string, deviceId: string): string { + return path.join(lockRoot, `${deviceId}.json`); +} + +function readOwner(lockRoot: string, deviceId: string): string | undefined { + try { + const claim = JSON.parse(fs.readFileSync(lockPath(lockRoot, deviceId), 'utf8')) as { + worktreeRoot?: string; + }; + if (claim.worktreeRoot && fs.existsSync(claim.worktreeRoot)) return claim.worktreeRoot; + fs.rmSync(lockPath(lockRoot, deviceId), { force: true }); + } catch { + // Missing or invalid claims are unowned. + } + return undefined; +} + +function claimSimulator(args: ClaimArgs): { device: SimulatorDevice; alreadyOwned: boolean } { + const { devices, lockRoot, worktreeRoot, requestedId } = args; + fs.mkdirSync(lockRoot, { recursive: true }); + const candidates = requestedId + ? devices.filter(device => device.id === requestedId) + : [...devices].sort((a, b) => Number(a.state === 'Booted') - Number(b.state === 'Booted')); + if (candidates.length === 0) + throw new Error(`Simulator ${requestedId ?? ''} is not available`.trim()); + + for (const device of candidates) { + const owner = readOwner(lockRoot, device.id); + if (owner === worktreeRoot) return { device, alreadyOwned: true }; + if (owner) { + if (requestedId) throw new Error(`Simulator ${device.id} is claimed by ${owner}`); + continue; + } + fs.writeFileSync( + lockPath(lockRoot, device.id), + JSON.stringify({ deviceId: device.id, worktreeRoot, claimedAt: new Date().toISOString() }) + ); + return { device, alreadyOwned: false }; + } + throw new Error('No unclaimed iOS simulator is available'); +} + +function releaseSimulator(args: { + deviceId: string; + lockRoot: string; + worktreeRoot: string; +}): void { + const owner = readOwner(args.lockRoot, args.deviceId); + if (owner && owner !== args.worktreeRoot) { + throw new Error(`Simulator ${args.deviceId} is claimed by ${owner}`); + } + fs.rmSync(lockPath(args.lockRoot, args.deviceId), { force: true }); +} + +function listIosDevices(): SimulatorDevice[] { + const raw = JSON.parse( + execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { + encoding: 'utf8', + }) + ) as { devices: Record> }; + return Object.entries(raw.devices) + .filter(([runtime]) => runtime.includes('.iOS-')) + .flatMap(([, devices]) => devices) + .map(device => ({ id: device.udid, name: device.name, state: device.state })); +} + +function main(): void { + const [command, requestedId] = process.argv.slice(2); + const worktreeRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }).trim(); + const lockRoot = path.join(os.tmpdir(), 'kilo-mobile-simulator-claims'); + if (command === 'claim') { + const claim = claimSimulator({ + devices: listIosDevices(), + lockRoot, + worktreeRoot, + requestedId, + }); + if (claim.device.state !== 'Booted') { + execFileSync('xcrun', ['simctl', 'boot', claim.device.id], { stdio: 'ignore' }); + execFileSync('xcrun', ['simctl', 'bootstatus', claim.device.id, '-b'], { stdio: 'inherit' }); + } + console.log(JSON.stringify({ ...claim, worktreeRoot })); + return; + } + if (command === 'release' && requestedId) { + releaseSimulator({ deviceId: requestedId, lockRoot, worktreeRoot }); + console.log(`Released ${requestedId}`); + return; + } + throw new Error('Usage: pnpm dev:mobile:simulator >'); +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename); +if (isMain) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} + +export { claimSimulator, releaseSimulator }; +export type { SimulatorDevice }; diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts new file mode 100644 index 0000000000..ef21e04549 --- /dev/null +++ b/dev/local/mobile-workflow.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +test('login waits for the delayed Expo developer menu after launching Kilo', () => { + const flow = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + const launchIndex = flow.indexOf("text: 'Kilo'"); + const developerMenuWaitIndex = flow.indexOf( + "visible: 'This is the developer menu.*'", + launchIndex + ); + const continueIndex = flow.indexOf("tapOn: 'Continue'", developerMenuWaitIndex); + + assert.ok(launchIndex >= 0); + assert.ok(developerMenuWaitIndex > launchIndex); + assert.ok(continueIndex > developerMenuWaitIndex); + assert.match(flow.slice(developerMenuWaitIndex - 80, continueIndex), /timeout: 15000/); + assert.match(flow.slice(developerMenuWaitIndex - 80, continueIndex), /optional: true/); +}); + +test('login flows never use an unidentified generic Allow selector', () => { + const request = fs.readFileSync('apps/mobile/e2e/flows/login-request-code.yaml', 'utf8'); + const openApp = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + + assert.doesNotMatch(request, /visible: 'Allow'/); + assert.match(openApp, /“Kilo” Would Like to Send You Notifications/); +}); + +test('mobile workflow documents hierarchy-derived tab selectors', () => { + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + + assert.match(runbook, /Agents, tab, 3 of 4/); + assert.match(runbook, /Never guess a selector from the visible label/); + assert.match(runbook, /pnpm dev:capture mobile/); + assert.match(runbook, /dev:mobile:simulator claim/); +}); + +test('login preflight reconnects the claimed iOS device to this worktree Metro URL', () => { + const preflight = fs.readFileSync('apps/mobile/e2e/preflight.sh', 'utf8'); + + assert.match(preflight, /pnpm -s dev:mobile:simulator claim/); + assert.match(preflight, /pnpm -s dev:capture mobile/); + assert.match(preflight, /exp\+kilo-app:\/\/expo-development-client\/\?url=/); + assert.match(preflight, /session-ingest secret readiness probe failed/); +}); + +test('Android tooling is resolved independently of the agent PATH', async () => { + const { resolveAndroidEnvironment } = await import('./mobile-android'); + const env = resolveAndroidEnvironment({ + home: '/Users/test', + path: '/usr/bin:/bin', + existingPaths: new Set([ + '/opt/homebrew/share/android-commandlinetools/platform-tools/adb', + '/opt/homebrew/share/android-commandlinetools/emulator/emulator', + '/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin/sdkmanager', + '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home/bin/java', + ]), + }); + + assert.equal(env.adb, '/opt/homebrew/share/android-commandlinetools/platform-tools/adb'); + assert.equal(env.emulator, '/opt/homebrew/share/android-commandlinetools/emulator/emulator'); + assert.equal(env.javaHome, '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home'); + assert.match(env.path, /android-commandlinetools\/platform-tools/); +}); + +test('env sync refreshes source-backed Wrangler secrets through completed stdin prompts', () => { + const plan = fs.readFileSync('dev/local/env-sync/plan.ts', 'utf8'); + const envOutput = fs.readFileSync('dev/local/env-sync/output.ts', 'utf8'); + + assert.match(plan, /Recreate source-backed secrets/); + assert.match(envOutput, /input: `\$\{value\}\\n`/); + assert.match(envOutput, /Failed to create Secrets Store secret/); +}); diff --git a/dev/local/runner.ts b/dev/local/runner.ts index 8ff7e73106..f800500a73 100644 --- a/dev/local/runner.ts +++ b/dev/local/runner.ts @@ -298,7 +298,8 @@ const inFlightRestarts = new Map void>(); export function restartServiceInTmux( sessionName: string, - serviceName: string + serviceName: string, + env?: Record ): Promise { const svc = getService(serviceName); if (svc.type === 'infra') return Promise.resolve('not-running'); @@ -339,7 +340,7 @@ export function restartServiceInTmux( // window instead of leaving the service stopped after reporting // "Restarted"; the new window inherits the tmux session environment. try { - startServiceInTmux(sessionName, serviceName); + startServiceInTmux(sessionName, serviceName, env); settle('recreated'); } catch { // Same policy as below: keep the TUI alive; the next explicit @@ -366,7 +367,15 @@ export function restartServiceInTmux( return; } try { - sendKeys(sessionName, currentPane.windowIndex, cmd, currentPane.paneIndex); + const envPrefix = Object.entries(env ?? {}) + .map(([key, value]) => `${key}=${shellQuote(value)}`) + .join(' '); + sendKeys( + sessionName, + currentPane.windowIndex, + envPrefix ? `${envPrefix} ${cmd}` : cmd, + currentPane.paneIndex + ); settle('relaunched'); } catch { // The dashboard may have moved or closed the pane after we resolved it. diff --git a/dev/local/services.test.ts b/dev/local/services.test.ts index bd0e3f93c3..c473bd780a 100644 --- a/dev/local/services.test.ts +++ b/dev/local/services.test.ts @@ -3,12 +3,32 @@ import fs from 'node:fs'; import test from 'node:test'; import { + computePortOffset, getAlwaysOnGroupIds, getService, resolveGroups, resolveSessionNextAuthUrl, } from './services'; +test('uses an automatic port offset for secondary worktrees by default', () => { + assert.equal( + computePortOffset({ explicit: undefined, isPrimary: false, slug: 'mobile-context-info' }), + 1100 + ); +}); + +test('never assigns default ports to a secondary worktree', () => { + assert.equal(computePortOffset({ explicit: 'auto', isPrimary: false, slug: 'd' }), 5000); +}); + +test('keeps the primary worktree on the default ports', () => { + assert.equal(computePortOffset({ explicit: undefined, isPrimary: true, slug: 'cloud' }), 0); +}); + +test('honors an explicit port offset', () => { + assert.equal(computePortOffset({ explicit: '1200', isPrimary: false, slug: 'anything' }), 1200); +}); + test('points NEXTAUTH_URL at the offset port when the web app runs without a tunnel', () => { const url = resolveSessionNextAuthUrl({ portOffset: 2900, @@ -51,14 +71,22 @@ test('keeps auto routing workers in their own opt-in group', () => { assert.equal(service.group, 'auto-routing'); assert.equal(service.type, 'worker'); assert.equal(service.dir, 'services/auto-routing'); - assert.equal(service.port, 8810); + assert.equal( + service.port, + 8810 + + computePortOffset({ + explicit: process.env.KILO_PORT_OFFSET, + isPrimary: false, + slug: 'harden-mobile-agent-workflow', + }) + ); assert.match(service.command.join(' '), /pnpm run dev/); const benchmark = getService('auto-routing-benchmark'); assert.equal(benchmark.group, 'auto-routing'); assert.equal(benchmark.type, 'worker'); assert.equal(benchmark.dir, 'services/auto-routing-benchmark'); - assert.equal(benchmark.port, 8814); + assert.equal(benchmark.port - service.port, 4); const alwaysOn = resolveGroups(getAlwaysOnGroupIds()); assert.ok(!alwaysOn.includes('auto-routing')); @@ -82,7 +110,7 @@ test('keeps auto routing package dev script compatible with local launcher flags test('starts Storybook with Storybook v10 port flags', () => { const service = getService('storybook'); - assert.deepEqual(service.command, ['pnpm', 'run', 'storybook', '-p', '6006']); + assert.deepEqual(service.command, ['pnpm', 'run', 'storybook', '-p', String(service.port)]); }); test('preserves auto routing backend auth secret name', () => { diff --git a/dev/local/services.ts b/dev/local/services.ts index 2e4aed0e88..e88d777aec 100644 --- a/dev/local/services.ts +++ b/dev/local/services.ts @@ -266,24 +266,35 @@ function isPrimaryWorktree(): boolean { return path.resolve(gitDir) === path.resolve(gitCommonDir); } -function computeAutoOffset(): number { - if (isPrimaryWorktree()) return 0; - - const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); - const slug = path.basename(root); - +export function computePortOffset(args: { + explicit: string | undefined; + isPrimary: boolean; + slug: string; +}): number { + const { explicit, isPrimary, slug } = args; + if (explicit !== undefined && explicit !== 'auto') { + const value = Number(explicit); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Invalid KILO_PORT_OFFSET: ${explicit}`); + } + return value; + } + if (isPrimary) return 0; let hash = 0; for (let i = 0; i < slug.length; i++) { hash = ((hash << 5) - hash + slug.charCodeAt(i)) | 0; } - return (((hash % 50) + 50) % 50) * 100; + const bucket = ((hash % 50) + 50) % 50; + return (bucket === 0 ? 50 : bucket) * 100; } function getPortOffset(): number { - const explicit = process.env.KILO_PORT_OFFSET; - if (explicit === undefined) return 0; // disabled by default - if (explicit === 'auto') return computeAutoOffset(); - return Number(explicit); + const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim(); + return computePortOffset({ + explicit: process.env.KILO_PORT_OFFSET, + isPrimary: isPrimaryWorktree(), + slug: path.basename(root), + }); } export const portOffset = getPortOffset(); diff --git a/dev/local/tmux.test.ts b/dev/local/tmux.test.ts index bc82e15137..f1ac20a274 100644 --- a/dev/local/tmux.test.ts +++ b/dev/local/tmux.test.ts @@ -5,7 +5,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import test from 'node:test'; -import { breakPane, buildInteractiveShellCommand, listWindows } from './tmux'; +import { breakPane, buildInteractiveShellCommand, captureServicePane, listWindows } from './tmux'; import { buildStartCommand, restartServiceInTmux } from './runner'; test('buildInteractiveShellCommand wraps quoted startup commands in parseable shell syntax', () => { @@ -33,6 +33,50 @@ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +test( + 'captureServicePane follows a service after the dashboard moves its pane', + { skip: !hasTmux }, + () => { + const sessionName = `kilo-tmux-test-${process.pid}-${Date.now()}`; + const serviceName = 'mobile'; + const tmux = (...args: string[]) => execFileSync('tmux', args, { stdio: 'ignore' }); + + try { + tmux('new-session', '-d', '-s', sessionName, '-n', 'dashboard', 'sleep 120'); + tmux( + 'new-window', + '-d', + '-t', + sessionName, + '-n', + serviceName, + '/bin/sh', + '-c', + 'printf "worktree-mobile-pane\\n"; sleep 120' + ); + const serviceWindow = listWindows(sessionName).find(window => window.name === serviceName); + assert.ok(serviceWindow); + tmux( + 'join-pane', + '-h', + '-s', + `${sessionName}:${serviceWindow.index}.0`, + '-t', + `${sessionName}:0.0` + ); + tmux('select-pane', '-t', `${sessionName}:0.1`, '-T', serviceName); + + assert.match(captureServicePane(sessionName, serviceName, 20), /worktree-mobile-pane/); + } finally { + try { + tmux('kill-session', '-t', sessionName); + } catch { + // Session may already be gone if tmux fails during setup. + } + } + } +); + test( 'breakPane keeps the requested service window name after tmux automatic rename', { skip: !hasTmux }, diff --git a/dev/local/tmux.ts b/dev/local/tmux.ts index f230c8b150..e1ff5a4afb 100644 --- a/dev/local/tmux.ts +++ b/dev/local/tmux.ts @@ -108,6 +108,12 @@ function createSession(sessionName: string, env?: Record): void }); } +function setSessionEnvironment(sessionName: string, env: Record): void { + for (const [key, value] of Object.entries(env)) { + execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' }); + } +} + function killSession(sessionName: string): void { try { execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' }); @@ -391,6 +397,26 @@ function findServicePane(sessionName: string, serviceName: string): PaneInfo | u } } +function captureServicePane(sessionName: string, serviceName: string, historyLines = 200): string { + const pane = findServicePane(sessionName, serviceName); + if (!pane) { + throw new Error(`Service ${serviceName} is not running in ${sessionName}`); + } + return execFileSync( + 'tmux', + [ + 'capture-pane', + '-p', + '-J', + '-t', + `${sessionName}:${pane.windowIndex}.${pane.paneIndex}`, + '-S', + `-${historyLines}`, + ], + { encoding: 'utf8' } + ); +} + function isPaneRunningCommand(sessionName: string, pane: PaneInfo): boolean { try { const command = execSync( @@ -503,6 +529,7 @@ export { sessionExists, findOtherKiloDevSessions, createSession, + setSessionEnvironment, killSession, attachSession, createWindow, @@ -523,6 +550,7 @@ export { breakPane, countPanes, findServicePane, + captureServicePane, isPaneRunningCommand, paneHasRunningChild, selectPane, diff --git a/package.json b/package.json index d4a653ce26..dff940f025 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,11 @@ "dev:stop": "tsx dev/local/cli.ts stop", "dev:status": "tsx dev/local/cli.ts status", "dev:restart": "tsx dev/local/cli.ts restart", + "dev:capture": "tsx dev/local/cli.ts capture", "dev:env": "tsx dev/local/cli.ts env", "dev:env:mobile": "tsx dev/local/mobile-env.ts", + "dev:mobile:simulator": "tsx dev/local/mobile-simulator.ts", + "dev:mobile:android": "tsx dev/local/mobile-android.ts", "dev:setup-env": "tsx dev/local/setup-env.ts", "dev:seed": "tsx dev/seed/index.ts", "dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts", diff --git a/scripts/worktree-prepare.sh b/scripts/worktree-prepare.sh index 27704be2cc..03283cb91c 100755 --- a/scripts/worktree-prepare.sh +++ b/scripts/worktree-prepare.sh @@ -12,6 +12,11 @@ WEB_ENV_FILE="apps/web/.env.development.local" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT +if command -v direnv >/dev/null 2>&1 && [ -f .envrc ]; then + echo "==> Authorizing this worktree's root direnv config…" + direnv allow . +fi + upsert_env_line() { local file="$1" local line="$2" @@ -92,6 +97,17 @@ elif [ -f "$MAIN_WORKTREE/$WEB_ENV_FILE" ]; then cp "$MAIN_WORKTREE/$WEB_ENV_FILE" "./$WEB_ENV_FILE" fi +if [ "$MAIN_WORKTREE_REALPATH" != "$CURRENT_WORKTREE_REALPATH" ] && + [ -f "$MAIN_WORKTREE/apps/mobile/.env.local" ]; then + echo "==> Copying apps/mobile/.env.local from main worktree…" + cp "$MAIN_WORKTREE/apps/mobile/.env.local" ./apps/mobile/.env.local +fi + +if command -v direnv >/dev/null 2>&1 && [ -f apps/mobile/.envrc ]; then + echo "==> Authorizing this worktree's mobile direnv config…" + direnv allow ./apps/mobile +fi + echo "==> Syncing Next.js development env…" pnpm dev:env -y nextjs From 0e1da47cf7ea3333ef73cea42137c32ecba28d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 18:42:48 +0200 Subject: [PATCH 03/10] fix(mobile): close E2E isolation gaps --- .../mobile/.kilo/agent/mobile-e2e-verifier.md | 1 + apps/mobile/e2e/AGENTS.md | 22 ++- apps/mobile/e2e/preflight.sh | 20 ++- dev/local/env-sync/index.ts | 6 +- dev/local/env-sync/plan.test.ts | 43 +++++- dev/local/env-sync/plan.ts | 7 +- dev/local/mobile-android.ts | 127 +++++++++++++++--- dev/local/mobile-env.test.ts | 4 +- dev/local/mobile-env.ts | 2 - dev/local/mobile-simulator.test.ts | 5 + dev/local/mobile-simulator.ts | 20 ++- dev/local/mobile-workflow.test.ts | 39 ++++++ 12 files changed, 252 insertions(+), 44 deletions(-) diff --git a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md index e4580d0f09..6e4ed7d17e 100644 --- a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md +++ b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md @@ -27,6 +27,7 @@ During verification: - Run the login/logout helper preflight rather than manually inferring bundle provenance, ports, or simulator ownership. - Run `pnpm dev:mobile:android doctor` before declaring Android tooling unavailable; do not rely on the inherited `PATH`. +- Use repository-wrapped ADB or Maestro for Android, whichever is more effective for the flow. Use Maestro as the primary iOS driver. - Inspect the current screen before selecting Maestro elements and re-inspect after UI changes. Copy exact hierarchy text; never infer selectors from screenshots or visible tab captions. - Exercise every applicable feature state that can be produced safely and deterministically. A skipped state requires an explicit rationale; do not silently omit it. - Confirm retryable and empty states show a meaningful message and actionable CTA, and that the CTA performs the expected recovery or next step. diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index 34401e6dcf..0451b325a6 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -189,20 +189,35 @@ Do not conclude that Android is unavailable from `command -v adb` or the agent's pnpm dev:mobile:android doctor ``` -Use the wrappers for all Android tooling so the resolved SDK/JDK environment is applied: +Use the wrappers for all Android tooling so the resolved SDK/JDK environment is applied, including the Expo/Gradle build: ```bash ANDROID_SESSION="kilo-e2e-android-$(basename "$PWD")" tmux new-session -d -s "$ANDROID_SESSION" -c "$PWD" \ "pnpm dev:mobile:android emulator -avd -no-snapshot-save -no-boot-anim -gpu swiftshader_indirect" pnpm dev:mobile:android adb wait-for-device +pnpm dev:mobile:android claim pnpm dev:mobile:android adb reverse tcp: tcp: pnpm dev:mobile:android adb reverse tcp: tcp: cd apps/mobile -npx expo run:android --no-install --no-bundler +pnpm -w dev:mobile:android build ``` -Android's `localhost` is the emulator, so restore both `adb reverse` mappings after clearing app data. The dev-client scheme is `exp+kilo-app`. `adb shell pm clear com.kilocode.kiloapp` also forgets the Metro URL, so re-open the dev-client URL afterward. +For Android, choose repository-wrapped ADB or Maestro based on which is more effective for the flow. ADB is useful for direct hierarchy inspection, prompt handling, intent launch, and deterministic shell input; Maestro is useful for reusable cross-screen flows such as OTP login. iOS uses Maestro. Use the repository wrapper rather than bare `adb`: + +```bash +pnpm dev:mobile:android adb devices -l +pnpm dev:mobile:android adb -s shell uiautomator dump /sdcard/window.xml +pnpm dev:mobile:android adb -s shell cat /sdcard/window.xml +pnpm dev:mobile:android adb -s exec-out screencap -p > /tmp/kilo-android.png +pnpm dev:mobile:android adb -s shell input tap +pnpm dev:mobile:android adb -s shell input text '' +pnpm dev:mobile:android adb -s shell input keyevent KEYCODE_BACK +``` + +Derive tap coordinates from the current `uiautomator` bounds, not screenshots or remembered positions. Re-dump after every navigation or prompt. Android's `localhost` is the emulator, so restore both `adb reverse` mappings after clearing app data. The dev-client scheme is `exp+kilo-app`. `adb shell pm clear com.kilocode.kiloapp` also forgets the Metro URL, so re-open the dev-client URL afterward with `adb shell am start`. + +The existing login/logout helpers accept either an iOS simulator UDID or an Android ADB serial. Their shared preflight applies platform-specific device ownership and reconnects the dev client to this worktree before Maestro runs. ## Cleanup @@ -217,6 +232,7 @@ rm -f "$LOGIN_LOG" # if created pnpm dev:stop # only if you started this worktree's stack xcrun simctl shutdown # only if you booted it pnpm dev:mobile:simulator release # release every simulator you claimed +pnpm dev:mobile:android release # release every Android device you claimed ``` Also stop recorders, log followers, and emulator processes you created. Never use `tmux kill-server`, kill unrelated `kilo-dev-*` sessions, stop a simulator that was already booted, or use `pnpm dev:stop --force` while sibling worktrees are active. diff --git a/apps/mobile/e2e/preflight.sh b/apps/mobile/e2e/preflight.sh index 856ec42886..6aeadebfaa 100755 --- a/apps/mobile/e2e/preflight.sh +++ b/apps/mobile/e2e/preflight.sh @@ -31,12 +31,18 @@ if [ "$SESSION_STATUS" != "401" ] || ! grep -Fq 'Invalid or expired token' "$SES exit 1 fi -CLAIM="$(cd "$REPO_ROOT" && pnpm -s dev:mobile:simulator claim "$DEVICE")" +PLATFORM="ios" +if (cd "$REPO_ROOT" && pnpm -s dev:mobile:android adb devices | awk -v device="$DEVICE" '$1 == device && $2 == "device" { found=1 } END { exit !found }'); then + PLATFORM="android" + CLAIM="$(cd "$REPO_ROOT" && pnpm -s dev:mobile:android claim "$DEVICE")" +else + CLAIM="$(cd "$REPO_ROOT" && pnpm -s dev:mobile:simulator claim "$DEVICE")" +fi node - "$CLAIM" "$REPO_ROOT" <<'NODE' const [claimOutput, expectedRoot] = process.argv.slice(2); const claim = JSON.parse(claimOutput.trim().split('\n').at(-1)); if (claim.worktreeRoot !== expectedRoot) { - throw new Error(`Simulator belongs to ${claim.worktreeRoot}, expected ${expectedRoot}`); + throw new Error(`Device belongs to ${claim.worktreeRoot}, expected ${expectedRoot}`); } NODE @@ -72,9 +78,15 @@ NODE )" METRO_URL="http://${MOBILE_HOST}:${METRO_PORT}" ENCODED_METRO_URL="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$METRO_URL")" -if xcrun simctl list devices available | grep -Fq "$DEVICE"; then +if [ "$PLATFORM" = "ios" ]; then xcrun simctl openurl "$DEVICE" \ "exp+kilo-app://expo-development-client/?url=${ENCODED_METRO_URL}" +else + cd "$REPO_ROOT" + pnpm -s dev:mobile:android adb -s "$DEVICE" reverse "tcp:${EXPECTED_API_PORT}" "tcp:${EXPECTED_API_PORT}" + pnpm -s dev:mobile:android adb -s "$DEVICE" reverse "tcp:${METRO_PORT}" "tcp:${METRO_PORT}" + pnpm -s dev:mobile:android adb -s "$DEVICE" shell am start -a android.intent.action.VIEW \ + -d "exp+kilo-app://expo-development-client/?url=${ENCODED_METRO_URL}" >/dev/null fi -echo "Mobile E2E preflight passed for $DEVICE in $REPO_ROOT ($METRO_URL)" +echo "Mobile E2E preflight passed for $PLATFORM device $DEVICE in $REPO_ROOT ($METRO_URL)" diff --git a/dev/local/env-sync/index.ts b/dev/local/env-sync/index.ts index 1da6a6c409..d8997251c6 100644 --- a/dev/local/env-sync/index.ts +++ b/dev/local/env-sync/index.ts @@ -44,7 +44,7 @@ async function syncEnvVars(options: { }): Promise { const { repoRoot, check = false, yes = false, targets } = options; const serviceFilter = resolveServiceFilter(targets); - const plan = computePlan(repoRoot, serviceFilter); + const plan = computePlan(repoRoot, serviceFilter, !check); if (plan.missingEnvLocal) { displayPlan(plan); @@ -74,7 +74,7 @@ async function syncEnvVars(options: { if (shouldApply) { applyEnvLocalAutoCreates(plan.envLocalAutoCreates, repoRoot); const applyReadyPlan = - plan.envLocalAutoCreates.length > 0 ? computePlan(repoRoot, serviceFilter) : plan; + plan.envLocalAutoCreates.length > 0 ? computePlan(repoRoot, serviceFilter, true) : plan; applyPlan(applyReadyPlan, repoRoot); console.log(`\n${GREEN}✓ Applied${RESET}`); } else { @@ -99,7 +99,7 @@ async function checkEnvVars(repoRoot: string, targets?: string[]): Promise sum + c.missingValues.length, 0) + plan.secretStoreWarnings.reduce((sum, c) => sum + c.bindings.length, 0); diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index 5f3e4b0438..d01492dc54 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import test from 'node:test'; import { computePlan } from './plan'; +import { getService } from '../services'; const workerDir = 'services/cloud-agent-next'; @@ -147,7 +148,7 @@ test('generates the auto routing worker URL for local Next.js', () => { { key: 'AUTO_ROUTING_WORKER_URL', oldValue: undefined, - newValue: 'http://localhost:8810', + newValue: `http://localhost:${getService('auto-routing').port}`, } ); } finally { @@ -580,6 +581,28 @@ test('refreshes an existing source-backed local Secrets Store secret', () => { } }); +test('check mode accepts an existing source-backed local Secrets Store secret', () => { + const repo = createRepo({ + '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', + 'services/event-service/package.json': JSON.stringify({ scripts: { dev: 'wrangler dev' } }), + 'services/event-service/wrangler.jsonc': `{ + "secrets_store_secrets": [{ + "binding": "NEXTAUTH_SECRET", + "store_id": "store-id", + "secret_name": "NEXTAUTH_SECRET_PROD" + }] + }`, + }); + try { + withFakePnpm('NEXTAUTH_SECRET_PROD\n', () => { + const plan = computePlan(repo.root, new Set(['event-service']), false); + assert.deepEqual(plan.secretStoreAutoCreates, []); + }); + } finally { + repo.cleanup(); + } +}); + test('auto-creates event-service NEXTAUTH Secrets Store binding from .env.local', () => { const repo = createRepo({ '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', @@ -802,16 +825,20 @@ test('preserves host.docker.internal in @url defaults for useLanIp services', () }); try { const plan = computePlan(repo.root, new Set(['cloud-agent-next'])); + const nextjsPort = getService('nextjs').port; + const cloudAgentPort = getService('cloud-agent-next').port; assert.equal(plan.missingEnvLocal, false); assert.equal(plan.devVarsChanges.length, 1); const [change] = plan.devVarsChanges; assert.ok(change); assert.equal(change.isNew, true); const content = change.newFileContent ?? ''; - assert.ok(content.includes('KILOCODE_BACKEND_BASE_URL=http://host.docker.internal:3000')); - assert.ok(content.includes('WORKER_URL=http://host.docker.internal:8794')); + assert.ok( + content.includes(`KILOCODE_BACKEND_BASE_URL=http://host.docker.internal:${nextjsPort}`) + ); + assert.ok(content.includes(`WORKER_URL=http://host.docker.internal:${cloudAgentPort}`)); // ORIGINS keys still use localhost even when example default has host.docker.internal - assert.ok(content.includes('ALLOWED_ORIGINS=http://localhost:3000')); + assert.ok(content.includes(`ALLOWED_ORIGINS=http://localhost:${nextjsPort}`)); } finally { repo.cleanup(); } @@ -838,15 +865,17 @@ test('preserves localhost in worker-side @url defaults for useLanIp services', ( }); try { const plan = computePlan(repo.root, new Set(['cloud-agent-next'])); + const nextjsPort = getService('nextjs').port; + const cloudAgentPort = getService('cloud-agent-next').port; assert.equal(plan.missingEnvLocal, false); assert.equal(plan.devVarsChanges.length, 1); const [change] = plan.devVarsChanges; assert.ok(change); assert.equal(change.isNew, true); const content = change.newFileContent ?? ''; - assert.ok(content.includes('KILOCODE_BACKEND_BASE_URL=http://localhost:3000')); - assert.ok(content.includes('KILO_OPENROUTER_BASE=http://localhost:3000/api')); - assert.ok(content.includes('WORKER_URL=http://host.docker.internal:8794')); + assert.ok(content.includes(`KILOCODE_BACKEND_BASE_URL=http://localhost:${nextjsPort}`)); + assert.ok(content.includes(`KILO_OPENROUTER_BASE=http://localhost:${nextjsPort}/api`)); + assert.ok(content.includes(`WORKER_URL=http://host.docker.internal:${cloudAgentPort}`)); } finally { repo.cleanup(); } diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index 572037b7b2..b8e27afd9c 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -477,7 +477,11 @@ function collectLocalSecretSources( // Plan computation // --------------------------------------------------------------------------- -function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan { +function computePlan( + repoRoot: string, + serviceFilter?: Set, + refreshSourceBackedSecrets = true +): EnvSyncPlan { const envLocalPath = path.join(repoRoot, '.env.local'); if (!fs.existsSync(envLocalPath)) { return { @@ -747,6 +751,7 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan const source = resolveSecretStoreSource(b.secret_name, envLocal, localSecretSources); if (source) { + if (!refreshSourceBackedSecrets && output.includes(b.secret_name)) continue; // Recreate source-backed secrets so metadata-only entries and stale values self-heal. secretStoreAutoCreates.push({ workerDir: svc.dir, diff --git a/dev/local/mobile-android.ts b/dev/local/mobile-android.ts index 864914adfa..0fe6851c9e 100644 --- a/dev/local/mobile-android.ts +++ b/dev/local/mobile-android.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -9,15 +9,18 @@ type AndroidEnvironment = { javaHome: string; path: string; sdkRoot: string; - sdkmanager: string; + sdkmanager?: string; }; type ResolveArgs = { home: string; path: string; existingPaths?: ReadonlySet; + javaMajor?: (javaHome: string) => number | undefined; }; +type DeviceClaim = { serial: string; worktreeRoot: string; claimedAt: string }; + function firstExisting( candidates: string[], exists: (candidate: string) => boolean @@ -59,18 +62,24 @@ function resolveAndroidEnvironment(args: ResolveArgs): AndroidEnvironment { exists ); const javaHomes = [ - process.env.JAVA_HOME, '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home', '/opt/homebrew/opt/temurin@17/libexec/openjdk.jdk/Contents/Home', + process.env.JAVA_HOME, ].filter((value): value is string => Boolean(value)); - const javaHome = javaHomes.find(candidate => exists(path.join(candidate, 'bin/java'))); - - const missing = [ - !adb && 'adb', - !emulator && 'emulator', - !sdkmanager && 'sdkmanager', - !javaHome && 'JDK 17', - ].filter(Boolean); + const javaMajor = + args.javaMajor ?? + ((candidate: string) => { + const result = spawnSync(path.join(candidate, 'bin/java'), ['-version'], { + encoding: 'utf8', + }); + const match = `${result.stdout}${result.stderr}`.match(/version "(\d+)/); + return match ? Number(match[1]) : undefined; + }); + const javaHome = javaHomes.find( + candidate => exists(path.join(candidate, 'bin/java')) && javaMajor(candidate) === 17 + ); + + const missing = [!adb && 'adb', !emulator && 'emulator', !javaHome && 'JDK 17'].filter(Boolean); if (missing.length > 0) { throw new Error(`Android tooling incomplete: missing ${missing.join(', ')}`); } @@ -78,9 +87,9 @@ function resolveAndroidEnvironment(args: ResolveArgs): AndroidEnvironment { const toolPaths = [ path.join(sdkRoot, 'platform-tools'), path.join(sdkRoot, 'emulator'), - path.dirname(sdkmanager), + sdkmanager && path.dirname(sdkmanager), path.join(javaHome, 'bin'), - ]; + ].filter((value): value is string => Boolean(value)); return { adb, emulator, @@ -95,9 +104,10 @@ function environment(): AndroidEnvironment { return resolveAndroidEnvironment({ home: os.homedir(), path: process.env.PATH ?? '' }); } -function run(command: string, args: string[], env: AndroidEnvironment): void { +function run(command: string, args: string[], env: AndroidEnvironment, cwd?: string): void { execFileSync(command, args, { stdio: 'inherit', + cwd, env: { ...process.env, ANDROID_HOME: env.sdkRoot, @@ -108,9 +118,64 @@ function run(command: string, args: string[], env: AndroidEnvironment): void { }); } +function getAndroidSerials(env: AndroidEnvironment): string[] { + const output = execFileSync(env.adb, ['devices'], { + encoding: 'utf8', + env: { ...process.env, PATH: env.path }, + }); + return output + .split('\n') + .slice(1) + .map(line => line.trim().split(/\s+/)) + .filter(([, state]) => state === 'device') + .map(([serial]) => serial); +} + +function claimPath(serial: string): string { + return path.join( + os.tmpdir(), + 'kilo-mobile-android-claims', + `${serial.replaceAll('/', '_')}.json` + ); +} + +function claimAndroidDevice(serial: string, worktreeRoot: string): DeviceClaim { + const filePath = claimPath(serial); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + try { + const claim = JSON.parse(fs.readFileSync(filePath, 'utf8')) as DeviceClaim; + if (claim.worktreeRoot === worktreeRoot) return claim; + if (fs.existsSync(claim.worktreeRoot)) + throw new Error(`${serial} is claimed by ${claim.worktreeRoot}`); + fs.rmSync(filePath, { force: true }); + } catch (error) { + if (error instanceof SyntaxError) { + fs.rmSync(filePath, { force: true }); + } else if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + // Missing claims can be created atomically below. + } else if (error instanceof Error && error.message.includes(' is claimed by ')) { + throw error; + } + } + const claim = { serial, worktreeRoot, claimedAt: new Date().toISOString() }; + fs.writeFileSync(filePath, JSON.stringify(claim), { flag: 'wx' }); + return claim; +} + +function releaseAndroidDevice(serial: string, worktreeRoot: string): void { + const filePath = claimPath(serial); + const claim = JSON.parse(fs.readFileSync(filePath, 'utf8')) as DeviceClaim; + if (claim.worktreeRoot !== worktreeRoot) + throw new Error(`${serial} is claimed by ${claim.worktreeRoot}`); + fs.rmSync(filePath); +} + function main(): void { const [command, ...args] = process.argv.slice(2); const env = environment(); + const worktreeRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }).trim(); if (command === 'doctor' || command === undefined) { const avds = execFileSync(env.emulator, ['-list-avds'], { encoding: 'utf8', @@ -122,10 +187,38 @@ function main(): void { console.log(JSON.stringify({ ...env, avds, worktree: path.basename(process.cwd()) }, null, 2)); return; } + if (command === 'build') { + return run( + 'npx', + ['expo', 'run:android', '--no-install', '--no-bundler', ...args], + env, + path.join(worktreeRoot, 'apps/mobile') + ); + } + if (command === 'claim') { + const requested = args[0]; + const serial = requested ?? getAndroidSerials(env)[0]; + if (!serial || !getAndroidSerials(env).includes(serial)) + throw new Error('No connected Android device is available'); + console.log(JSON.stringify(claimAndroidDevice(serial, worktreeRoot))); + return; + } + if (command === 'release') { + const serial = args[0]; + if (!serial) throw new Error('Usage: pnpm dev:mobile:android release '); + releaseAndroidDevice(serial, worktreeRoot); + console.log(`Released ${serial}`); + return; + } if (command === 'adb') return run(env.adb, args, env); if (command === 'emulator') return run(env.emulator, args, env); - if (command === 'sdkmanager') return run(env.sdkmanager, args, env); - throw new Error('Usage: pnpm dev:mobile:android [doctor|adb|emulator|sdkmanager] [args...]'); + if (command === 'sdkmanager') { + if (!env.sdkmanager) throw new Error('sdkmanager is not installed'); + return run(env.sdkmanager, args, env); + } + throw new Error( + 'Usage: pnpm dev:mobile:android [doctor|build|claim|release|adb|emulator|sdkmanager] [args...]' + ); } const isMain = @@ -139,5 +232,5 @@ if (isMain) { } } -export { resolveAndroidEnvironment }; +export { claimAndroidDevice, releaseAndroidDevice, resolveAndroidEnvironment }; export type { AndroidEnvironment }; diff --git a/dev/local/mobile-env.test.ts b/dev/local/mobile-env.test.ts index bc2aa88c3e..03a97086f4 100644 --- a/dev/local/mobile-env.test.ts +++ b/dev/local/mobile-env.test.ts @@ -126,7 +126,7 @@ test('prepares mobile URLs before returning values for the Metro tmux environmen assert.equal(result.appUrl, appUrl); assert.equal(result.sessionEnv.API_BASE_URL, appUrl); assert.equal(result.sessionEnv.WEB_BASE_URL, appUrl); - assert.equal(result.sessionEnv.NEXTAUTH_URL, appUrl); - assert.equal(result.sessionEnv.APP_URL_OVERRIDE, appUrl); + assert.equal(result.sessionEnv.NEXTAUTH_URL, undefined); + assert.equal(result.sessionEnv.APP_URL_OVERRIDE, undefined); fs.rmSync(root, { recursive: true, force: true }); }); diff --git a/dev/local/mobile-env.ts b/dev/local/mobile-env.ts index 6b0d555de8..20a2a0e208 100644 --- a/dev/local/mobile-env.ts +++ b/dev/local/mobile-env.ts @@ -205,8 +205,6 @@ function prepareMobileEnvironment(repoRoot: string, host: string): PreparedMobil host, sessionEnv: { ...Object.fromEntries(mobileValues), - APP_URL_OVERRIDE: appUrl, - NEXTAUTH_URL: appUrl, }, }; } diff --git a/dev/local/mobile-simulator.test.ts b/dev/local/mobile-simulator.test.ts index aedcdc168b..83fe4537e3 100644 --- a/dev/local/mobile-simulator.test.ts +++ b/dev/local/mobile-simulator.test.ts @@ -39,3 +39,8 @@ test('refuses to release a simulator claimed by another worktree', () => { fs.rmSync(one, { recursive: true, force: true }); fs.rmSync(two, { recursive: true, force: true }); }); + +test('uses exclusive claim creation to prevent concurrent simulator sharing', () => { + const source = fs.readFileSync(new URL('./mobile-simulator.ts', import.meta.url), 'utf8'); + assert.match(source, /flag: 'wx'/); +}); diff --git a/dev/local/mobile-simulator.ts b/dev/local/mobile-simulator.ts index 4381111ea8..e604a2d50d 100644 --- a/dev/local/mobile-simulator.ts +++ b/dev/local/mobile-simulator.ts @@ -23,6 +23,7 @@ function readOwner(lockRoot: string, deviceId: string): string | undefined { if (claim.worktreeRoot && fs.existsSync(claim.worktreeRoot)) return claim.worktreeRoot; fs.rmSync(lockPath(lockRoot, deviceId), { force: true }); } catch { + fs.rmSync(lockPath(lockRoot, deviceId), { force: true }); // Missing or invalid claims are unowned. } return undefined; @@ -44,11 +45,20 @@ function claimSimulator(args: ClaimArgs): { device: SimulatorDevice; alreadyOwne if (requestedId) throw new Error(`Simulator ${device.id} is claimed by ${owner}`); continue; } - fs.writeFileSync( - lockPath(lockRoot, device.id), - JSON.stringify({ deviceId: device.id, worktreeRoot, claimedAt: new Date().toISOString() }) - ); - return { device, alreadyOwned: false }; + try { + fs.writeFileSync( + lockPath(lockRoot, device.id), + JSON.stringify({ deviceId: device.id, worktreeRoot, claimedAt: new Date().toISOString() }), + { flag: 'wx' } + ); + return { device, alreadyOwned: false }; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'EEXIST') { + if (requestedId) throw new Error(`Simulator ${device.id} was claimed concurrently`); + continue; + } + throw error; + } } throw new Error('No unclaimed iOS simulator is available'); } diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts index ef21e04549..e3db5745b0 100644 --- a/dev/local/mobile-workflow.test.ts +++ b/dev/local/mobile-workflow.test.ts @@ -42,6 +42,8 @@ test('login preflight reconnects the claimed iOS device to this worktree Metro U assert.match(preflight, /pnpm -s dev:capture mobile/); assert.match(preflight, /exp\+kilo-app:\/\/expo-development-client\/\?url=/); assert.match(preflight, /session-ingest secret readiness probe failed/); + assert.match(preflight, /dev:mobile:android claim/); + assert.match(preflight, /adb -s "\$DEVICE" reverse/); }); test('Android tooling is resolved independently of the agent PATH', async () => { @@ -55,6 +57,7 @@ test('Android tooling is resolved independently of the agent PATH', async () => '/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin/sdkmanager', '/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home/bin/java', ]), + javaMajor: () => 17, }); assert.equal(env.adb, '/opt/homebrew/share/android-commandlinetools/platform-tools/adb'); @@ -63,6 +66,42 @@ test('Android tooling is resolved independently of the agent PATH', async () => assert.match(env.path, /android-commandlinetools\/platform-tools/); }); +test('Android workflow uses adb first and applies resolved tooling to Expo builds', () => { + const android = fs.readFileSync('dev/local/mobile-android.ts', 'utf8'); + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + + assert.match(android, /'build'/); + assert.match(android, /'run:android'/); + assert.match(android, /path\.join\(worktreeRoot, 'apps\/mobile'\)/); + assert.match(runbook, /choose repository-wrapped ADB or Maestro/); + assert.match(runbook, /iOS uses Maestro/); +}); + +test('Android tooling rejects a non-Java-17 JAVA_HOME', async () => { + const { resolveAndroidEnvironment } = await import('./mobile-android'); + assert.throws( + () => + resolveAndroidEnvironment({ + home: '/Users/test', + path: '/usr/bin:/bin', + existingPaths: new Set([ + '/opt/homebrew/share/android-commandlinetools/platform-tools/adb', + '/opt/homebrew/share/android-commandlinetools/emulator/emulator', + '/java/bin/java', + ]), + javaMajor: () => 21, + }), + /missing JDK 17/ + ); +}); + +test('Android device ownership uses exclusive worktree claims', () => { + const android = fs.readFileSync('dev/local/mobile-android.ts', 'utf8'); + assert.match(android, /flag: 'wx'/); + assert.match(android, /claimAndroidDevice/); + assert.match(android, /releaseAndroidDevice/); +}); + test('env sync refreshes source-backed Wrangler secrets through completed stdin prompts', () => { const plan = fs.readFileSync('dev/local/env-sync/plan.ts', 'utf8'); const envOutput = fs.readFileSync('dev/local/env-sync/output.ts', 'utf8'); From a8e3b6335688f6862515ad465460ae62364421d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 19:02:25 +0200 Subject: [PATCH 04/10] fix(mobile): validate runtime E2E provenance --- apps/mobile/e2e/AGENTS.md | 4 ++- apps/mobile/e2e/flows/open-app.yaml | 18 +++++++++++-- apps/mobile/e2e/preflight.sh | 16 ++++++++++++ dev/local/cli.ts | 12 ++++++++- dev/local/mobile-workflow.test.ts | 16 ++++++++++++ dev/local/runner.ts | 2 ++ dev/local/tmux.test.ts | 11 +++++--- dev/local/tmux.ts | 39 +++++++++++++++++++++++++++++ 8 files changed, 111 insertions(+), 7 deletions(-) diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index 0451b325a6..6dbb7b2dfe 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -21,6 +21,8 @@ xcrun simctl list devices booted Reuse a complete stack already running for this worktree. Do not start a competing stack or stop an unrelated `kilo-dev-*` session. +`kiloclaw-docker-tcp` on port 23750 is the sole intentional host-wide exception: it is a stateless loopback proxy to the same Docker socket. The runner reuses an occupied proxy. Never kill a `socat` process owned by another worktree to free this port. + When this worktree has no stack, start the complete mobile flow. Secondary worktrees automatically receive an isolated port offset, and mobile startup generates and explicitly injects this worktree's LAN URLs before Metro starts. Do not export `KILO_PORT_OFFSET` or source `apps/mobile/.env`; stale parent-shell values must not select the bundle endpoints: ```bash @@ -84,7 +86,7 @@ xcrun simctl openurl \ "exp+kilo-app://expo-development-client/?url=http%3A%2F%2F%3A" ``` -Before testing, capture the `mobile` pane and verify `Starting project at /apps/mobile` plus a fresh `iOS Bundled` line. Seeing the Kilo login screen alone does not prove bundle provenance. The login helper dismisses the clean-install tracking alert and Expo dev-menu introduction when present. +Before testing, capture the `mobile` pane and verify `Starting project at /apps/mobile` plus a fresh `iOS Bundled` line. Seeing the Kilo login screen alone does not prove bundle provenance. The login preflight also reads Metro's development manifest and verifies `expoConfig.extra.apiBaseUrl` and `_internal.projectRoot` against this worktree. These endpoint extras come from the Metro manifest in a dev client; after env changes, regenerate env, restart Metro, reconnect the dev client to the exact Metro URL, and reload. Rebuild only when native config/plugins changed. The login helper dismisses the clean-install tracking alert and Expo dev-menu introduction when present. ## Sign In and Out diff --git a/apps/mobile/e2e/flows/open-app.yaml b/apps/mobile/e2e/flows/open-app.yaml index 3ba3752e6f..b727d21272 100644 --- a/apps/mobile/e2e/flows/open-app.yaml +++ b/apps/mobile/e2e/flows/open-app.yaml @@ -1,8 +1,22 @@ appId: com.kilocode.kiloapp --- +- runFlow: + when: + visible: 'Ask App Not to Track' + commands: + - tapOn: 'Ask App Not to Track' +- runFlow: + when: + visible: 'This is the developer menu.*' + commands: + - tapOn: 'Continue' - stopApp -- tapOn: - text: 'Kilo' +- runFlow: + when: + visible: 'Kilo' + commands: + - tapOn: + text: 'Kilo' - extendedWaitUntil: visible: 'Allow “Kilo” to track your activity across other companies’ apps and websites\?|Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' timeout: 30000 diff --git a/apps/mobile/e2e/preflight.sh b/apps/mobile/e2e/preflight.sh index 6aeadebfaa..2095c61d7f 100755 --- a/apps/mobile/e2e/preflight.sh +++ b/apps/mobile/e2e/preflight.sh @@ -77,6 +77,22 @@ process.stdout.write(String(port)); NODE )" METRO_URL="http://${MOBILE_HOST}:${METRO_PORT}" +MANIFEST="$(mktemp)" +trap 'rm -f "$SESSION_PROBE" "$MANIFEST"' EXIT +curl -sS -H 'expo-platform: ios' -H 'expo-protocol-version: 1' \ + -H 'accept: application/expo+json,application/json' "$METRO_URL" >"$MANIFEST" +node - "$MANIFEST" "http://${MOBILE_HOST}:${EXPECTED_API_PORT}" "$REPO_ROOT" <<'NODE' +const fs = require('node:fs'); +const [manifestPath, expectedApiUrl, expectedRoot] = process.argv.slice(2); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const expoClient = manifest.extra?.expoClient; +if (expoClient?.extra?.apiBaseUrl !== expectedApiUrl) { + throw new Error(`Metro manifest API URL is ${expoClient?.extra?.apiBaseUrl}, expected ${expectedApiUrl}`); +} +if (expoClient?._internal?.projectRoot !== `${expectedRoot}/apps/mobile`) { + throw new Error(`Metro manifest belongs to ${expoClient?._internal?.projectRoot}, expected ${expectedRoot}/apps/mobile`); +} +NODE ENCODED_METRO_URL="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$METRO_URL")" if [ "$PLATFORM" = "ios" ]; then xcrun simctl openurl "$DEVICE" \ diff --git a/dev/local/cli.ts b/dev/local/cli.ts index f5d075ec67..7733537232 100644 --- a/dev/local/cli.ts +++ b/dev/local/cli.ts @@ -184,10 +184,15 @@ async function cmdUp(args: string[], repoRoot: string): Promise { } const conflictingPorts: string[] = []; + const reusedHostServices = new Set(); for (const name of serviceNames) { const service = getService(name); if (service.type !== 'infra' && service.port > 0 && (await probePort(service.port))) { - conflictingPorts.push(`${name}:${service.port}`); + if (name === 'kiloclaw-docker-tcp') { + reusedHostServices.add(name); + } else { + conflictingPorts.push(`${name}:${service.port}`); + } } } if (conflictingPorts.length > 0) { @@ -466,6 +471,11 @@ async function cmdUp(args: string[], repoRoot: string): Promise { const skippedServices: string[] = []; for (const name of otherServices) { + if (reusedHostServices.has(name)) { + console.log(`Reusing host ${name} on ${getService(name).port}`); + startedServices.push(name); + continue; + } const dependsOnKiloclaw = getService(name).dependsOn.includes('kiloclaw'); if (!kiloclawTunnelCaptured && (name === 'kiloclaw' || dependsOnKiloclaw)) { skippedServices.push(name); diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts index e3db5745b0..ce41376e04 100644 --- a/dev/local/mobile-workflow.test.ts +++ b/dev/local/mobile-workflow.test.ts @@ -26,6 +26,11 @@ test('login flows never use an unidentified generic Allow selector', () => { assert.match(openApp, /“Kilo” Would Like to Send You Notifications/); }); +test('shared launch clears an already-visible tracking prompt before tapping the app icon', () => { + const flow = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + assert.ok(flow.indexOf("visible: 'Ask App Not to Track'") < flow.indexOf("visible: 'Kilo'")); +}); + test('mobile workflow documents hierarchy-derived tab selectors', () => { const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); @@ -42,6 +47,7 @@ test('login preflight reconnects the claimed iOS device to this worktree Metro U assert.match(preflight, /pnpm -s dev:capture mobile/); assert.match(preflight, /exp\+kilo-app:\/\/expo-development-client\/\?url=/); assert.match(preflight, /session-ingest secret readiness probe failed/); + assert.match(preflight, /Metro manifest API URL is/); assert.match(preflight, /dev:mobile:android claim/); assert.match(preflight, /adb -s "\$DEVICE" reverse/); }); @@ -110,3 +116,13 @@ test('env sync refreshes source-backed Wrangler secrets through completed stdin assert.match(envOutput, /input: `\$\{value\}\\n`/); assert.match(envOutput, /Failed to create Secrets Store secret/); }); + +test('workflow documents the shared Docker proxy exception without weakening backend isolation', () => { + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + const cli = fs.readFileSync('dev/local/cli.ts', 'utf8'); + + assert.match(runbook, /sole intentional host-wide exception/); + assert.match(runbook, /Never kill a `socat` process owned by another worktree/); + assert.match(cli, /name === 'kiloclaw-docker-tcp'/); + assert.match(cli, /Refusing to share occupied worktree service ports/); +}); diff --git a/dev/local/runner.ts b/dev/local/runner.ts index f800500a73..b37fa7a3cd 100644 --- a/dev/local/runner.ts +++ b/dev/local/runner.ts @@ -17,6 +17,7 @@ import { paneHasRunningChild, selectPane, setPaneTitle, + setPaneServiceIdentity, setMainLeftLayout, pipePane, } from './tmux'; @@ -116,6 +117,7 @@ export function startServiceInTmux( const startupCommand = svc.type === 'infra' ? buildInfraLogCommand(serviceName) : buildStartCommand(serviceName); const winIndex = createWindow(sessionName, serviceName, env, startupCommand); + setPaneServiceIdentity(sessionName, winIndex, 0, serviceName); const logPath = path.join(findRepoRoot(), 'dev', 'logs', `${serviceName}.log`); pipePane(sessionName, winIndex, 0, buildLogPipeCommand(logPath)); } diff --git a/dev/local/tmux.test.ts b/dev/local/tmux.test.ts index f1ac20a274..3b6648d56b 100644 --- a/dev/local/tmux.test.ts +++ b/dev/local/tmux.test.ts @@ -5,7 +5,13 @@ import * as os from 'node:os'; import * as path from 'node:path'; import test from 'node:test'; -import { breakPane, buildInteractiveShellCommand, captureServicePane, listWindows } from './tmux'; +import { + breakPane, + buildInteractiveShellCommand, + captureServicePane, + listWindows, + setPaneServiceIdentity, +} from './tmux'; import { buildStartCommand, restartServiceInTmux } from './runner'; test('buildInteractiveShellCommand wraps quoted startup commands in parseable shell syntax', () => { @@ -56,6 +62,7 @@ test( ); const serviceWindow = listWindows(sessionName).find(window => window.name === serviceName); assert.ok(serviceWindow); + setPaneServiceIdentity(sessionName, serviceWindow.index, 0, serviceName); tmux( 'join-pane', '-h', @@ -64,8 +71,6 @@ test( '-t', `${sessionName}:0.0` ); - tmux('select-pane', '-t', `${sessionName}:0.1`, '-T', serviceName); - assert.match(captureServicePane(sessionName, serviceName, 20), /worktree-mobile-pane/); } finally { try { diff --git a/dev/local/tmux.ts b/dev/local/tmux.ts index e1ff5a4afb..e776e96309 100644 --- a/dev/local/tmux.ts +++ b/dev/local/tmux.ts @@ -158,6 +158,26 @@ function createWindow( return parseInt(output, 10); } +function setPaneServiceIdentity( + sessionName: string, + windowTarget: string | number, + pane: number, + serviceName: string +): void { + execFileSync( + 'tmux', + [ + 'set-option', + '-p', + '-t', + `${sessionName}:${windowTarget}.${pane}`, + '@kilo_service', + serviceName, + ], + { stdio: 'ignore' } + ); +} + function buildInteractiveShellCommand( startupCommand: string, shell = process.env.SHELL || '/bin/sh' @@ -376,6 +396,24 @@ type PaneInfo = { windowIndex: number; paneIndex: number }; */ function findServicePane(sessionName: string, serviceName: string): PaneInfo | undefined { try { + const panes = execFileSync( + 'tmux', + [ + 'list-panes', + '-s', + '-t', + sessionName, + '-F', + '#{window_index}:#{pane_index}:#{@kilo_service}', + ], + { encoding: 'utf8' } + ).trim(); + for (const line of panes.split('\n')) { + const [windowIndex, paneIndex, identity] = line.split(':'); + if (identity === serviceName) { + return { windowIndex: Number(windowIndex), paneIndex: Number(paneIndex) }; + } + } // Check named windows first (service in its own window) const windows = listWindows(sessionName); const win = windows.find(w => w.name === serviceName); @@ -533,6 +571,7 @@ export { killSession, attachSession, createWindow, + setPaneServiceIdentity, buildInteractiveShellCommand, sendKeys, sendInterrupt, From c501733e67065eda956c0c62078b5383b77ccf10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 19:16:54 +0200 Subject: [PATCH 05/10] fix(mobile): stabilize E2E tab navigation --- apps/mobile/e2e/login.sh | 3 +++ apps/mobile/src/app/(app)/(tabs)/_layout.tsx | 5 ++++- dev/local/mobile-workflow.test.ts | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/mobile/e2e/login.sh b/apps/mobile/e2e/login.sh index 62b639c5f5..1a489eed4a 100755 --- a/apps/mobile/e2e/login.sh +++ b/apps/mobile/e2e/login.sh @@ -40,6 +40,9 @@ latest_email() { before="$(latest_email)" +echo "==> establishing signed-out baseline" +maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/logout.yaml" + echo "==> requesting sign-in code for $EMAIL" if ! maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml"; then echo "==> retrying launch and sign-in request once after a dev-client startup failure" diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index d8fe7440da..af355d0557 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -79,6 +79,7 @@ export default function TabsLayout() { name="(0_home)" options={{ title: 'Home', + tabBarAccessibilityLabel: 'Home, tab, 1 of 4', tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( @@ -94,7 +95,7 @@ export default function TabsLayout() { name="(1_kiloclaw)" options={{ title: 'KiloClaw', - tabBarAccessibilityLabel: 'KiloClaw', + tabBarAccessibilityLabel: 'KiloClaw, tab, 2 of 4', tabBarLabel: ({ focused }) => ( TAB_LABEL_WRAP_FONT_SCALE ? 'Kilo\nClaw' : 'KiloClaw'} @@ -117,6 +118,7 @@ export default function TabsLayout() { name="(2_agents)" options={{ title: 'Agents', + tabBarAccessibilityLabel: 'Agents, tab, 3 of 4', tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( @@ -132,6 +134,7 @@ export default function TabsLayout() { name="(3_profile)" options={{ title: 'Profile', + tabBarAccessibilityLabel: 'Profile, tab, 4 of 4', tabBarLabel: ({ focused }) => , tabBarIcon: ({ color, focused }) => ( diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts index ce41376e04..ee82c39bae 100644 --- a/dev/local/mobile-workflow.test.ts +++ b/dev/local/mobile-workflow.test.ts @@ -26,6 +26,11 @@ test('login flows never use an unidentified generic Allow selector', () => { assert.match(openApp, /“Kilo” Would Like to Send You Notifications/); }); +test('login establishes a signed-out baseline before requesting a fresh OTP', () => { + const login = fs.readFileSync('apps/mobile/e2e/login.sh', 'utf8'); + assert.ok(login.indexOf('flows/logout.yaml') < login.indexOf('flows/login-request-code.yaml')); +}); + test('shared launch clears an already-visible tracking prompt before tapping the app icon', () => { const flow = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); assert.ok(flow.indexOf("visible: 'Ask App Not to Track'") < flow.indexOf("visible: 'Kilo'")); @@ -40,6 +45,19 @@ test('mobile workflow documents hierarchy-derived tab selectors', () => { assert.match(runbook, /dev:mobile:simulator claim/); }); +test('tab layout exposes the exact documented accessibility labels', () => { + const layout = fs.readFileSync('apps/mobile/src/app/(app)/(tabs)/_layout.tsx', 'utf8'); + + for (const label of [ + 'Home, tab, 1 of 4', + 'KiloClaw, tab, 2 of 4', + 'Agents, tab, 3 of 4', + 'Profile, tab, 4 of 4', + ]) { + assert.match(layout, new RegExp(`tabBarAccessibilityLabel: '${label}'`)); + } +}); + test('login preflight reconnects the claimed iOS device to this worktree Metro URL', () => { const preflight = fs.readFileSync('apps/mobile/e2e/preflight.sh', 'utf8'); From a808299f0fad59b1a9fd101ce1b5724915c513a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 19:32:19 +0200 Subject: [PATCH 06/10] docs(mobile): prefer Maestro for Android E2E --- apps/mobile/.kilo/agent/mobile-e2e-verifier.md | 2 +- apps/mobile/e2e/AGENTS.md | 2 +- dev/local/mobile-workflow.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md index 6e4ed7d17e..6142444e68 100644 --- a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md +++ b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md @@ -27,7 +27,7 @@ During verification: - Run the login/logout helper preflight rather than manually inferring bundle provenance, ports, or simulator ownership. - Run `pnpm dev:mobile:android doctor` before declaring Android tooling unavailable; do not rely on the inherited `PATH`. -- Use repository-wrapped ADB or Maestro for Android, whichever is more effective for the flow. Use Maestro as the primary iOS driver. +- Use Maestro as the primary driver on iOS and Android. Fall back to repository-wrapped ADB on Android only when Maestro cannot inspect or operate the state, or when low-level emulator control is required. - Inspect the current screen before selecting Maestro elements and re-inspect after UI changes. Copy exact hierarchy text; never infer selectors from screenshots or visible tab captions. - Exercise every applicable feature state that can be produced safely and deterministically. A skipped state requires an explicit rationale; do not silently omit it. - Confirm retryable and empty states show a meaningful message and actionable CTA, and that the CTA performs the expected recovery or next step. diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index 6dbb7b2dfe..c0a4963972 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -205,7 +205,7 @@ cd apps/mobile pnpm -w dev:mobile:android build ``` -For Android, choose repository-wrapped ADB or Maestro based on which is more effective for the flow. ADB is useful for direct hierarchy inspection, prompt handling, intent launch, and deterministic shell input; Maestro is useful for reusable cross-screen flows such as OTP login. iOS uses Maestro. Use the repository wrapper rather than bare `adb`: +Use Maestro as the primary Android automation driver, matching iOS. Fall back to repository-wrapped ADB when Maestro cannot inspect or operate a native prompt, when direct intent/process control is required, or when diagnosing the emulator itself. Android setup still uses ADB for readiness and port reversal. Use the repository wrapper rather than bare `adb`: ```bash pnpm dev:mobile:android adb devices -l diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts index ee82c39bae..cfa5a26f68 100644 --- a/dev/local/mobile-workflow.test.ts +++ b/dev/local/mobile-workflow.test.ts @@ -90,15 +90,15 @@ test('Android tooling is resolved independently of the agent PATH', async () => assert.match(env.path, /android-commandlinetools\/platform-tools/); }); -test('Android workflow uses adb first and applies resolved tooling to Expo builds', () => { +test('Android workflow uses Maestro first and applies resolved tooling to Expo builds', () => { const android = fs.readFileSync('dev/local/mobile-android.ts', 'utf8'); const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); assert.match(android, /'build'/); assert.match(android, /'run:android'/); assert.match(android, /path\.join\(worktreeRoot, 'apps\/mobile'\)/); - assert.match(runbook, /choose repository-wrapped ADB or Maestro/); - assert.match(runbook, /iOS uses Maestro/); + assert.match(runbook, /Use Maestro as the primary Android automation driver/); + assert.match(runbook, /Fall back to repository-wrapped ADB/); }); test('Android tooling rejects a non-Java-17 JAVA_HOME', async () => { From 07af66a74f3086eb5846900583a875e3ddda63cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 19:34:02 +0200 Subject: [PATCH 07/10] docs(mobile): make Maestro the default E2E driver --- apps/mobile/.kilo/agent/mobile-e2e-verifier.md | 2 +- apps/mobile/e2e/AGENTS.md | 2 ++ dev/local/mobile-workflow.test.ts | 9 +++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md index 6142444e68..ffb56fc933 100644 --- a/apps/mobile/.kilo/agent/mobile-e2e-verifier.md +++ b/apps/mobile/.kilo/agent/mobile-e2e-verifier.md @@ -27,7 +27,7 @@ During verification: - Run the login/logout helper preflight rather than manually inferring bundle provenance, ports, or simulator ownership. - Run `pnpm dev:mobile:android doctor` before declaring Android tooling unavailable; do not rely on the inherited `PATH`. -- Use Maestro as the primary driver on iOS and Android. Fall back to repository-wrapped ADB on Android only when Maestro cannot inspect or operate the state, or when low-level emulator control is required. +- Use Maestro as the primary driver on iOS and Android. Fall back to `xcrun simctl` on iOS or repository-wrapped ADB on Android only when Maestro cannot inspect or operate the state, or when low-level device control is required. - Inspect the current screen before selecting Maestro elements and re-inspect after UI changes. Copy exact hierarchy text; never infer selectors from screenshots or visible tab captions. - Exercise every applicable feature state that can be produced safely and deterministically. A skipped state requires an explicit rationale; do not silently omit it. - Confirm retryable and empty states show a meaningful message and actionable CTA, and that the CTA performs the expected recovery or next step. diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index c0a4963972..f87ab73386 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -133,6 +133,8 @@ brew install maestro For MCP use stdio command `maestro mcp`, then restart the agent session so its tools appear. Inspect the screen before selecting elements and re-inspect after UI changes. Never guess a selector from the visible label or screenshot: copy the exact `txt` or `a11y` value from `maestro_inspect_screen`, mapping `a11y` to Maestro `text:`. Maestro text matching is full-string regex, not substring matching. +Use Maestro as the primary iOS automation driver. Fall back to `xcrun simctl` only when Maestro cannot inspect or operate a native state, or when low-level simulator control is required. iOS setup still uses `simctl` for boot, install/uninstall, dev-client URL reconnection, screenshots, shutdown, and cleanup. + Tab buttons are exposed through React Navigation's full accessibility labels, not the visible uppercase text. Current iOS labels are `Home, tab, 1 of 4`, `KiloClaw, tab, 2 of 4`, `Agents, tab, 3 of 4`, and `Profile, tab, 4 of 4`. `tapOn: 'Agents'` is wrong. Inspect again before using these examples because tab count or labels can change. CLI fallback: diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts index cfa5a26f68..996b51be4e 100644 --- a/dev/local/mobile-workflow.test.ts +++ b/dev/local/mobile-workflow.test.ts @@ -101,6 +101,15 @@ test('Android workflow uses Maestro first and applies resolved tooling to Expo b assert.match(runbook, /Fall back to repository-wrapped ADB/); }); +test('iOS workflow uses Maestro first with simctl as the low-level fallback', () => { + const runbook = fs.readFileSync('apps/mobile/e2e/AGENTS.md', 'utf8'); + const verifier = fs.readFileSync('apps/mobile/.kilo/agent/mobile-e2e-verifier.md', 'utf8'); + + assert.match(runbook, /Use Maestro as the primary iOS automation driver/); + assert.match(runbook, /Fall back to `xcrun simctl`/); + assert.match(verifier, /Fall back to `xcrun simctl` on iOS/); +}); + test('Android tooling rejects a non-Java-17 JAVA_HOME', async () => { const { resolveAndroidEnvironment } = await import('./mobile-android'); assert.throws( From e68e45a0e2b2a98892b1326200f67576b0d12e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 19:53:04 +0200 Subject: [PATCH 08/10] perf(mobile): speed up E2E authentication flows --- apps/mobile/e2e/AGENTS.md | 2 +- apps/mobile/e2e/flows/login-request-code.yaml | 40 ++++-------- apps/mobile/e2e/flows/login-verify-code.yaml | 11 +++- apps/mobile/e2e/flows/logout.yaml | 10 ++- apps/mobile/e2e/flows/open-app.yaml | 10 +-- apps/mobile/e2e/flows/settle-app.yaml | 27 ++++++++ apps/mobile/e2e/login.sh | 18 ++---- dev/local/mobile-workflow.test.ts | 62 +++++++++++++++++-- 8 files changed, 125 insertions(+), 55 deletions(-) create mode 100644 apps/mobile/e2e/flows/settle-app.yaml diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index f87ab73386..7fad0f3b03 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -97,7 +97,7 @@ apps/mobile/e2e/login.sh [email] # default: e2e-mobile@example.com apps/mobile/e2e/logout.sh ``` -Login requests an email OTP, waits up to 30 seconds for the worktree-local outbox, verifies it, accepts first-account consent, and asserts Home. It retries only the known dev-client launch/request boundary once. Login and logout share `flows/open-app.yaml`, which waits for late tracking and Expo developer-menu prompts before continuing. +Login requests an email OTP, waits up to 30 seconds for the worktree-local outbox, verifies it, accepts first-account consent, and asserts Home. It retries only the known dev-client launch/request boundary once. The wrappers use preflight to open the exact dev-client URL, then `flows/settle-app.yaml` handles late tracking and Expo developer-menu prompts without restarting the app. `flows/open-app.yaml` remains the standalone cold-launch flow. Native prompts are states in the flow, not errors to tap through blindly. The shared launch flow recognizes the iOS tracking prompt (`Allow “Kilo” to track your activity across other companies’ apps and websites?`) and chooses `Ask App Not to Track`; login handles notification permission after authentication. Feature-triggered prompts such as speech recognition and microphone access must be handled only when the acceptance flow reaches that feature: inspect the hierarchy, copy the exact button accessibility text (`Allow` or `Don’t Allow`), and choose the state required by the test. Never use a generic `tapOn: 'Allow'` before identifying which prompt is visible. diff --git a/apps/mobile/e2e/flows/login-request-code.yaml b/apps/mobile/e2e/flows/login-request-code.yaml index 6012e5c610..0d281fb4a6 100644 --- a/apps/mobile/e2e/flows/login-request-code.yaml +++ b/apps/mobile/e2e/flows/login-request-code.yaml @@ -1,7 +1,7 @@ # Login step 1/2 — request an email sign-in code. # -# Fresh-launches the app and, if signed out, submits EMAIL to reach the -# "Enter the code" screen. The 6-digit code is emailed to the local outbox +# Establishes a signed-out baseline, then submits EMAIL to reach the "Enter the +# code" screen. The 6-digit code is emailed to the local outbox # (dev/logs/emails/); read it there and pass it to login-verify-code.yaml. # # maestro --device test -e EMAIL=e2e-mobile@example.com \ @@ -14,29 +14,13 @@ # code for you. See e2e/AGENTS.md ("Login / logout helper flows"). appId: com.kilocode.kiloapp --- -- runFlow: open-app.yaml -# Three possible launch states: Home (signed in), the consent gate (signed in, -# not yet consented), or the login page (signed out). -# Booted into the consent gate (already signed in) -> accept it. -- runFlow: - when: - visible: 'Accept and continue' - commands: - - tapOn: 'Accept and continue' - - extendedWaitUntil: - visible: 'HOME|Home, tab, 1 of 4' - timeout: 15000 -# Signed out -> submit the email to trigger the code. -- runFlow: - when: - visible: 'Welcome to Kilo Code' - commands: - # Tap the input via its placeholder — the "Email address" label shares the - # field's accessibility text and would match the (non-focusable) label. - - tapOn: 'you@example.com' - - inputText: ${EMAIL} - - tapOn: 'Send sign-in code' - # Wait for the code screen — sending is a network round-trip. - - extendedWaitUntil: - visible: 'Verify code' - timeout: 15000 +- runFlow: logout.yaml +# Tap the input via its placeholder — the "Email address" label shares the +# field's accessibility text and would match the (non-focusable) label. +- tapOn: 'you@example.com' +- inputText: ${EMAIL} +- tapOn: 'Send sign-in code' +# Wait for the code screen — sending is a network round-trip. +- extendedWaitUntil: + visible: 'Verify code' + timeout: 15000 diff --git a/apps/mobile/e2e/flows/login-verify-code.yaml b/apps/mobile/e2e/flows/login-verify-code.yaml index 4d9faef73e..db62612a9f 100644 --- a/apps/mobile/e2e/flows/login-verify-code.yaml +++ b/apps/mobile/e2e/flows/login-verify-code.yaml @@ -17,14 +17,21 @@ appId: com.kilocode.kiloapp # A brand-new account's first sign-in shows a consent screen before Home. - extendedWaitUntil: visible: - text: 'Accept and continue|HOME|Home, tab, 1 of 4' + text: 'Accept and continue|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4' timeout: 20000 - runFlow: when: visible: 'Accept and continue' commands: - tapOn: 'Accept and continue' -- runFlow: open-app.yaml +- extendedWaitUntil: + visible: '“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4' + timeout: 15000 +- runFlow: + when: + visible: '“Kilo” Would Like to Send You Notifications' + commands: + - tapOn: 'Allow' - extendedWaitUntil: visible: 'HOME|Home, tab, 1 of 4' timeout: 15000 diff --git a/apps/mobile/e2e/flows/logout.yaml b/apps/mobile/e2e/flows/logout.yaml index 6bb086ece0..f89159549e 100644 --- a/apps/mobile/e2e/flows/logout.yaml +++ b/apps/mobile/e2e/flows/logout.yaml @@ -11,7 +11,15 @@ # - See e2e/AGENTS.md ("Login / logout helper flows") for the full workflow. appId: com.kilocode.kiloapp --- -- runFlow: open-app.yaml +- runFlow: + when: + notVisible: 'Welcome to Kilo Code' + commands: + - runFlow: + when: + notVisible: 'HOME|Home, tab, 1 of 4' + commands: + - runFlow: settle-app.yaml # Booted into the consent gate (signed in, not consented) -> accept it to reach # Home, then sign out below. - runFlow: diff --git a/apps/mobile/e2e/flows/open-app.yaml b/apps/mobile/e2e/flows/open-app.yaml index b727d21272..aa34c935fd 100644 --- a/apps/mobile/e2e/flows/open-app.yaml +++ b/apps/mobile/e2e/flows/open-app.yaml @@ -21,8 +21,8 @@ appId: com.kilocode.kiloapp visible: 'Allow “Kilo” to track your activity across other companies’ apps and websites\?|Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' timeout: 30000 - extendedWaitUntil: - visible: 'Ask App Not to Track' - timeout: 15000 + visible: 'Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications' + timeout: 3000 optional: true - runFlow: when: @@ -31,17 +31,13 @@ appId: com.kilocode.kiloapp - tapOn: 'Ask App Not to Track' - extendedWaitUntil: visible: 'This is the developer menu.*' - timeout: 15000 + timeout: 2000 optional: true - runFlow: when: visible: 'This is the developer menu.*' commands: - tapOn: 'Continue' -- extendedWaitUntil: - visible: '“Kilo” Would Like to Send You Notifications' - timeout: 5000 - optional: true - runFlow: when: visible: '“Kilo” Would Like to Send You Notifications' diff --git a/apps/mobile/e2e/flows/settle-app.yaml b/apps/mobile/e2e/flows/settle-app.yaml new file mode 100644 index 0000000000..d03b043e72 --- /dev/null +++ b/apps/mobile/e2e/flows/settle-app.yaml @@ -0,0 +1,27 @@ +appId: com.kilocode.kiloapp +--- +- extendedWaitUntil: + visible: 'Allow “Kilo” to track your activity across other companies’ apps and websites\?|Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications|HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 15000 +- extendedWaitUntil: + visible: 'Ask App Not to Track|This is the developer menu.*|“Kilo” Would Like to Send You Notifications' + timeout: 3000 + optional: true +- runFlow: + when: + visible: 'Ask App Not to Track' + commands: + - tapOn: 'Ask App Not to Track' +- runFlow: + when: + visible: 'This is the developer menu.*' + commands: + - tapOn: 'Continue' +- runFlow: + when: + visible: '“Kilo” Would Like to Send You Notifications' + commands: + - tapOn: 'Allow' +- extendedWaitUntil: + visible: 'HOME|Home, tab, 1 of 4|Welcome to Kilo Code|Accept and continue' + timeout: 15000 diff --git a/apps/mobile/e2e/login.sh b/apps/mobile/e2e/login.sh index 1a489eed4a..b39804710b 100755 --- a/apps/mobile/e2e/login.sh +++ b/apps/mobile/e2e/login.sh @@ -3,7 +3,7 @@ # # Requests an email sign-in code, reads it from the local email outbox # (dev/logs/emails/, written by the Next.js dev server), and verifies it — -# leaving the app signed in on Home. No-op if already signed in. +# leaving the app signed in on Home. # # Usage: # e2e/login.sh [email] @@ -40,10 +40,7 @@ latest_email() { before="$(latest_email)" -echo "==> establishing signed-out baseline" -maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/logout.yaml" - -echo "==> requesting sign-in code for $EMAIL" +echo "==> signing out and requesting sign-in code for $EMAIL" if ! maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml"; then echo "==> retrying launch and sign-in request once after a dev-client startup failure" maestro --device "$DEVICE" test -e "EMAIL=$EMAIL" "$SCRIPT_DIR/flows/login-request-code.yaml" @@ -51,23 +48,20 @@ fi # Wait for a newer outbox email than we had before (the send is async). code="" -for _ in $(seq 1 30); do +for _ in $(seq 1 120); do after="$(latest_email)" if [ -n "$after" ] && [ "$after" != "$before" ]; then code="$(perl -0777 -ne 'print $1 if /letter-spacing:\s*8px.*?>\s*(\d{6})\s* no new sign-in code; verifying existing session" - maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/login-assert-home.yaml" - echo "==> already signed in" - exit + echo "==> no new sign-in code received" >&2 + exit 1 fi echo "==> verifying sign-in code" maestro --device "$DEVICE" test -e "OTP=$code" "$SCRIPT_DIR/flows/login-verify-code.yaml" -maestro --device "$DEVICE" test "$SCRIPT_DIR/flows/login-assert-home.yaml" echo "==> signed in" diff --git a/dev/local/mobile-workflow.test.ts b/dev/local/mobile-workflow.test.ts index 996b51be4e..7c9bfd1c35 100644 --- a/dev/local/mobile-workflow.test.ts +++ b/dev/local/mobile-workflow.test.ts @@ -9,13 +9,14 @@ test('login waits for the delayed Expo developer menu after launching Kilo', () "visible: 'This is the developer menu.*'", launchIndex ); + const optionalWaitIndex = flow.lastIndexOf('- extendedWaitUntil:', developerMenuWaitIndex); const continueIndex = flow.indexOf("tapOn: 'Continue'", developerMenuWaitIndex); assert.ok(launchIndex >= 0); assert.ok(developerMenuWaitIndex > launchIndex); assert.ok(continueIndex > developerMenuWaitIndex); - assert.match(flow.slice(developerMenuWaitIndex - 80, continueIndex), /timeout: 15000/); - assert.match(flow.slice(developerMenuWaitIndex - 80, continueIndex), /optional: true/); + assert.match(flow.slice(optionalWaitIndex, continueIndex), /timeout: 2000/); + assert.match(flow.slice(optionalWaitIndex, continueIndex), /optional: true/); }); test('login flows never use an unidentified generic Allow selector', () => { @@ -26,9 +27,62 @@ test('login flows never use an unidentified generic Allow selector', () => { assert.match(openApp, /“Kilo” Would Like to Send You Notifications/); }); -test('login establishes a signed-out baseline before requesting a fresh OTP', () => { +test('login verification does not pay a fixed optional notification wait', () => { + const verify = fs.readFileSync('apps/mobile/e2e/flows/login-verify-code.yaml', 'utf8'); + + assert.doesNotMatch(verify, /optional: true/); + assert.match(verify, /“Kilo” Would Like to Send You Notifications\|HOME/); +}); + +test('login request establishes a signed-out baseline before requesting a fresh OTP', () => { + const request = fs.readFileSync('apps/mobile/e2e/flows/login-request-code.yaml', 'utf8'); + assert.ok(request.indexOf('logout.yaml') < request.indexOf("tapOn: 'Send sign-in code'")); +}); + +test('login reuses the app state established by logout instead of relaunching each step', () => { + const request = fs.readFileSync('apps/mobile/e2e/flows/login-request-code.yaml', 'utf8'); + const verify = fs.readFileSync('apps/mobile/e2e/flows/login-verify-code.yaml', 'utf8'); + const login = fs.readFileSync('apps/mobile/e2e/login.sh', 'utf8'); + + assert.doesNotMatch(request, /open-app\.yaml/); + assert.doesNotMatch(verify, /open-app\.yaml/); + assert.doesNotMatch(login, /maestro .*logout\.yaml/); + assert.doesNotMatch(login, /login-assert-home\.yaml/); +}); + +test('login polls the local outbox without one-second latency', () => { const login = fs.readFileSync('apps/mobile/e2e/login.sh', 'utf8'); - assert.ok(login.indexOf('flows/logout.yaml') < login.indexOf('flows/login-request-code.yaml')); + assert.match(login, /sleep 0\.25/); +}); + +test('shared launch prompt grace periods total at most five seconds', () => { + const flow = fs.readFileSync('apps/mobile/e2e/flows/open-app.yaml', 'utf8'); + const optionalWaits = [...flow.matchAll(/timeout: (\d+)\n\s+optional: true/g)].map(match => + Number(match[1]) + ); + + assert.equal( + optionalWaits.reduce((total, timeout) => total + timeout, 0), + 5000 + ); +}); + +test('helper-driven logout settles the app already launched by preflight', () => { + const logout = fs.readFileSync('apps/mobile/e2e/flows/logout.yaml', 'utf8'); + const settle = fs.readFileSync('apps/mobile/e2e/flows/settle-app.yaml', 'utf8'); + + assert.match(logout, /settle-app\.yaml/); + assert.doesNotMatch(logout, /open-app\.yaml/); + assert.doesNotMatch(settle, /stopApp|text: 'Kilo'/); + assert.match(settle, /timeout: 3000/); +}); + +test('logout skips prompt settling for stable signed-in and signed-out states', () => { + const logout = fs.readFileSync('apps/mobile/e2e/flows/logout.yaml', 'utf8'); + const settleIndex = logout.indexOf('settle-app.yaml'); + + assert.ok(logout.indexOf("notVisible: 'Welcome to Kilo Code'") < settleIndex); + assert.ok(logout.indexOf("notVisible: 'HOME|Home, tab, 1 of 4'") < settleIndex); }); test('shared launch clears an already-visible tracking prompt before tapping the app icon', () => { From 95b9f4e049e2c458e10200f7e24b4466645a28af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 20:30:34 +0200 Subject: [PATCH 09/10] refactor(mobile): remove redundant E2E workflow code --- apps/mobile/e2e/AGENTS.md | 3 +-- apps/mobile/e2e/flows/logout.yaml | 6 +----- dev/local/mobile-env.ts | 6 +----- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index 7fad0f3b03..2008fb03ab 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -32,7 +32,7 @@ pnpm drizzle migrate pnpm dev:status --json ``` -`dev:env:mobile` writes this stack's LAN URLs. Restart Next.js for native auth callbacks and Metro for bundled Expo env values. The session-ingest env step creates the JWT Secrets Store binding; without it the worker can appear healthy while rejecting every session request. `event-service` is required for presence and notification behavior. +The session-ingest env step creates the JWT Secrets Store binding; without it the worker can appear healthy while rejecting every session request. `event-service` is required for presence and notification behavior. Secrets Store state is local to each Worker directory. `dev:start` runs env sync for its selected service graph and refreshes every source-backed secret before launching Workers; secret creation failures are fatal. Do not run bare `wrangler secrets-store` commands: use `pnpm dev:env -y ` from the repository root so values come from the canonical local source and Wrangler receives a complete non-interactive prompt. @@ -53,7 +53,6 @@ Do not guess a tmux window from `tmux ls` or use `:` directly; ```bash tmux ls tmux list-windows -t -tmux capture-pane -p -t : -S -200 ``` Put any extra long-lived CLI, recorder, or log follower in a clearly named `kilo-e2e-*` tmux session so it is visible and easy to remove. diff --git a/apps/mobile/e2e/flows/logout.yaml b/apps/mobile/e2e/flows/logout.yaml index f89159549e..7ad6213627 100644 --- a/apps/mobile/e2e/flows/logout.yaml +++ b/apps/mobile/e2e/flows/logout.yaml @@ -4,11 +4,7 @@ # # maestro --device test e2e/flows/logout.yaml # -# Notes: -# - Launches the dev build by tapping its home-screen icon. `launchApp` cold- -# restarts the Expo dev client and bounces back to SpringBoard, so we stop the -# app and relaunch via the icon instead (reliable for dev builds). -# - See e2e/AGENTS.md ("Login / logout helper flows") for the full workflow. +# See e2e/AGENTS.md for the full workflow. appId: com.kilocode.kiloapp --- - runFlow: diff --git a/dev/local/mobile-env.ts b/dev/local/mobile-env.ts index 20a2a0e208..94dc6bf101 100644 --- a/dev/local/mobile-env.ts +++ b/dev/local/mobile-env.ts @@ -21,7 +21,6 @@ const URL_KEY_TO_SERVICE = new Map; type PreparedMobileEnvironment = { appUrl: string; - host: string; sessionEnv: Record; }; @@ -202,10 +201,7 @@ function prepareMobileEnvironment(repoRoot: string, host: string): PreparedMobil const appUrl = serviceUrl(host, 'nextjs', 'http'); return { appUrl, - host, - sessionEnv: { - ...Object.fromEntries(mobileValues), - }, + sessionEnv: Object.fromEntries(mobileValues), }; } From 8b20231c3c0f75a50a606dbd49af84cd54e17a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 20:31:44 +0200 Subject: [PATCH 10/10] docs: document Kilobot review workflow --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 63eca33c1f..8018f88cf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,3 +69,12 @@ package manifests before running repository JavaScript or package scripts. Load ## Security Baseline - Never log tokens, credentials, API keys, authentication headers, cookies, or webhook secrets. Use `redactSensitiveHeaders` when headers must be retained or logged. Do not enable `sendDefaultPii` or `attachRpcInput` in Sentry. + +## Kilobot Review Remarks + +When addressing a Kilobot review remark: + +1. Verify that the remark is valid. If unsure, attempt to reproduce it with an E2E or unit test. +2. If valid, fix it and commit the change. Prefer small commits in general. +3. Reply in the review thread, then resolve the thread. +4. Push the commits.