diff --git a/package.json b/package.json index 99bd958..88092dc 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@versatly/workgraph-mcp-server": "workspace:*", "@types/node": "^20.11.0", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4e0483e..85ba002 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,6 +7,7 @@ import { registerAdapterCommands } from './cli/commands/adapter.js'; import { registerAutonomyCommands } from './cli/commands/autonomy.js'; import { registerCapabilityCommands } from './cli/commands/capability.js'; import { registerConversationCommands } from './cli/commands/conversation.js'; +import { registerCursorCommands } from './cli/commands/cursor.js'; import { registerDispatchCommands } from './cli/commands/dispatch.js'; import { registerMcpCommands } from './cli/commands/mcp.js'; import { registerSafetyCommands } from './cli/commands/safety.js'; @@ -2207,6 +2208,7 @@ addWorkspaceOption( registerAdapterCommands(program, DEFAULT_ACTOR); registerDispatchCommands(program, DEFAULT_ACTOR); +registerCursorCommands(program, DEFAULT_ACTOR); // ============================================================================ // trigger diff --git a/packages/cli/src/cli/commands/cursor.ts b/packages/cli/src/cli/commands/cursor.ts new file mode 100644 index 0000000..6dc2c2e --- /dev/null +++ b/packages/cli/src/cli/commands/cursor.ts @@ -0,0 +1,205 @@ +import { Command } from 'commander'; +import * as workgraph from '@versatly/workgraph-kernel'; +import { + addWorkspaceOption, + csv, + resolveWorkspacePath, + runCommand, +} from '../core.js'; + +export function registerCursorCommands(program: Command, defaultActor: string): void { + const cursorCmd = program + .command('cursor') + .description('Configure and run Cursor Automations bridge flows'); + + addWorkspaceOption( + cursorCmd + .command('setup') + .description('Configure Cursor webhook + dispatch bridge defaults') + .option('-a, --actor ', 'Dispatch actor for bridged runs', defaultActor) + .option('--enabled ', 'Enable bridge (true|false)') + .option('--secret ', 'Webhook HMAC shared secret') + .option('--event-types ', 'Comma-separated event patterns (supports *)') + .option('--adapter ', 'Dispatch adapter default') + .option('--execute ', 'Execute dispatch run immediately (true|false)') + .option('--agents ', 'Comma-separated agent identities') + .option('--max-steps ', 'Maximum scheduler steps') + .option('--step-delay-ms ', 'Delay between scheduler steps') + .option('--space ', 'Restrict dispatch to one space') + .option('--checkpoint ', 'Create dispatch checkpoint (true|false)') + .option('--timeout-ms ', 'Execution timeout in milliseconds') + .option('--dispatch-mode ', 'direct|self-assembly') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const config = workgraph.cursorBridge.setupCursorBridge(workspacePath, { + actor: opts.actor, + enabled: parseOptionalBoolean(opts.enabled, 'enabled'), + secret: opts.secret, + allowedEventTypes: csv(opts.eventTypes), + dispatch: { + adapter: opts.adapter, + execute: parseOptionalBoolean(opts.execute, 'execute'), + agents: csv(opts.agents), + maxSteps: parseOptionalInt(opts.maxSteps, 'max-steps'), + stepDelayMs: parseOptionalInt(opts.stepDelayMs, 'step-delay-ms'), + space: opts.space, + createCheckpoint: parseOptionalBoolean(opts.checkpoint, 'checkpoint'), + timeoutMs: parseOptionalInt(opts.timeoutMs, 'timeout-ms'), + dispatchMode: parseDispatchMode(opts.dispatchMode), + }, + }); + const status = workgraph.cursorBridge.getCursorBridgeStatus(workspacePath, { + recentEventsLimit: 3, + }); + return { + config, + status, + }; + }, + (result) => [ + `Cursor bridge configured: ${result.status.configPath}`, + `Enabled: ${result.config.enabled}`, + `Webhook secret: ${result.status.webhook.hasSecret ? 'configured' : 'not set'}`, + `Allowed events: ${result.config.webhook.allowedEventTypes.join(', ')}`, + `Dispatch default: actor=${result.config.dispatch.actor} adapter=${result.config.dispatch.adapter} execute=${result.config.dispatch.execute}`, + ], + ), + ); + + addWorkspaceOption( + cursorCmd + .command('status') + .description('Show Cursor bridge configuration and recent bridge events') + .option('--events ', 'Number of recent bridge events to show', '5') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + return workgraph.cursorBridge.getCursorBridgeStatus(workspacePath, { + recentEventsLimit: parseOptionalInt(opts.events, 'events') ?? 5, + }); + }, + (result) => [ + `Configured: ${result.configured}`, + `Enabled: ${result.enabled}`, + `Provider: ${result.provider}`, + `Config path: ${result.configPath}`, + `Events path: ${result.eventsPath}`, + `Webhook secret: ${result.webhook.hasSecret ? 'configured' : 'not set'}`, + `Allowed events: ${result.webhook.allowedEventTypes.join(', ')}`, + `Dispatch default: actor=${result.dispatch.actor} adapter=${result.dispatch.adapter} execute=${result.dispatch.execute}`, + ...(result.recentEvents.length === 0 + ? ['Recent events: none'] + : [ + 'Recent events:', + ...result.recentEvents.map((event) => + `- ${event.ts} ${event.eventType} run=${event.runId ?? 'none'} status=${event.runStatus ?? 'none'}${event.error ? ` error=${event.error}` : ''}`), + ]), + ], + ), + ); + + addWorkspaceOption( + cursorCmd + .command('dispatch ') + .description('Dispatch one Cursor automation event through the bridge') + .option('--event-type ', 'Cursor event type', 'cursor.automation.manual') + .option('--event-id ', 'Cursor event id') + .option('--actor ', 'Override dispatch actor') + .option('--adapter ', 'Override dispatch adapter') + .option('--execute ', 'Execute dispatch run immediately (true|false)') + .option('--context ', 'JSON object merged into dispatch context') + .option('--idempotency-key ', 'Override idempotency key') + .option('--agents ', 'Comma-separated agent identities') + .option('--max-steps ', 'Maximum scheduler steps') + .option('--step-delay-ms ', 'Delay between scheduler steps') + .option('--space ', 'Restrict dispatch to one space') + .option('--checkpoint ', 'Create dispatch checkpoint (true|false)') + .option('--timeout-ms ', 'Execution timeout in milliseconds') + .option('--dispatch-mode ', 'direct|self-assembly') + .option('--json', 'Emit structured JSON output'), + ).action((objective, opts) => + runCommand( + opts, + async () => { + const workspacePath = resolveWorkspacePath(opts); + const result = await workgraph.cursorBridge.dispatchCursorAutomationEvent(workspacePath, { + source: 'cli-dispatch', + eventType: opts.eventType, + eventId: opts.eventId, + objective, + actor: opts.actor, + adapter: opts.adapter, + execute: parseOptionalBoolean(opts.execute, 'execute'), + context: parseOptionalJsonObject(opts.context, 'context'), + idempotencyKey: opts.idempotencyKey, + agents: csv(opts.agents), + maxSteps: parseOptionalInt(opts.maxSteps, 'max-steps'), + stepDelayMs: parseOptionalInt(opts.stepDelayMs, 'step-delay-ms'), + space: opts.space, + createCheckpoint: parseOptionalBoolean(opts.checkpoint, 'checkpoint'), + timeoutMs: parseOptionalInt(opts.timeoutMs, 'timeout-ms'), + dispatchMode: parseDispatchMode(opts.dispatchMode), + }); + return result; + }, + (result) => [ + `Dispatched Cursor event: ${result.event.eventType}`, + `Run: ${result.run.id} [${result.run.status}]`, + `Adapter: ${result.run.adapter}`, + ...(result.run.output ? [`Output: ${result.run.output}`] : []), + ...(result.run.error ? [`Error: ${result.run.error}`] : []), + ], + ), + ); +} + +function parseOptionalBoolean(value: unknown, optionName: string): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + throw new Error(`Invalid --${optionName}. Expected true|false.`); +} + +function parseOptionalInt(value: unknown, optionName: string): number | undefined { + if (value === undefined) return undefined; + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid --${optionName}. Expected an integer.`); + } + return parsed; +} + +function parseDispatchMode(value: unknown): 'direct' | 'self-assembly' | undefined { + if (value === undefined) return undefined; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized === 'direct' || normalized === 'self-assembly') { + return normalized; + } + throw new Error(`Invalid --dispatch-mode "${String(value)}". Expected direct|self-assembly.`); +} + +function parseOptionalJsonObject(value: unknown, optionName: string): Record | undefined { + if (value === undefined) return undefined; + const text = String(value).trim(); + if (!text) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Invalid --${optionName}. Expected valid JSON.`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid --${optionName}. Expected a JSON object.`); + } + return parsed as Record; +} diff --git a/packages/kernel/src/cursor-bridge.test.ts b/packages/kernel/src/cursor-bridge.test.ts new file mode 100644 index 0000000..b720428 --- /dev/null +++ b/packages/kernel/src/cursor-bridge.test.ts @@ -0,0 +1,186 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + createCursorBridgeWebhookSignature, + dispatchCursorAutomationEvent, + getCursorBridgeStatus, + listCursorBridgeEvents, + receiveCursorAutomationWebhook, + setupCursorBridge, +} from './cursor-bridge.js'; +import { loadRegistry, saveRegistry } from './registry.js'; + +let workspacePath: string; + +beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-cursor-bridge-')); + const registry = loadRegistry(workspacePath); + saveRegistry(workspacePath, registry); +}); + +afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); +}); + +describe('cursor bridge', () => { + it('persists setup and reports status with webhook/dispatch defaults', () => { + setupCursorBridge(workspacePath, { + actor: 'cursor-ops', + enabled: true, + secret: 'bridge-secret', + allowedEventTypes: ['cursor.automation.*'], + dispatch: { + adapter: 'shell-worker', + execute: true, + maxSteps: 42, + }, + }); + + const status = getCursorBridgeStatus(workspacePath, { recentEventsLimit: 3 }); + expect(status.configured).toBe(true); + expect(status.enabled).toBe(true); + expect(status.webhook.hasSecret).toBe(true); + expect(status.webhook.allowedEventTypes).toEqual(['cursor.automation.*']); + expect(status.dispatch.actor).toBe('cursor-ops'); + expect(status.dispatch.adapter).toBe('shell-worker'); + expect(status.dispatch.execute).toBe(true); + expect(status.dispatch.maxSteps).toBe(42); + expect(status.recentEvents).toEqual([]); + }); + + it('creates queued dispatch runs and logs event bridge metadata', async () => { + setupCursorBridge(workspacePath, { + actor: 'cursor-ops', + enabled: true, + allowedEventTypes: ['cursor.automation.*'], + dispatch: { + adapter: 'cursor-cloud', + execute: false, + }, + }); + + const result = await dispatchCursorAutomationEvent(workspacePath, { + source: 'cli-dispatch', + eventType: 'cursor.automation.run.completed', + eventId: 'evt_queued_1', + objective: 'Sync Cursor completion into queued dispatch run', + context: { + cursor_job_id: 'job_123', + }, + }); + + expect(result.run.status).toBe('queued'); + expect(result.run.context?.cursor_bridge).toMatchObject({ + event_type: 'cursor.automation.run.completed', + event_id: 'evt_queued_1', + source: 'cli-dispatch', + }); + const events = listCursorBridgeEvents(workspacePath, { limit: 5 }); + expect(events).toHaveLength(1); + expect(events[0].runId).toBe(result.run.id); + expect(events[0].runStatus).toBe('queued'); + expect(events[0].error).toBeUndefined(); + }); + + it('can execute bridged runs via dispatch integration defaults', async () => { + setupCursorBridge(workspacePath, { + actor: 'cursor-ops', + enabled: true, + allowedEventTypes: ['*'], + dispatch: { + adapter: 'shell-worker', + execute: true, + }, + }); + const shellCommand = `"${process.execPath}" -e "process.stdout.write('cursor_bridge_ok')"`; + + const result = await dispatchCursorAutomationEvent(workspacePath, { + eventType: 'cursor.automation.run.completed', + eventId: 'evt_exec_1', + objective: 'Execute bridged run', + context: { + shell_command: shellCommand, + }, + }); + + expect(result.run.status).toBe('succeeded'); + expect(result.run.output).toContain('cursor_bridge_ok'); + const latest = listCursorBridgeEvents(workspacePath, { limit: 1 })[0]; + expect(latest.runId).toBe(result.run.id); + expect(latest.runStatus).toBe('succeeded'); + }); + + it('rejects signed webhooks when signature verification fails', async () => { + setupCursorBridge(workspacePath, { + actor: 'cursor-ops', + enabled: true, + secret: 'bridge-secret', + allowedEventTypes: ['cursor.automation.*'], + dispatch: { + adapter: 'cursor-cloud', + execute: false, + }, + }); + const body = JSON.stringify({ + id: 'evt_bad_sig', + type: 'cursor.automation.run.completed', + objective: 'Dispatch should not happen', + }); + + await expect(receiveCursorAutomationWebhook(workspacePath, { + body, + headers: { + 'x-cursor-signature': 'sha256=deadbeef', + }, + })).rejects.toThrow('Invalid Cursor webhook signature.'); + }); + + it('accepts valid signed webhooks and enforces allowed event patterns', async () => { + setupCursorBridge(workspacePath, { + actor: 'cursor-ops', + enabled: true, + secret: 'bridge-secret', + allowedEventTypes: ['cursor.automation.run.*'], + dispatch: { + adapter: 'cursor-cloud', + execute: false, + }, + }); + const body = JSON.stringify({ + id: 'evt_webhook_ok', + type: 'cursor.automation.run.completed', + objective: 'Webhook to dispatch', + }); + const signature = createCursorBridgeWebhookSignature({ + secret: 'bridge-secret', + body, + }); + const accepted = await receiveCursorAutomationWebhook(workspacePath, { + body, + headers: { + 'x-cursor-signature': signature, + }, + }); + expect(accepted.run.status).toBe('queued'); + expect(accepted.event.source).toBe('webhook'); + expect(accepted.event.eventType).toBe('cursor.automation.run.completed'); + + const disallowedBody = JSON.stringify({ + id: 'evt_webhook_denied', + type: 'cursor.automation.workflow.started', + objective: 'Should be rejected', + }); + const disallowedSignature = createCursorBridgeWebhookSignature({ + secret: 'bridge-secret', + body: disallowedBody, + }); + await expect(receiveCursorAutomationWebhook(workspacePath, { + body: disallowedBody, + headers: { + 'x-cursor-signature': disallowedSignature, + }, + })).rejects.toThrow('is not allowed by bridge configuration'); + }); +}); diff --git a/packages/kernel/src/cursor-bridge.ts b/packages/kernel/src/cursor-bridge.ts new file mode 100644 index 0000000..5e559d1 --- /dev/null +++ b/packages/kernel/src/cursor-bridge.ts @@ -0,0 +1,720 @@ +import crypto, { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as dispatch from './dispatch.js'; +import type { DispatchRun, RunStatus } from './types.js'; + +const CURSOR_BRIDGE_CONFIG_FILE = '.workgraph/cursor-bridge.json'; +const CURSOR_BRIDGE_EVENTS_FILE = '.workgraph/cursor-bridge-events.jsonl'; +const CURSOR_BRIDGE_VERSION = 1; +const DEFAULT_ALLOWED_EVENT_TYPES = ['*']; +const DEFAULT_DISPATCH_ADAPTER = 'cursor-cloud'; +const DEFAULT_DISPATCH_ACTOR = 'cursor-bridge'; + +export interface CursorBridgeConfig { + version: number; + enabled: boolean; + provider: 'cursor-automations'; + createdAt: string; + updatedAt: string; + webhook: { + secret?: string; + allowedEventTypes: string[]; + }; + dispatch: { + actor: string; + adapter: string; + execute: boolean; + agents?: string[]; + maxSteps?: number; + stepDelayMs?: number; + space?: string; + createCheckpoint: boolean; + timeoutMs?: number; + dispatchMode?: 'direct' | 'self-assembly'; + }; +} + +export interface CursorBridgeStatus { + configured: boolean; + enabled: boolean; + provider: CursorBridgeConfig['provider']; + configPath: string; + eventsPath: string; + webhook: { + hasSecret: boolean; + allowedEventTypes: string[]; + }; + dispatch: CursorBridgeConfig['dispatch']; + recentEvents: CursorBridgeEventRecord[]; +} + +export interface CursorBridgeSetupInput { + actor?: string; + enabled?: boolean; + secret?: string; + allowedEventTypes?: string[]; + dispatch?: Partial; +} + +export interface CursorBridgeDispatchInput { + source?: CursorBridgeEventSource; + eventId?: string; + eventType?: string; + objective?: string; + actor?: string; + adapter?: string; + execute?: boolean; + context?: Record; + idempotencyKey?: string; + agents?: string[]; + maxSteps?: number; + stepDelayMs?: number; + space?: string; + createCheckpoint?: boolean; + timeoutMs?: number; + dispatchMode?: 'direct' | 'self-assembly'; +} + +export interface CursorAutomationWebhookInput { + body: string; + headers?: Record; + signature?: string; + timestamp?: string; +} + +export interface CursorBridgeDispatchResult { + run: DispatchRun; + event: CursorBridgeEventRecord; +} + +export type CursorBridgeEventSource = 'webhook' | 'cli-dispatch'; + +export interface CursorBridgeEventRecord { + id: string; + ts: string; + source: CursorBridgeEventSource; + eventId?: string; + eventType: string; + objective: string; + runId?: string; + runStatus?: RunStatus; + adapter?: string; + actor?: string; + error?: string; +} + +interface CursorAutomationEventPayload { + id?: unknown; + type?: unknown; + event_type?: unknown; + objective?: unknown; + actor?: unknown; + adapter?: unknown; + execute?: unknown; + context?: unknown; + metadata?: unknown; +} + +interface CursorBridgeEventRecordFile extends CursorBridgeEventRecord { + runStatus?: RunStatus; +} + +export function cursorBridgeConfigPath(workspacePath: string): string { + return path.join(workspacePath, CURSOR_BRIDGE_CONFIG_FILE); +} + +export function cursorBridgeEventsPath(workspacePath: string): string { + return path.join(workspacePath, CURSOR_BRIDGE_EVENTS_FILE); +} + +export function setupCursorBridge(workspacePath: string, input: CursorBridgeSetupInput = {}): CursorBridgeConfig { + const now = new Date().toISOString(); + const existing = loadCursorBridgeConfig(workspacePath); + const actor = readNonEmptyString(input.actor) ?? existing.dispatch.actor ?? DEFAULT_DISPATCH_ACTOR; + const dispatchDefaults = { + ...(existing.dispatch ?? defaultCursorBridgeConfig().dispatch), + ...(normalizeDispatchDefaults(input.dispatch) ?? {}), + }; + const allowedEventTypes = input.allowedEventTypes + ? normalizeAllowedEventTypes(input.allowedEventTypes) + : existing.webhook.allowedEventTypes; + const secret = input.secret !== undefined + ? readNonEmptyString(input.secret) + : existing.webhook.secret; + + const next: CursorBridgeConfig = { + ...existing, + enabled: input.enabled ?? existing.enabled, + updatedAt: now, + webhook: { + secret, + allowedEventTypes, + }, + dispatch: { + ...dispatchDefaults, + actor, + }, + }; + writeCursorBridgeConfig(workspacePath, next); + return next; +} + +export function loadCursorBridgeConfig(workspacePath: string): CursorBridgeConfig { + const cfgPath = cursorBridgeConfigPath(workspacePath); + if (!fs.existsSync(cfgPath)) { + return defaultCursorBridgeConfig(); + } + try { + const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) as unknown; + return normalizeCursorBridgeConfig(raw); + } catch { + return defaultCursorBridgeConfig(); + } +} + +export function getCursorBridgeStatus( + workspacePath: string, + options: { recentEventsLimit?: number } = {}, +): CursorBridgeStatus { + const configPath = cursorBridgeConfigPath(workspacePath); + const configured = fs.existsSync(configPath); + const config = loadCursorBridgeConfig(workspacePath); + return { + configured, + enabled: config.enabled, + provider: config.provider, + configPath, + eventsPath: cursorBridgeEventsPath(workspacePath), + webhook: { + hasSecret: typeof config.webhook.secret === 'string' && config.webhook.secret.length > 0, + allowedEventTypes: [...config.webhook.allowedEventTypes], + }, + dispatch: { + ...config.dispatch, + ...(config.dispatch.agents ? { agents: [...config.dispatch.agents] } : {}), + }, + recentEvents: listCursorBridgeEvents(workspacePath, { + limit: options.recentEventsLimit ?? 5, + }), + }; +} + +export async function receiveCursorAutomationWebhook( + workspacePath: string, + input: CursorAutomationWebhookInput, +): Promise { + const config = loadCursorBridgeConfig(workspacePath); + if (!config.enabled) { + throw new Error('Cursor bridge is disabled. Run `workgraph cursor setup --enabled true` to enable it.'); + } + const payload = parseCursorAutomationWebhookBody(input.body); + const eventType = readNonEmptyString(payload.type) ?? readNonEmptyString(payload.event_type); + if (!eventType) { + throw new Error('Cursor webhook payload is missing required "type".'); + } + if (!eventTypeMatches(config.webhook.allowedEventTypes, eventType)) { + throw new Error(`Cursor webhook event type "${eventType}" is not allowed by bridge configuration.`); + } + const webhookSecret = readNonEmptyString(config.webhook.secret); + if (webhookSecret) { + const headers = normalizeHeaderMap(input.headers); + const signature = readNonEmptyString(input.signature) + ?? readHeader(headers, 'x-cursor-signature') + ?? readHeader(headers, 'x-workgraph-signature'); + if (!signature) { + throw new Error('Cursor webhook is missing required signature header.'); + } + const timestamp = readNonEmptyString(input.timestamp) + ?? readHeader(headers, 'x-cursor-timestamp') + ?? readHeader(headers, 'x-workgraph-timestamp'); + const verified = verifyCursorBridgeWebhookSignature({ + secret: webhookSecret, + body: input.body, + signature, + timestamp, + }); + if (!verified) { + throw new Error('Invalid Cursor webhook signature.'); + } + } + const context = asRecord(payload.context); + const metadata = asRecord(payload.metadata); + return dispatchCursorAutomationEvent(workspacePath, { + source: 'webhook', + eventId: readNonEmptyString(payload.id), + eventType, + objective: readNonEmptyString(payload.objective), + actor: readNonEmptyString(payload.actor), + adapter: readNonEmptyString(payload.adapter), + execute: normalizeOptionalBoolean(payload.execute), + context: { + ...context, + ...(Object.keys(metadata).length > 0 ? { cursor_metadata: metadata } : {}), + }, + }); +} + +export async function dispatchCursorAutomationEvent( + workspacePath: string, + input: CursorBridgeDispatchInput, +): Promise { + const config = loadCursorBridgeConfig(workspacePath); + if (!config.enabled) { + throw new Error('Cursor bridge is disabled. Run `workgraph cursor setup --enabled true` to enable it.'); + } + + const source = input.source ?? 'cli-dispatch'; + const eventType = readNonEmptyString(input.eventType) ?? 'cursor.automation.manual'; + if (!eventTypeMatches(config.webhook.allowedEventTypes, eventType)) { + throw new Error(`Cursor event type "${eventType}" is not allowed by bridge configuration.`); + } + const eventId = readNonEmptyString(input.eventId); + const objective = readNonEmptyString(input.objective) ?? defaultObjectiveForEvent(eventType, eventId); + const actor = readNonEmptyString(input.actor) ?? config.dispatch.actor; + const adapter = readNonEmptyString(input.adapter) ?? config.dispatch.adapter; + const execute = input.execute ?? config.dispatch.execute; + const bridgeContext = buildBridgeDispatchContext({ + eventType, + eventId, + source, + objective, + context: input.context, + }); + const idempotencyKey = readNonEmptyString(input.idempotencyKey) + ?? (eventId ? `cursor-bridge:${eventType}:${eventId}` : undefined); + + let run: DispatchRun | undefined; + try { + run = dispatch.createRun(workspacePath, { + actor, + adapter, + objective, + idempotencyKey, + context: bridgeContext, + }); + if (execute) { + run = await dispatch.executeRun(workspacePath, run.id, { + actor, + agents: input.agents ?? config.dispatch.agents, + maxSteps: input.maxSteps ?? config.dispatch.maxSteps, + stepDelayMs: input.stepDelayMs ?? config.dispatch.stepDelayMs, + space: input.space ?? config.dispatch.space, + createCheckpoint: input.createCheckpoint ?? config.dispatch.createCheckpoint, + timeoutMs: input.timeoutMs ?? config.dispatch.timeoutMs, + dispatchMode: input.dispatchMode ?? config.dispatch.dispatchMode, + }); + } + const record: CursorBridgeEventRecord = { + id: `cbe_${randomUUID()}`, + ts: new Date().toISOString(), + source, + eventId, + eventType, + objective, + runId: run.id, + runStatus: run.status, + adapter, + actor, + }; + appendCursorBridgeEvent(workspacePath, record); + return { run, event: record }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appendCursorBridgeEvent(workspacePath, { + id: `cbe_${randomUUID()}`, + ts: new Date().toISOString(), + source, + eventId, + eventType, + objective, + runId: run?.id, + runStatus: run?.status, + adapter, + actor, + error: message, + }); + throw error; + } +} + +export function listCursorBridgeEvents( + workspacePath: string, + options: { limit?: number } = {}, +): CursorBridgeEventRecord[] { + const eventsPath = cursorBridgeEventsPath(workspacePath); + if (!fs.existsSync(eventsPath)) return []; + const lines = fs.readFileSync(eventsPath, 'utf-8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const parsed = lines + .map((line) => { + try { + return normalizeCursorBridgeEventRecord(JSON.parse(line) as unknown); + } catch { + return null; + } + }) + .filter((entry): entry is CursorBridgeEventRecord => entry !== null); + parsed.sort((a, b) => b.ts.localeCompare(a.ts)); + const limit = clampPositiveInt(options.limit, parsed.length); + return parsed.slice(0, limit); +} + +export function createCursorBridgeWebhookSignature(input: { + secret: string; + body: string; + timestamp?: string; +}): string { + const payload = signaturePayload(input.body, input.timestamp); + const digest = crypto.createHmac('sha256', input.secret).update(payload).digest('hex'); + return `sha256=${digest}`; +} + +export function verifyCursorBridgeWebhookSignature(input: { + secret: string; + body: string; + signature: string; + timestamp?: string; +}): boolean { + const provided = normalizeSignature(input.signature); + if (!provided) return false; + const expected = createCursorBridgeWebhookSignature({ + secret: input.secret, + body: input.body, + timestamp: input.timestamp, + }); + return timingSafeEqual(provided, expected); +} + +function defaultCursorBridgeConfig(now: string = new Date().toISOString()): CursorBridgeConfig { + return { + version: CURSOR_BRIDGE_VERSION, + enabled: false, + provider: 'cursor-automations', + createdAt: now, + updatedAt: now, + webhook: { + allowedEventTypes: [...DEFAULT_ALLOWED_EVENT_TYPES], + }, + dispatch: { + actor: DEFAULT_DISPATCH_ACTOR, + adapter: DEFAULT_DISPATCH_ADAPTER, + execute: false, + createCheckpoint: true, + }, + }; +} + +function writeCursorBridgeConfig(workspacePath: string, config: CursorBridgeConfig): void { + const normalized = normalizeCursorBridgeConfig(config); + const cfgPath = cursorBridgeConfigPath(workspacePath); + const dir = path.dirname(cfgPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cfgPath, JSON.stringify(normalized, null, 2) + '\n', 'utf-8'); +} + +function appendCursorBridgeEvent(workspacePath: string, event: CursorBridgeEventRecord): void { + const normalized = normalizeCursorBridgeEventRecord(event); + if (!normalized) return; + const filePath = cursorBridgeEventsPath(workspacePath); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const payload = JSON.stringify(normalized) + '\n'; + fs.appendFileSync(filePath, payload, 'utf-8'); +} + +function normalizeCursorBridgeConfig(raw: unknown): CursorBridgeConfig { + const defaults = defaultCursorBridgeConfig(); + const root = asRecord(raw); + const createdAt = readNonEmptyString(root.createdAt) ?? defaults.createdAt; + const updatedAt = readNonEmptyString(root.updatedAt) ?? createdAt; + const webhookRoot = asRecord(root.webhook); + const dispatchRoot = asRecord(root.dispatch); + const dispatchDefaults = normalizeDispatchDefaults(dispatchRoot) ?? {}; + + return { + version: CURSOR_BRIDGE_VERSION, + enabled: asBoolean(root.enabled, defaults.enabled), + provider: 'cursor-automations', + createdAt, + updatedAt, + webhook: { + secret: readNonEmptyString(webhookRoot.secret), + allowedEventTypes: normalizeAllowedEventTypes( + asStringArray(webhookRoot.allowedEventTypes).length > 0 + ? asStringArray(webhookRoot.allowedEventTypes) + : defaults.webhook.allowedEventTypes, + ), + }, + dispatch: { + ...defaults.dispatch, + ...dispatchDefaults, + actor: readNonEmptyString(dispatchRoot.actor) ?? dispatchDefaults.actor ?? defaults.dispatch.actor, + adapter: readNonEmptyString(dispatchRoot.adapter) ?? dispatchDefaults.adapter ?? defaults.dispatch.adapter, + createCheckpoint: asBoolean( + dispatchRoot.createCheckpoint, + dispatchDefaults.createCheckpoint ?? defaults.dispatch.createCheckpoint, + ), + execute: asBoolean(dispatchRoot.execute, dispatchDefaults.execute ?? defaults.dispatch.execute), + }, + }; +} + +function normalizeDispatchDefaults( + value: unknown, +): Partial | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const root = value as Record; + const actor = readNonEmptyString(root.actor); + const adapter = readNonEmptyString(root.adapter); + const execute = normalizeOptionalBoolean(root.execute); + const agents = normalizeStringArray(root.agents); + const maxSteps = normalizePositiveInt(root.maxSteps); + const stepDelayMs = normalizeNonNegativeInt(root.stepDelayMs); + const space = readNonEmptyString(root.space); + const createCheckpoint = normalizeOptionalBoolean(root.createCheckpoint); + const timeoutMs = normalizePositiveInt(root.timeoutMs); + const dispatchMode = normalizeDispatchMode(root.dispatchMode); + + return { + ...(actor ? { actor } : {}), + ...(adapter ? { adapter } : {}), + ...(typeof execute === 'boolean' ? { execute } : {}), + ...(agents ? { agents } : {}), + ...(typeof maxSteps === 'number' ? { maxSteps } : {}), + ...(typeof stepDelayMs === 'number' ? { stepDelayMs } : {}), + ...(space ? { space } : {}), + ...(typeof createCheckpoint === 'boolean' ? { createCheckpoint } : {}), + ...(typeof timeoutMs === 'number' ? { timeoutMs } : {}), + ...(dispatchMode ? { dispatchMode } : {}), + }; +} + +function normalizeAllowedEventTypes(value: string[]): string[] { + const normalized = value + .map((item) => String(item).trim()) + .filter(Boolean); + if (normalized.length === 0) return [...DEFAULT_ALLOWED_EVENT_TYPES]; + return [...new Set(normalized)]; +} + +function normalizeStringArray(value: unknown): string[] | undefined { + const values = asStringArray(value) + .map((entry) => entry.trim()) + .filter(Boolean); + if (values.length === 0) return undefined; + return [...new Set(values)]; +} + +function normalizeCursorBridgeEventRecord(raw: unknown): CursorBridgeEventRecord | null { + const root = asRecord(raw); + const id = readNonEmptyString(root.id); + const ts = readNonEmptyString(root.ts); + const source = normalizeSource(root.source); + const eventType = readNonEmptyString(root.eventType); + const objective = readNonEmptyString(root.objective); + if (!id || !ts || !source || !eventType || !objective) { + return null; + } + const runStatus = normalizeRunStatus(root.runStatus); + return { + id, + ts, + source, + eventType, + objective, + ...(readNonEmptyString(root.eventId) ? { eventId: readNonEmptyString(root.eventId) } : {}), + ...(readNonEmptyString(root.runId) ? { runId: readNonEmptyString(root.runId) } : {}), + ...(runStatus ? { runStatus } : {}), + ...(readNonEmptyString(root.adapter) ? { adapter: readNonEmptyString(root.adapter) } : {}), + ...(readNonEmptyString(root.actor) ? { actor: readNonEmptyString(root.actor) } : {}), + ...(readNonEmptyString(root.error) ? { error: readNonEmptyString(root.error) } : {}), + }; +} + +function parseCursorAutomationWebhookBody(body: string): CursorAutomationEventPayload { + const raw = String(body ?? '').trim(); + if (!raw) throw new Error('Cursor webhook body is empty.'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('Cursor webhook body must be valid JSON.'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Cursor webhook payload must be a JSON object.'); + } + return parsed as CursorAutomationEventPayload; +} + +function normalizeHeaderMap( + value: Record | undefined, +): Record { + const normalized: Record = {}; + if (!value) return normalized; + for (const [key, raw] of Object.entries(value)) { + const normalizedKey = key.trim().toLowerCase(); + if (!normalizedKey) continue; + if (Array.isArray(raw)) { + const joined = raw.map((item) => String(item).trim()).filter(Boolean).join(','); + if (joined) normalized[normalizedKey] = joined; + continue; + } + const text = String(raw ?? '').trim(); + if (text) normalized[normalizedKey] = text; + } + return normalized; +} + +function readHeader(headers: Record, name: string): string | undefined { + const value = headers[name.trim().toLowerCase()]; + return readNonEmptyString(value); +} + +function buildBridgeDispatchContext(input: { + eventType: string; + eventId?: string; + source: CursorBridgeEventSource; + objective: string; + context?: Record; +}): Record { + return { + ...(input.context ?? {}), + cursor_bridge: { + event_type: input.eventType, + event_id: input.eventId, + source: input.source, + objective: input.objective, + received_at: new Date().toISOString(), + }, + }; +} + +function defaultObjectiveForEvent(eventType: string, eventId?: string): string { + return eventId + ? `Cursor automation event ${eventType} (${eventId})` + : `Cursor automation event ${eventType}`; +} + +function signaturePayload(body: string, timestamp?: string): string { + const normalizedBody = String(body ?? ''); + const normalizedTimestamp = readNonEmptyString(timestamp); + return normalizedTimestamp ? `${normalizedTimestamp}.${normalizedBody}` : normalizedBody; +} + +function normalizeSignature(value: string): string | null { + const raw = String(value ?? '').trim(); + if (!raw) return null; + const normalized = raw.toLowerCase().startsWith('sha256=') ? raw : `sha256=${raw}`; + return normalized; +} + +function timingSafeEqual(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + if (left.length !== right.length) return false; + return crypto.timingSafeEqual(left, right); +} + +function eventTypeMatches(allowedEventTypes: string[], eventType: string): boolean { + if (allowedEventTypes.length === 0) return true; + return allowedEventTypes.some((pattern) => { + if (pattern === '*') return true; + if (pattern.endsWith('*')) { + return eventType.startsWith(pattern.slice(0, -1)); + } + return pattern === eventType; + }); +} + +function normalizeRunStatus(value: unknown): RunStatus | undefined { + const normalized = String(value ?? '').trim().toLowerCase(); + if ( + normalized === 'queued' + || normalized === 'running' + || normalized === 'succeeded' + || normalized === 'failed' + || normalized === 'cancelled' + ) { + return normalized; + } + return undefined; +} + +function normalizeDispatchMode(value: unknown): 'direct' | 'self-assembly' | undefined { + const normalized = String(value ?? '').trim().toLowerCase(); + if (normalized === 'direct' || normalized === 'self-assembly') { + return normalized; + } + return undefined; +} + +function normalizeSource(value: unknown): CursorBridgeEventSource | undefined { + const normalized = String(value ?? '').trim().toLowerCase(); + if (normalized === 'webhook' || normalized === 'cli-dispatch') { + return normalized; + } + return undefined; +} + +function normalizePositiveInt(value: unknown): number | undefined { + const parsed = normalizeNumber(value); + if (typeof parsed !== 'number' || parsed <= 0) return undefined; + return Math.trunc(parsed); +} + +function normalizeNonNegativeInt(value: unknown): number | undefined { + const parsed = normalizeNumber(value); + if (typeof parsed !== 'number' || parsed < 0) return undefined; + return Math.trunc(parsed); +} + +function clampPositiveInt(value: number | undefined, fallback: number): number { + const normalized = typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.trunc(value)) + : fallback; + return normalized; +} + +function normalizeOptionalBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + } + return undefined; +} + +function normalizeNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + const parsed = normalizeOptionalBoolean(value); + if (typeof parsed === 'boolean') return parsed; + return fallback; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => String(item ?? '')); +} + +function readNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index a079d6c..98ff1e3 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -29,6 +29,7 @@ export * as adapterClaudeCode from './adapter-claude-code.js'; export * as adapterCursorCloud from './adapter-cursor-cloud.js'; export * as adapterHttpWebhook from './adapter-http-webhook.js'; export * as adapterShellWorker from './adapter-shell-worker.js'; +export * as cursorBridge from './cursor-bridge.js'; export * from './runtime-adapter-contracts.js'; export * from './adapter-shell-worker.js'; export * from './agent-self-assembly.js'; @@ -54,6 +55,7 @@ export * as searchQmdAdapter from './search-qmd-adapter.js'; export * as swarm from './swarm.js'; export * as clawdapus from './clawdapus.js'; export * as mcpEvents from './mcp-events.js'; +export * from './cursor-bridge.js'; export * as diagnostics from './diagnostics/index.js'; export * from './context-graph-contract.js'; export * as queryEngine from './query/engine.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2091b33..154c281 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@types/node': specifier: ^20.11.0 version: 20.19.35 + '@versatly/workgraph-mcp-server': + specifier: workspace:* + version: link:packages/mcp-server ajv: specifier: ^8.18.0 version: 8.18.0