feat: add gateway lock, shell completion, and tailscale serve (Tier 4 features 1-3)

This commit is contained in:
William Valentin
2026-02-09 13:29:59 -08:00
parent 9be8f76bc7
commit 4413c4dc7c
12 changed files with 535 additions and 5 deletions
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { generateBashCompletion, generateZshCompletion, generateFishCompletion } from './completion.js';
const EXPECTED_SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion'];
describe('generateBashCompletion', () => {
it('generates valid bash completion', () => {
const script = generateBashCompletion();
expect(script).toContain('_flynn_completions');
expect(script).toContain('complete -F _flynn_completions flynn');
});
it('contains all subcommands', () => {
const script = generateBashCompletion();
for (const cmd of EXPECTED_SUBCOMMANDS) {
expect(script).toContain(cmd);
}
});
it('contains global options', () => {
const script = generateBashCompletion();
expect(script).toContain('--help');
expect(script).toContain('--version');
});
it('contains subcommand-specific options', () => {
const script = generateBashCompletion();
expect(script).toContain('--config');
expect(script).toContain('--fullscreen');
expect(script).toContain('--no-tools');
expect(script).toContain('--raw');
});
});
describe('generateZshCompletion', () => {
it('generates valid zsh completion', () => {
const script = generateZshCompletion();
expect(script).toContain('#compdef flynn');
expect(script).toContain('compdef _flynn flynn');
});
it('contains all subcommands', () => {
const script = generateZshCompletion();
for (const cmd of EXPECTED_SUBCOMMANDS) {
expect(script).toContain(cmd);
}
});
it('contains subcommand-specific options', () => {
const script = generateZshCompletion();
expect(script).toContain('--config');
expect(script).toContain('--fullscreen');
});
});
describe('generateFishCompletion', () => {
it('generates valid fish completion', () => {
const script = generateFishCompletion();
expect(script).toContain('complete -c flynn');
expect(script).toContain('__fish_use_subcommand');
});
it('contains all subcommands', () => {
const script = generateFishCompletion();
for (const cmd of EXPECTED_SUBCOMMANDS) {
expect(script).toContain(cmd);
}
});
it('contains subcommand-specific options', () => {
const script = generateFishCompletion();
expect(script).toContain('__fish_seen_subcommand_from start');
expect(script).toContain('__fish_seen_subcommand_from tui');
});
it('contains shell type completions for completion subcommand', () => {
const script = generateFishCompletion();
expect(script).toContain("'bash zsh fish'");
});
});
+149
View File
@@ -0,0 +1,149 @@
import type { Command } from 'commander';
import { mkdirSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
const SUBCOMMANDS = ['start', 'tui', 'send', 'sessions', 'doctor', 'config', 'completion'];
const SUBCOMMAND_OPTIONS: Record<string, string[]> = {
start: ['-c', '--config'],
tui: ['-c', '--config', '-f', '--fullscreen'],
send: ['-c', '--config', '--no-tools'],
config: ['-c', '--config', '--raw'],
completion: ['--install'],
};
export function generateBashCompletion(): string {
const subcmds = SUBCOMMANDS.join(' ');
const cases = Object.entries(SUBCOMMAND_OPTIONS)
.map(([cmd, opts]) => ` ${cmd}) COMPREPLY=( $(compgen -W "${opts.join(' ')}" -- "$cur") ) ;;`)
.join('\n');
return `# Flynn bash completion — generated by 'flynn completion bash'
_flynn_completions() {
local cur prev subcmd
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
if [[ \${COMP_CWORD} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "${subcmds} --help --version" -- "$cur") )
return
fi
subcmd="\${COMP_WORDS[1]}"
case "$subcmd" in
${cases}
completion) COMPREPLY=( $(compgen -W "bash zsh fish --install" -- "$cur") ) ;;
*) COMPREPLY=() ;;
esac
}
complete -F _flynn_completions flynn
`;
}
export function generateZshCompletion(): string {
const subcmds = SUBCOMMANDS.map(c => `'${c}:${c} subcommand'`).join(' ');
const cases = Object.entries(SUBCOMMAND_OPTIONS)
.map(([cmd, opts]) => {
const flags = opts.filter(o => o.startsWith('--')).map(o => `'${o}'`).join(' ');
return ` ${cmd}) compadd ${flags} ;;`;
})
.join('\n');
return `#compdef flynn
# Flynn zsh completion — generated by 'flynn completion zsh'
_flynn() {
local -a subcmds
subcmds=(${subcmds})
if (( CURRENT == 2 )); then
_describe 'command' subcmds
return
fi
case "\${words[2]}" in
${cases}
completion) compadd bash zsh fish '--install' ;;
*) ;;
esac
}
compdef _flynn flynn
`;
}
export function generateFishCompletion(): string {
const lines = [
`# Flynn fish completion — generated by 'flynn completion fish'`,
`# Disable file completions by default`,
`complete -c flynn -f`,
``,
`# Subcommands`,
];
for (const cmd of SUBCOMMANDS) {
lines.push(`complete -c flynn -n '__fish_use_subcommand' -a '${cmd}' -d '${cmd} subcommand'`);
}
lines.push(`complete -c flynn -n '__fish_use_subcommand' -l help -d 'Show help'`);
lines.push(`complete -c flynn -n '__fish_use_subcommand' -l version -d 'Show version'`);
lines.push('');
lines.push('# Subcommand options');
for (const [cmd, opts] of Object.entries(SUBCOMMAND_OPTIONS)) {
for (const opt of opts) {
const flag = opt.replace(/^-+/, '');
const short = opt.startsWith('--') ? '' : ` -s ${flag}`;
const long = opt.startsWith('--') ? ` -l ${flag}` : '';
lines.push(`complete -c flynn -n '__fish_seen_subcommand_from ${cmd}'${short}${long}`);
}
}
lines.push(`complete -c flynn -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' -d 'Shell type'`);
lines.push('');
return lines.join('\n');
}
function getInstallPath(shell: string): string {
const home = homedir();
switch (shell) {
case 'bash': return resolve(home, '.local/share/bash-completion/completions/flynn');
case 'zsh': return resolve(home, '.zfunc/_flynn');
case 'fish': return resolve(home, '.config/fish/completions/flynn.fish');
default: throw new Error(`Unknown shell: ${shell}`);
}
}
export function registerCompletionCommand(program: Command): void {
program
.command('completion')
.description('Generate shell completion script')
.argument('<shell>', 'Shell type: bash, zsh, or fish')
.option('--install', 'Install to standard location')
.action((shell: string, opts: { install?: boolean }) => {
const generators: Record<string, () => string> = {
bash: generateBashCompletion,
zsh: generateZshCompletion,
fish: generateFishCompletion,
};
const generator = generators[shell];
if (!generator) {
console.error(`Unknown shell: ${shell}. Supported: bash, zsh, fish`);
process.exit(1);
}
const script = generator();
if (opts.install) {
const installPath = getInstallPath(shell);
mkdirSync(resolve(installPath, '..'), { recursive: true });
writeFileSync(installPath, script, 'utf-8');
console.log(`Completion script installed to: ${installPath}`);
if (shell === 'zsh') {
console.log(`Ensure ~/.zfunc is in your fpath: fpath=(~/.zfunc $fpath)`);
}
} else {
process.stdout.write(script);
}
});
}
+17
View File
@@ -185,6 +185,22 @@ const checkSkills: Check = async (ctx) => {
}
};
const checkTailscale: Check = async (ctx) => {
if (!ctx.config?.server?.tailscale?.serve) {
return { status: 'skip', label: 'Tailscale Serve', detail: '(not enabled)' };
}
try {
const { isTailscaleAvailable } = await import('../gateway/tailscale.js');
const result = await isTailscaleAvailable();
if (result.available) {
return { status: 'pass', label: 'Tailscale Serve', detail: `(v${result.version})` };
}
return { status: 'fail', label: 'Tailscale Serve', detail: result.error ?? 'Tailscale not available' };
} catch (err) {
return { status: 'fail', label: 'Tailscale Serve', detail: err instanceof Error ? err.message : String(err) };
}
};
const allChecks: Check[] = [
checkConfigExists,
checkConfigParses,
@@ -196,6 +212,7 @@ const allChecks: Check[] = [
checkTelegram,
checkMcpServers,
checkSkills,
checkTailscale,
];
/** Run all doctor checks in order. Exported for testing. */
+2
View File
@@ -6,6 +6,7 @@ import { registerSessionsCommand } from './sessions.js';
import { registerDoctorCommand } from './doctor.js';
import { registerConfigCommand } from './config-cmd.js';
import { registerTuiCommand } from './tui.js';
import { registerCompletionCommand } from './completion.js';
export function createProgram(): Command {
const program = new Command();
@@ -21,6 +22,7 @@ export function createProgram(): Command {
registerSessionsCommand(program);
registerDoctorCommand(program);
registerConfigCommand(program);
registerCompletionCommand(program);
return program;
}
+21 -1
View File
@@ -6,8 +6,26 @@ const telegramSchema = z.object({
require_mention: z.boolean().default(true),
});
const tailscaleSchema = z.object({
/** Enable Tailscale Serve to expose gateway on tailnet. */
serve: z.boolean().default(false),
/** Custom hostname for Tailscale Serve. Defaults to machine hostname. */
hostname: z.string().optional(),
/** Tailscale Serve HTTPS port. */
port: z.number().default(443),
}).default({});
const pairingSchema = z.object({
/** Enable DM pairing codes for unknown senders. */
enabled: z.boolean().default(false),
/** Pairing code time-to-live duration (e.g. '5m', '1h'). */
code_ttl: z.string().default('5m'),
/** Length of generated pairing codes. */
code_length: z.number().default(6),
}).default({});
const serverSchema = z.object({
tailscale_only: z.boolean().default(true),
tailscale: tailscaleSchema,
localhost: z.boolean().default(true),
port: z.number().default(18800),
/** Static bearer token for gateway auth. If set, all connections must provide it. */
@@ -16,6 +34,8 @@ const serverSchema = z.object({
tailscale_identity: z.boolean().default(false),
/** Apply token auth to HTTP requests too (not just WebSocket). Default: true when token is set. */
auth_http: z.boolean().default(true),
/** Single-client gateway lock. When true, only one WebSocket client can be connected at a time. */
lock: z.boolean().default(false),
});
const modelConfigBaseSchema = z.object({
+19
View File
@@ -811,6 +811,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
tailscaleIdentity: config.server.tailscale_identity,
},
authHttp: config.server.auth_http,
lock: config.server.lock,
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
config,
channelRegistry,
@@ -1002,6 +1003,24 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
await gateway.start();
// ── Tailscale Serve ────────────────────────────────────────────
if (config.server.tailscale?.serve) {
const { startTailscaleServe, stopTailscaleServe } = await import('../gateway/tailscale.js');
const tsConfig = {
localPort: config.server.port,
servePort: config.server.tailscale.port,
hostname: config.server.tailscale.hostname,
};
try {
await startTailscaleServe(tsConfig);
lifecycle.onShutdown(async () => {
await stopTailscaleServe(tsConfig);
});
} catch {
console.warn('Tailscale Serve failed to start — gateway still accessible on local port');
}
}
// ── Heartbeat Monitor ──────────────────────────────────────────
const heartbeatMonitor = new HeartbeatMonitor({
config: config.automation.heartbeat,
+3 -3
View File
@@ -444,7 +444,7 @@ describe('config handlers', () => {
function makeConfig() {
return {
telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] },
server: { tailscale_only: true, localhost: true, port: 18800 },
server: { tailscale: {}, localhost: true, port: 18800 },
models: {
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
fallback_chain: ['anthropic'],
@@ -551,7 +551,7 @@ describe('redactConfig comprehensive credential redaction', () => {
telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true },
discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true },
slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false },
server: { tailscale_only: true, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true },
server: { tailscale: {}, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true },
models: {
default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def',
fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' },
@@ -704,7 +704,7 @@ describe('redactConfig comprehensive credential redaction', () => {
expect((result.slack as any).allowed_channel_ids).toEqual([]);
// server
expect((result.server as any).port).toBe(18800);
expect((result.server as any).tailscale_only).toBe(true);
expect((result.server as any).tailscale).toBeDefined();
// models
expect((result.models as any).default.provider).toBe('anthropic');
expect((result.models as any).default.model).toBe('claude');
+2
View File
@@ -8,6 +8,8 @@ export { LaneQueue } from './lane-queue.js';
export { authenticateRequest } from './auth.js';
export type { AuthConfig, AuthResult } from './auth.js';
export { serveStatic } from './static.js';
export { isTailscaleAvailable, startTailscaleServe, stopTailscaleServe } from './tailscale.js';
export type { TailscaleServeConfig } from './tailscale.js';
export {
ErrorCode,
isValidRequest,
+18
View File
@@ -11,6 +11,7 @@ import type { AuthConfig } from './auth.js';
import {
parseMessage,
makeError,
makeResponse,
ErrorCode,
type OutboundMessage,
} from './protocol.js';
@@ -39,6 +40,8 @@ export interface GatewayServerConfig {
toolExecutor: ToolExecutor;
version?: string;
auth?: AuthConfig;
/** When true, only one WebSocket client can be connected at a time. */
lock?: boolean;
/** Whether to apply token auth to HTTP requests too (default: true when token is set). */
authHttp?: boolean;
uiDir?: string;
@@ -159,6 +162,15 @@ export class GatewayServer {
this.handleConnection(ws, authResult.identity);
});
// Register system.lock handler (needs access to connectionMap)
this.router.register('system.lock', async (request) => {
return makeResponse(request.id, {
locked: this.config.lock ?? false,
activeClients: this.connectionMap.size,
maxClients: this.config.lock ? 1 : null,
});
});
this.httpServer.listen(port, host, () => {
console.log(`Gateway server listening on ${host}:${port}`);
resolve();
@@ -199,6 +211,12 @@ export class GatewayServer {
}
private handleConnection(ws: WebSocket, identity?: string): void {
// Gateway lock — reject if another client is already connected
if (this.config.lock && this.connectionMap.size > 0) {
ws.close(4003, 'Gateway locked — another client is already connected');
return;
}
const connectionId = randomUUID();
this.sessionBridge.connect(connectionId);
this.connectionMap.set(ws, connectionId);
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { execFile } from 'child_process';
// Mock child_process before importing module
vi.mock('child_process', () => ({
execFile: vi.fn(),
}));
const mockExecFile = vi.mocked(execFile);
describe('tailscale', () => {
// Import after mocking
let isTailscaleAvailable: typeof import('./tailscale.js').isTailscaleAvailable;
let startTailscaleServe: typeof import('./tailscale.js').startTailscaleServe;
let stopTailscaleServe: typeof import('./tailscale.js').stopTailscaleServe;
beforeAll(async () => {
const mod = await import('./tailscale.js');
isTailscaleAvailable = mod.isTailscaleAvailable;
startTailscaleServe = mod.startTailscaleServe;
stopTailscaleServe = mod.stopTailscaleServe;
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('isTailscaleAvailable', () => {
it('returns available when tailscale CLI works', async () => {
mockExecFile
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(null, '1.62.0', '');
return {} as any;
})
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(null, '{}', '');
return {} as any;
});
const result = await isTailscaleAvailable();
expect(result.available).toBe(true);
expect(result.version).toBe('1.62.0');
});
it('returns unavailable when tailscale CLI fails', async () => {
mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(new Error('command not found'), '', 'command not found');
return {} as any;
});
const result = await isTailscaleAvailable();
expect(result.available).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('startTailscaleServe', () => {
it('calls tailscale serve with correct args', async () => {
mockExecFile
// serve command
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(null, '', '');
return {} as any;
})
// status for hostname
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(null, JSON.stringify({ Self: { DNSName: 'myhost.tailnet.ts.net.' } }), '');
return {} as any;
});
const url = await startTailscaleServe({ localPort: 18800 });
expect(url).toBe('https://myhost.tailnet.ts.net');
const serveCall = mockExecFile.mock.calls[0];
expect(serveCall[0]).toBe('tailscale');
expect(serveCall[1]).toEqual(['serve', '--bg', '--https=443', 'http://127.0.0.1:18800']);
});
it('uses custom serve port', async () => {
mockExecFile
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(null, '', '');
return {} as any;
})
.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(null, JSON.stringify({ Self: { DNSName: 'myhost.tailnet.ts.net.' } }), '');
return {} as any;
});
const url = await startTailscaleServe({ localPort: 18800, servePort: 8443 });
expect(url).toBe('https://myhost.tailnet.ts.net:8443');
const serveCall = mockExecFile.mock.calls[0];
expect(serveCall[1]).toEqual(['serve', '--bg', '--https=8443', 'http://127.0.0.1:18800']);
});
});
describe('stopTailscaleServe', () => {
it('calls tailscale serve off', async () => {
mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(null, '', '');
return {} as any;
});
await stopTailscaleServe({ localPort: 18800 });
expect(mockExecFile).toHaveBeenCalledOnce();
const call = mockExecFile.mock.calls[0];
expect(call[0]).toBe('tailscale');
expect(call[1]).toEqual(['serve', '--https=443', 'off']);
});
it('does not throw on failure', async () => {
mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback: any) => {
callback(new Error('failed'), '', 'failed');
return {} as any;
});
// Should not throw
await stopTailscaleServe({ localPort: 18800 });
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { execFile } from 'child_process';
export interface TailscaleServeConfig {
/** The gateway's local port (e.g. 18800). */
localPort: number;
/** Tailscale Serve HTTPS port (default 443). */
servePort?: number;
/** Custom hostname (default: machine hostname). */
hostname?: string;
}
/** Execute a command with a timeout, returning stdout. */
function exec(cmd: string, args: string[], timeoutMs = 5000): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`${cmd} ${args.join(' ')} failed: ${stderr || error.message}`));
} else {
resolve(stdout.trim());
}
});
});
}
/**
* Check if Tailscale CLI is available and the daemon is running.
*/
export async function isTailscaleAvailable(): Promise<{ available: boolean; version?: string; error?: string }> {
try {
const version = await exec('tailscale', ['version', '--short']);
// Check if daemon is running by getting status
await exec('tailscale', ['status', '--json']);
return { available: true, version };
} catch (err) {
return { available: false, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Get the MagicDNS hostname from Tailscale status.
*/
async function getTailscaleHostname(): Promise<string | undefined> {
try {
const statusJson = await exec('tailscale', ['status', '--json']);
const status = JSON.parse(statusJson) as { Self?: { DNSName?: string } };
const dnsName = status.Self?.DNSName;
// DNSName ends with a trailing dot — strip it
return dnsName?.replace(/\.$/, '');
} catch {
return undefined;
}
}
/**
* Start Tailscale Serve to expose the local gateway port on the tailnet.
*
* Runs: `tailscale serve --bg --https=<servePort> http://127.0.0.1:<localPort>`
*/
export async function startTailscaleServe(config: TailscaleServeConfig): Promise<string | undefined> {
const servePort = config.servePort ?? 443;
const target = `http://127.0.0.1:${config.localPort}`;
try {
await exec('tailscale', ['serve', '--bg', `--https=${servePort}`, target]);
const hostname = config.hostname ?? await getTailscaleHostname();
if (hostname) {
const url = servePort === 443 ? `https://${hostname}` : `https://${hostname}:${servePort}`;
console.log(`Tailscale Serve: Flynn available at ${url}`);
return url;
}
console.log('Tailscale Serve: started (could not determine hostname)');
return undefined;
} catch (err) {
console.error('Failed to start Tailscale Serve:', err instanceof Error ? err.message : err);
throw err;
}
}
/**
* Stop Tailscale Serve for the configured port.
*
* Runs: `tailscale serve --https=<servePort> off`
*/
export async function stopTailscaleServe(config: TailscaleServeConfig): Promise<void> {
const servePort = config.servePort ?? 443;
try {
await exec('tailscale', ['serve', `--https=${servePort}`, 'off']);
console.log('Tailscale Serve: stopped');
} catch (err) {
// Best effort — don't fail shutdown
console.error('Failed to stop Tailscale Serve:', err instanceof Error ? err.message : err);
}
}
+7 -1
View File
@@ -61,8 +61,14 @@ export class FlynnClient {
this._handleMessage(event.data);
};
this._ws.onclose = () => {
this._ws.onclose = (event) => {
this._ws = null;
// Gateway lock — show specific message and don't auto-reconnect
if (event.code === 4003) {
this._setStatus('locked');
this._autoReconnect = false;
return;
}
this._setStatus('disconnected');
// Reject all pending requests
for (const [id, pending] of this._pending) {