Files
gstack/ios-qa/daemon/src/session-cache.ts
T
Garry Tanandcapy-ai[bot] d875313071 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>
2026-10-20 20:23:27 +00:00

115 lines
4.6 KiB
TypeScript

// 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 },
);
}