Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
},
},
},
];
4 changes: 2 additions & 2 deletions src/__tests__/ocr-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
13 changes: 9 additions & 4 deletions src/action-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
20 changes: 10 additions & 10 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export class Agent {
stepsTotal: 0,
};
private aborted = false;
private taskExecutionLocked = false;

constructor(config: ClawdConfig) {
this.config = config;
Expand Down Expand Up @@ -377,15 +378,21 @@ public class WinAPI {
private static readonly TASK_TIMEOUT_MS = 60 * 1000; // 60s — fast fail, diagnose, iterate

async executeTask(task: string): Promise<TaskResult> {
// 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() }],
duration: 0,
};
}

this.taskExecutionLocked = true;
this.state = {
status: 'thinking',
currentTask: task,
stepsCompleted: 0,
stepsTotal: 1,
};
this.aborted = false;
const startTime = Date.now();

Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1745,4 +1746,3 @@ function tierEmoji(tier: SafetyTier): string {
case SafetyTier.Confirm: return '🔴';
}
}

2 changes: 1 addition & 1 deletion src/browser-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
12 changes: 9 additions & 3 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!DOCTYPE html>
function renderDashboardHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
Expand Down Expand Up @@ -912,3 +917,4 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
</script>
</body>
</html>`;
}
6 changes: 3 additions & 3 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 3 additions & 2 deletions src/ocr-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 18 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 <token> 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 <token> header. Token is at ~/.clawdcursor/token' });
return;
Expand Down Expand Up @@ -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());
});

Expand Down Expand Up @@ -287,15 +299,15 @@ 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([]);
res.json(logger.getRecentSummaries(50));
} 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();
Expand Down Expand Up @@ -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);
});

Expand Down
35 changes: 31 additions & 4 deletions src/task-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

// ─── 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 =
Expand Down Expand Up @@ -119,17 +121,17 @@

this.layersUsed.add(entry.layer);

const full: StepLogEntry = {

Check failure on line 124 in src/task-logger.ts

View workflow job for this annotation

GitHub Actions / windows-latest / Node 20.x

Type '{ durationMs?: number | undefined; error?: string | undefined; verification?: VerificationInfo | undefined; uiStateSummary?: string | undefined; llmReasoning?: string | undefined; ... 5 more ...; result: "success" | ... 3 more ... | "blocked"; }' is not assignable to type 'StepLogEntry'.

Check failure on line 124 in src/task-logger.ts

View workflow job for this annotation

GitHub Actions / macos-latest / Node 22.x

Type '{ durationMs?: number | undefined; error?: string | undefined; verification?: VerificationInfo | undefined; uiStateSummary?: string | undefined; llmReasoning?: string | undefined; ... 5 more ...; result: "success" | ... 3 more ... | "blocked"; }' is not assignable to type 'StepLogEntry'.

Check failure on line 124 in src/task-logger.ts

View workflow job for this annotation

GitHub Actions / windows-latest / Node 22.x

Type '{ durationMs?: number | undefined; error?: string | undefined; verification?: VerificationInfo | undefined; uiStateSummary?: string | undefined; llmReasoning?: string | undefined; ... 5 more ...; result: "success" | ... 3 more ... | "blocked"; }' is not assignable to type 'StepLogEntry'.

Check failure on line 124 in src/task-logger.ts

View workflow job for this annotation

GitHub Actions / macos-latest / Node 20.x

Type '{ durationMs?: number | undefined; error?: string | undefined; verification?: VerificationInfo | undefined; uiStateSummary?: string | undefined; llmReasoning?: string | undefined; ... 5 more ...; result: "success" | ... 3 more ... | "blocked"; }' is not assignable to type 'StepLogEntry'.

Check failure on line 124 in src/task-logger.ts

View workflow job for this annotation

GitHub Actions / ubuntu-latest / Node 20.x

Type '{ durationMs?: number | undefined; error?: string | undefined; verification?: VerificationInfo | undefined; uiStateSummary?: string | undefined; llmReasoning?: string | undefined; ... 5 more ...; result: "success" | ... 3 more ... | "blocked"; }' is not assignable to type 'StepLogEntry'.

Check failure on line 124 in src/task-logger.ts

View workflow job for this annotation

GitHub Actions / ubuntu-latest / Node 22.x

Type '{ durationMs?: number | undefined; error?: string | undefined; verification?: VerificationInfo | undefined; uiStateSummary?: string | undefined; llmReasoning?: string | undefined; ... 5 more ...; result: "success" | ... 3 more ... | "blocked"; }' is not assignable to type 'StepLogEntry'.
stepIndex: this.stepIndex++,
timestamp: new Date().toISOString(),
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 }),
};

Expand Down Expand Up @@ -233,3 +235,28 @@
}
}
}

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<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>).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);
}
11 changes: 5 additions & 6 deletions src/tools/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 };
},
},

Expand Down Expand Up @@ -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 };
}
},
},
Expand Down
Loading
Loading