diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b08d7d..8d119163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a79e887c..712b4f77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/package-lock.json b/package-lock.json index eaa64265..603b7f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/package.json b/package.json index c4cd422e..e2b031e5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a356e1c7..f531059c 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index c1d79fb7..960f4974 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 5c3f54df..aaa69d21 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -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)); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ffe049f4..19050c57 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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 */ diff --git a/test/routes/system-routes-tunnel-guard.test.ts b/test/routes/system-routes-tunnel-guard.test.ts index 01efdf5e..8da2b674 100644 --- a/test/routes/system-routes-tunnel-guard.test.ts +++ b/test/routes/system-routes-tunnel-guard.test.ts @@ -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;