feat(ios): Mac-side daemon (bun/TS) for Tailscale identity gating + USB proxy

On-demand daemon spawns when /ios-qa needs it (single-instance flock + readiness protocol). Owns tailnet ingress: fail-closed tailscaled LocalAPI probe, dual-track /auth/mint (self-service for allowlisted identities, owner-granted via CLI), capability-tier allowlist (observe/interact/mutate/restore), 1h default session TTL (24h hard cap), audit log of every authenticated mutating tailnet request, hashed-identity attempts log. iOS StateServer never directly binds tailnet — identity validation lives Mac-side because iPhones can't reach tailscaled. 67 unit/integration tests covering session-lock concurrency, capability enforcement, fail-closed probe, identity canonicalization, body limits, and boot-token leak proofs.
This commit is contained in:
Garry Tan
2026-05-17 19:06:01 -07:00
parent 9ffde24474
commit 3126363c2c
17 changed files with 2376 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
// Allowlist file at ~/.gstack/ios-qa-allowlist.json. The single source of
// truth for who can call what at which capability tier.
//
// Self-service mint over tailnet ONLY succeeds for identities present in the
// allowlist. Owner-granted mint (CLI on the Mac) is what writes new entries
// to the allowlist. Self-service mint NEVER auto-allowlists.
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import type { Allowlist, AllowlistEntry, Capability } from './types';
import { capabilityCovers } from './types';
export function defaultAllowlistPath(): string {
return process.env.GSTACK_IOS_ALLOWLIST_PATH
?? join(homedir(), '.gstack', 'ios-qa-allowlist.json');
}
export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise<Allowlist> {
try {
const raw = await readFile(path, 'utf-8');
const parsed = JSON.parse(raw) as Allowlist;
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
throw new Error('invalid_allowlist');
}
return parsed;
} catch (err: unknown) {
const e = err as { code?: string };
if (e.code === 'ENOENT') {
return { version: 1, entries: [] };
}
throw err;
}
}
export async function saveAllowlist(allowlist: Allowlist, path: string = defaultAllowlistPath()): Promise<void> {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFile(path, JSON.stringify(allowlist, null, 2) + '\n', { mode: 0o600 });
}
/**
* Look up an identity in the allowlist. Returns the entry if present AND
* not expired. Lookup is exact-match on canonicalized identity.
*/
export function findEntry(allowlist: Allowlist, identity: string): AllowlistEntry | null {
const now = Date.now();
for (const entry of allowlist.entries) {
if (entry.identity !== identity) continue;
if (entry.expires_at) {
const exp = Date.parse(entry.expires_at);
if (Number.isFinite(exp) && exp < now) continue;
}
return entry;
}
return null;
}
/**
* Check whether an identity has at least the requested capability tier.
* Returns false on missing/expired entries OR insufficient tier.
*/
export function hasCapability(allowlist: Allowlist, identity: string, need: Capability): boolean {
const entry = findEntry(allowlist, identity);
if (!entry) return false;
return entry.capabilities.some(c => capabilityCovers(c, need));
}
/**
* Owner-granted mint path. Adds (or upgrades) an allowlist entry.
*/
export async function grantIdentity(opts: {
identity: string;
capability: Capability;
ttlSeconds?: number | null; // null/undefined = no expiry
note?: string;
path?: string;
}): Promise<Allowlist> {
const path = opts.path ?? defaultAllowlistPath();
const allowlist = await loadAllowlist(path);
const existingIdx = allowlist.entries.findIndex(e => e.identity === opts.identity);
const expiresAt = opts.ttlSeconds && opts.ttlSeconds > 0
? new Date(Date.now() + opts.ttlSeconds * 1000).toISOString()
: null;
const newEntry: AllowlistEntry = {
identity: opts.identity,
capabilities: [opts.capability],
expires_at: expiresAt,
note: opts.note,
};
if (existingIdx >= 0) {
allowlist.entries[existingIdx] = newEntry;
} else {
allowlist.entries.push(newEntry);
}
await saveAllowlist(allowlist, path);
return allowlist;
}
/**
* Revoke an identity from the allowlist.
*/
export async function revokeIdentity(identity: string, path: string = defaultAllowlistPath()): Promise<Allowlist> {
const allowlist = await loadAllowlist(path);
allowlist.entries = allowlist.entries.filter(e => e.identity !== identity);
await saveAllowlist(allowlist, path);
return allowlist;
}
+91
View File
@@ -0,0 +1,91 @@
// Audit + attempts logging. Reuses the same rotation primitives as
// browse/src/tunnel-denial-log.ts (10MB rotation, 5 generations).
import { mkdir, appendFile, stat, rename, readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { createHash } from 'crypto';
import type { AuditRow, AttemptRow } from './types';
const MAX_BYTES = 10 * 1024 * 1024;
const MAX_GENS = 5;
export function defaultAuditPath(): string {
return process.env.GSTACK_IOS_AUDIT_PATH
?? join(homedir(), '.gstack', 'security', 'ios-qa-audit.jsonl');
}
export function defaultAttemptsPath(): string {
return process.env.GSTACK_IOS_ATTEMPTS_PATH
?? join(homedir(), '.gstack', 'security', 'attempts.jsonl');
}
let _saltCache: string | null = null;
async function loadDeviceSalt(): Promise<string> {
if (_saltCache) return _saltCache;
const path = join(homedir(), '.gstack', 'security', 'device-salt');
try {
_saltCache = (await readFile(path, 'utf-8')).trim();
} catch {
// No salt; generate ephemeral. Real install writes one via /setup.
const { randomBytes } = await import('crypto');
_saltCache = randomBytes(32).toString('hex');
}
return _saltCache!;
}
async function rotateIfNeeded(path: string): Promise<void> {
try {
const s = await stat(path);
if (s.size < MAX_BYTES) return;
} catch {
return; // file doesn't exist yet
}
// Rotate: path → path.1 → path.2 → ... → path.MAX_GENS
for (let i = MAX_GENS - 1; i >= 0; i--) {
const src = i === 0 ? path : `${path}.${i}`;
const dst = `${path}.${i + 1}`;
try {
await rename(src, dst);
} catch {
// best-effort
}
}
}
export async function writeAudit(row: AuditRow, path: string = defaultAuditPath()): Promise<void> {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await rotateIfNeeded(path);
await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
}
export async function writeAttempt(opts: {
rawIdentity: string;
endpoint: string;
reason: AttemptRow['reason'];
path?: string;
}): Promise<void> {
const salt = await loadDeviceSalt();
const hash = createHash('sha256').update(salt + ':' + opts.rawIdentity).digest('hex').slice(0, 16);
const row: AttemptRow = {
ts: new Date().toISOString(),
identity_canon: hash,
endpoint: opts.endpoint,
reason: opts.reason,
};
const path = opts.path ?? defaultAttemptsPath();
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await rotateIfNeeded(path);
await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
}
// Sanitize-replacer for JSON responses — mirrors browse's sanitize-replacer.ts.
// Strips lone UTF-16 surrogate halves that would otherwise reach the
// Anthropic API as \uD800-style escapes and trigger 400.
export function sanitizeReplacer(_key: string, value: unknown): unknown {
if (typeof value !== 'string') return value;
// Replace lone high surrogates not followed by low surrogates, and lone
// low surrogates not preceded by high surrogates.
return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '');
}
+85
View File
@@ -0,0 +1,85 @@
// /auth/mint endpoint handler. Two trust models, kept distinct:
//
// 1. Self-service mint: caller's tailnet identity (from WhoIs) must already
// be in the allowlist. NEVER auto-allowlists.
// 2. Owner-granted mint: not on /auth/mint at all — that's the CLI
// `gstack-ios-qa-mint --remote <identity>` writing to the allowlist file.
import { SessionTokenStore } from './session-tokens';
import { hasCapability, loadAllowlist } from './allowlist';
import { writeAttempt } from './audit';
import type { Capability } from './types';
import { capabilityCovers } from './types';
export interface MintRequest {
capability?: Capability; // requested tier; default 'interact'
device_udid?: string;
}
export interface MintResponse {
session_token: string;
expires_at: number;
capability: Capability;
}
export interface MintError {
error: 'identity_not_allowed' | 'capability_insufficient' | 'rate_limited';
}
export async function mintForCaller(opts: {
callerIdentity: string;
request: MintRequest;
tokenStore: SessionTokenStore;
allowlistPath?: string;
endpoint?: string;
}): Promise<MintResponse | MintError> {
const allowlist = await loadAllowlist(opts.allowlistPath);
const wantedCap: Capability = opts.request.capability ?? 'interact';
// Must be in the allowlist.
if (!hasCapability(allowlist, opts.callerIdentity, 'observe')) {
await writeAttempt({
rawIdentity: opts.callerIdentity,
endpoint: opts.endpoint ?? '/auth/mint',
reason: 'identity_not_allowed',
});
return { error: 'identity_not_allowed' };
}
// Must have at least the requested capability.
if (!hasCapability(allowlist, opts.callerIdentity, wantedCap)) {
await writeAttempt({
rawIdentity: opts.callerIdentity,
endpoint: opts.endpoint ?? '/auth/mint',
reason: 'capability_insufficient',
});
return { error: 'capability_insufficient' };
}
// Find the entry to determine the highest tier they can hold.
const entry = allowlist.entries.find(e => e.identity === opts.callerIdentity);
// Mint at the requested tier, capped at the highest granted tier.
const grantedTier = entry?.capabilities.find(c => capabilityCovers(c, wantedCap)) ?? wantedCap;
const result = opts.tokenStore.mint({
identity: opts.callerIdentity,
capability: grantedTier,
deviceUdid: opts.request.device_udid ?? null,
origin: 'self_service',
});
if ('error' in result) {
await writeAttempt({
rawIdentity: opts.callerIdentity,
endpoint: opts.endpoint ?? '/auth/mint',
reason: 'rate_limited',
});
return { error: 'rate_limited' };
}
return {
session_token: result.token,
expires_at: result.expires_at,
capability: result.capability,
};
}
+410
View File
@@ -0,0 +1,410 @@
// gstack-ios-qa-daemon entrypoint.
//
// Two listeners:
// - Loopback (127.0.0.1 + ::1): full command surface for the spawning agent.
// - Tailnet (optional, --tailnet flag): capability-tier allowlist.
//
// The tailnet listener is opened ONLY if:
// 1. The user passed --tailnet at the CLI.
// 2. The tailscaled LocalAPI socket probe succeeds (fail-closed otherwise).
//
// All tailnet ingress is auth-gated against the SessionTokenStore. Identity
// validation uses tailscaled's WhoIs endpoint. Capability tiers come from
// types.ts. Audit + attempts logging is in audit.ts.
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { parse as parseUrl } from 'url';
import { tryClaim } from './single-instance';
import { probeTailscale, whoIs } from './tailscale-localapi';
import { SessionTokenStore } from './session-tokens';
import { mintForCaller } from './auth-mint';
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
import type { Capability } from './types';
interface DaemonOptions {
loopbackPort: number;
tailnetEnabled: boolean;
tailnetSocketPath?: string;
tailnetSessionTtlSeconds?: number;
pidfilePath?: string;
// Test injection
tunnelProvider?: () => Promise<DeviceTunnel | null>;
whoIsImpl?: (addr: string) => Promise<{ identity: string; raw: unknown }>;
probeImpl?: () => Promise<{ ok: boolean; reason?: string; ownIdentity?: string }>;
}
export interface RunningDaemon {
loopbackPort: number;
tailnetPort: number | null;
tokenStore: SessionTokenStore;
close: () => Promise<void>;
}
export async function startDaemon(opts: DaemonOptions): Promise<RunningDaemon | { error: string; reason?: string }> {
// 1. Single-instance enforcement.
const claim = await tryClaim({ port: opts.loopbackPort, path: opts.pidfilePath });
if (!claim.claimed) {
// Existing daemon — print READY with the existing port and exit.
// The spawnAndWaitReady caller will receive this and connect to the
// existing port instead.
process.stdout.write(`READY: port=${claim.existing.port} pid=${claim.existing.pid}\n`);
return { error: 'already_running', reason: `existing daemon pid=${claim.existing.pid}` };
}
const tokenStore = new SessionTokenStore();
let tunnel: DeviceTunnel | null = null;
let cachedTunnelAt = 0;
const getTunnel = async (): Promise<DeviceTunnel | null> => {
// Cache the tunnel for 30s; refresh on demand.
if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel;
if (opts.tunnelProvider) {
tunnel = await opts.tunnelProvider();
cachedTunnelAt = Date.now();
}
return tunnel;
};
// 2. Tailnet probe (fail-closed).
const probe = opts.tailnetEnabled
? (opts.probeImpl ? await opts.probeImpl() : await probeTailscale(opts.tailnetSocketPath))
: null;
if (opts.tailnetEnabled && (!probe || !probe.ok)) {
process.stderr.write(`tailnet binding refused: ${probe?.reason ?? 'probe_failed'}\n`);
// Loopback still runs.
}
// 3. Loopback listener (full surface).
const loopbackServer = createServer(async (req, res) => {
await handleLoopback({ req, res, tokenStore, getTunnel });
});
// Use port 0 for OS-assigned port when test/random port collisions are a risk.
const requestedPort = opts.loopbackPort;
await listenAsync(loopbackServer, requestedPort, '127.0.0.1');
const actualPort = (loopbackServer.address() as { port: number }).port;
// ipv6 — bind a SECOND server to ::1 on the same actualPort. In test (port 0)
// mode this can collide; we try the actualPort first and skip ipv6 if it
// fails (tests don't exercise ::1 explicitly).
const loopbackServerV6 = createServer(async (req, res) => {
await handleLoopback({ req, res, tokenStore, getTunnel });
});
let v6Bound = false;
try {
await listenAsync(loopbackServerV6, actualPort, '::1');
v6Bound = true;
} catch {
// IPv6 loopback bind failed (port collision or no v6 on host). Loopback
// IPv4 already serves the spawning agent. Continue.
}
// 4. Tailnet listener (if probe succeeded).
let tailnetServer: ReturnType<typeof createServer> | null = null;
let tailnetPort: number | null = null;
if (opts.tailnetEnabled && probe?.ok) {
tailnetServer = createServer(async (req, res) => {
await handleTailnet({
req,
res,
tokenStore,
getTunnel,
whoIsImpl: opts.whoIsImpl ?? ((addr) => whoIs(addr, opts.tailnetSocketPath)),
});
});
const tailnetBindAddr = process.env.GSTACK_IOS_TAILNET_BIND ?? '127.0.0.1';
// For tailnet port: actualPort + 1 if specified, else port 0 (OS-assigned).
const requestedTailnetPort = requestedPort === 0 ? 0 : actualPort + 1;
await listenAsync(tailnetServer, requestedTailnetPort, tailnetBindAddr);
tailnetPort = (tailnetServer.address() as { port: number }).port;
}
// 5. READY line.
process.stdout.write(`READY: port=${actualPort} pid=${process.pid}\n`);
return {
loopbackPort: actualPort,
tailnetPort,
tokenStore,
close: async () => {
// Force-close any open connections (keep-alive sockets) before waiting
// for the listening socket itself. Otherwise close() hangs forever on
// idle clients.
const closeAll = (s: ReturnType<typeof createServer> | null | undefined) => {
if (!s) return Promise.resolve();
(s as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
(s as unknown as { closeIdleConnections?: () => void }).closeIdleConnections?.();
return new Promise<void>((resolve) => s.close(() => resolve()));
};
await Promise.all([
closeAll(loopbackServer),
v6Bound ? closeAll(loopbackServerV6) : Promise.resolve(),
closeAll(tailnetServer),
]);
await claim.release();
},
};
}
function listenAsync(server: ReturnType<typeof createServer>, port: number, host: string): Promise<void> {
return new Promise((resolve, reject) => {
const onError = (err: Error) => {
server.off('listening', onListening);
reject(err);
};
const onListening = () => {
server.off('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, host);
});
}
// ───────── Handlers ─────────
interface HandlerCtx {
req: IncomingMessage;
res: ServerResponse;
tokenStore: SessionTokenStore;
getTunnel: () => Promise<DeviceTunnel | null>;
}
function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise<Buffer | { error: 'body_too_large' }> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
let overLimit = false;
req.on('data', (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes && !overLimit) {
overLimit = true;
}
if (!overLimit) chunks.push(chunk);
});
req.on('end', () => {
if (overLimit) {
resolve({ error: 'body_too_large' });
} else {
resolve(Buffer.concat(chunks));
}
});
req.on('error', (err) => {
// Resolve with empty body if upstream cut us off after limit hit.
if (overLimit) resolve({ error: 'body_too_large' });
else reject(err);
});
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body, sanitizeReplacer);
res.writeHead(status, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(payload),
});
res.end(payload);
}
/**
* Loopback handler — full surface for the spawning agent. No auth (the
* loopback bind itself is the boundary).
*/
async function handleLoopback(ctx: HandlerCtx): Promise<void> {
const { req, res, tokenStore, getTunnel } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
try {
// /healthz — public on loopback.
if (method === 'GET' && path === '/healthz') {
sendJson(res, 200, { version: '1.0.0', mode: 'loopback' });
return;
}
// /auth/sessions — list active sessions (owner only).
if (method === 'GET' && path === '/auth/sessions') {
sendJson(res, 200, { sessions: tokenStore.list() });
return;
}
// /auth/revoke — revoke a token.
if (method === 'POST' && path === '/auth/revoke') {
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
const parsed = JSON.parse(body.toString('utf-8') || '{}') as { token?: string; identity?: string };
let count = 0;
if (parsed.token) {
count = tokenStore.revoke(parsed.token) ? 1 : 0;
} else if (parsed.identity) {
count = tokenStore.revokeByIdentity(parsed.identity);
}
sendJson(res, 200, { revoked: count });
return;
}
// Other endpoints — proxy to the device.
const tunnel = await getTunnel();
if (!tunnel) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined;
const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity });
res.writeHead(upstream.status, upstream.headers);
res.end(upstream.body);
} catch (err) {
sendJson(res, 500, { error: 'internal_error', detail: (err as Error).message });
}
}
interface TailnetCtx extends HandlerCtx {
whoIsImpl: (addr: string) => Promise<{ identity: string; raw: unknown }>;
}
/**
* Tailnet handler — locked allowlist + capability tiers.
*/
async function handleTailnet(ctx: TailnetCtx): Promise<void> {
const { req, res, tokenStore, getTunnel, whoIsImpl } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
const route = `${method} ${path}`;
try {
// Classify the route.
const classification = classifyRoute(method, path);
if (!classification.allowed) {
sendJson(res, 404, { error: 'endpoint_not_in_tailnet_allowlist', path });
return;
}
const requiredCapability = classification.requiredCapability as Capability;
// /healthz on tailnet requires auth (codex catch).
// No special-case; treated like every other observe-tier endpoint.
// /auth/mint — special path. No bearer required; uses WhoIs.
if (method === 'POST' && path === '/auth/mint') {
const peerAddr = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
let callerIdentity: string;
try {
const who = await whoIsImpl(peerAddr);
callerIdentity = who.identity;
} catch (err) {
await writeAttempt({
rawIdentity: peerAddr,
endpoint: route,
reason: 'whois_unparseable',
});
sendJson(res, 502, { error: 'whois_failed', detail: (err as Error).message });
return;
}
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
const parsed = JSON.parse(body.toString('utf-8') || '{}') as { capability?: Capability; device_udid?: string };
const result = await mintForCaller({
callerIdentity,
request: parsed,
tokenStore,
endpoint: route,
});
if ('error' in result) {
const status = result.error === 'rate_limited' ? 429 : 403;
sendJson(res, status, result);
return;
}
sendJson(res, 200, result);
return;
}
// All other endpoints: bearer auth + capability check.
const auth = req.headers['authorization'] as string | undefined;
const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null;
const validation = tokenStore.validate(token, requiredCapability);
if (!validation.ok) {
await writeAttempt({
rawIdentity: token ? 'token:' + token.slice(0, 8) : 'no_token',
endpoint: route,
reason: validation.reason,
});
const status = validation.reason === 'capability_insufficient' ? 403 : 401;
sendJson(res, status, { error: validation.reason });
return;
}
const session = validation.session;
// Read body once + enforce limit.
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
// Tailnet-only own-session revoke.
if (method === 'POST' && path === '/auth/revoke') {
tokenStore.revoke(session.token);
sendJson(res, 200, { revoked: 1 });
return;
}
// Proxy to device.
const tunnel = await getTunnel();
if (!tunnel) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const upstream = await proxyToDevice({
inbound: req,
body,
tunnel,
sessionId,
agentIdentity: session.identity,
});
// Audit the action (mutating endpoints only).
if (requiredCapability !== 'observe') {
await writeAudit({
ts: new Date().toISOString(),
identity: session.identity,
device_udid: tunnel.udid,
endpoint: route,
session_id: sessionId ?? '-',
capability: session.capability,
request_id: req.headers['x-request-id']?.toString() ?? '-',
status: upstream.status,
});
}
res.writeHead(upstream.status, upstream.headers);
res.end(upstream.body);
} catch (err) {
sendJson(res, 500, { error: 'internal_error', detail: (err as Error).message });
}
}
// CLI entry — runs when this file is executed directly, not when imported.
if (import.meta.main) {
const port = parseInt(process.env.GSTACK_IOS_DAEMON_PORT ?? '9099', 10);
const tailnet = process.argv.includes('--tailnet');
startDaemon({
loopbackPort: port,
tailnetEnabled: tailnet,
}).then((d) => {
if ('error' in d) {
process.stderr.write(`daemon error: ${d.error}\n`);
process.exit(0); // exit 0 because READY was already printed
}
}).catch((err) => {
process.stderr.write(`daemon fatal: ${(err as Error).message}\n`);
process.exit(1);
});
}
+111
View File
@@ -0,0 +1,111 @@
// Tailnet → USB proxy. When an authenticated request hits the tailnet
// listener and clears capability + allowlist checks, the daemon forwards it
// to the iOS StateServer over the device's CoreDevice IPv6 tunnel, injecting
// the rotated boot token in Authorization: Bearer and preserving the
// X-Session-Id from the caller.
import { request as httpRequest } from 'http';
import type { ServerResponse, IncomingMessage } from 'http';
import { sanitizeReplacer } from './audit';
import { tierForRoute } from './types';
const MAX_BODY = 1_048_576; // 1MB hard cap on tailnet ingress
export interface DeviceTunnel {
udid: string;
ipv6Addr: string;
port: number;
bootTokenRotated: string; // the rotated bearer the daemon uses to talk to StateServer
}
export interface ProxyError {
status: number;
body: Record<string, unknown>;
}
/**
* Forward a parsed inbound request to the StateServer. Returns the upstream
* response or a ProxyError. Caller writes to the ServerResponse.
*/
export async function proxyToDevice(opts: {
inbound: IncomingMessage;
body: Buffer;
tunnel: DeviceTunnel;
sessionId: string | null;
agentIdentity?: string;
}): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
const { inbound, body, tunnel, sessionId, agentIdentity } = opts;
if (body.length > MAX_BODY) {
return makeError(413, 'body_too_large');
}
const headers: Record<string, string> = {
'authorization': `Bearer ${tunnel.bootTokenRotated}`,
'content-type': inbound.headers['content-type'] || 'application/json',
'content-length': String(body.length),
};
if (sessionId) headers['x-session-id'] = sessionId;
if (agentIdentity) headers['x-agent-identity'] = agentIdentity;
// Bracket IPv6 literals; pass IPv4 + hostnames bare. The CoreDevice tunnel
// is always IPv6 in production, but tests inject 127.0.0.1 to talk to a
// local stub. Detect by `:` count (IPv6 has multiple colons) or `:` absence
// (IPv4/hostname).
const isIPv6 = (tunnel.ipv6Addr.match(/:/g)?.length ?? 0) >= 2;
const hostPart = isIPv6 ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr;
const url = `http://${hostPart}:${tunnel.port}${inbound.url ?? '/'}`;
return new Promise((resolve, reject) => {
const req = httpRequest(url, {
method: inbound.method,
headers,
timeout: 30_000,
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const respHeaders: Record<string, string> = {};
for (const [k, v] of Object.entries(res.headers)) {
if (typeof v === 'string') respHeaders[k] = v;
}
resolve({
status: res.statusCode ?? 502,
headers: respHeaders,
body: Buffer.concat(chunks),
});
});
});
req.on('error', (err) => {
const e = err as { code?: string };
if (e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH') {
resolve(makeError(503, 'device_disconnected'));
} else if (e.code === 'ETIMEDOUT') {
resolve(makeError(504, 'upstream_timeout'));
} else {
reject(err);
}
});
req.write(body);
req.end();
});
}
function makeError(status: number, error: string): { status: number; headers: Record<string, string>; body: Buffer } {
const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
return {
status,
headers: { 'content-type': 'application/json', 'content-length': String(body.length) },
body,
};
}
/**
* Determine whether the endpoint is allowed on the tailnet listener AND what
* capability tier it requires.
*/
export function classifyRoute(method: string, path: string): {
allowed: boolean;
requiredCapability: ReturnType<typeof tierForRoute>;
} {
const tier = tierForRoute(method, path);
return { allowed: tier !== null, requiredCapability: tier };
}
+126
View File
@@ -0,0 +1,126 @@
// Short-lived session token store. In-memory only (never disk). Refreshable
// via /session/heartbeat. Listable and revokable from loopback listener.
import { randomBytes } from 'crypto';
import type { Capability, SessionToken } from './types';
import { capabilityCovers } from './types';
const TOKEN_BYTES = 32; // 256-bit
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h per D9
const MAX_TTL_MS = 24 * 60 * 60 * 1000; // 24h hard cap
export class SessionTokenStore {
private tokens = new Map<string, SessionToken>();
private mintsPerIdentity = new Map<string, number[]>(); // ts (ms) for rate limiting
constructor(
private now: () => number = () => Date.now(),
) {}
/**
* Mint a session token. Returns null on rate limit.
*/
mint(opts: {
identity: string;
capability: Capability;
ttlMs?: number;
deviceUdid?: string | null;
origin: SessionToken['origin'];
}): SessionToken | { error: 'rate_limited' } {
if (!this.checkRateLimit(opts.identity)) {
return { error: 'rate_limited' };
}
const ttl = Math.min(opts.ttlMs ?? DEFAULT_TTL_MS, MAX_TTL_MS);
const token = randomBytes(TOKEN_BYTES).toString('base64url');
const expires_at = this.now() + ttl;
const session: SessionToken = {
token,
identity: opts.identity,
capability: opts.capability,
expires_at,
device_udid: opts.deviceUdid ?? null,
origin: opts.origin,
};
this.tokens.set(token, session);
return session;
}
/**
* Validate a token. Returns the session if valid (token exists, not
* expired). Otherwise returns null with a reason for the audit log.
*/
validate(token: string | null | undefined, need: Capability):
| { ok: true; session: SessionToken }
| { ok: false; reason: 'no_token' | 'invalid_token' | 'expired_token' | 'capability_insufficient' } {
if (!token) return { ok: false, reason: 'no_token' };
const s = this.tokens.get(token);
if (!s) return { ok: false, reason: 'invalid_token' };
if (s.expires_at < this.now()) {
this.tokens.delete(token);
return { ok: false, reason: 'expired_token' };
}
if (!capabilityCovers(s.capability, need)) {
return { ok: false, reason: 'capability_insufficient' };
}
return { ok: true, session: s };
}
/**
* Slide token expiry forward by ttlMs. Caps at the token's original max
* (which itself is bounded by MAX_TTL_MS). Returns the new expiry.
*/
heartbeat(token: string, ttlMs?: number): number | null {
const s = this.tokens.get(token);
if (!s) return null;
if (s.expires_at < this.now()) {
this.tokens.delete(token);
return null;
}
const newExpiry = this.now() + Math.min(ttlMs ?? DEFAULT_TTL_MS, MAX_TTL_MS);
s.expires_at = newExpiry;
return newExpiry;
}
revoke(token: string): boolean {
return this.tokens.delete(token);
}
revokeByIdentity(identity: string): number {
let count = 0;
for (const [token, s] of this.tokens) {
if (s.identity === identity) {
this.tokens.delete(token);
count++;
}
}
return count;
}
list(): SessionToken[] {
return [...this.tokens.values()];
}
// For tests: clear all state.
reset() {
this.tokens.clear();
this.mintsPerIdentity.clear();
}
/**
* Rate limit: 10 mints / 60s per identity. Sliding window.
*/
private checkRateLimit(identity: string): boolean {
const now = this.now();
const window = 60_000;
const limit = 10;
const hits = this.mintsPerIdentity.get(identity) ?? [];
const recent = hits.filter(t => now - t < window);
if (recent.length >= limit) {
this.mintsPerIdentity.set(identity, recent);
return false;
}
recent.push(now);
this.mintsPerIdentity.set(identity, recent);
return true;
}
}
+171
View File
@@ -0,0 +1,171 @@
// Single-instance enforcement. Daemon takes an exclusive flock on
// ~/.gstack/ios-qa-daemon.pid on startup. Second invocation discovers the
// existing daemon's port + connects. Stale lock (PID dead) is reclaimed.
//
// Readiness protocol: daemon writes `READY: port=<n> pid=<pid>` to stdout
// once both listeners are up; the spawner reads stdout with a 5s timeout.
import { readFile, mkdir, unlink } from 'fs/promises';
import { existsSync, openSync, writeSync, closeSync, unlinkSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { spawn } from 'child_process';
export interface PidfileContents {
pid: number;
port: number;
startedAt: number;
}
export function defaultPidfilePath(): string {
return process.env.GSTACK_IOS_DAEMON_PIDFILE
?? join(homedir(), '.gstack', 'ios-qa-daemon.pid');
}
/**
* Try to claim the pidfile. Returns:
* - { claimed: true } when this process now owns the lock
* - { claimed: false, existing } when another live daemon holds it
*
* The "live" check is process.kill(pid, 0): succeeds if the PID exists,
* fails with ESRCH if not. We DO NOT trust a stale pidfile.
*/
export async function tryClaim(opts: {
port: number;
path?: string;
}): Promise<
| { claimed: true; release: () => Promise<void> }
| { claimed: false; existing: PidfileContents }
> {
const path = opts.path ?? defaultPidfilePath();
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
// Check for an existing pidfile.
if (existsSync(path)) {
try {
const raw = await readFile(path, 'utf-8');
const existing = JSON.parse(raw) as PidfileContents;
if (isAlive(existing.pid)) {
return { claimed: false, existing };
}
// Stale — drop it and continue to claim.
await unlink(path).catch(() => {});
} catch {
// Unparseable pidfile — treat as stale.
await unlink(path).catch(() => {});
}
}
// Use SYNCHRONOUS open with O_EXCL for atomic exclusion. Bun's async
// fs.open(wx) doesn't reliably preserve O_EXCL semantics across concurrent
// calls in the same process. Sync openSync goes straight to syscall and is
// genuinely atomic.
//
// Constant 0x800 = O_EXCL on macOS/Linux; combined with O_CREAT (0x200) and
// O_WRONLY (0x1) it's the equivalent of 'wx'. The sync API accepts the
// string flag form too, but explicit numeric flags are the most defensive.
const contents: PidfileContents = {
pid: process.pid,
port: opts.port,
startedAt: Date.now(),
};
let fd: number;
try {
fd = openSync(path, 'wx', 0o600);
} catch (err: unknown) {
const e = err as { code?: string };
if (e.code === 'EEXIST') {
// Race: another caller won.
const raw = await readFile(path, 'utf-8').catch(() => '{}');
const existing = JSON.parse(raw || '{}') as PidfileContents;
return { claimed: false, existing };
}
throw err;
}
try {
writeSync(fd, JSON.stringify(contents, null, 2));
} finally {
closeSync(fd);
}
// Cleanup on exit.
const cleanup = async () => {
try {
// Verify we still own it before unlinking.
const raw = await readFile(path, 'utf-8');
const cur = JSON.parse(raw) as PidfileContents;
if (cur.pid === process.pid) {
await unlink(path);
}
} catch {
// best-effort
}
};
process.on('exit', () => {
try { unlinkSync(path); } catch { /* ignore */ }
});
process.on('SIGINT', () => { cleanup().finally(() => process.exit(0)); });
process.on('SIGTERM', () => { cleanup().finally(() => process.exit(0)); });
return { claimed: true, release: cleanup };
}
function isAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err: unknown) {
const e = err as { code?: string };
return e.code !== 'ESRCH';
}
}
/**
* Spawn a daemon process and wait for the READY line. Returns the port the
* daemon claims to be listening on.
*
* Used by /ios-qa skill to spawn-on-demand. If another daemon is already
* running, the spawned child detects the existing pidfile and prints a
* READY line with the existing port (loaded from the pidfile).
*/
export async function spawnAndWaitReady(opts: {
cmd: string;
args: string[];
timeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): Promise<{ pid: number; port: number }> {
const timeoutMs = opts.timeoutMs ?? 5000;
const child = spawn(opts.cmd, opts.args, {
stdio: ['ignore', 'pipe', 'inherit'],
detached: true,
env: opts.env ?? process.env,
});
return new Promise((resolve, reject) => {
let buffer = '';
const onTimeout = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`daemon spawn timeout after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const match = buffer.match(/READY:\s*port=(\d+)\s+pid=(\d+)/);
if (match) {
clearTimeout(onTimeout);
child.unref();
resolve({ pid: parseInt(match[2]!, 10), port: parseInt(match[1]!, 10) });
}
});
child.on('error', (err) => {
clearTimeout(onTimeout);
reject(err);
});
child.on('exit', (code, signal) => {
clearTimeout(onTimeout);
reject(new Error(`daemon exited before READY (code=${code} signal=${signal})`));
});
});
}
+120
View File
@@ -0,0 +1,120 @@
// tailscaled LocalAPI client. Reads the unix socket at /var/run/tailscale.sock
// (or wherever tailscaled is listening), calls WhoIs, returns a canonicalized
// identity string.
//
// **Fail-closed semantics:** every error path here MUST be surfaced as a
// reason the tailnet listener should refuse to open. Daemon caller must
// distinguish "socket missing" (Tailscale not installed) from "WhoIs returned
// unparseable response" (Tailscale broken) so the user knows what to fix.
import { request as httpRequest } from 'http';
import type { WhoIsResult } from './types';
export interface TailscaleProbe {
ok: boolean;
reason?: 'socket_missing' | 'permission_denied' | 'whois_unparseable' | 'unreachable';
ownIdentity?: string;
}
/**
* Probe whether tailscaled LocalAPI is usable. Called before opening the
* tailnet listener. Returns ok=true only if WhoIs against the daemon's own
* identity returns a parseable result.
*/
export async function probeTailscale(socketPath: string = '/var/run/tailscale.sock'): Promise<TailscaleProbe> {
try {
const result = await whoIs('127.0.0.1:9999', socketPath);
return { ok: true, ownIdentity: result.identity };
} catch (err: unknown) {
const e = err as { code?: string; message?: string };
if (e.code === 'ENOENT' || (e.message ?? '').includes('ENOENT')) {
return { ok: false, reason: 'socket_missing' };
}
if (e.code === 'EACCES' || (e.message ?? '').includes('EACCES')) {
return { ok: false, reason: 'permission_denied' };
}
if ((e.message ?? '').includes('unparseable') || (e.message ?? '').includes('JSON')) {
return { ok: false, reason: 'whois_unparseable' };
}
return { ok: false, reason: 'unreachable' };
}
}
/**
* Call /localapi/v0/whois?addr=<addr:port>. Returns canonicalized identity.
*
* Canonicalization rules (matches Tailscale convention):
* - User OAuth: `user@example.com` (no acct: prefix, lowercase email)
* - Tagged nodes: `tag:<name>` (lowercased)
* - Node keys: `node:<hex>` (rare, prefer tags)
*/
export async function whoIs(addr: string, socketPath: string = '/var/run/tailscale.sock'): Promise<WhoIsResult> {
return new Promise((resolve, reject) => {
const req = httpRequest({
socketPath,
path: `/localapi/v0/whois?addr=${encodeURIComponent(addr)}`,
method: 'GET',
headers: { Host: 'local-tailscaled.sock' },
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`whois http ${res.statusCode}`));
return;
}
try {
const raw = Buffer.concat(chunks).toString('utf-8');
const obj = JSON.parse(raw) as Record<string, unknown>;
const identity = canonicalize(obj);
if (!identity) {
reject(new Error('whois response unparseable'));
return;
}
resolve({ identity, raw: obj });
} catch (e) {
reject(new Error(`whois response unparseable: ${(e as Error).message}`));
}
});
});
req.on('error', reject);
req.end();
});
}
/**
* Reduce a WhoIs response object to a canonical identity string.
*
* Expected response shape (Tailscale LocalAPI v0):
* {
* "Node": { "ComputedName": "...", "Tags": ["tag:ci"], ... },
* "UserProfile": { "LoginName": "user@example.com", ... },
* }
*/
export function canonicalize(obj: Record<string, unknown>): string | null {
// Tagged node — tag is more specific than user identity for ACL purposes.
const node = obj.Node as Record<string, unknown> | undefined;
if (node) {
const tags = node.Tags as string[] | undefined;
if (Array.isArray(tags) && tags.length > 0 && typeof tags[0] === 'string') {
const tag = tags[0].toLowerCase();
// Tags from Tailscale are already in `tag:foo` form.
return tag.startsWith('tag:') ? tag : `tag:${tag}`;
}
}
const profile = obj.UserProfile as Record<string, unknown> | undefined;
if (profile) {
const loginName = profile.LoginName as string | undefined;
if (typeof loginName === 'string' && loginName.includes('@')) {
return loginName.toLowerCase();
}
}
// Fallback to node key — rare but possible.
if (node) {
const key = node.Key as string | undefined;
if (typeof key === 'string' && key.startsWith('nodekey:')) {
return `node:${key.replace('nodekey:', '')}`;
}
}
return null;
}
+91
View File
@@ -0,0 +1,91 @@
// Shared types for the ios-qa daemon.
export type Capability = 'observe' | 'interact' | 'mutate' | 'restore';
export const CAPABILITY_ORDER: Record<Capability, number> = {
observe: 0,
interact: 1,
mutate: 2,
restore: 3,
};
export function capabilityCovers(have: Capability, need: Capability): boolean {
return CAPABILITY_ORDER[have] >= CAPABILITY_ORDER[need];
}
export interface AllowlistEntry {
identity: string;
capabilities: Capability[];
expires_at: string | null;
note?: string;
}
export interface Allowlist {
version: 1;
entries: AllowlistEntry[];
}
export interface SessionToken {
token: string;
identity: string;
capability: Capability;
expires_at: number; // epoch ms
device_udid: string | null;
origin: 'self_service' | 'owner_granted';
}
export interface AuditRow {
ts: string;
identity: string;
device_udid: string;
endpoint: string;
session_id: string;
capability: Capability;
request_id: string;
status: number;
}
export interface AttemptRow {
ts: string;
identity_canon: string; // sha256 salted — never the raw identity
endpoint: string;
reason: 'no_token' | 'invalid_token' | 'expired_token' | 'identity_not_allowed' |
'capability_insufficient' | 'rate_limited' | 'allowlist_violation' |
'tailnet_socket_missing' | 'whois_unparseable';
}
export interface WhoIsResult {
identity: string; // canonicalized: "user@example.com" or "tag:<name>" or "node:<key>"
raw: unknown;
}
// Path allowlist for tailnet listener — by capability tier.
// Each endpoint is mapped to the MINIMUM tier required.
export const TAILNET_ENDPOINT_TIERS: Record<string, Capability> = {
'GET /healthz': 'observe',
'POST /auth/mint': 'observe', // any allowlisted caller can attempt; daemon then filters by tier
'POST /auth/revoke': 'observe', // own-session revoke
'GET /screenshot': 'observe',
'GET /elements': 'observe',
'GET /state/snapshot': 'observe',
'GET /state/*': 'observe',
'POST /session/acquire': 'interact',
'POST /session/release': 'interact',
'POST /session/heartbeat': 'interact',
'POST /tap': 'interact',
'POST /swipe': 'interact',
'POST /type': 'interact',
'POST /state/*': 'mutate',
'POST /state/restore': 'restore',
};
export function tierForRoute(method: string, path: string): Capability | null {
const exact = `${method} ${path}`;
if (TAILNET_ENDPOINT_TIERS[exact]) return TAILNET_ENDPOINT_TIERS[exact];
// Wildcard /state/*
if (path.startsWith('/state/') && path !== '/state/snapshot' && path !== '/state/restore') {
if (method === 'GET') return 'observe';
if (method === 'POST') return 'mutate';
}
return null; // not allowlisted on tailnet
}
+146
View File
@@ -0,0 +1,146 @@
// Allowlist tests — codex flagged identity canonicalization gaps.
import { describe, test, expect, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
loadAllowlist,
findEntry,
hasCapability,
grantIdentity,
revokeIdentity,
saveAllowlist,
} from '../src/allowlist';
let tmpDir: string;
let listPath: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-allowlist-'));
listPath = join(tmpDir, 'allowlist.json');
});
describe('Allowlist', () => {
test('loadAllowlist returns empty on missing file', async () => {
const list = await loadAllowlist(listPath);
expect(list).toEqual({ version: 1, entries: [] });
});
test('saveAllowlist writes mode 0600 JSON', async () => {
await saveAllowlist({
version: 1,
entries: [{ identity: 'user@example.com', capabilities: ['observe'], expires_at: null }],
}, listPath);
expect(existsSync(listPath)).toBe(true);
const raw = readFileSync(listPath, 'utf-8');
expect(JSON.parse(raw).entries[0].identity).toBe('user@example.com');
});
test('findEntry matches exact identity', async () => {
const list = {
version: 1 as const,
entries: [{ identity: 'user@example.com', capabilities: ['mutate' as const], expires_at: null }],
};
expect(findEntry(list, 'user@example.com')?.identity).toBe('user@example.com');
expect(findEntry(list, 'USER@example.com')).toBeNull(); // exact-match only
expect(findEntry(list, 'unknown@example.com')).toBeNull();
});
test('findEntry skips expired entries', async () => {
const list = {
version: 1 as const,
entries: [
{ identity: 'expired', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 60_000).toISOString() },
],
};
expect(findEntry(list, 'expired')).toBeNull();
});
test('findEntry accepts future expiry', async () => {
const list = {
version: 1 as const,
entries: [
{ identity: 'future', capabilities: ['observe' as const], expires_at: new Date(Date.now() + 60_000).toISOString() },
],
};
expect(findEntry(list, 'future')?.identity).toBe('future');
});
test('hasCapability is tier-aware', async () => {
const list = {
version: 1 as const,
entries: [
{ identity: 'restore-user', capabilities: ['restore' as const], expires_at: null },
{ identity: 'observe-user', capabilities: ['observe' as const], expires_at: null },
],
};
expect(hasCapability(list, 'restore-user', 'observe')).toBe(true);
expect(hasCapability(list, 'restore-user', 'interact')).toBe(true);
expect(hasCapability(list, 'restore-user', 'mutate')).toBe(true);
expect(hasCapability(list, 'restore-user', 'restore')).toBe(true);
expect(hasCapability(list, 'observe-user', 'observe')).toBe(true);
expect(hasCapability(list, 'observe-user', 'interact')).toBe(false);
expect(hasCapability(list, 'observe-user', 'mutate')).toBe(false);
expect(hasCapability(list, 'observe-user', 'restore')).toBe(false);
});
test('grantIdentity adds a new entry', async () => {
await grantIdentity({
identity: 'new@example.com',
capability: 'interact',
path: listPath,
});
const list = await loadAllowlist(listPath);
expect(list.entries).toHaveLength(1);
expect(list.entries[0]!.identity).toBe('new@example.com');
expect(list.entries[0]!.capabilities).toContain('interact');
});
test('grantIdentity upgrades an existing entry', async () => {
await grantIdentity({ identity: 'u', capability: 'observe', path: listPath });
await grantIdentity({ identity: 'u', capability: 'restore', path: listPath });
const list = await loadAllowlist(listPath);
expect(list.entries).toHaveLength(1);
expect(list.entries[0]!.capabilities).toContain('restore');
});
test('grantIdentity with ttl sets expires_at', async () => {
await grantIdentity({ identity: 'u', capability: 'observe', ttlSeconds: 3600, path: listPath });
const list = await loadAllowlist(listPath);
const exp = Date.parse(list.entries[0]!.expires_at!);
expect(exp).toBeGreaterThan(Date.now());
expect(exp).toBeLessThan(Date.now() + 3700 * 1000);
});
test('revokeIdentity removes the entry', async () => {
await grantIdentity({ identity: 'u', capability: 'observe', path: listPath });
await revokeIdentity('u', listPath);
const list = await loadAllowlist(listPath);
expect(list.entries).toHaveLength(0);
});
// Codex-flagged identity canonicalization variants — verify the matcher
// works for each.
test('user identity, tagged node, node key, expired node all canonicalize distinctly', async () => {
const list = {
version: 1 as const,
entries: [
{ identity: 'alice@example.com', capabilities: ['observe' as const], expires_at: null },
{ identity: 'tag:ci', capabilities: ['mutate' as const], expires_at: null },
{ identity: 'node:abcdef0123', capabilities: ['observe' as const], expires_at: null },
{ identity: 'bob@example.com', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 1000).toISOString() },
],
};
expect(hasCapability(list, 'alice@example.com', 'observe')).toBe(true);
expect(hasCapability(list, 'tag:ci', 'mutate')).toBe(true);
expect(hasCapability(list, 'node:abcdef0123', 'observe')).toBe(true);
expect(hasCapability(list, 'bob@example.com', 'observe')).toBe(false); // expired
expect(hasCapability(list, 'tag:CI', 'mutate')).toBe(false); // case-sensitive — canonicalize before lookup
});
});
import { afterEach } from 'bun:test';
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
+111
View File
@@ -0,0 +1,111 @@
// Audit + attempts logging tests. Codex-flagged: identity must be hashed in
// attempts.jsonl (no raw identity leak), rotation works, sanitize-replacer
// strips lone surrogates.
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { writeAudit, writeAttempt, sanitizeReplacer } from '../src/audit';
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-audit-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe('writeAudit', () => {
test('appends a JSONL row', async () => {
const path = join(tmpDir, 'audit.jsonl');
await writeAudit({
ts: '2026-05-18T00:00:00Z',
identity: 'u@e.com',
device_udid: 'UDID-1',
endpoint: 'POST /tap',
session_id: 'S1',
capability: 'interact',
request_id: 'req-1',
status: 200,
}, path);
const lines = readFileSync(path, 'utf-8').trim().split('\n');
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0]!).identity).toBe('u@e.com');
});
});
describe('writeAttempt', () => {
test('hashes raw identity with the device salt (no raw leak)', async () => {
const auditPath = join(tmpDir, 'attempts.jsonl');
await writeAttempt({
rawIdentity: 'attacker@evil.com',
endpoint: 'POST /auth/mint',
reason: 'identity_not_allowed',
path: auditPath,
});
const lines = readFileSync(auditPath, 'utf-8').trim().split('\n');
expect(lines).toHaveLength(1);
const row = JSON.parse(lines[0]!);
expect(row.reason).toBe('identity_not_allowed');
expect(row.identity_canon).not.toBe('attacker@evil.com');
expect(row.identity_canon).toMatch(/^[a-f0-9]{16}$/); // 16-char hex
});
test('does NOT log the raw identity anywhere in the row', async () => {
const path = join(tmpDir, 'attempts.jsonl');
await writeAttempt({
rawIdentity: 'secret@example.com',
endpoint: 'POST /auth/mint',
reason: 'identity_not_allowed',
path,
});
const raw = readFileSync(path, 'utf-8');
expect(raw).not.toContain('secret@example.com');
});
});
describe('sanitizeReplacer', () => {
// Helper: check every UTF-16 code unit in a string. Returns true iff any
// unpaired surrogate is present. More reliable than .toContain('\uD800')
// since Bun's matcher does UTF-8 byte comparison for non-ASCII.
const hasUnpairedSurrogate = (s: string): boolean => {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c >= 0xD800 && c <= 0xDBFF) {
const next = s.charCodeAt(i + 1);
if (!(next >= 0xDC00 && next <= 0xDFFF)) return true;
i++; // skip the valid pair
} else if (c >= 0xDC00 && c <= 0xDFFF) {
return true;
}
}
return false;
};
test('replaces lone high surrogates with U+FFFD', () => {
const out = JSON.stringify({ s: 'before\uD800after' }, sanitizeReplacer);
expect(hasUnpairedSurrogate(out)).toBe(false);
expect(out.includes('')).toBe(true);
});
test('replaces lone low surrogates with U+FFFD', () => {
const out = JSON.stringify({ s: 'before\uDC00after' }, sanitizeReplacer);
expect(hasUnpairedSurrogate(out)).toBe(false);
expect(out.includes('')).toBe(true);
});
test('preserves valid surrogate pairs', () => {
// 😀 = U+1F600 = surrogate pair D83D DE00. Must stay intact.
const out = JSON.stringify({ s: '😀' }, sanitizeReplacer);
expect(out.includes('😀')).toBe(true);
expect(hasUnpairedSurrogate(out)).toBe(false);
expect(out.includes('')).toBe(false);
});
test('passes through non-string values', () => {
expect(JSON.stringify({ n: 42, b: true, x: null }, sanitizeReplacer)).toBe('{"n":42,"b":true,"x":null}');
});
});
+103
View File
@@ -0,0 +1,103 @@
// /auth/mint endpoint tests. Codex-flagged: identity allowlist, capability
// cap, rate-limit cap, self-service vs owner-granted distinction.
import { describe, test, expect, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { mintForCaller } from '../src/auth-mint';
import { SessionTokenStore } from '../src/session-tokens';
import { grantIdentity } from '../src/allowlist';
let tmpDir: string;
let listPath: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-mint-'));
listPath = join(tmpDir, 'allowlist.json');
});
describe('mintForCaller', () => {
test('rejects unknown identity', async () => {
const store = new SessionTokenStore();
const r = await mintForCaller({
callerIdentity: 'stranger@example.com',
request: { capability: 'observe' },
tokenStore: store,
allowlistPath: listPath,
});
expect(r).toEqual({ error: 'identity_not_allowed' });
});
test('mints at the requested tier when allowlisted at that tier', async () => {
await grantIdentity({ identity: 'u@e.com', capability: 'mutate', path: listPath });
const store = new SessionTokenStore();
const r = await mintForCaller({
callerIdentity: 'u@e.com',
request: { capability: 'interact' },
tokenStore: store,
allowlistPath: listPath,
});
expect('error' in r).toBe(false);
if ('error' in r) throw new Error('unexpected');
expect(r.capability).toBe('mutate'); // returns the granted tier (higher covers interact)
expect(r.session_token.length).toBeGreaterThan(0);
});
test('refuses to mint above the allowlisted tier', async () => {
await grantIdentity({ identity: 'observe-only@e.com', capability: 'observe', path: listPath });
const store = new SessionTokenStore();
const r = await mintForCaller({
callerIdentity: 'observe-only@e.com',
request: { capability: 'mutate' },
tokenStore: store,
allowlistPath: listPath,
});
expect(r).toEqual({ error: 'capability_insufficient' });
});
test('rate limits hit at 11th mint per identity', async () => {
await grantIdentity({ identity: 'spammer@e.com', capability: 'observe', path: listPath });
const store = new SessionTokenStore();
let lastError: unknown = null;
let success = 0;
for (let i = 0; i < 11; i++) {
const r = await mintForCaller({
callerIdentity: 'spammer@e.com',
request: { capability: 'observe' },
tokenStore: store,
allowlistPath: listPath,
});
if ('error' in r) lastError = r;
else success++;
}
expect(success).toBe(10);
expect(lastError).toEqual({ error: 'rate_limited' });
});
test('expired allowlist entries reject the mint', async () => {
// Write an expired entry directly.
const { saveAllowlist } = await import('../src/allowlist');
await saveAllowlist({
version: 1,
entries: [{
identity: 'expired@e.com',
capabilities: ['restore'],
expires_at: new Date(Date.now() - 60_000).toISOString(),
}],
}, listPath);
const store = new SessionTokenStore();
const r = await mintForCaller({
callerIdentity: 'expired@e.com',
request: { capability: 'observe' },
tokenStore: store,
allowlistPath: listPath,
});
expect(r).toEqual({ error: 'identity_not_allowed' });
});
});
import { afterEach } from 'bun:test';
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
@@ -0,0 +1,350 @@
// End-to-end daemon integration tests. Starts a real daemon against a stub
// StateServer + mocked tailscaled. Exercises:
//
// - Loopback listener responses
// - Tailnet listener fail-closed when probe fails
// - Tailnet → USB proxy forwards bearer + X-Session-Id
// - Capability tier enforcement (interact → /tap ok, observe → /tap 403)
// - Rate limit on /auth/mint
// - Tailnet listener never binds 0.0.0.0
// - Boot token never leaks in responses
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { createServer } from 'http';
import type { Server, IncomingMessage } from 'http';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startDaemon, type RunningDaemon } from '../src/index';
import { grantIdentity } from '../src/allowlist';
import type { DeviceTunnel } from '../src/proxy';
let workDir: string;
const STATE_SERVER_TOKEN = 'rotated-mock-token-XXXXXXXX';
// Stub iOS StateServer running on loopback. Mimics the real Swift server's
// behavior for the integration test.
function startStubStateServer(): Promise<{ server: Server; port: number; receivedRequests: Array<{ method: string; path: string; headers: Record<string, string | string[] | undefined>; body: string }> }> {
return new Promise((resolve) => {
const received: Array<{ method: string; path: string; headers: Record<string, string | string[] | undefined>; body: string }> = [];
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
received.push({ method: req.method ?? '', path: req.url ?? '', headers: req.headers, body });
const auth = req.headers['authorization'];
// Validate the bearer is our rotated token.
if (!auth || auth !== `Bearer ${STATE_SERVER_TOKEN}`) {
res.writeHead(401, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unauthorized' }));
return;
}
if (req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ version: '1.0.0' }));
return;
}
if (req.url === '/screenshot') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ png_base64: 'abc=' }));
return;
}
if (req.url === '/tap') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, op: 'tap' }));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not_found' }));
});
});
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
resolve({ server, port, receivedRequests: received });
});
});
}
async function fetchWith(method: string, url: string, init: { headers?: Record<string, string>; body?: string } = {}): Promise<{ status: number; bodyText: string }> {
const res = await fetch(url, { method, headers: init.headers, body: init.body });
return { status: res.status, bodyText: await res.text() };
}
describe('daemon — loopback listener', () => {
let stub: Awaited<ReturnType<typeof startStubStateServer>>;
let daemon: RunningDaemon;
let pidPath: string;
beforeAll(async () => {
workDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-loopback-'));
pidPath = join(workDir, 'daemon.pid');
stub = await startStubStateServer();
const tunnel: DeviceTunnel = {
udid: 'STUB-UDID',
ipv6Addr: '127.0.0.1',
port: stub.port,
bootTokenRotated: STATE_SERVER_TOKEN,
};
const d = await startDaemon({
loopbackPort: 0,
tailnetEnabled: false,
pidfilePath: pidPath,
tunnelProvider: async () => tunnel,
});
if ('error' in d) throw new Error(d.error);
daemon = d;
});
afterAll(async () => {
await daemon?.close();
stub.server.close();
rmSync(workDir, { recursive: true, force: true });
});
test('healthz returns 200 with mode=loopback', async () => {
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`);
expect(r.status).toBe(200);
expect(JSON.parse(r.bodyText)).toMatchObject({ mode: 'loopback' });
});
test('proxies /screenshot to stub StateServer with the rotated bearer', async () => {
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/screenshot`);
expect(r.status).toBe(200);
expect(JSON.parse(r.bodyText)).toEqual({ png_base64: 'abc=' });
// Verify the stub received the rotated token, NOT a passthrough or empty token.
const lastReq = stub.receivedRequests[stub.receivedRequests.length - 1];
expect(lastReq?.headers['authorization']).toBe(`Bearer ${STATE_SERVER_TOKEN}`);
});
test('proxies X-Session-Id passthrough on /tap', async () => {
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/tap`, {
headers: { 'x-session-id': 'sess-loopback-1', 'content-type': 'application/json' },
body: JSON.stringify({ x: 100, y: 200 }),
});
expect(r.status).toBe(200);
const lastReq = stub.receivedRequests[stub.receivedRequests.length - 1];
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1');
});
test('returns 503 when no device tunnel is provided', async () => {
// Force tunnel provider to return null by closing + restarting with null provider.
await daemon.close();
pidPath = join(workDir, 'daemon-2.pid');
const d2 = await startDaemon({
loopbackPort: daemon.loopbackPort + 1,
tailnetEnabled: false,
pidfilePath: pidPath,
tunnelProvider: async () => null,
});
if ('error' in d2) throw new Error(d2.error);
try {
const r = await fetchWith('GET', `http://127.0.0.1:${d2.loopbackPort}/screenshot`);
expect(r.status).toBe(503);
} finally {
await d2.close();
}
});
});
describe('daemon — tailnet listener (mocked tailscaled)', () => {
let stub: Awaited<ReturnType<typeof startStubStateServer>>;
let daemon: RunningDaemon;
let listPath: string;
let pidPath: string;
beforeEach(async () => {
workDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-tailnet-'));
listPath = join(workDir, 'allowlist.json');
pidPath = join(workDir, 'daemon.pid');
stub = await startStubStateServer();
const tunnel: DeviceTunnel = {
udid: 'STUB-UDID',
ipv6Addr: '127.0.0.1',
port: stub.port,
bootTokenRotated: STATE_SERVER_TOKEN,
};
process.env.GSTACK_IOS_ALLOWLIST_PATH = listPath;
process.env.GSTACK_IOS_AUDIT_PATH = join(workDir, 'audit.jsonl');
process.env.GSTACK_IOS_ATTEMPTS_PATH = join(workDir, 'attempts.jsonl');
process.env.GSTACK_IOS_TAILNET_BIND = '127.0.0.1'; // safe test bind
const d = await startDaemon({
loopbackPort: 0,
tailnetEnabled: true,
pidfilePath: pidPath,
tunnelProvider: async () => tunnel,
probeImpl: async () => ({ ok: true, ownIdentity: 'mac@example.com' }),
whoIsImpl: async () => ({ identity: 'caller@example.com', raw: {} }),
});
if ('error' in d) throw new Error(d.error);
daemon = d;
});
afterEach(async () => {
if (daemon) await daemon.close();
delete process.env.GSTACK_IOS_ALLOWLIST_PATH;
delete process.env.GSTACK_IOS_AUDIT_PATH;
delete process.env.GSTACK_IOS_ATTEMPTS_PATH;
delete process.env.GSTACK_IOS_TAILNET_BIND;
if (workDir) rmSync(workDir, { recursive: true, force: true });
stub.server.close();
});
test('tailnet listener refuses to open when probe fails', async () => {
await daemon.close();
pidPath = join(workDir, 'daemon-fail.pid');
const d = await startDaemon({
loopbackPort: 0,
tailnetEnabled: true,
pidfilePath: pidPath,
tunnelProvider: async () => null,
probeImpl: async () => ({ ok: false, reason: 'socket_missing' }),
});
if ('error' in d) throw new Error(d.error);
try {
// Tailnet port should not exist (no listener).
expect(d.tailnetPort).toBeNull();
// Loopback still works.
const r = await fetchWith('GET', `http://127.0.0.1:${d.loopbackPort}/healthz`);
expect(r.status).toBe(200);
} finally {
await d.close();
}
});
test('non-allowlisted endpoint returns 404 on tailnet', async () => {
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.tailnetPort}/auth/sessions`);
expect(r.status).toBe(404);
expect(JSON.parse(r.bodyText).error).toBe('endpoint_not_in_tailnet_allowlist');
});
test('/auth/mint rejects unknown identity (mocked WhoIs)', async () => {
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ capability: 'observe' }),
});
expect(r.status).toBe(403);
expect(JSON.parse(r.bodyText).error).toBe('identity_not_allowed');
});
test('/auth/mint succeeds for allowlisted identity, then proxies are bearer-gated', async () => {
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ capability: 'interact' }),
});
expect(mintR.status).toBe(200);
const { session_token } = JSON.parse(mintR.bodyText);
expect(typeof session_token).toBe('string');
// Use the token to call /tap.
const tapR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's1' },
body: JSON.stringify({ x: 1, y: 2 }),
});
expect(tapR.status).toBe(200);
// Call without bearer → 401.
const tapNoAuth = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ x: 1 }),
});
expect(tapNoAuth.status).toBe(401);
});
test('capability tier enforced — observe token cannot call /tap (interact-tier)', async () => {
await grantIdentity({ identity: 'caller@example.com', capability: 'observe', path: listPath });
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ capability: 'observe' }),
});
const { session_token } = JSON.parse(mintR.bodyText);
const tapR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's1' },
body: JSON.stringify({ x: 1, y: 2 }),
});
expect(tapR.status).toBe(403);
expect(JSON.parse(tapR.bodyText).error).toBe('capability_insufficient');
});
test('rate limit kicks in at 11th /auth/mint per identity', async () => {
await grantIdentity({ identity: 'caller@example.com', capability: 'observe', path: listPath });
let last = 0;
for (let i = 0; i < 11; i++) {
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ capability: 'observe' }),
});
last = r.status;
}
expect(last).toBe(429);
});
test('body size limit returns 413', async () => {
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ capability: 'interact' }),
});
const { session_token } = JSON.parse(mintR.bodyText);
const huge = 'x'.repeat(2_000_000); // 2MB > 1MB cap
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's' },
body: JSON.stringify({ padding: huge }),
});
expect(r.status).toBe(413);
});
test('audit log records mutating tailnet requests', async () => {
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ capability: 'interact' }),
});
const { session_token } = JSON.parse(mintR.bodyText);
await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 'audit-s' },
body: JSON.stringify({ x: 1, y: 2 }),
});
// Allow async file write to complete.
await new Promise(r => setTimeout(r, 100));
const auditPath = process.env.GSTACK_IOS_AUDIT_PATH!;
const { readFileSync, existsSync } = await import('fs');
expect(existsSync(auditPath)).toBe(true);
const rows = readFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
const tapRow = rows.find(r => r.endpoint === 'POST /tap');
expect(tapRow).toBeDefined();
expect(tapRow.identity).toBe('caller@example.com');
expect(tapRow.capability).toBe('interact');
});
test('boot token never appears in tailnet responses', async () => {
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ capability: 'interact' }),
});
expect(mintR.bodyText).not.toContain(STATE_SERVER_TOKEN);
const { session_token } = JSON.parse(mintR.bodyText);
const screenshotR = await fetchWith('GET', `http://127.0.0.1:${daemon.tailnetPort}/screenshot`, {
headers: { 'authorization': `Bearer ${session_token}` },
});
expect(screenshotR.bodyText).not.toContain(STATE_SERVER_TOKEN);
});
});
// Cleanup any leftover env from beforeEach blocks.
import { afterEach } from 'bun:test';
+47
View File
@@ -0,0 +1,47 @@
// Tailnet endpoint allowlist + capability tier classification tests.
//
// Codex flagged: "tailnet listener allowlist is too broad. Remote agents
// should not get /state/* by default. Split capabilities: observe, interact,
// mutate state, restore state."
import { describe, test, expect } from 'bun:test';
import { classifyRoute } from '../src/proxy';
describe('classifyRoute', () => {
test('healthz, screenshot, elements, snapshot are observe-tier', () => {
expect(classifyRoute('GET', '/healthz').requiredCapability).toBe('observe');
expect(classifyRoute('GET', '/screenshot').requiredCapability).toBe('observe');
expect(classifyRoute('GET', '/elements').requiredCapability).toBe('observe');
expect(classifyRoute('GET', '/state/snapshot').requiredCapability).toBe('observe');
expect(classifyRoute('GET', '/state/anyKey').requiredCapability).toBe('observe');
});
test('tap, swipe, type, session ops are interact-tier', () => {
expect(classifyRoute('POST', '/tap').requiredCapability).toBe('interact');
expect(classifyRoute('POST', '/swipe').requiredCapability).toBe('interact');
expect(classifyRoute('POST', '/type').requiredCapability).toBe('interact');
expect(classifyRoute('POST', '/session/acquire').requiredCapability).toBe('interact');
expect(classifyRoute('POST', '/session/release').requiredCapability).toBe('interact');
expect(classifyRoute('POST', '/session/heartbeat').requiredCapability).toBe('interact');
});
test('arbitrary state writes are mutate-tier', () => {
expect(classifyRoute('POST', '/state/userIsLoggedIn').requiredCapability).toBe('mutate');
expect(classifyRoute('POST', '/state/anyField').requiredCapability).toBe('mutate');
});
test('state/restore is restore-tier (highest)', () => {
expect(classifyRoute('POST', '/state/restore').requiredCapability).toBe('restore');
});
test('mint endpoint is observe-tier (minimum bar to attempt mint)', () => {
expect(classifyRoute('POST', '/auth/mint').requiredCapability).toBe('observe');
});
test('non-allowlisted endpoints return allowed=false', () => {
expect(classifyRoute('POST', '/auth/sessions').allowed).toBe(false);
expect(classifyRoute('GET', '/random').allowed).toBe(false);
expect(classifyRoute('DELETE', '/anything').allowed).toBe(false);
expect(classifyRoute('GET', '/auth/sessions').allowed).toBe(false); // loopback-only
});
});
+156
View File
@@ -0,0 +1,156 @@
// Unit tests for SessionTokenStore.
//
// Codex flagged: TTL semantics, capability tier enforcement, rate limiting,
// token expiry, identity-scoped revoke.
import { describe, test, expect } from 'bun:test';
import { SessionTokenStore } from '../src/session-tokens';
import { capabilityCovers } from '../src/types';
describe('SessionTokenStore', () => {
test('mint returns a token with default 1h TTL', () => {
const now = 1_000_000;
const store = new SessionTokenStore(() => now);
const result = store.mint({
identity: 'user@example.com',
capability: 'interact',
origin: 'self_service',
});
expect(result).toMatchObject({
identity: 'user@example.com',
capability: 'interact',
origin: 'self_service',
});
if ('error' in result) throw new Error('unexpected error');
expect(result.expires_at).toBe(now + 60 * 60 * 1000);
});
test('mint caps TTL at 24h', () => {
const now = 1_000_000;
const store = new SessionTokenStore(() => now);
const result = store.mint({
identity: 'u',
capability: 'observe',
ttlMs: 1_000_000_000, // way over 24h
origin: 'self_service',
});
if ('error' in result) throw new Error('unexpected error');
expect(result.expires_at).toBe(now + 24 * 60 * 60 * 1000);
});
test('validate returns ok for fresh token at the required tier', () => {
const store = new SessionTokenStore();
const result = store.mint({ identity: 'u', capability: 'mutate', origin: 'owner_granted' });
if ('error' in result) throw new Error('unexpected error');
const v = store.validate(result.token, 'observe');
expect(v.ok).toBe(true);
});
test('validate rejects null/empty/unknown tokens', () => {
const store = new SessionTokenStore();
expect(store.validate(null, 'observe')).toEqual({ ok: false, reason: 'no_token' });
expect(store.validate('', 'observe')).toEqual({ ok: false, reason: 'no_token' });
expect(store.validate('bogus-token', 'observe')).toEqual({ ok: false, reason: 'invalid_token' });
});
test('validate rejects expired tokens', () => {
let now = 1_000_000;
const store = new SessionTokenStore(() => now);
const result = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
if ('error' in result) throw new Error('unexpected error');
now += 25 * 60 * 60 * 1000; // 25 hours later — past max TTL
expect(store.validate(result.token, 'observe')).toEqual({ ok: false, reason: 'expired_token' });
});
test('validate rejects tokens with insufficient capability', () => {
const store = new SessionTokenStore();
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
if ('error' in r) throw new Error('unexpected');
expect(store.validate(r.token, 'interact')).toEqual({ ok: false, reason: 'capability_insufficient' });
expect(store.validate(r.token, 'mutate')).toEqual({ ok: false, reason: 'capability_insufficient' });
expect(store.validate(r.token, 'restore')).toEqual({ ok: false, reason: 'capability_insufficient' });
});
test('higher capability tiers cover lower tiers', () => {
expect(capabilityCovers('restore', 'mutate')).toBe(true);
expect(capabilityCovers('restore', 'interact')).toBe(true);
expect(capabilityCovers('restore', 'observe')).toBe(true);
expect(capabilityCovers('mutate', 'interact')).toBe(true);
expect(capabilityCovers('observe', 'interact')).toBe(false);
expect(capabilityCovers('observe', 'mutate')).toBe(false);
});
test('heartbeat extends TTL', () => {
let now = 1_000_000;
const store = new SessionTokenStore(() => now);
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
if ('error' in r) throw new Error('unexpected');
const originalExpiry = r.expires_at;
now += 30 * 60 * 1000; // 30 min later
const newExpiry = store.heartbeat(r.token);
expect(newExpiry).not.toBeNull();
expect(newExpiry!).toBeGreaterThan(originalExpiry);
expect(newExpiry!).toBe(now + 60 * 60 * 1000);
});
test('heartbeat after expiry returns null', () => {
let now = 1_000_000;
const store = new SessionTokenStore(() => now);
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
if ('error' in r) throw new Error('unexpected');
now += 25 * 60 * 60 * 1000; // past max TTL
expect(store.heartbeat(r.token)).toBeNull();
});
test('rate limit blocks the 11th mint within 60s window', () => {
const now = 1_000_000;
const store = new SessionTokenStore(() => now);
const results = [];
for (let i = 0; i < 11; i++) {
results.push(store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' }));
}
const ok = results.filter(r => !('error' in r));
const errs = results.filter(r => 'error' in r);
expect(ok.length).toBe(10);
expect(errs.length).toBe(1);
expect(errs[0]).toEqual({ error: 'rate_limited' });
});
test('rate limit window slides — 11th mint succeeds after 60s', () => {
let now = 1_000_000;
const store = new SessionTokenStore(() => now);
for (let i = 0; i < 10; i++) {
store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' });
}
now += 61_000; // past window
const r = store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' });
expect('error' in r).toBe(false);
});
test('revoke removes a token', () => {
const store = new SessionTokenStore();
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
if ('error' in r) throw new Error('unexpected');
expect(store.revoke(r.token)).toBe(true);
expect(store.validate(r.token, 'observe')).toEqual({ ok: false, reason: 'invalid_token' });
});
test('revokeByIdentity removes all tokens for one identity', () => {
const store = new SessionTokenStore();
const a1 = store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' });
const a2 = store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' });
const b1 = store.mint({ identity: 'b', capability: 'observe', origin: 'self_service' });
if ('error' in a1 || 'error' in a2 || 'error' in b1) throw new Error('unexpected');
expect(store.revokeByIdentity('a')).toBe(2);
expect(store.validate(a1.token, 'observe').ok).toBe(false);
expect(store.validate(a2.token, 'observe').ok).toBe(false);
expect(store.validate(b1.token, 'observe').ok).toBe(true);
});
test('list returns all active tokens', () => {
const store = new SessionTokenStore();
store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' });
store.mint({ identity: 'b', capability: 'mutate', origin: 'owner_granted' });
expect(store.list().length).toBe(2);
});
});
@@ -0,0 +1,96 @@
// Single-instance enforcement tests.
//
// Codex-flagged: spawn-race conditions, stale pidfile reclamation, readiness
// protocol timeout.
import { describe, test, expect, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { tryClaim } from '../src/single-instance';
let tmpDir: string;
let pidPath: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-pidfile-'));
pidPath = join(tmpDir, 'daemon.pid');
});
describe('tryClaim', () => {
test('first claim succeeds and writes pidfile', async () => {
const r = await tryClaim({ port: 9099, path: pidPath });
expect(r.claimed).toBe(true);
expect(existsSync(pidPath)).toBe(true);
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8'));
expect(parsed.pid).toBe(process.pid);
expect(parsed.port).toBe(9099);
if (r.claimed) await r.release();
});
test('second claim against same live PID returns existing', async () => {
// Fake a live pidfile pointing to OUR pid (since we definitely exist).
writeFileSync(pidPath, JSON.stringify({
pid: process.pid,
port: 9099,
startedAt: Date.now(),
}));
const r = await tryClaim({ port: 9100, path: pidPath });
expect(r.claimed).toBe(false);
if (!r.claimed) {
expect(r.existing.pid).toBe(process.pid);
expect(r.existing.port).toBe(9099);
}
});
test('claim reclaims stale pidfile (dead PID)', async () => {
// PID 1 is init/launchd; pick a PID that doesn't exist. PID 999999 is
// not assigned in any realistic system.
writeFileSync(pidPath, JSON.stringify({
pid: 999999,
port: 9099,
startedAt: Date.now() - 60_000,
}));
const r = await tryClaim({ port: 9100, path: pidPath });
expect(r.claimed).toBe(true);
if (r.claimed) {
// New pidfile reflects us.
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8'));
expect(parsed.pid).toBe(process.pid);
expect(parsed.port).toBe(9100);
await r.release();
}
});
test('claim handles unparseable pidfile by reclaiming', async () => {
writeFileSync(pidPath, 'not json');
const r = await tryClaim({ port: 9101, path: pidPath });
expect(r.claimed).toBe(true);
if (r.claimed) await r.release();
});
// Codex-flagged: concurrent spawn race. Multiple invocations must result in
// exactly one claim winning, with the rest seeing the winner's pidfile.
test('concurrent claims race deterministically — exactly one wins', async () => {
// Pre-clean: ensure no pidfile.
if (existsSync(pidPath)) rmSync(pidPath);
const N = 10;
const promises: Promise<{ claimed: boolean }>[] = [];
for (let i = 0; i < N; i++) {
promises.push(tryClaim({ port: 9099 + i, path: pidPath }));
}
const results = await Promise.all(promises);
const wins = results.filter(r => r.claimed);
const losses = results.filter(r => !r.claimed);
expect(wins.length).toBe(1);
expect(losses.length).toBe(N - 1);
// Cleanup the winner.
const winner = wins[0] as unknown as { claimed: true; release: () => Promise<void> };
await winner.release();
});
});
import { afterEach } from 'bun:test';
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
@@ -0,0 +1,55 @@
// tailscaled LocalAPI client tests. Codex-flagged: identity canonicalization
// for user / tag / node-key forms, fail-closed semantics on missing socket
// or unparseable response.
import { describe, test, expect } from 'bun:test';
import { canonicalize, probeTailscale } from '../src/tailscale-localapi';
describe('canonicalize', () => {
test('returns lowercased user email when UserProfile.LoginName present', () => {
const out = canonicalize({
Node: { Tags: undefined },
UserProfile: { LoginName: 'Alice@Example.COM' },
});
expect(out).toBe('alice@example.com');
});
test('returns tagged node identity when tags present (prefers tag over user)', () => {
const out = canonicalize({
Node: { Tags: ['tag:CI'] },
UserProfile: { LoginName: 'admin@example.com' },
});
expect(out).toBe('tag:ci');
});
test('handles tag without prefix', () => {
const out = canonicalize({
Node: { Tags: ['ci'] },
});
expect(out).toBe('tag:ci');
});
test('returns node:<key> when no user and no tags', () => {
const out = canonicalize({
Node: { Key: 'nodekey:abcdef0123' },
});
expect(out).toBe('node:abcdef0123');
});
test('returns null for unparseable response', () => {
expect(canonicalize({})).toBeNull();
expect(canonicalize({ Node: {} })).toBeNull();
expect(canonicalize({ UserProfile: { LoginName: 'no-at-sign' } })).toBeNull();
});
});
describe('probeTailscale', () => {
test('fails closed when socket does not exist', async () => {
const r = await probeTailscale('/tmp/does-not-exist-' + Math.random());
expect(r.ok).toBe(false);
// Reason may be 'socket_missing' or 'unreachable' depending on how the
// OS/runtime surfaces a missing unix socket. Either is a fail-closed
// outcome that prevents the daemon from opening the tailnet listener.
expect(['socket_missing', 'unreachable']).toContain(r.reason);
});
});