diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f110fb..22077040a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.91.171.0] - 2026-10-20 + +**A restarted `/ios-qa` daemon resumes the device session instead of relaunching the app.** The app's boot token works once, so a new daemon process used to have no way back in except `devicectl process launch --terminate-existing`, which wiped the app's QA state. The daemon now writes the rotated StateServer bearer to `$GSTACK_STATE_ROOT/ios-qa-session.json` (mode 0600, through a fresh temp file) after every successful bootstrap, bound to the device UDID, bundle ID and StateServer port, and a new daemon tries it once on its first device request. The bearer goes only to the tunnel address `devicectl` reports for the bound UDID, after `/healthz` names the same bundle; there is no mDNS or remembered-address fallback. The daemon bootstraps normally, without sending the bearer, when the target UDID, app or port differs, another device or app answers, `devicectl` reports no address, or the file is not private to you (the file is removed unused). A bearer the app rejects is cleared before the bootstrap and never presented again, and the boot token is never written to disk. `/ios-qa` Phase 0 now describes this file instead of a cache no code wrote. Draft until verified on a real iPhone. + +- Thanks @Bmathews721 (#1796) for the diagnosis, the hardware repro and the reuse scenarios the new tests carry. + ## [1.91.170.0] - 2026-10-20 **/autoplan no longer stops a healthy run with a false "event order" or "init" denial when Claude Code is slow to write its session journal.** Daily flaky-fix pass, group 5 (other paid cases): the same pass clarifies how the office-hours reviewer recovers from a seal error and fixes two plan-review floor harness gaps in reading cropped permission menus. diff --git a/VERSION b/VERSION index 655eb2be3..be80bdf59 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.170.0 +1.91.171.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 83c065f3a..4c0a5ad9d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.170.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.171.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index 421abc1d2..dfd43a37a 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -425,23 +425,27 @@ tokens (default 1h) for remote agents. ## Phase 0: Session warm-start (optional) -If `~/.gstack/ios-qa-session.json` exists and the device is still connected, -skip Phase 1-2 and jump to Phase 3. The session cache holds the rotated token, -UDID, tunnel address, and accessor hash. Invalidate the cache when: +After each successful bootstrap the daemon writes the rotated StateServer +bearer to `$GSTACK_STATE_ROOT/ios-qa-session.json` (mode 0600), bound to the +device UDID, bundle ID and StateServer port. A restarted daemon resumes that +bearer on its first device request, so the app keeps its QA state instead of +being relaunched. It bootstraps normally instead when the app was relaunched +(the app rejects the bearer), another device or app answers, devicectl reports +no tunnel address for the bound UDID, or other users can read the file. -- The user passes `--cold` to force a full bootstrap. -- The accessor hash mismatch is detected on first state query. -- The daemon reports the cached UDID is no longer connected. +If the file exists and the daemon answers, the bridge is already installed: +skip Phase 1-2 and jump to Phase 3. `--cold` deletes the file so the next +daemon start runs a full bootstrap (the app relaunches once); a daemon that is +already running keeps its in-memory session. ```bash GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" SESSION="$GSTACK_STATE_ROOT/ios-qa-session.json" -if [ -f "$SESSION" ] && [ "$COLD" != "1" ]; then - CACHED_UDID=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['udid'])") - CACHED_PORT=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['daemon_port'])") - if curl -sf "http://127.0.0.1:$CACHED_PORT/healthz" > /dev/null; then - echo "Warm start: daemon alive, device $CACHED_UDID connected" - fi +PORT="${GSTACK_IOS_DAEMON_PORT:-9099}" +if [ "$COLD" = "1" ]; then + rm -f "$SESSION" +elif [ -f "$SESSION" ] && curl -sf "http://127.0.0.1:$PORT/healthz" > /dev/null; then + echo "Warm start: daemon alive on port $PORT with a persisted device session" fi ``` diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl index b14a3349c..4c7b4a2c5 100644 --- a/ios-qa/SKILL.md.tmpl +++ b/ios-qa/SKILL.md.tmpl @@ -81,23 +81,27 @@ tokens (default 1h) for remote agents. ## Phase 0: Session warm-start (optional) -If `~/.gstack/ios-qa-session.json` exists and the device is still connected, -skip Phase 1-2 and jump to Phase 3. The session cache holds the rotated token, -UDID, tunnel address, and accessor hash. Invalidate the cache when: +After each successful bootstrap the daemon writes the rotated StateServer +bearer to `$GSTACK_STATE_ROOT/ios-qa-session.json` (mode 0600), bound to the +device UDID, bundle ID and StateServer port. A restarted daemon resumes that +bearer on its first device request, so the app keeps its QA state instead of +being relaunched. It bootstraps normally instead when the app was relaunched +(the app rejects the bearer), another device or app answers, devicectl reports +no tunnel address for the bound UDID, or other users can read the file. -- The user passes `--cold` to force a full bootstrap. -- The accessor hash mismatch is detected on first state query. -- The daemon reports the cached UDID is no longer connected. +If the file exists and the daemon answers, the bridge is already installed: +skip Phase 1-2 and jump to Phase 3. `--cold` deletes the file so the next +daemon start runs a full bootstrap (the app relaunches once); a daemon that is +already running keeps its in-memory session. ```bash GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" SESSION="$GSTACK_STATE_ROOT/ios-qa-session.json" -if [ -f "$SESSION" ] && [ "$COLD" != "1" ]; then - CACHED_UDID=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['udid'])") - CACHED_PORT=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['daemon_port'])") - if curl -sf "http://127.0.0.1:$CACHED_PORT/healthz" > /dev/null; then - echo "Warm start: daemon alive, device $CACHED_UDID connected" - fi +PORT="${GSTACK_IOS_DAEMON_PORT:-9099}" +if [ "$COLD" = "1" ]; then + rm -f "$SESSION" +elif [ -f "$SESSION" ] && curl -sf "http://127.0.0.1:$PORT/healthz" > /dev/null; then + echo "Warm start: daemon alive on port $PORT with a persisted device session" fi ``` diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts index b16643426..8bcd4c4dd 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -14,6 +14,7 @@ import { createServer, IncomingMessage, ServerResponse } from 'http'; import { parse as parseUrl } from 'url'; +import { dirname } from 'path'; import { tryClaim } from './single-instance'; import { probeTailscale, whoIs } from './tailscale-localapi'; import { SessionTokenStore } from './session-tokens'; @@ -22,6 +23,7 @@ import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy'; import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit'; import { bootstrapTunnel, recoverTunnel, type TunnelRecovery } from './tunnel-bootstrap'; import { startTunnelKeepalive, type SpawnImpl, type ResolveImpl } from './devicectl'; +import { clearSession, defaultSessionPath, resumeSession, writeSession } from './session-cache'; import type { Capability } from './types'; interface DaemonOptions { @@ -605,13 +607,16 @@ async function handleTailnet(ctx: TailnetCtx): Promise { /** * 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. + * keepalive, plus route-drop recovery bound to the same target. The first + * tunnel request of a daemon process resumes the bearer an earlier daemon + * persisted at `sessionPath` (see session-cache.ts) instead of relaunching the + * app. Exported so tests drive exactly this wiring against a simulated device. */ export function deviceTunnelSource(opts: { udid?: string; bundleId: string; port?: number; + sessionPath: string; spawnImpl?: SpawnImpl; resolveImpl?: ResolveImpl; fetchImpl?: typeof fetch; @@ -624,13 +629,43 @@ export function deviceTunnelSource(opts: { } { const log = opts.log ?? ((line: string) => process.stderr.write(`${line}\n`)); const keepaliveFor = opts.keepalive ?? ((udid: string) => startTunnelKeepalive(udid)); + const port = opts.port ?? 9999; let keepalive: { stop: () => void } | null = null; + // Later provider calls follow a failure of this process's own tunnel, so the + // persisted bearer is either the one that just failed or already replaced. + let resumable = true; + const keep = (tunnel: DeviceTunnel): DeviceTunnel => { + keepalive?.stop(); + keepalive = keepaliveFor(tunnel.udid); + return tunnel; + }; return { tunnelProvider: async () => { + if (resumable) { + const resumed = await resumeSession(opts.sessionPath, { + udid: opts.udid, + bundleId: opts.bundleId, + port, + spawnImpl: opts.spawnImpl, + resolveImpl: opts.resolveImpl, + fetchImpl: opts.fetchImpl, + }); + if (resumed.action === 'unavailable') { + log(`session resume: ${resumed.reason}; kept ${opts.sessionPath} for the next request`); + return null; + } + resumable = false; + if (resumed.action === 'reuse') { + log(`session resumed: reused the persisted bearer for ${resumed.tunnel.udid} without relaunching ${opts.bundleId}`); + return keep(resumed.tunnel); + } + log(`session resume: ${resumed.reason}; bootstrapping`); + } + clearSession(opts.sessionPath); const result = await bootstrapTunnel({ udid: opts.udid, bundleId: opts.bundleId, - port: opts.port, + port, spawnImpl: opts.spawnImpl, resolveImpl: opts.resolveImpl, fetchImpl: opts.fetchImpl, @@ -639,9 +674,12 @@ export function deviceTunnelSource(opts: { log(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}`); return null; } - keepalive?.stop(); - keepalive = keepaliveFor(result.tunnel.udid); - return result.tunnel; + try { + writeSession(result.tunnel, opts.bundleId, opts.sessionPath); + } catch (err) { + log(`session not persisted: ${(err as Error).message}; a restarted daemon will relaunch ${opts.bundleId} once. Check that ${dirname(opts.sessionPath)} is writable by you.`); + } + return keep(result.tunnel); }, tunnelRecovery: async (failed) => { const recovery = await recoverTunnel(failed, { @@ -675,7 +713,7 @@ if (import.meta.main) { // 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 }); + const source = deviceTunnelSource({ udid: targetUDID, bundleId, sessionPath: defaultSessionPath() }); process.on('SIGINT', source.stop); process.on('SIGTERM', source.stop); process.on('exit', source.stop); diff --git a/ios-qa/daemon/src/session-cache.ts b/ios-qa/daemon/src/session-cache.ts new file mode 100644 index 000000000..da950fe4c --- /dev/null +++ b/ios-qa/daemon/src/session-cache.ts @@ -0,0 +1,114 @@ +// Persisted session bearer. A successful bootstrap consumes the app's one-shot +// boot token, so a restarted daemon used to have no way back in except +// relaunching the app, which wipes its QA state (#1796). The daemon now keeps +// the rotated bearer in $GSTACK_STATE_ROOT/ios-qa-session.json and a fresh +// daemon process resumes it once, at its first tunnel request. +// +// The boot token is never written here. The bearer is bound to the device, +// app and port that issued it and is only ever sent through recoverTunnel in +// pinned-address mode: to the address devicectl reports for that UDID, after +// /healthz names the same bundle. It is presented at most once per daemon +// process, and the file is cleared before every bootstrap, so a rejected or +// foreign bearer is never replayed. + +import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { resolveStateRoot } from '../../../lib/state-root'; +import { recoverTunnel, type RecoveryOptions, type TunnelRecovery } from './tunnel-bootstrap'; +import type { DeviceTunnel } from './proxy'; + +export interface PersistedSession { + version: 1; + udid: string; + bundle_id: string; + port: number; + bearer: string; + created_at: string; +} + +export function defaultSessionPath(): string { + return join(resolveStateRoot(), 'ios-qa-session.json'); +} + +/** Read the persisted session. A file that is not private to this user, or not valid, is removed unused. */ +export function readSession(path: string): { session: PersistedSession | null; discarded?: string } { + let st; + try { + st = lstatSync(path); + } catch { + return { session: null }; + } + const discard = (reason: string) => { + clearSession(path); + return { session: null, discarded: reason }; + }; + if (!st.isFile()) return discard('it is not a regular file'); + if (process.platform !== 'win32') { + if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return discard(`it is owned by uid ${st.uid}`); + if ((st.mode & 0o077) !== 0) return discard(`other users can access it (mode ${(st.mode & 0o777).toString(8)})`); + } + let parsed: Partial; + try { + parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial; + } catch { + return discard('it is not valid JSON'); + } + if ( + parsed?.version !== 1 + || typeof parsed.udid !== 'string' || !parsed.udid + || typeof parsed.bundle_id !== 'string' || !parsed.bundle_id + || !Number.isInteger(parsed.port) + || typeof parsed.bearer !== 'string' || !parsed.bearer + ) { + return discard('it does not hold a version 1 session'); + } + return { session: parsed as PersistedSession }; +} + +/** Write the session owner-only through a fresh temp file, so an existing file's mode or link never applies. */ +export function writeSession(tunnel: DeviceTunnel, bundleId: string, path: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const session: PersistedSession = { + version: 1, + udid: tunnel.udid, + bundle_id: bundleId, + port: tunnel.port, + bearer: tunnel.bootTokenRotated, + created_at: new Date().toISOString(), + }; + const tmp = `${path}.${process.pid}.tmp`; + rmSync(tmp, { force: true }); + writeFileSync(tmp, `${JSON.stringify(session)}\n`, { mode: 0o600, flag: 'wx' }); + renameSync(tmp, path); +} + +/** Best effort: a directory at the path stays, and writeSession then reports it. */ +export function clearSession(path: string): void { + try { rmSync(path, { force: true }); } catch { /* ignore */ } +} + +/** + * Decide whether a new daemon process can resume the persisted bearer. Only + * a session bound to the configured app, port and (when pinned) UDID is + * probed, and only through recoverTunnel's pinned-address mode. + */ +export async function resumeSession( + path: string, + opts: Omit & { port: number }, +): Promise { + const { session, discarded } = readSession(path); + if (discarded) { + return { action: 'bootstrap', reason: `ignored ${path} because ${discarded}` }; + } + if (!session) return { action: 'bootstrap', reason: `no persisted session at ${path}` }; + if (session.bundle_id !== opts.bundleId || session.port !== opts.port || (opts.udid && session.udid !== opts.udid)) { + return { + action: 'bootstrap', + reason: `persisted session belongs to ${session.bundle_id} on ${session.udid} port ${session.port}, not this target`, + }; + } + return recoverTunnel( + { udid: session.udid, ipv6Addr: '', port: session.port, bootTokenRotated: session.bearer }, + { ...opts, pinnedAddressOnly: true }, + ); +} diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts index c1517f1b2..76d66b16b 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -390,6 +390,12 @@ export interface RecoveryOptions { /** Same explicit UDID the bootstrap used, if any. */ udid?: string; bundleId: string; + /** + * Send the bearer only to the address devicectl reports for the UDID. A + * session persisted by an earlier daemon has no address proven in this + * process, so it never falls back to mDNS or a remembered address. + */ + pinnedAddressOnly?: boolean; probeTimeoutMs?: number; spawnImpl?: SpawnImpl; resolveImpl?: ResolveImpl; @@ -422,6 +428,9 @@ export async function recoverTunnel(failed: DeviceTunnel, opts: RecoveryOptions) } let address = getDeviceTunnelIPv6FromDevicectl(device.identifier, spawn); + if (!address && opts.pinnedAddressOnly) { + return { action: 'bootstrap', reason: `devicectl reported no tunnel address for ${device.identifier}` }; + } if (!address) { const byName = await getDeviceTunnelIPv6(device.name, opts.resolveImpl); if (byName && byName !== failed.ipv6Addr) { diff --git a/ios-qa/daemon/test/daemon-integration.test.ts b/ios-qa/daemon/test/daemon-integration.test.ts index 8db5bf5b0..40a081e90 100644 --- a/ios-qa/daemon/test/daemon-integration.test.ts +++ b/ios-qa/daemon/test/daemon-integration.test.ts @@ -535,6 +535,7 @@ describe('daemon — route-drop recovery (simulated device)', () => { const source = deviceTunnelSource({ bundleId: BUNDLE, port: fake.port, + sessionPath: join(recoveryDir, `${name}.session.json`), spawnImpl: fake.spawn, keepalive: () => ({ stop() {} }), log: (line) => logs.push(line), 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..842275fa3 --- /dev/null +++ b/ios-qa/daemon/test/session-cache.test.ts @@ -0,0 +1,265 @@ +// Persisted session bearer (#1796): a restarted daemon resumes the rotated +// bearer instead of relaunching the app, without weakening device binding or +// letting a rejected or foreign bearer be replayed. Restart scenarios run the +// production deviceTunnelSource wiring against the simulated device: daemon A +// bootstraps and exits, daemon B starts with the same session file. + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { chmodSync, existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startDaemon, deviceTunnelSource } from '../src/index'; +import { readSession, writeSession, type PersistedSession } from '../src/session-cache'; +import { startFakeDevice, type FakeDevice } from './fake-device'; +import type { SpawnImpl } from '../src/devicectl'; + +const BUNDLE = 'com.test.session'; +let dir: string; +const cleanups: Array<() => Promise> = []; + +beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'ios-qa-session-')); }); +afterAll(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + rmSync(dir, { recursive: true, force: true }); +}); + +const TUNNEL = { udid: 'UDID-1', ipv6Addr: '::1', port: 9999, bootTokenRotated: 'bearer-abc' }; + +describe('session file', () => { + test('is written owner-only with the bearer and its binding, never a boot token', () => { + const path = join(dir, 'write.json'); + writeSession(TUNNEL, BUNDLE, path); + expect(statSync(path).mode & 0o777).toBe(0o600); + const raw = JSON.parse(readFileSync(path, 'utf-8')) as PersistedSession; + expect(raw).toMatchObject({ version: 1, udid: 'UDID-1', bundle_id: BUNDLE, port: 9999, bearer: 'bearer-abc' }); + expect(Object.keys(raw).sort()).toEqual(['bearer', 'bundle_id', 'created_at', 'port', 'udid', 'version']); + expect(readSession(path).session).toEqual(raw); + }); + + test('replaces a group-readable file or a symlink instead of writing through it', () => { + const loose = join(dir, 'loose.json'); + writeFileSync(loose, '{}', { mode: 0o644 }); + chmodSync(loose, 0o644); + writeSession(TUNNEL, BUNDLE, loose); + expect(statSync(loose).mode & 0o777).toBe(0o600); + + const target = join(dir, 'link-target.txt'); + writeFileSync(target, 'untouched'); + const link = join(dir, 'link.json'); + symlinkSync(target, link); + writeSession(TUNNEL, BUNDLE, link); + expect(lstatSync(link).isSymbolicLink()).toBe(false); + expect(readFileSync(target, 'utf-8')).toBe('untouched'); + }); + + test('a file other users can read, a symlink, or an invalid session is removed unused', () => { + const loose = join(dir, 'read-loose.json'); + writeSession(TUNNEL, BUNDLE, loose); + chmodSync(loose, 0o640); + expect(readSession(loose)).toEqual({ session: null, discarded: 'other users can access it (mode 640)' }); + expect(existsSync(loose)).toBe(false); + + const real = join(dir, 'read-real.json'); + writeSession(TUNNEL, BUNDLE, real); + const link = join(dir, 'read-link.json'); + symlinkSync(real, link); + expect(readSession(link).discarded).toBe('it is not a regular file'); + expect(existsSync(link)).toBe(false); + + for (const [name, body, reason] of [ + ['bad-json.json', '{', 'it is not valid JSON'], + ['old-shape.json', JSON.stringify({ udid: 'U', bundleId: BUNDLE, port: 9999, rotatedToken: 't' }), 'it does not hold a version 1 session'], + ] as const) { + const path = join(dir, name); + writeFileSync(path, body, { mode: 0o600 }); + expect(readSession(path)).toEqual({ session: null, discarded: reason }); + expect(existsSync(path)).toBe(false); + } + expect(readSession(join(dir, 'missing.json'))).toEqual({ session: null }); + }); +}); + +describe('daemon restart (simulated device)', () => { + async function device(opts: { udid?: string; bundleId?: string } = {}) { + const fake = await startFakeDevice({ bundleId: opts.bundleId ?? BUNDLE, udid: opts.udid ?? 'FAKE-UDID-1' }); + cleanups.push(() => fake.close()); + return fake; + } + + async function daemon(name: string, fake: FakeDevice, opts: { udid?: string; spawn?: SpawnImpl; sessionPath?: string } = {}) { + const logs: string[] = []; + const source = deviceTunnelSource({ + udid: opts.udid, + bundleId: BUNDLE, + port: fake.port, + sessionPath: opts.sessionPath ?? join(dir, `${name}.session.json`), + spawnImpl: opts.spawn ?? fake.spawn, + resolveImpl: async () => ['::1'], + keepalive: () => ({ stop() {} }), + log: (line) => logs.push(line), + }); + const d = await startDaemon({ + loopbackPort: 0, + tailnetEnabled: false, + pidfilePath: join(dir, `${name}-${Math.random().toString(36).slice(2)}.pid`), + proxyTimeoutMs: 300, + tunnelProvider: source.tunnelProvider, + tunnelRecovery: source.tunnelRecovery, + }); + if ('error' in d) throw new Error(d.error); + let closed = false; + const close = async () => { if (!closed) { closed = true; source.stop(); await d.close(); } }; + cleanups.push(close); + const get = async (path: string, method = 'GET') => { + const res = await fetch(`http://127.0.0.1:${d.loopbackPort}${path}`, { method, body: method === 'POST' ? '{}' : undefined }); + return { status: res.status, body: await res.text() }; + }; + return { logs, close, get }; + } + + /** Daemon A bootstraps, taps, records its bearer and exits. */ + async function firstRun(name: string, fake: FakeDevice) { + const a = await daemon(name, fake); + expect((await a.get('/tap', 'POST')).status).toBe(200); + const bearer = fake.requests.at(-1)!.authorization!; + await a.close(); + return bearer; + } + + 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; + const sentBearer = (fake: FakeDevice, bearer: string, since = 0) => fake.requests.slice(since).filter((r) => r.authorization === bearer); + const persistedBearer = (path: string) => `Bearer ${readSession(path).session?.bearer}`; + + test('a restarted daemon resumes the persisted bearer: no relaunch, app state kept', async () => { + const fake = await device(); + const bearer = await firstRun('resume', fake); + expect(persistedBearer(join(dir, 'resume.session.json'))).toBe(bearer); + + const b = await daemon('resume', fake); + const snap = await b.get('/state/snapshot'); + expect(snap.status).toBe(200); + expect(JSON.parse(snap.body).state).toEqual({ generation: 1, marker: 'tapped' }); + expect(terminations(fake)).toBe(0); + expect(bootTokenReads(fake)).toBe(1); + expect(fake.requests.at(-1)?.authorization).toBe(bearer); + expect(b.logs.some((l) => l.startsWith('session resumed: reused the persisted bearer for FAKE-UDID-1'))).toBe(true); + }); + + test('app relaunched between daemons: the old bearer is presented once, then a normal bootstrap', async () => { + const fake = await device(); + const bearer = await firstRun('relaunched', fake); + fake.relaunchExternally(); + const since = fake.requests.length; + + const b = await daemon('relaunched', fake); + expect((await b.get('/screenshot')).status).toBe(200); + expect(sentBearer(fake, bearer, since)).toHaveLength(1); + expect(sentBearer(fake, bearer, since)[0]!.path).toBe('/state/snapshot'); + expect(bootTokenReads(fake)).toBe(2); + expect(terminations(fake)).toBe(0); + const fresh = persistedBearer(join(dir, 'relaunched.session.json')); + expect(fresh).not.toBe(bearer); + expect(fake.requests.at(-1)?.authorization).toBe(fresh); + expect(b.logs.some((l) => l.includes('rejected the session bearer'))).toBe(true); + }); + + test('a resumed bearer that later gets 401 is never re-probed; the replacement is persisted', async () => { + const fake = await device(); + const bearer = await firstRun('later-401', fake); + const b = await daemon('later-401', fake); + expect((await b.get('/screenshot')).status).toBe(200); + + fake.relaunchExternally(); + const since = fake.requests.length; + expect((await b.get('/screenshot')).status).toBe(200); + expect(sentBearer(fake, bearer, since)).toHaveLength(1); + expect(sentBearer(fake, bearer, since)[0]!.path).toBe('/screenshot'); + expect(persistedBearer(join(dir, 'later-401.session.json'))).not.toBe(bearer); + expect(terminations(fake)).toBe(0); + }); + + test('another device at the same address: the old bearer is never sent to it', async () => { + const fake = await device({ udid: 'PHONE-UDID' }); + const bearer = await firstRun('replaced', fake); + fake.replaceDevice('PAD-UDID', 'iPad'); + + const b = await daemon('replaced', fake); + expect((await b.get('/screenshot')).status).toBe(200); + const pad = fake.requests.filter((r) => r.udid === 'PAD-UDID'); + expect(pad.length).toBeGreaterThan(0); + expect(pad.some((r) => r.authorization === bearer)).toBe(false); + expect(b.logs.some((l) => l.includes('target device changed from PHONE-UDID to PAD-UDID'))).toBe(true); + }); + + test('a session bound to another UDID or app is not sent anywhere', async () => { + const fake = await device(); + const bearer = await firstRun('binding', fake); + const path = join(dir, 'binding.session.json'); + const since = fake.requests.length; + + const pinned = await daemon('binding-udid', fake, { udid: 'OTHER-UDID', sessionPath: path }); + expect((await pinned.get('/screenshot')).status).toBe(503); + expect(sentBearer(fake, bearer, since)).toHaveLength(0); + expect(pinned.logs.some((l) => l.includes('persisted session belongs to com.test.session on FAKE-UDID-1'))).toBe(true); + expect(existsSync(path)).toBe(false); + }); + + test('a StateServer now owned by another app never receives the bearer', async () => { + const fake = await device({ bundleId: 'com.other.app' }); + fake.relaunchExternally(); + const path = join(dir, 'wrong-app.session.json'); + writeSession({ udid: fake.udid, ipv6Addr: '::1', port: fake.port, bootTokenRotated: 'persisted-secret' }, BUNDLE, path); + + const b = await daemon('wrong-app', fake, { sessionPath: path }); + expect((await b.get('/screenshot')).status).toBe(503); + expect(sentBearer(fake, 'Bearer persisted-secret')).toHaveLength(0); + expect(b.logs.some((l) => l.includes('now belongs to com.other.app'))).toBe(true); + }); + + test('no devicectl address for the persisted UDID: the bearer is not sent to a fallback address', async () => { + const fake = await device(); + const bearer = await firstRun('no-address', fake); + const since = fake.requests.length; + const noDetails: SpawnImpl = (cmd, args) => (args.join(' ').includes('info details') + ? fake.spawn(cmd, ['unsupported']) + : fake.spawn(cmd, args)); + + const b = await daemon('no-address', fake, { spawn: noDetails }); + expect((await b.get('/screenshot')).status).toBe(200); + expect(sentBearer(fake, bearer, since)).toHaveLength(0); + expect(terminations(fake)).toBe(1); + expect(b.logs.some((l) => l.includes('devicectl reported no tunnel address for FAKE-UDID-1'))).toBe(true); + }); + + test('an insecure session file is removed unused and replaced by a private one', async () => { + const fake = await device(); + const bearer = await firstRun('insecure', fake); + const path = join(dir, 'insecure.session.json'); + chmodSync(path, 0o644); + const since = fake.requests.length; + + const b = await daemon('insecure', fake); + expect((await b.get('/screenshot')).status).toBe(200); + expect(sentBearer(fake, bearer, since)).toHaveLength(0); + expect(b.logs.some((l) => l.includes('because other users can access it (mode 644)'))).toBe(true); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + test('device momentarily unreachable: the file is kept and the next request resumes', async () => { + const fake = await device(); + const bearer = await firstRun('blip', fake); + const path = join(dir, 'blip.session.json'); + + const b = await daemon('blip', fake); + fake.dropConnections(1); + expect((await b.get('/screenshot')).status).toBe(503); + expect(persistedBearer(path)).toBe(bearer); + expect(b.logs.some((l) => l.includes(`kept ${path} for the next request`))).toBe(true); + + expect((await b.get('/screenshot')).status).toBe(200); + expect(fake.requests.at(-1)?.authorization).toBe(bearer); + expect(terminations(fake)).toBe(0); + expect(bootTokenReads(fake)).toBe(1); + }, 15_000); +}); diff --git a/package.json b/package.json index 08a5766d7..988a9d893 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.170", + "version": "1.91.171", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",