mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-26 22:51:47 +02:00
feat(ios): ship gstack-ios-qa-daemon + gstack-ios-qa-mint launchers
The skill doc has been telling users to run `gstack-ios-qa-daemon` and `gstack-ios-qa-mint` since v1.41.0.0, but neither binary actually existed. Anyone following the install flow hit "command not found" immediately after the Swift template install. Adds the missing pieces: - bin/gstack-ios-qa-daemon — bash shim that execs `bun run ios-qa/daemon/src/index.ts`. Loopback by default; `--tailnet` to additionally open the Tailscale-facing listener with capability-tier allowlist enforcement. - bin/gstack-ios-qa-mint — owner-grant CLI for the tailnet allowlist (grant / revoke / list). Writes ~/.gstack/ios-qa-allowlist.json at mode 0600. Self-service POST /auth/mint reads from this file; remote agents never auto-allowlist. - ios-qa/daemon/src/cli-mint.ts — TS implementation behind the shim. Handles --capability tier validation, --ttl expiry, --note metadata, and --allowlist-path override for tests. - ios-qa/daemon/src/allowlist.ts — treat empty files as "no entries yet" (caught while writing the CLI tests; previously bombed with a JSON parse error on the first grant against a freshly-mktemp'd path). Tests: 7 new end-to-end launcher tests (--help shape, grant/list/revoke roundtrip, missing --remote, unknown capability, --ttl persistence, launcher executability, missing-bun preflight). All 81 daemon tests pass. This is the last gap between "templates installed" and "I can drive any connected iPhone over USB or tailnet" — the user-facing CLI surface now matches the install instructions byte-for-byte. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c2f2acebf6
commit
4274b1204d
Executable
+39
@@ -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=<n> pid=<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" "$@"
|
||||||
Executable
+28
@@ -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" "$@"
|
||||||
@@ -17,13 +17,9 @@ export function defaultAllowlistPath(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise<Allowlist> {
|
export async function loadAllowlist(path: string = defaultAllowlistPath()): Promise<Allowlist> {
|
||||||
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(path, 'utf-8');
|
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) {
|
} catch (err: unknown) {
|
||||||
const e = err as { code?: string };
|
const e = err as { code?: string };
|
||||||
if (e.code === 'ENOENT') {
|
if (e.code === 'ENOENT') {
|
||||||
@@ -31,6 +27,17 @@ export async function loadAllowlist(path: string = defaultAllowlistPath()): Prom
|
|||||||
}
|
}
|
||||||
throw err;
|
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<void> {
|
export async function saveAllowlist(allowlist: Allowlist, path: string = defaultAllowlistPath()): Promise<void> {
|
||||||
|
|||||||
@@ -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 <identity> [--capability <tier>] [--ttl <seconds>] [--note <text>]
|
||||||
|
gstack-ios-qa-mint revoke --remote <identity>
|
||||||
|
gstack-ios-qa-mint list
|
||||||
|
|
||||||
|
ARGUMENTS
|
||||||
|
--remote <identity> Canonical tailnet identity (e.g. user@example.com or tag:ci).
|
||||||
|
--capability <tier> observe | interact (default) | mutate | restore
|
||||||
|
--ttl <seconds> Optional expiry. Omit for no-expiry entry.
|
||||||
|
--note <text> Free-form note kept alongside the entry.
|
||||||
|
--allowlist-path <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<void> {
|
||||||
|
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 <identity> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user