Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bin/gstack-ios-qa-daemon
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n> pid=<pid>` to stdout once both
# listeners are bound. Spawners read stdin with a ~5s timeout to confirm.
Expand Down
11 changes: 10 additions & 1 deletion ios-qa/daemon/src/devicectl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,17 @@ export function launchApp(
udid: string,
bundleId: string,
spawn: SpawnImpl = defaultSpawn,
env?: Record<string, string>,
): { 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')) {
Expand Down
33 changes: 25 additions & 8 deletions ios-qa/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, string> | undefined;
if (process.env.GSTACK_IOS_LAUNCH_ENV) {
try {
launchEnv = JSON.parse(process.env.GSTACK_IOS_LAUNCH_ENV) as Record<string, string>;
} 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.
Expand All @@ -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 = () => {
Expand Down
176 changes: 176 additions & 0 deletions ios-qa/daemon/src/session-cache.ts
Original file line number Diff line number Diff line change
@@ -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<SessionCache>;
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<string, string>;
// 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<string | null>;
probeImpl?: (ipv6: string, port: number, token: string) => Promise<number>;
bootstrapImpl?: (opts: BootstrapOptions) => Promise<import('./tunnel-bootstrap').BootstrapResult>;
/** 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<DeviceTunnel | null> {
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<number> => {
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
}
};
}
7 changes: 6 additions & 1 deletion ios-qa/daemon/src/tunnel-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** Max time to wait for the StateServer to start after launch (ms). */
startupTimeoutMs?: number;
/** Test injection. */
Expand Down Expand Up @@ -91,7 +96,7 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap

// Step 2: launch app (idempotent — devicectl returns success if already running)
if (!isAppRunning(target.identifier, opts.bundleId, spawn)) {
const launched = launchApp(target.identifier, opts.bundleId, spawn);
const launched = launchApp(target.identifier, opts.bundleId, spawn, opts.launchEnv);
if (!launched.ok) {
return { ok: false, error: launched.error === 'device_locked' ? 'device_locked' : 'launch_failed', detail: launched.error };
}
Expand Down
95 changes: 95 additions & 0 deletions ios-qa/daemon/test/launch-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Cold-start launch env. BuckHound (and any app that gates its debug bridge
// behind an env var) needs the daemon to pass that var when it launches the
// app itself. Without it, a cold start (app not already running) launches the
// app WITHOUT the bridge -> 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<SpawnImpl>;
}

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' });
});
});
Loading