fix(tunnel): give the unauthenticated tunnel its own acknowledgement

The network acknowledgement that install.sh writes for every passwordless LAN install also satisfied the tunnel guard, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. The tunnel now needs CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL or --allow-unauthenticated-tunnel (carried by web -d and service install), and with no login the browser asks before every enable, reading a new loginConfigured field from GET /api/tunnel/status.

The guard is now decided once per request and required again where the tunnel starts, so a save that found the tunnel running can no longer restart it unguarded after a concurrent stop.

Closes #706
This commit is contained in:
JD committed 2026-10-26 03:13:25 -04:00
1 parent 3e06f3c141
commit fc0ecc6051
26 files changed
+1029 -165

No files matched your search

+26
View File
@@ -208,3 +208,29 @@ describe('help text', () => {
}
});
});
describe('unauthenticated tunnel launch option', () => {
it.each(['web', 'service install'])('registers --allow-unauthenticated-tunnel on %s', (path) => {
const command = path === 'web' ? find(program, 'web')! : find(find(program, 'service')!, 'install')!;
const options = command.options.filter((option) => option.long === '--allow-unauthenticated-tunnel');
expect(
options.map((option) => ({
flags: option.flags,
attribute: option.attributeName(),
required: option.required,
optional: option.optional,
}))
).toEqual([
{
flags: '--allow-unauthenticated-tunnel',
attribute: 'allowUnauthenticatedTunnel',
required: false,
optional: false,
},
]);
});
it('documents --allow-unauthenticated-tunnel in web help', () => {
expect(find(program, 'web')!.helpInformation()).toContain('--allow-unauthenticated-tunnel');
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Issue #706 tunnel flag plumbing through the real command parser into daemon, foreground and service launches. Launch boundaries are mocked, so the CLI's valid nonzero port never binds a socket or creates a service unit.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const launches = vi.hoisted(() => ({
startDaemon: vi.fn(),
startWebServer: vi.fn(),
installService: vi.fn(),
stop: vi.fn(),
}));
vi.mock('../src/daemon-control.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/daemon-control.js')>()),
startDaemon: launches.startDaemon,
}));
vi.mock('../src/service-installer.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/service-installer.js')>()),
installService: launches.installService,
}));
vi.mock('../src/web/server.js', () => ({ startWebServer: launches.startWebServer }));
const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const;
let originalListeners: Map<NodeJS.Signals, ReturnType<typeof process.listeners>>;
let exitSpy: ReturnType<typeof vi.spyOn<typeof process, 'exit'>>;
beforeEach(() => {
// Commander retains parsed option values; use a fresh real program for each invocation.
vi.resetModules();
vi.clearAllMocks();
originalListeners = new Map(SIGNALS.map((signal) => [signal, process.listeners(signal)]));
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`Unexpected process.exit(${code})`);
});
launches.startDaemon.mockResolvedValue({ ok: true, url: 'http://127.0.0.1:8080', pid: 123, logPath: 'web.log' });
launches.stop.mockResolvedValue(undefined);
launches.startWebServer.mockResolvedValue({ stop: launches.stop });
launches.installService.mockResolvedValue({ ok: true, message: 'Installed', unitPath: 'codeman-web.service' });
});
afterEach(() => {
for (const signal of SIGNALS) {
for (const listener of process.listeners(signal)) {
if (!originalListeners.get(signal)!.includes(listener)) process.removeListener(signal, listener);
}
}
vi.restoreAllMocks();
});
const expectedOptions = (allowUnauthenticatedTunnel: boolean) => ({
host: '127.0.0.1',
port: 8080,
https: false,
basePath: '',
titleHostname: undefined,
allowUnauthenticatedNetwork: false,
allowUnauthenticatedTunnel,
multiuser: false,
});
async function parse(args: string[], enabled: boolean) {
const { program } = await import('../src/cli.js');
await program.parseAsync([
'node',
'codeman',
...args,
'--port',
'8080',
'--host',
'127.0.0.1',
'--base-url',
'/',
...(enabled ? ['--allow-unauthenticated-tunnel'] : []),
]);
expect(exitSpy).not.toHaveBeenCalled();
}
describe('tunnel flag CLI plumbing', () => {
it.each([true, false])('web -d passes allowUnauthenticatedTunnel=%s to startDaemon', async (enabled) => {
await parse(['web', '-d'], enabled);
expect(launches.startDaemon.mock.calls).toEqual([[expectedOptions(enabled)]]);
expect(launches.startWebServer.mock.calls).toEqual([]);
expect(launches.installService.mock.calls).toEqual([]);
});
it.each([true, false])('foreground web passes allowUnauthenticatedTunnel=%s as argument eight', async (enabled) => {
await parse(['web'], enabled);
expect(launches.startWebServer.mock.calls).toEqual([
[8080, false, false, '127.0.0.1', undefined, false, '', enabled],
]);
expect(launches.startDaemon.mock.calls).toEqual([]);
expect(launches.installService.mock.calls).toEqual([]);
expect(launches.stop.mock.calls).toEqual([]);
});
it.each([true, false])('service install passes allowUnauthenticatedTunnel=%s to installService', async (enabled) => {
await parse(['service', 'install'], enabled);
expect(launches.installService.mock.calls).toEqual([[expectedOptions(enabled)]]);
expect(launches.startDaemon.mock.calls).toEqual([]);
expect(launches.startWebServer.mock.calls).toEqual([]);
});
});
+30
View File
@@ -30,6 +30,36 @@ async function closedPort(): Promise<number> {
}
describe('buildWebArgs', () => {
it('forwards the tunnel opt-in between the network and multi-user flags', () => {
const options = {
host: '0.0.0.0',
port: 8080,
https: true,
titleHostname: 'tower',
allowUnauthenticatedNetwork: true,
allowUnauthenticatedTunnel: true,
multiuser: true,
};
expect(buildWebArgs(options)).toEqual([
'web',
'--host',
'0.0.0.0',
'--port',
'8080',
'--https',
'--title-hostname',
'tower',
'--allow-unauthenticated-network',
'--allow-unauthenticated-tunnel',
'--multiuser',
]);
});
it.each([false, undefined])('omits the tunnel opt-in when it is %s', (allowUnauthenticatedTunnel) => {
const options = { host: '127.0.0.1', port: 8080, https: false, allowUnauthenticatedTunnel };
expect(buildWebArgs(options)).toEqual(['web', '--host', '127.0.0.1', '--port', '8080']);
});
it('always passes host and port through explicitly', () => {
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
'web',
+4
View File
@@ -531,3 +531,7 @@ describe('install.sh review fixes for #460', () => {
expect(drive('--tailscale')).toContain('host=127.0.0.1 pw=s3cret ack=0');
});
});
it('install.sh never contains the unauthenticated tunnel opt-in', () => {
expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
});
+1
View File
@@ -169,6 +169,7 @@ export function createMockRouteContext(options?: {
scheduledRuns: new Map(),
teamWatcher: { getTeams: vi.fn(() => []), getTeamTasks: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) },
tunnelManager: null,
allowUnauthenticatedTunnel: false,
pushStore: null,
startScheduledRun: vi.fn(),
stopScheduledRun: vi.fn(),
+256 -98
View File
@@ -1,20 +1,11 @@
/**
* @fileoverview COD-55 — tunnel password guard.
*
* Enabling the Cloudflare tunnel publishes the whole app (full terminal control =
* effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD
* is set, requests through that URL are unauthenticated. These tests assert the
* PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a
* password is set OR the unauthenticated-network opt-in is acknowledged.
*
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
* @fileoverview Issue #706 keeps LAN acknowledgement separate from public tunnel consent. A login, tunnel-specific env opt-in, launch flag or per-request acknowledgement permits enabling. Early refusals persist nothing; late race refusals save unrelated settings without enabling the tunnel. Status reports login configuration independently of exposure opt-ins.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
// Settings are written to disk via fs/promises — stub so the guard test never
// Settings are written to disk via fs/promises; stub so the guard test never
// touches the real settings.json, and so we can assert "not persisted on refusal".
vi.mock('node:fs/promises', () => ({
default: {
@@ -34,129 +25,296 @@ vi.mock('node:fs', async (importOriginal) => {
});
import fs from 'node:fs/promises';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
const mockedWriteFile = vi.mocked(fs.writeFile);
/** Build a tunnelManager stub the route's ctx can use. */
function makeTunnelManager(running = false) {
const STATUS = { running: false, url: null };
function makeTunnelManager() {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => running),
isRunning: vi.fn(() => false),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running })),
getStatus: vi.fn(() => ({ ...STATUS })),
};
}
describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => {
describe('tunnel authentication policy', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
const savedPassword = process.env.CODEMAN_PASSWORD;
const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
const envKeys = [
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL',
'CODEMAN_MULTIUSER',
] as const;
let savedEnv: (string | undefined)[];
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
savedEnv = envKeys.map((key) => process.env[key]);
for (const key of envKeys) delete process.env[key];
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
vi.clearAllMocks();
vi.mocked(fs.readFile).mockResolvedValue('{}');
mockedWriteFile.mockResolvedValue(undefined);
tunnel = makeTunnelManager(false);
// tunnelManager is null in the default mock ctx — inject our spy.
tunnel = makeTunnelManager();
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false;
});
afterEach(async () => {
await harness.app.close();
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = savedPassword;
if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn;
try {
await harness?.app.close();
} finally {
envKeys.forEach((key, i) => {
if (savedEnv[i] === undefined) delete process.env[key];
else process.env[key] = savedEnv[i];
});
}
});
it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
const put = (payload: Record<string, boolean> = { tunnelEnabled: true }) =>
harness.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
// Message should tell the user how to fix it.
expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/);
// The tunnel must NOT have been started.
expect(tunnel.start).not.toHaveBeenCalled();
// And tunnelEnabled:true must NOT have been persisted.
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => {
process.env.CODEMAN_PASSWORD = 'hunter2';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
function expectRefused(res: Awaited<ReturnType<typeof put>>) {
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json()).toEqual({
success: false,
errorCode: 'OPERATION_FAILED',
error: expect.any(String),
});
expect.soft(tunnel.start.mock.calls).toEqual([]);
expect.soft(mockedWriteFile.mock.calls).toEqual([]);
}
function expectEnabled(res: Awaited<ReturnType<typeof put>>) {
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]);
expect(tunnel.stop.mock.calls).toEqual([]);
expect(mockedWriteFile.mock.calls).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: true }, null, 2)]]);
}
it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => {
it('refuses the incident: network acknowledgement does not authorize a public tunnel', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
const res = await put();
expectRefused(res);
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK');
expect.soft(res.json().error).toMatch(/no longer/i);
});
it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
// The action flag must NOT be persisted to settings.json.
expect(mockedWriteFile).toHaveBeenCalled();
const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(persisted.acknowledgeUnauthTunnel).toBeUndefined();
expect(persisted.tunnelEnabled).toBe(true);
it('refuses without credentials or acknowledgement and names the tunnel opt-in', async () => {
const res = await put();
expectRefused(res);
expect(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
});
it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false },
});
it.each(['1', 'true', 'yes', 'on', ' TRUE '])(
'allows tunnel env value %j without an acknowledgement',
async (value) => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value;
expectEnabled(await put());
}
);
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
expect(tunnel.start).not.toHaveBeenCalled();
expect(mockedWriteFile).not.toHaveBeenCalled();
it('allows the route context flag without an env opt-in or acknowledgement', async () => {
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true;
expectEnabled(await put());
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
it('allows acknowledgement alone and persists only tunnelEnabled', async () => {
expectEnabled(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }));
});
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: false },
});
it('refuses a false acknowledgement', async () => {
expectRefused(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: false }));
});
it('allows a configured password without acknowledgement', async () => {
process.env.CODEMAN_PASSWORD = 'test-password';
expectEnabled(await put());
});
it('allows a multi-user admin without acknowledgement', async () => {
process.env.CODEMAN_MULTIUSER = '1';
expectEnabled(await put());
});
it.each(['0', 'false', '', 'enabled'])('refuses non-true tunnel env value %j', async (value) => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value;
expectRefused(await put());
});
it('does not guard disabling a running tunnel', async () => {
tunnel.isRunning.mockReturnValue(true);
const res = await put({ tunnelEnabled: false });
expect(res.statusCode).toBe(200);
expect(tunnel.stop).toHaveBeenCalledTimes(1);
expect(tunnel.start.mock.calls).toEqual([]);
expect(tunnel.stop.mock.calls).toEqual([[]]);
expect(mockedWriteFile.mock.calls).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: false }, null, 2)]]);
});
it.each([
['password', 'CODEMAN_PASSWORD', 'test-password', false, true],
['multi-user', 'CODEMAN_MULTIUSER', '1', false, true],
['neither', undefined, undefined, false, false],
['empty password', 'CODEMAN_PASSWORD', '', false, false],
['network opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', '1', false, false],
['tunnel opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', '1', false, false],
['tunnel flag only', undefined, undefined, true, false],
] as const)('status reports loginConfigured for %s', async (_label, key, value, flag, loginConfigured) => {
if (key !== undefined) process.env[key] = value;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = flag;
const res = await harness.app.inject({ method: 'GET', url: '/api/tunnel/status' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ...STATUS, loginConfigured });
expect(tunnel.getStatus.mock.calls).toEqual([[]]);
expect(mockedWriteFile.mock.calls).toEqual([]);
});
});
describe('tunnel enable authorization across a concurrent stop', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
let running: boolean;
let stopDuringWrite: boolean;
let afterWrite: (() => void) | undefined;
let storedSettings: Record<string, boolean>;
const envKeys = [
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL',
'CODEMAN_MULTIUSER',
] as const;
let savedEnv: (string | undefined)[];
beforeEach(async () => {
savedEnv = envKeys.map((key) => process.env[key]);
for (const key of envKeys) delete process.env[key];
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
vi.clearAllMocks();
running = true;
stopDuringWrite = true;
afterWrite = undefined;
storedSettings = { tunnelEnabled: false, subagentTrackingEnabled: false };
tunnel = makeTunnelManager();
tunnel.isRunning.mockImplementation(() => running);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false;
vi.mocked(fs.readFile).mockImplementation(async (path) => {
expect(path).toBe(SETTINGS_PATH);
return JSON.stringify(storedSettings);
});
mockedWriteFile.mockImplementation(async (path, contents) => {
expect(path).toBe(SETTINGS_PATH);
storedSettings = JSON.parse(contents as string);
// The stop becomes visible during the awaited write, regardless of isRunning() call count.
if (stopDuringWrite) running = false;
afterWrite?.();
});
});
afterEach(async () => {
try {
await harness?.app.close();
} finally {
envKeys.forEach((key, i) => {
if (savedEnv[i] === undefined) delete process.env[key];
else process.env[key] = savedEnv[i];
});
vi.mocked(fs.readFile).mockResolvedValue('{}');
mockedWriteFile.mockResolvedValue(undefined);
}
});
const put = (acknowledgement: Record<string, boolean> = {}) =>
harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, autoNameSessions: false, ...acknowledgement },
});
function expectWritten(tunnelEnabled: boolean) {
const expected = { tunnelEnabled, subagentTrackingEnabled: false, autoNameSessions: false };
// Check every write so a transient unauthorized enable followed by a rollback also fails.
expect
.soft(mockedWriteFile.mock.calls.map(([path, contents]) => ({ path, settings: JSON.parse(contents as string) })))
.toEqual([{ path: SETTINGS_PATH, settings: expected }]);
expect.soft(storedSettings).toEqual(expected);
}
function expectLateRefusal(res: Awaited<ReturnType<typeof put>>) {
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json()).toEqual({
success: false,
errorCode: 'OPERATION_FAILED',
error: expect.any(String),
});
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect.soft(res.json().error).toMatch(/not (re)?start/i);
expect.soft(tunnel.start.mock.calls).toEqual([]);
expect.soft(tunnel.stop.mock.calls).toEqual([]);
expectWritten(false);
}
function expectAuthorizedRestart(res: Awaited<ReturnType<typeof put>>) {
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]);
expect(tunnel.stop.mock.calls).toEqual([]);
expectWritten(true);
}
it('refuses an unauthorized restart after a concurrent stop while saving other settings', async () => {
expectLateRefusal(await put());
});
it.each(['password', 'tunnel env', 'route flag', 'per-request acknowledgement', 'multi-user admin'] as const)(
'allows a restart with %s authorization',
async (authorization) => {
if (authorization === 'password') process.env.CODEMAN_PASSWORD = 'test-password';
if (authorization === 'tunnel env') process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
if (authorization === 'route flag') {
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true;
}
if (authorization === 'multi-user admin') process.env.CODEMAN_MULTIUSER = '1';
expectAuthorizedRestart(
await put(authorization === 'per-request acknowledgement' ? { acknowledgeUnauthTunnel: true } : {})
);
}
);
it.each([false, true])(
'preserves stored tunnelEnabled=%s when an unauthorized tunnel stays running',
async (tunnelEnabled) => {
stopDuringWrite = false;
storedSettings.tunnelEnabled = tunnelEnabled;
const res = await put();
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(tunnel.start.mock.calls).toEqual([]);
expect(tunnel.stop.mock.calls).toEqual([]);
expectWritten(tunnelEnabled);
}
);
it('does not acquire restart authorization from an env opt-in added during the write', async () => {
afterWrite = () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
};
expectLateRefusal(await put());
});
it('retains the request authorization when the env opt-in is removed during the write', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
afterWrite = () => {
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL;
};
expectAuthorizedRestart(await put());
});
});
+24
View File
@@ -6,6 +6,7 @@
import { describe, it, expect } from 'vitest';
import {
resolveServicePlan,
buildLaunchAgentPlist,
buildServiceEnv,
buildServicePath,
@@ -177,3 +178,26 @@ describe('detectServiceKind', () => {
expect(detectServiceKind()).toBe(expected);
});
});
describe('resolveServicePlan tunnel opt-in', () => {
it.each(['systemd', 'launchd'] as const)('carries the tunnel flag into %s args', (kind) => {
const options = {
host: '0.0.0.0',
port: 8080,
https: false,
allowUnauthenticatedNetwork: true,
allowUnauthenticatedTunnel: true,
multiuser: true,
};
expect(resolveServicePlan(kind, options).args).toEqual([
'web',
'--host',
'0.0.0.0',
'--port',
'8080',
'--allow-unauthenticated-network',
'--allow-unauthenticated-tunnel',
'--multiuser',
]);
});
});
+230
View File
@@ -0,0 +1,230 @@
/**
* @fileoverview Issue #706 browser confirmation policy through the real welcome, tunnel panel and App Settings entry points. HTTP and browser prompts are stubbed; settings and acknowledgement logic run unchanged.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js', 'api-client.js'].map(read);
const STORAGE_KEY = 'codeman-app-settings';
const NO_LOGIN = { running: false, url: null, loginConfigured: false };
type Status = { running: boolean; url: string | null; loginConfigured?: boolean };
type Call = { method: string; url: string; body: Record<string, unknown> | undefined };
type Entry = 'welcome' | 'panel' | 'save';
const windows: JSDOM[] = [];
afterEach(() => {
for (const dom of windows.splice(0)) dom.window.close();
});
async function boot(
status: Status = NO_LOGIN,
accept = true,
statusFailure?: 'reject' | 'non-ok',
refuseFirstPut = false
) {
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
windows.push(dom);
const win = dom.window;
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
// Keep status polling and unrelated rendering timers outside this user action.
win.setTimeout = (() => 0) as unknown as typeof win.setTimeout;
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
win.localStorage.setItem(STORAGE_KEY, JSON.stringify({ tunnelEnabled: false }));
const calls: Call[] = [];
const order: string[] = [];
const confirm = vi.fn(() => {
order.push('confirm');
return accept;
});
win.confirm = confirm;
let putCount = 0;
const response = (body: unknown, statusCode = 200) => ({
ok: statusCode >= 200 && statusCode < 300,
status: statusCode,
json: async () => body,
clone: () => response(body, statusCode),
});
const fetch = vi.fn(async (url: string, opts: { method?: string; body?: string } = {}) => {
const method = opts.method || 'GET';
calls.push({ method, url, body: opts.body === undefined ? undefined : JSON.parse(opts.body) });
order.push(`${method} ${url}`);
if (url === '/api/tunnel/status' && method === 'GET') {
if (statusFailure === 'reject') throw new Error('Status unavailable');
// A failed HTTP response must not be trusted, even if its JSON resembles a status.
return response(status, statusFailure === 'non-ok' ? 503 : 200);
}
if (url === '/api/settings' && method === 'PUT') {
putCount++;
if (refuseFirstPut && putCount === 1) {
return response(
{ success: false, error: 'Tunnel acknowledgement required', errorCode: 'OPERATION_FAILED' },
403
);
}
return response({ success: true, data: {} });
}
if (url === '/api/execution/model-config' && method === 'PUT') return response({ success: true, data: {} });
throw new Error(`Unexpected request: ${method} ${url}`);
});
Object.assign(win, { fetch });
win.eval(
[
`var MobileDetection = {
getDeviceType: () => 'desktop', isHandheldDevice: () => false,
isMobile: () => false, isTouchDevice: () => false,
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
'window.__App = CodemanApp;',
].join('\n')
);
// Only peripheral rendering is stubbed. All tunnel logic, settings reads/writes and HTTP helpers run for real.
const app = Object.create((win as unknown as { __App: { prototype: object } }).__App.prototype);
app.sessions = new Map();
app.sessionOrder = [];
for (const name of [
'showToast',
'closeAppSettings',
'_updateLocalEchoState',
'applyTerminalFontFamily',
'applyTerminalFontWeights',
'_syncPushPreferences',
'applyHeaderVisibilitySettings',
'applySkin',
'applyLocalization',
'applySessionListLayout',
'applyTabOrientation',
'applyLineageLineSettings',
'_updateTokensImmediate',
'applyMonitorVisibility',
'renderApprovals',
'renderProjectInsightsPanel',
'updateSubagentWindowVisibility',
'_updateCjkInputState',
'showWelcome',
])
app[name] = vi.fn();
const box = win.document.getElementById('appSettingsTunnelEnabled') as HTMLInputElement;
const button = win.document.getElementById('welcomeTunnelBtn') as HTMLButtonElement;
// The welcome screen has finished its status-dependent reveal before the user clicks.
button.style.display = '';
const puts = () => calls.filter((call) => call.method === 'PUT' && call.url === '/api/settings');
const events = () =>
order.filter(
(event) => event === 'confirm' || event.endsWith('/api/settings') || event.endsWith('/api/tunnel/status')
);
const stored = () => JSON.parse(win.localStorage.getItem(STORAGE_KEY) || '{}');
const act = async (entry: Entry, enable = true) => {
if (entry === 'save') box.checked = enable;
if (entry === 'welcome') button.classList.toggle('active', !enable);
vi.clearAllMocks();
calls.length = 0;
order.length = 0;
if (entry === 'welcome') await app.toggleTunnelFromWelcome();
if (entry === 'panel') await app._tunnelPanelToggle(enable);
if (entry === 'save') await app.saveAppSettings();
};
return { act, puts, events, confirm, box, button, stored };
}
function expectEnablePut(calls: Call[], entry: Entry, acknowledge: boolean) {
expect(calls).toHaveLength(1);
const expected = { tunnelEnabled: true, ...(acknowledge ? { acknowledgeUnauthTunnel: true } : {}) };
if (entry === 'save') {
// The spec leaves the other App Settings fields unchanged.
expect(calls[0].body).toMatchObject(expected);
if (!acknowledge) expect(calls[0].body).not.toHaveProperty('acknowledgeUnauthTunnel');
} else {
expect(calls).toEqual([{ method: 'PUT', url: '/api/settings', body: expected }]);
}
}
describe.each(['welcome', 'panel', 'save'] as const)('%s tunnel enable', (entry) => {
it('confirms before a successful PUT and sends the acknowledgement', async () => {
const h = await boot();
await h.act(entry);
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']);
expectEnablePut(h.puts(), entry, true);
});
it('asks again on every enable attempt', async () => {
const h = await boot();
for (let attempt = 0; attempt < 2; attempt++) {
await h.act(entry);
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']);
}
});
it('cancels without a PUT and leaves the tunnel off', async () => {
const h = await boot(NO_LOGIN, false);
await h.act(entry);
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.puts()).toEqual([]);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm']);
expect.soft(h.button.classList.contains('active')).toBe(false);
expect.soft(h.button.disabled).toBe(false);
expect.soft(h.button.style.display).not.toBe('none');
expect.soft(h.box.disabled).toBe(false);
expect.soft(h.box.checked).toBe(false);
if (entry === 'save') expect.soft(h.stored().tunnelEnabled).toBe(false);
});
it('does not confirm or acknowledge when login is configured', async () => {
const h = await boot({ ...NO_LOGIN, loginConfigured: true });
await h.act(entry);
expect(h.confirm).not.toHaveBeenCalled();
expectEnablePut(h.puts(), entry, false);
});
it('does not confirm or acknowledge when loginConfigured is absent', async () => {
const h = await boot({ running: false, url: null });
await h.act(entry);
expect(h.confirm).not.toHaveBeenCalled();
expect(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']);
expectEnablePut(h.puts(), entry, false);
});
it.each(['reject', 'non-ok'] as const)('sends no acknowledgement when the status read is %s', async (failure) => {
const h = await boot(NO_LOGIN, true, failure);
await h.act(entry);
expect(h.confirm).not.toHaveBeenCalled();
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']);
expectEnablePut(h.puts(), entry, false);
});
});
it('save does not confirm or acknowledge an already running tunnel', async () => {
const h = await boot({ running: true, url: 'https://test.trycloudflare.com', loginConfigured: false });
await h.act('save');
expect(h.confirm).not.toHaveBeenCalled();
expectEnablePut(h.puts(), 'save', false);
});
it.each(['reject', 'non-ok'] as const)('welcome keeps the 403 fallback when the status read is %s', async (failure) => {
const h = await boot(NO_LOGIN, true, failure, true);
await h.act('welcome');
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings', 'confirm', 'PUT /api/settings']);
expect(h.puts()).toEqual([
{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true } },
{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true, acknowledgeUnauthTunnel: true } },
]);
});
it.each(['welcome', 'panel'] as const)('%s disables without confirmation', async (entry) => {
const h = await boot();
await h.act(entry, false);
expect(h.confirm).not.toHaveBeenCalled();
expect(h.puts()).toEqual([{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: false } }]);
});
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview Issue #706 carries the WebServer tunnel opt-in into its real route context. Requests use Fastify injection and settings use the isolated HOME from test/setup.ts; the tunnel process and socket listener are stubbed.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { startWebServer, WebServer } from '../src/web/server.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
const ENV_KEYS = [
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL',
'CODEMAN_MULTIUSER',
] as const;
const INITIAL = { tunnelEnabled: false, subagentTrackingEnabled: false };
let savedEnv: (string | undefined)[];
let server: WebServer | undefined;
let startTunnel: ReturnType<typeof vi.spyOn<TunnelManager, 'start'>>;
beforeEach(async () => {
savedEnv = ENV_KEYS.map((key) => process.env[key]);
for (const key of ENV_KEYS) delete process.env[key];
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
startTunnel = vi.spyOn(TunnelManager.prototype, 'start').mockImplementation(() => {});
vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify(INITIAL));
});
afterEach(async () => {
try {
await server?.stop();
} finally {
server = undefined;
vi.restoreAllMocks();
ENV_KEYS.forEach((key, i) => {
if (savedEnv[i] === undefined) delete process.env[key];
else process.env[key] = savedEnv[i];
});
}
});
async function enableTunnel(instance: WebServer) {
server = instance;
const app = (server as unknown as { app: FastifyInstance }).app;
// Only the transport is replaced: start() still builds the production route context and middleware.
const listen = vi.spyOn(app, 'listen').mockImplementation(async () => {
await app.ready();
return 'http://127.0.0.1:0';
});
await server.start();
expect(listen.mock.calls).toEqual([[{ port: 0, host: '127.0.0.1' }]]);
startTunnel.mockClear();
return app.inject({ method: 'PUT', url: '/api/settings', payload: { tunnelEnabled: true } });
}
describe('WebServer tunnel flag plumbing', () => {
it('permits an unauthenticated tunnel through the startWebServer factory flag', async () => {
const start = WebServer.prototype.start;
// Keep the real factory and startup path, replacing only the socket transport before startup.
vi.spyOn(WebServer.prototype, 'start').mockImplementation(async function (this: WebServer) {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- Teardown must also stop an instance whose factory startup fails.
server = this;
const app = (this as unknown as { app: FastifyInstance }).app;
const listen = vi.spyOn(app, 'listen').mockImplementation(async () => {
await app.ready();
return 'http://127.0.0.1:0';
});
await start.call(this);
expect(listen.mock.calls).toEqual([[{ port: 0, host: '127.0.0.1' }]]);
});
server = await startWebServer(0, false, true, '127.0.0.1', undefined, false, '', true);
const app = (server as unknown as { app: FastifyInstance }).app;
startTunnel.mockClear();
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: { tunnelEnabled: true } });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: {} });
expect(startTunnel.mock.calls).toEqual([[server.boundPort, false]]);
expect(JSON.parse(await readFile(SETTINGS_PATH, 'utf8'))).toEqual({ ...INITIAL, tunnelEnabled: true });
});
it('permits an unauthenticated tunnel through the constructor flag', async () => {
const res = await enableTunnel(new WebServer(0, false, true, '127.0.0.1', undefined, false, '', true));
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: {} });
expect(startTunnel.mock.calls).toEqual([[0, false]]);
expect(JSON.parse(await readFile(SETTINGS_PATH, 'utf8'))).toEqual({ ...INITIAL, tunnelEnabled: true });
});
it('refuses an unauthenticated tunnel when the constructor flag is omitted', async () => {
const res = await enableTunnel(new WebServer(0, false, true, '127.0.0.1', undefined, false, ''));
expect(res.statusCode).toBe(403);
expect(res.json()).toEqual({ success: false, errorCode: 'OPERATION_FAILED', error: expect.any(String) });
expect(startTunnel.mock.calls).toEqual([]);
expect(await readFile(SETTINGS_PATH, 'utf8')).toBe(JSON.stringify(INITIAL));
});
});