feat(security): hook-event auth secret + tunnel password guard

Two hardening fixes for the public-tunnel exposure path (COD-54 / COD-55).

COD-54 — gate the /api/hook-event localhost bypass when a tunnel is up:
`cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
loopback origin, so a tunneled hook request arrives with req.ip === 127.0.0.1
and the old bare-localhost bypass would pass it unauthenticated. Now:
- tunnel running  → bypass requires a shared per-instance hook secret
  (X-Codeman-Hook-Secret header; constant-time compare) + per-IP rate limiting
- tunnel not running (loopback-only, the normal case) → unchanged, so
  already-deployed credential-less hooks keep working.
New src/config/hook-secret.ts; auth middleware takes a getTunnelRunning probe
(wired from server.ts via tunnelManager.isRunning()).

COD-55 — refuse starting the Cloudflare tunnel without auth:
enabling the tunnel publishes full terminal control to a public URL; with no
CODEMAN_PASSWORD the auth middleware is inactive and the bind guard never trips
(tunnel binds loopback). PUT /api/settings now refuses tunnelEnabled:true with a
403 (before persisting) unless CODEMAN_PASSWORD is set or
CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 is acknowledged. New
isUnauthenticatedNetworkAcknowledged() in network-auth-policy; settings-ui
surfaces the refusal as an error toast and reverts the toggle.

Scope: the always-on CSRF/Origin guard, Host-header allowlist, and
network-auth-policy itself are already upstream (#113) and not re-proposed here.

Verification: tsc, eslint, prettier, check:frontend-syntax clean; full test:ci
green (2723 passed), incl. test/cod54-hook-event-auth and
test/routes/system-routes-tunnel-guard.
This commit is contained in:
Aamer Akhter
2026-06-10 12:25:26 -04:00
parent fad32eeaab
commit 42f0b28c75
8 changed files with 485 additions and 10 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Per-instance shared hook secret (COD-54).
*
* Claude Code hooks POST to `/api/hook-event` with no Basic-Auth credentials,
* relying on a localhost bypass in `web/middleware/auth.ts`. That bypass is safe
* for loopback-only deploys, but a `cloudflared --url http://127.0.0.1:port`
* tunnel proxies internet traffic INTO the loopback origin, so tunneled requests
* arrive with `req.ip === 127.0.0.1` and would otherwise pass the bypass and
* drive respawn/Ralph signals unauthenticated.
*
* To close that hole WITHOUT breaking the loop's own (credential-less) hook
* channel, every locally-generated hook command now presents a per-instance
* shared secret in the `X-Codeman-Hook-Secret` header. The middleware requires
* a matching secret for the bypass WHEN A TUNNEL IS RUNNING. Tunneled internet
* traffic can't know the secret; local hooks (which we generate) do.
*
* Storage mirrors the VAPID-key pattern in `push-store.ts`: a small file under
* the instance data dir (`dataPath('hook-secret')`), read-if-present /
* generate-if-missing, stable across restarts. 256 bits of hex.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { getDataDir, dataPath } from './instance.js';
/** HTTP header local hooks use to present the shared secret. */
export const HOOK_SECRET_HEADER = 'X-Codeman-Hook-Secret';
/** Number of random bytes in the secret (256 bits → 64 hex chars). */
const SECRET_BYTES = 32;
let cachedSecret: string | null = null;
/**
* Return this instance's hook secret, generating and persisting it on first use.
* Stable across restarts. Cached in-process after the first read.
*/
export function getHookSecret(): string {
if (cachedSecret) return cachedSecret;
const secretFile = dataPath('hook-secret');
if (existsSync(secretFile)) {
try {
const raw = readFileSync(secretFile, 'utf-8').trim();
if (raw) {
cachedSecret = raw;
return cachedSecret;
}
// Empty/whitespace file — fall through and regenerate.
} catch {
// Unreadable — fall through and regenerate.
}
}
const secret = randomBytes(SECRET_BYTES).toString('hex');
try {
mkdirSync(getDataDir(), { recursive: true });
// Owner-only perms — the secret gates the hook bypass.
writeFileSync(secretFile, secret, { mode: 0o600 });
} catch {
// Best-effort persistence: even if the write fails we still return a usable
// secret for this process so hooks/middleware agree within this run.
}
cachedSecret = secret;
return cachedSecret;
}
+49 -6
View File
@@ -19,6 +19,7 @@ import {
AUTH_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
// Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session';
@@ -34,9 +35,20 @@ interface AuthState {
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
*
* @param getTunnelRunning - returns true while a managed tunnel is active. Used
* to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled
* internet traffic reaches the loopback origin with `req.ip === 127.0.0.1`, so
* the bypass additionally requires the shared hook secret (COD-54). When no
* tunnel is running (loopback-only, the normal case) the plain localhost bypass
* is kept so already-deployed (pre-secret) hooks + the loop channel keep working.
* Optional; defaults to "no tunnel" (unchanged behavior) when omitted.
* @returns AuthState for lifecycle management (dispose on server stop)
*/
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
export function registerAuthMiddleware(
app: FastifyInstance,
https: boolean,
getTunnelRunning: () => boolean = () => false
): AuthState {
const state: AuthState = {
authSessions: null,
authFailures: null,
@@ -78,13 +90,44 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
}
app.addHook('onRequest', (req, reply, done) => {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
// Safe: validated by HookEventSchema, only triggers broadcasts.
// Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
// Hook events come from local Claude Code hooks (curl from localhost) — no
// Basic-Auth credentials available. Validated downstream by HookEventSchema.
//
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. So:
// - tunnel running → bypass requires the shared hook secret (local hooks present
// it via the X-Codeman-Hook-Secret header; internet traffic can't know it),
// - tunnel not running (loopback-only, the normal case) → keep the plain
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
// credential-less hook channel keep working.
if (req.url === '/api/hook-event' && req.method === 'POST') {
const ip = req.ip;
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
done();
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
if (!getTunnelRunning()) {
// Loopback-only: unchanged behavior.
done();
return;
}
// Tunnel up: require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret while tunneled — treat as a failed auth attempt so the
// per-IP rate limiter (below) throttles brute-force/abuse of this route.
const hookIp = req.ip;
const hookFailures = authFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookIp);
return;
}
authFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return;
}
// Non-localhost hook requests fall through to normal auth
+10
View File
@@ -6,6 +6,16 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
/**
* True when unauthenticated network exposure is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Used by the
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
*/
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
if (process.env.CODEMAN_PASSWORD) return true;
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
+54 -3
View File
@@ -844,11 +844,18 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true;
try {
const newEnabled = !isActive;
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
this._dismissTunnelConnecting();
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
@@ -1148,13 +1155,40 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
try {
const body = await res.json();
if (body && body.error) message = body.error;
} catch {
/* non-JSON body — use the default message */
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
},
async _tunnelPanelToggle(enable) {
try {
await fetch('/api/settings', {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
this.closeTunnelPanel();
return;
}
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
@@ -1473,7 +1507,24 @@ Object.assign(CodemanApp.prototype, {
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
const res = await this._apiPut('/api/settings', {
...serverSettings,
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
this.closeAppSettings();
return;
}
// Save model configuration separately
await this.saveModelConfigFromSettings();
+21
View File
@@ -14,6 +14,7 @@ import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -498,6 +499,26 @@ export function registerSystemRoutes(
app.put('/api/settings', async (req) => {
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR the
// operator has acknowledged unauthenticated-network exposure. A public tunnel is
// higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn).
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' +
'unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
+1 -1
View File
@@ -603,7 +603,7 @@ export class WebServer extends EventEmitter {
registerHostGuard(this.app, () => this.getHostPolicy());
// Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https);
const authState = registerAuthMiddleware(this.app, this.https, () => this.tunnelManager.isRunning());
if (authState) {
this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures;
+150
View File
@@ -0,0 +1,150 @@
/**
* @fileoverview COD-54 — hook-event auth bypass hardening.
*
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
* 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates
* the bypass behind a shared hook secret WHEN A TUNNEL IS RUNNING, while
* keeping the plain localhost bypass for the normal loopback-only case so
* already-deployed (pre-secret) hooks and the loop's own channel keep working.
*
* Tests:
* - tunnel running + no secret → 401 (closes the hole)
* - tunnel running + bad secret → 401
* - tunnel running + good secret → not 401 (allowed)
* - tunnel NOT running + no secret → not 401 (back-compat regression guard)
* - rate limiting: rapid unauthorized hook POSTs eventually 429
*
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { TunnelManager } from '../src/tunnel-manager.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TUNNEL_UP_PORT = 3230;
const TUNNEL_DOWN_PORT = 3231;
const RATE_LIMIT_PORT = 3232;
const TEST_USER = 'admin';
const TEST_PASS = 'cod54-test-password';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
function hookBody(): string {
return JSON.stringify({ event: 'stop', sessionId: 'nonexistent-session', data: {} });
}
async function postHook(baseUrl: string, headers: Record<string, string> = {}): Promise<Response> {
return fetch(`${baseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: hookBody(),
});
}
describe('COD-54 hook-event auth — tunnel running requires secret', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Force the middleware's tunnel check to report "running".
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(TUNNEL_UP_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('rejects a localhost hook POST WITHOUT the secret header (closes the tunnel hole)', async () => {
const res = await postHook(baseUrl);
expect(res.status).toBe(401);
});
it('rejects a localhost hook POST with a WRONG secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: 'wrong-secret-value' });
expect(res.status).toBe(401);
});
it('allows a localhost hook POST WITH the correct secret', async () => {
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
// Passes auth (may 200 with success:false for unknown session) — key is NOT 401.
expect(res.status).not.toBe(401);
});
});
describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel NOT running — loopback-only normal prod case.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => {
const res = await postHook(baseUrl);
expect(res.status).not.toBe(401);
});
});
describe('COD-54 hook-event auth — rate limiting', () => {
let server: WebServer;
let baseUrl: string;
let isRunningSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(RATE_LIMIT_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
});
afterAll(async () => {
await server.stop();
isRunningSpy.mockRestore();
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
});
it('eventually returns 429 for rapid unauthorized hook POSTs', async () => {
let saw429 = false;
// A few more than the failure max to cross the threshold.
for (let i = 0; i < AUTH_FAILURE_MAX + 3; i++) {
const res = await postHook(baseUrl);
if (res.status === 429) {
saw429 = true;
expect(res.headers.get('retry-after')).toMatch(/^\d+$/);
break;
}
expect(res.status).toBe(401);
}
expect(saw429).toBe(true);
});
});
@@ -0,0 +1,133 @@
/**
* @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.
*/
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
// touches the real settings.json, and so we can assert "not persisted on refusal".
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
readdirSync: vi.fn(() => []),
};
});
import fs from 'node:fs/promises';
const mockedWriteFile = vi.mocked(fs.writeFile);
/** Build a tunnelManager stub the route's ctx can use. */
function makeTunnelManager(running = false) {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => running),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running })),
};
}
describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
const savedPassword = process.env.CODEMAN_PASSWORD;
const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
vi.clearAllMocks();
mockedWriteFile.mockResolvedValue(undefined);
tunnel = makeTunnelManager(false);
// tunnelManager is null in the default mock ctx — inject our spy.
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
});
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;
});
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 },
});
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 },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', 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);
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: false },
});
expect(res.statusCode).toBe(200);
expect(tunnel.stop).toHaveBeenCalledTimes(1);
});
});