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
+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) {