Skip to content

feat(crashes): add opdev crashes subcommand for iOS crash diagnosis - #22

Merged
tonyferrell merged 4 commits into
mainfrom
feat/crashes-subcommand
May 25, 2026
Merged

feat(crashes): add opdev crashes subcommand for iOS crash diagnosis#22
tonyferrell merged 4 commits into
mainfrom
feat/crashes-subcommand

Conversation

@tonyferrell

Copy link
Copy Markdown
Contributor

Summary

Adds opdev crashes diagnose — pulls symbolicated iOS crash reports from App Store Connect so any Claude session (or a human at a terminal) can investigate user-reported crashes with one command.

This is the Go-native landing place for what started as a Python script in ownpulse#247. That PR is now docs-only and points here.

Usage

export OWNPULSE_INFRA_PATH=~/src/ownpulse/ownpulse-infra
opdev crashes diagnose --since 24h

Subcommands:

  • opdev crashes diagnose — primary surface. Resolves credentials, mints JWT, lists recent builds, fetches crash feedback per build, applies signal/build filters, renders table or --json.
  • opdev crashes list-builds — debug helper. Just enumerates builds visible to this key.
  • opdev crashes crash-feedback --build <id> — debug helper. Raw output for one build.

Credentials resolve field-by-field in this order: explicit flag → env var → SOPS-loaded value. SOPS file lives at $OWNPULSE_INFRA_PATH/secrets/ios/appstore-connect.sops.yaml and supports both asc_api_key_b64 (production schema, base64-encoded .p8) and key_pem (literal PEM, for backward compatibility / tests).

What's in the package

src/crashes/crashes.go:

  • ES256 JWT minting using stdlib only (crypto/ecdsa, crypto/x509, encoding/pem, encoding/base64). Raw R||S signature with 32-byte zero-padding, NOT DER. Hardcoded alg=ES256alg=none is unreachable. 20-minute expiry.
  • assertASCHost host pinning: requires scheme=https and host exactly api.appstoreconnect.apple.com (case-insensitive). Called before every request, including the first.
  • DefaultHTTPGetter disables redirect following (http.ErrUseLastResponse) — Bearer token cannot leak via 302.
  • ASCError.Body runs through redactBearer before storage, so any echoed Authorization header is scrubbed.
  • LoadCredentials shells out to sops -d and parses YAML in-process. PEM bytes never written to disk.

src/crashes/crashes_test.go — 21 unit tests:

  • JWT structure (header/payload/exp), signature verification with generated EC P-256 key, RSA key rejection.
  • ListBuilds since filter, pagination via links.next, off-host rejection, http-scheme rejection.
  • TestAssertASCHost_Vectors — 14 sub-cases (suffix, prefix, userinfo, mixed case, IP literal, file scheme, empty, garbage).
  • TestResolveCredentials_FieldByFieldPrecedence — 6 sub-cases covering flag/env/SOPS merging.
  • TestDefaultHTTPGetter_DoesNotFollowRedirects — strong negative check.
  • TestRedactBearer, TestCrashFeedback_Swallows404, TestCrashFeedback_PropagatesNon404, TestDiagnose_SignalFilter.
  • TestLoadCredentials_SOPS_Base64Encoded and TestLoadCredentials_SOPS_PrefersBase64OverPEM — covers production schema.

src/main.gocrashesCmd() registers the three subcommands. --key-pem is deliberately NOT a flag (would leak PEM via ps / shell history).

Reviews

  • code-review (post-fixes): no blockers. Several should-fixes from a prior pass were addressed: LoadKey now surfaces both PKCS8 and SEC1 parse errors, %w error wrapping in three places, trailing-Z date parsing fixed.
  • security-review (post-fixes): no blockers. Prior High finding (--key-pem CLI flag exposing PEM via ps) was the trigger for removing the flag entirely. Prior Medium findings (redirect following, host-pin test coverage, all-or-nothing credential precedence, Bearer-token leakage) all resolved.

Dependencies

  • Added: gopkg.in/yaml.v3 (direct).
  • Crypto/JWT/HTTP: stdlib only. No third-party JWT or crypto libraries.

Verification

go build ./...      # clean
go vet ./...        # clean
go test ./src/crashes/... -v   # 21 tests, all pass
make build
./opdev crashes diagnose --help
./opdev crashes diagnose --since 24h
# → exit 1, stderr: "Error: credentials missing — set OWNPULSE_INFRA_PATH or ASC_KEY_ID/ASC_ISSUER_ID/ASC_APP_ID/ASC_KEY_PEM"

End-to-end verification (requires the SOPS file in ownpulse-infra to have app_id populated — separate one-line infra PR coming):

export OWNPULSE_INFRA_PATH=~/src/ownpulse/ownpulse-infra
opdev crashes diagnose --since 30d
# Should surface historical HealthKit NSException crashes submitted before ownpulse#246 shipped.

Out of scope

  • Phase 2 of the broader crash-tooling plan (own-backend /admin/telemetry/crashes endpoint, first-launch consent prompt, offline retry queue) — separate PRs in the main ownpulse repo.
  • TestFlight upload automation — already wired in ownpulse-infra/.github/workflows/ios-build.yml (manual dispatch). Triggering on tag push + verifying dSYM upload is a small follow-up.
  • dSYM download for offline symbolication — download_dsyms stub returns NotImplementedError; can fill in if Apple-side reports come back partially symbolicated.

🤖 Generated with Claude Code

tonyferrell and others added 3 commits May 25, 2026 10:33
Ports ops/asc_client.py from the ownpulse repo into native Go so
crash triage lives in opdev itself. Three subcommands:
  - `opdev crashes diagnose` — end-to-end report
  - `opdev crashes list-builds` — raw build list as JSON
  - `opdev crashes crash-feedback` — per-build diagnostics as JSON

Implementation notes:
  - ES256 JWT minted with stdlib only (raw R||S, no third-party JWT lib)
  - HTTPGetter is injected so tests never hit Apple
  - links.next pagination asserts scheme=https and host=api.appstoreconnect.apple.com
  - SOPS is invoked via `sops -d <path>`; PEM never written to disk
  - 12 unit tests mirror the Python suite

Adds gopkg.in/yaml.v3 as a direct dep.
- Remove --key-pem flag (PEM via env or SOPS only; prior flag exposed
  contents to ps/shell history).
- Fix LoadKey to surface both PKCS8 and SEC1 parse errors.
- Field-by-field credential precedence with new test coverage.
- Disable HTTP redirect following; add host-pin tests for suffix,
  userinfo, mixed-case, and IP-literal vectors.
- Redact Bearer tokens from ASCError.Body.
- Tests for CrashFeedback 404-swallow and Diagnose signal filter.
- %w error wrapping in three places; trailing-Z date parse fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The decrypted SOPS file uses asc_api_key_b64 (base64-encoded .p8
contents), not the key_pem field opdev was previously looking for.
LoadCredentials now reads asc_api_key_b64 first, base64-decodes into
PEM bytes, and falls back to key_pem if present. Tests cover both
branches and the precedence ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tonyferrell
tonyferrell marked this pull request as ready for review May 25, 2026 18:15
opdev already knows where ownpulse-infra lives via workspace.toml.
Stop requiring OWNPULSE_INFRA_PATH for the default case.

Resolution order is now:
  1. --sops-path flag (explicit)
  2. OWNPULSE_INFRA_PATH env var (kept for non-workspace use)
  3. Workspace config: ownpulse-infra repo + standard secrets path
  4. ASC_* env vars (one-off override)

The crashes package stays workspace-config-agnostic; the lookup
lives in main.go alongside the existing loadConfig() helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tonyferrell
tonyferrell merged commit a9f4be7 into main May 25, 2026
1 check passed
@tonyferrell
tonyferrell deleted the feat/crashes-subcommand branch May 25, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant