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
@@ -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;