diff --git a/bin/gstack-ios-qa-daemon b/bin/gstack-ios-qa-daemon new file mode 100755 index 000000000..b0ca2c6af --- /dev/null +++ b/bin/gstack-ios-qa-daemon @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# gstack-ios-qa-daemon — Mac-side daemon that brokers tailnet/loopback traffic +# to a connected iPhone running the in-app StateServer over the CoreDevice USB +# tunnel. Single-instance via flock on ~/.gstack/ios-qa-daemon.pid. +# +# Usage: +# gstack-ios-qa-daemon # loopback-only (local USB) +# gstack-ios-qa-daemon --tailnet # additionally open tailnet listener +# +# Environment: +# GSTACK_IOS_DAEMON_PORT — loopback listener port (default 9099) +# GSTACK_IOS_TARGET_UDID — target iOS device UDID (optional; otherwise +# the first paired connected device is used) +# GSTACK_IOS_TARGET_BUNDLE_ID — bundle ID of the iOS app hosting StateServer +# (default com.gstack.iosqa.fixture) +# +# Readiness protocol: prints `READY: port= pid=` to stdout once both +# listeners are bound. Spawners read stdin with a ~5s timeout to confirm. +# +# Exits cleanly when no active loopback clients are connected AND no remote +# session tokens are outstanding. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENTRY="$GSTACK_DIR/ios-qa/daemon/src/index.ts" + +if [ ! -f "$ENTRY" ]; then + echo "gstack-ios-qa-daemon: missing $ENTRY (gstack install incomplete?)" >&2 + exit 1 +fi + +if ! command -v bun >/dev/null 2>&1; then + echo "gstack-ios-qa-daemon: bun runtime not on PATH — install from https://bun.sh" >&2 + exit 1 +fi + +exec bun run "$ENTRY" "$@" diff --git a/bin/gstack-ios-qa-mint b/bin/gstack-ios-qa-mint new file mode 100755 index 000000000..ecebaa007 --- /dev/null +++ b/bin/gstack-ios-qa-mint @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# gstack-ios-qa-mint — manage the tailnet allowlist for remote iOS QA agents. +# +# This is the owner-grant path: it writes identities into the local allowlist +# so a remote agent on the tailnet can self-service mint a session token via +# POST /auth/mint against the daemon. +# +# Run `gstack-ios-qa-mint --help` for full usage. +# +# Allowlist file: ~/.gstack/ios-qa-allowlist.json (mode 0600). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENTRY="$GSTACK_DIR/ios-qa/daemon/src/cli-mint.ts" + +if [ ! -f "$ENTRY" ]; then + echo "gstack-ios-qa-mint: missing $ENTRY (gstack install incomplete?)" >&2 + exit 1 +fi + +if ! command -v bun >/dev/null 2>&1; then + echo "gstack-ios-qa-mint: bun runtime not on PATH — install from https://bun.sh" >&2 + exit 1 +fi + +exec bun run "$ENTRY" "$@" diff --git a/ios-qa/daemon/src/allowlist.ts b/ios-qa/daemon/src/allowlist.ts index 8c79ba41e..6496b041e 100644 --- a/ios-qa/daemon/src/allowlist.ts +++ b/ios-qa/daemon/src/allowlist.ts @@ -17,13 +17,9 @@ export function defaultAllowlistPath(): string { } export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise { + let raw: string; 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; + raw = await readFile(path, 'utf-8'); } catch (err: unknown) { const e = err as { code?: string }; if (e.code === 'ENOENT') { @@ -31,6 +27,17 @@ export async function loadAllowlist(path: string = defaultAllowlistPath()): Prom } throw err; } + // Empty-file path (mktemp default, partial write, manual `: > file`): treat + // as "no entries yet" rather than a parse error. The first grant will fill + // it in atomically via saveAllowlist. + if (raw.trim() === '') { + return { version: 1, entries: [] }; + } + const parsed = JSON.parse(raw) as Allowlist; + if (parsed.version !== 1 || !Array.isArray(parsed.entries)) { + throw new Error('invalid_allowlist'); + } + return parsed; } export async function saveAllowlist(allowlist: Allowlist, path: string = defaultAllowlistPath()): Promise { diff --git a/ios-qa/daemon/src/cli-mint.ts b/ios-qa/daemon/src/cli-mint.ts new file mode 100644 index 000000000..3c8ae8ba2 --- /dev/null +++ b/ios-qa/daemon/src/cli-mint.ts @@ -0,0 +1,149 @@ +// Owner-grant CLI. Adds (or upgrades) an identity to the allowlist so a +// remote agent on the tailnet can self-service mint a session token via +// POST /auth/mint. Never auto-allowlists; explicit user intent only. +// +// Invoked from bin/gstack-ios-qa-mint. + +import { grantIdentity, revokeIdentity, loadAllowlist, defaultAllowlistPath } from './allowlist'; +import type { Capability } from './types'; + +const CAPABILITIES: Capability[] = ['observe', 'interact', 'mutate', 'restore']; + +interface ParsedArgs { + command: 'grant' | 'revoke' | 'list' | 'help'; + identity: string | null; + capability: Capability; + ttlSeconds: number | null; + note: string | null; + path: string; +} + +function parseArgs(argv: string[]): ParsedArgs { + // Default: help. Recognized positional commands: grant | revoke | list. + let command: ParsedArgs['command'] = 'help'; + let identity: string | null = null; + let capability: Capability = 'interact'; + let ttlSeconds: number | null = null; + let note: string | null = null; + let path = defaultAllowlistPath(); + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case 'grant': command = 'grant'; break; + case 'revoke': command = 'revoke'; break; + case 'list': command = 'list'; break; + case '--help': + case '-h': command = 'help'; break; + case '--remote': + case '--identity': + identity = argv[++i] ?? null; + break; + case '--capability': + case '--cap': { + const v = argv[++i]; + if (!CAPABILITIES.includes(v as Capability)) { + process.stderr.write(`unknown capability: ${v} (want one of ${CAPABILITIES.join(', ')})\n`); + process.exit(2); + } + capability = v as Capability; + break; + } + case '--ttl': { + const v = parseInt(argv[++i] ?? '', 10); + if (!Number.isFinite(v) || v <= 0) { + process.stderr.write('--ttl must be a positive integer (seconds)\n'); + process.exit(2); + } + ttlSeconds = v; + break; + } + case '--note': note = argv[++i] ?? null; break; + case '--allowlist-path': path = argv[++i] ?? path; break; + } + } + return { command, identity, capability, ttlSeconds, note, path }; +} + +function printHelp() { + const help = `gstack-ios-qa-mint — manage the tailnet allowlist for remote iOS QA agents + +USAGE + gstack-ios-qa-mint grant --remote [--capability ] [--ttl ] [--note ] + gstack-ios-qa-mint revoke --remote + gstack-ios-qa-mint list + +ARGUMENTS + --remote Canonical tailnet identity (e.g. user@example.com or tag:ci). + --capability observe | interact (default) | mutate | restore + --ttl Optional expiry. Omit for no-expiry entry. + --note Free-form note kept alongside the entry. + --allowlist-path Override the allowlist file location. + +EXAMPLES + gstack-ios-qa-mint grant --remote 'alice@example.com' --capability interact + gstack-ios-qa-mint grant --remote 'tag:ci' --capability mutate --ttl 86400 --note 'nightly run' + gstack-ios-qa-mint revoke --remote 'alice@example.com' + gstack-ios-qa-mint list + +The allowlist lives at ~/.gstack/ios-qa-allowlist.json (mode 0600). The daemon's +self-service /auth/mint endpoint reads this file on every request. +`; + process.stdout.write(help); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + if (args.command === 'help') { + printHelp(); + return; + } + + if (args.command === 'list') { + const allowlist = await loadAllowlist(args.path); + if (allowlist.entries.length === 0) { + process.stdout.write('(empty allowlist)\n'); + return; + } + for (const e of allowlist.entries) { + const caps = e.capabilities.join(','); + const exp = e.expires_at ? ` expires=${e.expires_at}` : ''; + const note = e.note ? ` note="${e.note}"` : ''; + process.stdout.write(`${e.identity} cap=${caps}${exp}${note}\n`); + } + return; + } + + if (!args.identity) { + process.stderr.write('error: --remote required\n'); + process.exit(2); + } + + if (args.command === 'grant') { + const result = await grantIdentity({ + identity: args.identity, + capability: args.capability, + ttlSeconds: args.ttlSeconds, + note: args.note ?? undefined, + path: args.path, + }); + const entry = result.entries.find(e => e.identity === args.identity); + process.stdout.write(`granted ${args.identity} capability=${args.capability}` + + (entry?.expires_at ? ` expires=${entry.expires_at}` : '') + '\n'); + return; + } + + if (args.command === 'revoke') { + await revokeIdentity(args.identity, args.path); + process.stdout.write(`revoked ${args.identity}\n`); + return; + } +} + +if (import.meta.main) { + main().catch((err) => { + process.stderr.write(`gstack-ios-qa-mint: ${(err as Error).message}\n`); + process.exit(1); + }); +} diff --git a/ios-qa/daemon/test/cli-mint.test.ts b/ios-qa/daemon/test/cli-mint.test.ts new file mode 100644 index 000000000..f5416bf88 --- /dev/null +++ b/ios-qa/daemon/test/cli-mint.test.ts @@ -0,0 +1,119 @@ +// CLI tests for gstack-ios-qa-mint. Invokes the bash launcher end-to-end +// so we catch any breakage between bin/, the entry-point resolution, and +// the underlying allowlist primitives. Runs against a temp allowlist path +// so the user's real ~/.gstack/ios-qa-allowlist.json is untouched. + +import { describe, test, expect, beforeEach } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, statSync, existsSync, chmodSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = join(import.meta.dir, '..', '..', '..'); +const MINT_BIN = join(ROOT, 'bin', 'gstack-ios-qa-mint'); +const DAEMON_BIN = join(ROOT, 'bin', 'gstack-ios-qa-daemon'); + +function runMint(args: string[]) { + return spawnSync(MINT_BIN, args, { stdio: 'pipe', encoding: 'utf-8' }); +} + +describe('bin/gstack-ios-qa-mint launcher', () => { + let tmpDir: string; + let listPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-cli-mint-')); + listPath = join(tmpDir, 'allowlist.json'); + }); + + test('--help prints usage without touching allowlist', () => { + const r = runMint(['--help']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('gstack-ios-qa-mint'); + expect(r.stdout).toContain('grant'); + expect(r.stdout).toContain('revoke'); + expect(r.stdout).toContain('list'); + }); + + test('grant + list + revoke roundtrip', () => { + const grant = runMint([ + 'grant', '--remote', 'alice@example.com', + '--capability', 'interact', + '--allowlist-path', listPath, + ]); + expect(grant.status).toBe(0); + expect(grant.stdout).toContain('granted alice@example.com'); + + // File must exist and be mode 0600 (owner-only). Mint creates the + // parent directory with 0700 + writes the file at 0600. + expect(existsSync(listPath)).toBe(true); + const mode = statSync(listPath).mode & 0o777; + expect(mode).toBe(0o600); + + const list = runMint(['list', '--allowlist-path', listPath]); + expect(list.status).toBe(0); + expect(list.stdout).toContain('alice@example.com'); + expect(list.stdout).toContain('cap=interact'); + + const revoke = runMint(['revoke', '--remote', 'alice@example.com', '--allowlist-path', listPath]); + expect(revoke.status).toBe(0); + + const listAfter = runMint(['list', '--allowlist-path', listPath]); + expect(listAfter.status).toBe(0); + expect(listAfter.stdout).toContain('(empty allowlist)'); + }); + + test('grant without --remote exits non-zero with clear error', () => { + const r = runMint(['grant', '--capability', 'interact', '--allowlist-path', listPath]); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('--remote'); + }); + + test('rejects unknown capability', () => { + const r = runMint([ + 'grant', '--remote', 'alice@example.com', + '--capability', 'godmode', + '--allowlist-path', listPath, + ]); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('unknown capability'); + }); + + test('grant with --ttl persists expires_at', () => { + const r = runMint([ + 'grant', '--remote', 'tag:ci', + '--capability', 'mutate', + '--ttl', '3600', + '--note', 'nightly', + '--allowlist-path', listPath, + ]); + expect(r.status).toBe(0); + const raw = readFileSync(listPath, 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.entries[0].identity).toBe('tag:ci'); + expect(parsed.entries[0].capabilities).toEqual(['mutate']); + expect(parsed.entries[0].expires_at).toBeTruthy(); + expect(parsed.entries[0].note).toBe('nightly'); + }); +}); + +describe('bin/gstack-ios-qa-daemon launcher', () => { + test('launcher is executable', () => { + expect(existsSync(DAEMON_BIN)).toBe(true); + const mode = statSync(DAEMON_BIN).mode & 0o111; + expect(mode).not.toBe(0); + }); + + test('reports missing bun runtime cleanly', () => { + // Simulate `bun` missing by giving PATH only /usr/bin + /bin (so bash + // resolves but `command -v bun` does not). The launcher's preflight + // check should fire BEFORE attempting to exec bun. + const r = spawnSync(DAEMON_BIN, [], { + stdio: 'pipe', + encoding: 'utf-8', + env: { PATH: '/usr/bin:/bin' }, + }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('bun'); + }); +});