Files
hapi/cli/src/cursor/cursorAcpRemoteLauncher.ts
T
af962fc61f fix(cli): stop prepending skill_lookup $name instruction onto user turns (#1096)
Cursor ACP (and other remotes) flagged the glued-on SKILL_LOOKUP_INSTRUCTION
as prompt injection. Keep discovery on the skill_lookup MCP tool description
and on system prompts (OpenCode/Grok); do not taint user messages.

Fixes #1095

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 18:29:29 +01:00

600 lines
24 KiB
TypeScript

import React from 'react';
import { logger } from '@/ui/logger';
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
import { convertAgentMessage } from '@/agent/messageConverter';
import { PermissionAdapter } from '@/agent/permissionAdapter';
import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types';
import {
RemoteLauncherBase,
type RemoteLauncherDisplayContext,
type RemoteLauncherExitReason
} from '@/modules/common/remote/RemoteLauncherBase';
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
import type { CursorSession } from './session';
import type { PermissionMode } from './loop';
import {
createCursorAcpBackend,
CURSOR_ACP_REQUIRED_MESSAGE,
resolveCursorNativeWorktreePath
} from './utils/cursorAcpBackend';
import { setCursorAcpModelsSnapshot } from './utils/cursorAcpModelsBridge';
import { buildCursorModelsSnapshotFromAcp } from './utils/cursorAcpModelsSnapshot';
import { CursorExtensionAdapter } from './utils/cursorExtensionAdapter';
import {
applyCursorAcpMode,
applyCursorAcpModel,
isCursorAutoReviewMode,
wireIdForCursorSessionState
} from './utils/cursorModeConfig';
import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands';
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
import type { AcpSdkBackend } from '@/agent/backends/acp';
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
private readonly session: CursorSession;
private backend: ReturnType<typeof createCursorAcpBackend> | null = null;
private permissionAdapter: PermissionAdapter | null = null;
private extensionAdapter: CursorExtensionAdapter | null = null;
private happyServer: { stop: () => void } | null = null;
private abortController = new AbortController();
private displayPermissionMode: PermissionMode | null = null;
private currentBackendModel: string | null = null;
private defaultBackendModel: string | null = null;
private unregisterModelApplyHandler: (() => void) | null = null;
private modelApplySeq = 0;
/** True when ACP process was spawned with `--auto-review`. */
private spawnedWithAutoReview = false;
/** Avoid re-queueing `/auto-review` on every mid-session mode sync. */
private autoReviewSlashQueued = false;
constructor(session: CursorSession) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
}
public async launch(): Promise<RemoteLauncherExitReason> {
return this.start({
onExit: () => this.handleExitFromUi(),
onSwitchToLocal: () => this.handleSwitchFromUi()
});
}
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
return React.createElement(OpencodeDisplay, context);
}
protected async runMainLoop(): Promise<void> {
const session = this.session;
const messageBuffer = this.messageBuffer;
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, {
skillLookup: { workingDirectory: session.path, flavor: 'cursor' }
});
this.happyServer = happyServer;
const autoReview = isCursorAutoReviewMode(session.getPermissionMode() as PermissionMode);
this.spawnedWithAutoReview = autoReview;
const backend = createCursorAcpBackend({
cwd: session.path,
model: session.model,
autoReview,
worktree: session.cursorWorktree,
addDirs: session.cursorAddDirs
});
this.backend = backend;
this.recordCursorNativeWorktreeMetadata();
backend.setUsageUpdateListener((message) => this.handleAgentMessage(message));
backend.onStderrError((error) => {
logger.debug('[cursor-acp] stderr error', error);
const converted = convertAgentMessage({ type: 'error', message: error.message });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(error.message, 'status');
});
try {
await backend.initialize();
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
const fullMsg = `${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`;
const converted = convertAgentMessage({ type: 'error', message: fullMsg });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(fullMsg, 'status');
throw new Error(fullMsg);
}
await backend.authenticateIfAvailable('cursor_login');
const extensionAdapter = new CursorExtensionAdapter(
session.client,
backend,
(message) => this.handleAgentMessage(message)
);
this.extensionAdapter = extensionAdapter;
this.permissionAdapter = new PermissionAdapter(
session.client,
backend,
() => session.getPermissionMode(),
(response) => extensionAdapter.handlePermissionResponse(response)
);
const resumeSessionId = session.sessionId;
const mcpServerList = toAcpMcpServers(mcpServers);
let acpSessionId: string;
if (resumeSessionId && backend.supportsLoadSession()) {
// Register pending cursorSessionId before awaiting session/load (Zed PR #54431).
session.onSessionFoundWithProtocol(resumeSessionId, 'acp');
try {
acpSessionId = await backend.loadSession({
sessionId: resumeSessionId,
cwd: session.path,
mcpServers: mcpServerList
});
} catch (error) {
logger.warn('[cursor-acp] session/load failed', formatAcpLoadError(error));
throw new Error(
'Failed to resume Cursor ACP session. Legacy stream-json sessions cannot be loaded via ACP.'
);
}
} else if (resumeSessionId) {
throw new Error(
'Cursor ACP session/load is not supported by this agent build. Start a new Cursor session.'
);
} else {
acpSessionId = await backend.newSession({
cwd: session.path,
mcpServers: mcpServerList
});
}
if (acpSessionId !== resumeSessionId) {
session.onSessionFoundWithProtocol(acpSessionId, 'acp');
// tiann/hapi#913: block until the metadata write that pins
// `cursorSessionId` reaches the hub DB before we drop into
// `runMainLoop`. If SIGTERM (hub-restart cascade) lands during
// the first turn without this gate, the only durable handle
// linking the session to its on-disk ACP store is lost and the
// session strands. The resume path at lines 98-100 already
// relies on the latency of `backend.loadSession()` to flush the
// same write; the fresh-session path has no such cover.
const flushed = await session.client.flushMetadata();
if (!flushed) {
logger.warn(`[cursor-acp] cursorSessionId metadata write did not ACK within 5s; session may be unrecoverable if killed before the lock drains (acpSessionId=${acpSessionId})`);
}
}
session.client.emitSessionReady();
syncCursorModelsFromAcp(backend, acpSessionId);
const initialMetadata = backend.getSessionModelsMetadata(acpSessionId);
this.currentBackendModel = initialMetadata?.currentModelId ?? session.model ?? null;
this.defaultBackendModel = this.currentBackendModel;
const previousSetModel = session.setModel.bind(session);
await applyCursorAcpMode(backend, acpSessionId, session.getPermissionMode() as PermissionMode);
if (session.model) {
await this.applyLiveModel(backend, acpSessionId, session.model, previousSetModel, {
optimistic: false,
throwOnFailure: false
});
} else if (this.currentBackendModel && !isSpawnDefaultModel(this.currentBackendModel)) {
this.pushModelStatusLine(this.currentBackendModel);
}
this.installLiveSessionConfigSync(backend, acpSessionId, previousSetModel);
this.applyDisplayMode(session.getPermissionMode() as PermissionMode);
this.setupAbortHandlers(session.client.rpcHandlerManager, {
onAbort: () => this.handleAbort(),
onSwitch: () => this.handleSwitchRequest()
});
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
};
while (!this.shouldExit) {
const waitSignal = this.abortController.signal;
const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal);
if (!batch) {
if (waitSignal.aborted && !this.shouldExit) {
continue;
}
break;
}
const requestedModel = batch.mode.model === null
? this.defaultBackendModel
: batch.mode.model;
const modelChanged = Boolean(
requestedModel && requestedModel !== this.currentBackendModel
);
if (modelChanged) {
const appliedModel = await this.applyLiveModel(
backend,
acpSessionId,
requestedModel,
previousSetModel,
{ optimistic: false, throwOnFailure: false }
);
batch.mode.model = appliedModel ?? this.currentBackendModel ?? undefined;
}
await applyCursorAcpMode(backend, acpSessionId, batch.mode.permissionMode as PermissionMode);
this.applyDisplayMode(batch.mode.permissionMode as PermissionMode);
const specialCommand = parseCursorSpecialCommand(batch.message);
if (specialCommand.type === 'pass-through') {
messageBuffer.addMessage(cursorPassThroughStatusMessage(specialCommand.command), 'status');
}
messageBuffer.addMessage(batch.message, 'user');
// skill_lookup discovery lives on the MCP tool description — do not
// prepend instructions onto user turns (prompt-injection false positive).
const promptContent: PromptContent[] = [{
type: 'text',
text: batch.message
}];
session.onThinkingChange(true);
try {
await backend.prompt(acpSessionId, promptContent, (message) => {
this.handleAgentMessage(message);
});
} catch (error) {
logger.warn('[cursor-acp] prompt failed', error);
const errMsg = error instanceof Error ? error.message : String(error);
const message = `Cursor Agent failed: ${errMsg}`;
const converted = convertAgentMessage({ type: 'error', message });
if (converted) {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(message, 'status');
} finally {
session.onThinkingChange(false);
await this.permissionAdapter?.cancelAll('Prompt finished');
await this.extensionAdapter?.cancelAll('Prompt finished');
if (session.queue.size() === 0 && !this.shouldExit) {
sendReady();
}
}
}
}
protected async cleanup(): Promise<void> {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
this.unregisterModelApplyHandler?.();
this.unregisterModelApplyHandler = null;
if (this.permissionAdapter) {
await this.permissionAdapter.cancelAll('Session ended');
this.permissionAdapter = null;
}
if (this.extensionAdapter) {
await this.extensionAdapter.cancelAll('Session ended');
this.extensionAdapter = null;
}
if (this.backend) {
await this.backend.disconnect();
this.backend = null;
}
if (this.happyServer) {
this.happyServer.stop();
this.happyServer = null;
}
setCursorAcpModelsSnapshot(null);
}
private handleAgentMessage(message: AgentMessage): void {
const converted = convertAgentMessage(message);
if (converted) {
this.session.sendAgentMessage(converted);
}
switch (message.type) {
case 'text':
this.messageBuffer.addMessage(message.text, 'assistant');
break;
case 'reasoning':
break;
case 'usage':
break;
case 'tool_call':
this.messageBuffer.addMessage(`Tool: ${message.name}`, 'tool');
break;
case 'tool_result':
this.messageBuffer.addMessage('Tool result', 'result');
break;
case 'plan':
this.messageBuffer.addMessage('Plan updated', 'status');
break;
case 'error':
this.messageBuffer.addMessage(message.message, 'status');
break;
case 'turn_complete':
break;
default:
break;
}
}
private installLiveSessionConfigSync(
backend: AcpSdkBackend,
acpSessionId: string,
previousSetModel: CursorSession['setModel']
): void {
const session = this.session;
const previousSetPermissionMode = session.setPermissionMode.bind(session);
session.setPermissionMode = (mode: PermissionMode) => {
previousSetPermissionMode(mode);
void applyCursorAcpMode(backend, acpSessionId, mode).then(() => {
this.applyDisplayMode(mode);
});
this.maybeQueueAutoReviewSlash(mode);
};
this.unregisterModelApplyHandler = session.registerModelApplyHandler(async (model) => (
await this.applyLiveModel(backend, acpSessionId, model, previousSetModel, {
optimistic: false,
throwOnFailure: true
})
));
session.setModel = (model: string | null | undefined) => {
void this.applyLiveModel(backend, acpSessionId, model, previousSetModel, {
optimistic: true,
throwOnFailure: false
}).catch((error) => {
logger.warn('[cursor-acp] Failed to apply model from session sync', error);
});
};
}
private async applyLiveModel(
backend: AcpSdkBackend,
acpSessionId: string,
model: string | null | undefined,
previousSetModel: CursorSession['setModel'],
options: { optimistic: boolean; throwOnFailure: boolean }
): Promise<string | null> {
const requested = model?.trim();
const previousModel = this.currentBackendModel ?? this.session.model ?? null;
const applySeq = ++this.modelApplySeq;
if (!requested || isSpawnDefaultModel(requested)) {
const modelOption = backend.getConfigOptionByCategory?.(acpSessionId, 'model');
const defaultWire = modelOption?.options?.find(
(option) => isSpawnDefaultModel(option.value)
)?.value;
if (modelOption && defaultWire && backend.setConfigOption) {
try {
await backend.setConfigOption(acpSessionId, modelOption.id, defaultWire);
backend.pinSessionModelWireId(acpSessionId, defaultWire);
} catch (error) {
logger.debug('[cursor-acp] Failed to set default model via ACP', error);
if (options.throwOnFailure) {
throw new Error('Cursor default model is not available via ACP');
}
}
} else if (options.throwOnFailure) {
throw new Error('Cursor default model is not available via ACP');
}
this.currentBackendModel = null;
previousSetModel(undefined);
this.session.pushKeepAlive();
syncCursorModelsFromAcp(backend, acpSessionId);
return null;
}
if (options.optimistic) {
const optimisticWire = wireIdForCursorSessionState(requested, requested);
this.currentBackendModel = optimisticWire;
previousSetModel(optimisticWire);
this.session.pushKeepAlive();
}
const result = await applyCursorAcpModel(backend, acpSessionId, requested);
if (!result.applied || !result.resolvedWireId) {
const message = `Cursor model is not available via ACP: ${requested}`;
logger.warn(`[cursor-acp] ${message}`);
if (options.optimistic && applySeq === this.modelApplySeq) {
this.currentBackendModel = previousModel;
previousSetModel(previousModel ?? undefined);
this.session.pushKeepAlive();
} else if (!options.throwOnFailure && previousModel && !isSpawnDefaultModel(previousModel)) {
this.currentBackendModel = previousModel;
previousSetModel(previousModel);
this.session.pushKeepAlive();
}
syncCursorModelsFromAcp(backend, acpSessionId);
if (options.throwOnFailure) {
throw new Error(message);
}
return previousModel;
}
const sessionWire = wireIdForCursorSessionState(
result.requestedWireId ?? requested,
result.resolvedWireId
);
if (applySeq !== this.modelApplySeq) {
return this.currentBackendModel;
}
const changed = sessionWire !== this.currentBackendModel || this.session.model !== sessionWire;
this.currentBackendModel = sessionWire;
previousSetModel(sessionWire);
if (changed) {
this.pushModelStatusLine(sessionWire);
}
this.session.pushKeepAlive();
syncCursorModelsFromAcp(backend, acpSessionId);
return sessionWire;
}
private pushModelStatusLine(model: string | null | undefined): void {
const trimmed = model?.trim();
if (!trimmed || isSpawnDefaultModel(trimmed)) {
this.messageBuffer.addMessage('[MODEL:auto]', 'system');
return;
}
this.messageBuffer.addMessage(`[MODEL:${trimmed}]`, 'system');
}
private applyDisplayMode(permissionMode: PermissionMode | undefined): void {
if (permissionMode && permissionMode !== this.displayPermissionMode) {
this.displayPermissionMode = permissionMode;
this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system');
}
}
/**
* Mid-session Auto-review: ACP has no config option, so when the process was
* not spawned with `--auto-review`, queue an isolated `/auto-review` slash once.
*/
private maybeQueueAutoReviewSlash(mode: PermissionMode): void {
if (!isCursorAutoReviewMode(mode)) {
return;
}
if (this.spawnedWithAutoReview || this.autoReviewSlashQueued) {
return;
}
this.autoReviewSlashQueued = true;
this.session.queue.pushIsolated(
'/auto-review',
{
permissionMode: mode,
model: this.session.model
}
);
this.messageBuffer.addMessage(cursorPassThroughStatusMessage('auto-review'), 'status');
}
private recordCursorNativeWorktreeMetadata(): void {
const worktree = this.session.cursorWorktree;
if (worktree === undefined || worktree === false) {
return;
}
const name = typeof worktree === 'string' ? worktree.trim() : '';
if (!name) {
this.messageBuffer.addMessage('Cursor native worktree enabled', 'status');
return;
}
const worktreePath = resolveCursorNativeWorktreePath(this.session.path, name);
this.session.client.updateMetadata((metadata) => ({
...metadata,
worktree: {
basePath: this.session.path,
branch: name,
name,
worktreePath,
createdAt: Date.now()
}
}));
this.messageBuffer.addMessage(`Cursor worktree: ${worktreePath}`, 'status');
}
private async handleAbort(): Promise<void> {
const backend = this.backend;
const sessionId = this.session.sessionId;
if (backend && sessionId) {
await backend.cancelPrompt(sessionId);
}
await this.permissionAdapter?.cancelAll('User aborted');
await this.extensionAdapter?.cancelAll('User aborted');
this.session.queue.reset();
this.session.onThinkingChange(false);
this.abortController.abort();
this.abortController = new AbortController();
this.messageBuffer.addMessage('Turn aborted', 'status');
}
private async handleExitFromUi(): Promise<void> {
await this.requestExit('exit', () => this.handleAbort());
}
private async handleSwitchFromUi(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
private async handleSwitchRequest(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
}
function formatAcpLoadError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
const record: Record<string, unknown> = {
name: error.name,
message: error.message
};
const code = (error as Error & { code?: unknown }).code;
if (code !== undefined) {
record.code = code;
}
const data = (error as Error & { data?: unknown }).data;
if (data !== undefined) {
record.data = data;
}
const cause = error.cause;
if (cause !== undefined) {
record.cause = cause instanceof Error
? { name: cause.name, message: cause.message }
: cause;
}
return record;
}
if (typeof error === 'object' && error !== null) {
return { ...(error as Record<string, unknown>) };
}
return { message: String(error) };
}
function isSpawnDefaultModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
return normalized === 'auto' || normalized === 'default' || normalized === 'default[]';
}
function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string): void {
const snapshot = buildCursorModelsSnapshotFromAcp(backend, acpSessionId);
if (!snapshot) {
return;
}
const payload = buildCursorModelsSeedPayload(snapshot, readSharedCursorModelsCache());
setCursorAcpModelsSnapshot(snapshot);
seedCursorModelsCache(payload);
}
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
return Object.entries(config).map(([name, entry]) => ({
name,
command: entry.command,
args: entry.args,
env: []
}));
}
export async function cursorAcpRemoteLauncher(session: CursorSession): Promise<'switch' | 'exit'> {
const launcher = new CursorAcpRemoteLauncher(session);
return launcher.launch();
}