From ec58d9e561570be08986cb4469ab7ec052b4e658 Mon Sep 17 00:00:00 2001 From: "Autonomy AI, LLC" Date: Fri, 29 May 2026 21:37:56 -0400 Subject: [PATCH] fix(ios-qa): reuse rotated bearer across bootstraps so a real device drives end-to-end The in-app StateServer boot token is single-use: the first POST /auth/rotate invalidates it AND deletes the on-disk token file. But the daemon re-ran the full bootstrap (copy boot token + rotate) on every 30s tunnel-cache refresh, daemon restart, and new /ios-qa session, so the 2nd+ bootstrap hit boot_token_unavailable (CoreDeviceError 7000, file gone) and a real device was drivable exactly once per app launch (the simulator was unaffected; it never uses the devicectl file-copy path). Implement the warm-start that SKILL.md Phase 0 documents but the daemon never had: persist the rotated bearer the daemon already holds to ~/.gstack/ios-qa-session.json (0600) and reuse it after a cheap authed probe; only fall back to a full boot-token bootstrap when there is no cache or the cached token is rejected (probe 401 = app was relaunched). Zero device-side change: StateServer stays loopback-only, every endpoint still requires the rotated bearer, and the boot token stays single-use. Also close the cold-start gap: forward GSTACK_IOS_LAUNCH_ENV to `devicectl ... --environment-variables` so the daemon can launch apps whose debug bridge is env-gated (e.g. BuckHound's BH_ENABLE_IOS_QA_BRIDGE=1) instead of launching them without the bridge. - new ios-qa/daemon/src/session-cache.ts (acquireTunnel + cache I/O) - index.ts realTunnelProvider uses acquireTunnel - devicectl.launchApp forwards env; bootstrapTunnel threads launchEnv - 16 new tests (session-cache, launch-env); full daemon suite 107 pass Verified end-to-end on a physical iPhone 16 Pro: a fresh daemon process drove /elements + /screenshot + /tap purely by reusing the cached bearer while the boot token was confirmed consumed (CoreDeviceError 7000). Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/gstack-ios-qa-daemon | 6 + ios-qa/daemon/src/devicectl.ts | 11 +- ios-qa/daemon/src/index.ts | 33 ++- ios-qa/daemon/src/session-cache.ts | 176 ++++++++++++++++ ios-qa/daemon/src/tunnel-bootstrap.ts | 7 +- ios-qa/daemon/test/launch-env.test.ts | 95 +++++++++ ios-qa/daemon/test/session-cache.test.ts | 246 +++++++++++++++++++++++ 7 files changed, 564 insertions(+), 10 deletions(-) create mode 100644 ios-qa/daemon/src/session-cache.ts create mode 100644 ios-qa/daemon/test/launch-env.test.ts create mode 100644 ios-qa/daemon/test/session-cache.test.ts diff --git a/bin/gstack-ios-qa-daemon b/bin/gstack-ios-qa-daemon index b0ca2c6af..40dc78c12 100755 --- a/bin/gstack-ios-qa-daemon +++ b/bin/gstack-ios-qa-daemon @@ -13,6 +13,12 @@ # the first paired connected device is used) # GSTACK_IOS_TARGET_BUNDLE_ID — bundle ID of the iOS app hosting StateServer # (default com.gstack.iosqa.fixture) +# GSTACK_IOS_LAUNCH_ENV — JSON dict of env vars set when the daemon +# cold-launches the app, forwarded to +# `devicectl ... --environment-variables`. +# Required for apps that gate their debug +# bridge behind a flag, e.g. BuckHound: +# '{"BH_ENABLE_IOS_QA_BRIDGE":"1"}' # # Readiness protocol: prints `READY: port= pid=` to stdout once both # listeners are bound. Spawners read stdin with a ~5s timeout to confirm. diff --git a/ios-qa/daemon/src/devicectl.ts b/ios-qa/daemon/src/devicectl.ts index ee1696eb9..31d5763ab 100644 --- a/ios-qa/daemon/src/devicectl.ts +++ b/ios-qa/daemon/src/devicectl.ts @@ -270,8 +270,17 @@ export function launchApp( udid: string, bundleId: string, spawn: SpawnImpl = defaultSpawn, + env?: Record, ): { ok: boolean; error?: string } { - const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]); + const args = ['devicectl', 'device', 'process', 'launch', '--device', udid]; + // Forward launch-time env vars (e.g. an app's debug-bridge enable flag) so a + // cold start actually boots the in-app StateServer. Bundle id stays the + // trailing positional arg, after every flag. + if (env && Object.keys(env).length > 0) { + args.push('--environment-variables', JSON.stringify(env)); + } + args.push(bundleId); + const r = spawn('xcrun', args); if (r.status === 0) return { ok: true }; const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? ''); if (err.includes('was not, or could not be, unlocked')) { diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts index abfe38be3..cfe172589 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -20,7 +20,7 @@ import { SessionTokenStore } from './session-tokens'; import { mintForCaller } from './auth-mint'; import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy'; import { writeAudit, writeAttempt, sanitizeReplacer } from './audit'; -import { bootstrapTunnel } from './tunnel-bootstrap'; +import { acquireTunnel } from './session-cache'; import { startTunnelKeepalive } from './devicectl'; import type { Capability } from './types'; @@ -399,6 +399,18 @@ if (import.meta.main) { const tailnet = process.argv.includes('--tailnet'); const targetUDID = process.env.GSTACK_IOS_TARGET_UDID; const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture'; + // Env vars to set when the daemon cold-launches the app. Apps that gate their + // debug bridge behind a flag (BuckHound: BH_ENABLE_IOS_QA_BRIDGE=1) MUST set + // this, or a cold start launches the app without the bridge and the + // StateServer never binds. Malformed JSON is ignored (warn + no launch env). + let launchEnv: Record | undefined; + if (process.env.GSTACK_IOS_LAUNCH_ENV) { + try { + launchEnv = JSON.parse(process.env.GSTACK_IOS_LAUNCH_ENV) as Record; + } catch { + process.stderr.write('GSTACK_IOS_LAUNCH_ENV is not valid JSON; ignoring\n'); + } + } // Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with // any connected paired device) is set, bootstrap a real CoreDevice tunnel. @@ -410,17 +422,22 @@ if (import.meta.main) { // without a poke every few seconds the IPv6 becomes unroutable. let keepalive: { stop: () => void } | null = null; const realTunnelProvider = async () => { - const result = await bootstrapTunnel({ + // acquireTunnel reuses a cached rotated bearer (warm-start) when the device + // still honors it, and only falls back to a full single-use boot-token + // rotate on a genuinely fresh app launch. This is what lets a real device + // be driven across tunnel-cache refreshes, daemon restarts, and repeat + // /ios-qa sessions instead of exactly once per app launch. + const tunnel = await acquireTunnel({ udid: targetUDID, bundleId, + port: 9999, // in-app StateServer port (not the daemon's loopback port) + launchEnv, + logImpl: (m) => process.stderr.write(m + '\n'), }); - if (!result.ok) { - process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`); - return null; - } + if (!tunnel) return null; if (keepalive) keepalive.stop(); - keepalive = startTunnelKeepalive(result.tunnel.udid); - return result.tunnel; + keepalive = startTunnelKeepalive(tunnel.udid); + return tunnel; }; const shutdown = () => { diff --git a/ios-qa/daemon/src/session-cache.ts b/ios-qa/daemon/src/session-cache.ts new file mode 100644 index 000000000..bc82693aa --- /dev/null +++ b/ios-qa/daemon/src/session-cache.ts @@ -0,0 +1,176 @@ +// Rotated-token session cache + reuse. This is the Mac-side half of the +// "bootstrap once per app launch" contract that SKILL.md Phase 0 (warm-start) +// describes but the daemon never implemented. +// +// WHY this exists: the in-app StateServer boot token is single-use. The first +// POST /auth/rotate sets bootTokenValid=false AND deletes the on-disk token +// file. The daemon, however, re-runs the full bootstrap (copy boot token + +// rotate) on every tunnel-cache refresh, daemon restart, and new /ios-qa +// session. The second bootstrap then finds the token file gone -> +// `boot_token_unavailable`, so a real device can be driven exactly once per +// app launch. Persisting the rotated bearer the daemon already holds, and +// reusing it after a cheap authenticated probe, makes re-bootstrap unnecessary. +// +// SECURITY: this changes nothing on the device. The StateServer stays +// loopback-only, every endpoint still requires the rotated bearer, and the boot +// token stays single-use. The only new artifact is a Mac-side 0600 cache file +// holding the rotated bearer (the same value SKILL.md Phase 0 already specifies +// the session cache holds), valid only while the app stays launched — a probe +// that returns 401 means the app was relaunched, so we drop the stale token and +// re-bootstrap from a freshly written boot token. + +import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, join } from 'path'; +import { bootstrapTunnel, type BootstrapOptions } from './tunnel-bootstrap'; +import { getDeviceTunnelIPv6FromDevicectl, type SpawnImpl, type ResolveImpl } from './devicectl'; +import type { DeviceTunnel } from './proxy'; + +export interface SessionCache { + udid: string; + bundleId: string; + port: number; + rotatedToken: string; + ipv6: string; + createdAt: number; +} + +export function defaultSessionCachePath(): string { + return process.env.GSTACK_IOS_SESSION_CACHE + ?? join(homedir(), '.gstack', 'ios-qa-session.json'); +} + +/** Read the session cache. Returns null on missing/unreadable/corrupt file. */ +export function readSessionCache(path: string = defaultSessionCachePath()): SessionCache | null { + try { + const obj = JSON.parse(readFileSync(path, 'utf-8')) as Partial; + if ( + obj && typeof obj.udid === 'string' && typeof obj.bundleId === 'string' + && typeof obj.port === 'number' && typeof obj.rotatedToken === 'string' + && typeof obj.ipv6 === 'string' && typeof obj.createdAt === 'number' + ) { + return obj as SessionCache; + } + return null; + } catch { + return null; + } +} + +/** Persist the session cache with owner-only (0600) perms. */ +export function writeSessionCache(cache: SessionCache, path: string = defaultSessionCachePath()): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(cache), { mode: 0o600 }); + // writeFileSync's mode only applies on create; force 0600 on overwrite too. + chmodSync(path, 0o600); +} + +/** Remove the session cache (best-effort). */ +export function clearSessionCache(path: string = defaultSessionCachePath()): void { + try { rmSync(path, { force: true }); } catch { /* ignore */ } +} + +export interface AcquireTunnelOptions { + udid?: string; + bundleId: string; + port: number; + /** Env vars to set if a cold-start bootstrap has to launch the app. */ + launchEnv?: Record; + // Injection seams (real defaults wire the production impls). + cachePath?: string; + readCacheImpl?: (path?: string) => SessionCache | null; + writeCacheImpl?: (cache: SessionCache, path?: string) => void; + clearCacheImpl?: (path?: string) => void; + resolveIPv6Impl?: (udid: string) => string | null | Promise; + probeImpl?: (ipv6: string, port: number, token: string) => Promise; + bootstrapImpl?: (opts: BootstrapOptions) => Promise; + /** Diagnostic sink (defaults to no-op; the CLI wires it to stderr). */ + logImpl?: (msg: string) => void; + // Pass-throughs to the underlying bootstrap. + spawnImpl?: SpawnImpl; + resolveImpl?: ResolveImpl; + fetchImpl?: typeof fetch; +} + +/** + * Acquire a usable DeviceTunnel, reusing a cached rotated bearer when the + * device still honors it and only falling back to a full boot-token bootstrap + * when there is no usable cache or the cached bearer is rejected (app + * relaunched). Returns null on a transient device-unreachable condition (cache + * preserved) or a failed bootstrap. + */ +export async function acquireTunnel(opts: AcquireTunnelOptions): Promise { + const cachePath = opts.cachePath; + const readCache = opts.readCacheImpl ?? readSessionCache; + const writeCache = opts.writeCacheImpl ?? writeSessionCache; + const clearCache = opts.clearCacheImpl ?? clearSessionCache; + const resolveIPv6 = opts.resolveIPv6Impl ?? ((udid: string) => getDeviceTunnelIPv6FromDevicectl(udid, opts.spawnImpl)); + const probe = opts.probeImpl ?? defaultProbe(opts.fetchImpl); + const bootstrap = opts.bootstrapImpl ?? bootstrapTunnel; + const log = opts.logImpl ?? (() => {}); + + const cache = readCache(cachePath); + const cacheUsable = !!cache + && cache.bundleId === opts.bundleId + && cache.port === opts.port + && (!opts.udid || cache.udid === opts.udid); + + if (cache && cacheUsable) { + const ipv6 = await resolveIPv6(cache.udid); + if (!ipv6) { log('device unresolvable; keeping cached session token, will retry'); return null; } + const status = await probe(ipv6, cache.port, cache.rotatedToken); + if (status === 200) { + if (ipv6 !== cache.ipv6) writeCache({ ...cache, ipv6 }, cachePath); + log(`reusing cached session token for ${cache.udid} (no app relaunch needed)`); + return { udid: cache.udid, ipv6Addr: ipv6, port: cache.port, bootTokenRotated: cache.rotatedToken }; + } + if (status === 401 || status === 403) { + log(`cached session token rejected (HTTP ${status}); app was relaunched — re-bootstrapping`); + clearCache(cachePath); // app relaunched -> stale rotated token; re-bootstrap below + } else { + log(`device unreachable during token probe (HTTP ${status}); keeping cached token, will retry`); + return null; // 0 (connection error) / 5xx — transient; do not discard a good token + } + } + + const result = await bootstrap({ + udid: opts.udid, + bundleId: opts.bundleId, + port: opts.port, + launchEnv: opts.launchEnv, + spawnImpl: opts.spawnImpl, + resolveImpl: opts.resolveImpl, + fetchImpl: opts.fetchImpl, + }); + if (!result.ok) { + log(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}`); + return null; + } + log(`bootstrapped fresh session token for ${result.tunnel.udid}`); + + writeCache({ + udid: result.tunnel.udid, + bundleId: opts.bundleId, + port: opts.port, + rotatedToken: result.tunnel.bootTokenRotated, + ipv6: result.tunnel.ipv6Addr, + createdAt: Date.now(), + }, cachePath); + return result.tunnel; +} + +function defaultProbe(fetchFn: typeof fetch = fetch) { + return async (ipv6: string, port: number, token: string): Promise => { + const isIPv6 = (ipv6.match(/:/g)?.length ?? 0) >= 2; + const host = isIPv6 ? `[${ipv6}]` : ipv6; + try { + const r = await fetchFn(`http://${host}:${port}/state/snapshot`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(4_000), + }); + return r.status; + } catch { + return 0; // connection refused / timeout / tunnel down + } + }; +} diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts index aa6636938..11f486aa3 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -34,6 +34,11 @@ export interface BootstrapOptions { port?: number; /** Token-path inside the app sandbox (relative to data container). */ bootTokenPath?: string; + /** Env vars to set when the daemon launches the app (cold start). Forwarded + * to `devicectl ... --environment-variables`. Apps that gate their debug + * bridge behind a flag (e.g. BuckHound's BH_ENABLE_IOS_QA_BRIDGE) need this + * or a cold launch never boots the StateServer. */ + launchEnv?: Record; /** Max time to wait for the StateServer to start after launch (ms). */ startupTimeoutMs?: number; /** Test injection. */ @@ -91,7 +96,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise the StateServer never binds -> state_server_unreachable, +// and `/ios-qa` only works if the operator pre-launches by hand. The daemon +// reads GSTACK_IOS_LAUNCH_ENV (a JSON dict) and forwards it to +// `devicectl device process launch --environment-variables`. + +import { describe, test, expect } from 'bun:test'; +import { launchApp, type SpawnImpl } from '../src/devicectl'; +import { bootstrapTunnel } from '../src/tunnel-bootstrap'; +import { writeFileSync } from 'fs'; + +function makeReturn(exit: number, stdout = '', stderr = '') { + return { + pid: 0, + output: [null, Buffer.from(stdout), Buffer.from(stderr)], + stdout: Buffer.from(stdout), + stderr: Buffer.from(stderr), + status: exit, + signal: null, + } as ReturnType; +} + +describe('launchApp environment variables', () => { + test('passes --environment-variables with the JSON dict, bundle id stays last', () => { + let captured: string[] = []; + const spawn: SpawnImpl = ((_cmd: string, args: string[]) => { captured = args; return makeReturn(0); }) as SpawnImpl; + const r = launchApp('UDID-1', 'com.test.app', spawn, { BH_ENABLE_IOS_QA_BRIDGE: '1' }); + expect(r.ok).toBe(true); + const i = captured.indexOf('--environment-variables'); + expect(i).toBeGreaterThan(-1); + expect(JSON.parse(captured[i + 1]!)).toEqual({ BH_ENABLE_IOS_QA_BRIDGE: '1' }); + expect(captured[captured.length - 1]).toBe('com.test.app'); // bundle id is the trailing positional + }); + + test('omits --environment-variables when env is undefined or empty', () => { + for (const env of [undefined, {}]) { + let captured: string[] = []; + const spawn: SpawnImpl = ((_cmd: string, args: string[]) => { captured = args; return makeReturn(0); }) as SpawnImpl; + launchApp('UDID-1', 'com.test.app', spawn, env); + expect(captured.includes('--environment-variables')).toBe(false); + expect(captured[captured.length - 1]).toBe('com.test.app'); + } + }); +}); + +describe('bootstrapTunnel forwards launchEnv on cold start', () => { + test('threads launchEnv into the launch when the app is not already running', async () => { + const calls: string[][] = []; + const spawn: SpawnImpl = ((_cmd: string, args: string[]) => { + calls.push(args); + const joined = args.join(' '); + const writeJson = (obj: object) => { + const fi = args.indexOf('--json-output'); + if (fi !== -1 && args[fi + 1]) writeFileSync(args[fi + 1]!, JSON.stringify(obj)); + }; + if (joined.includes('list devices')) { + writeJson({ result: { devices: [{ identifier: 'UDID-1', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, deviceProperties: { name: 'Test' }, hardwareProperties: { productType: 'iPhone17,1' } }] } }); + return makeReturn(0); + } + if (joined.includes('info processes')) { writeJson({ result: { runningProcesses: [] } }); return makeReturn(0); } // not running -> launch + if (joined.includes('process launch')) { return makeReturn(0); } + if (joined.includes('info details')) { writeJson({ result: { connectionProperties: { tunnelIPAddress: 'fd00::1' } } }); return makeReturn(0); } + if (joined.includes('copy from')) { + const fi = args.indexOf('--destination'); + if (fi !== -1 && args[fi + 1]) writeFileSync(args[fi + 1]!, 'BOOT-TOK\n'); + return makeReturn(0); + } + return makeReturn(1, '', 'unexpected ' + joined); + }) as SpawnImpl; + + const r = await bootstrapTunnel({ + udid: 'UDID-1', + bundleId: 'com.test.app', + launchEnv: { BH_ENABLE_IOS_QA_BRIDGE: '1' }, + spawnImpl: spawn, + resolveImpl: async () => ['fd00::1'], + fetchImpl: (async (url: unknown) => { + const u = String(url); + if (u.endsWith('/healthz')) return new Response('{"version":"1.0.0"}', { status: 200 }); + if (u.endsWith('/auth/rotate')) return new Response('{"ok":true}', { status: 200 }); + return new Response('nope', { status: 404 }); + }) as typeof fetch, + startupTimeoutMs: 1_000, + }); + + expect(r.ok).toBe(true); + const launchCall = calls.find((a) => a.join(' ').includes('process launch')); + expect(launchCall).toBeDefined(); + const i = launchCall!.indexOf('--environment-variables'); + expect(i).toBeGreaterThan(-1); + expect(JSON.parse(launchCall![i + 1]!)).toEqual({ BH_ENABLE_IOS_QA_BRIDGE: '1' }); + }); +}); diff --git a/ios-qa/daemon/test/session-cache.test.ts b/ios-qa/daemon/test/session-cache.test.ts new file mode 100644 index 000000000..52779c814 --- /dev/null +++ b/ios-qa/daemon/test/session-cache.test.ts @@ -0,0 +1,246 @@ +// Session-cache unit tests. The daemon persists the rotated bearer it obtains +// from a one-time boot-token rotate so that subsequent bootstraps (30s tunnel +// refresh, daemon restart, a new /ios-qa session) REUSE that bearer instead of +// re-copying the now-deleted single-use boot token. Without this, the second +// bootstrap after the first /auth/rotate fails with boot_token_unavailable +// (the StateServer deletes + invalidates the boot token on rotate), so a real +// device can be driven exactly once per app launch. +// +// All decision-logic tests inject cache + resolve + probe + bootstrap stubs so +// no real device / filesystem is needed. The round-trip tests use a temp file. + +import { describe, test, expect, afterEach } from 'bun:test'; +import { + readSessionCache, + writeSessionCache, + clearSessionCache, + acquireTunnel, + type SessionCache, +} from '../src/session-cache'; +import type { BootstrapResult } from '../src/tunnel-bootstrap'; +import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const tmpFiles: string[] = []; +function tmpCachePath(): string { + const dir = mkdtempSync(join(tmpdir(), 'gstack-sc-')); + const p = join(dir, 'ios-qa-session.json'); + tmpFiles.push(dir); + return p; +} +afterEach(() => { + while (tmpFiles.length) { + const d = tmpFiles.pop()!; + try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +const SAMPLE: SessionCache = { + udid: 'UDID-1', + bundleId: 'com.test.app', + port: 9999, + rotatedToken: 'ROTATED-ABC-123', + ipv6: 'fd00::1', + createdAt: 1_700_000_000_000, +}; + +function okBootstrap(token = 'FRESH-ROTATED-999', ipv6 = 'fd00::2', udid = 'UDID-1'): () => Promise { + return async () => ({ ok: true, tunnel: { udid, ipv6Addr: ipv6, port: 9999, bootTokenRotated: token } }); +} + +describe('session cache file I/O', () => { + test('write then read round-trips the cache', () => { + const p = tmpCachePath(); + writeSessionCache(SAMPLE, p); + const got = readSessionCache(p); + expect(got).toEqual(SAMPLE); + }); + + test('writes the cache file with 0600 perms (owner-only)', () => { + const p = tmpCachePath(); + writeSessionCache(SAMPLE, p); + const mode = statSync(p).mode & 0o777; + expect(mode).toBe(0o600); + }); + + test('read returns null for a missing file', () => { + expect(readSessionCache(join(tmpdir(), 'definitely-missing-xyz.json'))).toBeNull(); + }); + + test('clear removes the file and read then returns null', () => { + const p = tmpCachePath(); + writeSessionCache(SAMPLE, p); + expect(existsSync(p)).toBe(true); + clearSessionCache(p); + expect(existsSync(p)).toBe(false); + expect(readSessionCache(p)).toBeNull(); + }); +}); + +describe('acquireTunnel — reuse path', () => { + test('reuses the cached rotated token when the probe succeeds (no bootstrap, no boot-token copy)', async () => { + let bootstrapCalled = false; + const tunnel = await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => SAMPLE, + writeCacheImpl: () => {}, + clearCacheImpl: () => {}, + resolveIPv6Impl: () => 'fd00::1', + probeImpl: async () => 200, + bootstrapImpl: async () => { bootstrapCalled = true; return { ok: false, error: 'no_devices' }; }, + }); + expect(bootstrapCalled).toBe(false); + expect(tunnel).not.toBeNull(); + expect(tunnel!.bootTokenRotated).toBe('ROTATED-ABC-123'); + expect(tunnel!.udid).toBe('UDID-1'); + }); + + test('reuse picks up a refreshed tunnel IPv6 and rewrites the cache', async () => { + let written: SessionCache | null = null; + const tunnel = await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => SAMPLE, // cached ipv6 fd00::1 + writeCacheImpl: (c) => { written = c; }, + clearCacheImpl: () => {}, + resolveIPv6Impl: () => 'fd00::beef', // device now on a different tunnel addr + probeImpl: async () => 200, + bootstrapImpl: okBootstrap(), + }); + expect(tunnel!.ipv6Addr).toBe('fd00::beef'); + expect(tunnel!.bootTokenRotated).toBe('ROTATED-ABC-123'); // token unchanged + expect(written).not.toBeNull(); + expect(written!.ipv6).toBe('fd00::beef'); + }); +}); + +describe('acquireTunnel — bootstrap path', () => { + test('with no cache, runs a full bootstrap and persists the rotated token', async () => { + let written: SessionCache | null = null; + let probeCalled = false; + const tunnel = await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => null, + writeCacheImpl: (c) => { written = c; }, + clearCacheImpl: () => {}, + resolveIPv6Impl: () => 'fd00::1', + probeImpl: async () => { probeCalled = true; return 200; }, + bootstrapImpl: okBootstrap('FRESH-ROTATED-999', 'fd00::2', 'UDID-1'), + }); + expect(probeCalled).toBe(false); // no cached token to probe + expect(tunnel!.bootTokenRotated).toBe('FRESH-ROTATED-999'); + expect(written).not.toBeNull(); + expect(written!.rotatedToken).toBe('FRESH-ROTATED-999'); + expect(written!.ipv6).toBe('fd00::2'); + }); + + test('when the cached token is rejected (401), clears cache then re-bootstraps', async () => { + let cleared = false; + let bootstrapCalled = false; + let written: SessionCache | null = null; + const tunnel = await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => SAMPLE, + writeCacheImpl: (c) => { written = c; }, + clearCacheImpl: () => { cleared = true; }, + resolveIPv6Impl: () => 'fd00::1', + probeImpl: async () => 401, // app was relaunched → old rotated token dead + bootstrapImpl: async () => { bootstrapCalled = true; return (await okBootstrap('NEW-TOKEN')()); }, + }); + expect(cleared).toBe(true); + expect(bootstrapCalled).toBe(true); + expect(tunnel!.bootTokenRotated).toBe('NEW-TOKEN'); + expect(written!.rotatedToken).toBe('NEW-TOKEN'); + }); + + test('ignores a cache whose udid does not match an explicitly requested udid', async () => { + let bootstrapCalled = false; + const tunnel = await acquireTunnel({ + udid: 'UDID-OTHER', + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => SAMPLE, // cache is for UDID-1 + writeCacheImpl: () => {}, + clearCacheImpl: () => {}, + resolveIPv6Impl: () => 'fd00::1', + probeImpl: async () => 200, + bootstrapImpl: async () => { bootstrapCalled = true; return (await okBootstrap('X', 'fd00::9', 'UDID-OTHER')()); }, + }); + expect(bootstrapCalled).toBe(true); + expect(tunnel!.udid).toBe('UDID-OTHER'); + }); +}); + +describe('acquireTunnel — transient + failure handling', () => { + test('returns null WITHOUT clearing the cache when the probe hits a connection error', async () => { + let cleared = false; + let bootstrapCalled = false; + const tunnel = await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => SAMPLE, + writeCacheImpl: () => {}, + clearCacheImpl: () => { cleared = true; }, + resolveIPv6Impl: () => 'fd00::1', + probeImpl: async () => 0, // connection refused / device momentarily unreachable + bootstrapImpl: async () => { bootstrapCalled = true; return { ok: false, error: 'no_devices' }; }, + }); + expect(tunnel).toBeNull(); + expect(cleared).toBe(false); // do NOT destroy a good token on a transient blip + expect(bootstrapCalled).toBe(false); + }); + + test('returns null and does not write cache when bootstrap fails', async () => { + let written = false; + const tunnel = await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => null, + writeCacheImpl: () => { written = true; }, + clearCacheImpl: () => {}, + resolveIPv6Impl: () => null, + probeImpl: async () => 200, + bootstrapImpl: async () => ({ ok: false, error: 'boot_token_unavailable', detail: 'gone' }), + }); + expect(tunnel).toBeNull(); + expect(written).toBe(false); + }); +}); + +describe('acquireTunnel — diagnostics', () => { + test('surfaces the bootstrap failure reason via logImpl', async () => { + const logs: string[] = []; + await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => null, + writeCacheImpl: () => {}, + clearCacheImpl: () => {}, + resolveIPv6Impl: () => null, + probeImpl: async () => 200, + bootstrapImpl: async () => ({ ok: false, error: 'boot_token_unavailable', detail: 'gone' }), + logImpl: (m) => logs.push(m), + }); + expect(logs.some((l) => l.includes('boot_token_unavailable'))).toBe(true); + }); + + test('logs that it reused a cached session token (no app relaunch needed)', async () => { + const logs: string[] = []; + await acquireTunnel({ + bundleId: 'com.test.app', + port: 9999, + readCacheImpl: () => SAMPLE, + writeCacheImpl: () => {}, + clearCacheImpl: () => {}, + resolveIPv6Impl: () => 'fd00::1', + probeImpl: async () => 200, + bootstrapImpl: okBootstrap(), + logImpl: (m) => logs.push(m), + }); + expect(logs.some((l) => /reus/i.test(l))).toBe(true); + }); +});