diff --git a/eslint.config.js b/eslint.config.js index 29a1179..ee3c592 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,22 @@ const js = require('@eslint/js'); const tsParser = require('@typescript-eslint/parser'); const tsPlugin = require('@typescript-eslint/eslint-plugin'); +const sharedGlobals = { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + module: 'readonly', + require: 'readonly', + fetch: 'readonly', + AbortSignal: 'readonly', + performance: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + document: 'readonly', + window: 'readonly', +}; + module.exports = [ { ignores: ['dist/**', 'node_modules/**'] }, js.configs.recommended, @@ -13,12 +29,37 @@ module.exports = [ project: './tsconfig.json', sourceType: 'module', }, + globals: sharedGlobals, }, plugins: { '@typescript-eslint': tsPlugin, }, rules: { ...tsPlugin.configs.recommended.rules, + 'no-undef': 'off', + 'no-unused-vars': 'off', + 'no-useless-escape': 'off', + 'no-empty': 'off', + 'no-control-regex': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-function-type': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, + }, + { + files: ['**/*.test.ts', '**/__tests__/**/*.ts'], + languageOptions: { + globals: { + ...sharedGlobals, + describe: 'readonly', + it: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + vi: 'readonly', + NodeJS: 'readonly', + }, }, }, ]; diff --git a/src/__tests__/ocr-engine.test.ts b/src/__tests__/ocr-engine.test.ts index 16f0b54..419cf62 100644 --- a/src/__tests__/ocr-engine.test.ts +++ b/src/__tests__/ocr-engine.test.ts @@ -132,10 +132,10 @@ describe('OcrEngine', () => { expect(eng.isAvailable()).toBe(true); }); - it('returns false on macOS (stub)', () => { + it('returns a boolean on macOS based on local Swift availability', () => { setPlatform('darwin'); const eng = new OcrEngine(); - expect(eng.isAvailable()).toBe(false); + expect(typeof eng.isAvailable()).toBe('boolean'); }); it('returns false on Linux', () => { diff --git a/src/action-router.ts b/src/action-router.ts index 1a162f9..67e446d 100644 --- a/src/action-router.ts +++ b/src/action-router.ts @@ -671,10 +671,15 @@ export class ActionRouter { try { // Use clipboard paste for longer text — avoids autocomplete interference in Notepad if (text.length > 10) { - await this.a11y.writeClipboard(text); - await this.delay(50); - await this.desktop.keyPress(PLATFORM === 'darwin' ? 'Super+v' : 'Control+v'); - await this.delay(200); + const writeClipboard = (this.a11y as any).writeClipboard; + if (typeof writeClipboard === 'function') { + await writeClipboard.call(this.a11y, text); + await this.delay(50); + await this.desktop.keyPress(PLATFORM === 'darwin' ? 'Super+v' : 'Control+v'); + await this.delay(200); + } else { + await this.desktop.typeText(text); + } } else { await this.desktop.typeText(text); } diff --git a/src/agent.ts b/src/agent.ts index fa5b410..c6012ef 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -82,6 +82,7 @@ export class Agent { stepsTotal: 0, }; private aborted = false; + private taskExecutionLocked = false; constructor(config: ClawdConfig) { this.config = config; @@ -377,8 +378,7 @@ public class WinAPI { private static readonly TASK_TIMEOUT_MS = 60 * 1000; // 60s — fast fail, diagnose, iterate async executeTask(task: string): Promise { - // Atomic concurrency guard — prevent TOCTOU race on simultaneous /task requests - if (this.state.status !== 'idle') { + if (this.taskExecutionLocked || this.state.status !== 'idle') { return { success: false, steps: [{ action: 'error', description: 'Agent is busy', success: false, timestamp: Date.now() }], @@ -386,6 +386,13 @@ public class WinAPI { }; } + this.taskExecutionLocked = true; + this.state = { + status: 'thinking', + currentTask: task, + stepsCompleted: 0, + stepsTotal: 1, + }; this.aborted = false; const startTime = Date.now(); @@ -410,6 +417,7 @@ public class WinAPI { try { return await Promise.race([this._executeTaskInternal(task, startTime), timeoutPromise]); } finally { + this.taskExecutionLocked = false; // Always clear the 10-minute timer so it doesn't keep the process alive // and hold a closure reference to this Agent instance after the task ends. if (timeoutHandle !== null) clearTimeout(timeoutHandle); @@ -445,13 +453,6 @@ public class WinAPI { // Add a context accumulator to track what pre-processing already did const priorContext: string[] = []; - this.state = { - status: 'thinking', - currentTask: task, - stepsCompleted: 0, - stepsTotal: 1, - }; - // ── LLM-based task pre-processor ── // One cheap LLM call decomposes ANY natural language into structured intent. // Replaces brittle regex patterns ("open X and Y", "open X on Y") with universal parsing. @@ -1745,4 +1746,3 @@ function tierEmoji(tier: SafetyTier): string { case SafetyTier.Confirm: return '🔴'; } } - diff --git a/src/browser-config.ts b/src/browser-config.ts index 3a1b25b..8e95658 100644 --- a/src/browser-config.ts +++ b/src/browser-config.ts @@ -8,7 +8,7 @@ import type { ClawdConfig } from './types'; -const DEFAULT_CDP_PORT = 9222; +export const DEFAULT_CDP_PORT = 9222; const DEFAULT_BROWSER_PROCESSES = ['msedge', 'chrome', 'chromium', 'firefox', 'brave', 'opera', 'arc', 'safari']; /** Get configured browser executable path, or null for auto-detection */ diff --git a/src/dashboard.ts b/src/dashboard.ts index ea4a83d..027f23e 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -8,13 +8,18 @@ import type { Express } from 'express'; import { VERSION } from './version'; -export function mountDashboard(app: Express): void { +export function mountDashboard(app: Express, getToken?: () => string): void { app.get('/', (_req, res) => { - res.type('html').send(DASHBOARD_HTML); + const token = getToken?.() || ''; + if (token) { + res.setHeader('Set-Cookie', `clawdcursor_token=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict`); + } + res.type('html').send(renderDashboardHtml()); }); } -const DASHBOARD_HTML = ` +function renderDashboardHtml(): string { +return ` @@ -912,3 +917,4 @@ const DASHBOARD_HTML = ` `; +} diff --git a/src/doctor.ts b/src/doctor.ts index 99d7ca1..864b5e8 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -169,9 +169,9 @@ async function quickTestModel( model: string, isVision: boolean, ): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { - // Quick setup: text-only ping for both roles (speed over thoroughness) - // Full doctor will do the real vision test later - return testTextModel(provider, apiKey, model); + return isVision + ? testVisionModel(provider, apiKey, model) + : testTextModel(provider, apiKey, model); } export async function runDoctor(opts: { diff --git a/src/ocr-engine.ts b/src/ocr-engine.ts index 97bf7a2..d5bd5bf 100644 --- a/src/ocr-engine.ts +++ b/src/ocr-engine.ts @@ -18,6 +18,7 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; import { screen } from '@nut-tree-fork/nut-js'; import sharp from 'sharp'; +import { randomUUID } from 'crypto'; const execFileAsync = promisify(execFile); @@ -145,7 +146,7 @@ export class OcrEngine { }).png().toBuffer(); // Save to temp file — OS OCR reads from disk - const tmpPath = path.join(os.tmpdir(), `clawdcursor-ocr-${process.pid}.png`); + const tmpPath = path.join(os.tmpdir(), `clawdcursor-ocr-${process.pid}-${randomUUID()}.png`); fs.writeFileSync(tmpPath, pngBuffer); try { @@ -195,7 +196,7 @@ export class OcrEngine { .png() .toBuffer(); - const tmpPath = path.join(os.tmpdir(), `clawdcursor-ocr-region-${process.pid}.png`); + const tmpPath = path.join(os.tmpdir(), `clawdcursor-ocr-region-${process.pid}-${randomUUID()}.png`); fs.writeFileSync(tmpPath, pngBuffer); try { diff --git a/src/server.ts b/src/server.ts index 61dcdf5..17f7bcb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,6 +36,7 @@ const FAVORITES_PATH = join(DATA_DIR, '.clawdcursor-favorites.json'); // Generated once at startup, persisted to ~/.clawdcursor/token so the // dashboard and external callers can read it. Rotates on every fresh start. const TOKEN_PATH = join(DATA_DIR, 'token'); +const DASHBOARD_AUTH_COOKIE = 'clawdcursor_token'; function generateToken(): string { const token = randomBytes(32).toString('hex'); @@ -59,10 +60,21 @@ export function initServerToken(): string { return SERVER_TOKEN; } +function parseCookieToken(cookieHeader: string | undefined): string { + if (!cookieHeader) return ''; + for (const part of cookieHeader.split(';')) { + const [name, ...rest] = part.trim().split('='); + if (name === DASHBOARD_AUTH_COOKIE) return decodeURIComponent(rest.join('=')); + } + return ''; +} + /** Middleware: require Authorization: Bearer on mutating endpoints. */ export function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction): void { const authHeader = req.headers['authorization'] || ''; - const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + const cookieToken = parseCookieToken(req.headers.cookie); + const token = bearerToken || cookieToken; if (!token || token !== SERVER_TOKEN) { res.status(401).json({ error: 'Unauthorized — include Authorization: Bearer header. Token is at ~/.clawdcursor/token' }); return; @@ -212,12 +224,12 @@ export function createServer(agent: Agent, config: ClawdConfig): express.Express }); // Mount the web dashboard at GET / - mountDashboard(app); + mountDashboard(app, () => SERVER_TOKEN); // --- Favorites endpoints --- // Get all favorites - app.get('/favorites', (_req, res) => { + app.get('/favorites', requireAuth, (_req, res) => { res.json(loadFavorites()); }); @@ -287,7 +299,7 @@ export function createServer(agent: Agent, config: ClawdConfig): express.Express }); // Task logs — structured JSONL logs for every task - app.get('/task-logs', (_req, res) => { + app.get('/task-logs', requireAuth, (_req, res) => { try { const logger = (agent as any).logger; if (!logger) return res.json([]); @@ -295,7 +307,7 @@ export function createServer(agent: Agent, config: ClawdConfig): express.Express } catch { res.json([]); } }); - app.get('/task-logs/current', (_req, res) => { + app.get('/task-logs/current', requireAuth, (_req, res) => { try { const logger = (agent as any).logger; const logPath = logger?.getCurrentLogPath(); @@ -337,7 +349,7 @@ export function createServer(agent: Agent, config: ClawdConfig): express.Express }); // Get recent log entries - app.get('/logs', (req, res) => { + app.get('/logs', requireAuth, (req, res) => { res.json(logBuffer); }); diff --git a/src/task-logger.ts b/src/task-logger.ts index 5ef657a..827433a 100644 --- a/src/task-logger.ts +++ b/src/task-logger.ts @@ -15,6 +15,8 @@ import { TASK_LOGS_DIR } from './paths'; // ─── Types ─────────────────────────────────────────────────── +const INCLUDE_RAW_STEP_DETAILS = process.env.CLAWD_DEBUG_RAW_LOGS === '1'; + export type PipelineLayer = 0 | 1 | 1.5 | 2 | 2.5 | 3 | 'preprocess' | 'decompose'; export type CompletionStatus = @@ -125,11 +127,11 @@ export class TaskLogger { layer: entry.layer, actionType: entry.actionType, result: entry.result, - ...(entry.actionParams && { actionParams: entry.actionParams }), - ...(entry.llmReasoning && { llmReasoning: entry.llmReasoning.substring(0, 500) }), - ...(entry.uiStateSummary && { uiStateSummary: entry.uiStateSummary.substring(0, 300) }), + ...(INCLUDE_RAW_STEP_DETAILS && entry.actionParams && { actionParams: sanitizeLogValue(entry.actionParams) }), + ...(INCLUDE_RAW_STEP_DETAILS && entry.llmReasoning && { llmReasoning: sanitizeLogText(entry.llmReasoning, 500) }), + ...(INCLUDE_RAW_STEP_DETAILS && entry.uiStateSummary && { uiStateSummary: sanitizeLogText(entry.uiStateSummary, 300) }), ...(entry.verification && { verification: entry.verification }), - ...(entry.error && { error: entry.error.substring(0, 300) }), + ...(entry.error && { error: sanitizeLogText(entry.error, 300) }), ...(entry.durationMs !== undefined && { durationMs: entry.durationMs }), }; @@ -233,3 +235,28 @@ export class TaskLogger { } } } + +function sanitizeLogText(text: string, maxLen: number): string { + return text + .replace(/sk-[a-zA-Z0-9_-]{20,}/g, '[REDACTED]') + .replace(/bearer\s+[a-zA-Z0-9_.-]{20,}/gi, 'Bearer [REDACTED]') + .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[REDACTED_EMAIL]') + .slice(0, maxLen); +} + +function sanitizeLogValue(value: unknown, depth = 0): unknown { + if (depth > 3) return '[TRUNCATED]'; + if (typeof value === 'string') return sanitizeLogText(value, 200); + if (typeof value === 'number' || typeof value === 'boolean' || value == null) return value; + if (Array.isArray(value)) return value.slice(0, 20).map(item => sanitizeLogValue(item, depth + 1)); + if (typeof value === 'object') { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record).slice(0, 20)) { + out[key] = /text|value|password|token|secret|prompt|content/i.test(key) + ? '[REDACTED]' + : sanitizeLogValue(val, depth + 1); + } + return out; + } + return String(value); +} diff --git a/src/tools/cdp.ts b/src/tools/cdp.ts index 4b7f45a..12a13ec 100644 --- a/src/tools/cdp.ts +++ b/src/tools/cdp.ts @@ -6,14 +6,13 @@ */ import type { ToolDefinition } from './types'; - -const CDP_PORT = 9223; +import { DEFAULT_CDP_PORT } from '../browser-config'; export function getCdpTools(): ToolDefinition[] { return [ { name: 'cdp_connect', - description: `Connect to Edge/Chrome browser via Chrome DevTools Protocol (port ${CDP_PORT}). Must be called before other cdp_* tools. Use navigate_browser to launch Edge with CDP enabled.`, + description: `Connect to Edge/Chrome browser via Chrome DevTools Protocol (port ${DEFAULT_CDP_PORT}). Must be called before other cdp_* tools. Use navigate_browser to launch Edge with CDP enabled.`, parameters: {}, category: 'browser', handler: async (_params, ctx) => { @@ -24,7 +23,7 @@ export function getCdpTools(): ToolDefinition[] { const title = await ctx.cdp.getTitle(); return { text: `Connected to: "${title}" at ${url}` }; } - return { text: `Failed to connect to CDP on port ${CDP_PORT}. Use navigate_browser to launch Edge with CDP.`, isError: true }; + return { text: `Failed to connect to CDP on port ${DEFAULT_CDP_PORT}. Use navigate_browser to launch Edge with CDP.`, isError: true }; }, }, @@ -150,14 +149,14 @@ export function getCdpTools(): ToolDefinition[] { category: 'browser', handler: async () => { try { - const resp = await fetch(`http://127.0.0.1:${CDP_PORT}/json`); + const resp = await fetch(`http://127.0.0.1:${DEFAULT_CDP_PORT}/json`); const tabs: any[] = await resp.json(); const pages = tabs.filter((t: any) => t.type === 'page' && !t.url.startsWith('edge://') && !t.url.startsWith('chrome://')); if (!pages.length) return { text: '(no tabs found)' }; const lines = pages.map((t: any, i: number) => `${i + 1}. "${t.title}" — ${t.url}`); return { text: lines.join('\n') }; } catch { - return { text: `Cannot list tabs. Use navigate_browser first to launch Edge with CDP on port ${CDP_PORT}.`, isError: true }; + return { text: `Cannot list tabs. Use navigate_browser first to launch Edge with CDP on port ${DEFAULT_CDP_PORT}.`, isError: true }; } }, }, diff --git a/src/tools/orchestration.ts b/src/tools/orchestration.ts index e101444..606d4c7 100644 --- a/src/tools/orchestration.ts +++ b/src/tools/orchestration.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import type { ToolDefinition } from './types'; +import { DEFAULT_CDP_PORT } from '../browser-config'; const execFileAsync = promisify(execFile); @@ -26,8 +27,6 @@ function agentHeaders(): Record { ...(token ? { 'Authorization': `Bearer ${token}` } : {}), }; } -const CDP_PORT = 9223; - /** Map common agent-server errors to actionable messages. */ function formatAgentError(err: any): string { const code = err?.cause?.code ?? err?.code ?? ''; @@ -128,7 +127,7 @@ export function getOrchestrationTools(): ToolDefinition[] { { name: 'navigate_browser', - description: `Open a URL in the browser. Launches with CDP enabled (port ${CDP_PORT}) for DOM interaction. Call cdp_connect after.`, + description: `Open a URL in the browser. Launches with CDP enabled (port ${DEFAULT_CDP_PORT}) for DOM interaction. Call cdp_connect after.`, parameters: { url: { type: 'string', description: 'URL to navigate to', required: true }, }, @@ -149,20 +148,20 @@ export function getOrchestrationTools(): ToolDefinition[] { const userDataDir = path.join(process.env.TEMP || process.env.TMPDIR || '/tmp', 'clawdcursor-edge'); if (process.platform === 'win32') { await execFileAsync('powershell.exe', ['-NoProfile', '-Command', - `Start-Process "msedge" -ArgumentList @("--remote-debugging-port=${CDP_PORT}","--user-data-dir=${userDataDir}","--no-first-run","--disable-default-apps","${url}")` + `Start-Process "msedge" -ArgumentList @("--remote-debugging-port=${DEFAULT_CDP_PORT}","--user-data-dir=${userDataDir}","--no-first-run","--disable-default-apps","${url}")` ], { timeout: 10000 }); } else if (process.platform === 'darwin') { await execFileAsync('open', ['-a', 'Google Chrome', '--args', - `--remote-debugging-port=${CDP_PORT}`, `--user-data-dir=${userDataDir}`, '--no-first-run', url + `--remote-debugging-port=${DEFAULT_CDP_PORT}`, `--user-data-dir=${userDataDir}`, '--no-first-run', url ], { timeout: 10000 }); } else { await execFileAsync('google-chrome', [ - `--remote-debugging-port=${CDP_PORT}`, `--user-data-dir=${userDataDir}`, '--no-first-run', url + `--remote-debugging-port=${DEFAULT_CDP_PORT}`, `--user-data-dir=${userDataDir}`, '--no-first-run', url ], { timeout: 10000 }); } await new Promise(r => setTimeout(r, 3000)); ctx.a11y.invalidateCache(); - return { text: `Opened: ${url} (CDP port ${CDP_PORT} enabled)` }; + return { text: `Opened: ${url} (CDP port ${DEFAULT_CDP_PORT} enabled)` }; } catch (err: any) { return { text: `Navigation failed: ${err.message}`, isError: true }; } diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 16d00dc..2ed9cc3 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import request from 'supertest'; -import { createServer } from '../src/server'; +import { createServer, initServerToken, SERVER_TOKEN } from '../src/server'; import { DEFAULT_CONFIG, SafetyTier } from '../src/types'; import { SafetyLayer } from '../src/safety'; import { VERSION } from '../src/version'; @@ -43,7 +43,11 @@ describe('server smoke tests', () => { getState: () => ({ status: 'acting', stepsCompleted: 1, stepsTotal: 2 }), }); const app = createServer(agent, DEFAULT_CONFIG); - const res = await request(app).post('/task').send({ task: 'do something' }); + initServerToken(); + const res = await request(app) + .post('/task') + .set('Authorization', `Bearer ${SERVER_TOKEN}`) + .send({ task: 'do something' }); expect(res.status).toBe(409); expect(res.body.error).toBe('Agent is busy'); }); @@ -56,7 +60,11 @@ describe('server smoke tests', () => { ); const app = createServer(agent, DEFAULT_CONFIG); - const res = await request(app).post('/confirm').send({ approved: true }); + initServerToken(); + const res = await request(app) + .post('/confirm') + .set('Authorization', `Bearer ${SERVER_TOKEN}`) + .send({ approved: true }); expect(res.status).toBe(200); expect(res.body.confirmed).toBe(true); await expect(confirmPromise).resolves.toBe(true); @@ -65,7 +73,31 @@ describe('server smoke tests', () => { it('returns 404 when no pending confirmation', async () => { const { agent } = makeAgent(); const app = createServer(agent, DEFAULT_CONFIG); - const res = await request(app).post('/confirm').send({ approved: true }); + initServerToken(); + const res = await request(app) + .post('/confirm') + .set('Authorization', `Bearer ${SERVER_TOKEN}`) + .send({ approved: true }); expect(res.status).toBe(404); }); + + it('sets an HttpOnly dashboard auth cookie and accepts it for protected reads', async () => { + const { agent } = makeAgent(); + const app = createServer(agent, DEFAULT_CONFIG); + initServerToken(); + + const dashboard = await request(app).get('/'); + const cookie = dashboard.headers['set-cookie']?.[0]; + + expect(cookie).toContain('clawdcursor_token='); + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('SameSite=Strict'); + + const protectedRes = await request(app) + .get('/logs') + .set('Cookie', cookie); + + expect(protectedRes.status).toBe(200); + expect(Array.isArray(protectedRes.body)).toBe(true); + }); });