v1.91.171.0 fix(ios-qa): a restarted daemon resumes the device session instead of relaunching the app (#3132)

* fix(ios-qa): a restarted daemon resumes the persisted session bearer instead of relaunching the app

The boot token is one-use, so a new daemon process could only get back in by
relaunching the app with --terminate-existing, wiping its QA state (#1796).
The daemon now persists the rotated bearer to
$GSTACK_STATE_ROOT/ios-qa-session.json (0600, fresh temp file + rename),
bound to UDID, bundle ID and port, and resumes it once per process through
recoverTunnel's new pinned-address mode: devicectl-reported address only,
/healthz bundle check first. Mismatched binding, a foreign device or app, no
devicectl address, or a non-private file bootstraps without sending the
bearer. The file is cleared before every bootstrap, so a rejected bearer is
never replayed. /ios-qa Phase 0 now describes the real file.

Diagnosis and reuse scenarios from #1796 by @Bmathews721.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: take draft #3132 off the version slot (VERSION back to main's 1.91.84.0, CHANGELOG [Unreleased])

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.171.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-20 20:23:27 +00:00
1 parent 5de607f26c
commit d875313071
11 files changed
+475 -34

No files matched your search

+6
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.91.170.0
1.91.171.0
+1 -1
View File
@@ -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,
+16 -12
View File
@@ -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
```
+16 -12
View File
@@ -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
```
+45 -7
View File
@@ -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<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.
* 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);
+114
View File
@@ -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<PersistedSession>;
try {
parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<PersistedSession>;
} 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<RecoveryOptions, 'pinnedAddressOnly'> & { port: number },
): Promise<TunnelRecovery> {
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 },
);
}
+9
View File
@@ -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) {
@@ -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),
+265
View File
@@ -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<void>> = [];
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);
});
+1 -1
View File
@@ -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",