diff --git a/src/cli-web-branch.ts b/src/cli-web-branch.ts index b0c9cc979..ea8e5c6e0 100644 --- a/src/cli-web-branch.ts +++ b/src/cli-web-branch.ts @@ -18,6 +18,12 @@ export interface CliFlags { web?: boolean /** Optional project path for web mode: `gsd --web ` or `gsd web start ` */ webPath?: string + /** Custom host to bind web server to: `--host 0.0.0.0` */ + webHost?: string + /** Custom port for web server: `--port 8080` */ + webPort?: number + /** Additional allowed origins for CORS: `--allowed-origins http://192.168.1.10:8080` */ + webAllowedOrigins?: string[] help?: boolean version?: boolean } @@ -54,6 +60,17 @@ export function parseCliArgs(argv: string[]): CliFlags { if (i + 1 < args.length && !args[i + 1].startsWith('-')) { flags.webPath = args[++i] } + } else if (arg === '--host' && i + 1 < args.length) { + flags.webHost = args[++i] + } else if (arg === '--port' && i + 1 < args.length) { + const portStr = args[++i] + const port = parseInt(portStr, 10) + if (Number.isFinite(port) && port > 0 && port < 65536) { + flags.webPort = port + } + } else if (arg === '--allowed-origins' && i + 1 < args.length) { + const origins = args[++i].split(',').map(o => o.trim()).filter(Boolean) + flags.webAllowedOrigins = (flags.webAllowedOrigins ?? []).concat(origins) } else if (arg === '--model' && i + 1 < args.length) { flags.model = args[++i] } else if (arg === '--extension' && i + 1 < args.length) { @@ -266,6 +283,9 @@ export async function runWebCliBranch( cwd: currentCwd, projectSessionsDir, agentDir, + host: flags.webHost, + port: flags.webPort, + allowedOrigins: flags.webAllowedOrigins, }) if (!status.ok) { diff --git a/src/tests/web-mode-cli.test.ts b/src/tests/web-mode-cli.test.ts index 8634618e1..e6b8ae802 100644 --- a/src/tests/web-mode-cli.test.ts +++ b/src/tests/web-mode-cli.test.ts @@ -76,6 +76,9 @@ test('cli.ts branches to web mode before interactive startup and preserves cwd-s cwd, projectSessionsDir: cliWeb.getProjectSessionsDir(cwd), agentDir: join(process.env.HOME || '', '.gsd', 'agent'), + host: undefined, + port: undefined, + allowedOrigins: undefined, }) } finally { rmSync(tmp, { recursive: true, force: true }) diff --git a/src/tests/web-mode-network-flags.test.ts b/src/tests/web-mode-network-flags.test.ts new file mode 100644 index 000000000..216f269ce --- /dev/null +++ b/src/tests/web-mode-network-flags.test.ts @@ -0,0 +1,201 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const cliWeb = await import('../cli-web-branch.ts') +const webMode = await import('../web-mode.ts') + +// ─── CLI flag parsing ──────────────────────────────────────────────── + +test('parseCliArgs captures --host flag', () => { + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '--host', '0.0.0.0']) + assert.equal(flags.web, true) + assert.equal(flags.webHost, '0.0.0.0') +}) + +test('parseCliArgs captures --port flag', () => { + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '--port', '8080']) + assert.equal(flags.web, true) + assert.equal(flags.webPort, 8080) +}) + +test('parseCliArgs ignores invalid port values', () => { + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '--port', 'abc']) + assert.equal(flags.webPort, undefined) +}) + +test('parseCliArgs ignores out-of-range port', () => { + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '--port', '99999']) + assert.equal(flags.webPort, undefined) +}) + +test('parseCliArgs captures --allowed-origins flag', () => { + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '--allowed-origins', 'http://192.168.1.10:3000']) + assert.deepEqual(flags.webAllowedOrigins, ['http://192.168.1.10:3000']) +}) + +test('parseCliArgs splits comma-separated allowed origins', () => { + const flags = cliWeb.parseCliArgs([ + 'node', 'dist/loader.js', '--web', + '--allowed-origins', 'http://192.168.1.10:3000,http://tailscale-host:3000', + ]) + assert.deepEqual(flags.webAllowedOrigins, ['http://192.168.1.10:3000', 'http://tailscale-host:3000']) +}) + +test('parseCliArgs captures all web network flags together', () => { + const flags = cliWeb.parseCliArgs([ + 'node', 'dist/loader.js', '--web', + '--host', '0.0.0.0', + '--port', '4000', + '--allowed-origins', 'http://my-tailscale:4000', + ]) + assert.equal(flags.webHost, '0.0.0.0') + assert.equal(flags.webPort, 4000) + assert.deepEqual(flags.webAllowedOrigins, ['http://my-tailscale:4000']) +}) + +test('parseCliArgs does not set network flags when not provided', () => { + const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web']) + assert.equal(flags.webHost, undefined) + assert.equal(flags.webPort, undefined) + assert.equal(flags.webAllowedOrigins, undefined) +}) + +// ─── launchWebMode env forwarding ──────────────────────────────────── + +test('launchWebMode forwards custom host, port, and allowed origins to subprocess env', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-net-')) + const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') + const serverPath = join(standaloneRoot, 'server.js') + mkdirSync(standaloneRoot, { recursive: true }) + writeFileSync(serverPath, 'console.log("stub")\n') + + let spawnEnv: Record | undefined + + try { + const status = await webMode.launchWebMode( + { + cwd: '/tmp/project', + projectSessionsDir: '/tmp/.gsd/sessions', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + host: '0.0.0.0', + port: 8080, + allowedOrigins: ['http://192.168.1.10:8080', 'http://tailscale-host:8080'], + }, + { + initResources: () => {}, + spawn: (_command, _args, options) => { + spawnEnv = (options as { env: Record }).env + return { pid: 99999, once: () => undefined, unref: () => {} } as any + }, + waitForBootReady: async () => undefined, + openBrowser: () => {}, + stderr: { write: () => true }, + }, + ) + + assert.equal(status.ok, true) + if (!status.ok) throw new Error('expected success') + assert.equal(status.host, '0.0.0.0') + assert.equal(status.port, 8080) + assert.equal(status.url, 'http://0.0.0.0:8080') + + assert.ok(spawnEnv) + assert.equal(spawnEnv!.HOSTNAME, '0.0.0.0') + assert.equal(spawnEnv!.PORT, '8080') + assert.equal(spawnEnv!.GSD_WEB_HOST, '0.0.0.0') + assert.equal(spawnEnv!.GSD_WEB_PORT, '8080') + assert.equal(spawnEnv!.GSD_WEB_ALLOWED_ORIGINS, 'http://192.168.1.10:8080,http://tailscale-host:8080') + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('launchWebMode omits GSD_WEB_ALLOWED_ORIGINS when none provided', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-no-origins-')) + const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') + const serverPath = join(standaloneRoot, 'server.js') + mkdirSync(standaloneRoot, { recursive: true }) + writeFileSync(serverPath, 'console.log("stub")\n') + + let spawnEnv: Record | undefined + + try { + await webMode.launchWebMode( + { + cwd: '/tmp/project', + projectSessionsDir: '/tmp/.gsd/sessions', + agentDir: '/tmp/.gsd/agent', + packageRoot: tmp, + }, + { + initResources: () => {}, + resolvePort: async () => 45000, + env: { CLEAN_ENV: '1' }, + spawn: (_command, _args, options) => { + spawnEnv = (options as { env: Record }).env + return { pid: 99999, once: () => undefined, unref: () => {} } as any + }, + waitForBootReady: async () => undefined, + openBrowser: () => {}, + stderr: { write: () => true }, + }, + ) + + assert.ok(spawnEnv) + assert.equal(spawnEnv!.GSD_WEB_ALLOWED_ORIGINS, undefined) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +// ─── runWebCliBranch end-to-end forwarding ─────────────────────────── + +test('runWebCliBranch forwards --host, --port, --allowed-origins to launchWebMode', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-branch-flags-')) + const projectDir = join(tmp, 'project') + mkdirSync(projectDir, { recursive: true }) + + let receivedOptions: Record | undefined + + try { + const flags = cliWeb.parseCliArgs([ + 'node', 'dist/loader.js', '--web', projectDir, + '--host', '0.0.0.0', + '--port', '9000', + '--allowed-origins', 'http://my-host:9000', + ]) + + const result = await cliWeb.runWebCliBranch(flags, { + runWebMode: async (options) => { + receivedOptions = options as unknown as Record + return { + mode: 'web' as const, + ok: true as const, + cwd: options.cwd, + projectSessionsDir: options.projectSessionsDir, + host: '0.0.0.0', + port: 9000, + url: 'http://0.0.0.0:9000', + hostKind: 'source-dev' as const, + hostPath: '/tmp/fake-web/package.json', + hostRoot: '/tmp/fake-web', + } + }, + stderr: { write: () => true }, + }) + + assert.equal(result.handled, true) + if (!result.handled) throw new Error('expected handled') + assert.equal(result.exitCode, 0) + assert.ok(receivedOptions) + assert.equal(receivedOptions!.host, '0.0.0.0') + assert.equal(receivedOptions!.port, 9000) + assert.deepEqual(receivedOptions!.allowedOrigins, ['http://my-host:9000']) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/src/web-mode.ts b/src/web-mode.ts index 3daa0e267..f3a1e5014 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -36,6 +36,8 @@ export interface WebModeLaunchOptions { packageRoot?: string host?: string port?: number + /** Additional allowed origins for CORS (forwarded as GSD_WEB_ALLOWED_ORIGINS). */ + allowedOrigins?: string[] } export interface ResolvedWebHostBootstrap { @@ -539,6 +541,7 @@ export async function launchWebMode( GSD_WEB_PACKAGE_ROOT: resolution.packageRoot, GSD_WEB_HOST_KIND: resolution.kind, ...(resolution.kind === 'source-dev' ? { NEXT_PUBLIC_GSD_DEV: '1' } : {}), + ...(options.allowedOrigins?.length ? { GSD_WEB_ALLOWED_ORIGINS: options.allowedOrigins.join(',') } : {}), } try {