feat(tunnel,ui): purple tunnel button + opt-in unauthenticated tunnel with warning (v1.1.9)

- Daylight Blue: Cloudflare Tunnel welcome button is now purple (was orange),
  keeping Claude blue / Tunnel purple / OpenCode green distinct.
- Allow enabling the Cloudflare tunnel with no CODEMAN_PASSWORD via the UI: the
  toggle now pops a security confirm dialog and, on confirm, sends an explicit
  per-request acknowledgeUnauthTunnel:true (new action field, never persisted).
  Server logs a loud warning whenever a passwordless public tunnel starts.
  curl/API/CLI stay refused unless password/env/flag — no accidental exposure.

Tests: extend test/routes/system-routes-tunnel-guard.test.ts (ack allows + not
persisted; ack:false still refuses). Verified e2e on an isolated instance
(purple button, confirm dialog, retry carries the flag, no real tunnel opened).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-06-16 18:44:03 +02:00
parent f7814ad364
commit a0ac10a07c
9 changed files with 133 additions and 35 deletions
+8
View File
@@ -1,5 +1,13 @@
# aicodeman
## 1.1.9
### Patch Changes
- Two welcome-screen tunnel changes:
- **UI (Daylight Blue skin):** the **Cloudflare Tunnel** button is now purple (was orange/yellow), keeping the three welcome buttons visually distinct — Claude blue, Tunnel purple, OpenCode green.
- **Enable a tunnel without `CODEMAN_PASSWORD`, with a warning.** Previously enabling the Cloudflare tunnel with no password set was hard-refused unless you set `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`. Now you can opt in straight from the browser: clicking the tunnel toggle without a password pops a **security confirm dialog** ("publishes this machine to a public URL with no login — effectively remote code execution; set CODEMAN_PASSWORD instead"), and only on confirm does it enable, sending an explicit per-request `acknowledgeUnauthTunnel:true`. The server logs a loud warning whenever a passwordless public tunnel starts. curl/API/CLI callers are unchanged — still refused unless they set a password, set the env var, or pass `acknowledgeUnauthTunnel:true` — so nothing gets exposed accidentally. The acknowledgment is an action field and is never persisted to settings.json.
## 1.1.8
### Patch Changes
+1 -1
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.1.8 (must match `package.json`)
**Version**: 1.1.9 (must match `package.json`)
## Project Overview
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.8",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.8",
"version": "1.1.9",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.8",
"version": "1.1.9",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+42
View File
@@ -1176,6 +1176,48 @@ Object.assign(CodemanApp.prototype, {
} catch {
/* non-JSON body — use the default message */
}
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
const confirmed = confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
if (!confirmed) {
this._dismissTunnelConnecting?.();
this.showToast('Tunnel not enabled', 'info');
return true;
}
try {
const retry = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
});
if (retry.ok) {
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
return false; // proceed with the caller's success/connecting path
}
let m = 'Failed to enable tunnel.';
try {
const b = await retry.json();
if (b && b.error) m = b.error;
} catch {
/* non-JSON */
}
this._dismissTunnelConnecting?.();
this.showToast(m, 'error');
return true;
} catch {
this._dismissTunnelConnecting?.();
this.showToast('Failed to enable tunnel', 'error');
return true;
}
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
+15 -15
View File
@@ -10201,10 +10201,10 @@ html:not([data-skin="og"]) {
}
/* ---- Daylight Blue: distinct identity per welcome action button ----
Run Claude Code keeps the blue accent; Cloudflare Tunnel takes Cloudflare's
brand orange; Run OpenCode takes an emerald green — so the three are clearly
different colors instead of all reading blue. Scoped to daylight-blue only;
placed after the shared daylight block to win on equal specificity. ---- */
Run Claude Code keeps the blue accent; Cloudflare Tunnel is purple; Run
OpenCode is emerald green — so the three are clearly different colors instead
of all reading blue. Scoped to daylight-blue only; placed after the shared
daylight block to win on equal specificity. ---- */
html[data-skin="daylight-blue"] .welcome-btn-opencode {
background: linear-gradient(135deg, #0d9f6e, #2fbf85);
border-color: rgba(16, 185, 129, 0.5);
@@ -10216,21 +10216,21 @@ html[data-skin="daylight-blue"] .welcome-btn-opencode:hover {
box-shadow: 0 0 28px -4px rgba(16, 185, 129, 0.4);
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel {
background: linear-gradient(135deg, #ef7611, #fbad41);
border-color: rgba(246, 130, 31, 0.5);
color: #2e1503;
background: linear-gradient(135deg, #7c3aed, #a855f7);
border-color: rgba(168, 85, 247, 0.5);
color: #f5f3ff;
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel:hover {
background: linear-gradient(135deg, #fb8b2a, #ffc05a);
border-color: rgba(251, 173, 65, 0.6);
box-shadow: 0 0 28px -4px rgba(246, 130, 31, 0.45);
background: linear-gradient(135deg, #8b5cf6, #c084fc);
border-color: rgba(192, 132, 252, 0.6);
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.45);
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active {
background: linear-gradient(135deg, #d9650b, #f6821f);
border-color: rgba(246, 130, 31, 0.6);
color: #fff7ed;
background: linear-gradient(135deg, #6d28d9, #7c3aed);
border-color: rgba(124, 58, 237, 0.6);
color: #f5f3ff;
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: linear-gradient(135deg, #ef7611, #fb8b2a);
box-shadow: 0 0 28px -4px rgba(246, 130, 31, 0.5);
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
}
+30 -16
View File
@@ -505,20 +505,34 @@ export function registerSystemRoutes(
// 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).
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
// confirm dialog). This keeps curl/API/CLI callers protected by default while
// letting an operator opt in from the browser without setting the env var.
// 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),
});
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
if (!acknowledged) {
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, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
// Loud warning whenever a public tunnel is started with no password — whether
// acknowledged via env var or the per-request UI confirmation.
if (!process.env.CODEMAN_PASSWORD) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
}
try {
@@ -532,9 +546,9 @@ export function registerSystemRoutes(
} catch {
/* ignore */
}
// statusLineTelemetry is an ACTION field (reconcile the plan-usage exporter),
// not a stored setting — strip it before persisting so settings.json stays clean.
const { statusLineTelemetry, ...settingsToStore } = settings;
// statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored
// settings) — strip them before persisting so settings.json stays clean.
const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings;
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
+5
View File
@@ -354,6 +354,11 @@ export const SettingsUpdateSchema = z
ultracodeFloatingWindows: z.boolean().optional(),
imageWatcherEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
// Action field (NOT persisted): explicit per-request acknowledgment that the
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
// Lets the UI enable a tunnel after a confirm dialog without the
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(),
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
@@ -117,6 +117,35 @@ describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () =>
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
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('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 },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
expect(tunnel.start).not.toHaveBeenCalled();
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;