diff --git a/apps/desktop/src/main/computer-use.ts b/apps/desktop/src/main/computer-use.ts index d3e3d25..43eeb38 100644 --- a/apps/desktop/src/main/computer-use.ts +++ b/apps/desktop/src/main/computer-use.ts @@ -381,6 +381,7 @@ export function buildOpencodeMcpEntry(): OpencodeMcpEntry { export interface McpEntryUrl { url: string; type: 'http'; + headers?: Record; } /** @@ -390,6 +391,7 @@ export interface McpEntryUrl { */ export interface GeminiMcpEntryUrl { url: string; + headers?: Record; } /** opencode format for a remote MCP server. */ @@ -397,6 +399,7 @@ export interface OpencodeMcpEntryUrl { type: 'remote'; url: string; enabled: boolean; + headers?: Record; } /** @@ -404,7 +407,10 @@ export interface OpencodeMcpEntryUrl { * Uses `type: 'http'` per the MCP spec — claude-code requires this exact key. * Do NOT use this for Gemini; Gemini requires a different format (url only). */ -export function buildMcpEntryUrl(url: string): McpEntryUrl { +export function buildMcpEntryUrl(url: string, authToken?: string | null): McpEntryUrl { + if (authToken) { + return { type: 'http', url, headers: { Authorization: `Bearer ${authToken}` } }; + } return { type: 'http', url }; } @@ -412,21 +418,31 @@ export function buildMcpEntryUrl(url: string): McpEntryUrl { * Returns a URL-based MCP entry for Gemini CLI settings. * Gemini only accepts `{ url }` — any extra key causes a validation error. */ -export function buildGeminiMcpEntryUrl(url: string): GeminiMcpEntryUrl { +export function buildGeminiMcpEntryUrl(url: string, authToken?: string | null): GeminiMcpEntryUrl { + if (authToken) { + return { url, headers: { Authorization: `Bearer ${authToken}` } }; + } return { url }; } /** Returns a URL-based MCP entry for opencode (remote server). */ -export function buildOpencodeMcpEntryUrl(url: string): OpencodeMcpEntryUrl { +export function buildOpencodeMcpEntryUrl(url: string, authToken?: string | null): OpencodeMcpEntryUrl { + if (authToken) { + return { type: 'remote', url, enabled: true, headers: { Authorization: `Bearer ${authToken}` } }; + } return { type: 'remote', url, enabled: true }; } /** * Returns the codex `-c` args to configure a remote MCP server by URL. - * Example: `-c mcp_servers.tday-computer-use.url=http://127.0.0.1:PORT/mcp` + * Includes an Authorization header if an auth token is provided. */ -export function codexMcpCliArgsUrl(url: string): string[] { - return ['-c', `mcp_servers.${MCP_SERVER_KEY}.url=${url}`]; +export function codexMcpCliArgsUrl(url: string, authToken?: string | null): string[] { + const args = ['-c', `mcp_servers.${MCP_SERVER_KEY}.url=${url}`]; + if (authToken) { + args.push('-c', `mcp_servers.${MCP_SERVER_KEY}.headers.Authorization=Bearer ${authToken}`); + } + return args; } // ── claude-code injection (per-session, no cleanup needed) ─────────────────── @@ -486,9 +502,10 @@ export function applyClaudeCodeMcpUrl( sessionSettings: Record, url: string, isAnthropicBackend = true, + authToken?: string | null, ): void { const existing = (sessionSettings.mcpServers as Record) ?? {}; - sessionSettings.mcpServers = { ...existing, [MCP_SERVER_KEY]: buildMcpEntryUrl(url) }; + sessionSettings.mcpServers = { ...existing, [MCP_SERVER_KEY]: buildMcpEntryUrl(url, authToken) }; // Inject skill as custom instructions. const existingInstructions = typeof sessionSettings.customInstructions === 'string' @@ -648,11 +665,11 @@ export function injectGeminiMcp(home?: string): () => void { * Use this when NativecoreService is running in HTTP mode. * Returns a cleanup function; must be called when the PTY exits. */ -export function injectGeminiMcpUrl(url: string, home?: string): () => void { +export function injectGeminiMcpUrl(url: string, home?: string, authToken?: string | null): () => void { const dir = join(home ?? homedir(), '.gemini'); const filePath = join(dir, 'settings.json'); // Gemini CLI only accepts { url } — no type/transport field allowed. - return injectMcpToFile(filePath, dir, 'mcpServers', buildGeminiMcpEntryUrl(url) as unknown as Record); + return injectMcpToFile(filePath, dir, 'mcpServers', buildGeminiMcpEntryUrl(url, authToken) as unknown as Record); } /** @@ -678,7 +695,7 @@ export function injectOpencodeMcp(home?: string): () => void { * Use this when NativecoreService is running in HTTP mode. * Returns a cleanup function; must be called when the PTY exits. */ -export function injectOpencodeMcpUrl(url: string, home?: string): () => void { +export function injectOpencodeMcpUrl(url: string, home?: string, authToken?: string | null): () => void { const xdgBase = process.env['XDG_CONFIG_HOME'] ?? join(home ?? homedir(), '.config'); const dir = join(xdgBase, 'opencode'); const filePath = join(dir, 'opencode.json'); @@ -687,7 +704,7 @@ export function injectOpencodeMcpUrl(url: string, home?: string): () => void { filePath, dir, 'mcp', - buildOpencodeMcpEntryUrl(url) as unknown as Record, + buildOpencodeMcpEntryUrl(url, authToken) as unknown as Record, ); } @@ -800,10 +817,14 @@ export function injectPiMcp(): { extensionPath: string; env: Record } { +export function injectPiMcpUrl(url: string, authToken?: string | null): { extensionPath: string; env: Record } { + const env: Record = { TDAY_DEVTOOLS_URL: url }; + if (authToken) { + env['TDAY_DEVTOOLS_AUTH_TOKEN'] = authToken; + } return { extensionPath: bridgeExtensionPath(), - env: { TDAY_DEVTOOLS_URL: url }, + env, }; } @@ -1034,6 +1055,7 @@ function writeProxyError( */ export function startMcpSessionProxy( nativecoreOrigin: string, + authToken?: string, ): Promise<{ proxyBaseUrl: string; stop: () => void }> { let sessionId: string | null = null; let cachedInitBody: Buffer | null = null; @@ -1055,11 +1077,14 @@ export function startMcpSessionProxy( ): Promise<{ status: number; resHeaders: http.IncomingHttpHeaders; body: Buffer }> => new Promise((resolve, reject) => { const targetUrl = `${nativecoreOrigin}${path}`; + const headers: Record = { ...reqHeaders, 'content-length': String(body.length) }; + // Inject auth header for every upstream request when a token is configured. + if (authToken) headers['authorization'] = `Bearer ${authToken}`; const req = http.request( targetUrl, { method, - headers: { ...reqHeaders, 'content-length': String(body.length) }, + headers, agent: upstreamAgent, }, (res) => { @@ -1093,6 +1118,8 @@ export function startMcpSessionProxy( fwdHeaders[lk] = Array.isArray(v) ? v.join(', ') : (v ?? ''); } fwdHeaders['host'] = new URL(nativecoreOrigin).host; + // Inject auth header for every upstream request when a token is configured. + if (authToken) fwdHeaders['authorization'] = `Bearer ${authToken}`; // Non-POST (GET for SSE notifications, DELETE for session close): pipe directly if (method !== 'POST') { diff --git a/apps/desktop/src/main/cron.ts b/apps/desktop/src/main/cron.ts index 86f7db0..5b7d27d 100644 --- a/apps/desktop/src/main/cron.ts +++ b/apps/desktop/src/main/cron.ts @@ -223,12 +223,22 @@ export class CronScheduler { const delay = nextDate.getTime() - Date.now(); updateJobStats(job.id, { nextRunAt: nextDate.getTime() }); + // setTimeout delay is stored as a 32-bit signed integer, so values above + // 2^31 - 1 ms (~24.8 days) wrap to negative and fire immediately. + // When the next run is further away, cap the delay and reschedule at + // expiry without firing, so we converge on the true fire time. + const MAX_TIMEOUT_MS = 2 ** 31 - 1; + const clampedDelay = Math.min(Math.max(0, delay), MAX_TIMEOUT_MS); + const willFireNow = delay <= MAX_TIMEOUT_MS; + const timer = setTimeout(() => { this.timers.delete(job.id); - this.fireFn(job); - // Immediately reschedule for the next occurrence. + if (willFireNow && Date.now() >= nextDate.getTime()) { + this.fireFn(job); + } + // Immediately reschedule for the next (or same, if clamped) occurrence. this.scheduleOne(job); - }, Math.max(0, delay)); + }, clampedDelay); this.timers.set(job.id, timer); } diff --git a/apps/desktop/src/main/gateway/adapter.ts b/apps/desktop/src/main/gateway/adapter.ts index 57c1252..1230cd7 100644 --- a/apps/desktop/src/main/gateway/adapter.ts +++ b/apps/desktop/src/main/gateway/adapter.ts @@ -58,12 +58,38 @@ export function sessionKeyFromRequest(req: IncomingMessage): string { return ''; } +// ─── Bounded LRU-style map ──────────────────────────────────────────────────── + +/** + * A Map that evicts the oldest entry when it grows beyond `maxSize`. + * Insertion order is used as the eviction order (least-recently-inserted first). + */ +class BoundedMap extends Map { + constructor(private readonly maxSize: number) { super(); } + + override set(key: K, value: V): this { + // If the key already exists, delete it first so the insertion order is + // updated (most-recently-used semantics). + if (this.has(key)) this.delete(key); + super.set(key, value); + // Each set() adds at most one entry, so a single eviction step suffices. + if (this.size > this.maxSize) { + const oldest = this.keys().next().value; + if (oldest !== undefined) this.delete(oldest); + } + return this; + } +} + +// Maximum number of conversation / thinking-state entries to keep in memory. +const MAX_CONVERSATION_ENTRIES = 100; + // ─── Adapter ───────────────────────────────────────────────────────────────── export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { private readonly proxies = new Map(); - private readonly conversations = new Map(); - private readonly thinkingStates = new Map(); + private readonly conversations = new BoundedMap(MAX_CONVERSATION_ENTRIES); + private readonly thinkingStates = new BoundedMap(MAX_CONVERSATION_ENTRIES); /** Maps agentId → last-known cwd for usage attribution. */ private readonly cwdByAgentId = new Map(); diff --git a/apps/desktop/src/main/gateway/bridge/input.ts b/apps/desktop/src/main/gateway/bridge/input.ts index 59c0ac7..2b19a5b 100644 --- a/apps/desktop/src/main/gateway/bridge/input.ts +++ b/apps/desktop/src/main/gateway/bridge/input.ts @@ -359,7 +359,10 @@ export function convertInput( // user (or anything else with content) { - const r = role || 'user'; + // Only 'user' and 'assistant' are valid Anthropic message roles. + // Anything else (e.g. 'system', 'tool', unknown strings) defaults to 'user' + // to prevent a 400 error from the Anthropic API. + const r: 'user' | 'assistant' = role === 'assistant' ? 'assistant' : 'user'; const blocks = contentBlocksFromContent(item.content); if (blocks.length === 0) { pendingSummary = undefined; continue; } messages.push({ role: r, content: blocks }); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index fe44be5..93c3713 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -205,6 +205,12 @@ function registerIpc(): void { // PTY spawn ipcMain.handle(IPC.ptySpawn, async (event, req: SpawnRequest) => { + // Validate tabId early — it is used to construct file paths and must not + // contain path-traversal sequences (e.g. '../', '/', null bytes). + if (!/^[\w-]+$/.test(req.tabId)) { + throw new Error(`[tday] Invalid tabId — must match [a-zA-Z0-9_-]: "${req.tabId}"`); + } + const existing = ptys.get(req.tabId); if (existing) { try { existing.kill(); } catch { /* already dead */ } @@ -294,7 +300,8 @@ function registerIpc(): void { console.warn('[tday] NativecoreService unavailable for pi, falling back to stdio:', e); } if (piCuUrl) { - const { extensionPath, env: cuEnv } = injectPiMcpUrl(piCuUrl); + const piAuthToken = NativecoreService.getAuthToken(); + const { extensionPath, env: cuEnv } = injectPiMcpUrl(piCuUrl, piAuthToken); Object.assign(env, cuEnv); args = [...args, '--extension', extensionPath]; computerUseCleanup = () => NativecoreService.release(); @@ -410,6 +417,7 @@ function registerIpc(): void { const claudeDir = join(homedir(), '.claude'); const globalSettingsPath = join(claudeDir, 'settings.json'); + // Per-session temp settings file — unique per tab, never collides. const sessionSettingsPath = join(claudeDir, `tday-session-${req.tabId}.json`); // Separate MCP config file passed via --mcp-config (mcpServers in --settings is ignored by claude-code). @@ -433,7 +441,8 @@ function registerIpc(): void { console.warn('[tday] NativecoreService unavailable for claude-code, falling back to stdio:', e); } if (cuNativecoreUrl) { - applyClaudeCodeMcpUrl(sessionSettings, cuNativecoreUrl, isAnthropicBackend); + const ccAuthToken = NativecoreService.getAuthToken(); + applyClaudeCodeMcpUrl(sessionSettings, cuNativecoreUrl, isAnthropicBackend, ccAuthToken); computerUseCleanup = () => NativecoreService.release(); } else { applyClaudeCodeMcp(sessionSettings, isAnthropicBackend); @@ -513,7 +522,8 @@ function registerIpc(): void { console.warn('[tday] NativecoreService unavailable for claude-code (no provider), falling back to stdio:', e); } if (cuUrl) { - applyClaudeCodeMcpUrl(sessionSettings, cuUrl, true); + const ccAuthToken2 = NativecoreService.getAuthToken(); + applyClaudeCodeMcpUrl(sessionSettings, cuUrl, true, ccAuthToken2); computerUseCleanup = () => NativecoreService.release(); } else { applyClaudeCodeMcp(sessionSettings, true); @@ -555,7 +565,8 @@ function registerIpc(): void { console.warn('[tday] NativecoreService unavailable for gemini, falling back to stdio:', e); } if (cuUrl) { - const cleanup = injectGeminiMcpUrl(cuUrl); + const geminiAuthToken = NativecoreService.getAuthToken(); + const cleanup = injectGeminiMcpUrl(cuUrl, undefined, geminiAuthToken); computerUseCleanup = () => { cleanup(); NativecoreService.release(); }; } else { computerUseCleanup = injectGeminiMcp(); @@ -570,7 +581,8 @@ function registerIpc(): void { console.warn('[tday] NativecoreService unavailable for opencode, falling back to stdio:', e); } if (cuUrl) { - const cleanup = injectOpencodeMcpUrl(cuUrl); + const opencodeAuthToken = NativecoreService.getAuthToken(); + const cleanup = injectOpencodeMcpUrl(cuUrl, undefined, opencodeAuthToken); computerUseCleanup = () => { cleanup(); NativecoreService.release(); }; } else { computerUseCleanup = injectOpencodeMcp(); @@ -590,8 +602,9 @@ function registerIpc(): void { try { await NativecoreService.addRef(); const nativecoreUrl = NativecoreService.getUrl(); + const codexAuthToken = NativecoreService.getAuthToken(); if (!nativecoreUrl) throw new Error('NativecoreService not ready'); - const mcpProxy = await startMcpSessionProxy(nativecoreUrl); + const mcpProxy = await startMcpSessionProxy(nativecoreUrl, codexAuthToken ?? undefined); mcpProxyStop = mcpProxy.stop; // codex MCP URL = proxy base + the /mcp path that nativecore serves args.push(...codexMcpCliArgsUrl(mcpProxy.proxyBaseUrl + '/mcp')); @@ -895,10 +908,21 @@ function registerIpc(): void { ipcMain.handle(IPC.coworkerReset, (_e, id: string) => resetBuiltinCoworker(id)); ipcMain.handle(IPC.coworkerFetchUrl, async (_e, rawUrl: string): Promise => { const trimmed = rawUrl.trim(); - // Local file path: read directly + // Local file path: restrict reads to user-owned config/project directories. + // Disallow absolute paths that could read arbitrary system files. if (trimmed.startsWith('/') || /^[A-Za-z]:[/\\]/.test(trimmed)) { const { readFileSync } = await import('fs'); - return readFileSync(trimmed, 'utf8'); + const { resolve: resolvePath } = await import('path'); + const { homedir } = await import('os'); + + const resolved = resolvePath(trimmed); + const home = homedir(); + // Only allow reading files inside the user's home directory to prevent + // reading sensitive system files such as /etc/passwd or SSH keys. + if (!resolved.startsWith(home + '/') && !resolved.startsWith(home + '\\')) { + throw new Error(`Reading files outside the home directory is not allowed: ${resolved}`); + } + return readFileSync(resolved, 'utf8'); } const url = normalizeGitHubUrl(trimmed); const res = await fetch(url); diff --git a/apps/desktop/src/main/nativecore-service.ts b/apps/desktop/src/main/nativecore-service.ts index eefc90c..c70b66a 100644 --- a/apps/desktop/src/main/nativecore-service.ts +++ b/apps/desktop/src/main/nativecore-service.ts @@ -19,6 +19,7 @@ * stdout before accepting any connections. */ +import { randomBytes } from 'node:crypto'; import { type ChildProcess, spawn } from 'node:child_process'; import { devToolsBinaryPath } from './computer-use'; @@ -42,6 +43,8 @@ class NativecoreServiceImpl { private _killTimer: ReturnType | null = null; private _status: NativecoreStatus = 'stopped'; private _lastError: string | null = null; + /** Per-process random bearer token to authenticate MCP requests. */ + private _authToken: string | null = null; // ── Public API ───────────────────────────────────────────────────────────── @@ -111,6 +114,14 @@ class NativecoreServiceImpl { return `http://127.0.0.1:${this._port}/mcp`; } + /** + * Returns the bearer auth token for the current process, or null if none. + * Callers should include this as `Authorization: Bearer ` when connecting. + */ + getAuthToken(): string | null { + return this._authToken; + } + /** True when the process is running and the port is known. */ get isRunning(): boolean { return this.proc !== null && this._port !== null; @@ -142,7 +153,13 @@ class NativecoreServiceImpl { return; } - const proc = spawn(bin, ['--port', '0'], { + // Generate a cryptographically-random bearer token for this process + // instance. The token is passed via --auth-token and must be sent by + // all MCP clients in the Authorization header. + const authToken = randomBytes(32).toString('hex'); + this._authToken = authToken; + + const proc = spawn(bin, ['--port', '0', '--auth-token', authToken], { stdio: ['ignore', 'pipe', 'pipe'], detached: false, // On Windows, hide the console window that would otherwise flash briefly. @@ -163,6 +180,7 @@ class NativecoreServiceImpl { this._startPromise = null; this._status = 'stopped'; this._lastError = err.message; + this._authToken = null; reject(err); } else { this._port = port!; @@ -173,17 +191,24 @@ class NativecoreServiceImpl { }; // Read port announcement from stdout line by line. - proc.stdout!.on('data', (chunk: Buffer) => { - stdoutBuf += chunk.toString('utf8'); - const m = /NATIVECORE_PORT:(\d+)/.exec(stdoutBuf); - if (m) { - const port = parseInt(m[1], 10); - settle(undefined, port); - } - }); + // proc.stdout is always non-null when stdio includes 'pipe', but guard + // defensively to avoid a crash if the spawn configuration changes. + if (proc.stdout) { + proc.stdout.on('data', (chunk: Buffer) => { + stdoutBuf += chunk.toString('utf8'); + const m = /NATIVECORE_PORT:(\d+)/.exec(stdoutBuf); + if (m) { + const port = parseInt(m[1], 10); + settle(undefined, port); + } + }); + } else { + settle(new Error('[NativecoreService] stdout is null — cannot read NATIVECORE_PORT')); + return; + } // Forward nativecore logs (stderr) to Electron's own stderr for debugging. - proc.stderr!.on('data', (chunk: Buffer) => process.stderr.write(chunk)); + proc.stderr?.on('data', (chunk: Buffer) => process.stderr.write(chunk)); proc.on('error', (err) => { settle(new Error(`[NativecoreService] Spawn error: ${err.message}`)); @@ -228,6 +253,7 @@ class NativecoreServiceImpl { this.proc = null; this._port = null; this._startPromise = null; + this._authToken = null; this._status = 'stopping'; try { p.kill(); } catch { /* already dead */ } if (this._refCount === 0) { diff --git a/apps/desktop/src/renderer/src/Settings/shared.tsx b/apps/desktop/src/renderer/src/Settings/shared.tsx index 15cf31e..122f7b5 100644 --- a/apps/desktop/src/renderer/src/Settings/shared.tsx +++ b/apps/desktop/src/renderer/src/Settings/shared.tsx @@ -1,7 +1,14 @@ import { memo, useMemo } from 'react'; -import { marked } from 'marked'; +import { marked, Renderer } from 'marked'; -marked.setOptions({ breaks: true }); +// Disable raw HTML pass-through so that untrusted markdown content cannot +// inject