mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
Merge remote-tracking branch 'origin/main' into capy/fixwave-C1-windows-setup-degrade
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md # VERSION # agents-digest/gstack-AGENTS.md # package.json
This commit is contained in:
commit
4637fae03b
21 files changed
+1719
-235
No files matched your search
+85
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.43.0] - 2026-10-07
|
||||
## [1.91.43.0] - 2026-10-08
|
||||
|
||||
**Windows setup finishes when the optional /cso helper fails to build, and it tells you when Smart App Control blocks gstack's binaries.**
|
||||
|
||||
@@ -43,6 +43,90 @@ Run `/gstack-upgrade` (or `git pull && ./setup`), then run `~/.claude/skills/gst
|
||||
- Deliberately not done: a `bun run` shim for blocked binaries. PowerShell callers could not use it, and it would load the calling project's `.env` and `bunfig.toml`.
|
||||
- Thanks to @dviolante for the #3071 report and its diagnosis, including the PowerShell 5.1 crash and the `pwsh` fix. Thanks to @tomfluff for the font fix (#2287, landed with original authorship). The Smart App Control diagnosis came from @knetics9000 (#2595, #2596), @smartjelic-sys (#2595), @pstilwell90 (#2124), @cko32002 (#2265) and @salluexez (#2127).
|
||||
|
||||
## [1.91.42.0] - 2026-10-08
|
||||
|
||||
**/cso lifts its file-count ceiling: repositories with tens of thousands of files reach the audit.**
|
||||
**Repositories with more than 64 MiB of tracked source, or with a tracked symlink, are still refused, and the message now says which limit applied and what to do.**
|
||||
|
||||
/cso refused any repository with more than about 3,100 files before an audit started, with "Snapshot manifest exceeds the 1 MiB private-state admission limit" (#3068). The snapshot's file list shared a 1 MiB limit with every other private file /cso keeps, and so did the report it writes, the list of files with secret-like findings, and Git's own file listing. The snapshot list now has its own 16 MiB limit (about 50,000 files) and is written compactly. Per-file lists in the report and the evidence file keep their first entries and end with one line saying how many more there are. The counts in the report stay exact.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Shallow clones measured on 2026-10-08 with `start --offline`, before and after this release:
|
||||
|
||||
| Repository | Files | Tracked source | Before | After |
|
||||
|---|---|---|---|---|
|
||||
| sveltejs/svelte | 9,182 | 6.9 MiB | refused (file list over 1 MiB) | audit starts; 2.8 MiB file list, 10 KB report |
|
||||
| facebook/react | 7,252 | 38.7 MiB | refused (file list over 1 MiB) | audit starts; 2.3 MiB file list, 53 KB report |
|
||||
| laravel/framework | 3,439 | 25.7 MiB | refused (file list over 1 MiB) | audit starts; 1.0 MiB file list, 30 KB report |
|
||||
| django/django | 7,085 | 44.0 MiB | refused (4 tracked symlinks) | refused (symlinks); starts once they are removed |
|
||||
| rails/rails | 5,007 | 38.1 MiB | refused (1 tracked symlink) | refused (symlink); starts once it is removed |
|
||||
| hashicorp/terraform | 5,562 | 25.6 MiB | refused (10 tracked symlinks) | refused (symlinks); starts once they are removed |
|
||||
| grafana/grafana | 23,749 | 210.7 MiB | refused (Git file listing over 1 MiB) | refused (symlinks); then the 64 MiB source cap |
|
||||
| microsoft/vscode | 20,238 | 540.0 MiB | refused (Git file listing over 1 MiB) | refused (symlink); then the 64 MiB source cap |
|
||||
|
||||
On a synthetic 5,000-file repository with long nested paths, where most files are excluded or withheld and 1,000 files carry secret-like strings, the report is 267 KB and the evidence file 524 KB. Unbounded, the same lists took 1.97 MB and 1.68 MB, over the 1 MiB limit. A free test drives that shape through start, inspect, findings, finish and recheck.
|
||||
|
||||
### What this means for you
|
||||
|
||||
Run `/gstack-upgrade`, then run `/cso` on a repository with more than 3,100 files: the audit starts instead of stopping at the snapshot. If /cso still refuses, the message names the limit (the 16 MiB snapshot list, the 64 MiB source cap, or a symlink), the measured size, what counts toward it and the next step. No setting raises these limits yet; the 64 MiB source cap is tracked in #2993, and `docs/troubleshooting.md#cso-capacity` explains all three.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
- /cso refused repositories with more than about 3,100 files before an audit. The snapshot file list is written compactly and capped at 16 MiB; every other private file keeps its 1 MiB limit. Snapshot lists written by earlier releases still load in recheck and inspect. (#3068)
|
||||
- Report coverage gaps, exclusions and transformations, and the evidence file of secret-like findings, keep their first entries and end with an omitted count, so a large repository no longer overflows the report partway through an audit.
|
||||
- Git's file listings during capture are allowed up to the snapshot list size, so repositories past roughly 11,000 to 23,000 files, depending on path length (7,000 to 10,000 with `--diff` or `--base`), no longer fail with "Could not read bounded Git metadata: ls-tree exceeded the output limit".
|
||||
- The snapshot-list and 64 MiB source-cap errors state the measured value, the limit, what counts toward it, the next step and the #2993 link. A single file that would cross 64 MiB now gets the same message before it is read. (#2993)
|
||||
|
||||
#### For contributors
|
||||
- `readJson` takes a per-artifact size cap. `readSnapshotManifest(dir)` is the only reader of `snapshot.json`, and a source test fails on a direct read.
|
||||
- `boundedList` in `lib/cso/state.ts` bounds a per-entry list by the pretty-printed bytes it adds and appends one omitted-count item.
|
||||
- `runProcess` lets raw callers opt in to up to 16 MiB of output; everything else keeps the `MAX_OUTPUT` clamp, which stays 1 MiB.
|
||||
- Thanks to @almoatasemm for the report (#3068) and @saanjay for the capacity diagnosis of the manifest, reader and source caps (#2993).
|
||||
|
||||
## [1.91.38.0] - 2026-10-07
|
||||
|
||||
**`/ios-qa` works on iPads, and a dropped USB route no longer restarts the app you are testing.**
|
||||
|
||||
Three things went wrong on real devices. The daemon accepted only iPhones, so a paired iPad was refused with "not an iPhone" (#2743). Once a session was running, any blip in Xcode 26's CoreDevice tunnel made the daemon start over: the app had already deleted its one-use boot token, so the only way back in was `devicectl process launch --terminate-existing`, which threw away the app's in-memory QA state in the middle of a test flow (#1975). And if the app could not write that boot token in the first place, `StateServer` failed silently with `try?`, so the daemon kept relaunching an app that could never let it in (#1837).
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Measured with the simulated-device tests in `ios-qa/daemon/test/daemon-integration.test.ts` (a `devicectl` emulator plus a StateServer with the real token rules). Real iPhone and iPad runs have not been done for this release.
|
||||
|
||||
| Check | Before | After |
|
||||
|---|---|---|
|
||||
| Paired iPad on USB, no target set | refused ("not an iPhone") | bootstraps |
|
||||
| iPhone and iPad both on USB, no target set | silently drives whichever devicectl listed first | stops and lists both UDIDs with a ready `export GSTACK_IOS_TARGET_UDID=...` line |
|
||||
| One route drop during a session | 1 `--terminate-existing` relaunch, in-app state lost | 0 relaunches, state kept, same bearer |
|
||||
| Three requests failing on the same drop | one bootstrap and one relaunch | one shared recovery, no relaunch |
|
||||
| App that cannot write its boot token | silent; daemon relaunches it once, then `boot_token_unavailable` with no cause | `NOT READY` in the device log; daemon names the cause and does not relaunch |
|
||||
|
||||
### What this means for you
|
||||
|
||||
Run `/gstack-upgrade`, then rerun `gstack-ios-qa-regen` in your app so the new `StateServer` lands in `DebugBridge/`. Plug in an iPad and run `/ios-qa`: it connects like an iPhone. With an iPhone and an iPad both plugged in, the daemon prints both UDIDs and the `export GSTACK_IOS_TARGET_UDID=<udid>` line to run. When the tunnel drops mid-session, the daemon log says `tunnel recovered: kept the session` and the app keeps its state. A restarted daemon still relaunches the app once, because a new daemon has no session bearer.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
- **Physical iPads are accepted** (#2743). Device selection takes iPhones and iPads (platform iOS or iPadOS); errors say "iPhone or iPad". Watches, Vision Pro and other devices are still refused. Contributed by @loulanyue (#2779), reported by @Artic0din.
|
||||
- **Two devices are never guessed between.** When an iPhone and an iPad (or any two devices) tie for the default, bootstrap fails with `multiple_devices`, lists each device with its UDID, and prints the export line.
|
||||
- **A route drop keeps the session** (#1975, finding 1). On `503 device_disconnected` or `504 upstream_timeout` the daemon re-selects the device with the same rules, re-resolves the tunnel address for the same UDID, checks the unauthenticated `/healthz` owner, and probes `/state/snapshot` with the bearer it already holds. If the app accepts it, the session continues. It bootstraps only when the app rejects the bearer (401, the app was relaunched), the app is confirmed not running (one normal launch), or a different device is now selected. The bearer is only sent to the address `devicectl` reports for that UDID, or to the address the session already used, never to another device. Concurrent failures share one recovery, and a tap or other mutation whose response was lost is still never replayed. Diagnosis by @jpb33333 in #1975; @Bmathews721 traced the same rotate-then-rebootstrap failure in #1796.
|
||||
- **A failed boot-token write is loud** (#1837). `StateServer.start()` writes the 0600 token file in a `do`/`catch`, logs the path and error (never the token), logs `gstack-ios-qa-bootstrap NOT READY`, and reports `boot_token_error` on `/healthz`. The daemon turns that into `boot_token_unavailable` with the cause instead of relaunching the app. Reported by @aweevenson-sea.
|
||||
|
||||
#### Docs
|
||||
- `/ios-qa` no longer tells you to capture the boot token from `os_log`; the token left `os_log` in v1.65.0.0. The stale comments in `StateServer` are gone too (#1837, #1735 item 7).
|
||||
- Source-control guidance for the generated `DebugBridge/` package: commit it or ignore it, never hand-edit it (#1735 item 7, reported by @frank-alvarado).
|
||||
- A new "Known limits" section: in-process synthesized touches do not reach SwiftUI `DragGesture` on iOS 26 and `/swipe` only scrolls a `UIScrollView` (#1975 findings 2 and 3, @jpb33333); on iOS 26.3.1 `/elements` returns only the hosting views (device data from @sternryan in #1755); iPad Stage Manager is not verified.
|
||||
- The iOS how-to, README and skill index cover iPads, `GSTACK_IOS_TARGET_UDID`, and the new failure rows.
|
||||
|
||||
#### For contributors
|
||||
- `selectDevice()` and `recoverTunnel()` in `ios-qa/daemon/src/tunnel-bootstrap.ts`; `refreshTunnel` in `startDaemon()` coalesces refreshes per failed tunnel. The CLI wiring is `deviceTunnelSource()` in `ios-qa/daemon/src/index.ts`, so tests drive exactly what the daemon runs.
|
||||
- `ios-qa/daemon/test/fake-device.ts` simulates a device: a `devicectl` emulator plus a StateServer with the one-use token file, rotation, relaunch and black-holed requests.
|
||||
- Pinned by `ios-qa/daemon/test/daemon-integration.test.ts` (live route drop, lost tap, concurrent drops, stopped app, Xcode relaunch, device change), `ios-qa/daemon/test/tunnel-bootstrap.test.ts` (iPad, Watch, multiple devices, recovery address and owner rules, token-write error) and `test/ios-qa-stateserver-hardening.test.ts` (no silent `try?` write, no os_log claim) on both the template and the fixture copy.
|
||||
- Not verified here: Swift compilation of the changed `StateServer` (only `swiftc -parse` ran; no Apple SDK), and any iPhone or iPad run.
|
||||
|
||||
## [1.91.36.0] - 2026-10-07
|
||||
|
||||
**Python and Rails repairs can now reach `runtime_tested` and replay, and a killed verification leaves nothing behind.**
|
||||
|
||||
@@ -353,7 +353,7 @@ and PR publication; the docs helper does not commit or push independently.
|
||||
| `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). |
|
||||
| `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Idempotent; safe to re-run. |
|
||||
| `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. |
|
||||
| `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. |
|
||||
| `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone or iPad over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. |
|
||||
| `/ios-fix`, `/ios-design-review`, `/ios-clean`, `/ios-sync` | iOS bug-fix loop, designer's-eye HIG audit, debug-bridge cleanup, and accessor resync. See `docs/skills.md`. End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
|
||||
|
||||
### Standalone binaries
|
||||
@@ -374,7 +374,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](docs/skills.md#review-readiness-dashboard). |
|
||||
| `gstack-evidence` | **Verification-evidence ledger** — `run --label <lane> -- <cmd>` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint; `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
|
||||
| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue <n>`, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. |
|
||||
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
|
||||
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
|
||||
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
|
||||
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
|
||||
|
||||
|
||||
@@ -2,6 +2,61 @@
|
||||
|
||||
## NEXT PRIORITY
|
||||
|
||||
### P1/P2: /cso capacity follow-ups (filed 2026-10-08)
|
||||
|
||||
Left open by v1.91.42.0, which lifted the snapshot file-count ceiling.
|
||||
|
||||
- **Tracked symlinks refuse the whole /cso run** — `capture()` fails closed on
|
||||
any tracked symlink ("Symlink or special source file"). 5 of 8 mid-size OSS
|
||||
repos measured for v1.91.42.0 stop there (django, rails, terraform, grafana,
|
||||
vscode; 1-10 symlinks each, mostly test fixtures). Recording them as unread
|
||||
exclusions in the manifest, like dependency trees, is a security design call
|
||||
for Garry. **Effort:** M. **Priority:** P1.
|
||||
- **64 MiB aggregate source cap (#2993)** — files over 1 MiB are withheld from
|
||||
the audit but still read and counted. Stream-hash them without counting
|
||||
toward the cap, report them as unread coverage, and add an admission estimate
|
||||
to `doctor`. grafana (211 MiB) and vscode (540 MiB) hit it once symlinks are
|
||||
out of the way. A `GSTACK_CSO_SNAPSHOT_MAX_BYTES` knob was deferred with it.
|
||||
**Effort:** L. **Priority:** P2.
|
||||
- **`inspect` prints the whole public manifest** — about 2.8 MB of stdout for
|
||||
5,000 files, more than an agent's tool output keeps. The skill reads paths
|
||||
from it, so trimming it is a skill-contract change with evals.
|
||||
**Effort:** M. **Priority:** P2.
|
||||
- **Repair bundles carry every transformation** — `lib/cso/verification.ts`
|
||||
binds the full transformation list into a 1 MiB immutable bundle. Only
|
||||
runtime verification writes bundles, and no qualified runtime catalog exists
|
||||
yet. **Effort:** M. **Priority:** P3.
|
||||
|
||||
### P2/P3: iOS QA fix-wave follow-ups (filed 2026-10-07, v1.91.38.0)
|
||||
|
||||
Left open by the iPad and route-drop release, each with its reason.
|
||||
|
||||
- **Device verification of v1.91.38.0** — the iPad bootstrap, route-drop
|
||||
recovery on a real Xcode 26 tunnel drop, the multiple-devices error and the
|
||||
`NOT READY` path were proven only against the simulated device; the changed
|
||||
`StateServer` passed `swiftc -parse` but no Apple-SDK build. Run
|
||||
`test/skill-e2e-ios-device.test.ts` and the Swift build lane on a Mac with an
|
||||
iPhone and an iPad. **Effort:** S. **Priority:** P2.
|
||||
- **`GSTACK_IOS_LAUNCH_ENV` (split from #1796, @Bmathews721)** — pass launch
|
||||
environment to the app through `devicectl process launch`. Nothing equivalent
|
||||
exists on main; land it as its own small PR with credit, then close #1796.
|
||||
**Effort:** S. **Priority:** P2.
|
||||
- **A restarted daemon still relaunches the app once** — a new daemon has no
|
||||
session bearer and the one-use boot token is gone. Fixing it needs either a
|
||||
device-side re-mint with proof of the old session or a persisted bearer under
|
||||
the state root, and both change the token's security model. Needs a design.
|
||||
**Effort:** M. **Priority:** P3.
|
||||
- **iPad Stage Manager and multiple scenes** — overlay and window selection
|
||||
(`frontmostWindow`, `searchRoots`) are unverified on iPad multi-window
|
||||
layouts. **Effort:** S (verify) / M (fix). **Priority:** P3.
|
||||
- **Stale "Session warm-start" phase in `/ios-qa`** — Phase 0 describes an
|
||||
`ios-qa-session.json` cache that no daemon code writes. Implement it with the
|
||||
state-root chain or remove the phase. **Effort:** S. **Priority:** P3.
|
||||
- **Sanctioned input-routing hook for SwiftUI gestures** (#1975 finding 2) —
|
||||
synthesized touches miss `DragGesture` on iOS 26; a documented `#if DEBUG`
|
||||
hook would let gesture-driven apps route `/tap` and `/swipe` to their own
|
||||
handlers. **Effort:** M. **Priority:** P3.
|
||||
|
||||
### P2/P3: Oct 7 fix-wave follow-ups (filed 2026-10-07)
|
||||
|
||||
Left open by the Oct 7 wave (docs/designs/FOLLOWUP_WAVE_2026_10_07.md), each with its reason.
|
||||
@@ -231,10 +286,10 @@ Deferred from the approved severe fix wave (docs/designs/SEVERE_FIX_WAVE_2026_10
|
||||
- **Redaction repo allowlist (#2598)** — a per-repo `+++ b/<path>` skip list
|
||||
(generated `*.svg`/`*.excalidraw`, #2827) for noise context rules cannot
|
||||
cover. New config surface, so out of the wave. **Effort:** M. **Priority:** P2.
|
||||
- **Global-discover Codex session schema (#2750)**, **ios-qa boot-token fixes
|
||||
(#1837, #1975; need a device)**, **absolute bun path for compiled browse
|
||||
spawns (#931; macOS repro unclear)** and **GitLab CI `curl | bash` (#1713)** —
|
||||
out of the wave's scope. **Priority:** P3 each.
|
||||
- **Global-discover Codex session schema (#2750)**, **absolute bun path for
|
||||
compiled browse spawns (#931; macOS repro unclear)** and **GitLab CI
|
||||
`curl | bash` (#1713)** — out of the wave's scope. (The ios-qa boot-token
|
||||
items #1837 and #1975 landed in v1.91.38.0.) **Priority:** P3 each.
|
||||
- **Upstream gbrain batch lookup** — a per-slug existence lookup and a separate
|
||||
`pending` list in `gbrain import --json`, so the landing check need not list a
|
||||
whole source and spawn `get` per page. **Effort:** M (upstream). **Priority:** P2.
|
||||
|
||||
@@ -7,7 +7,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin
|
||||
## What you'll need
|
||||
|
||||
- macOS with Xcode 16.0+ installed (`xcrun devicectl --version` must succeed). Xcode 16 ships the CoreDevice tunnel `devicectl` uses to reach the device over USB.
|
||||
- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
|
||||
- A real iPhone or iPad running iOS/iPadOS 16 or later (iPad Stage Manager and multi-window layouts are not device-verified yet). Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
|
||||
- An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`.
|
||||
- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
|
||||
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
|
||||
@@ -24,7 +24,7 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca
|
||||
└─────────────────┘ └──────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
- iOS app embeds a `StateServer` (`DebugBridge` SPM library, `#if DEBUG` only) listening on `::1` + `127.0.0.1` port 9999. Bearer-token gated. Boot token rotates within ~5 seconds of daemon spawn so anything scraping `os_log` past then sees a dead credential.
|
||||
- iOS app embeds a `StateServer` (`DebugBridge` SPM library, `#if DEBUG` only) listening on `::1` + `127.0.0.1` port 9999. Bearer-token gated. The one-use boot token lives in a 0600 file in the app's `tmp/` (never `os_log`); the daemon copies it out and rotates it within ~5 seconds, which deletes the file, so a later copy is a dead credential. If the app cannot write the file, it logs `NOT READY` and `/healthz` carries `boot_token_error`.
|
||||
- Mac daemon brokers traffic over the CoreDevice IPv6 tunnel that `xcrun devicectl` opens automatically when a paired device is connected.
|
||||
- In Tailscale mode, the daemon exposes a separate listener bound to your tailnet IP, with capability tiers (observe / interact / mutate / restore) enforced per session token. Tokens are minted explicitly by the Mac owner via `gstack-ios-qa-mint`; remote callers never auto-allowlist.
|
||||
|
||||
@@ -131,11 +131,18 @@ GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
|
||||
```
|
||||
|
||||
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
|
||||
available iPhone.
|
||||
Automatic selection is restricted to available iPhones and prefers a wired
|
||||
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
|
||||
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
|
||||
failure.
|
||||
available iPhone or iPad, preferring a wired device. When two devices tie (an
|
||||
iPhone and an iPad both on USB), it refuses to guess: the bootstrap error lists
|
||||
each device with its UDID and prints a ready
|
||||
`export GSTACK_IOS_TARGET_UDID=<udid>` line.
|
||||
The daemon keeps a healthy rotated tunnel for its lifetime. When the CoreDevice
|
||||
route drops (`503 device_disconnected` / `504 upstream_timeout`), it re-resolves
|
||||
the tunnel address for the same UDID and probes the running app with the bearer
|
||||
it already holds, so the app keeps its state. It bootstraps again only when the
|
||||
app rejects that bearer (401, the app was relaunched), the app is not running
|
||||
(one normal launch), or a different device is now selected; the old bearer is
|
||||
never sent to another device. A tap or other mutation whose response was lost
|
||||
is never replayed.
|
||||
If a newly started daemon reaches an already-running target whose one-use boot
|
||||
token was consumed by an earlier daemon, it verifies the bundle owner, force
|
||||
relaunches that target once, waits for a fresh token, verifies ownership again,
|
||||
@@ -222,6 +229,9 @@ Before you ship to TestFlight or the App Store, run `/ios-clean`. It removes the
|
||||
| First install on a paired device fails with no clear error | The phone needs to Trust the Mac. Open Settings → General → VPN & Device Management on the phone and confirm. |
|
||||
| `Developer Mode` toggle missing from Settings → Privacy | Connect the device to Xcode → Window → Devices and Simulators once, or try any `devicectl device install` against it. iOS will surface the toggle after the first attempt. |
|
||||
| `xcrun devicectl device copy from` returns ERROR 7000 | The source path is wrong — boot token lives at `tmp/gstack-ios-qa.token` inside the app's data container (NSTemporaryDirectory), not at the path's root. |
|
||||
| Bootstrap fails with `multiple_devices` | More than one iPhone/iPad is connected. Run the printed `export GSTACK_IOS_TARGET_UDID=<udid>` and restart the daemon. |
|
||||
| `boot_token_unavailable ... could not write tmp/gstack-ios-qa.token` | The app could not write its boot-token file. Fix the app's `tmp/` directory and relaunch the app. |
|
||||
| A SwiftUI `DragGesture` or `Canvas` ignores taps and swipes on iOS 26 | Synthesized in-process touches do not reach SwiftUI gesture recognizers on iOS 26, and `/swipe` only scrolls a `UIScrollView`. See "Known limits" in the `/ios-qa` skill for the input-routing workaround. |
|
||||
| `/healthz` returns 200 but `/tap` returns ok:true with no UI change | The phone is paired but the StateServer port may have changed across launches. Re-resolve the CoreDevice IPv6 (`dscacheutil -q host -a name '<DeviceName>.coredevice.local'`). |
|
||||
| `403 identity_not_allowed` from `/auth/mint` | The remote caller's identity isn't on the Mac's allowlist. Run `gstack-ios-qa-mint grant --remote <identity> --capability interact` on the Mac. |
|
||||
| Daemon won't open the tailnet listener | Tailscale isn't installed, or `/var/run/tailscale.sock` is unreadable. Fix Tailscale, then restart the daemon. Loopback still runs in the meantime. |
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples.
|
||||
| [`/gstack-upgrade`](#gstack-upgrade) | **Self-Updater** | Upgrade gstack to the latest version. Detects global vs vendored install, syncs both, shows what changed. |
|
||||
| [`/make-pdf`](#make-pdf) | **PDF Generator** | Turn any markdown file into a publication-quality PDF. Proper margins, page numbers, cover pages, clickable TOC. Mermaid/excalidraw fences render as vector diagrams; `--to html\|docx` for other formats. Prints through your Aside browser (macOS 15+), or gstack's bundled browser when Aside is absent. |
|
||||
| [`/diagram`](#diagram) | **Diagram Maker** | English in, diagram out: mermaid source + editable `.excalidraw` (open it on excalidraw.com, hand-drawn style) + rendered SVG/PNG. Fully offline, rendered through your Aside browser (macOS 15+) or gstack's bundled browser when Aside is absent. |
|
||||
| [`/ios-qa`](#ios-qa) | **iOS QA Lead** | Live-device iOS QA via USB CoreDevice tunnel + embedded StateServer. Reads Swift source, codegens accessors, drives the real iPhone. Optionally exposes the device over Tailscale for remote agents. |
|
||||
| [`/ios-qa`](#ios-qa) | **iOS QA Lead** | Live-device iOS QA via USB CoreDevice tunnel + embedded StateServer. Reads Swift source, codegens accessors, drives the real iPhone or iPad. Optionally exposes the device over Tailscale for remote agents. |
|
||||
| [`/ios-fix`](#ios-fix) | **iOS Autonomous Fixer** | Closes the find→fix→verify loop on a real iPhone. Captures a reproducing snapshot, fixes the source, rebuilds, redeploys, verifies. |
|
||||
| [`/ios-design-review`](#ios-design-review) | **iOS Designer's Eye** | 10-dimension Apple HIG audit on a real iPhone. Rates each screen, says what would make it a 10. |
|
||||
| [`/ios-clean`](#ios-clean) | **iOS Bridge Cleanup** | Convenience wrapper to strip DebugBridge SPM + `#if DEBUG` wiring. The structural Release-build guard is in Package.swift + CI; this skill is for guided manual removals. |
|
||||
|
||||
@@ -704,6 +704,24 @@ Linux) with a local Docker socket. On Windows, static assessment is the
|
||||
supported mode; if the refusal named a user directory, install Docker Desktop
|
||||
under Program Files.
|
||||
|
||||
<a id="cso-capacity"></a>
|
||||
### `Snapshot manifest cap exceeded: ...` / `64 MiB source cap exceeded: ...` / `Symlink or special source file: <path>`
|
||||
|
||||
**Meaning.** /cso copies the repository into a private snapshot before any
|
||||
audit starts, and refuses the whole run when the snapshot would be incomplete
|
||||
or too large. Each message names the cap that applied:
|
||||
|
||||
- **Snapshot manifest cap (16 MiB).** One entry per tracked or nonignored
|
||||
untracked file; about 50,000 files fit.
|
||||
- **64 MiB source cap.** The full size of every such file outside dependency
|
||||
and VCS directories counts, including files over 1 MiB whose contents are
|
||||
withheld from the audit.
|
||||
- **Symlinks.** A tracked symlink anywhere in the repository stops the run.
|
||||
|
||||
**Fix.** No setting raises these caps. Run /cso on a smaller checkout that
|
||||
holds the code you want audited and no symlinks. Add your file count or size to
|
||||
[#2993](https://github.com/garrytan/gstack/issues/2993).
|
||||
|
||||
<a id="conductor-auq-hook-removed"></a>
|
||||
### `removed the AskUserQuestion preference hook: it breaks Conductor's native AskUserQuestion (#2207). ...`
|
||||
|
||||
|
||||
+49
-12
@@ -14,6 +14,7 @@ allowed-tools:
|
||||
triggers:
|
||||
- ios qa
|
||||
- test the iphone app
|
||||
- test the ipad app
|
||||
- test my ios app
|
||||
- find bugs on the device
|
||||
- qa the ios app
|
||||
@@ -24,17 +25,17 @@ triggers:
|
||||
|
||||
## When to invoke this skill
|
||||
|
||||
Connects to a real iPhone via USB
|
||||
CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
|
||||
Connects to a real iPhone or iPad via
|
||||
USB CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
|
||||
runs a vision-driven agent loop: screenshot → analyze → decide → act →
|
||||
verify → repeat. All interaction happens via HTTP to an embedded
|
||||
StateServer in the app under test. Optionally exposes the device over
|
||||
Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can
|
||||
run iOS QA from anywhere without touching the hardware.
|
||||
Use when asked to "ios qa", "test my iPhone app", "find bugs on the device",
|
||||
or "qa the iOS app".
|
||||
Use when asked to "ios qa", "test my iPhone app", "test my iPad app",
|
||||
"find bugs on the device", or "qa the iOS app".
|
||||
|
||||
Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "run iOS QA".
|
||||
Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "test the iPad app", "run iOS QA".
|
||||
|
||||
## Preamble (run first)
|
||||
|
||||
@@ -390,7 +391,7 @@ Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXI
|
||||
|
||||
# Live-device iOS QA
|
||||
|
||||
This skill drives a real iPhone via USB. The agent reads your Swift source,
|
||||
This skill drives a real iPhone or iPad via USB. The agent reads your Swift source,
|
||||
generates typed state accessors, deploys a debug bridge, and runs a closed
|
||||
find→fix→verify loop. No simulator, no XCTest, no WebDriverAgent.
|
||||
|
||||
@@ -422,7 +423,11 @@ tokens (default 1h) for remote agents.
|
||||
## Prerequisites
|
||||
|
||||
- macOS (the daemon uses `devicectl` from Xcode).
|
||||
- iPhone connected via USB, paired and trusted.
|
||||
- iPhone or iPad connected via USB, paired and trusted. With more than one
|
||||
connected, pick one before starting the daemon:
|
||||
`export GSTACK_IOS_TARGET_UDID=<udid>` (`xcrun devicectl list devices` shows
|
||||
UDIDs). Otherwise the daemon refuses to guess, lists each device with its
|
||||
UDID, and prints that export line.
|
||||
- Xcode + Swift toolchain installed (`swift --version` reports >= 5.9).
|
||||
- App source available on disk, with at least one `@Observable` class.
|
||||
- For remote-control mode: Tailscale installed and the user logged in.
|
||||
@@ -490,6 +495,11 @@ fi
|
||||
The regenerator also removes the explicit obsolete flat-file set created by
|
||||
older ios-sync versions, preventing a stale second harness from remaining
|
||||
in the app target.
|
||||
Source control: `DebugBridge/` is generated; never hand-edit it. Commit it
|
||||
when teammates or CI build the Debug configuration without gstack (re-run
|
||||
the regenerator after a gstack upgrade); otherwise add `DebugBridge/` to
|
||||
`.gitignore` and have each developer run the regenerator. Tell the user
|
||||
which one you picked.
|
||||
2. Add the generated `DebugBridge` local SPM dependency to the app's
|
||||
`Package.swift`. The package
|
||||
ships three Debug-config-only library products:
|
||||
@@ -519,14 +529,17 @@ fi
|
||||
```
|
||||
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Launch via `devicectl device process launch --device <UDID> <bundle-id>`.
|
||||
On launch the StateServer writes a one-use boot token to a 0600 file in the
|
||||
app's `tmp/`; the daemon copies it out with `devicectl`. The token is never
|
||||
printed to `os_log`. If the app cannot write that file, it logs `NOT READY`
|
||||
and the daemon reports `boot_token_unavailable` with the cause.
|
||||
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
|
||||
daemon is alive, the second invocation discovers its port and connects.
|
||||
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
fresh in-memory-only token. The boot token becomes useless ~5s later.
|
||||
Anything scraping `os_log` past this point sees a dead credential.
|
||||
fresh in-memory-only token. Rotation deletes the boot-token file, so a copy
|
||||
taken after this point is a dead credential.
|
||||
If a fresh daemon finds the app running after another daemon consumed that
|
||||
one-use token, it verifies the bundle owner, relaunches the target once,
|
||||
waits for the new token, verifies ownership again, and then rotates.
|
||||
@@ -601,10 +614,34 @@ live.
|
||||
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
|
||||
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
|
||||
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
|
||||
| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
|
||||
| `503 device_disconnected` / `504 upstream_timeout` from proxy | USB route dropped, app stopped, or app relaunched | Daemon probes the running app with its session bearer and keeps the session (no relaunch, app state intact). It bootstraps only when the app rejected the bearer (401), is not running, or a different device is now selected. A lost `/tap`/`/swipe`/`/type` response is never replayed: check the screen before retrying. If it persists, reconnect/unlock the device |
|
||||
| `multiple_devices` at bootstrap | iPhone and iPad (or two devices) connected, no target set | Run the printed `export GSTACK_IOS_TARGET_UDID=<udid>`, then restart the daemon |
|
||||
| `boot_token_unavailable ... could not write` | app's `tmp/` not writable | Fix the app container, relaunch the app |
|
||||
| App relaunched after the daemon restarted | a new daemon has no session bearer and the one-use boot token is gone | Expected: the first bootstrap relaunches the app once; keep one daemon alive for a session |
|
||||
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
|
||||
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
|
||||
|
||||
## Known limits
|
||||
|
||||
Device-verified by users, not fixable in the bridge today. Plan around them:
|
||||
|
||||
- **SwiftUI gestures on iOS 26.** In-process synthesized touches report success
|
||||
but never reach a SwiftUI `DragGesture` (for example a `Canvas` driven by
|
||||
drag input), even with phase-separated touches (seen on iOS 26.5). Buttons and
|
||||
UIKit controls still respond. For gesture-driven views, have the app expose
|
||||
its input handlers to the bridge under `#if DEBUG` and drive them through a
|
||||
state write, or cover the flow with an XCUITest harness.
|
||||
- **`/swipe` scrolls only.** It moves the nearest enclosing `UIScrollView` and
|
||||
returns `false` when there is none; it is not a drag. Custom pan or drag
|
||||
views need the input-routing approach above.
|
||||
- **`/elements` on iOS 26.** The in-process SwiftUI accessibility tree is often
|
||||
not materialized: an iPhone 12 Pro on iOS 26.3.1 returned only the three
|
||||
hosting views, with no identifiers or labels. Locate controls from the
|
||||
screenshot and tap by coordinate.
|
||||
- **iPad windows.** iPad sessions work like iPhone sessions, but the overlay
|
||||
and window selection have not been verified with Stage Manager or multiple
|
||||
scenes; report what you see.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Use `/ios-clean` to remove the DebugBridge SPM dependency and all `#if DEBUG`
|
||||
|
||||
+49
-11
@@ -3,18 +3,19 @@ name: ios-qa
|
||||
preamble-tier: 3
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Live-device iOS QA for SwiftUI apps. Connects to a real iPhone via USB
|
||||
CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
|
||||
Live-device iOS QA for SwiftUI apps. Connects to a real iPhone or iPad via
|
||||
USB CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
|
||||
runs a vision-driven agent loop: screenshot → analyze → decide → act →
|
||||
verify → repeat. All interaction happens via HTTP to an embedded
|
||||
StateServer in the app under test. Optionally exposes the device over
|
||||
Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can
|
||||
run iOS QA from anywhere without touching the hardware.
|
||||
Use when asked to "ios qa", "test my iPhone app", "find bugs on the device",
|
||||
or "qa the iOS app". (gstack)
|
||||
Use when asked to "ios qa", "test my iPhone app", "test my iPad app",
|
||||
"find bugs on the device", or "qa the iOS app". (gstack)
|
||||
voice-triggers:
|
||||
- "iOS quality check"
|
||||
- "test the iPhone app"
|
||||
- "test the iPad app"
|
||||
- "run iOS QA"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
@@ -27,6 +28,7 @@ allowed-tools:
|
||||
triggers:
|
||||
- ios qa
|
||||
- test the iphone app
|
||||
- test the ipad app
|
||||
- test my ios app
|
||||
- find bugs on the device
|
||||
- qa the ios app
|
||||
@@ -36,7 +38,7 @@ triggers:
|
||||
|
||||
# Live-device iOS QA
|
||||
|
||||
This skill drives a real iPhone via USB. The agent reads your Swift source,
|
||||
This skill drives a real iPhone or iPad via USB. The agent reads your Swift source,
|
||||
generates typed state accessors, deploys a debug bridge, and runs a closed
|
||||
find→fix→verify loop. No simulator, no XCTest, no WebDriverAgent.
|
||||
|
||||
@@ -68,7 +70,11 @@ tokens (default 1h) for remote agents.
|
||||
## Prerequisites
|
||||
|
||||
- macOS (the daemon uses `devicectl` from Xcode).
|
||||
- iPhone connected via USB, paired and trusted.
|
||||
- iPhone or iPad connected via USB, paired and trusted. With more than one
|
||||
connected, pick one before starting the daemon:
|
||||
`export GSTACK_IOS_TARGET_UDID=<udid>` (`xcrun devicectl list devices` shows
|
||||
UDIDs). Otherwise the daemon refuses to guess, lists each device with its
|
||||
UDID, and prints that export line.
|
||||
- Xcode + Swift toolchain installed (`swift --version` reports >= 5.9).
|
||||
- App source available on disk, with at least one `@Observable` class.
|
||||
- For remote-control mode: Tailscale installed and the user logged in.
|
||||
@@ -136,6 +142,11 @@ fi
|
||||
The regenerator also removes the explicit obsolete flat-file set created by
|
||||
older ios-sync versions, preventing a stale second harness from remaining
|
||||
in the app target.
|
||||
Source control: `DebugBridge/` is generated; never hand-edit it. Commit it
|
||||
when teammates or CI build the Debug configuration without gstack (re-run
|
||||
the regenerator after a gstack upgrade); otherwise add `DebugBridge/` to
|
||||
`.gitignore` and have each developer run the regenerator. Tell the user
|
||||
which one you picked.
|
||||
2. Add the generated `DebugBridge` local SPM dependency to the app's
|
||||
`Package.swift`. The package
|
||||
ships three Debug-config-only library products:
|
||||
@@ -165,14 +176,17 @@ fi
|
||||
```
|
||||
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Launch via `devicectl device process launch --device <UDID> <bundle-id>`.
|
||||
On launch the StateServer writes a one-use boot token to a 0600 file in the
|
||||
app's `tmp/`; the daemon copies it out with `devicectl`. The token is never
|
||||
printed to `os_log`. If the app cannot write that file, it logs `NOT READY`
|
||||
and the daemon reports `boot_token_unavailable` with the cause.
|
||||
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
|
||||
daemon is alive, the second invocation discovers its port and connects.
|
||||
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
fresh in-memory-only token. The boot token becomes useless ~5s later.
|
||||
Anything scraping `os_log` past this point sees a dead credential.
|
||||
fresh in-memory-only token. Rotation deletes the boot-token file, so a copy
|
||||
taken after this point is a dead credential.
|
||||
If a fresh daemon finds the app running after another daemon consumed that
|
||||
one-use token, it verifies the bundle owner, relaunches the target once,
|
||||
waits for the new token, verifies ownership again, and then rotates.
|
||||
@@ -247,10 +261,34 @@ live.
|
||||
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
|
||||
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
|
||||
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
|
||||
| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
|
||||
| `503 device_disconnected` / `504 upstream_timeout` from proxy | USB route dropped, app stopped, or app relaunched | Daemon probes the running app with its session bearer and keeps the session (no relaunch, app state intact). It bootstraps only when the app rejected the bearer (401), is not running, or a different device is now selected. A lost `/tap`/`/swipe`/`/type` response is never replayed: check the screen before retrying. If it persists, reconnect/unlock the device |
|
||||
| `multiple_devices` at bootstrap | iPhone and iPad (or two devices) connected, no target set | Run the printed `export GSTACK_IOS_TARGET_UDID=<udid>`, then restart the daemon |
|
||||
| `boot_token_unavailable ... could not write` | app's `tmp/` not writable | Fix the app container, relaunch the app |
|
||||
| App relaunched after the daemon restarted | a new daemon has no session bearer and the one-use boot token is gone | Expected: the first bootstrap relaunches the app once; keep one daemon alive for a session |
|
||||
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
|
||||
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
|
||||
|
||||
## Known limits
|
||||
|
||||
Device-verified by users, not fixable in the bridge today. Plan around them:
|
||||
|
||||
- **SwiftUI gestures on iOS 26.** In-process synthesized touches report success
|
||||
but never reach a SwiftUI `DragGesture` (for example a `Canvas` driven by
|
||||
drag input), even with phase-separated touches (seen on iOS 26.5). Buttons and
|
||||
UIKit controls still respond. For gesture-driven views, have the app expose
|
||||
its input handlers to the bridge under `#if DEBUG` and drive them through a
|
||||
state write, or cover the flow with an XCUITest harness.
|
||||
- **`/swipe` scrolls only.** It moves the nearest enclosing `UIScrollView` and
|
||||
returns `false` when there is none; it is not a drag. Custom pan or drag
|
||||
views need the input-routing approach above.
|
||||
- **`/elements` on iOS 26.** The in-process SwiftUI accessibility tree is often
|
||||
not materialized: an iPhone 12 Pro on iOS 26.3.1 returned only the three
|
||||
hosting views, with no identifiers or labels. Locate controls from the
|
||||
screenshot and tap by coordinate.
|
||||
- **iPad windows.** iPad sessions work like iPhone sessions, but the overlay
|
||||
and window selection have not been verified with Stage Manager or multiple
|
||||
scenes; report what you see.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Use `/ios-clean` to remove the DebugBridge SPM dependency and all `#if DEBUG`
|
||||
|
||||
+122
-43
@@ -20,8 +20,8 @@ import { SessionTokenStore } from './session-tokens';
|
||||
import { mintForCaller } from './auth-mint';
|
||||
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit';
|
||||
import { bootstrapTunnel } from './tunnel-bootstrap';
|
||||
import { startTunnelKeepalive } from './devicectl';
|
||||
import { bootstrapTunnel, recoverTunnel, type TunnelRecovery } from './tunnel-bootstrap';
|
||||
import { startTunnelKeepalive, type SpawnImpl, type ResolveImpl } from './devicectl';
|
||||
import type { Capability } from './types';
|
||||
|
||||
interface DaemonOptions {
|
||||
@@ -39,6 +39,8 @@ interface DaemonOptions {
|
||||
// Test injection
|
||||
proxyTimeoutMs?: number;
|
||||
tunnelProvider?: () => Promise<DeviceTunnel | null>;
|
||||
/** Route-drop recovery (see recoverTunnel). Without it a route drop re-bootstraps. */
|
||||
tunnelRecovery?: (failed: DeviceTunnel) => Promise<TunnelRecovery>;
|
||||
whoIsImpl?: (addr: string) => Promise<{ identity: string; raw: unknown }>;
|
||||
probeImpl?: () => Promise<{ ok: boolean; reason?: string; ownIdentity?: string }>;
|
||||
}
|
||||
@@ -95,6 +97,35 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
if (tunnel === failedTunnel) tunnel = null;
|
||||
};
|
||||
|
||||
// Every request that failed on the same cached tunnel shares one refresh,
|
||||
// so concurrent failures cause one probe or one bootstrap, never several.
|
||||
let refreshInFlight: { failed: DeviceTunnel; promise: Promise<DeviceTunnel | null> } | null = null;
|
||||
const refreshTunnel = (failedTunnel: DeviceTunnel, cause: RefreshCause): Promise<DeviceTunnel | null> => {
|
||||
if (tunnel && tunnel !== failedTunnel) return Promise.resolve(tunnel);
|
||||
if (refreshInFlight?.failed === failedTunnel) return refreshInFlight.promise;
|
||||
if (!tunnel && tunnelInFlight) return tunnelInFlight;
|
||||
|
||||
const promise: Promise<DeviceTunnel | null> = (async () => {
|
||||
// A route drop leaves the app and its in-memory bearer alive. Probe it
|
||||
// first: a full bootstrap would have to relaunch the app (the one-shot
|
||||
// boot-token file is gone) and wipe its QA state. A 401 skips this.
|
||||
if (cause === 'route_lost' && opts.tunnelRecovery) {
|
||||
const recovery = await opts.tunnelRecovery(failedTunnel);
|
||||
if (recovery.action === 'unavailable') return null;
|
||||
if (recovery.action === 'reuse') {
|
||||
if (tunnel === failedTunnel) tunnel = recovery.tunnel;
|
||||
return tunnel ?? recovery.tunnel;
|
||||
}
|
||||
}
|
||||
invalidateTunnel(failedTunnel);
|
||||
return getTunnel();
|
||||
})().finally(() => {
|
||||
if (refreshInFlight?.promise === promise) refreshInFlight = null;
|
||||
});
|
||||
refreshInFlight = { failed: failedTunnel, promise };
|
||||
return promise;
|
||||
};
|
||||
|
||||
// 2. Tailnet probe (fail-closed).
|
||||
const probe = opts.tailnetEnabled
|
||||
? (opts.probeImpl ? await opts.probeImpl() : await probeTailscale(opts.tailnetSocketPath))
|
||||
@@ -107,7 +138,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
|
||||
// 3. Loopback listener (full surface).
|
||||
const loopbackServer = createServer(async (req, res) => {
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, proxyTimeoutMs: opts.proxyTimeoutMs });
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, proxyTimeoutMs: opts.proxyTimeoutMs });
|
||||
});
|
||||
// Use port 0 for OS-assigned port when test/random port collisions are a risk.
|
||||
const requestedPort = opts.loopbackPort;
|
||||
@@ -118,7 +149,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
// mode this can collide; we try the actualPort first and skip ipv6 if it
|
||||
// fails (tests don't exercise ::1 explicitly).
|
||||
const loopbackServerV6 = createServer(async (req, res) => {
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, proxyTimeoutMs: opts.proxyTimeoutMs });
|
||||
await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, proxyTimeoutMs: opts.proxyTimeoutMs });
|
||||
});
|
||||
let v6Bound = false;
|
||||
try {
|
||||
@@ -140,6 +171,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon |
|
||||
tokenStore,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
refreshTunnel,
|
||||
proxyTimeoutMs: opts.proxyTimeoutMs,
|
||||
auditPath: opts.auditPath,
|
||||
attemptsPath: opts.attemptsPath,
|
||||
@@ -205,6 +237,7 @@ interface HandlerCtx {
|
||||
tokenStore: SessionTokenStore;
|
||||
getTunnel: () => Promise<DeviceTunnel | null>;
|
||||
invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
|
||||
refreshTunnel: (failedTunnel: DeviceTunnel, cause: RefreshCause) => Promise<DeviceTunnel | null>;
|
||||
proxyTimeoutMs?: number;
|
||||
// Explicit security-log + allowlist paths (default to env-derived when undefined).
|
||||
auditPath?: string;
|
||||
@@ -213,6 +246,7 @@ interface HandlerCtx {
|
||||
}
|
||||
|
||||
type DeviceProxyResponse = Awaited<ReturnType<typeof proxyToDevice>>;
|
||||
type RefreshCause = 'unauthorized' | 'route_lost';
|
||||
const RECOVERABLE_SOCKET_ERRORS = new Set([
|
||||
'ECONNABORTED',
|
||||
'ECONNREFUSED',
|
||||
@@ -249,8 +283,9 @@ async function proxyAttempt(opts: Parameters<typeof proxyToDevice>[0]): Promise<
|
||||
|
||||
function shouldRefreshTunnel(upstream: DeviceProxyResponse): boolean {
|
||||
// A relaunched app has a new in-memory bearer and rejects the daemon's old
|
||||
// rotated token. A redeploy can instead leave the old CoreDevice route
|
||||
// refusing connections or timing out. Both cases require a fresh bootstrap.
|
||||
// rotated token: that needs a fresh bootstrap. A blip or redeploy can
|
||||
// instead leave the CoreDevice route refusing connections or timing out:
|
||||
// that goes through route-drop recovery first.
|
||||
if (upstream.status === 401) return true;
|
||||
if (upstream.status !== 503 && upstream.status !== 504) return false;
|
||||
try {
|
||||
@@ -278,6 +313,7 @@ async function proxyWithTunnelRecovery(opts: {
|
||||
agentIdentity?: string;
|
||||
getTunnel: HandlerCtx['getTunnel'];
|
||||
invalidateTunnel: HandlerCtx['invalidateTunnel'];
|
||||
refreshTunnel: HandlerCtx['refreshTunnel'];
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ tunnel: DeviceTunnel; upstream: DeviceProxyResponse } | null> {
|
||||
let tunnel = await opts.getTunnel();
|
||||
@@ -297,8 +333,7 @@ async function proxyWithTunnelRecovery(opts: {
|
||||
|
||||
const failedTunnel = tunnel;
|
||||
const replaySafe = canReplayAfterRefresh(opts.inbound, upstream);
|
||||
opts.invalidateTunnel(tunnel);
|
||||
const refreshed = await opts.getTunnel();
|
||||
const refreshed = await opts.refreshTunnel(tunnel, upstream.status === 401 ? 'unauthorized' : 'route_lost');
|
||||
if (!refreshed) return replaySafe ? null : { tunnel: failedTunnel, upstream };
|
||||
|
||||
// The replacement is now cached for the next request, but never replay an
|
||||
@@ -308,9 +343,10 @@ async function proxyWithTunnelRecovery(opts: {
|
||||
|
||||
tunnel = refreshed;
|
||||
upstream = await makeAttempt(tunnel);
|
||||
// Do not loop forever if the replacement app is itself unavailable. Leave
|
||||
// the cache empty so the next independent request can bootstrap again.
|
||||
if (shouldRefreshTunnel(upstream)) opts.invalidateTunnel(tunnel);
|
||||
// Do not loop forever if the replacement is itself unavailable. A rejected
|
||||
// bearer empties the cache so the next request bootstraps; a route that is
|
||||
// still down keeps it so the next request probes again instead.
|
||||
if (upstream.status === 401) opts.invalidateTunnel(tunnel);
|
||||
return { tunnel, upstream };
|
||||
}
|
||||
|
||||
@@ -355,7 +391,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
* loopback bind itself is the boundary).
|
||||
*/
|
||||
async function handleLoopback(ctx: HandlerCtx): Promise<void> {
|
||||
const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx;
|
||||
const { req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel } = ctx;
|
||||
const url = parseUrl(req.url ?? '/');
|
||||
const path = url.pathname ?? '/';
|
||||
const method = req.method ?? 'GET';
|
||||
@@ -418,6 +454,7 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
|
||||
agentIdentity,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
refreshTunnel,
|
||||
timeoutMs: ctx.proxyTimeoutMs,
|
||||
});
|
||||
if (!proxied) {
|
||||
@@ -440,7 +477,7 @@ interface TailnetCtx extends HandlerCtx {
|
||||
* Tailnet handler — locked allowlist + capability tiers.
|
||||
*/
|
||||
async function handleTailnet(ctx: TailnetCtx): Promise<void> {
|
||||
const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
|
||||
const { req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
|
||||
const url = parseUrl(req.url ?? '/');
|
||||
const path = url.pathname ?? '/';
|
||||
const method = req.method ?? 'GET';
|
||||
@@ -536,6 +573,7 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
|
||||
agentIdentity: session.identity,
|
||||
getTunnel,
|
||||
invalidateTunnel,
|
||||
refreshTunnel,
|
||||
timeoutMs: ctx.proxyTimeoutMs,
|
||||
});
|
||||
if (!proxied) {
|
||||
@@ -565,6 +603,66 @@ async function handleTailnet(ctx: TailnetCtx): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-device tunnel wiring the CLI uses: bootstrap with the CoreDevice
|
||||
* keepalive, plus route-drop recovery bound to the same target. Exported so
|
||||
* tests drive exactly this wiring against a simulated device.
|
||||
*/
|
||||
export function deviceTunnelSource(opts: {
|
||||
udid?: string;
|
||||
bundleId: string;
|
||||
port?: number;
|
||||
spawnImpl?: SpawnImpl;
|
||||
resolveImpl?: ResolveImpl;
|
||||
fetchImpl?: typeof fetch;
|
||||
keepalive?: (udid: string) => { stop: () => void };
|
||||
log?: (line: string) => void;
|
||||
}): {
|
||||
tunnelProvider: () => Promise<DeviceTunnel | null>;
|
||||
tunnelRecovery: (failed: DeviceTunnel) => Promise<TunnelRecovery>;
|
||||
stop: () => void;
|
||||
} {
|
||||
const log = opts.log ?? ((line: string) => process.stderr.write(`${line}\n`));
|
||||
const keepaliveFor = opts.keepalive ?? ((udid: string) => startTunnelKeepalive(udid));
|
||||
let keepalive: { stop: () => void } | null = null;
|
||||
return {
|
||||
tunnelProvider: async () => {
|
||||
const result = await bootstrapTunnel({
|
||||
udid: opts.udid,
|
||||
bundleId: opts.bundleId,
|
||||
port: opts.port,
|
||||
spawnImpl: opts.spawnImpl,
|
||||
resolveImpl: opts.resolveImpl,
|
||||
fetchImpl: opts.fetchImpl,
|
||||
});
|
||||
if (!result.ok) {
|
||||
log(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}`);
|
||||
return null;
|
||||
}
|
||||
keepalive?.stop();
|
||||
keepalive = keepaliveFor(result.tunnel.udid);
|
||||
return result.tunnel;
|
||||
},
|
||||
tunnelRecovery: async (failed) => {
|
||||
const recovery = await recoverTunnel(failed, {
|
||||
udid: opts.udid,
|
||||
bundleId: opts.bundleId,
|
||||
spawnImpl: opts.spawnImpl,
|
||||
resolveImpl: opts.resolveImpl,
|
||||
fetchImpl: opts.fetchImpl,
|
||||
});
|
||||
if (recovery.action === 'reuse') log(`tunnel recovered: kept the session at [${recovery.tunnel.ipv6Addr}]:${recovery.tunnel.port}`);
|
||||
else if (recovery.action === 'bootstrap') log(`tunnel recovery: ${recovery.reason}; bootstrapping`);
|
||||
else log(`tunnel recovery: ${recovery.reason}`);
|
||||
return recovery;
|
||||
},
|
||||
stop: () => {
|
||||
keepalive?.stop();
|
||||
keepalive = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// CLI entry — runs when this file is executed directly, not when imported.
|
||||
if (import.meta.main) {
|
||||
const port = parseInt(process.env.GSTACK_IOS_DAEMON_PORT ?? '9099', 10);
|
||||
@@ -572,40 +670,21 @@ if (import.meta.main) {
|
||||
const targetUDID = process.env.GSTACK_IOS_TARGET_UDID;
|
||||
const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture';
|
||||
|
||||
// Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with
|
||||
// any connected paired device) is set, bootstrap a real CoreDevice tunnel.
|
||||
// Otherwise return null (proxy will return 503 device_not_connected).
|
||||
//
|
||||
// After a successful bootstrap we spawn a periodic devicectl `info details`
|
||||
// call to keep the CoreDevice tunnel session alive — Xcode 26's CoreDevice
|
||||
// only holds the tunnel up while a devicectl command is in-flight, so
|
||||
// without a poke every few seconds the IPv6 becomes unroutable.
|
||||
let keepalive: { stop: () => void } | null = null;
|
||||
const realTunnelProvider = async () => {
|
||||
const result = await bootstrapTunnel({
|
||||
udid: targetUDID,
|
||||
bundleId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`);
|
||||
return null;
|
||||
}
|
||||
if (keepalive) keepalive.stop();
|
||||
keepalive = startTunnelKeepalive(result.tunnel.udid);
|
||||
return result.tunnel;
|
||||
};
|
||||
|
||||
const shutdown = () => {
|
||||
if (keepalive) { keepalive.stop(); keepalive = null; }
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('exit', shutdown);
|
||||
// Bootstrap a real CoreDevice tunnel to GSTACK_IOS_TARGET_UDID, or to the
|
||||
// single best connected paired iPhone/iPad. With no device the provider
|
||||
// returns null and the proxy answers 503 device_not_connected. Xcode 26's
|
||||
// CoreDevice only holds the tunnel up while a devicectl command is in
|
||||
// flight, so a successful bootstrap also starts a periodic keepalive.
|
||||
const source = deviceTunnelSource({ udid: targetUDID, bundleId });
|
||||
process.on('SIGINT', source.stop);
|
||||
process.on('SIGTERM', source.stop);
|
||||
process.on('exit', source.stop);
|
||||
|
||||
startDaemon({
|
||||
loopbackPort: port,
|
||||
tailnetEnabled: tailnet,
|
||||
tunnelProvider: realTunnelProvider,
|
||||
tunnelProvider: source.tunnelProvider,
|
||||
tunnelRecovery: source.tunnelRecovery,
|
||||
}).then((d) => {
|
||||
if ('error' in d) {
|
||||
process.stderr.write(`daemon error: ${d.error}\n`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Bootstrap the CoreDevice tunnel to a connected iPhone running the iOS app
|
||||
// under test. Orchestrates the full hand-rolled flow we verified end-to-end:
|
||||
// Bootstrap the CoreDevice tunnel to a connected iPhone or iPad running the
|
||||
// iOS app under test. Orchestrates the full hand-rolled flow we verified
|
||||
// end-to-end:
|
||||
//
|
||||
// 1. find a paired, connected device via devicectl list devices
|
||||
// 2. launch the app on it (no-op if already running)
|
||||
@@ -11,9 +12,13 @@
|
||||
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
|
||||
// bearer that subsequent proxied requests carry
|
||||
//
|
||||
// Step 5 is critical: after rotation, anything scraping os_log or the
|
||||
// on-disk token file sees a dead credential. The Mac daemon holds the only
|
||||
// live token, which it scopes per-tailnet-session via /auth/mint.
|
||||
// Step 5 is critical: rotation deletes the on-disk token file, so anything
|
||||
// that copied it sees a dead credential. The Mac daemon holds the only live
|
||||
// token, which it scopes per-tailnet-session via /auth/mint.
|
||||
//
|
||||
// recoverTunnel() handles a dropped route afterwards without repeating this
|
||||
// flow: the app still holds the rotated bearer, and a second bootstrap could
|
||||
// only get a new token by relaunching the app.
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
@@ -21,6 +26,8 @@ import type { DeviceTunnel } from './proxy';
|
||||
import {
|
||||
listDevices,
|
||||
resolveTunnelIPv6,
|
||||
getDeviceTunnelIPv6,
|
||||
getDeviceTunnelIPv6FromDevicectl,
|
||||
isAppRunning,
|
||||
launchApp,
|
||||
copyFileFromAppContainer,
|
||||
@@ -30,7 +37,7 @@ import {
|
||||
} from './devicectl';
|
||||
|
||||
export interface BootstrapOptions {
|
||||
/** Target iPhone UDID. If null, picks the best connected paired iPhone. */
|
||||
/** Target iPhone/iPad UDID. If null, picks the best connected paired device. */
|
||||
udid?: string;
|
||||
/** Bundle ID of the iOS app hosting the StateServer. */
|
||||
bundleId: string;
|
||||
@@ -54,6 +61,7 @@ export type BootstrapErrorReason =
|
||||
| 'no_devices'
|
||||
| 'no_paired_device'
|
||||
| 'device_not_found'
|
||||
| 'multiple_devices'
|
||||
| 'launch_failed'
|
||||
| 'device_locked'
|
||||
| 'state_server_unreachable'
|
||||
@@ -62,7 +70,7 @@ export type BootstrapErrorReason =
|
||||
| 'rotate_failed'
|
||||
| 'resolve_failed';
|
||||
|
||||
function isIPhoneDevice(device: DeviceEntry): boolean {
|
||||
function isSupportedIOSDevice(device: DeviceEntry): boolean {
|
||||
const platform = device.platform.trim().toLowerCase();
|
||||
const deviceType = device.deviceType.trim().toLowerCase();
|
||||
const model = device.model.trim().toLowerCase();
|
||||
@@ -70,8 +78,13 @@ function isIPhoneDevice(device: DeviceEntry): boolean {
|
||||
// productType is present even on older CoreDevice versions. Prefer the
|
||||
// explicit platform/type fields when available, but retain productType as
|
||||
// a compatibility fallback. An explicit non-iOS platform always loses.
|
||||
if (platform && platform !== 'ios') return false;
|
||||
return deviceType === 'iphone' || model.startsWith('iphone');
|
||||
if (platform && platform !== 'ios' && platform !== 'ipados') return false;
|
||||
return (
|
||||
deviceType === 'iphone'
|
||||
|| deviceType === 'ipad'
|
||||
|| model.startsWith('iphone')
|
||||
|| model.startsWith('ipad')
|
||||
);
|
||||
}
|
||||
|
||||
function isAvailableDevice(device: Pick<DeviceEntry, 'state' | 'transport'>): boolean {
|
||||
@@ -87,7 +100,7 @@ function isAvailableDevice(device: Pick<DeviceEntry, 'state' | 'transport'>): bo
|
||||
}
|
||||
|
||||
function defaultDeviceRank(device: DeviceEntry): number {
|
||||
if (!device.paired || !isIPhoneDevice(device) || !isAvailableDevice(device)) return -1;
|
||||
if (!device.paired || !isSupportedIOSDevice(device) || !isAvailableDevice(device)) return -1;
|
||||
|
||||
const state = device.state.trim().toLowerCase();
|
||||
const transport = device.transport.trim().toLowerCase();
|
||||
@@ -98,17 +111,60 @@ function defaultDeviceRank(device: DeviceEntry): number {
|
||||
+ (state.startsWith('available') ? 1 : 0);
|
||||
}
|
||||
|
||||
function pickDefaultDevice(devices: DeviceEntry[]): DeviceEntry | undefined {
|
||||
let best: DeviceEntry | undefined;
|
||||
let bestRank = -1;
|
||||
for (const device of devices) {
|
||||
const rank = defaultDeviceRank(device);
|
||||
if (rank > bestRank) {
|
||||
best = device;
|
||||
bestRank = rank;
|
||||
}
|
||||
type DeviceSelection =
|
||||
| { ok: true; device: DeviceEntry }
|
||||
| { ok: false; error: BootstrapErrorReason; detail?: string };
|
||||
|
||||
/**
|
||||
* Choose the device a QA session targets: the explicit UDID when one is set,
|
||||
* otherwise the best-ranked paired iPhone or iPad. Ranks that tie (an iPhone
|
||||
* and an iPad both on USB, say) are ambiguous, so the caller gets every
|
||||
* candidate with its UDID instead of a silent pick. `prefer` breaks a tie in
|
||||
* favor of the device a live session already uses.
|
||||
*/
|
||||
export function selectDevice(devices: DeviceEntry[], udid?: string, prefer?: string): DeviceSelection {
|
||||
if (devices.length === 0) return { ok: false, error: 'no_devices' };
|
||||
if (udid) {
|
||||
const explicit = devices.find((d) => d.identifier === udid);
|
||||
return explicit ? { ok: true, device: explicit } : { ok: false, error: 'device_not_found', detail: udid };
|
||||
}
|
||||
return best;
|
||||
|
||||
const bestRank = Math.max(...devices.map(defaultDeviceRank));
|
||||
const best = bestRank < 0 ? [] : devices.filter((d) => defaultDeviceRank(d) === bestRank);
|
||||
if (best.length === 1) return { ok: true, device: best[0]! };
|
||||
if (best.length > 1) {
|
||||
const preferred = best.find((d) => d.identifier === prefer);
|
||||
if (preferred) return { ok: true, device: preferred };
|
||||
const listing = best.map((d) => ` ${d.name} (${d.deviceType || d.model}): ${d.identifier}`).join('\n');
|
||||
return {
|
||||
ok: false,
|
||||
error: 'multiple_devices',
|
||||
detail: `${best.length} iPhones/iPads are connected and none is selected:\n${listing}\n`
|
||||
+ `Pick one, then restart the daemon:\n export GSTACK_IOS_TARGET_UDID=${best[0]!.identifier}`,
|
||||
};
|
||||
}
|
||||
|
||||
const pairedIOS = devices.find((d) => d.paired && isSupportedIOSDevice(d));
|
||||
if (pairedIOS) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: `paired device ${pairedIOS.name} (${pairedIOS.identifier}) is ${pairedIOS.state}; connect it over USB and unlock it`,
|
||||
};
|
||||
}
|
||||
const firstIOS = devices.find(isSupportedIOSDevice);
|
||||
if (!firstIOS) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: 'no iPhone or iPad is connected; non-iOS devices are not eligible for iOS QA',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'no_paired_device',
|
||||
detail: `device ${firstIOS.name} (${firstIOS.identifier}) is ${firstIOS.state}; run \`xcrun devicectl manage pair --device ${firstIOS.identifier}\` and tap Trust on the device`,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, {
|
||||
@@ -150,51 +206,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
|
||||
// Step 1: pick a device
|
||||
const devices = listDevices(spawn);
|
||||
if (devices.length === 0) {
|
||||
return { ok: false, error: 'no_devices' };
|
||||
}
|
||||
const target = opts.udid
|
||||
? devices.find((d) => d.identifier === opts.udid)
|
||||
: pickDefaultDevice(devices);
|
||||
if (!target) {
|
||||
if (opts.udid) {
|
||||
return { ok: false, error: 'device_not_found', detail: opts.udid };
|
||||
}
|
||||
const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d));
|
||||
if (pairedIPhone) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`,
|
||||
};
|
||||
}
|
||||
const firstIPhone = devices.find(isIPhoneDevice);
|
||||
if (!firstIPhone) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'no_paired_device',
|
||||
detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`,
|
||||
};
|
||||
}
|
||||
if (!isIPhoneDevice(target)) {
|
||||
const selection = selectDevice(listDevices(spawn), opts.udid);
|
||||
if (!selection.ok) return selection;
|
||||
const target = selection.device;
|
||||
if (!isSupportedIOSDevice(target)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'device_not_found',
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`,
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone or iPad`,
|
||||
};
|
||||
}
|
||||
if (!target.paired) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'no_paired_device',
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`,
|
||||
detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the device`,
|
||||
};
|
||||
}
|
||||
if (!isAvailableDevice(target)) {
|
||||
@@ -234,6 +260,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
|
||||
// Step 4: wait for StateServer to become reachable, then scrape boot token.
|
||||
// Probe /healthz with retries (the listener can take a moment to bind).
|
||||
let bootTokenWriteError: string | undefined;
|
||||
const waitForStateServer = async (): Promise<BootstrapResult | null> => {
|
||||
const deadline = Date.now() + startupTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -242,7 +269,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (r.ok) {
|
||||
const health = await r.json().catch(() => null) as { bundle_id?: string } | null;
|
||||
const health = await r.json().catch(() => null) as { bundle_id?: string; boot_token_error?: string } | null;
|
||||
// Older bridges did not identify their bundle. Preserve compatibility,
|
||||
// but reject an explicit mismatch from current bridges: another debug
|
||||
// app already owns the fixed StateServer port on this device.
|
||||
@@ -253,6 +280,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`,
|
||||
};
|
||||
}
|
||||
bootTokenWriteError = health?.boot_token_error;
|
||||
return null;
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
@@ -276,6 +304,15 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
});
|
||||
|
||||
let bootToken = readBootToken();
|
||||
if (!bootToken && bootTokenWriteError) {
|
||||
// The app reported that it could not write the file. A relaunch would
|
||||
// only fail the same way and wipe the app's state, so stop here.
|
||||
return {
|
||||
ok: false,
|
||||
error: 'boot_token_unavailable',
|
||||
detail: `${opts.bundleId} could not write ${tokenPath}: ${bootTokenWriteError}; fix the app's tmp/ directory, then relaunch the app`,
|
||||
};
|
||||
}
|
||||
if (!bootToken) {
|
||||
// A healthy running app can lack a boot token when an earlier daemon
|
||||
// already rotated it. A new daemon has no way to recover that in-memory
|
||||
@@ -336,3 +373,87 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type TunnelRecovery =
|
||||
/** The app still holds the bearer; keep the session at this (maybe new) address. */
|
||||
| { action: 'reuse'; tunnel: DeviceTunnel }
|
||||
/** Full bootstrap needed. The old bearer was not sent to anything unproven. */
|
||||
| { action: 'bootstrap'; reason: string }
|
||||
/** Route still down with the app running: surface the error, keep the session. */
|
||||
| { action: 'unavailable'; reason: string };
|
||||
|
||||
export interface RecoveryOptions {
|
||||
/** Same explicit UDID the bootstrap used, if any. */
|
||||
udid?: string;
|
||||
bundleId: string;
|
||||
probeTimeoutMs?: number;
|
||||
spawnImpl?: SpawnImpl;
|
||||
resolveImpl?: ResolveImpl;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how to recover a cached tunnel after a route failure (503
|
||||
* device_disconnected / 504 upstream_timeout) without relaunching the app.
|
||||
* CoreDevice routes blip while the app keeps running with the rotated bearer
|
||||
* in memory, and the one-shot boot-token file is already gone, so a full
|
||||
* bootstrap would have to relaunch the app and wipe its QA state (#1975).
|
||||
*
|
||||
* The bearer is only ever sent to the address `devicectl` reports for the
|
||||
* pinned UDID, or to the address this session already used. Only a 401 (the
|
||||
* app was replaced) or a confirmed app-absent state asks for a bootstrap.
|
||||
*/
|
||||
export async function recoverTunnel(failed: DeviceTunnel, opts: RecoveryOptions): Promise<TunnelRecovery> {
|
||||
const spawn = opts.spawnImpl;
|
||||
const fetchFn = opts.fetchImpl ?? fetch;
|
||||
const timeoutMs = opts.probeTimeoutMs ?? 3_000;
|
||||
|
||||
const selection = selectDevice(listDevices(spawn), opts.udid, failed.udid);
|
||||
if (!selection.ok) {
|
||||
return { action: 'unavailable', reason: `${selection.error}${selection.detail ? `: ${selection.detail}` : ''}` };
|
||||
}
|
||||
const device = selection.device;
|
||||
if (device.identifier !== failed.udid) {
|
||||
return { action: 'bootstrap', reason: `target device changed from ${failed.udid} to ${device.identifier}` };
|
||||
}
|
||||
|
||||
let address = getDeviceTunnelIPv6FromDevicectl(device.identifier, spawn);
|
||||
if (!address) {
|
||||
const byName = await getDeviceTunnelIPv6(device.name, opts.resolveImpl);
|
||||
if (byName && byName !== failed.ipv6Addr) {
|
||||
return { action: 'bootstrap', reason: `tunnel address changed to ${byName} and devicectl could not tie it to ${failed.udid}` };
|
||||
}
|
||||
address = failed.ipv6Addr;
|
||||
}
|
||||
|
||||
const base = `http://[${address}]:${failed.port}`;
|
||||
const appGone = (reason: string): TunnelRecovery => (isAppRunning(device.identifier, opts.bundleId, spawn)
|
||||
? { action: 'unavailable', reason: `${reason}; ${opts.bundleId} is still running, so the session is kept` }
|
||||
: { action: 'bootstrap', reason: `${opts.bundleId} is not running on ${device.identifier}` });
|
||||
|
||||
let health: Response;
|
||||
try {
|
||||
health = await fetchFn(`${base}/healthz`, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
} catch {
|
||||
return appGone(`no /healthz response from ${base}`);
|
||||
}
|
||||
const owner = (await health.json().catch(() => null) as { bundle_id?: string } | null)?.bundle_id;
|
||||
if (owner && owner !== opts.bundleId) {
|
||||
return { action: 'bootstrap', reason: `StateServer at ${base} now belongs to ${owner}` };
|
||||
}
|
||||
|
||||
let probe: Response;
|
||||
try {
|
||||
probe = await fetchFn(`${base}/state/snapshot`, {
|
||||
headers: { 'Authorization': `Bearer ${failed.bootTokenRotated}` },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch {
|
||||
return appGone(`authenticated probe to ${base} failed`);
|
||||
}
|
||||
await probe.arrayBuffer().catch(() => undefined);
|
||||
if (probe.status === 401) {
|
||||
return { action: 'bootstrap', reason: `${opts.bundleId} rejected the session bearer (the app was relaunched)` };
|
||||
}
|
||||
return { action: 'reuse', tunnel: { ...failed, ipv6Addr: address } };
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
// - Rate limit on /auth/mint
|
||||
// - Tailnet listener never binds 0.0.0.0
|
||||
// - Boot token never leaks in responses
|
||||
// - Route-drop recovery against a simulated device (#1975): no app relaunch
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { createServer } from 'http';
|
||||
@@ -15,7 +16,8 @@ import type { Server, IncomingMessage } from 'http';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { startDaemon, type RunningDaemon } from '../src/index';
|
||||
import { startDaemon, deviceTunnelSource, type RunningDaemon } from '../src/index';
|
||||
import { startFakeDevice, type FakeDevice } from './fake-device';
|
||||
import { grantIdentity } from '../src/allowlist';
|
||||
import type { DeviceTunnel } from '../src/proxy';
|
||||
|
||||
@@ -512,6 +514,149 @@ describe('daemon — loopback listener', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Route-drop recovery (#1975 finding 1) through the production wiring
|
||||
// (deviceTunnelSource = bootstrapTunnel + recoverTunnel) against a simulated
|
||||
// device. A black-holed request times out after PROXY_TIMEOUT_MS, which is how
|
||||
// a dropped CoreDevice route looks to the daemon (504 upstream_timeout).
|
||||
describe('daemon — route-drop recovery (simulated device)', () => {
|
||||
const BUNDLE = 'com.test.recovery';
|
||||
const PROXY_TIMEOUT_MS = 300;
|
||||
let recoveryDir: string;
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
beforeAll(() => { recoveryDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-recovery-')); });
|
||||
afterAll(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
||||
rmSync(recoveryDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function startSession(name: string, fake: FakeDevice) {
|
||||
const logs: string[] = [];
|
||||
const source = deviceTunnelSource({
|
||||
bundleId: BUNDLE,
|
||||
port: fake.port,
|
||||
spawnImpl: fake.spawn,
|
||||
keepalive: () => ({ stop() {} }),
|
||||
log: (line) => logs.push(line),
|
||||
});
|
||||
let recoveries = 0;
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: join(recoveryDir, `${name}.pid`),
|
||||
proxyTimeoutMs: PROXY_TIMEOUT_MS,
|
||||
tunnelProvider: source.tunnelProvider,
|
||||
tunnelRecovery: async (failed) => { recoveries++; return source.tunnelRecovery(failed); },
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
cleanups.push(() => d.close());
|
||||
return { base: `http://127.0.0.1:${d.loopbackPort}`, logs, recoveries: () => recoveries };
|
||||
}
|
||||
|
||||
async function device(udid = 'FAKE-UDID-1', deviceType: 'iPhone' | 'iPad' = 'iPhone') {
|
||||
const fake = await startFakeDevice({ bundleId: BUNDLE, udid, deviceType });
|
||||
cleanups.push(() => fake.close());
|
||||
return fake;
|
||||
}
|
||||
|
||||
const terminations = (fake: FakeDevice) => fake.calls.filter((c) => c.includes('--terminate-existing')).length;
|
||||
const bootTokenReads = (fake: FakeDevice) => fake.calls.filter((c) => c.includes('device copy from')).length;
|
||||
|
||||
test('live app: a route drop keeps the bearer and the app state, with no relaunch', async () => {
|
||||
const fake = await device();
|
||||
const session = await startSession('live-route-drop', fake);
|
||||
expect((await fetchWith('POST', `${session.base}/tap`, { body: '{}' })).status).toBe(200);
|
||||
const bearer = fake.requests.at(-1)?.authorization;
|
||||
|
||||
fake.dropConnections(1);
|
||||
const after = await fetchWith('GET', `${session.base}/state/snapshot`);
|
||||
expect(after.status).toBe(200);
|
||||
expect(JSON.parse(after.bodyText).state).toEqual({ generation: 1, marker: 'tapped' });
|
||||
expect(terminations(fake)).toBe(0);
|
||||
expect(bootTokenReads(fake)).toBe(1);
|
||||
expect(session.recoveries()).toBe(1);
|
||||
expect(fake.requests.at(-1)?.authorization).toBe(bearer);
|
||||
expect(session.logs.some((l) => l.startsWith('tunnel recovered: kept the session'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a tap whose response was lost is not replayed; the recovered session takes the next tap', async () => {
|
||||
const fake = await device();
|
||||
const session = await startSession('ambiguous-tap', fake);
|
||||
expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200);
|
||||
|
||||
fake.dropConnections(1);
|
||||
const lost = await fetchWith('POST', `${session.base}/tap`, { body: '{}' });
|
||||
expect(lost.status).toBe(504);
|
||||
expect(fake.requests.filter((r) => r.path === '/tap')).toHaveLength(0);
|
||||
|
||||
expect((await fetchWith('POST', `${session.base}/tap`, { body: '{}' })).status).toBe(200);
|
||||
expect(fake.requests.filter((r) => r.path === '/tap')).toHaveLength(1);
|
||||
expect(terminations(fake)).toBe(0);
|
||||
expect(fake.appState.generation).toBe(1);
|
||||
});
|
||||
|
||||
test('concurrent requests during a route drop share one recovery', async () => {
|
||||
const fake = await device();
|
||||
const session = await startSession('concurrent-recovery', fake);
|
||||
expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200);
|
||||
const listingsBefore = fake.calls.filter((c) => c.includes('list devices')).length;
|
||||
|
||||
fake.dropConnections(3);
|
||||
const responses = await Promise.all([1, 2, 3].map(() => fetchWith('GET', `${session.base}/screenshot`)));
|
||||
expect(responses.map((r) => r.status)).toEqual([200, 200, 200]);
|
||||
expect(session.recoveries()).toBe(1);
|
||||
expect(fake.calls.filter((c) => c.includes('list devices')).length - listingsBefore).toBe(1);
|
||||
expect(fake.requests.filter((r) => r.path === '/state/snapshot')).toHaveLength(1);
|
||||
expect(terminations(fake)).toBe(0);
|
||||
});
|
||||
|
||||
test('stopped app: one normal launch, never --terminate-existing', async () => {
|
||||
const fake = await device();
|
||||
const session = await startSession('stopped-app', fake);
|
||||
expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200);
|
||||
|
||||
fake.stopApp();
|
||||
const after = await fetchWith('GET', `${session.base}/screenshot`);
|
||||
expect(after.status).toBe(200);
|
||||
expect(fake.calls.filter((c) => c.includes('device process launch'))).toHaveLength(2);
|
||||
expect(terminations(fake)).toBe(0);
|
||||
expect(fake.appState.generation).toBe(2);
|
||||
expect(session.logs.some((l) => l.includes(`${BUNDLE} is not running`))).toBe(true);
|
||||
});
|
||||
|
||||
test('app relaunched from Xcode during a drop: the probe gets 401 and bootstraps from the fresh token', async () => {
|
||||
const fake = await device();
|
||||
const session = await startSession('relaunched-app', fake);
|
||||
expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200);
|
||||
|
||||
fake.dropConnections(1);
|
||||
fake.relaunchExternally();
|
||||
expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200);
|
||||
expect(bootTokenReads(fake)).toBe(2);
|
||||
expect(terminations(fake)).toBe(0);
|
||||
expect(session.logs.some((l) => l.includes('rejected the session bearer'))).toBe(true);
|
||||
});
|
||||
|
||||
test('target device changed: full bootstrap on the new device, old bearer never sent to it', async () => {
|
||||
const fake = await device('PHONE-UDID', 'iPhone');
|
||||
const session = await startSession('udid-change', fake);
|
||||
expect((await fetchWith('GET', `${session.base}/screenshot`)).status).toBe(200);
|
||||
const oldBearer = fake.requests.at(-1)?.authorization;
|
||||
|
||||
fake.dropConnections(1);
|
||||
fake.replaceDevice('PAD-UDID', 'iPad');
|
||||
const after = await fetchWith('GET', `${session.base}/screenshot`);
|
||||
expect(after.status).toBe(200);
|
||||
expect(session.logs.some((l) => l.includes('target device changed from PHONE-UDID to PAD-UDID'))).toBe(true);
|
||||
const padRequests = fake.requests.filter((r) => r.udid === 'PAD-UDID');
|
||||
expect(padRequests.length).toBeGreaterThan(0);
|
||||
expect(padRequests.some((r) => r.authorization === oldBearer)).toBe(false);
|
||||
expect(padRequests.at(-1)).toMatchObject({ path: '/screenshot' });
|
||||
expect(fake.calls.some((c) => c.includes('device copy from --device PAD-UDID'))).toBe(true);
|
||||
expect(terminations(fake)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon — tailnet listener (mocked tailscaled)', () => {
|
||||
let stub: Awaited<ReturnType<typeof startStubStateServer>>;
|
||||
let daemon: RunningDaemon;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// Simulated iOS device for daemon recovery tests: a `devicectl` emulator plus
|
||||
// a StateServer stand-in with the real auth state machine (one-shot boot
|
||||
// token file, scrubbed on /auth/rotate; rotated bearer held in memory; a
|
||||
// relaunch mints a new boot token and forgets the bearer and all app state).
|
||||
// It lets tests drive the production bootstrap + recovery wiring end-to-end
|
||||
// without Xcode or an iPhone.
|
||||
|
||||
import { createServer, type Server } from 'http';
|
||||
import type { Socket } from 'net';
|
||||
import { writeFileSync } from 'fs';
|
||||
import type { SpawnImpl } from '../src/devicectl';
|
||||
|
||||
export interface FakeDeviceEntry {
|
||||
identifier: string;
|
||||
name: string;
|
||||
productType: string;
|
||||
platform: string;
|
||||
deviceType: string;
|
||||
tunnelState?: string;
|
||||
transportType?: string;
|
||||
paired?: boolean;
|
||||
}
|
||||
|
||||
export interface FakeDevice {
|
||||
udid: string;
|
||||
port: number;
|
||||
/** Tunnel address `devicectl device info details` reports for `udid`. */
|
||||
address: string;
|
||||
/** Entries `devicectl list devices` returns; mutate to simulate re-plugging. */
|
||||
devices: FakeDeviceEntry[];
|
||||
/** Every devicectl invocation, joined. */
|
||||
calls: string[];
|
||||
/** Every StateServer request, the bearer it carried, and the device it reached. */
|
||||
requests: Array<{ method: string; path: string; authorization?: string; udid: string }>;
|
||||
running: boolean;
|
||||
/** In-app QA state; lost whenever the app process is replaced. */
|
||||
appState: { generation: number; marker: string | null };
|
||||
spawn: SpawnImpl;
|
||||
/** Black-hole the next `count` requests (CoreDevice route blip: no reply until the proxy times out). */
|
||||
dropConnections(count: number): void;
|
||||
/** Simulate the app crashing or being stopped. */
|
||||
stopApp(): void;
|
||||
/** Simulate Xcode re-running the app: new process, new boot-token file. */
|
||||
relaunchExternally(): void;
|
||||
/**
|
||||
* Unplug this device and plug in another one that answers at the same
|
||||
* tunnel address and port (production StateServers all use one port). The
|
||||
* new device starts with the app not running.
|
||||
*/
|
||||
replaceDevice(udid: string, deviceType: 'iPhone' | 'iPad'): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const BUNDLE_PATH = (bundleId: string) => `/private/var/containers/Bundle/Application/FAKE/${bundleId}.app/`;
|
||||
|
||||
function ret(status: number, stdout = '', stderr = ''): ReturnType<SpawnImpl> {
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, Buffer.from(stdout), Buffer.from(stderr)],
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
status,
|
||||
signal: null,
|
||||
} as ReturnType<SpawnImpl>;
|
||||
}
|
||||
|
||||
function targetOf(args: string[]): string | undefined {
|
||||
const flag = args.findIndex((a) => a === '--device' || a === '-d');
|
||||
return flag === -1 ? undefined : args[flag + 1];
|
||||
}
|
||||
|
||||
function writeJson(args: string[], payload: unknown): void {
|
||||
const at = args.indexOf('--json-output');
|
||||
if (at !== -1 && args[at + 1]) writeFileSync(args[at + 1]!, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function writeListing(args: string[], entries: FakeDeviceEntry[]): void {
|
||||
writeJson(args, {
|
||||
result: {
|
||||
devices: entries.map((d) => ({
|
||||
identifier: d.identifier,
|
||||
connectionProperties: {
|
||||
tunnelState: d.tunnelState ?? 'connected',
|
||||
pairingState: d.paired === false ? 'unpaired' : 'paired',
|
||||
transportType: d.transportType ?? 'wired',
|
||||
},
|
||||
deviceProperties: { name: d.name },
|
||||
hardwareProperties: { productType: d.productType, platform: d.platform, deviceType: d.deviceType },
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function startFakeDevice(opts: {
|
||||
bundleId: string;
|
||||
udid?: string;
|
||||
deviceType?: 'iPhone' | 'iPad';
|
||||
}): Promise<FakeDevice> {
|
||||
const entryFor = (udid: string, deviceType: 'iPhone' | 'iPad'): FakeDeviceEntry => ({
|
||||
identifier: udid,
|
||||
name: `Fake ${deviceType}`,
|
||||
productType: deviceType === 'iPad' ? 'iPad14,5' : 'iPhone15,2',
|
||||
platform: 'iOS',
|
||||
deviceType,
|
||||
});
|
||||
let bootToken: string | null = null;
|
||||
let bootTokenFile: string | null = null;
|
||||
let rotatedToken: string | null = null;
|
||||
let dropping = 0;
|
||||
const sockets = new Set<Socket>();
|
||||
|
||||
const device: FakeDevice = {
|
||||
udid: opts.udid ?? 'FAKE-UDID-1',
|
||||
port: 0,
|
||||
address: '::1',
|
||||
devices: [],
|
||||
calls: [],
|
||||
requests: [],
|
||||
running: false,
|
||||
appState: { generation: 0, marker: null },
|
||||
spawn: undefined as unknown as SpawnImpl,
|
||||
dropConnections(count) { dropping = count; },
|
||||
stopApp() {
|
||||
device.running = false;
|
||||
rotatedToken = null;
|
||||
bootToken = null;
|
||||
},
|
||||
relaunchExternally() { launch(); },
|
||||
replaceDevice(udid, deviceType) {
|
||||
device.stopApp();
|
||||
device.udid = udid;
|
||||
device.devices = [entryFor(udid, deviceType)];
|
||||
device.appState = { generation: 0, marker: null };
|
||||
},
|
||||
close: () => new Promise<void>((resolve) => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
|
||||
device.devices = [entryFor(device.udid, opts.deviceType ?? 'iPhone')];
|
||||
|
||||
const launch = () => {
|
||||
device.running = true;
|
||||
device.appState = { generation: device.appState.generation + 1, marker: null };
|
||||
bootToken = `boot-${device.appState.generation}-${Math.random().toString(36).slice(2)}`;
|
||||
bootTokenFile = bootToken;
|
||||
rotatedToken = null;
|
||||
};
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
if (!device.running) {
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (dropping > 0) {
|
||||
dropping--;
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const authorization = req.headers.authorization;
|
||||
device.requests.push({ method: req.method ?? '', path: req.url ?? '', authorization, udid: device.udid });
|
||||
const send = (status: number, body: unknown) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const bearer = authorization?.startsWith('Bearer ') ? authorization.slice(7) : null;
|
||||
if (req.method === 'GET' && req.url === '/healthz') {
|
||||
send(200, { version: '1.0.0', bundle_id: opts.bundleId });
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/auth/rotate') {
|
||||
if (!bootToken || bearer !== bootToken) { send(401, { error: 'boot_token_invalid' }); return; }
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { new_token: string };
|
||||
rotatedToken = parsed.new_token;
|
||||
bootToken = null;
|
||||
bootTokenFile = null;
|
||||
send(200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (!rotatedToken || bearer !== rotatedToken) { send(401, { error: 'unauthorized' }); return; }
|
||||
if (req.url === '/state/snapshot') { send(200, { state: device.appState }); return; }
|
||||
if (req.url === '/screenshot') { send(200, { png_base64: 'abc=' }); return; }
|
||||
if (req.method === 'POST' && req.url === '/tap') {
|
||||
device.appState.marker = 'tapped';
|
||||
send(200, { ok: true });
|
||||
return;
|
||||
}
|
||||
send(404, { error: 'not_found' });
|
||||
});
|
||||
});
|
||||
server.on('connection', (socket: Socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '::1', () => resolve());
|
||||
});
|
||||
device.port = (server.address() as { port: number }).port;
|
||||
|
||||
|
||||
device.spawn = (cmd, args) => {
|
||||
const joined = `${cmd} ${args.join(' ')}`;
|
||||
device.calls.push(joined);
|
||||
if (/devicectl list devices/.test(joined)) {
|
||||
writeListing(args, device.devices);
|
||||
return ret(0);
|
||||
}
|
||||
const target = targetOf(args);
|
||||
if (target !== device.udid) return ret(1, '', `device ${target} not found`);
|
||||
if (/devicectl device info details/.test(joined)) {
|
||||
writeJson(args, { result: { connectionProperties: { tunnelIPAddress: device.address } } });
|
||||
return ret(0);
|
||||
}
|
||||
if (/devicectl device info processes/.test(joined)) {
|
||||
writeJson(args, {
|
||||
result: { runningProcesses: device.running ? [{ executable: `file://${BUNDLE_PATH(opts.bundleId)}App` }] : [] },
|
||||
});
|
||||
return ret(0);
|
||||
}
|
||||
if (/devicectl device process launch/.test(joined)) {
|
||||
if (device.running && !args.includes('--terminate-existing')) return ret(0);
|
||||
launch();
|
||||
return ret(0);
|
||||
}
|
||||
if (/devicectl device copy from/.test(joined)) {
|
||||
if (!bootTokenFile) return ret(1, '', 'file not found');
|
||||
writeFileSync(args[args.indexOf('--destination') + 1]!, bootTokenFile);
|
||||
return ret(0);
|
||||
}
|
||||
return ret(1, '', `unexpected devicectl call: ${joined}`);
|
||||
};
|
||||
|
||||
return device;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// rotate_failed, success) without needing a real iPhone connected.
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { bootstrapTunnel } from '../src/tunnel-bootstrap';
|
||||
import { bootstrapTunnel, recoverTunnel, selectDevice } from '../src/tunnel-bootstrap';
|
||||
import {
|
||||
getDeviceTunnelIPv6FromDevicectl,
|
||||
resolveTunnelIPv6,
|
||||
@@ -158,7 +158,7 @@ describe('bootstrapTunnel', () => {
|
||||
if (r.ok) expect(r.tunnel.udid).toBe('IPHONE');
|
||||
});
|
||||
|
||||
test('fails clearly when only non-iPhone platforms are connected', async () => {
|
||||
test('fails clearly when only non-iOS platforms are connected', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
@@ -175,13 +175,13 @@ describe('bootstrapTunnel', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'IPAD',
|
||||
identifier: 'WATCH',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'iPad' },
|
||||
deviceProperties: { name: 'Apple Watch' },
|
||||
hardwareProperties: {
|
||||
productType: 'iPad16,6',
|
||||
platform: 'iOS',
|
||||
deviceType: 'iPad',
|
||||
productType: 'Watch7,5',
|
||||
platform: 'watchOS',
|
||||
deviceType: 'watch',
|
||||
},
|
||||
},
|
||||
] },
|
||||
@@ -193,11 +193,11 @@ describe('bootstrapTunnel', () => {
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('device_not_found');
|
||||
expect(r.detail).toContain('no iPhone');
|
||||
expect(r.detail).toContain('no iPhone or iPad');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an explicitly targeted non-iPhone device', async () => {
|
||||
test('rejects an explicitly targeted non-iPhone/iPad device', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
@@ -220,10 +220,88 @@ describe('bootstrapTunnel', () => {
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('device_not_found');
|
||||
expect(r.detail).toContain('not an iPhone');
|
||||
expect(r.detail).toContain('not an iPhone or iPad');
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts a physical iPad alongside iPhones', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'IPAD-14-5',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
|
||||
deviceProperties: { name: 'Test iPad' },
|
||||
hardwareProperties: {
|
||||
productType: 'iPad14,5',
|
||||
platform: 'iOS',
|
||||
deviceType: 'iPad',
|
||||
},
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device process launch --device IPAD-14-5/,
|
||||
exitCode: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
// Fast-fail at resolution stage so we verify device acceptance
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
resolveImpl: () => Promise.reject(new Error('no mDNS record')),
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('resolve_failed');
|
||||
}
|
||||
});
|
||||
|
||||
test('iPhone and iPad both on USB: lists each UDID and a ready export line instead of guessing', async () => {
|
||||
const wired = { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' };
|
||||
const spawn = makeSpawn([{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [
|
||||
{
|
||||
identifier: 'IPHONE-UDID',
|
||||
connectionProperties: wired,
|
||||
deviceProperties: { name: 'Test iPhone' },
|
||||
hardwareProperties: { productType: 'iPhone15,2', platform: 'iOS', deviceType: 'iPhone' },
|
||||
},
|
||||
{
|
||||
identifier: 'IPAD-UDID',
|
||||
connectionProperties: wired,
|
||||
deviceProperties: { name: 'Test iPad' },
|
||||
hardwareProperties: { productType: 'iPad14,5', platform: 'iOS', deviceType: 'iPad' },
|
||||
},
|
||||
] },
|
||||
},
|
||||
}]);
|
||||
const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(r.error).toBe('multiple_devices');
|
||||
expect(r.detail).toContain('Test iPhone (iPhone): IPHONE-UDID');
|
||||
expect(r.detail).toContain('Test iPad (iPad): IPAD-UDID');
|
||||
expect(r.detail).toMatch(/^ export GSTACK_IOS_TARGET_UDID=IPHONE-UDID$/m);
|
||||
});
|
||||
|
||||
test('selectDevice: explicit UDID picks the iPad; a live session breaks a tie for its own device', () => {
|
||||
const entry = (identifier: string, deviceType: string) => ({
|
||||
identifier, name: identifier, model: `${deviceType}1,1`, platform: 'iOS', deviceType,
|
||||
state: 'connected', transport: 'wired', paired: true,
|
||||
});
|
||||
const devices = [entry('PHONE', 'iPhone'), entry('PAD', 'iPad')];
|
||||
expect(selectDevice(devices, 'PAD')).toMatchObject({ ok: true, device: { identifier: 'PAD' } });
|
||||
expect(selectDevice(devices, undefined, 'PAD')).toMatchObject({ ok: true, device: { identifier: 'PAD' } });
|
||||
expect(selectDevice(devices)).toMatchObject({ ok: false, error: 'multiple_devices' });
|
||||
expect(selectDevice([{ ...entry('PAD', 'iPad'), transport: '' }, entry('PHONE', 'iPhone')]))
|
||||
.toMatchObject({ ok: true, device: { identifier: 'PHONE' } });
|
||||
});
|
||||
|
||||
test('skips an unavailable paired device for a connected or available paired device', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
@@ -552,6 +630,47 @@ describe('bootstrapTunnel', () => {
|
||||
expect(fetchCalls.at(-1)?.authorization).toBe('Bearer FRESH-BOOT-TOKEN');
|
||||
});
|
||||
|
||||
test('an app that reports a failed boot-token write gets a clear error, not a relaunch (#1837)', async () => {
|
||||
const calls: string[] = [];
|
||||
const scripted = makeSpawn([
|
||||
{
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'TEST-UDID',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
|
||||
deviceProperties: { name: 'Test Device' },
|
||||
hardwareProperties: { productType: 'iPhone18,2' },
|
||||
}] },
|
||||
},
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info processes/,
|
||||
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
|
||||
},
|
||||
{
|
||||
argsMatch: /devicectl device info details/,
|
||||
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
|
||||
},
|
||||
{ argsMatch: /devicectl device copy from/, exitCode: 1, stderr: 'source does not exist' },
|
||||
]);
|
||||
const spawn: SpawnImpl = (cmd, args) => { calls.push(args.join(' ')); return scripted(cmd, args); };
|
||||
|
||||
const r = await bootstrapTunnel({
|
||||
bundleId: 'com.test',
|
||||
spawnImpl: spawn,
|
||||
fetchImpl: (async () => new Response(
|
||||
JSON.stringify({ bundle_id: 'com.test', boot_token_error: 'You don’t have permission to save the file.' }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)) as unknown as typeof fetch,
|
||||
startupTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(r).toMatchObject({ ok: false, error: 'boot_token_unavailable' });
|
||||
if (!r.ok) expect(r.detail).toContain('could not write tmp/gstack-ios-qa.token: You don’t have permission');
|
||||
expect(calls.some((c) => c.includes('--terminate-existing'))).toBe(false);
|
||||
});
|
||||
|
||||
test('rechecks bundle ownership after recovering a consumed boot token', async () => {
|
||||
const spawn = makeSpawn([
|
||||
{
|
||||
@@ -675,6 +794,112 @@ describe('bootstrapTunnel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoverTunnel (route drop, #1975)', () => {
|
||||
const failed = { udid: 'PHONE', ipv6Addr: 'fd00::1', port: 9999, bootTokenRotated: 'session-bearer-XXXXXXXX' };
|
||||
const listing = {
|
||||
argsMatch: /devicectl list devices/,
|
||||
jsonOutput: {
|
||||
result: { devices: [{
|
||||
identifier: 'PHONE',
|
||||
connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
|
||||
deviceProperties: { name: 'Test iPhone' },
|
||||
hardwareProperties: { productType: 'iPhone15,2', platform: 'iOS', deviceType: 'iPhone' },
|
||||
}] },
|
||||
},
|
||||
};
|
||||
const details = (addr: string | null) => addr
|
||||
? { argsMatch: /devicectl device info details --device PHONE/, jsonOutput: { result: { connectionProperties: { tunnelIPAddress: addr } } } }
|
||||
: { argsMatch: /devicectl device info details --device PHONE/, exitCode: 1 };
|
||||
const processes = (running: boolean) => ({
|
||||
argsMatch: /devicectl device info processes -d PHONE/,
|
||||
jsonOutput: { result: { runningProcesses: running ? [{ executable: 'file:///x/com.test.app/App' }] : [] } },
|
||||
});
|
||||
|
||||
function recordingFetch(reply: (url: string) => Response | Error) {
|
||||
const seen: Array<{ url: string; authorization?: string }> = [];
|
||||
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
seen.push({ url, authorization: headers['Authorization'] });
|
||||
const r = reply(url);
|
||||
if (r instanceof Error) throw r;
|
||||
return r;
|
||||
}) as unknown as typeof fetch;
|
||||
return { seen, fetchImpl };
|
||||
}
|
||||
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status });
|
||||
|
||||
test('keeps the bearer and follows a new address devicectl reports for the pinned UDID', async () => {
|
||||
const { seen, fetchImpl } = recordingFetch((url) => url.endsWith('/healthz')
|
||||
? json(200, { bundle_id: 'com.test.app' })
|
||||
: json(200, { state: {} }));
|
||||
const r = await recoverTunnel(failed, {
|
||||
bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::2')]), fetchImpl,
|
||||
});
|
||||
expect(r).toEqual({ action: 'reuse', tunnel: { ...failed, ipv6Addr: 'fd00::2' } });
|
||||
expect(seen.map((s) => s.url)).toEqual(['http://[fd00::2]:9999/healthz', 'http://[fd00::2]:9999/state/snapshot']);
|
||||
expect(seen[0]!.authorization).toBeUndefined();
|
||||
expect(seen[1]!.authorization).toBe(`Bearer ${failed.bootTokenRotated}`);
|
||||
});
|
||||
|
||||
test('an mDNS address devicectl cannot tie to the UDID gets no bearer: bootstrap instead', async () => {
|
||||
const { seen, fetchImpl } = recordingFetch(() => new Error('must not be called'));
|
||||
const r = await recoverTunnel(failed, {
|
||||
bundleId: 'com.test.app',
|
||||
spawnImpl: makeSpawn([listing, details(null)]),
|
||||
resolveImpl: async () => ['fd99::7'],
|
||||
fetchImpl,
|
||||
});
|
||||
expect(r.action).toBe('bootstrap');
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a StateServer now owned by another bundle gets no bearer', async () => {
|
||||
const { seen, fetchImpl } = recordingFetch(() => json(200, { bundle_id: 'com.other.app' }));
|
||||
const r = await recoverTunnel(failed, {
|
||||
bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::1')]), fetchImpl,
|
||||
});
|
||||
expect(r.action).toBe('bootstrap');
|
||||
expect(seen.every((s) => s.authorization === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('401 from the probe means the app was replaced: bootstrap', async () => {
|
||||
const { fetchImpl } = recordingFetch((url) => url.endsWith('/healthz') ? json(200, {}) : json(401, { error: 'unauthorized' }));
|
||||
const r = await recoverTunnel(failed, {
|
||||
bundleId: 'com.test.app', spawnImpl: makeSpawn([listing, details('fd00::1')]), fetchImpl,
|
||||
});
|
||||
expect(r).toMatchObject({ action: 'bootstrap', reason: expect.stringContaining('rejected the session bearer') });
|
||||
});
|
||||
|
||||
test('route still down: running app keeps the session, absent app bootstraps', async () => {
|
||||
const down = () => new Error('connect EHOSTUNREACH');
|
||||
const running = await recoverTunnel(failed, {
|
||||
bundleId: 'com.test.app',
|
||||
spawnImpl: makeSpawn([listing, details('fd00::1'), processes(true)]),
|
||||
fetchImpl: recordingFetch(down).fetchImpl,
|
||||
});
|
||||
expect(running.action).toBe('unavailable');
|
||||
const absent = await recoverTunnel(failed, {
|
||||
bundleId: 'com.test.app',
|
||||
spawnImpl: makeSpawn([listing, details('fd00::1'), processes(false)]),
|
||||
fetchImpl: recordingFetch(down).fetchImpl,
|
||||
});
|
||||
expect(absent).toMatchObject({ action: 'bootstrap', reason: expect.stringContaining('is not running') });
|
||||
});
|
||||
|
||||
test('pinned UDID unplugged: surface the error and send nothing', async () => {
|
||||
const { seen, fetchImpl } = recordingFetch(() => new Error('must not be called'));
|
||||
const r = await recoverTunnel(failed, {
|
||||
udid: 'PHONE',
|
||||
bundleId: 'com.test.app',
|
||||
spawnImpl: makeSpawn([{ argsMatch: /devicectl list devices/, jsonOutput: { result: { devices: [] } } }]),
|
||||
fetchImpl,
|
||||
});
|
||||
expect(r.action).toBe('unavailable');
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeviceTunnelIPv6FromDevicectl', () => {
|
||||
test('extracts tunnelIPAddress from connectionProperties', () => {
|
||||
const spawn = makeSpawn([
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//
|
||||
// Threat model: this surface is reachable from the local Mac via the CoreDevice
|
||||
// IPv6 tunnel. It MUST refuse any caller without a current bearer token. The
|
||||
// boot token is rotated within ~5 seconds of daemon spawn so anything scraping
|
||||
// os_log past that window sees a dead credential.
|
||||
// boot token is rotated within ~5 seconds of daemon spawn, and rotation deletes
|
||||
// its file, so a copy taken past that window is a dead credential.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
@@ -34,9 +34,11 @@ public final class StateServer {
|
||||
private var ipv6Listener: NWListener?
|
||||
private var ipv4Listener: NWListener?
|
||||
|
||||
// Auth state. The boot token is what we wrote to os_log on first launch.
|
||||
// It exists ONLY long enough for the daemon to call /auth/rotate.
|
||||
// Auth state. The boot token is written to a 0600 file in tmp/ on launch
|
||||
// (never to os_log). It exists ONLY long enough for the daemon to call
|
||||
// /auth/rotate. A failed write is kept here and reported on /healthz.
|
||||
private var bootToken: String
|
||||
private var bootTokenWriteError: String?
|
||||
private var rotatedToken: String? // set after first /auth/rotate
|
||||
private var bootTokenValid: Bool = true
|
||||
|
||||
@@ -92,17 +94,28 @@ public final class StateServer {
|
||||
}
|
||||
|
||||
public func start() {
|
||||
// 1. Persist boot token to a 0600 file (best-effort fallback for the
|
||||
// daemon if os_log scrape misses).
|
||||
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
|
||||
// 1. Persist the boot token to a 0600 file. This is the ONLY way the
|
||||
// daemon can authenticate (devicectl copies it out of tmp/), so a
|
||||
// failed write is an error, not a silent skip.
|
||||
let tokenFile = bootTokenPath
|
||||
do {
|
||||
try bootToken.write(toFile: tokenFile, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenFile)
|
||||
} catch {
|
||||
bootTokenWriteError = error.localizedDescription
|
||||
logger.error("Boot token file write failed at \(tokenFile, privacy: .public): \(error.localizedDescription, privacy: .public). No daemon can authenticate until the app relaunches with a writable tmp/.")
|
||||
}
|
||||
|
||||
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
|
||||
// token from the 0600 file above (copyFileFromAppContainer); the
|
||||
// os_log line that used to carry it had no consumer and handed a
|
||||
// live credential to anything reading the unified log during the
|
||||
// launch window. Port/build stay for diagnostics.
|
||||
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
if let failure = bootTokenWriteError {
|
||||
logger.error("gstack-ios-qa-bootstrap NOT READY port=\(self.port, privacy: .public): boot token file unwritable (\(failure, privacy: .public))")
|
||||
} else {
|
||||
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
}
|
||||
|
||||
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
|
||||
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
|
||||
@@ -296,12 +309,14 @@ public final class StateServer {
|
||||
|
||||
// 1. Public on loopback: /healthz.
|
||||
if request.method == "GET" && path == "/healthz" {
|
||||
send(connection: connection, status: 200, body: [
|
||||
var health: JSONDict = [
|
||||
"version": "1.0.0",
|
||||
"build": appBuildId,
|
||||
"accessor_hash": accessorHash,
|
||||
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||
])
|
||||
]
|
||||
if let failure = bootTokenWriteError { health["boot_token_error"] = failure }
|
||||
send(connection: connection, status: 200, body: health)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+66
-46
@@ -35,9 +35,10 @@ import {
|
||||
validateFinding,
|
||||
validateVerificationRequest,
|
||||
} from './contracts';
|
||||
import { capture, containedFile, assertSnapshot } from './snapshot';
|
||||
import { capture, containedFile, assertSnapshot, readSnapshotManifest } from './snapshot';
|
||||
import {
|
||||
assertStateOutside,
|
||||
boundedList,
|
||||
event,
|
||||
finalizeReplayTemporary,
|
||||
loadReport,
|
||||
@@ -470,32 +471,42 @@ function planned(scope: string): CoverageRecord[] {
|
||||
evidence: [],
|
||||
}));
|
||||
}
|
||||
// One 256 KiB budget for the per-entry snapshot lists in report.json, split so
|
||||
// material gaps keep the largest share; findings and events keep the rest.
|
||||
const SNAPSHOT_DISCLOSURE_BUDGET = { gaps: 128 * 1024, transformations: 96 * 1024, exclusions: 32 * 1024 };
|
||||
const UNLISTED = "to keep report.json within its 1 MiB bound; the run's snapshot manifest keeps every entry";
|
||||
function snapshotCoverage(manifest: Awaited<ReturnType<typeof capture>>, scope: string): CoverageRecord {
|
||||
const omitted = manifest.entries.filter((entry) => !entry.executionHash),
|
||||
excluded = omitted.filter((entry) => entry.transformation?.startsWith('excluded:'));
|
||||
excluded = omitted.filter((entry) => entry.transformation?.startsWith('excluded:')),
|
||||
excludedSet = new Set(excluded);
|
||||
// Classify every unexplained omission as a material coverage gap. Coverage
|
||||
// must not depend on transformation prose retaining a particular prefix.
|
||||
const unread = omitted.filter((entry) => !excluded.includes(entry));
|
||||
const unread = omitted.filter((entry) => !excludedSet.has(entry));
|
||||
const deleted = manifest.deletedPaths ?? [],
|
||||
captured = manifest.entries.filter((entry) => entry.executionHash).length,
|
||||
captured = manifest.entries.length - omitted.length,
|
||||
missing = unread.length + deleted.length;
|
||||
return {
|
||||
domain: 'snapshot-inputs',
|
||||
scope,
|
||||
status: missing ? (captured ? 'partial' : 'not_assessed') : 'assessed',
|
||||
method: 'fail-closed captured source inventory',
|
||||
gaps: [
|
||||
...unread.map(
|
||||
(entry) =>
|
||||
`${publicSnapshotPath(manifest, entry.path).path}: in-scope source payload was unread and withheld from static and runtime assessment`,
|
||||
),
|
||||
...deleted.map(
|
||||
(item) =>
|
||||
`${publicSnapshotPath(manifest, item.path).path}: tracked source is deleted from the worktree; only retained history is available for assessment`,
|
||||
),
|
||||
],
|
||||
exclusions: excluded.map(
|
||||
(entry) => `${publicSnapshotPath(manifest, entry.path).path}: ${entry.transformation}`,
|
||||
gaps: boundedList(
|
||||
missing,
|
||||
(index) =>
|
||||
index < unread.length
|
||||
? `${publicSnapshotPath(manifest, unread[index].path, unread[index].pathId).path}: in-scope source payload was unread and withheld from static and runtime assessment`
|
||||
: `${publicSnapshotPath(manifest, deleted[index - unread.length].path, deleted[index - unread.length].pathId).path}: tracked source is deleted from the worktree; only retained history is available for assessment`,
|
||||
{ bytes: SNAPSHOT_DISCLOSURE_BUDGET.gaps },
|
||||
4,
|
||||
(count) => `${count} more unread or deleted in-scope inputs are not listed ${UNLISTED}`,
|
||||
),
|
||||
exclusions: boundedList(
|
||||
excluded.length,
|
||||
(index) =>
|
||||
`${publicSnapshotPath(manifest, excluded[index].path, excluded[index].pathId).path}: ${excluded[index].transformation}`,
|
||||
{ bytes: SNAPSHOT_DISCLOSURE_BUDGET.exclusions },
|
||||
4,
|
||||
(count) => `${count} more explicit exclusions are not listed ${UNLISTED}`,
|
||||
),
|
||||
evidence: [
|
||||
`${captured} sanitized execution input${captured === 1 ? '' : 's'} captured; ${excluded.length} explicit non-executable exclusion${excluded.length === 1 ? '' : 's'}; ${unread.length} unread in-scope input${unread.length === 1 ? '' : 's'}; ${deleted.length} tracked deletion${deleted.length === 1 ? '' : 's'}`,
|
||||
@@ -607,6 +618,8 @@ async function start(
|
||||
fs.rmSync(run.dir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
const transformed = manifest.entries.filter((entry) => entry.transformation),
|
||||
deletedPaths = manifest.deletedPaths ?? [];
|
||||
const report: RunReportV3 = {
|
||||
schemaVersion: 3,
|
||||
runId: run.runId,
|
||||
@@ -621,18 +634,25 @@ async function start(
|
||||
snapshotHash: manifest.executionHash,
|
||||
originalHash: manifest.originalHash,
|
||||
baseCommit: manifest.baseCommit,
|
||||
transformations: [
|
||||
...manifest.entries
|
||||
.filter((entry) => entry.transformation)
|
||||
.map((entry) => ({
|
||||
path: publicSnapshotPath(manifest, entry.path).path,
|
||||
handling: entry.transformation!,
|
||||
})),
|
||||
...(manifest.deletedPaths ?? []).map((item) => ({
|
||||
path: publicSnapshotPath(manifest, item.path).path,
|
||||
handling: 'tracked source deleted; retained history only',
|
||||
})),
|
||||
],
|
||||
transformations: boundedList(
|
||||
transformed.length + deletedPaths.length,
|
||||
(index) =>
|
||||
index < transformed.length
|
||||
? {
|
||||
path: publicSnapshotPath(manifest, transformed[index].path, transformed[index].pathId).path,
|
||||
handling: transformed[index].transformation!,
|
||||
}
|
||||
: {
|
||||
path: publicSnapshotPath(manifest, deletedPaths[index - transformed.length].path).path,
|
||||
handling: 'tracked source deleted; retained history only',
|
||||
},
|
||||
{ bytes: SNAPSHOT_DISCLOSURE_BUDGET.transformations },
|
||||
3,
|
||||
(count) => ({
|
||||
path: '[not listed]',
|
||||
handling: `${count} more transformations are not listed ${UNLISTED}`,
|
||||
}),
|
||||
),
|
||||
},
|
||||
application: {
|
||||
actors: [],
|
||||
@@ -655,7 +675,7 @@ async function start(
|
||||
event(
|
||||
report,
|
||||
'snapshot',
|
||||
`Captured ${manifest.entries.length} source entries; ${manifest.entries.filter((e) => e.transformation).length + (manifest.deletedPaths?.length ?? 0)} transformations disclosed`,
|
||||
`Captured ${manifest.entries.length} source entries; ${transformed.length + deletedPaths.length} transformations disclosed`,
|
||||
);
|
||||
if (policy.mode === 'comprehensive') {
|
||||
const plan = inspectPreparation(join(run.dir, 'snapshot'));
|
||||
@@ -958,9 +978,9 @@ function recoveryEvents(dir: string): string[] {
|
||||
function publicSnapshotPath(
|
||||
manifest: SnapshotManifest,
|
||||
path: string,
|
||||
pathId = manifest.entries.find((item) => item.path === path)?.pathId,
|
||||
): { path: string; displayPath?: string } {
|
||||
const entry = manifest.entries.find((item) => item.path === path),
|
||||
handle = snapshotPathHandle(entry?.pathId ?? snapshotPathId(manifest.root, path));
|
||||
const handle = snapshotPathHandle(pathId ?? snapshotPathId(manifest.root, path));
|
||||
let displayPath: string;
|
||||
try {
|
||||
displayPath = redact(path);
|
||||
@@ -974,8 +994,8 @@ function publicSnapshotManifest(manifest: SnapshotManifest): Record<string, unkn
|
||||
...manifest,
|
||||
root: PUBLIC_SOURCE_ROOT,
|
||||
entries: manifest.entries.map((entry) => {
|
||||
const { path, pathId: _, ...rest } = entry;
|
||||
return { ...rest, ...publicSnapshotPath(manifest, path) };
|
||||
const { path, pathId, ...rest } = entry;
|
||||
return { ...rest, ...publicSnapshotPath(manifest, path, pathId) };
|
||||
}),
|
||||
...(manifest.deletedPaths?.length
|
||||
? { deletedPaths: manifest.deletedPaths.map((item) => publicSnapshotPath(manifest, item.path)) }
|
||||
@@ -1059,7 +1079,7 @@ function originalBoundary(report: RunReportV3): {
|
||||
throw new CsoError('INVALID_SCHEMA', 'Recheck evidence requires a linked original finding');
|
||||
const dir = runDirectory(report.parent.runId),
|
||||
original = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest,
|
||||
manifest = readSnapshotManifest(dir),
|
||||
finding = original.findings.find((item) => item.id === report.parent!.findingId);
|
||||
if (report.repoId !== original.repoId)
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Recheck repository identity differs from the original audit');
|
||||
@@ -1295,7 +1315,7 @@ function submit(args: string[]) {
|
||||
const input = rawInput as SubmissionV3;
|
||||
return withLock(dir, () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
requireReportingTime(report);
|
||||
if (report.status !== 'running')
|
||||
@@ -1401,7 +1421,7 @@ function finish(args: string[]) {
|
||||
if (args.length) throw new CsoError('INVALID_ARGUMENT', 'finish takes only a run ID');
|
||||
return withLock(dir, () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
reclaimDeadAttemptScratch(dir);
|
||||
for (const c of report.coverage)
|
||||
@@ -1478,7 +1498,7 @@ async function inspect(args: string[]) {
|
||||
const { dir } = run(args);
|
||||
if (args.length) throw new CsoError('INVALID_ARGUMENT', 'inspect takes one run ID');
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const rawSensitive = readJson(join(dir, 'sensitive-evidence.json')),
|
||||
sensitiveEvidence = Array.isArray(rawSensitive)
|
||||
@@ -1508,7 +1528,7 @@ async function inspect(args: string[]) {
|
||||
async function read(args: string[]) {
|
||||
const { dir } = run(args);
|
||||
if (args.length !== 1) throw new CsoError('INVALID_ARGUMENT', 'read requires one path or opaque handle');
|
||||
const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
const manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const selected = resolveSnapshotPath(manifest, args[0], true),
|
||||
full = containedFile(join(dir, 'readable'), selected.path),
|
||||
@@ -1517,7 +1537,7 @@ async function read(args: string[]) {
|
||||
}
|
||||
async function history(args: string[]) {
|
||||
const { dir } = run(args),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
let selected: string | undefined;
|
||||
if (args.length) selected = resolveSnapshotPath(manifest, args.shift(), false, 'History path', true).path;
|
||||
@@ -1540,7 +1560,7 @@ function resume(args: string[]) {
|
||||
if (args.length) throw new CsoError('INVALID_ARGUMENT', 'resume takes one run ID');
|
||||
return withLock(dir, () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(dir);
|
||||
if (report.status === 'finished')
|
||||
throw new CsoError('INVALID_SCHEMA', 'A finished audit cannot be resumed');
|
||||
assertSnapshot(dir, manifest);
|
||||
@@ -1677,7 +1697,7 @@ async function scanner(args: string[], sarif = false) {
|
||||
id,
|
||||
runId: report.runId,
|
||||
runDir: dir,
|
||||
manifest: readJson(join(dir, 'snapshot.json')),
|
||||
manifest: readSnapshotManifest(dir),
|
||||
policy: report.policy,
|
||||
executionDeadline: Date.parse(report.deadline) - 60_000,
|
||||
platform: platform(),
|
||||
@@ -1828,7 +1848,7 @@ async function recheck(args: string[], dependencies: CsoCliDependencies) {
|
||||
throw new CsoError('INVALID_SCHEMA', 'Recheck requires a finished original audit');
|
||||
// Keep the original immutable while the fresh snapshot is captured and
|
||||
// until its child lineage report has been durably published.
|
||||
const oldManifest = readJson(join(originalDir, 'snapshot.json'));
|
||||
const oldManifest = readSnapshotManifest(originalDir);
|
||||
const preserveBase = original.policy.diff || Boolean(original.source.baseCommit),
|
||||
report = await start(
|
||||
[
|
||||
@@ -1928,7 +1948,7 @@ function testPlan(args: string[]) {
|
||||
if (args.length !== 1 || !['node', 'bun', 'python', 'rails'].includes(args[0]))
|
||||
throw new CsoError('INVALID_ARGUMENT', 'test-plan requires one supported stack');
|
||||
const stack = args[0] as 'node' | 'bun' | 'python' | 'rails',
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const preparation = inspectPreparation(join(dir, 'snapshot'), stack);
|
||||
if (preparation.status !== 'ready')
|
||||
@@ -1953,7 +1973,7 @@ function runtimePlan(args: string[]) {
|
||||
const port = Number(rawPort);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||
throw new CsoError('INVALID_ARGUMENT', '--port must be an integer from 1024 to 65535');
|
||||
const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
const manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const preparation = inspectPreparation(join(dir, 'snapshot'), stack);
|
||||
if (preparation.status !== 'ready')
|
||||
@@ -2186,7 +2206,7 @@ async function verify(args: string[], dependencies: CsoCliDependencies) {
|
||||
request = validateVerificationRequest(raw);
|
||||
return await withLock(dir, async () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
requireTime(report);
|
||||
if (report.policy.mode !== 'comprehensive' || report.status !== 'running')
|
||||
@@ -2510,7 +2530,7 @@ async function replay(args: string[], dependencies: CsoCliDependencies) {
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Supplied source does not match the bundle input hashes');
|
||||
};
|
||||
if (fs.existsSync(retained)) {
|
||||
manifest = readJson(join(stored.dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(stored.dir);
|
||||
const expiresAt =
|
||||
typeof manifest?.expiresAt === 'string' ? Date.parse(manifest.expiresAt) : Number.NaN;
|
||||
if (!Number.isFinite(expiresAt) || new Date(expiresAt).toISOString() !== manifest.expiresAt)
|
||||
|
||||
+5
-2
@@ -521,6 +521,8 @@ export function commandTimeoutMs(
|
||||
Math.min(phase === 'preparation' ? PREPARATION_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS, deadline - now),
|
||||
);
|
||||
}
|
||||
/** Raw output is never printed or persisted, so a caller may opt in to more than MAX_OUTPUT (snapshot path listings). */
|
||||
const RAW_OUTPUT_LIMIT = 16 * 1024 * 1024;
|
||||
export async function runProcess(
|
||||
file: string,
|
||||
args: string[],
|
||||
@@ -546,7 +548,7 @@ export async function runProcess(
|
||||
throw new CsoError('INVALID_ARGUMENT', 'Invalid child argument');
|
||||
const hardened = hardenGit(file, args);
|
||||
args = hardened.args;
|
||||
const cap = Math.min(opts.maxBytes ?? MAX_OUTPUT, MAX_OUTPUT);
|
||||
const cap = Math.min(opts.maxBytes ?? MAX_OUTPUT, opts.raw ? RAW_OUTPUT_LIMIT : MAX_OUTPUT);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(file, args, {
|
||||
cwd: opts.cwd,
|
||||
@@ -640,12 +642,13 @@ export async function runProcess(
|
||||
child.stdin.end(opts.input);
|
||||
});
|
||||
}
|
||||
export async function git(repo: string, args: string[], home: string): Promise<string> {
|
||||
export async function git(repo: string, args: string[], home: string, maxBytes?: number): Promise<string> {
|
||||
const result = await runProcess(executable('git'), ['--no-optional-locks', '-C', repo, ...args], {
|
||||
cwd: home,
|
||||
env: childEnvironment(home),
|
||||
raw: true,
|
||||
timeoutMs: 15_000,
|
||||
maxBytes,
|
||||
});
|
||||
if (result.code || result.timedOut || result.truncated) {
|
||||
// Git stderr and argv can contain repository paths, refs, and configured
|
||||
|
||||
+68
-11
@@ -15,7 +15,7 @@ import {
|
||||
MAX_OUTPUT,
|
||||
} from './contracts';
|
||||
import { childEnvironment, executable, git, redact, runProcess } from './process';
|
||||
import { secureDirectory, writeHelperJson, writeJson } from './state';
|
||||
import { boundedList, readJson, secureDirectory, writeHelperJson, writeJson } from './state';
|
||||
import { scan } from '../redact-engine';
|
||||
import { atomicWriteSync } from '../fs-atomic';
|
||||
|
||||
@@ -52,6 +52,22 @@ function omittedPath(path: string): boolean {
|
||||
const SECRET_FILE =
|
||||
/(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.pypirc|pip\.conf|credentials(?:\.yml(?:\.enc)?)?|master\.key|id_(?:rsa|ed25519)|.*\.(?:pem|p12|pfx|key)|AGENTS\.md|CLAUDE\.md|GEMINI\.md|bunfig\.toml)$/i;
|
||||
const SOURCE_LIMIT = 64 * 1024 * 1024;
|
||||
/** snapshot.json holds one entry per source file, so it gets its own cap; other private state stays at 1 MiB. */
|
||||
export const SNAPSHOT_MANIFEST_LIMIT = 16 * 1024 * 1024;
|
||||
const SENSITIVE_EVIDENCE_BUDGET = 512 * 1024;
|
||||
const CAPACITY_ISSUE = 'https://github.com/garrytan/gstack/issues/2993';
|
||||
const COUNTED_SOURCE =
|
||||
'every tracked or nonignored untracked file outside dependency and VCS directories (node_modules, .git, .venv, vendor/bundle and similar)';
|
||||
const mib = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||
export function snapshotManifestCapMessage(entries: number, bytes: number): string {
|
||||
return `Snapshot manifest cap exceeded: ${entries} source entries need a ${mib(bytes)} (${bytes} byte) manifest; the snapshot manifest cap is ${mib(SNAPSHOT_MANIFEST_LIMIT)}. One entry is recorded for ${COUNTED_SOURCE}. Next step: run /cso on a smaller checkout of the code you want audited, and add your entry count to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`;
|
||||
}
|
||||
export function sourceCapMessage(bytes: number, files: number): string {
|
||||
return `64 MiB source cap exceeded: ${files} source files hold about ${mib(bytes)} (${bytes} bytes); the source admission cap is ${mib(SOURCE_LIMIT)}. The full size of ${COUNTED_SOURCE} counts, including files over 1 MiB whose payloads are withheld from the audit. Next step: run /cso on a smaller checkout of the code you want audited, and add your measured size to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`;
|
||||
}
|
||||
export function readSnapshotManifest(runDir: string): SnapshotManifest {
|
||||
return readJson(join(runDir, 'snapshot.json'), SNAPSHOT_MANIFEST_LIMIT) as SnapshotManifest;
|
||||
}
|
||||
const SNAPSHOT_ENTRY_LIMIT = 100_000;
|
||||
const GIT_POINTER_LIMIT = 8192;
|
||||
export interface SnapshotCaptureLimits {
|
||||
@@ -447,9 +463,19 @@ async function paths(
|
||||
// for, while still collecting nonignored untracked source.
|
||||
admission.time();
|
||||
const [working, head] = await Promise.all([
|
||||
git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], home),
|
||||
git(
|
||||
repo,
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||
home,
|
||||
SNAPSHOT_MANIFEST_LIMIT,
|
||||
),
|
||||
headCommit
|
||||
? git(repo, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', headCommit, '--'], home)
|
||||
? git(
|
||||
repo,
|
||||
['ls-tree', '-r', '-z', '--name-only', '--full-tree', headCommit, '--'],
|
||||
home,
|
||||
SNAPSHOT_MANIFEST_LIMIT,
|
||||
)
|
||||
: Promise.resolve(''),
|
||||
]),
|
||||
seen = new Set<string>();
|
||||
@@ -478,6 +504,7 @@ async function rejectSpecialFiles(
|
||||
repo,
|
||||
['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'],
|
||||
home,
|
||||
SNAPSHOT_MANIFEST_LIMIT,
|
||||
),
|
||||
ignoredDirectories = new Set<string>();
|
||||
admission.time();
|
||||
@@ -611,10 +638,23 @@ export async function capture(
|
||||
manifest.baseCommit = (await git(repo, ['rev-parse', '--verify', `${base}^{commit}`], home)).trim();
|
||||
guard();
|
||||
}
|
||||
const sourceCapError = () => {
|
||||
let measured = 0,
|
||||
files = 0;
|
||||
for (const candidate of list) {
|
||||
if (noReadPath(candidate)) continue;
|
||||
try {
|
||||
measured += fs.lstatSync(join(repo, candidate)).size;
|
||||
files++;
|
||||
} catch {}
|
||||
}
|
||||
return new CsoError('MISSING_INPUT', sourceCapMessage(Math.max(measured, total), files));
|
||||
};
|
||||
for (const path of list) {
|
||||
guard();
|
||||
let size: number;
|
||||
try {
|
||||
fs.lstatSync(join(repo, path));
|
||||
size = fs.lstatSync(join(repo, path)).size;
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
absentPaths.add(path);
|
||||
@@ -640,11 +680,11 @@ export async function capture(
|
||||
guard();
|
||||
continue;
|
||||
}
|
||||
if (total + size > SOURCE_LIMIT) throw sourceCapError();
|
||||
const { data, mode } = readStable(repo, path, SOURCE_LIMIT);
|
||||
total += data.length;
|
||||
guard();
|
||||
if (total > SOURCE_LIMIT)
|
||||
throw new CsoError('MISSING_INPUT', 'Source exceeds the 64 MiB snapshot admission limit');
|
||||
if (total > SOURCE_LIMIT) throw sourceCapError();
|
||||
const entry: SnapshotEntry = {
|
||||
path,
|
||||
pathId: snapshotPathId(repo, path),
|
||||
@@ -749,7 +789,12 @@ export async function capture(
|
||||
canonical(entries.filter((e) => e.executionHash).map((e) => [e.path, e.executionHash, e.mode])),
|
||||
);
|
||||
if (manifest.baseCommit) {
|
||||
const tree = await git(repo, ['ls-tree', '-r', '-z', '--full-tree', manifest.baseCommit, '--'], home),
|
||||
const tree = await git(
|
||||
repo,
|
||||
['ls-tree', '-r', '-z', '--full-tree', manifest.baseCommit, '--'],
|
||||
home,
|
||||
SNAPSHOT_MANIFEST_LIMIT,
|
||||
),
|
||||
baseFiles = new Map<string, { hash: string; mode: string }>();
|
||||
guard();
|
||||
for (const row of tree.split('\0').filter(Boolean)) {
|
||||
@@ -858,15 +903,27 @@ export async function capture(
|
||||
guard();
|
||||
assertAbsent();
|
||||
admission.time();
|
||||
writeHelperJson(join(runDir, 'sensitive-evidence.json'), sensitiveEvidence);
|
||||
writeHelperJson(
|
||||
join(runDir, 'sensitive-evidence.json'),
|
||||
boundedList<Record<string, unknown>>(
|
||||
sensitiveEvidence.length,
|
||||
(index) => sensitiveEvidence[index],
|
||||
{ bytes: SENSITIVE_EVIDENCE_BUDGET },
|
||||
1,
|
||||
(omitted) => ({
|
||||
omitted,
|
||||
note: `${omitted} more files with sensitive-pattern findings are not listed, to keep this artifact within its 1 MiB bound`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// The manifest contains helper-computed identities and source pathnames but
|
||||
// never source payloads. Persist it exactly in private state: generic
|
||||
// content redaction would silently break the path/hash identity relation.
|
||||
const serialized = JSON.stringify(manifest, null, 2);
|
||||
if (Buffer.byteLength(serialized) + 1 > MAX_OUTPUT)
|
||||
const serialized = JSON.stringify(manifest);
|
||||
if (Buffer.byteLength(serialized) + 1 > SNAPSHOT_MANIFEST_LIMIT)
|
||||
throw new CsoError(
|
||||
'MISSING_INPUT',
|
||||
'Snapshot manifest exceeds the 1 MiB private-state admission limit',
|
||||
snapshotManifestCapMessage(entries.length, Buffer.byteLength(serialized) + 1),
|
||||
);
|
||||
atomicWriteSync(join(runDir, 'snapshot.json'), serialized + '\n', { mode: 0o600 });
|
||||
return manifest;
|
||||
|
||||
+35
-5
@@ -814,7 +814,7 @@ export function writeJsonExclusive(path: string, value: unknown): void {
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Private immutable artifact could not be written');
|
||||
}
|
||||
}
|
||||
function readPrivateJson(path: string): unknown {
|
||||
function readPrivateJson(path: string, maxBytes: number): unknown {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
const before = exactLstat(path);
|
||||
@@ -823,7 +823,7 @@ function readPrivateJson(path: string): unknown {
|
||||
before.isSymbolicLink() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size <= 0 ||
|
||||
before.size > MAX_STATE_FILE ||
|
||||
before.size > maxBytes ||
|
||||
(process.getuid && before.uid !== process.getuid()) ||
|
||||
(process.platform !== 'win32' && (before.mode & 0o077) !== 0)
|
||||
)
|
||||
@@ -857,16 +857,46 @@ function readPrivateJson(path: string): unknown {
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
export function readJson(path: string): any {
|
||||
/** maxBytes is a per-artifact cap; only the snapshot manifest passes a larger one. */
|
||||
export function readJson(path: string, maxBytes = MAX_STATE_FILE): any {
|
||||
try {
|
||||
secureDirectory(dirname(path));
|
||||
recoverAtomicNoReplaceJson(path, { label: 'Private immutable artifact', maxBytes: MAX_STATE_FILE });
|
||||
return readPrivateJson(path);
|
||||
recoverAtomicNoReplaceJson(path, { label: 'Private immutable artifact', maxBytes });
|
||||
return readPrivateJson(path, maxBytes);
|
||||
} catch (e) {
|
||||
if (e instanceof CsoError) throw e;
|
||||
throw new CsoError('MISSING_INPUT', 'Private state file is missing or invalid');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Keep the leading items of a per-entry list that fit `budget.bytes`, measured
|
||||
* as pretty-printed JSON nested `depth` levels deep, and replace the rest with
|
||||
* one summary item carrying the omitted count. A shared budget object bounds
|
||||
* several lists in one artifact together.
|
||||
*/
|
||||
export function boundedList<T>(
|
||||
total: number,
|
||||
at: (index: number) => T,
|
||||
budget: { bytes: number },
|
||||
depth: number,
|
||||
summary: (omitted: number) => T,
|
||||
): T[] {
|
||||
const indent = ' '.repeat(2 * depth),
|
||||
size = (item: T) =>
|
||||
Buffer.byteLength(indent + JSON.stringify(item, null, 2).replaceAll('\n', '\n' + indent) + ',\n'),
|
||||
reserve = size(summary(total)),
|
||||
kept: T[] = [];
|
||||
for (let index = 0; index < total; index++) {
|
||||
const item = at(index),
|
||||
bytes = size(item);
|
||||
if (index < total - 1 ? bytes + reserve > budget.bytes : bytes > budget.bytes) break;
|
||||
kept.push(item);
|
||||
budget.bytes -= bytes;
|
||||
}
|
||||
if (kept.length === total) return kept;
|
||||
budget.bytes -= reserve;
|
||||
return [...kept, summary(total - kept.length)];
|
||||
}
|
||||
export const PUBLIC_SOURCE_ROOT = '<REDACTED-internal.user_path>';
|
||||
/** A report is public evidence; the real root remains in the private snapshot. */
|
||||
export function publicReport(report: RunReportV3): RunReportV3 {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// /cso file-count ceiling (#3068, manifest half of #2993): snapshot.json has its own
|
||||
// 16 MiB cap and compact form, every per-entry list in a 1 MiB private artifact is
|
||||
// bounded with an omitted count, and both capacity errors say what to do next.
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { MAX_OUTPUT } from '../lib/cso/contracts';
|
||||
import {
|
||||
SNAPSHOT_MANIFEST_LIMIT,
|
||||
capture,
|
||||
readSnapshotManifest,
|
||||
snapshotManifestCapMessage,
|
||||
sourceCapMessage,
|
||||
} from '../lib/cso/snapshot';
|
||||
import { boundedList, newRun, readJson } from '../lib/cso/state';
|
||||
import { runProcess } from '../lib/cso/process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const launcher = path.join(ROOT, 'bin', process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher');
|
||||
const MIB = 1024 * 1024;
|
||||
const ISSUE = 'https://github.com/garrytan/gstack/issues/2993';
|
||||
const KEY = ['pass', 'word'].join('');
|
||||
let root = '';
|
||||
let state = '';
|
||||
const originalHome = process.env.GSTACK_HOME;
|
||||
|
||||
function git(repo: string, ...args: string[]): string {
|
||||
const result = spawnSync('/usr/bin/git', ['-C', repo, ...args], { encoding: 'utf8', env: { HOME: root, PATH: '/usr/bin:/bin' }, timeout: 60_000 });
|
||||
if (result.status) throw new Error(result.stderr);
|
||||
return result.stdout;
|
||||
}
|
||||
function cso(repo: string, args: string[]) {
|
||||
return spawnSync(launcher, args, { cwd: repo, encoding: 'utf8', env: { HOME: root, GSTACK_HOME: state, PATH: '/usr/bin:/bin' }, timeout: 120_000, maxBuffer: 64 * MIB });
|
||||
}
|
||||
function makeRepo(name: string, files: Record<string, string | Buffer>): string {
|
||||
const repo = path.join(root, name);
|
||||
fs.mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
git(repo, 'config', 'user.email', 'fixture@example.test');
|
||||
git(repo, 'config', 'user.name', 'Fixture');
|
||||
for (const [file, body] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, file), body);
|
||||
}
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'fixture');
|
||||
return repo;
|
||||
}
|
||||
function runDir(run: { repoId: string; runId: string }): string {
|
||||
return path.join(state, 'security', 'cso', run.repoId, run.runId);
|
||||
}
|
||||
function writeInput(name: string, value: unknown): string {
|
||||
const file = path.join(root, name);
|
||||
fs.writeFileSync(file, JSON.stringify(value));
|
||||
return file;
|
||||
}
|
||||
const application = { actors: ['tenant user'], assets: ['tenant records'], entrypoints: ['GET /users/:id'], tenantBoundaries: ['tenant id'], sensitiveOperations: ['record read'], invariants: ['tenant isolation'] };
|
||||
const finding = { title: 'Cross-tenant user read', rootCause: 'Tenant query omits caller tenant predicate', location: { path: 'src/users.ts', line: 1, symbol: 'tenantQuery' }, advisoryIds: [], severity: 'high', confidence: 'high', confidenceRationale: 'The caller-to-query trace and missing tenant predicate directly support the finding', evidence: 'supported', attackerControl: 'Authenticated caller chooses the record ID', impact: 'Another tenant record is returned', scenario: 'A tenant supplies a known record ID owned by another tenant and receives that record', trace: ['GET /users/:id', 'tenantQuery', 'findUnique by id'], references: ['src/users.ts:1', 'OWASP API1:2023'], recommendation: 'Bind the lookup predicate to the authenticated tenant identifier', challenge: { reviewer: 'independent-2', independent: true, mode: 'independent_agent', callers: 'Authenticated route forwards the ID', controls: 'Authentication does not bind tenant', counterevidence: 'Opaque IDs reduce guessing but do not authorize', conclusion: 'The authorization invariant is absent' } };
|
||||
function completeEvidence(report: any, findings: unknown[]) {
|
||||
return { application, findings, coverage: report.coverage.filter((item: any) => !['snapshot-inputs', 'history-inputs'].includes(item.domain)).map((item: any) => ({ ...item, status: 'assessed', method: 'fresh caller and boundary trace', gaps: [], evidence: ['current captured source'] })), gaps: [] };
|
||||
}
|
||||
const USERS = 'export const tenantQuery = (id:string) => db.user.findUnique({where:{id}})\n';
|
||||
// Long nested paths where most files are excluded or withheld and many carry
|
||||
// scanner hits: the shape that overflowed report.json and sensitive-evidence.json.
|
||||
function hostileFiles(count: number): Record<string, string | Buffer> {
|
||||
const files: Record<string, string | Buffer> = { 'src/users.ts': USERS };
|
||||
for (let i = 1; i < count; i++) {
|
||||
const dir = `packages/very-long-workspace-package-name-${i % 50}/src/components/deeply/nested/feature-area-${i % 17}`;
|
||||
switch (i % 5) {
|
||||
case 0:
|
||||
files[`${dir}/module_file_number_${i}.ts`] = Array.from({ length: 12 }, (_, k) => `${KEY} = "Sup3rS3cretValue${i}x${k}"\n`).join('');
|
||||
break;
|
||||
case 1:
|
||||
files[`node_modules/${dir}/dependency_file_number_${i}.js`] = `module.exports=${i}\n`;
|
||||
break;
|
||||
case 2:
|
||||
files[`.claude/${dir}/agent_config_file_number_${i}.md`] = `cfg ${i}\n`;
|
||||
break;
|
||||
case 3:
|
||||
files[`${dir}/binary_asset_number_${i}.bin`] = Buffer.from(`a\0b${i}`);
|
||||
break;
|
||||
default:
|
||||
files[`${dir}/certificate_number_${i}.pem`] = `not a key ${i}\n`;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-ceiling-'));
|
||||
state = path.join(root, 'state');
|
||||
process.env.GSTACK_HOME = state;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = originalHome;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('CSO snapshot manifest cap', () => {
|
||||
test('the snapshot cap is local to snapshot.json; shared limits stay 1 MiB', () => {
|
||||
expect(MAX_OUTPUT).toBe(MIB);
|
||||
expect(SNAPSHOT_MANIFEST_LIMIT).toBe(16 * MIB);
|
||||
const dir = fs.mkdtempSync(path.join(root, 'caps-'));
|
||||
fs.chmodSync(dir, 0o700);
|
||||
const big = JSON.stringify({ padding: 'x'.repeat(MIB + 16) });
|
||||
for (const name of ['report.json', 'snapshot.json']) fs.writeFileSync(path.join(dir, name), big, { mode: 0o600 });
|
||||
expect(() => readJson(path.join(dir, 'report.json'))).toThrow('Invalid private state file');
|
||||
expect(() => readJson(path.join(dir, 'snapshot.json'))).toThrow('Invalid private state file');
|
||||
expect(readSnapshotManifest(dir)).toEqual(JSON.parse(big));
|
||||
fs.writeFileSync(path.join(dir, 'snapshot.json'), JSON.stringify({ padding: 'x'.repeat(16 * MIB) }), { mode: 0o600 });
|
||||
expect(() => readSnapshotManifest(dir)).toThrow('Invalid private state file');
|
||||
});
|
||||
|
||||
test('every snapshot.json read in the helper goes through readSnapshotManifest', () => {
|
||||
const files = fs.readdirSync(path.join(ROOT, 'lib', 'cso')).filter((file) => file.endsWith('.ts'));
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(path.join(ROOT, 'lib', 'cso', file), 'utf8');
|
||||
expect(source).not.toMatch(/readJson\(\s*join\([^)]*'snapshot\.json'\)\s*\)/);
|
||||
}
|
||||
const cli = fs.readFileSync(path.join(ROOT, 'lib', 'cso', 'cli.ts'), 'utf8');
|
||||
expect(cli.match(/readSnapshotManifest\(/g)?.length).toBeGreaterThanOrEqual(13);
|
||||
const snapshot = fs.readFileSync(path.join(ROOT, 'lib', 'cso', 'snapshot.ts'), 'utf8');
|
||||
expect(snapshot).toContain("readJson(join(runDir, 'snapshot.json'), SNAPSHOT_MANIFEST_LIMIT)");
|
||||
expect(snapshot).toContain('> SNAPSHOT_MANIFEST_LIMIT');
|
||||
expect(snapshot).not.toContain('JSON.stringify(manifest, null, 2)');
|
||||
});
|
||||
|
||||
test('both capacity errors name the bound cap, measured value, what counts, next step and #2993', () => {
|
||||
const manifest = snapshotManifestCapMessage(61_234, 17_000_000);
|
||||
expect(manifest).toStartWith('Snapshot manifest cap exceeded:');
|
||||
for (const part of ['61234 source entries', '17000000 byte', 'cap is 16.0 MiB', 'One entry is recorded for every tracked', 'Next step:', ISSUE, 'No supported workaround'])
|
||||
expect(manifest).toContain(part);
|
||||
const source = sourceCapMessage(346_199_207, 7_561);
|
||||
expect(source).toStartWith('64 MiB source cap exceeded:');
|
||||
for (const part of ['7561 source files', '346199207 bytes', 'cap is 64.0 MiB', 'files over 1 MiB whose payloads are withheld', 'Next step:', ISSUE, 'No supported workaround'])
|
||||
expect(source).toContain(part);
|
||||
});
|
||||
|
||||
test('the 64 MiB source cap still fails closed, with the actionable message on stderr', () => {
|
||||
const repo = makeRepo('over-source-cap', { 'src/users.ts': USERS, 'assets/a.bin': Buffer.alloc(40 * MIB, 1), 'assets/b.bin': Buffer.alloc(25 * MIB, 1) });
|
||||
const result = cso(repo, ['start', '--repo', repo, '--offline']);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
const error = JSON.parse(result.stderr).error;
|
||||
expect(error.code).toBe('MISSING_INPUT');
|
||||
expect(error.message).toStartWith('64 MiB source cap exceeded: 3 source files hold about 65.0 MiB');
|
||||
expect(error.message).toContain(ISSUE);
|
||||
expect(error.message).toContain('No supported workaround');
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('CSO bounded per-entry lists', () => {
|
||||
test('keeps leading items, appends one omitted-count summary, and leaves short lists untouched', () => {
|
||||
const items = Array.from({ length: 1000 }, (_, i) => `entry-${i}`);
|
||||
expect(boundedList(3, (i) => items[i], { bytes: 4096 }, 2, (n) => `${n} omitted`)).toEqual(items.slice(0, 3));
|
||||
const budget = { bytes: 2048 },
|
||||
kept = boundedList(items.length, (i) => items[i], budget, 2, (n) => `${n} omitted`);
|
||||
expect(kept.length).toBeLessThan(items.length);
|
||||
expect(kept.at(-1)).toBe(`${items.length - kept.length + 1} omitted`);
|
||||
expect(kept.slice(0, -1)).toEqual(items.slice(0, kept.length - 1));
|
||||
expect(budget.bytes).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO above the old file-count ceiling', () => {
|
||||
test('a 2,500-file repository still captures and round-trips', async () => {
|
||||
const files: Record<string, string> = {};
|
||||
for (let i = 1; i <= 2500; i++) files[`src/pkg/sub/file_number_${i}.ts`] = `export const v${i} = ${i};\n`;
|
||||
const repo = makeRepo('plain-2500', files),
|
||||
run = newRun(repo),
|
||||
manifest = await capture(repo, run.dir);
|
||||
expect(manifest.entries).toHaveLength(2500);
|
||||
expect(readSnapshotManifest(run.dir)).toEqual(manifest);
|
||||
}, 120_000);
|
||||
|
||||
test('Git path listings over 1 MiB are admitted, with and without a comparison base', async () => {
|
||||
const files: Record<string, string> = {},
|
||||
dir = Array.from({ length: 6 }, (_, i) => `workspace-segment-with-a-long-descriptive-name-${i}`).join('/');
|
||||
for (let i = 1; i <= 5000; i++) files[`${dir}/file_number_${i}.ts`] = `export const v${i} = ${i};\n`;
|
||||
const repo = makeRepo('long-listing', files);
|
||||
expect(Buffer.byteLength(git(repo, 'ls-files', '-z'))).toBeGreaterThan(MIB);
|
||||
const plain = await capture(repo, newRun(repo).dir);
|
||||
expect(plain.entries).toHaveLength(5000);
|
||||
const based = await capture(repo, newRun(repo).dir, 'HEAD');
|
||||
expect(based.entries).toHaveLength(5000);
|
||||
expect(based.changedPaths).toEqual([]);
|
||||
}, 180_000);
|
||||
|
||||
test('only raw callers can opt in to output above MAX_OUTPUT', async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(root, 'raw-')),
|
||||
script = ['-c', 'head -c 1200000 /dev/zero | tr "\\0" a'],
|
||||
env = { PATH: '/usr/bin:/bin' };
|
||||
expect((await runProcess('/bin/sh', script, { cwd, env, maxBytes: 2 * MIB })).truncated).toBe(true);
|
||||
expect((await runProcess('/bin/sh', script, { cwd, env, raw: true })).truncated).toBe(true);
|
||||
const raw = await runProcess('/bin/sh', script, { cwd, env, raw: true, maxBytes: 2 * MIB });
|
||||
expect(raw.truncated).toBe(false);
|
||||
expect(raw.stdout).toHaveLength(1_200_000);
|
||||
});
|
||||
|
||||
test('5,000 long nested paths, mostly excluded or withheld with many scanner hits, run start to final report and recheck', () => {
|
||||
const repo = makeRepo('hostile-5000', hostileFiles(5000)),
|
||||
started = cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']);
|
||||
expect(started.stderr).toBe('');
|
||||
expect(started.status).toBe(0);
|
||||
expect(Buffer.byteLength(started.stdout)).toBeLessThan(MIB);
|
||||
const run = JSON.parse(started.stdout),
|
||||
dir = runDir(run);
|
||||
|
||||
const rawManifest = fs.readFileSync(path.join(dir, 'snapshot.json'), 'utf8'),
|
||||
manifest = readSnapshotManifest(dir);
|
||||
expect(Buffer.byteLength(rawManifest)).toBeGreaterThan(MIB);
|
||||
expect(rawManifest).toBe(JSON.stringify(manifest) + '\n');
|
||||
expect(manifest.entries).toHaveLength(5000);
|
||||
|
||||
const evidence = readJson(path.join(dir, 'sensitive-evidence.json'));
|
||||
expect(fs.statSync(path.join(dir, 'sensitive-evidence.json')).size).toBeLessThan(MIB);
|
||||
expect(evidence.at(-1).note).toContain(`${evidence.at(-1).omitted} more files with sensitive-pattern findings are not listed`);
|
||||
expect(evidence.length - 1 + evidence.at(-1).omitted).toBe(999);
|
||||
|
||||
const initial = readJson(path.join(dir, 'report.json')),
|
||||
snapshot = initial.coverage.find((item: any) => item.domain === 'snapshot-inputs'),
|
||||
transformations = initial.source.transformations;
|
||||
expect(fs.statSync(path.join(dir, 'report.json')).size).toBeLessThan(400 * 1024);
|
||||
expect(snapshot.evidence).toEqual(['1000 sanitized execution inputs captured; 3000 explicit non-executable exclusions; 1000 unread in-scope inputs; 0 tracked deletions']);
|
||||
expect(snapshot.status).toBe('partial');
|
||||
const omittedCount = (line: string) => Number(line.match(/^(\d+) more/)?.[1]);
|
||||
expect(snapshot.gaps.length - 1 + omittedCount(snapshot.gaps.at(-1))).toBe(1000);
|
||||
expect(snapshot.gaps.at(-1)).toContain('more unread or deleted in-scope inputs are not listed to keep report.json within its 1 MiB bound');
|
||||
expect(snapshot.exclusions.length - 1 + omittedCount(snapshot.exclusions.at(-1))).toBe(3000);
|
||||
expect(transformations.at(-1).path).toBe('[not listed]');
|
||||
expect(transformations.length - 1 + omittedCount(transformations.at(-1).handling)).toBe(4999);
|
||||
expect(transformations.length).toBeGreaterThan(100);
|
||||
expect(initial.events[0].message).toBe('Captured 5000 source entries; 4999 transformations disclosed');
|
||||
|
||||
const inspected = cso(repo, ['inspect', run.runId]);
|
||||
expect(inspected.status).toBe(0);
|
||||
expect(JSON.parse(inspected.stdout).manifest.entries).toHaveLength(5000);
|
||||
|
||||
expect(cso(repo, ['submit', run.runId, writeInput('hostile-evidence.json', completeEvidence(initial, [finding]))]).status).toBe(0);
|
||||
const finished = cso(repo, ['finish', run.runId]);
|
||||
expect(finished.stderr).toBe('');
|
||||
expect(finished.status).toBe(0);
|
||||
const final = readJson(path.join(dir, 'report.json'));
|
||||
expect(final.status).toBe('finished');
|
||||
expect(final.findings).toHaveLength(1);
|
||||
expect(fs.statSync(path.join(dir, 'report.json')).size).toBeLessThan(MIB);
|
||||
|
||||
const rechecked = cso(repo, ['recheck', final.findings[0].id, '--repo', repo]);
|
||||
expect(rechecked.stderr).toBe('');
|
||||
expect(rechecked.status).toBe(0);
|
||||
const child = JSON.parse(rechecked.stdout);
|
||||
expect(child.parent).toEqual({ runId: run.runId, findingId: final.findings[0].id, kind: 'recheck' });
|
||||
expect(readSnapshotManifest(runDir({ ...child, repoId: run.repoId })).entries).toHaveLength(5000);
|
||||
}, 300_000);
|
||||
|
||||
test('a retained pretty-printed snapshot.json from an older helper still loads in recheck', () => {
|
||||
const repo = makeRepo('legacy-pretty', { 'src/users.ts': USERS, 'src/other.ts': 'export const unrelated = true\n' }),
|
||||
run = JSON.parse(cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']).stdout),
|
||||
dir = runDir(run),
|
||||
initial = readJson(path.join(dir, 'report.json'));
|
||||
expect(cso(repo, ['submit', run.runId, writeInput('legacy-evidence.json', completeEvidence(initial, [finding]))]).status).toBe(0);
|
||||
expect(cso(repo, ['finish', run.runId]).status).toBe(0);
|
||||
const file = path.join(dir, 'snapshot.json');
|
||||
fs.writeFileSync(file, JSON.stringify(JSON.parse(fs.readFileSync(file, 'utf8')), null, 2) + '\n');
|
||||
expect(fs.readFileSync(file, 'utf8')).toContain('\n "entries": [');
|
||||
expect(cso(repo, ['inspect', run.runId]).status).toBe(0);
|
||||
const findingId = readJson(path.join(dir, 'report.json')).findings[0].id,
|
||||
rechecked = cso(repo, ['recheck', findingId, '--run', run.runId, '--repo', repo]);
|
||||
expect(rechecked.stderr).toBe('');
|
||||
expect(rechecked.status).toBe(0);
|
||||
expect(JSON.parse(rechecked.stdout).parent.runId).toBe(run.runId);
|
||||
}, 120_000);
|
||||
});
|
||||
+26
-11
@@ -6,8 +6,8 @@
|
||||
//
|
||||
// Threat model: this surface is reachable from the local Mac via the CoreDevice
|
||||
// IPv6 tunnel. It MUST refuse any caller without a current bearer token. The
|
||||
// boot token is rotated within ~5 seconds of daemon spawn so anything scraping
|
||||
// os_log past that window sees a dead credential.
|
||||
// boot token is rotated within ~5 seconds of daemon spawn, and rotation deletes
|
||||
// its file, so a copy taken past that window is a dead credential.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
@@ -34,9 +34,11 @@ public final class StateServer {
|
||||
private var ipv6Listener: NWListener?
|
||||
private var ipv4Listener: NWListener?
|
||||
|
||||
// Auth state. The boot token is what we wrote to os_log on first launch.
|
||||
// It exists ONLY long enough for the daemon to call /auth/rotate.
|
||||
// Auth state. The boot token is written to a 0600 file in tmp/ on launch
|
||||
// (never to os_log). It exists ONLY long enough for the daemon to call
|
||||
// /auth/rotate. A failed write is kept here and reported on /healthz.
|
||||
private var bootToken: String
|
||||
private var bootTokenWriteError: String?
|
||||
private var rotatedToken: String? // set after first /auth/rotate
|
||||
private var bootTokenValid: Bool = true
|
||||
|
||||
@@ -92,17 +94,28 @@ public final class StateServer {
|
||||
}
|
||||
|
||||
public func start() {
|
||||
// 1. Persist boot token to a 0600 file (best-effort fallback for the
|
||||
// daemon if os_log scrape misses).
|
||||
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
|
||||
// 1. Persist the boot token to a 0600 file. This is the ONLY way the
|
||||
// daemon can authenticate (devicectl copies it out of tmp/), so a
|
||||
// failed write is an error, not a silent skip.
|
||||
let tokenFile = bootTokenPath
|
||||
do {
|
||||
try bootToken.write(toFile: tokenFile, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenFile)
|
||||
} catch {
|
||||
bootTokenWriteError = error.localizedDescription
|
||||
logger.error("Boot token file write failed at \(tokenFile, privacy: .public): \(error.localizedDescription, privacy: .public). No daemon can authenticate until the app relaunches with a writable tmp/.")
|
||||
}
|
||||
|
||||
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
|
||||
// token from the 0600 file above (copyFileFromAppContainer); the
|
||||
// os_log line that used to carry it had no consumer and handed a
|
||||
// live credential to anything reading the unified log during the
|
||||
// launch window. Port/build stay for diagnostics.
|
||||
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
if let failure = bootTokenWriteError {
|
||||
logger.error("gstack-ios-qa-bootstrap NOT READY port=\(self.port, privacy: .public): boot token file unwritable (\(failure, privacy: .public))")
|
||||
} else {
|
||||
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
|
||||
}
|
||||
|
||||
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
|
||||
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
|
||||
@@ -296,12 +309,14 @@ public final class StateServer {
|
||||
|
||||
// 1. Public on loopback: /healthz.
|
||||
if request.method == "GET" && path == "/healthz" {
|
||||
send(connection: connection, status: 200, body: [
|
||||
var health: JSONDict = [
|
||||
"version": "1.0.0",
|
||||
"build": appBuildId,
|
||||
"accessor_hash": accessorHash,
|
||||
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||
])
|
||||
]
|
||||
if let failure = bootTokenWriteError { health["boot_token_error"] = failure }
|
||||
send(connection: connection, status: 200, body: health)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
* solely on the per-connection peer check. IPv6 keeps the wildcard bind for
|
||||
* CoreDevice ULA peers by design.
|
||||
*
|
||||
* #1837: the boot-token file is the daemon's only way in, so writing it must
|
||||
* not be a silent `try?`. A failure is logged and reported on /healthz (the
|
||||
* daemon then names the device-side cause instead of relaunching the app).
|
||||
*
|
||||
* Pinned on BOTH the generated template and the fixture app copy so neither
|
||||
* can drift back independently.
|
||||
*/
|
||||
@@ -39,6 +43,20 @@ describe.each(COPIES)("StateServer hardening — %s", (rel) => {
|
||||
expect(src).not.toContain("gstack-ios-qa-bootstrap token=");
|
||||
});
|
||||
|
||||
test("boot-token write failures are loud, not a silent try? (#1837)", () => {
|
||||
expect(src).not.toMatch(/try\?\s*bootToken\.write/);
|
||||
expect(src).not.toMatch(/try\?\s*FileManager\.default\.setAttributes\(\[\.posixPermissions: 0o600\]/);
|
||||
const start = src.slice(src.indexOf("public func start()"), src.indexOf("public func register("));
|
||||
expect(start).toMatch(/do \{\s*try bootToken\.write\(toFile: tokenFile/);
|
||||
expect(start).toMatch(/\} catch \{\s*bootTokenWriteError = error\.localizedDescription\s*logger\.error\(/);
|
||||
expect(start).toContain("gstack-ios-qa-bootstrap NOT READY");
|
||||
expect(src).toContain('health["boot_token_error"] = failure');
|
||||
});
|
||||
|
||||
test("comments no longer claim os_log carries the boot token", () => {
|
||||
expect(src).not.toMatch(/os_log scrape|wrote to os_log|scraping\s*\n?\/\/\s*os_log/);
|
||||
});
|
||||
|
||||
test("IPv4 listener binds loopback at the socket level (B5)", () => {
|
||||
expect(src).toContain("requiredLocalEndpoint");
|
||||
expect(src).toContain('NWEndpoint.Host("127.0.0.1")');
|
||||
|
||||
Reference in new issue
Block a user