Skill Tracer
Skills › gstack › ios-qa › index.ts

index.ts

735 lines · ios-qa/daemon/src/index.ts at 3f79d81

1// gstack-ios-qa-daemon entrypoint.
2//
3// Two listeners:
4// - Loopback (127.0.0.1 + ::1): full command surface for the spawning agent.
5// - Tailnet (optional, --tailnet flag): capability-tier allowlist.
6//
7// The tailnet listener is opened ONLY if:
8// 1. The user passed --tailnet at the CLI.
9// 2. The tailscaled LocalAPI socket probe succeeds (fail-closed otherwise).
10//
11// All tailnet ingress is auth-gated against the SessionTokenStore. Identity
12// validation uses tailscaled's WhoIs endpoint. Capability tiers come from
13// types.ts. Audit + attempts logging is in audit.ts.
15import { createServer, IncomingMessage, ServerResponse } from 'http';
16import { parse as parseUrl } from 'url';
17import { dirname } from 'path';
18import { tryClaim } from './single-instance';
19import { probeTailscale, whoIs } from './tailscale-localapi';
20import { SessionTokenStore } from './session-tokens';
21import { mintForCaller } from './auth-mint';
22import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
23import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit';
24import { bootstrapTunnel, recoverTunnel, type TunnelRecovery } from './tunnel-bootstrap';
25import { startTunnelKeepalive, type SpawnImpl, type ResolveImpl } from './devicectl';
26import { clearSession, defaultSessionPath, resumeSession, writeSession } from './session-cache';
27import type { Capability } from './types';
29interface DaemonOptions {
30 loopbackPort: number;
31 tailnetEnabled: boolean;
32 tailnetSocketPath?: string;
33 tailnetSessionTtlSeconds?: number;
34 pidfilePath?: string;
35 // Explicit security-log + allowlist paths. Default to the env-var-derived
36 // locations (defaultAuditPath etc.) when omitted, but passing them lets
37 // concurrent test instances stay isolated without racing on process.env.
38 auditPath?: string;
39 attemptsPath?: string;
40 allowlistPath?: string;
41 // Test injection
42 proxyTimeoutMs?: number;
43 tunnelProvider?: () => Promise<DeviceTunnel | null>;
44 /** Route-drop recovery (see recoverTunnel). Without it a route drop re-bootstraps. */
45 tunnelRecovery?: (failed: DeviceTunnel) => Promise<TunnelRecovery>;
46 whoIsImpl?: (addr: string) => Promise<{ identity: string; raw: unknown }>;
47 probeImpl?: () => Promise<{ ok: boolean; reason?: string; ownIdentity?: string }>;
50export interface RunningDaemon {
51 loopbackPort: number;
52 tailnetPort: number | null;
53 tokenStore: SessionTokenStore;
54 close: () => Promise<void>;
57export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon | { error: string; reason?: string }> {
58 // 1. Single-instance enforcement.
59 const claim = await tryClaim({ port: opts.loopbackPort, path: opts.pidfilePath });
60 if (!claim.claimed) {
61 // Existing daemon — print READY with the existing port and exit.
62 // The spawnAndWaitReady caller will receive this and connect to the
63 // existing port instead.
64 process.stdout.write(`READY: port=${claim.existing.port} pid=${claim.existing.pid}\n`);
65 return { error: 'already_running', reason: `existing daemon pid=${claim.existing.pid}` };
66 }
68 const tokenStore = new SessionTokenStore();
69 let tunnel: DeviceTunnel | null = null;
70 let tunnelInFlight: Promise<DeviceTunnel | null> | null = null;
72 const getTunnel = async (): Promise<DeviceTunnel | null> => {
73 // A successful bootstrap consumes and deletes the app's one-shot boot
74 // token. Keep that rotated tunnel for this daemon's lifetime instead of
75 // trying to bootstrap it again on a timer. Failed attempts are not cached.
76 if (tunnel) return tunnel;
77 if (!opts.tunnelProvider) return null;
79 // Multiple first requests can arrive before bootstrap completes. Share
80 // one provider call so they cannot race through independent rotations.
81 if (!tunnelInFlight) {
82 tunnelInFlight = Promise.resolve()
83 .then(() => opts.tunnelProvider!())
84 .then((candidate) => {
85 if (candidate) tunnel = candidate;
86 return candidate;
87 })
88 .finally(() => {
89 tunnelInFlight = null;
90 });
91 }
92 return tunnelInFlight;
93 };
95 const invalidateTunnel = (failedTunnel: DeviceTunnel): void => {
96 // A late response from the old app must not evict a tunnel that another
97 // request has already refreshed. Object identity gives each bootstrap a
98 // cheap generation token without exposing generation state elsewhere.
99 if (tunnel === failedTunnel) tunnel = null;
100 };
102 // Every request that failed on the same cached tunnel shares one refresh,
103 // so concurrent failures cause one probe or one bootstrap, never several.
104 let refreshInFlight: { failed: DeviceTunnel; promise: Promise<DeviceTunnel | null> } | null = null;
105 const refreshTunnel = (failedTunnel: DeviceTunnel, cause: RefreshCause): Promise<DeviceTunnel | null> => {
106 if (tunnel && tunnel !== failedTunnel) return Promise.resolve(tunnel);
107 if (refreshInFlight?.failed === failedTunnel) return refreshInFlight.promise;
108 if (!tunnel && tunnelInFlight) return tunnelInFlight;
110 const promise: Promise<DeviceTunnel | null> = (async () => {
111 // A route drop leaves the app and its in-memory bearer alive. Probe it
112 // first: a full bootstrap would have to relaunch the app (the one-shot
113 // boot-token file is gone) and wipe its QA state. A 401 skips this.
114 if (cause === 'route_lost' && opts.tunnelRecovery) {
115 const recovery = await opts.tunnelRecovery(failedTunnel);
116 if (recovery.action === 'unavailable') return null;
117 if (recovery.action === 'reuse') {
118 if (tunnel === failedTunnel) tunnel = recovery.tunnel;
119 return tunnel ?? recovery.tunnel;
120 }
121 }
122 invalidateTunnel(failedTunnel);
123 return getTunnel();
124 })().finally(() => {
125 if (refreshInFlight?.promise === promise) refreshInFlight = null;
126 });
127 refreshInFlight = { failed: failedTunnel, promise };
128 return promise;
129 };
131 // 2. Tailnet probe (fail-closed).
132 const probe = opts.tailnetEnabled
133 ? (opts.probeImpl ? await opts.probeImpl() : await probeTailscale(opts.tailnetSocketPath))
134 : null;
136 if (opts.tailnetEnabled && (!probe || !probe.ok)) {
137 process.stderr.write(`tailnet binding refused: ${probe?.reason ?? 'probe_failed'}\n`);
138 // Loopback still runs.
139 }
141 // 3. Loopback listener (full surface).
142 const loopbackServer = createServer(async (req, res) => {
143 await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, proxyTimeoutMs: opts.proxyTimeoutMs });
144 });
145 // Use port 0 for OS-assigned port when test/random port collisions are a risk.
146 const requestedPort = opts.loopbackPort;
147 await listenAsync(loopbackServer, requestedPort, '127.0.0.1');
148 const actualPort = (loopbackServer.address() as { port: number }).port;
150 // ipv6 — bind a SECOND server to ::1 on the same actualPort. In test (port 0)
151 // mode this can collide; we try the actualPort first and skip ipv6 if it
152 // fails (tests don't exercise ::1 explicitly).
153 const loopbackServerV6 = createServer(async (req, res) => {
154 await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, proxyTimeoutMs: opts.proxyTimeoutMs });
155 });
156 let v6Bound = false;
157 try {
158 await listenAsync(loopbackServerV6, actualPort, '::1');
159 v6Bound = true;
160 } catch {
161 // IPv6 loopback bind failed (port collision or no v6 on host). Loopback
162 // IPv4 already serves the spawning agent. Continue.
163 }
165 // 4. Tailnet listener (if probe succeeded).
166 let tailnetServer: ReturnType<typeof createServer> | null = null;
167 let tailnetPort: number | null = null;
168 if (opts.tailnetEnabled && probe?.ok) {
169 tailnetServer = createServer(async (req, res) => {
170 await handleTailnet({
171 req,
172 res,
173 tokenStore,
174 getTunnel,
175 invalidateTunnel,
176 refreshTunnel,
177 proxyTimeoutMs: opts.proxyTimeoutMs,
178 auditPath: opts.auditPath,
179 attemptsPath: opts.attemptsPath,
180 allowlistPath: opts.allowlistPath,
181 whoIsImpl: opts.whoIsImpl ?? ((addr) => whoIs(addr, opts.tailnetSocketPath)),
182 });
183 });
184 const tailnetBindAddr = process.env.GSTACK_IOS_TAILNET_BIND ?? '127.0.0.1';
185 // For tailnet port: actualPort + 1 if specified, else port 0 (OS-assigned).
186 const requestedTailnetPort = requestedPort === 0 ? 0 : actualPort + 1;
187 await listenAsync(tailnetServer, requestedTailnetPort, tailnetBindAddr);
188 tailnetPort = (tailnetServer.address() as { port: number }).port;
189 }
191 // 5. READY line.
192 process.stdout.write(`READY: port=${actualPort} pid=${process.pid}\n`);
194 return {
195 loopbackPort: actualPort,
196 tailnetPort,
197 tokenStore,
198 close: async () => {
199 // Force-close any open connections (keep-alive sockets) before waiting
200 // for the listening socket itself. Otherwise close() hangs forever on
201 // idle clients.
202 const closeAll = (s: ReturnType<typeof createServer> | null | undefined) => {
203 if (!s) return Promise.resolve();
204 (s as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
205 (s as unknown as { closeIdleConnections?: () => void }).closeIdleConnections?.();
206 return new Promise<void>((resolve) => s.close(() => resolve()));
207 };
208 await Promise.all([
209 closeAll(loopbackServer),
210 v6Bound ? closeAll(loopbackServerV6) : Promise.resolve(),
211 closeAll(tailnetServer),
212 ]);
213 await claim.release();
214 },
215 };
218function listenAsync(server: ReturnType<typeof createServer>, port: number, host: string): Promise<void> {
219 return new Promise((resolve, reject) => {
220 const onError = (err: Error) => {
221 server.off('listening', onListening);
222 reject(err);
223 };
224 const onListening = () => {
225 server.off('error', onError);
226 resolve();
227 };
228 server.once('error', onError);
229 server.once('listening', onListening);
230 server.listen(port, host);
231 });
234// ───────── Handlers ─────────
236interface HandlerCtx {
237 req: IncomingMessage;
238 res: ServerResponse;
239 tokenStore: SessionTokenStore;
240 getTunnel: () => Promise<DeviceTunnel | null>;
241 invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
242 refreshTunnel: (failedTunnel: DeviceTunnel, cause: RefreshCause) => Promise<DeviceTunnel | null>;
243 proxyTimeoutMs?: number;
244 // Explicit security-log + allowlist paths (default to env-derived when undefined).
245 auditPath?: string;
246 attemptsPath?: string;
247 allowlistPath?: string;
250type DeviceProxyResponse = Awaited<ReturnType<typeof proxyToDevice>>;
251type RefreshCause = 'unauthorized' | 'route_lost';
252const RECOVERABLE_SOCKET_ERRORS = new Set([
253 'ECONNABORTED',
254 'ECONNREFUSED',
255 'ECONNRESET',
256 'EHOSTUNREACH',
257 'ENETUNREACH',
258 'EPIPE',
259 'ETIMEDOUT',
260]);
262function localProxyError(status: number, error: string): DeviceProxyResponse {
263 const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
264 return {
265 status,
266 headers: { 'content-type': 'application/json', 'content-length': String(body.length) },
267 body,
268 };
271async function proxyAttempt(opts: Parameters<typeof proxyToDevice>[0]): Promise<DeviceProxyResponse> {
272 try {
273 return await proxyToDevice(opts);
274 } catch (err) {
275 const code = (err as { code?: string }).code;
276 // CoreDevice can surface the same stale route as several different socket
277 // failures while an app is being relaunched or replaced. Normalize those
278 // failures so the cache recovery path below can handle all of them.
279 if (code && RECOVERABLE_SOCKET_ERRORS.has(code)) {
280 return localProxyError(503, 'device_disconnected');
281 }
282 throw err;
283 }
286function shouldRefreshTunnel(upstream: DeviceProxyResponse): boolean {
287 // A relaunched app has a new in-memory bearer and rejects the daemon's old
288 // rotated token: that needs a fresh bootstrap. A blip or redeploy can
289 // instead leave the CoreDevice route refusing connections or timing out:
290 // that goes through route-drop recovery first.
291 if (upstream.status === 401) return true;
292 if (upstream.status !== 503 && upstream.status !== 504) return false;
293 try {
294 const body = JSON.parse(upstream.body.toString('utf-8')) as { error?: string };
295 return body.error === 'device_disconnected' || body.error === 'upstream_timeout';
296 } catch {
297 return false;
298 }
301function canReplayAfterRefresh(inbound: IncomingMessage, upstream: DeviceProxyResponse): boolean {
302 // A 401 proves the stale bearer was rejected before StateServer dispatched
303 // the operation, so retrying is safe even for a mutation. Connection loss
304 // is ambiguous: the app may have applied a tap/write before its response was
305 // lost. Replay only read-only requests in that case to prevent double taps.
306 if (upstream.status === 401) return true;
307 const method = inbound.method ?? 'GET';
308 return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
311async function proxyWithTunnelRecovery(opts: {
312 inbound: IncomingMessage;
313 body: Buffer;
314 sessionId: string | null;
315 agentIdentity?: string;
316 getTunnel: HandlerCtx['getTunnel'];
317 invalidateTunnel: HandlerCtx['invalidateTunnel'];
318 refreshTunnel: HandlerCtx['refreshTunnel'];
319 timeoutMs?: number;
320}): Promise<{ tunnel: DeviceTunnel; upstream: DeviceProxyResponse } | null> {
321 let tunnel = await opts.getTunnel();
322 if (!tunnel) return null;
324 const makeAttempt = (candidate: DeviceTunnel) => proxyAttempt({
325 inbound: opts.inbound,
326 body: opts.body,
327 tunnel: candidate,
328 sessionId: opts.sessionId,
329 agentIdentity: opts.agentIdentity,
330 timeoutMs: opts.timeoutMs,
331 });
333 let upstream = await makeAttempt(tunnel);
334 if (!shouldRefreshTunnel(upstream)) return { tunnel, upstream };
336 const failedTunnel = tunnel;
337 const replaySafe = canReplayAfterRefresh(opts.inbound, upstream);
338 const refreshed = await opts.refreshTunnel(tunnel, upstream.status === 401 ? 'unauthorized' : 'route_lost');
339 if (!refreshed) return replaySafe ? null : { tunnel: failedTunnel, upstream };
341 // The replacement is now cached for the next request, but never replay an
342 // ambiguous mutation whose response was lost: doing so could double-tap or
343 // apply a state transition twice.
344 if (!replaySafe) return { tunnel: failedTunnel, upstream };
346 tunnel = refreshed;
347 upstream = await makeAttempt(tunnel);
348 // Do not loop forever if the replacement is itself unavailable. A rejected
349 // bearer empties the cache so the next request bootstraps; a route that is
350 // still down keeps it so the next request probes again instead.
351 if (upstream.status === 401) opts.invalidateTunnel(tunnel);
352 return { tunnel, upstream };
355function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise<Buffer | { error: 'body_too_large' }> {
356 return new Promise((resolve, reject) => {
357 const chunks: Buffer[] = [];
358 let total = 0;
359 let overLimit = false;
360 req.on('data', (chunk: Buffer) => {
361 total += chunk.length;
362 if (total > maxBytes && !overLimit) {
363 overLimit = true;
364 }
365 if (!overLimit) chunks.push(chunk);
366 });
367 req.on('end', () => {
368 if (overLimit) {
369 resolve({ error: 'body_too_large' });
370 } else {
371 resolve(Buffer.concat(chunks));
372 }
373 });
374 req.on('error', (err) => {
375 // Resolve with empty body if upstream cut us off after limit hit.
376 if (overLimit) resolve({ error: 'body_too_large' });
377 else reject(err);
378 });
379 });
382function sendJson(res: ServerResponse, status: number, body: unknown): void {
383 const payload = JSON.stringify(body, sanitizeReplacer);
384 res.writeHead(status, {
385 'content-type': 'application/json',
386 'content-length': Buffer.byteLength(payload),
387 });
388 res.end(payload);
391/**
392 * Loopback handler — full surface for the spawning agent. No auth (the
393 * loopback bind itself is the boundary).
394 */
395async function handleLoopback(ctx: HandlerCtx): Promise<void> {
396 const { req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel } = ctx;
397 const url = parseUrl(req.url ?? '/');
398 const path = url.pathname ?? '/';
399 const method = req.method ?? 'GET';
401 try {
402 // /healthz — public on loopback.
403 if (method === 'GET' && path === '/healthz') {
404 sendJson(res, 200, { version: '1.0.0', mode: 'loopback' });
405 return;
406 }
408 // /auth/sessions — list active sessions (owner only). Raw token values
409 // never leave the store: any local process can hit this listener, so a
410 // list that echoed live bearer tokens was a harvest-and-replay primitive.
411 // Callers get a salted-hash id plus metadata; revoke by identity, by the
412 // token they already hold from mint, or by token_id from this list.
413 if (method === 'GET' && path === '/auth/sessions') {
414 const sessions = await Promise.all(tokenStore.list().map(async ({ token, ...meta }) => ({
415 token_id: await saltedHash(token),
416 ...meta,
417 })));
418 sendJson(res, 200, { sessions });
419 return;
420 }
422 // /auth/revoke — revoke by raw token (the caller's own, from mint), by
423 // token_id (from /auth/sessions — keeps the list→revoke workflow alive
424 // now that the list is hash-only), or by identity.
425 if (method === 'POST' && path === '/auth/revoke') {
426 const body = await readBody(req);
427 if ('error' in body) { sendJson(res, 413, body); return; }
428 const parsed = JSON.parse(body.toString('utf-8') || '{}') as {
429 token?: string; token_id?: string; identity?: string;
430 };
431 let count = 0;
432 if (parsed.token) {
433 count = tokenStore.revoke(parsed.token) ? 1 : 0;
434 } else if (parsed.token_id) {
435 for (const s of tokenStore.list()) {
436 if ((await saltedHash(s.token)) === parsed.token_id) {
437 count += tokenStore.revoke(s.token) ? 1 : 0;
438 }
439 }
440 } else if (parsed.identity) {
441 count = tokenStore.revokeByIdentity(parsed.identity);
442 }
443 sendJson(res, 200, { revoked: count });
444 return;
445 }
447 // Other endpoints — proxy to the device.
448 const body = await readBody(req);
449 if ('error' in body) { sendJson(res, 413, body); return; }
450 const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
451 const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined;
452 const proxied = await proxyWithTunnelRecovery({
453 inbound: req,
454 body,
455 sessionId,
456 agentIdentity,
457 getTunnel,
458 invalidateTunnel,
459 refreshTunnel,
460 timeoutMs: ctx.proxyTimeoutMs,
461 });
462 if (!proxied) {
463 sendJson(res, 503, { error: 'device_not_connected' });
464 return;
465 }
466 const { upstream } = proxied;
467 res.writeHead(upstream.status, upstream.headers);
468 res.end(upstream.body);
469 } catch (err) {
470 sendJson(res, 500, { error: 'internal_error', detail: (err as Error).message });
471 }
474interface TailnetCtx extends HandlerCtx {
475 whoIsImpl: (addr: string) => Promise<{ identity: string; raw: unknown }>;
478/**
479 * Tailnet handler — locked allowlist + capability tiers.
480 */
481async function handleTailnet(ctx: TailnetCtx): Promise<void> {
482 const { req, res, tokenStore, getTunnel, invalidateTunnel, refreshTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
483 const url = parseUrl(req.url ?? '/');
484 const path = url.pathname ?? '/';
485 const method = req.method ?? 'GET';
486 const route = `${method} ${path}`;
488 try {
489 // Classify the route.
490 const classification = classifyRoute(method, path);
491 if (!classification.allowed) {
492 sendJson(res, 404, { error: 'endpoint_not_in_tailnet_allowlist', path });
493 return;
494 }
495 const requiredCapability = classification.requiredCapability as Capability;
497 // /healthz on tailnet requires auth (codex catch).
498 // No special-case; treated like every other observe-tier endpoint.
500 // /auth/mint — special path. No bearer required; uses WhoIs.
501 if (method === 'POST' && path === '/auth/mint') {
502 const peerAddr = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
503 let callerIdentity: string;
504 try {
505 const who = await whoIsImpl(peerAddr);
506 callerIdentity = who.identity;
507 } catch (err) {
508 await writeAttempt({
509 rawIdentity: peerAddr,
510 endpoint: route,
511 reason: 'whois_unparseable',
512 path: attemptsPath,
513 });
514 sendJson(res, 502, { error: 'whois_failed', detail: (err as Error).message });
515 return;
516 }
518 const body = await readBody(req);
519 if ('error' in body) { sendJson(res, 413, body); return; }
520 const parsed = JSON.parse(body.toString('utf-8') || '{}') as { capability?: Capability; device_udid?: string };
522 const result = await mintForCaller({
523 callerIdentity,
524 request: parsed,
525 tokenStore,
526 endpoint: route,
527 allowlistPath,
528 attemptsPath,
529 });
531 if ('error' in result) {
532 const status = result.error === 'rate_limited' ? 429 : 403;
533 sendJson(res, status, result);
534 return;
535 }
536 sendJson(res, 200, result);
537 return;
538 }
540 // All other endpoints: bearer auth + capability check.
541 const auth = req.headers['authorization'] as string | undefined;
542 const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null;
543 const validation = tokenStore.validate(token, requiredCapability);
544 if (!validation.ok) {
545 await writeAttempt({
546 rawIdentity: token ? 'token:' + token.slice(0, 8) : 'no_token',
547 endpoint: route,
548 reason: validation.reason,
549 path: attemptsPath,
550 });
551 const status = validation.reason === 'capability_insufficient' ? 403 : 401;
552 sendJson(res, status, { error: validation.reason });
553 return;
554 }
556 const session = validation.session;
558 // Read body once + enforce limit.
559 const body = await readBody(req);
560 if ('error' in body) { sendJson(res, 413, body); return; }
562 // Tailnet-only own-session revoke.
563 if (method === 'POST' && path === '/auth/revoke') {
564 tokenStore.revoke(session.token);
565 sendJson(res, 200, { revoked: 1 });
566 return;
567 }
569 // Proxy to device.
570 const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
571 const proxied = await proxyWithTunnelRecovery({
572 inbound: req,
573 body,
574 sessionId,
575 agentIdentity: session.identity,
576 getTunnel,
577 invalidateTunnel,
578 refreshTunnel,
579 timeoutMs: ctx.proxyTimeoutMs,
580 });
581 if (!proxied) {
582 sendJson(res, 503, { error: 'device_not_connected' });
583 return;
584 }
585 const { tunnel, upstream } = proxied;
587 // Audit the action (mutating endpoints only).
588 if (requiredCapability !== 'observe') {
589 await writeAudit({
590 ts: new Date().toISOString(),
591 identity: session.identity,
592 device_udid: tunnel.udid,
593 endpoint: route,
594 session_id: sessionId ?? '-',
595 capability: session.capability,
596 request_id: req.headers['x-request-id']?.toString() ?? '-',
597 status: upstream.status,
598 }, auditPath);
599 }
601 res.writeHead(upstream.status, upstream.headers);
602 res.end(upstream.body);
603 } catch (err) {
604 sendJson(res, 500, { error: 'internal_error', detail: (err as Error).message });
605 }
608/**
609 * Real-device tunnel wiring the CLI uses: bootstrap with the CoreDevice
610 * keepalive, plus route-drop recovery bound to the same target. The first
611 * tunnel request of a daemon process resumes the bearer an earlier daemon
612 * persisted at `sessionPath` (see session-cache.ts) instead of relaunching the
613 * app. Exported so tests drive exactly this wiring against a simulated device.
614 */
615export function deviceTunnelSource(opts: {
616 udid?: string;
617 bundleId: string;
618 port?: number;
619 sessionPath: string;
620 spawnImpl?: SpawnImpl;
621 resolveImpl?: ResolveImpl;
622 fetchImpl?: typeof fetch;
623 keepalive?: (udid: string) => { stop: () => void };
624 log?: (line: string) => void;
625}): {
626 tunnelProvider: () => Promise<DeviceTunnel | null>;
627 tunnelRecovery: (failed: DeviceTunnel) => Promise<TunnelRecovery>;
628 stop: () => void;
629} {
630 const log = opts.log ?? ((line: string) => process.stderr.write(`${line}\n`));
631 const keepaliveFor = opts.keepalive ?? ((udid: string) => startTunnelKeepalive(udid));
632 const port = opts.port ?? 9999;
633 let keepalive: { stop: () => void } | null = null;
634 // Later provider calls follow a failure of this process's own tunnel, so the
635 // persisted bearer is either the one that just failed or already replaced.
636 let resumable = true;
637 const keep = (tunnel: DeviceTunnel): DeviceTunnel => {
638 keepalive?.stop();
639 keepalive = keepaliveFor(tunnel.udid);
640 return tunnel;
641 };
642 return {
643 tunnelProvider: async () => {
644 if (resumable) {
645 const resumed = await resumeSession(opts.sessionPath, {
646 udid: opts.udid,
647 bundleId: opts.bundleId,
648 port,
649 spawnImpl: opts.spawnImpl,
650 resolveImpl: opts.resolveImpl,
651 fetchImpl: opts.fetchImpl,
652 });
653 if (resumed.action === 'unavailable') {
654 log(`session resume: ${resumed.reason}; kept ${opts.sessionPath} for the next request`);
655 return null;
656 }
657 resumable = false;
658 if (resumed.action === 'reuse') {
659 log(`session resumed: reused the persisted bearer for ${resumed.tunnel.udid} without relaunching ${opts.bundleId}`);
660 return keep(resumed.tunnel);
661 }
662 log(`session resume: ${resumed.reason}; bootstrapping`);
663 }
664 clearSession(opts.sessionPath);
665 const result = await bootstrapTunnel({
666 udid: opts.udid,
667 bundleId: opts.bundleId,
668 port,
669 spawnImpl: opts.spawnImpl,
670 resolveImpl: opts.resolveImpl,
671 fetchImpl: opts.fetchImpl,
672 });
673 if (!result.ok) {
674 log(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}`);
675 return null;
676 }
677 try {
678 writeSession(result.tunnel, opts.bundleId, opts.sessionPath);
679 } catch (err) {
680 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.`);
681 }
682 return keep(result.tunnel);
683 },
684 tunnelRecovery: async (failed) => {
685 const recovery = await recoverTunnel(failed, {
686 udid: opts.udid,
687 bundleId: opts.bundleId,
688 spawnImpl: opts.spawnImpl,
689 resolveImpl: opts.resolveImpl,
690 fetchImpl: opts.fetchImpl,
691 });
692 if (recovery.action === 'reuse') log(`tunnel recovered: kept the session at [${recovery.tunnel.ipv6Addr}]:${recovery.tunnel.port}`);
693 else if (recovery.action === 'bootstrap') log(`tunnel recovery: ${recovery.reason}; bootstrapping`);
694 else log(`tunnel recovery: ${recovery.reason}`);
695 return recovery;
696 },
697 stop: () => {
698 keepalive?.stop();
699 keepalive = null;
700 },
701 };
704// CLI entry — runs when this file is executed directly, not when imported.
705if (import.meta.main) {
706 const port = parseInt(process.env.GSTACK_IOS_DAEMON_PORT ?? '9099', 10);
707 const tailnet = process.argv.includes('--tailnet');
708 const targetUDID = process.env.GSTACK_IOS_TARGET_UDID;
709 const bundleId = process.env.GSTACK_IOS_TARGET_BUNDLE_ID ?? 'com.gstack.iosqa.fixture';
711 // Bootstrap a real CoreDevice tunnel to GSTACK_IOS_TARGET_UDID, or to the
712 // single best connected paired iPhone/iPad. With no device the provider
713 // returns null and the proxy answers 503 device_not_connected. Xcode 26's
714 // CoreDevice only holds the tunnel up while a devicectl command is in
715 // flight, so a successful bootstrap also starts a periodic keepalive.
716 const source = deviceTunnelSource({ udid: targetUDID, bundleId, sessionPath: defaultSessionPath() });
717 process.on('SIGINT', source.stop);
718 process.on('SIGTERM', source.stop);
719 process.on('exit', source.stop);
721 startDaemon({
722 loopbackPort: port,
723 tailnetEnabled: tailnet,
724 tunnelProvider: source.tunnelProvider,
725 tunnelRecovery: source.tunnelRecovery,
726 }).then((d) => {
727 if ('error' in d) {
728 process.stderr.write(`daemon error: ${d.error}\n`);
729 process.exit(0); // exit 0 because READY was already printed
730 }
731 }).catch((err) => {
732 process.stderr.write(`daemon fatal: ${(err as Error).message}\n`);
733 process.exit(1);
734 });