mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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:
1 parent
5de607f26c
commit
d875313071
11 files changed
+475
-34
No files matched your search
@@ -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,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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user