mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
feat: implement QR code authentication for tunnel access
Adds ephemeral single-use QR tokens for passwordless tunnel login. Scanning the QR auto-authenticates; bare tunnel URL requires Basic Auth. Backend: - TunnelManager: 60s token rotation, 90s grace, rejection-sampled 6-char base62 short codes, Map-based O(1) lookup, SVG caching, global rate limit - Auth middleware: /q/ bypass, separate qrAuthFailures counter, enhanced AuthSessionRecord with device context (ip, ua, createdAt, method) - Routes: GET /q/:code (consume + cookie + redirect), POST /api/tunnel/qr/ regenerate, POST /api/auth/revoke, updated GET /api/tunnel/qr with cache - SSE: tunnel:qrRotated, tunnel:qrRegenerated, tunnel:qrAuthUsed events - Audit: qr_auth lifecycle log entries Frontend: - Auto-refresh QR via inline SVG in SSE (fallback fetch if absent) - 60s countdown indicator on QR badge - Regenerate QR button - QRLjacking detection toast with [Revoke All] action button (10s duration) - showToast enhanced with optional duration and action button support Fixes: - /api/logout now invalidates server-side session token (was only clearing browser cookie, leaving token valid for replay) Tests: 20 new tests in test/qr-auth.test.ts covering token lifecycle, bias check, rate limiting, SVG caching, and full server integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -26,8 +27,41 @@ export interface TunnelStatus {
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
interface QrTokenRecord {
|
||||
token: string; // 64 hex chars (256 bits)
|
||||
shortCode: string; // 6 chars base62 (for URL path)
|
||||
createdAt: number; // Date.now()
|
||||
consumed: boolean; // single-use flag
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/** QR token auto-rotation interval */
|
||||
const QR_TOKEN_TTL_MS = 60_000;
|
||||
/** Grace period for previous token (scan-during-rotation race) */
|
||||
const QR_TOKEN_GRACE_MS = 90_000;
|
||||
/** Length of short code in QR URL path */
|
||||
const SHORT_CODE_LENGTH = 6;
|
||||
/** Global rate limit for QR attempts across all IPs */
|
||||
const QR_RATE_LIMIT_MAX = 30;
|
||||
/** Global rate limit reset window */
|
||||
const QR_RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
|
||||
/** Rejection-sampled base62 short code — no modulo bias */
|
||||
function generateShortCode(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const maxUnbiased = 248; // largest multiple of 62 that fits in a byte (248 = 62 * 4)
|
||||
const result: string[] = [];
|
||||
while (result.length < SHORT_CODE_LENGTH) {
|
||||
const [byte] = randomBytes(1);
|
||||
if (byte < maxUnbiased) result.push(chars[byte % 62]);
|
||||
// else: discard and re-draw (rejection sampling)
|
||||
}
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
// ========== Constants (Tunnel) ==========
|
||||
|
||||
/** Regex to extract the trycloudflare.com URL from cloudflared output */
|
||||
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
||||
|
||||
@@ -54,6 +88,17 @@ export class TunnelManager extends EventEmitter {
|
||||
private localPort = 3000;
|
||||
private useHttps = false;
|
||||
|
||||
// ========== QR Token State ==========
|
||||
/** Map-based lookup: shortCode → QrTokenRecord (hash-based, timing-safe) */
|
||||
private qrTokensByCode = new Map<string, QrTokenRecord>();
|
||||
private currentShortCode: string | null = null;
|
||||
private rotationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** SVG cache — regenerated only on token rotation, not per request */
|
||||
private cachedQrSvg: { shortCode: string; svg: string } | null = null;
|
||||
/** Global rate limit counter (separate from Basic Auth rate limiting) */
|
||||
private qrAttemptCount = 0;
|
||||
private qrRateLimitResetTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Resolve cloudflared binary path.
|
||||
* Checks ~/.local/bin first, then falls back to PATH.
|
||||
@@ -170,6 +215,10 @@ export class TunnelManager extends EventEmitter {
|
||||
// Detach listeners — no need to parse further output
|
||||
this.process?.stdout?.off('data', handleOutput);
|
||||
this.process?.stderr?.off('data', handleOutput);
|
||||
// Start QR token rotation when tunnel URL is acquired (only if auth enabled)
|
||||
if (process.env.CODEMAN_PASSWORD) {
|
||||
this.startTokenRotation();
|
||||
}
|
||||
this.emit('started', { url: this.url });
|
||||
}
|
||||
};
|
||||
@@ -243,6 +292,7 @@ export class TunnelManager extends EventEmitter {
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.clearTimers();
|
||||
this.stopTokenRotation();
|
||||
|
||||
if (this.process) {
|
||||
const pid = this.process.pid;
|
||||
@@ -264,6 +314,111 @@ export class TunnelManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== QR Token Management ==========
|
||||
|
||||
/** Start token rotation — called after tunnel URL is acquired */
|
||||
startTokenRotation(): void {
|
||||
this.stopTokenRotation();
|
||||
this.rotateToken();
|
||||
this.rotationTimer = setInterval(() => this.rotateToken(), QR_TOKEN_TTL_MS);
|
||||
this.qrRateLimitResetTimer = setInterval(() => {
|
||||
this.qrAttemptCount = 0;
|
||||
}, QR_RATE_LIMIT_WINDOW_MS);
|
||||
}
|
||||
|
||||
/** Stop token rotation and clear all tokens */
|
||||
stopTokenRotation(): void {
|
||||
if (this.rotationTimer) {
|
||||
clearInterval(this.rotationTimer);
|
||||
this.rotationTimer = null;
|
||||
}
|
||||
if (this.qrRateLimitResetTimer) {
|
||||
clearInterval(this.qrRateLimitResetTimer);
|
||||
this.qrRateLimitResetTimer = null;
|
||||
}
|
||||
this.qrTokensByCode.clear();
|
||||
this.currentShortCode = null;
|
||||
this.cachedQrSvg = null;
|
||||
this.qrAttemptCount = 0;
|
||||
}
|
||||
|
||||
/** Create a new token, evict expired/consumed ones, emit rotation event */
|
||||
private rotateToken(): void {
|
||||
const record: QrTokenRecord = {
|
||||
token: randomBytes(32).toString('hex'),
|
||||
shortCode: generateShortCode(),
|
||||
createdAt: Date.now(),
|
||||
consumed: false,
|
||||
};
|
||||
|
||||
// Evict expired or consumed tokens
|
||||
const now = Date.now();
|
||||
for (const [code, rec] of this.qrTokensByCode) {
|
||||
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) {
|
||||
this.qrTokensByCode.delete(code);
|
||||
}
|
||||
}
|
||||
|
||||
this.qrTokensByCode.set(record.shortCode, record);
|
||||
this.currentShortCode = record.shortCode;
|
||||
this.cachedQrSvg = null; // invalidate SVG cache
|
||||
this.emit('qrTokenRotated');
|
||||
}
|
||||
|
||||
/** Get the current (newest) token's short code for QR URL */
|
||||
getCurrentShortCode(): string | undefined {
|
||||
return this.currentShortCode ?? undefined;
|
||||
}
|
||||
|
||||
/** Get cached QR SVG, regenerating only if the short code changed */
|
||||
async getQrSvg(tunnelUrl: string): Promise<string> {
|
||||
const code = this.currentShortCode;
|
||||
if (!code) throw new Error('No QR token available');
|
||||
if (this.cachedQrSvg?.shortCode === code) return this.cachedQrSvg.svg;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency
|
||||
const QRCode = require('qrcode');
|
||||
const svg: string = await QRCode.toString(`${tunnelUrl}/q/${code}`, {
|
||||
type: 'svg',
|
||||
margin: 2,
|
||||
width: 256,
|
||||
});
|
||||
this.cachedQrSvg = { shortCode: code, svg };
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and atomically consume a token by short code.
|
||||
* Map.get() is hash-based — no timing side-channel from string comparison.
|
||||
*/
|
||||
consumeToken(shortCode: string): boolean {
|
||||
// Global rate limit (across all IPs)
|
||||
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false;
|
||||
this.qrAttemptCount++;
|
||||
|
||||
const record = this.qrTokensByCode.get(shortCode);
|
||||
if (!record) return false;
|
||||
if (record.consumed) return false;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false;
|
||||
|
||||
// Atomic consume (single-threaded JS = no race)
|
||||
record.consumed = true;
|
||||
// Immediately rotate so desktop gets a fresh QR
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Force-regenerate (manual revocation via API) */
|
||||
regenerateQrToken(): void {
|
||||
this.qrTokensByCode.clear();
|
||||
this.currentShortCode = null;
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.process !== null || this.restartTimer !== null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export type LifecycleEventType =
|
||||
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
||||
| 'mux_died' // tmux session died (detected by reconciliation)
|
||||
| 'server_started' // Server started (marker for restart detection)
|
||||
| 'server_stopped'; // Server shutting down
|
||||
| 'server_stopped' // Server shutting down
|
||||
| 'qr_auth'; // Device authenticated via QR code scan
|
||||
|
||||
/** A single entry in the session lifecycle audit log */
|
||||
export interface LifecycleEntry {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
|
||||
// Auth session cookie TTL (24h — matches autonomous run length)
|
||||
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -25,8 +26,9 @@ const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
||||
export interface AuthState {
|
||||
authSessions: StaleExpirationMap<string, string> | null;
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const state: AuthState = {
|
||||
authSessions: null,
|
||||
authFailures: null,
|
||||
qrAuthFailures: null,
|
||||
};
|
||||
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
@@ -48,7 +51,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
|
||||
// Session token store — active sessions extend TTL on access
|
||||
state.authSessions = new StaleExpirationMap<string, string>({
|
||||
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
|
||||
ttlMs: AUTH_SESSION_TTL_MS,
|
||||
refreshOnGet: true,
|
||||
});
|
||||
@@ -59,6 +62,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
refreshOnGet: false,
|
||||
});
|
||||
|
||||
// Separate QR auth failure counter — independent from Basic Auth failures
|
||||
state.qrAuthFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
|
||||
const authSessions = state.authSessions;
|
||||
const authFailures = state.authFailures;
|
||||
|
||||
@@ -75,6 +84,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
}
|
||||
|
||||
// QR auth path — handled by the route itself (token validation + rate limiting)
|
||||
if (req.url?.startsWith('/q/')) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Rate limit: reject if too many failed attempts from this IP
|
||||
@@ -106,7 +121,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
if (oldestKey !== undefined) authSessions.delete(oldestKey);
|
||||
}
|
||||
|
||||
authSessions.set(token, clientIp);
|
||||
authSessions.set(token, {
|
||||
ip: clientIp,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
});
|
||||
|
||||
// Reset failure count on successful auth
|
||||
authFailures.delete(clientIp);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @fileoverview Auth port — capabilities for authentication state.
|
||||
* Route modules that need access to auth sessions or QR rate limiting depend on this port.
|
||||
*/
|
||||
|
||||
import type { StaleExpirationMap } from '../../utils/index.js';
|
||||
|
||||
/** Enhanced session record with device context for audit logging */
|
||||
export interface AuthSessionRecord {
|
||||
ip: string;
|
||||
ua: string;
|
||||
createdAt: number;
|
||||
method: 'qr' | 'basic';
|
||||
}
|
||||
|
||||
export interface AuthPort {
|
||||
readonly authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||
readonly qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
readonly https: boolean;
|
||||
}
|
||||
@@ -11,3 +11,4 @@ export type { EventPort } from './event-port.js';
|
||||
export type { RespawnPort } from './respawn-port.js';
|
||||
export type { ConfigPort } from './config-port.js';
|
||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
|
||||
|
||||
@@ -49,6 +49,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this._api(path, { method: 'POST', body });
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT JSON to an API endpoint.
|
||||
* @param {string} path - API path
|
||||
* @param {object} body - JSON body
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async _apiPut(path, body) {
|
||||
return this._api(path, { method: 'PUT', body });
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE an API resource.
|
||||
* @param {string} path - API path
|
||||
|
||||
+153
-172
@@ -2326,6 +2326,48 @@ class CodemanApp {
|
||||
if (btn) { btn.disabled = false; btn.classList.remove('connecting'); }
|
||||
});
|
||||
|
||||
// QR auto-refresh — inline SVG from SSE (no extra fetch)
|
||||
addListener('tunnel:qrRotated', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.svg) {
|
||||
const container = document.getElementById('tunnelQrContainer');
|
||||
if (container) container.innerHTML = data.svg;
|
||||
const welcomeInner = document.getElementById('welcomeQrInner');
|
||||
if (welcomeInner) welcomeInner.innerHTML = data.svg;
|
||||
} else {
|
||||
this._refreshTunnelQrFromApi();
|
||||
}
|
||||
this._resetQrCountdown();
|
||||
});
|
||||
|
||||
addListener('tunnel:qrRegenerated', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.svg) {
|
||||
const container = document.getElementById('tunnelQrContainer');
|
||||
if (container) container.innerHTML = data.svg;
|
||||
const welcomeInner = document.getElementById('welcomeQrInner');
|
||||
if (welcomeInner) welcomeInner.innerHTML = data.svg;
|
||||
} else {
|
||||
this._refreshTunnelQrFromApi();
|
||||
}
|
||||
this._resetQrCountdown();
|
||||
});
|
||||
|
||||
// QR auth consumed — notify desktop user (QRLjacking detection)
|
||||
addListener('tunnel:qrAuthUsed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const ua = data.ua || 'Unknown device';
|
||||
const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser';
|
||||
this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', {
|
||||
duration: 10000,
|
||||
action: { label: 'Revoke All', onClick: () => {
|
||||
fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
.then(() => this.showToast('All sessions revoked', 'success'))
|
||||
.catch(() => this.showToast('Failed to revoke sessions', 'error'));
|
||||
}},
|
||||
});
|
||||
});
|
||||
|
||||
// Plan subagent visibility events (show Opus agents during plan generation)
|
||||
addListener('plan:subagent', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
@@ -2459,16 +2501,8 @@ class CodemanApp {
|
||||
this._updateConnectionIndicator();
|
||||
|
||||
for (const [sessionId, input] of queued) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
} catch {
|
||||
const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input });
|
||||
if (!resp?.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
}
|
||||
@@ -3107,94 +3141,6 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
|
||||
// Show subagent dropdown on hover
|
||||
showSubagentDropdown(badgeEl) {
|
||||
this.cancelHideSubagentDropdown();
|
||||
const dropdown = badgeEl.querySelector('.subagent-dropdown');
|
||||
if (!dropdown || dropdown.classList.contains('open')) return;
|
||||
|
||||
// Close other dropdowns first
|
||||
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
|
||||
d.classList.remove('open', 'pinned');
|
||||
if (d.parentElement === document.body && d._originalParent) {
|
||||
d._originalParent.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
// Move to body to escape clipping
|
||||
dropdown._originalParent = badgeEl;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
// Position below badge
|
||||
const rect = badgeEl.getBoundingClientRect();
|
||||
dropdown.style.top = `${rect.bottom + 2}px`;
|
||||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||||
dropdown.style.transform = 'translateX(-50%)';
|
||||
dropdown.classList.add('open');
|
||||
}
|
||||
|
||||
// Schedule hide after delay (allows moving mouse to dropdown)
|
||||
scheduleHideSubagentDropdown(badgeEl) {
|
||||
this._subagentHideTimeout = setTimeout(() => {
|
||||
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
|
||||
document.querySelector('.subagent-dropdown.open');
|
||||
if (dropdown && !dropdown.classList.contains('pinned')) {
|
||||
dropdown.classList.remove('open');
|
||||
if (dropdown._originalParent) {
|
||||
dropdown._originalParent.appendChild(dropdown);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Cancel scheduled hide
|
||||
cancelHideSubagentDropdown() {
|
||||
if (this._subagentHideTimeout) {
|
||||
clearTimeout(this._subagentHideTimeout);
|
||||
this._subagentHideTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pin dropdown open on click (stays until clicking outside)
|
||||
pinSubagentDropdown(badgeEl) {
|
||||
const dropdown = document.querySelector('.subagent-dropdown.open');
|
||||
if (!dropdown) {
|
||||
this.showSubagentDropdown(badgeEl);
|
||||
// On mobile/touch, pin immediately so onmouseleave doesn't close it
|
||||
const openedDropdown = document.querySelector('.subagent-dropdown.open');
|
||||
if (openedDropdown) {
|
||||
openedDropdown.classList.add('pinned');
|
||||
const closeHandler = (e) => {
|
||||
if (!badgeEl.contains(e.target) && !openedDropdown.contains(e.target)) {
|
||||
openedDropdown.classList.remove('open', 'pinned');
|
||||
if (openedDropdown._originalParent) {
|
||||
openedDropdown._originalParent.appendChild(openedDropdown);
|
||||
}
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
dropdown.classList.toggle('pinned');
|
||||
|
||||
if (dropdown.classList.contains('pinned')) {
|
||||
// Close on outside click
|
||||
const closeHandler = (e) => {
|
||||
if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) {
|
||||
dropdown.classList.remove('open', 'pinned');
|
||||
if (dropdown._originalParent) {
|
||||
dropdown._originalParent.appendChild(dropdown);
|
||||
}
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getSessionName(session) {
|
||||
// Use custom name if set
|
||||
if (session.name) {
|
||||
@@ -3514,7 +3460,7 @@ class CodemanApp {
|
||||
|
||||
async closeSession(sessionId, killMux = true) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}?killMux=${killMux}`, { method: 'DELETE' });
|
||||
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
||||
this._cleanupSessionData(sessionId);
|
||||
|
||||
if (this.activeSessionId === sessionId) {
|
||||
@@ -4543,7 +4489,7 @@ class CodemanApp {
|
||||
async stopRespawn() {
|
||||
if (!this.activeSessionId) return;
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' });
|
||||
await this._apiPost(`/api/sessions/${this.activeSessionId}/respawn/stop`, {});
|
||||
delete this.respawnTimers[this.activeSessionId];
|
||||
this.clearCountdownTimers(this.activeSessionId);
|
||||
} catch (err) {
|
||||
@@ -4567,7 +4513,7 @@ class CodemanApp {
|
||||
if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/sessions', { method: 'DELETE' });
|
||||
await this._apiDelete('/api/sessions');
|
||||
this.sessions.clear();
|
||||
this.terminalBuffers.clear();
|
||||
this.terminalBufferCache.clear();
|
||||
@@ -4949,11 +4895,7 @@ class CodemanApp {
|
||||
if (!this.editingSessionId) return;
|
||||
const name = document.getElementById('modalSessionName').value.trim();
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
||||
} catch (err) {
|
||||
this.showToast('Failed to save session name: ' + err.message, 'error');
|
||||
}
|
||||
@@ -4962,14 +4904,10 @@ class CodemanApp {
|
||||
async autoSaveAutoCompact() {
|
||||
if (!this.editingSessionId) return;
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: document.getElementById('modalAutoCompactEnabled').checked,
|
||||
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
|
||||
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
|
||||
})
|
||||
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-compact`, {
|
||||
enabled: document.getElementById('modalAutoCompactEnabled').checked,
|
||||
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
|
||||
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
@@ -4977,13 +4915,9 @@ class CodemanApp {
|
||||
async autoSaveAutoClear() {
|
||||
if (!this.editingSessionId) return;
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: document.getElementById('modalAutoClearEnabled').checked,
|
||||
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
|
||||
})
|
||||
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
||||
enabled: document.getElementById('modalAutoClearEnabled').checked,
|
||||
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
@@ -4992,11 +4926,7 @@ class CodemanApp {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/image-watcher`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
});
|
||||
await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
|
||||
// Update local session state
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (session) {
|
||||
@@ -5012,11 +4942,7 @@ class CodemanApp {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalFlickerFilterEnabled').checked;
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/flicker-filter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
});
|
||||
await this._apiPost(`/api/sessions/${this.editingSessionId}/flicker-filter`, { enabled });
|
||||
// Update local session state
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (session) {
|
||||
@@ -5038,11 +4964,7 @@ class CodemanApp {
|
||||
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
|
||||
};
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/respawn/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/respawn/config`, config);
|
||||
} catch {
|
||||
// Silent save - don't interrupt user
|
||||
}
|
||||
@@ -5841,9 +5763,8 @@ class CodemanApp {
|
||||
}
|
||||
try {
|
||||
// Get VAPID public key from server
|
||||
const keyRes = await fetch('/api/push/vapid-key');
|
||||
const keyData = await keyRes.json();
|
||||
if (!keyData.success) throw new Error('Failed to get VAPID key');
|
||||
const keyData = await this._apiJson('/api/push/vapid-key');
|
||||
if (!keyData?.success) throw new Error('Failed to get VAPID key');
|
||||
|
||||
const applicationServerKey = urlBase64ToUint8Array(keyData.data.publicKey);
|
||||
const subscription = await this._swRegistration.pushManager.subscribe({
|
||||
@@ -5853,18 +5774,16 @@ class CodemanApp {
|
||||
|
||||
// Send subscription to server
|
||||
const subJson = subscription.toJSON();
|
||||
const res = await fetch('/api/push/subscribe', {
|
||||
const data = await this._apiJson('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
endpoint: subJson.endpoint,
|
||||
keys: subJson.keys,
|
||||
userAgent: navigator.userAgent,
|
||||
pushPreferences: this._buildPushPreferences(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error('Failed to register subscription');
|
||||
if (!data?.success) throw new Error('Failed to register subscription');
|
||||
|
||||
this._pushSubscription = subscription;
|
||||
this._pushSubscriptionId = data.data.id;
|
||||
@@ -6175,6 +6094,27 @@ class CodemanApp {
|
||||
.then(data => {
|
||||
const container = document.getElementById('tunnelQrContainer');
|
||||
if (container && data.svg) container.innerHTML = data.svg;
|
||||
// Show auth badge, countdown, and regenerate button when auth is enabled
|
||||
if (data.authEnabled) {
|
||||
const badge = document.createElement('div');
|
||||
badge.id = 'tunnelQrBadge';
|
||||
badge.style.cssText = 'margin-top:8px;font-size:11px;color:var(--text-muted)';
|
||||
badge.textContent = 'Single-use auth \u00b7 expires in 60s';
|
||||
const regenBtn = document.createElement('button');
|
||||
regenBtn.textContent = 'Regenerate QR';
|
||||
regenBtn.style.cssText = 'margin-top:8px;padding:4px 12px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);cursor:pointer;font-size:11px';
|
||||
regenBtn.onclick = () => {
|
||||
fetch('/api/tunnel/qr/regenerate', { method: 'POST' })
|
||||
.then(() => this.showToast('QR code regenerated', 'success'))
|
||||
.catch(() => this.showToast('Failed to regenerate QR', 'error'));
|
||||
};
|
||||
const card = container.parentElement;
|
||||
if (card) {
|
||||
card.appendChild(badge);
|
||||
card.appendChild(regenBtn);
|
||||
}
|
||||
this._resetQrCountdown();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const container = document.getElementById('tunnelQrContainer');
|
||||
@@ -6209,6 +6149,50 @@ class CodemanApp {
|
||||
document.removeEventListener('keydown', this._tunnelQrEscHandler);
|
||||
this._tunnelQrEscHandler = null;
|
||||
}
|
||||
this._clearQrCountdown();
|
||||
}
|
||||
|
||||
/** Fallback: fetch QR SVG from API when SSE payload lacks it */
|
||||
_refreshTunnelQrFromApi() {
|
||||
fetch('/api/tunnel/qr')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (!data?.svg) return;
|
||||
const container = document.getElementById('tunnelQrContainer');
|
||||
if (container) container.innerHTML = data.svg;
|
||||
const welcomeInner = document.getElementById('welcomeQrInner');
|
||||
if (welcomeInner) welcomeInner.innerHTML = data.svg;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Start or reset the 60s countdown on the QR badge */
|
||||
_resetQrCountdown() {
|
||||
this._clearQrCountdown();
|
||||
this._qrCountdownSec = 60;
|
||||
this._updateQrCountdownText();
|
||||
this._qrCountdownTimer = setInterval(() => {
|
||||
this._qrCountdownSec--;
|
||||
if (this._qrCountdownSec <= 0) {
|
||||
this._clearQrCountdown();
|
||||
return;
|
||||
}
|
||||
this._updateQrCountdownText();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
_updateQrCountdownText() {
|
||||
const badge = document.getElementById('tunnelQrBadge');
|
||||
if (badge) {
|
||||
badge.textContent = `Single-use auth \u00b7 expires in ${this._qrCountdownSec}s`;
|
||||
}
|
||||
}
|
||||
|
||||
_clearQrCountdown() {
|
||||
if (this._qrCountdownTimer) {
|
||||
clearInterval(this._qrCountdownTimer);
|
||||
this._qrCountdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async toggleTunnelFromWelcome() {
|
||||
@@ -6613,11 +6597,7 @@ class CodemanApp {
|
||||
// Strip device-specific keys — localEchoEnabled is per-platform (touch default differs)
|
||||
const { localEchoEnabled: _leo, ...serverSettings } = settings;
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings })
|
||||
});
|
||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||
|
||||
// Save model configuration separately
|
||||
await this.saveModelConfigFromSettings();
|
||||
@@ -7666,11 +7646,7 @@ class CodemanApp {
|
||||
this.ralphClosedSessions.add(this.activeSessionId);
|
||||
|
||||
// Disable tracker via API
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false })
|
||||
});
|
||||
await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-config`, { enabled: false });
|
||||
|
||||
// Clear local state and hide panel
|
||||
this.ralphStates.delete(this.activeSessionId);
|
||||
@@ -7697,12 +7673,10 @@ class CodemanApp {
|
||||
if (!this.activeSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await response.json();
|
||||
const response = await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {});
|
||||
const data = await response?.json();
|
||||
|
||||
if (data.success) {
|
||||
if (data?.success) {
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'circuit-breaker',
|
||||
@@ -8120,12 +8094,7 @@ class CodemanApp {
|
||||
async resetCircuitBreaker() {
|
||||
if (!this.activeSessionId) return;
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.ok) {
|
||||
console.log('Circuit breaker reset');
|
||||
}
|
||||
await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {});
|
||||
} catch (err) {
|
||||
console.error('Failed to reset circuit breaker:', err);
|
||||
}
|
||||
@@ -8882,9 +8851,9 @@ class CodemanApp {
|
||||
|
||||
async killSubagent(agentId) {
|
||||
try {
|
||||
const res = await fetch(`/api/subagents/${agentId}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const res = await this._apiDelete(`/api/subagents/${agentId}`);
|
||||
const data = await res?.json();
|
||||
if (data?.success) {
|
||||
// Update local state
|
||||
const agent = this.subagents.get(agentId);
|
||||
if (agent) {
|
||||
@@ -11384,10 +11353,22 @@ class CodemanApp {
|
||||
return this.showToast(message, type);
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
showToast(message, type = 'info', opts = {}) {
|
||||
const { duration = 3000, action } = opts;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = message;
|
||||
toast.appendChild(msgSpan);
|
||||
|
||||
if (action) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = action.label;
|
||||
btn.style.cssText = 'margin-left:12px;padding:2px 10px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);border-radius:3px;color:inherit;cursor:pointer;font-size:12px';
|
||||
btn.onclick = (e) => { e.stopPropagation(); action.onClick(); toast.remove(); };
|
||||
toast.appendChild(btn);
|
||||
}
|
||||
|
||||
// Cache toast container reference
|
||||
if (!this._toastContainer) {
|
||||
@@ -11405,7 +11386,7 @@ class CodemanApp {
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
}, 3000);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ========== System Stats ==========
|
||||
|
||||
@@ -1029,4 +1029,91 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Return listener references for cleanup
|
||||
return { move: moveListener, up: upListener, touchMove: touchMoveListener };
|
||||
},
|
||||
|
||||
// Show subagent dropdown on hover
|
||||
showSubagentDropdown(badgeEl) {
|
||||
this.cancelHideSubagentDropdown();
|
||||
const dropdown = badgeEl.querySelector('.subagent-dropdown');
|
||||
if (!dropdown || dropdown.classList.contains('open')) return;
|
||||
|
||||
// Close other dropdowns first
|
||||
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
|
||||
d.classList.remove('open', 'pinned');
|
||||
if (d.parentElement === document.body && d._originalParent) {
|
||||
d._originalParent.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
// Move to body to escape clipping
|
||||
dropdown._originalParent = badgeEl;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
// Position below badge
|
||||
const rect = badgeEl.getBoundingClientRect();
|
||||
dropdown.style.top = `${rect.bottom + 2}px`;
|
||||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||||
dropdown.style.transform = 'translateX(-50%)';
|
||||
dropdown.classList.add('open');
|
||||
},
|
||||
|
||||
// Schedule hide after delay (allows moving mouse to dropdown)
|
||||
scheduleHideSubagentDropdown(badgeEl) {
|
||||
this._subagentHideTimeout = setTimeout(() => {
|
||||
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
|
||||
document.querySelector('.subagent-dropdown.open');
|
||||
if (dropdown && !dropdown.classList.contains('pinned')) {
|
||||
dropdown.classList.remove('open');
|
||||
if (dropdown._originalParent) {
|
||||
dropdown._originalParent.appendChild(dropdown);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
|
||||
// Cancel scheduled hide
|
||||
cancelHideSubagentDropdown() {
|
||||
if (this._subagentHideTimeout) {
|
||||
clearTimeout(this._subagentHideTimeout);
|
||||
this._subagentHideTimeout = null;
|
||||
}
|
||||
},
|
||||
|
||||
// Pin dropdown open on click (stays until clicking outside)
|
||||
pinSubagentDropdown(badgeEl) {
|
||||
const dropdown = document.querySelector('.subagent-dropdown.open');
|
||||
if (!dropdown) {
|
||||
this.showSubagentDropdown(badgeEl);
|
||||
// On mobile/touch, pin immediately so onmouseleave doesn't close it
|
||||
const openedDropdown = document.querySelector('.subagent-dropdown.open');
|
||||
if (openedDropdown) {
|
||||
openedDropdown.classList.add('pinned');
|
||||
const closeHandler = (e) => {
|
||||
if (!badgeEl.contains(e.target) && !openedDropdown.contains(e.target)) {
|
||||
openedDropdown.classList.remove('open', 'pinned');
|
||||
if (openedDropdown._originalParent) {
|
||||
openedDropdown._originalParent.appendChild(openedDropdown);
|
||||
}
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
dropdown.classList.toggle('pinned');
|
||||
|
||||
if (dropdown.classList.contains('pinned')) {
|
||||
// Close on outside click
|
||||
const closeHandler = (e) => {
|
||||
if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) {
|
||||
dropdown.classList.remove('open', 'pinned');
|
||||
if (dropdown._originalParent) {
|
||||
dropdown._originalParent.appendChild(dropdown);
|
||||
}
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
@@ -56,11 +56,16 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
): void {
|
||||
// ========== Logout ==========
|
||||
|
||||
app.post('/api/logout', async (_req, reply) => {
|
||||
app.post('/api/logout', async (req, reply) => {
|
||||
// Invalidate server-side session token (not just the browser cookie)
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
if (sessionToken) {
|
||||
ctx.authSessions?.delete(sessionToken);
|
||||
}
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
@@ -23,7 +24,8 @@ import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
|
||||
// Maximum screenshot upload size (10MB)
|
||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||
@@ -81,7 +83,7 @@ function getSystemStats(): {
|
||||
|
||||
export function registerSystemRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
): void {
|
||||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
||||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
||||
@@ -100,15 +102,108 @@ export function registerSystemRoutes(
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
|
||||
}
|
||||
try {
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
if (authPassword) {
|
||||
// Auth enabled — use cached SVG with embedded short code
|
||||
const svg = await ctx.tunnelManager.getQrSvg(url);
|
||||
return { svg, authEnabled: true };
|
||||
}
|
||||
// No auth — just encode the raw tunnel URL
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency
|
||||
const QRCode = require('qrcode');
|
||||
const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
|
||||
return { svg };
|
||||
return { svg, authEnabled: false };
|
||||
} catch (err) {
|
||||
return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)));
|
||||
}
|
||||
});
|
||||
|
||||
// ========== QR Auth Route ==========
|
||||
|
||||
app.get('/q/:code', async (req, reply) => {
|
||||
const shortCode = (req.params as { code: string }).code;
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
|
||||
// No point if auth isn't enabled — just redirect
|
||||
if (!authPassword) {
|
||||
return reply.redirect('/');
|
||||
}
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Per-IP rate limit (separate counter from Basic Auth failures)
|
||||
const qrFailures = ctx.qrAuthFailures?.get(clientIp) ?? 0;
|
||||
if (qrFailures >= 10) {
|
||||
return reply.code(429).send('Too Many Requests');
|
||||
}
|
||||
|
||||
// Validate and atomically consume the token
|
||||
if (!shortCode || !ctx.tunnelManager.consumeToken(shortCode)) {
|
||||
ctx.qrAuthFailures?.set(clientIp, qrFailures + 1);
|
||||
return reply.code(401).send('Invalid or expired QR code');
|
||||
}
|
||||
|
||||
// Issue session cookie (same pattern as Basic Auth success path)
|
||||
const sessionToken = randomBytes(32).toString('hex');
|
||||
const clientUA = req.headers['user-agent'] ?? '';
|
||||
ctx.authSessions?.set(sessionToken, {
|
||||
ip: clientIp,
|
||||
ua: clientUA,
|
||||
createdAt: Date.now(),
|
||||
method: 'qr',
|
||||
});
|
||||
ctx.qrAuthFailures?.delete(clientIp);
|
||||
|
||||
// Audit log
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
lifecycleLog.log({
|
||||
event: 'qr_auth',
|
||||
sessionId: 'system',
|
||||
extra: {
|
||||
ip: clientIp,
|
||||
ua: clientUA,
|
||||
shortCodePrefix: shortCode.slice(0, 3) + '***',
|
||||
},
|
||||
});
|
||||
|
||||
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: ctx.https,
|
||||
sameSite: 'lax',
|
||||
maxAge: 86400, // 24h
|
||||
path: '/',
|
||||
});
|
||||
|
||||
// Broadcast auth notification — desktop sees who authenticated
|
||||
ctx.broadcast('tunnel:qrAuthUsed', {
|
||||
ip: clientIp,
|
||||
ua: clientUA,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return reply.redirect('/');
|
||||
});
|
||||
|
||||
// ========== QR Regeneration ==========
|
||||
|
||||
app.post('/api/tunnel/qr/regenerate', async () => {
|
||||
ctx.tunnelManager.regenerateQrToken();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Auth Session Revocation ==========
|
||||
|
||||
app.post('/api/auth/revoke', async (req) => {
|
||||
const body = req.body as { sessionToken?: string } | undefined;
|
||||
if (body?.sessionToken) {
|
||||
ctx.authSessions?.delete(body.sessionToken);
|
||||
} else {
|
||||
// Revoke all sessions (nuclear option)
|
||||
ctx.authSessions?.clear();
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== OpenCode ==========
|
||||
|
||||
app.get('/api/opencode/status', async () => {
|
||||
|
||||
+35
-1
@@ -255,8 +255,9 @@ export class WebServer extends EventEmitter {
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private tunnelManager: TunnelManager = new TunnelManager();
|
||||
private authSessions: StaleExpirationMap<string, string> | null = null;
|
||||
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private teamWatcherHandlers: {
|
||||
@@ -321,6 +322,31 @@ export class WebServer extends EventEmitter {
|
||||
this.tunnelManager.on('progress', (data: { message: string }) => {
|
||||
this.broadcast('tunnel:progress', data);
|
||||
});
|
||||
|
||||
// QR token rotation — broadcast inline SVG for instant desktop refresh
|
||||
this.tunnelManager.on('qrTokenRotated', async () => {
|
||||
const url = this.tunnelManager.getUrl();
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRotated', { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip this rotation
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.tunnelManager.on('qrTokenRegenerated', async () => {
|
||||
const url = this.tunnelManager.getUrl();
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRegenerated', { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -485,6 +511,9 @@ export class WebServer extends EventEmitter {
|
||||
pushStore: this.pushStore,
|
||||
startScheduledRun: this.startScheduledRun.bind(this),
|
||||
stopScheduledRun: this.stopScheduledRun.bind(this),
|
||||
// AuthPort
|
||||
authSessions: this.authSessions,
|
||||
qrAuthFailures: this.qrAuthFailures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -510,6 +539,7 @@ export class WebServer extends EventEmitter {
|
||||
if (authState) {
|
||||
this.authSessions = authState.authSessions;
|
||||
this.authFailures = authState.authFailures;
|
||||
this.qrAuthFailures = authState.qrAuthFailures;
|
||||
}
|
||||
|
||||
// Security headers + CORS
|
||||
@@ -2652,6 +2682,10 @@ export class WebServer extends EventEmitter {
|
||||
this.authFailures.dispose();
|
||||
this.authFailures = null;
|
||||
}
|
||||
if (this.qrAuthFailures) {
|
||||
this.qrAuthFailures.dispose();
|
||||
this.qrAuthFailures = null;
|
||||
}
|
||||
this.activePlanOrchestrators.clear();
|
||||
this.cleaningUp.clear();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user