From ac11a54e21c550e12654d4b3c2cd722880f13007 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 29 Jun 2026 21:59:55 +0100 Subject: [PATCH 1/4] Roadmap NOW batch: secret-egress fix, atomic audit log, agent todo memory Implements three verified items from the competitive roadmap. All changes are tsgo-clean, pass the node test suite, and are hygiene-safe (ASCII-only added lines). - Secret redaction at the single outbound dispatch boundary. New pure, unit-tested common/outboundRedaction.ts scans every text-bearing payload: chat string/array content, tool_result content (e.g. `cat .env` output routed back to the model), Gemini parts, AND FIM/autocomplete prefix/suffix. Previously only chatMessages text parts were scanned, so autocomplete and terminal-tool output shipped raw to cloud providers -- making "never leaks a secret" false in production. run_command/run_persistent_command output is also masked at the source. - Atomic audit-log writes. auditLogService.ts now writes via temp+rename ({ atomic: { postfix } }) for append, rotation, and truncate, so a crash mid-write can no longer corrupt the entire append-only audit trail. Adds the first service-level test (in-memory file service) pinning the atomic option, append/read round-trip, and crash-truncated-tail tolerance. - Agent working memory. Re-inject the todo_write list into the per-turn instructions (new pure, tested common/todoReminder.ts) -- folded like rules, not into the cached system message, so it stays current. getLatestTodos previously had zero consumers, so the agent could never read back its own plan. New tests: outboundRedaction, todoReminder, auditLogService (atomic append). The Open VSX extension gallery enablement lives in the cortexide-builder repo (prepare_vscode.sh); it is injected at build time because the OSS hygiene check forbids extensionsGallery in product.json. Co-Authored-By: Claude Opus 4.8 --- .../cortexide/browser/chatThreadService.ts | 10 +- .../browser/convertToLLMMessageService.ts | 12 +- .../contrib/cortexide/browser/toolsService.ts | 20 ++- .../cortexide/common/auditLogService.ts | 18 +-- .../cortexide/common/outboundRedaction.ts | 135 ++++++++++++++++++ .../cortexide/common/sendLLMMessageService.ts | 94 ++++-------- .../contrib/cortexide/common/todoReminder.ts | 45 ++++++ .../test/common/auditLogService.test.ts | 122 ++++++++++++++++ .../test/common/outboundRedaction.test.ts | 135 ++++++++++++++++++ .../test/common/todoReminder.test.ts | 50 +++++++ 10 files changed, 558 insertions(+), 83 deletions(-) create mode 100644 src/vs/workbench/contrib/cortexide/common/outboundRedaction.ts create mode 100644 src/vs/workbench/contrib/cortexide/common/todoReminder.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/auditLogService.test.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/outboundRedaction.test.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/todoReminder.test.ts diff --git a/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts b/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts index 0fd51023867c..6fa73e056aea 100644 --- a/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts @@ -24,6 +24,7 @@ import { IBackgroundAgentsService } from './backgroundAgentsService.js'; import { approvalTypeOfBuiltinToolName, BuiltinToolCallParams, BuiltinToolResultType, ToolCallParams, ToolName, ToolResult } from '../common/toolsServiceTypes.js'; import { checkToolAllowedInMode } from '../common/toolPermissions.js'; import { classifyCommandRisk, cwdEscapesWorkspace } from '../common/commandRisk.js'; +import { formatTodoReminder } from '../common/todoReminder.js'; import { decideAutoApprove } from '../common/autoApprovePolicy.js'; import { AgentFileOpRecord, AgentFileOpType, FileOpIO, undoFileOpsAfterCheckpoint } from '../common/agentFileOps.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; @@ -3726,7 +3727,8 @@ Output ONLY the JSON, no other text. Start with { and end with }.` chatMode, repoIndexerPromise: repoIndexerResults ? Promise.resolve(repoIndexerResults) : repoIndexerPromise, subagentSystemPrompt: runCtx?.systemPromptOverride, - allowedToolNames: runCtx?.allowedToolNames + allowedToolNames: runCtx?.allowedToolNames, + todoReminder: formatTodoReminder(this._toolsService.getLatestTodos()) }); } catch (prepErr) { // The first prompt assembly can throw (and has no prior messages to fall back to); @@ -3818,7 +3820,8 @@ Output ONLY the JSON, no other text. Start with { and end with }.` chatMode, repoIndexerPromise: repoIndexerResults ? Promise.resolve(repoIndexerResults) : repoIndexerPromise, subagentSystemPrompt: runCtx?.systemPromptOverride, - allowedToolNames: runCtx?.allowedToolNames + allowedToolNames: runCtx?.allowedToolNames, + todoReminder: formatTodoReminder(this._toolsService.getLatestTodos()) }) if (prep2.messages && prep2.messages.length > 0) { messages = prep2.messages @@ -3950,7 +3953,8 @@ Output ONLY the JSON, no other text. Start with { and end with }.` chatMode, repoIndexerPromise: repoIndexerResults ? Promise.resolve(repoIndexerResults) : repoIndexerPromise, subagentSystemPrompt: runCtx?.systemPromptOverride, - allowedToolNames: runCtx?.allowedToolNames + allowedToolNames: runCtx?.allowedToolNames, + todoReminder: formatTodoReminder(this._toolsService.getLatestTodos()) }); messages = prepResult.messages; separateSystemMessage = prepResult.separateSystemMessage; diff --git a/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts b/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts index c2fbd912b186..1358baa962b5 100644 --- a/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts @@ -1240,7 +1240,7 @@ const prepareMessages = (params: { export interface IConvertToLLMMessageService { readonly _serviceBrand: undefined; prepareLLMSimpleMessages: (opts: { simpleMessages: SimpleLLMMessage[], systemMessage: string, modelSelection: ModelSelection | null, featureName: FeatureName }) => { messages: LLMChatMessage[], separateSystemMessage: string | undefined } - prepareLLMChatMessages: (opts: { chatMessages: ChatMessage[], chatMode: ChatMode, modelSelection: ModelSelection | null, repoIndexerPromise?: Promise<{ results: string[], metrics: any } | null>, subagentSystemPrompt?: string, allowedToolNames?: string[] }) => Promise<{ messages: LLMChatMessage[], separateSystemMessage: string | undefined }> + prepareLLMChatMessages: (opts: { chatMessages: ChatMessage[], chatMode: ChatMode, modelSelection: ModelSelection | null, repoIndexerPromise?: Promise<{ results: string[], metrics: any } | null>, subagentSystemPrompt?: string, allowedToolNames?: string[], todoReminder?: string }) => Promise<{ messages: LLMChatMessage[], separateSystemMessage: string | undefined }> prepareFIMMessage(opts: { messages: LLMFIMMessage, modelSelection: ModelSelection | null, featureName: FeatureName, languageId?: string }): { prefix: string, suffix: string, stopTokens: string[] } startRepoIndexerQuery: (chatMessages: ChatMessage[], chatMode: ChatMode) => Promise<{ results: string[], metrics: any } | null> } @@ -1519,7 +1519,7 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess } } - prepareLLMChatMessages: IConvertToLLMMessageService['prepareLLMChatMessages'] = async ({ chatMessages, chatMode, modelSelection, repoIndexerPromise, subagentSystemPrompt, allowedToolNames }) => { + prepareLLMChatMessages: IConvertToLLMMessageService['prepareLLMChatMessages'] = async ({ chatMessages, chatMode, modelSelection, repoIndexerPromise, subagentSystemPrompt, allowedToolNames, todoReminder }) => { if (modelSelection === null) return { messages: [], separateSystemMessage: undefined } const { overridesOfModel } = this.cortexideSettingsService.state @@ -1683,7 +1683,13 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess const modelSelectionOptions = this.cortexideSettingsService.state.optionsOfModelSelection['Chat'][validProviderName]?.[modelName] // Get combined AI instructions - const aiInstructions = this._getCombinedAIInstructions(); + let aiInstructions = this._getCombinedAIInstructions(); + // Re-inject the agent's current todo list as fresh working memory each turn. Folded into the + // per-turn instructions (like rules) rather than the CACHED system message, so it stays current + // as steps complete. Empty -> caller passes undefined -> zero impact (e.g. normal/plan turns). + if (todoReminder) { + aiInstructions = aiInstructions ? `${aiInstructions}\n\n${todoReminder}` : todoReminder; + } const isReasoningEnabled = getIsReasoningEnabledState('Chat', validProviderName, modelName, modelSelectionOptions, overridesOfModel) const reservedOutputTokenSpace = getReservedOutputTokenSpace(validProviderName, modelName, { isReasoningEnabled, overridesOfModel }) let llmMessages = this._chatMessagesToSimpleMessages(chatMessages) diff --git a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts index 518047b2d22a..e3de7d967413 100644 --- a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts @@ -246,6 +246,8 @@ export interface IToolsService { validateParams: ValidateBuiltinParams; callTool: CallBuiltinTool; stringOfResult: BuiltinToolResultToString; + /** The agent's latest todo_write list, re-injected as working memory each turn. */ + getLatestTodos(): ReadonlyArray<{ content: string; status: 'pending' | 'in_progress' | 'completed' }>; } export const IToolsService = createDecorator('ToolsService'); @@ -1270,7 +1272,15 @@ export class ToolsService implements IToolsService { this.notificationService.info(`⚠️ Potentially risky command: ${command}\nReview before execution.`); } const { resPromise, interrupt } = await this.terminalToolService.runCommand(command, { type: 'temporary', cwd, terminalId }) - return { result: resPromise, interruptTool: interrupt } + // Mask secrets in command output (e.g. `cat .env`) before it is fed back to + // the model. Mirrors run_nl_command; defense-in-depth alongside the outbound + // dispatch redaction, so the masked text is also what shows in the UI tool + // card and local thread history. + const maskedResPromise = resPromise.then((res) => { + const secretResult = this.secretDetectionService.detectSecrets(res.result); + return secretResult.hasSecrets ? { ...res, result: secretResult.redactedText } : res; + }); + return { result: maskedResPromise, interruptTool: interrupt } }, run_nl_command: async ({ nlInput, cwd, terminalId }) => { // Parse natural language to shell command @@ -1326,7 +1336,13 @@ export class ToolsService implements IToolsService { this.notificationService.info(`⚠️ Potentially risky command: ${command}\nReview before execution.`); } const { resPromise, interrupt } = await this.terminalToolService.runCommand(command, { type: 'persistent', persistentTerminalId }) - return { result: resPromise, interruptTool: interrupt } + // Mask secrets in persistent-terminal output before it is fed back to the + // model. Mirrors run_command / run_nl_command (defense-in-depth). + const maskedResPromise = resPromise.then((res) => { + const secretResult = this.secretDetectionService.detectSecrets(res.result); + return secretResult.hasSecrets ? { ...res, result: secretResult.redactedText } : res; + }); + return { result: maskedResPromise, interruptTool: interrupt } }, open_persistent_terminal: async ({ cwd }) => { const persistentTerminalId = await this.terminalToolService.createPersistentTerminal({ cwd }) diff --git a/src/vs/workbench/contrib/cortexide/common/auditLogService.ts b/src/vs/workbench/contrib/cortexide/common/auditLogService.ts index 159456dd890a..19be75fec97b 100644 --- a/src/vs/workbench/contrib/cortexide/common/auditLogService.ts +++ b/src/vs/workbench/contrib/cortexide/common/auditLogService.ts @@ -44,7 +44,7 @@ export interface IAuditLogService { readEvents(): Promise<{ events: AuditEvent[]; skipped: number }>; } -class AuditLogService extends Disposable implements IAuditLogService { +export class AuditLogService extends Disposable implements IAuditLogService { declare readonly _serviceBrand: undefined; private _enabled = false; @@ -170,8 +170,10 @@ class AuditLogService extends Disposable implements IAuditLogService { } try { - // Append to file (non-blocking) - // Read existing content and append + // Read existing content and append. The whole file is rewritten, so the write MUST be + // atomic (temp file + rename, the pattern applyEngineV2/saveModel use): otherwise a crash + // mid-write corrupts or truncates the ENTIRE audit log, not just the new tail -- defeating + // the point of a tamper-evident, append-only audit trail. let existingContent = VSBuffer.fromString(''); try { const existing = await this._fileService.readFile(this._logPath); @@ -180,7 +182,7 @@ class AuditLogService extends Disposable implements IAuditLogService { // File doesn't exist yet, that's fine } const combined = VSBuffer.concat([existingContent, buffer]); - await this._fileService.writeFile(this._logPath, combined); + await this._fileService.writeFile(this._logPath, combined, { atomic: { postfix: '.cortexide-tmp' } }); this._currentFileSize += sizeBytes; } catch (err) { this._logService.error('[AuditLog] Failed to write audit log:', err); @@ -217,11 +219,11 @@ class AuditLogService extends Disposable implements IAuditLogService { rotationNum++; } while (await this._fileService.exists(rotatedPath)); - // Write compressed file - await this._fileService.writeFile(rotatedPath, VSBuffer.wrap(compressed)); + // Write compressed file (atomic: a crash must not leave a half-written rotation archive). + await this._fileService.writeFile(rotatedPath, VSBuffer.wrap(compressed), { atomic: { postfix: '.cortexide-tmp' } }); - // Create new empty log file - await this._fileService.writeFile(this._logPath, VSBuffer.fromString('')); + // Create new empty log file (atomic: the truncation to empty must not race a concurrent read). + await this._fileService.writeFile(this._logPath, VSBuffer.fromString(''), { atomic: { postfix: '.cortexide-tmp' } }); this._currentFileSize = 0; this._logService.debug(`[AuditLog] Rotated log file to ${rotatedPath.path}`); diff --git a/src/vs/workbench/contrib/cortexide/common/outboundRedaction.ts b/src/vs/workbench/contrib/cortexide/common/outboundRedaction.ts new file mode 100644 index 000000000000..2b169007df73 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/common/outboundRedaction.ts @@ -0,0 +1,135 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +/** + * Outbound secret-redaction at the single LLM dispatch boundary. + * + * Every payload that can leave the machine flows through sendLLMMessage before it + * crosses the IPC channel to electron-main: chat messages (including tool_result + * content) AND FIM/autocomplete prefix/suffix. Previously redaction ran ONLY on + * chatMessages text parts, so two paths leaked raw to cloud providers: + * 1. FIM/autocomplete shipped the raw surrounding code on every keystroke. + * 2. tool_result content (e.g. `cat .env` output routed back as a tool result) + * was never walked, because the scan only looked at {type:'text'} parts. + * + * These helpers redact IN PLACE so the redacted objects are exactly what ships, + * and are pure (a detect function is injected) so they are node-unit-testable + * without the renderer DI graph -- the layer where the leak actually occurred. + */ + +export type SecretDetectFn = (text: string) => { + hasSecrets: boolean; + redactedText: string; + matches: { pattern: { name: string } }[]; +}; + +export type RedactionSummary = { + hasSecrets: boolean; + /** secret-type name -> number of redacted matches (for trace logging / UX) */ + countByType: Map; +}; + +function emptySummary(): RedactionSummary { + return { hasSecrets: false, countByType: new Map() }; +} + +function accumulate(summary: RedactionSummary, matches: { pattern: { name: string } }[]): void { + for (const m of matches) { + const name = m.pattern.name; + summary.countByType.set(name, (summary.countByType.get(name) ?? 0) + 1); + } + if (matches.length > 0) { + summary.hasSecrets = true; + } +} + +function redactString(text: unknown, detect: SecretDetectFn, summary: RedactionSummary): string | unknown { + if (typeof text !== 'string' || text.length === 0) { + return text; + } + const d = detect(text); + if (d.hasSecrets) { + accumulate(summary, d.matches); + return d.redactedText; + } + return text; +} + +/** + * Redact secrets in chat messages IN PLACE across every text-bearing shape: + * string content; OpenAI/Anthropic array parts ({type:'text'} text AND + * {type:'tool_result'} content); and Gemini `parts`. Returns a summary. + */ +export function redactChatMessages(messages: any[], detect: SecretDetectFn): RedactionSummary { + const summary = emptySummary(); + if (!Array.isArray(messages)) { + return summary; + } + for (const msg of messages) { + if (!msg || typeof msg !== 'object') { + continue; + } + if ('content' in msg) { + if (typeof msg.content === 'string') { + msg.content = redactString(msg.content, detect, summary); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (!part || typeof part !== 'object') { + continue; + } + // Anthropic/OpenAI text parts. + if (part.type === 'text' && typeof part.text === 'string') { + part.text = redactString(part.text, detect, summary); + } + // Tool results carry command/file output back to the model on the + // `content` field (NOT `text`) -- this is the path that leaked + // terminal output such as `cat .env`. + else if (part.type === 'tool_result' && typeof part.content === 'string') { + part.content = redactString(part.content, detect, summary); + } + } + } + } else if ('parts' in msg && Array.isArray(msg.parts)) { + // Gemini-style message. + for (const part of msg.parts) { + if (part && typeof part === 'object' && typeof part.text === 'string') { + part.text = redactString(part.text, detect, summary); + } + } + } + } + return summary; +} + +/** + * Redact secrets in a FIM (autocomplete) payload IN PLACE: prefix + suffix. + * + * Autocomplete must redact-and-continue, never block: blocking would break the + * completion and a notification on every keystroke would be unusable. Callers + * therefore should NOT apply the chat `block` mode to FIM. + */ +export function redactFimMessage( + fim: { prefix?: unknown; suffix?: unknown } | undefined | null, + detect: SecretDetectFn +): RedactionSummary { + const summary = emptySummary(); + if (!fim || typeof fim !== 'object') { + return summary; + } + if (typeof fim.prefix === 'string') { + fim.prefix = redactString(fim.prefix, detect, summary); + } + if (typeof fim.suffix === 'string') { + fim.suffix = redactString(fim.suffix, detect, summary); + } + return summary; +} + +/** Render a redaction summary as `name=count, ...` for trace logging. */ +export function summarizeRedaction(summary: RedactionSummary): string { + return Array.from(summary.countByType.entries()) + .map(([name, count]) => `${name}=${count}`) + .join(', '); +} diff --git a/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts b/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts index 173ee75c941b..39b5cecc97f0 100644 --- a/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts +++ b/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts @@ -16,6 +16,7 @@ import { ICortexideSettingsService } from './cortexideSettingsService.js'; import { canDispatchToProvider } from './egressPolicy.js'; import { IMCPService } from './mcpService.js'; import { ISecretDetectionService } from './secretDetectionService.js'; +import { redactChatMessages, redactFimMessage, summarizeRedaction } from './outboundRedaction.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { isWeb } from '../../../../base/common/platform.js'; @@ -156,72 +157,25 @@ export class LLMMessageService extends Disposable implements ILLMMessageService return null } - // Detect and redact secrets before sending + // Detect and redact secrets at the SINGLE outbound dispatch boundary, before the + // payload crosses the IPC channel to electron-main. This covers EVERY payload that + // can leave the machine: chat messages (string content, text parts, AND tool_result + // content such as `cat .env` output) plus FIM/autocomplete prefix/suffix. Previously + // only chatMessages text parts were scanned, so autocomplete and terminal-tool output + // shipped raw to cloud providers -- contradicting the "never leaks a secret" guarantee. + // The message-walking logic lives in the pure, unit-tested common/outboundRedaction.ts. const config = this.secretDetectionService.getConfig(); - if (config.enabled && params.messagesType === 'chatMessages' && params.messages) { - let totalMatches: any[] = []; - let hasAnySecrets = false; - - // Scan all messages for secrets - for (const msg of params.messages) { - // Handle different message types - if ('content' in msg) { - // AnthropicLLMChatMessage or OpenAILLMChatMessage - if (typeof msg.content === 'string') { - const detection = this.secretDetectionService.detectSecrets(msg.content); - if (detection.hasSecrets) { - hasAnySecrets = true; - totalMatches.push(...detection.matches); - // Redact the message content - (msg as any).content = detection.redactedText; - } - } else if (Array.isArray(msg.content)) { - // Handle array content (e.g., OpenAI format with images) - for (const part of msg.content) { - if ('type' in part && part.type === 'text' && 'text' in part && typeof part.text === 'string') { - const detection = this.secretDetectionService.detectSecrets(part.text); - if (detection.hasSecrets) { - hasAnySecrets = true; - totalMatches.push(...detection.matches); - (part as any).text = detection.redactedText; - } - } - } - } - } else if ('parts' in msg) { - // GeminiLLMChatMessage - uses 'parts' instead of 'content' - for (const part of msg.parts) { - if ('text' in part && typeof part.text === 'string') { - const detection = this.secretDetectionService.detectSecrets(part.text); - if (detection.hasSecrets) { - hasAnySecrets = true; - totalMatches.push(...detection.matches); - (part as any).text = detection.redactedText; - } - } - } - } - } + if (config.enabled) { + const detect = (text: string) => this.secretDetectionService.detectSecrets(text); - // Log secret detection result (trace) for verification that paths are not falsely redacted as AWS Secret Key - const countByType = new Map(); - for (const match of totalMatches) { - const name = match.pattern.name; - countByType.set(name, (countByType.get(name) || 0) + 1); - } - const typesList = Array.from(countByType.entries()) - .map(([name, count]) => `${name}=${count}`) - .join(', '); - this.logService.trace('[SecretDetection] Chat messages scanned.', hasAnySecrets ? `Redacted: ${typesList}` : 'No secrets detected (paths in system message are not redacted).'); - - // Show warning if secrets detected - if (hasAnySecrets) { - const typesListForUser = Array.from(countByType.entries()) - .map(([name, count]) => `${name} (${count})`) - .join(', '); - - if (config.mode === 'block') { - // Always show block notifications (they're important) + if (params.messagesType === 'chatMessages' && params.messages) { + const summary = redactChatMessages(params.messages, detect); + this.logService.trace('[SecretDetection] Chat messages scanned.', summary.hasSecrets ? `Redacted: ${summarizeRedaction(summary)}` : 'No secrets detected (paths in system message are not redacted).'); + + if (summary.hasSecrets && config.mode === 'block') { + const typesListForUser = Array.from(summary.countByType.entries()) + .map(([name, count]) => `${name} (${count})`) + .join(', '); this.notificationService.warn( `Secret detected: ${typesListForUser}. Message blocked from sending. Use environment variables or secure vaults instead of pasting keys into chat.` ); @@ -230,9 +184,15 @@ export class LLMMessageService extends Disposable implements ILLMMessageService fullError: null, }); return null; - } else { - // Redact mode - silently redact without notification - // (Notification removed per user request) + } + // Redact mode: secrets already redacted in place; send silently. + } else if (params.messagesType === 'FIMMessage' && params.messages) { + // Autocomplete must redact-and-continue (never block / never notify) so a + // secret in the surrounding code can't ship to a cloud model, without + // breaking completions or spamming the user on every keystroke. + const summary = redactFimMessage(params.messages, detect); + if (summary.hasSecrets) { + this.logService.trace('[SecretDetection] FIM payload redacted.', summarizeRedaction(summary)); } } } diff --git a/src/vs/workbench/contrib/cortexide/common/todoReminder.ts b/src/vs/workbench/contrib/cortexide/common/todoReminder.ts new file mode 100644 index 000000000000..54ec89bdeae4 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/common/todoReminder.ts @@ -0,0 +1,45 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +/** + * Formats the agent's current todo list as a working-memory reminder that is + * re-injected into the model's per-turn instructions. + * + * Without this, todo_write writes a plan the model can never read back + * (getLatestTodos had no consumers), so on long-horizon tasks the agent loses + * track of what it has done and what remains -- a capability incumbents and the + * local-first field both ship. The reminder is folded into the per-turn + * instructions (alongside rules/MCP), NOT the cached system message, so it stays + * current as steps complete. + */ + +export type TodoStatus = 'pending' | 'in_progress' | 'completed'; +export type TodoItem = { content: string; status: TodoStatus }; + +function checkbox(status: TodoStatus): string { + switch (status) { + case 'completed': return '[x]'; + case 'in_progress': return '[~]'; + default: return '[ ]'; + } +} + +/** + * Returns a formatted reminder block, or undefined when there is nothing to + * inject (no todos) so callers can skip injection entirely with zero impact. + */ +export function formatTodoReminder(todos: ReadonlyArray | undefined | null): string | undefined { + if (!todos || todos.length === 0) { + return undefined; + } + const completed = todos.filter(t => t.status === 'completed').length; + const lines = todos.map(t => `${checkbox(t.status)} ${t.content}`); + return [ + `CURRENT TODO LIST (your working memory -- you maintain this with the todo_write tool).`, + `Keep it updated as you make progress; mark items completed and set the next one in_progress. Call attempt_completion only when every item is done.`, + `Progress: ${completed}/${todos.length} completed.`, + ...lines, + ].join('\n'); +} diff --git a/src/vs/workbench/contrib/cortexide/test/common/auditLogService.test.ts b/src/vs/workbench/contrib/cortexide/test/common/auditLogService.test.ts new file mode 100644 index 000000000000..d4970a3be1f9 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/auditLogService.test.ts @@ -0,0 +1,122 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { AuditLogService, AuditEvent } from '../../common/auditLogService.js'; +import { serializeEvents } from '../../common/auditLogFormat.js'; + +/** + * First service-level coverage for AuditLogService (previously only the pure auditLogFormat module + * was tested). Pins the corruption fix: the whole-file rewrite MUST go through an ATOMIC write + * (temp + rename) so a crash mid-write cannot truncate or corrupt the entire audit trail. Also + * checks the append->read round-trip and crash-truncated-tail tolerance end-to-end via an + * in-memory file service. + */ + +const LOG_PATH = '/tmp/cortexide-audit-test/audit.jsonl'; + +type WriteCall = { path: string; options: any }; + +class FakeFileService { + private readonly _store = new Map(); + readonly writeCalls: WriteCall[] = []; + + // Directly seed file content (simulate prior content / a crash-truncated file). + seed(uri: URI, content: string) { this._store.set(uri.toString(), VSBuffer.fromString(content)); } + + async createFolder(_uri: URI) { return undefined as any; } + async exists(uri: URI) { return this._store.has(uri.toString()); } + async stat(uri: URI) { + const b = this._store.get(uri.toString()); + if (!b) { throw new Error('ENOENT'); } + return { size: b.byteLength } as any; + } + async readFile(uri: URI) { + const b = this._store.get(uri.toString()); + if (!b) { throw new Error('ENOENT'); } + return { value: b } as any; + } + async writeFile(uri: URI, content: VSBuffer, options?: any) { + this.writeCalls.push({ path: uri.toString(), options }); + this._store.set(uri.toString(), content); + return undefined as any; + } + onDidChangeConfiguration() { return { dispose() { } }; } +} + +function makeService(fs: FakeFileService): AuditLogService { + const config = { + getValue(key: string) { + switch (key) { + case 'cortexide.audit.enable': return true; + case 'cortexide.audit.path': return LOG_PATH; + case 'cortexide.audit.rotationSizeMB': return 10; + default: return undefined; + } + }, + onDidChangeConfiguration() { return { dispose() { } }; }, + }; + const workspace = { getWorkspace() { return { folders: [] }; } }; + const env = { workspaceStorageHome: URI.file('/tmp/cortexide-audit-test/storage') }; + const log = { error() { }, warn() { }, info() { }, debug() { }, trace() { } }; + return new AuditLogService(fs as any, workspace as any, config as any, env as any, log as any); +} + +const ev = (action: AuditEvent['action'], ok = true): AuditEvent => ({ ts: 1700000000000, action, ok }); + +suite('AuditLogService (atomic append)', () => { + + test('append + readEvents round-trips the event', async () => { + const fs = new FakeFileService(); + const svc = makeService(fs); + await svc.append(ev('prompt')); + const { events, skipped } = await svc.readEvents(); + assert.strictEqual(skipped, 0); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].action, 'prompt'); + svc.dispose(); + }); + + test('the log write is ATOMIC (temp + rename) -- the corruption fix', async () => { + const fs = new FakeFileService(); + const svc = makeService(fs); + await svc.append(ev('apply')); + await svc.readEvents(); // forces the flush/write + const logWrites = fs.writeCalls.filter(c => c.path === URI.file(LOG_PATH).toString()); + assert.ok(logWrites.length >= 1, 'the log file must have been written'); + for (const w of logWrites) { + assert.ok(w.options && w.options.atomic && typeof w.options.atomic.postfix === 'string', + 'every audit-log write must pass { atomic: { postfix } } so a crash cannot corrupt the whole file'); + } + svc.dispose(); + }); + + test('successive appends accumulate (no lost events)', async () => { + const fs = new FakeFileService(); + const svc = makeService(fs); + await svc.append(ev('prompt')); + await svc.readEvents(); + await svc.append(ev('reply')); + const { events } = await svc.readEvents(); + assert.deepStrictEqual(events.map(e => e.action), ['prompt', 'reply']); + svc.dispose(); + }); + + test('a crash-truncated trailing line is skipped; prior events survive', async () => { + const fs = new FakeFileService(); + const svc = makeService(fs); + // One valid serialized event line, then a partial JSON object with no newline (mid-crash write). + const valid = serializeEvents([ev('prompt')]); + fs.seed(URI.file(LOG_PATH), valid + '{"ts":1700000000001,"action":"reply"'); + const { events, skipped } = await svc.readEvents(); + assert.strictEqual(events.length, 1, 'the complete prior event survives'); + assert.strictEqual(events[0].action, 'prompt'); + assert.ok(skipped >= 1, 'the truncated tail line is reported as skipped'); + svc.dispose(); + }); +}); diff --git a/src/vs/workbench/contrib/cortexide/test/common/outboundRedaction.test.ts b/src/vs/workbench/contrib/cortexide/test/common/outboundRedaction.test.ts new file mode 100644 index 000000000000..b13ce295cfe0 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/outboundRedaction.test.ts @@ -0,0 +1,135 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { detectSecrets } from '../../common/secretDetection.js'; +import { redactChatMessages, redactFimMessage, summarizeRedaction } from '../../common/outboundRedaction.js'; + +/** + * Pins the "never leaks a secret" guarantee at the OUTBOUND DISPATCH BOUNDARY -- the + * layer where the leak actually occurred. Redaction is exercised with the REAL secret + * patterns (detectSecrets), proving that across every message shape a key cannot ship + * verbatim. Regression coverage for two paths that previously leaked raw to cloud + * providers: tool_result content (e.g. `cat .env` output) and FIM/autocomplete. + */ + +// A real-shaped OpenAI key (same fixture style the secretDetection suite uses). +const KEY = 'sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz'; +const detect = (text: string) => detectSecrets(text); + +function assertRedacted(s: unknown) { + assert.strictEqual(typeof s, 'string'); + assert.ok(!(s as string).includes(KEY), `the secret must not survive verbatim: ${String(s)}`); + assert.ok((s as string).includes('[[REDACTED:'), 'a redaction placeholder must be present'); +} + +suite('outboundRedaction', () => { + + suite('redactChatMessages', () => { + + test('redacts a secret in plain string content (in place)', () => { + const messages: any[] = [{ role: 'user', content: `here is my key ${KEY} ok` }]; + const summary = redactChatMessages(messages, detect); + assert.strictEqual(summary.hasSecrets, true); + assertRedacted(messages[0].content); + }); + + test('redacts a secret in an OpenAI-style {type:"text"} array part', () => { + const messages: any[] = [{ role: 'user', content: [{ type: 'text', text: `key: ${KEY}` }] }]; + const summary = redactChatMessages(messages, detect); + assert.strictEqual(summary.hasSecrets, true); + assertRedacted(messages[0].content[0].text); + }); + + // THE regression: command/file output routed back as a tool_result was never + // walked (the old scan only looked at {type:'text'} parts), so `cat .env` + // output reached the model raw. + test('redacts a secret in a {type:"tool_result"} content part (cat .env regression)', () => { + const messages: any[] = [{ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'abc', content: `OPENAI_API_KEY=${KEY}\n` }], + }]; + const summary = redactChatMessages(messages, detect); + assert.strictEqual(summary.hasSecrets, true, 'tool_result content must be scanned'); + assertRedacted(messages[0].content[0].content); + }); + + test('does not crash on tool_use parts (no text/content string)', () => { + const messages: any[] = [{ + role: 'assistant', + content: [{ type: 'tool_use', id: 'x', name: 'run_command', input: { command: 'ls' } }], + }]; + assert.doesNotThrow(() => redactChatMessages(messages, detect)); + }); + + test('redacts a secret in Gemini-style parts[].text', () => { + const messages: any[] = [{ role: 'user', parts: [{ text: `secret ${KEY}` }] }]; + const summary = redactChatMessages(messages, detect); + assert.strictEqual(summary.hasSecrets, true); + assertRedacted(messages[0].parts[0].text); + }); + + test('leaves clean messages untouched and reports no secrets', () => { + const messages: any[] = [ + { role: 'system', content: 'You are a helpful assistant. Path: /usr/local/bin' }, + { role: 'user', content: [{ type: 'text', text: 'refactor src/app.ts please' }] }, + ]; + const before = JSON.stringify(messages); + const summary = redactChatMessages(messages, detect); + assert.strictEqual(summary.hasSecrets, false); + assert.strictEqual(JSON.stringify(messages), before, 'clean content must not be mutated'); + }); + + test('summary counts each redacted secret type', () => { + const messages: any[] = [ + { role: 'user', content: `a ${KEY}` }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't', content: `b ${KEY}` }] }, + ]; + const summary = redactChatMessages(messages, detect); + assert.strictEqual(summary.hasSecrets, true); + const total = Array.from(summary.countByType.values()).reduce((a, b) => a + b, 0); + assert.strictEqual(total, 2, 'both occurrences must be counted'); + assert.ok(summarizeRedaction(summary).includes('='), 'summary string renders name=count'); + }); + + test('tolerates malformed input without throwing', () => { + assert.doesNotThrow(() => redactChatMessages(undefined as any, detect)); + assert.doesNotThrow(() => redactChatMessages([null, 42, 'x'] as any, detect)); + assert.doesNotThrow(() => redactChatMessages([{ role: 'user' }] as any, detect)); + }); + }); + + suite('redactFimMessage', () => { + + test('redacts a secret in the FIM prefix (autocomplete leak regression)', () => { + const fim: any = { prefix: `const k = "${KEY}";\n`, suffix: '}', stopTokens: [] }; + const summary = redactFimMessage(fim, detect); + assert.strictEqual(summary.hasSecrets, true); + assertRedacted(fim.prefix); + }); + + test('redacts a secret in the FIM suffix', () => { + const fim: any = { prefix: 'function f() {\n', suffix: `\nreturn "${KEY}"; }`, stopTokens: [] }; + const summary = redactFimMessage(fim, detect); + assert.strictEqual(summary.hasSecrets, true); + assertRedacted(fim.suffix); + }); + + test('leaves clean FIM payloads untouched', () => { + const fim: any = { prefix: 'const x = 1;\n', suffix: '\nconsole.log(x);', stopTokens: [] }; + const summary = redactFimMessage(fim, detect); + assert.strictEqual(summary.hasSecrets, false); + assert.strictEqual(fim.prefix, 'const x = 1;\n'); + assert.strictEqual(fim.suffix, '\nconsole.log(x);'); + }); + + test('tolerates missing/empty fields', () => { + assert.doesNotThrow(() => redactFimMessage(undefined, detect)); + assert.doesNotThrow(() => redactFimMessage({}, detect)); + assert.strictEqual(redactFimMessage({ prefix: '', suffix: '' }, detect).hasSecrets, false); + }); + }); +}); diff --git a/src/vs/workbench/contrib/cortexide/test/common/todoReminder.test.ts b/src/vs/workbench/contrib/cortexide/test/common/todoReminder.test.ts new file mode 100644 index 000000000000..36018b23e2d9 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/todoReminder.test.ts @@ -0,0 +1,50 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { formatTodoReminder } from '../../common/todoReminder.js'; + +suite('todoReminder.formatTodoReminder', () => { + + test('returns undefined for empty / missing input (no injection)', () => { + assert.strictEqual(formatTodoReminder([]), undefined); + assert.strictEqual(formatTodoReminder(undefined), undefined); + assert.strictEqual(formatTodoReminder(null), undefined); + }); + + test('renders each item with a status checkbox', () => { + const out = formatTodoReminder([ + { content: 'Read the spec', status: 'completed' }, + { content: 'Write the code', status: 'in_progress' }, + { content: 'Add tests', status: 'pending' }, + ]); + assert.ok(out); + assert.ok(out!.includes('[x] Read the spec'), 'completed -> [x]'); + assert.ok(out!.includes('[~] Write the code'), 'in_progress -> [~]'); + assert.ok(out!.includes('[ ] Add tests'), 'pending -> [ ]'); + }); + + test('reports accurate completed/total progress', () => { + const out = formatTodoReminder([ + { content: 'a', status: 'completed' }, + { content: 'b', status: 'completed' }, + { content: 'c', status: 'pending' }, + ]); + assert.ok(out!.includes('Progress: 2/3 completed.')); + }); + + test('mentions todo_write and attempt_completion so the model knows the contract', () => { + const out = formatTodoReminder([{ content: 'x', status: 'pending' }]); + assert.ok(out!.includes('todo_write'), 'tells the model how to maintain it'); + assert.ok(out!.includes('attempt_completion'), 'tells the model when to finish'); + }); + + test('preserves item content verbatim (no truncation/escaping surprises)', () => { + const content = 'Refactor src/app.ts -> extract handler(); keep behaviour'; + const out = formatTodoReminder([{ content, status: 'pending' }]); + assert.ok(out!.includes(content)); + }); +}); From ef04d39daf36315f04a1ea47961ba93ce3471cb4 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 29 Jun 2026 23:01:57 +0100 Subject: [PATCH 2/4] Egress audit ledger: record every LLM egress decision (provider/destination/redaction) Adds the building block of the tamper-evident egress ledger -- a complete record of every outbound LLM call (allowed AND blocked): provider, destination kind (loopback/ remote/private/unknown), whether it leaves the machine, the gate's allow/block decision + reason, and whether a secret was redacted from the payload. - New pure, unit-tested common/egressAudit.ts (buildEgressAuditEvent) reuses the SAME classification the egress gate enforces (classifyProviderDestination + canDispatchToProvider), so the ledger never disagrees with what actually happened. - AuditEvent gains an 'egress' action. - sendLLMMessageService appends one egress event per dispatch (chat + autocomplete), gated behind the opt-in audit log, fire-and-forget. Reuses the redactionApplied signal from the outbound-redaction pass. This is the "verify us, don't trust us" auditability differentiator -- neither Cursor nor Claude Code exposes a per-request egress ledger. Wiring the remaining chokepoints (web-tool, MCP-URL, model-catalog) is a follow-up. New tests: egressAudit. Co-Authored-By: Claude Opus 4.8 --- .../cortexide/common/auditLogService.ts | 2 +- .../contrib/cortexide/common/egressAudit.ts | 61 +++++++++++++++ .../cortexide/common/sendLLMMessageService.ts | 29 ++++++- .../cortexide/test/common/egressAudit.test.ts | 75 +++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/cortexide/common/egressAudit.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/egressAudit.test.ts diff --git a/src/vs/workbench/contrib/cortexide/common/auditLogService.ts b/src/vs/workbench/contrib/cortexide/common/auditLogService.ts index 19be75fec97b..9a7330271887 100644 --- a/src/vs/workbench/contrib/cortexide/common/auditLogService.ts +++ b/src/vs/workbench/contrib/cortexide/common/auditLogService.ts @@ -20,7 +20,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; export interface AuditEvent { ts: number; user?: string; - action: 'prompt' | 'reply' | 'diff_preview' | 'apply' | 'undo' | 'rollback' | 'snapshot:create' | 'snapshot:restore' | 'snapshot:discard' | 'git:stash' | 'git:stash:restore'; + action: 'prompt' | 'reply' | 'diff_preview' | 'apply' | 'undo' | 'rollback' | 'snapshot:create' | 'snapshot:restore' | 'snapshot:discard' | 'git:stash' | 'git:stash:restore' | 'egress'; files?: string[]; diffStats?: { linesAdded: number; linesRemoved: number; hunks: number }; model?: string; diff --git a/src/vs/workbench/contrib/cortexide/common/egressAudit.ts b/src/vs/workbench/contrib/cortexide/common/egressAudit.ts new file mode 100644 index 000000000000..ef0273c88e09 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/common/egressAudit.ts @@ -0,0 +1,61 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import type { AuditEvent } from './auditLogService.js'; +import type { EgressDestinationKind } from './egressPolicy.js'; + +/** + * Builds an audit event recording a single egress DECISION -- the building block of the + * tamper-evident egress ledger ("verify us, don't trust us"): a complete record of every + * outbound call (allowed AND blocked), where it went, and whether secrets were redacted + * from it. Reuses the SAME classification the egress gate enforces (classifyProviderDestination + * + canDispatchToProvider) so the ledger never disagrees with what actually happened. + * + * Pure (no I/O) so it is node-unit-testable. Type-only imports => no runtime cycle with the + * audit service. + */ + +export interface EgressAuditInput { + /** Event timestamp (ms since epoch). Passed in so this stays pure/deterministic for tests. */ + ts: number; + providerName: string; + modelName: string; + /** loopback | private | remote | unknown -- from egressPolicy.classifyProviderDestination. */ + destinationKind: EgressDestinationKind; + /** The gate's decision (canDispatchToProvider). false => blocked by local-only / privacy. */ + allowed: boolean; + /** Block reason when not allowed. */ + reason?: string; + /** Whether secret redaction actually rewrote the outbound payload before this call. */ + redactionApplied: boolean; + /** Defaults to 'cloud-llm'. */ + modality?: string; +} + +/** True for any destination that leaves the machine (everything except loopback). */ +export function isOffMachine(kind: EgressDestinationKind): boolean { + return kind !== 'loopback'; +} + +export function buildEgressAuditEvent(input: EgressAuditInput): AuditEvent { + const event: AuditEvent = { + ts: input.ts, + action: 'egress', + model: input.modelName, + ok: input.allowed, + meta: { + provider: input.providerName, + modality: input.modality ?? 'cloud-llm', + destination: input.destinationKind, + offMachine: isOffMachine(input.destinationKind), + blocked: !input.allowed, + redactionApplied: input.redactionApplied, + }, + }; + if (input.reason) { + event.meta!.reason = input.reason; + } + return event; +} diff --git a/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts b/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts index 39b5cecc97f0..e298003f187a 100644 --- a/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts +++ b/src/vs/workbench/contrib/cortexide/common/sendLLMMessageService.ts @@ -13,7 +13,9 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { ICortexideSettingsService } from './cortexideSettingsService.js'; -import { canDispatchToProvider } from './egressPolicy.js'; +import { canDispatchToProvider, classifyProviderDestination } from './egressPolicy.js'; +import { buildEgressAuditEvent } from './egressAudit.js'; +import { IAuditLogService } from './auditLogService.js'; import { IMCPService } from './mcpService.js'; import { ISecretDetectionService } from './secretDetectionService.js'; import { redactChatMessages, redactFimMessage, summarizeRedaction } from './outboundRedaction.js'; @@ -77,6 +79,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService @ISecretDetectionService private readonly secretDetectionService: ISecretDetectionService, @ILogService private readonly logService: ILogService, @IFreeTierQuotaService private readonly freeTierQuotaService: IFreeTierQuotaService, + @IAuditLogService private readonly auditLogService: IAuditLogService, ) { super() @@ -164,12 +167,14 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // only chatMessages text parts were scanned, so autocomplete and terminal-tool output // shipped raw to cloud providers -- contradicting the "never leaks a secret" guarantee. // The message-walking logic lives in the pure, unit-tested common/outboundRedaction.ts. + let redactionApplied = false; // recorded in the egress audit ledger below const config = this.secretDetectionService.getConfig(); if (config.enabled) { const detect = (text: string) => this.secretDetectionService.detectSecrets(text); if (params.messagesType === 'chatMessages' && params.messages) { const summary = redactChatMessages(params.messages, detect); + redactionApplied = summary.hasSecrets; this.logService.trace('[SecretDetection] Chat messages scanned.', summary.hasSecrets ? `Redacted: ${summarizeRedaction(summary)}` : 'No secrets detected (paths in system message are not redacted).'); if (summary.hasSecrets && config.mode === 'block') { @@ -191,6 +196,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // secret in the surrounding code can't ship to a cloud model, without // breaking completions or spamming the user on every keystroke. const summary = redactFimMessage(params.messages, detect); + redactionApplied = summary.hasSecrets; if (summary.hasSecrets) { this.logService.trace('[SecretDetection] FIM payload redacted.', summarizeRedaction(summary)); } @@ -204,6 +210,27 @@ export class LLMMessageService extends Disposable implements ILLMMessageService // line of defense behind the router. const localOnly = this.cortexideSettingsService.state.globalSettings.routingPolicy === 'local-only' + // Egress ledger: record EVERY outbound LLM decision (allowed AND blocked) -- provider, + // destination (loopback/remote), whether it leaves the machine, and whether a secret was + // redacted from it -- using the SAME classification the egress gate enforces. Gated behind + // the (opt-in) audit log; fire-and-forget so it never blocks or fails a request. + const egressProvider = modelSelection.providerName // 'auto' is resolved upstream; narrow it out + if (this.auditLogService.isEnabled() && egressProvider !== 'auto') { + const endpoint = settingsOfProvider[egressProvider]?.endpoint + const destinationKind = classifyProviderDestination(egressProvider, endpoint) + const decision = canDispatchToProvider(localOnly, egressProvider, endpoint) + this.auditLogService.append(buildEgressAuditEvent({ + ts: Date.now(), + providerName: egressProvider, + modelName: modelSelection.modelName, + destinationKind, + allowed: decision.allowed, + reason: decision.reason, + redactionApplied, + modality: params.messagesType === 'FIMMessage' ? 'autocomplete' : 'cloud-llm', + })).catch(err => this.logService.warn('[AuditLog] egress append failed', err)) + } + const mcpTools = this.mcpService.getMCPTools() // add state for request id diff --git a/src/vs/workbench/contrib/cortexide/test/common/egressAudit.test.ts b/src/vs/workbench/contrib/cortexide/test/common/egressAudit.test.ts new file mode 100644 index 000000000000..6970260015e6 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/egressAudit.test.ts @@ -0,0 +1,75 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { buildEgressAuditEvent, isOffMachine } from '../../common/egressAudit.js'; + +/** + * Pins the egress-ledger event builder: every outbound LLM decision (allowed AND blocked) is + * recorded with provider, destination, off-machine flag, block status, and redaction status -- + * the tamper-evident "verify us" trail neither Cursor nor Claude Code offers. + */ +suite('egressAudit.buildEgressAuditEvent', () => { + + test('an allowed remote call is recorded as off-machine, not blocked', () => { + const e = buildEgressAuditEvent({ + ts: 1700000000000, providerName: 'anthropic', modelName: 'claude-opus-4-8', + destinationKind: 'remote', allowed: true, redactionApplied: false, + }); + assert.strictEqual(e.action, 'egress'); + assert.strictEqual(e.ok, true); + assert.strictEqual(e.model, 'claude-opus-4-8'); + assert.strictEqual(e.meta!.provider, 'anthropic'); + assert.strictEqual(e.meta!.destination, 'remote'); + assert.strictEqual(e.meta!.offMachine, true); + assert.strictEqual(e.meta!.blocked, false); + assert.strictEqual(e.meta!.redactionApplied, false); + assert.strictEqual(e.meta!.modality, 'cloud-llm'); + }); + + test('a blocked call (local-only + remote) records ok=false, blocked=true, and the reason', () => { + const e = buildEgressAuditEvent({ + ts: 1, providerName: 'openai', modelName: 'gpt-x', + destinationKind: 'remote', allowed: false, reason: 'Local-only privacy mode is on.', + redactionApplied: false, + }); + assert.strictEqual(e.ok, false); + assert.strictEqual(e.meta!.blocked, true); + assert.strictEqual(e.meta!.reason, 'Local-only privacy mode is on.'); + }); + + test('a loopback (Ollama) call is recorded as on-machine (offMachine=false)', () => { + const e = buildEgressAuditEvent({ + ts: 1, providerName: 'ollama', modelName: 'qwen2.5-coder', + destinationKind: 'loopback', allowed: true, redactionApplied: false, + }); + assert.strictEqual(e.meta!.offMachine, false); + assert.strictEqual(e.meta!.blocked, false); + }); + + test('redactionApplied is propagated (a secret was scrubbed from the payload)', () => { + const e = buildEgressAuditEvent({ + ts: 1, providerName: 'anthropic', modelName: 'm', + destinationKind: 'remote', allowed: true, redactionApplied: true, + }); + assert.strictEqual(e.meta!.redactionApplied, true); + }); + + test('no reason field is added when the call is allowed', () => { + const e = buildEgressAuditEvent({ + ts: 1, providerName: 'a', modelName: 'm', destinationKind: 'remote', + allowed: true, redactionApplied: false, + }); + assert.ok(!('reason' in e.meta!), 'reason should be omitted when there is none'); + }); + + test('isOffMachine: only loopback is on-machine', () => { + assert.strictEqual(isOffMachine('loopback'), false); + assert.strictEqual(isOffMachine('remote'), true); + assert.strictEqual(isOffMachine('private'), true); + assert.strictEqual(isOffMachine('unknown'), true); + }); +}); From 4b9d607fdac5cfb896a9cea26a1dcd9cc61d18d4 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 20 Jul 2026 21:48:12 +0100 Subject: [PATCH 3/4] fix(cortexide): Phase 0 Sprint 1 stabilization (#8, #54, #45) Restore menubar dropdowns, wire Add File to Chat to CortexIDE staging, and prevent duplicate native tool payloads for local inference. Includes URI.revive storage reviver, UI polish, and unit tests. Co-authored-by: Cursor --- .../contrib/cortexide/browser/actionIDs.ts | 2 + .../cortexide/browser/chatThreadService.ts | 38 +------- .../browser/convertToLLMMessageService.ts | 5 +- .../cortexide/browser/media/cortexide.css | 12 ++- .../react/src/sidebar-tsx/SidebarChat.tsx | 2 +- .../cortexide/browser/react/src/styles.css | 7 +- .../cortexide/browser/sidebarActions.ts | 95 ++++++++++++++++++- .../cortexide/common/attachFileToChat.ts | 38 ++++++++ .../common/chatThreadStorageReviver.ts | 39 ++++++++ .../cortexide/common/providerToolFormat.ts | 13 +++ .../llmMessage/sendLLMMessage.impl.ts | 19 ++-- .../test/common/attachFileToChat.test.ts | 43 +++++++++ .../common/chatThreadStorageReviver.test.ts | 52 ++++++++++ .../test/common/providerToolFormat.test.ts | 16 +++- 14 files changed, 327 insertions(+), 54 deletions(-) create mode 100644 src/vs/workbench/contrib/cortexide/common/attachFileToChat.ts create mode 100644 src/vs/workbench/contrib/cortexide/common/chatThreadStorageReviver.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/attachFileToChat.test.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/chatThreadStorageReviver.test.ts diff --git a/src/vs/workbench/contrib/cortexide/browser/actionIDs.ts b/src/vs/workbench/contrib/cortexide/browser/actionIDs.ts index eb8f414fa79e..89f009230785 100644 --- a/src/vs/workbench/contrib/cortexide/browser/actionIDs.ts +++ b/src/vs/workbench/contrib/cortexide/browser/actionIDs.ts @@ -5,6 +5,8 @@ // Normally you'd want to put these exports in the files that register them, but if you do that you'll get an import order error if you import them in certain cases. // (importing them runs the whole file to get the ID, causing an import error). I guess it's best practice to separate out IDs, pretty annoying... +export const CORTEXIDE_ATTACH_FILE_TO_CHAT_ACTION_ID = 'cortexide.attachFileToChat'; + export const CORTEXIDE_CTRL_L_ACTION_ID = 'cortexide.ctrlLAction'; export const CORTEXIDE_CTRL_K_ACTION_ID = 'cortexide.ctrlKAction'; diff --git a/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts b/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts index 6fa73e056aea..3d6894faebe7 100644 --- a/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/chatThreadService.ts @@ -7,8 +7,9 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; - import { URI } from '../../../../base/common/uri.js'; + +import { parseChatThreadsFromStorage } from '../common/chatThreadStorageReviver.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; import { chat_userMessageContent, isABuiltinToolName, builtinToolNames, localToolsetFor, READ_ONLY_SUBAGENT_TOOLS } from '../common/prompt/prompts.js'; @@ -559,40 +560,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { // !!! this is important for properly restoring URIs and images from storage // should probably re-use code from void/src/vs/base/common/marshalling.ts instead. but this is simple enough private _convertThreadDataFromStorage(threadsStr: string): ChatThreads { - return JSON.parse(threadsStr, (key, value) => { - if (value && typeof value === 'object' && value.$mid === 1) { // $mid is the MarshalledId. $mid === 1 means it is a URI - return URI.from(value); // TODO URI.revive instead of this? - } - // Restore Uint8Array from base64 string for image data - // Only process 'data' keys that are directly under image attachment objects - // Check key === 'data' to match image attachment structure - if (key === 'data') { - if (typeof value === 'string' && value.startsWith('__base64__:')) { - // Handle base64 string format (the normal case) - try { - const base64 = value.substring(11); // Remove '__base64__:' prefix - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - } catch (e) { - console.error('Failed to decode base64 image data in storage reviver', e); - return value; // Return original value on error - } - } else if (Array.isArray(value)) { - // Handle case where it's already an array but not Uint8Array - // Only convert if it looks like byte data (all numbers 0-255) - if (value.length > 0 && value.every((v: any) => typeof v === 'number' && v >= 0 && v <= 255)) { - return new Uint8Array(value as number[]); - } - } - // For objects, don't try to convert here - let it be handled later if needed - // This prevents infinite recursion and unexpected conversions - } - return value; - }); + return parseChatThreadsFromStorage(threadsStr); } private _readAllThreads(): ChatThreads | null { diff --git a/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts b/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts index 1358baa962b5..640ac7036af8 100644 --- a/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/convertToLLMMessageService.ts @@ -54,6 +54,7 @@ function uint8ArrayToBase64(data: Uint8Array): string { } } import { getIsReasoningEnabledState, getReservedOutputTokenSpace, getModelCapabilities } from '../common/modelCapabilities.js'; +import { effectiveSpecialToolFormat } from '../common/providerToolFormat.js'; import { reParsedToolXMLString, chat_systemMessage, chat_systemMessage_local } from '../common/prompt/prompts.js'; import { isCapableLocalModel } from '../common/routing/codingModelScore.js'; import { AnthropicLLMChatMessage, AnthropicReasoning, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMessage, OpenAILLMChatMessage, RawToolParamsObj } from '../common/sendLLMMessageTypes.js'; @@ -1488,7 +1489,7 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess systemMessage: enrichedSystemMessage, aiInstructions, supportsSystemMessage, - specialToolFormat, + specialToolFormat: effectiveSpecialToolFormat(specialToolFormat, isLocal), supportsAnthropicReasoning: providerName === 'anthropic', contextWindow: effectiveContextWindow, reservedOutputTokenSpace: effectiveReservedOutput, @@ -1795,7 +1796,7 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess // Local providers don't actually return native tool_calls (the calls arrive as XML/JSON text), // so encode prior tool turns with the XML/text format to stay consistent with the system prompt // + parser — otherwise turn 2+ of the agent loop loses all prior tool context (finding #8). - specialToolFormat: isLocalProviderForContext ? undefined : specialToolFormat, + specialToolFormat: effectiveSpecialToolFormat(specialToolFormat, isLocalProviderForContext), supportsAnthropicReasoning: validProviderName === 'anthropic', contextWindow, reservedOutputTokenSpace, diff --git a/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css b/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css index 5e0998bf8591..1a8001025de4 100644 --- a/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css +++ b/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css @@ -179,13 +179,19 @@ body.monaco-workbench, box-shadow: var(--cortex-shadow-hairline); } -/* Disable backdrop-filter on Windows title bar - causes stacking context bugs - * that prevent menubar dropdowns from rendering above the title bar. */ -.monaco-workbench.windows .part.titlebar { +/* backdrop-filter on the title bar creates a stacking context that clips menubar + * dropdowns on every platform (issues #8, #68). Keep blur on the status bar only. */ +.monaco-workbench .part.titlebar { backdrop-filter: none; -webkit-backdrop-filter: none; } +/* Ensure menubar / context menus render above the custom title bar chrome. */ +.monaco-workbench .monaco-menu-container, +.monaco-workbench .context-view.monaco-menu-container { + z-index: 2500; +} + .monaco-workbench .part.sidebar > .content .pane-header, .monaco-workbench .part.auxiliarybar > .content .pane-header { padding: var(--cortex-space-sm) var(--cortex-space-md) !important; diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx index 55a9ecc77723..f170a61f8d65 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -1024,7 +1024,7 @@ export const SelectedFiles = ( } {selection.type === 'File' && selection.state.wasAddedAsCurrentFile && messageIdx === undefined && currentURI?.fsPath === selection.uri.fsPath ? - + {`(Current File)`} : null diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/styles.css b/src/vs/workbench/contrib/cortexide/browser/react/src/styles.css index 4d4999611c43..ddc2e893ebf4 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/styles.css +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/styles.css @@ -17,7 +17,8 @@ color: var(--void-fg-3); } -* { +/* Suppress default outlines inside CortexIDE React UI, but preserve keyboard focus rings. */ +.void-scope *:not(:focus-visible) { outline: none !important; } @@ -40,7 +41,7 @@ /* generic focus ring for keyboard users */ .void-focus-ring:focus-visible { - outline: 1px solid var(--void-ring-color); + outline: 1px solid var(--void-ring-color) !important; outline-offset: 1px; } @@ -136,12 +137,14 @@ } .monaco-editor .error-detection-glyph-error::before { + // allow-any-unicode-next-line content: '❌'; font-size: 14px; color: var(--vscode-errorForeground); } .monaco-editor .error-detection-glyph-warning::before { + // allow-any-unicode-next-line content: '⚠️'; font-size: 14px; color: var(--vscode-warningForeground); diff --git a/src/vs/workbench/contrib/cortexide/browser/sidebarActions.ts b/src/vs/workbench/contrib/cortexide/browser/sidebarActions.ts index d5a299c2d3dd..f718b13a8ec0 100644 --- a/src/vs/workbench/contrib/cortexide/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/cortexide/browser/sidebarActions.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------*/ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; - +import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; @@ -14,15 +16,22 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { IRange } from '../../../../editor/common/core/range.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { CORTEXIDE_VIEW_CONTAINER_ID, CORTEXIDE_VIEW_ID } from './sidebarPane.js'; import { IMetricsService } from '../common/metricsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { CORTEXIDE_TOGGLE_SETTINGS_ACTION_ID } from './cortexideSettingsPane.js'; -import { CORTEXIDE_CTRL_L_ACTION_ID } from './actionIDs.js'; +import { CORTEXIDE_ATTACH_FILE_TO_CHAT_ACTION_ID, CORTEXIDE_CTRL_L_ACTION_ID } from './actionIDs.js'; import { localize2 } from '../../../../nls.js'; import { IChatThreadService } from './chatThreadService.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { ResourceContextKey } from '../../../common/contextkeys.js'; +import { ExplorerFolderContext } from '../../files/common/files.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../common/editor.js'; +import { collectAttachableUris } from '../common/attachFileToChat.js'; // ---------- Register commands and keybindings ---------- @@ -348,6 +357,88 @@ registerAction2(class extends Action2 { } }) +const getAttachableUrisFromArgs = (accessor: ServicesAccessor, ...args: unknown[]): URI[] => { + const editorService = accessor.get(IEditorService) + const activeUri = EditorResourceAccessor.getCanonicalUri(editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }) + return collectAttachableUris(args, activeUri) +} + +// Explorer / editor context menu: attach a file to the CortexIDE chat input (issue #54). +// Upstream "Add File to Chat" targets the hidden built-in VS Code chat; this wires the same menus +// to CortexIDE's staging selections instead. +registerAction2(class extends Action2 { + constructor() { + super({ + id: CORTEXIDE_ATTACH_FILE_TO_CHAT_ACTION_ID, + title: localize2('cortexideAttachFileToChat', 'Add File to Chat'), + icon: Codicon.attach, + f1: true, + menu: [{ + id: MenuId.ExplorerContext, + group: '5_chat', + order: 1, + when: ContextKeyExpr.and( + ExplorerFolderContext.negate(), + ContextKeyExpr.or( + ResourceContextKey.Scheme.isEqualTo(Schemas.file), + ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote), + ), + ), + }, { + id: MenuId.EditorTitleContext, + group: '2_chat', + order: 1, + when: ContextKeyExpr.or( + ResourceContextKey.Scheme.isEqualTo(Schemas.file), + ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote), + ), + }, { + id: MenuId.EditorContext, + group: '1_chat', + order: 2, + when: ContextKeyExpr.and( + EditorContextKeys.hasNonEmptySelection.negate(), + ContextKeyExpr.or( + ResourceContextKey.Scheme.isEqualTo(Schemas.file), + ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote), + ResourceContextKey.Scheme.isEqualTo(Schemas.untitled), + ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeUserData), + ), + ), + }], + }) + } + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const commandService = accessor.get(ICommandService) + const viewsService = accessor.get(IViewsService) + const chatThreadService = accessor.get(IChatThreadService) + const languageService = accessor.get(ILanguageService) + const metricsService = accessor.get(IMetricsService) + + const files = getAttachableUrisFromArgs(accessor, ...args) + if (!files.length) { + return + } + + metricsService.capture('Attach File to Chat', { count: files.length }) + + if (!viewsService.isViewContainerVisible(CORTEXIDE_VIEW_CONTAINER_ID)) { + await commandService.executeCommand(CORTEXIDE_OPEN_SIDEBAR_ACTION_ID) + } + + for (const uri of files) { + chatThreadService.addNewStagingSelection({ + type: 'File', + uri, + language: languageService.guessLanguageIdByFilepathOrFirstLine(uri) ?? 'plaintext', + state: { wasAddedAsCurrentFile: false }, + }) + } + + await chatThreadService.focusCurrentChat() + } +}) + diff --git a/src/vs/workbench/contrib/cortexide/common/attachFileToChat.ts b/src/vs/workbench/contrib/cortexide/common/attachFileToChat.ts new file mode 100644 index 000000000000..cab3eeda149e --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/common/attachFileToChat.ts @@ -0,0 +1,38 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; + +/** URI schemes the CortexIDE "Add File to Chat" action accepts. */ +export const SUPPORTED_ATTACH_SCHEMES = new Set([ + Schemas.file, + Schemas.vscodeRemote, + Schemas.untitled, + Schemas.vscodeUserData, +]); + +/** + * Collect file URIs to attach from action arguments and an optional active-editor fallback. + * Pure helper (issue #54) — tested in test/common/attachFileToChat.test.ts. + */ +export const collectAttachableUris = ( + args: unknown[], + activeEditorUri: URI | undefined, +): URI[] => { + const uris: URI[] = []; + for (const arg of args) { + if (URI.isUri(arg) && SUPPORTED_ATTACH_SCHEMES.has(arg.scheme)) { + uris.push(arg); + } + } + if (uris.length) { + return uris; + } + if (activeEditorUri && SUPPORTED_ATTACH_SCHEMES.has(activeEditorUri.scheme)) { + return [activeEditorUri]; + } + return []; +}; diff --git a/src/vs/workbench/contrib/cortexide/common/chatThreadStorageReviver.ts b/src/vs/workbench/contrib/cortexide/common/chatThreadStorageReviver.ts new file mode 100644 index 000000000000..1e2244aa21b5 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/common/chatThreadStorageReviver.ts @@ -0,0 +1,39 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { MarshalledId } from '../../../../base/common/marshallingIds.js'; +import { URI } from '../../../../base/common/uri.js'; + +/** + * JSON.parse reviver for persisted chat threads. Restores marshalled URIs via URI.revive + * (not URI.from — revive preserves VS Code URI metadata correctly) and image byte payloads. + */ +export const reviveChatThreadStorageValue = (key: string, value: unknown): unknown => { + if (value && typeof value === 'object' && (value as { $mid?: number }).$mid === MarshalledId.Uri) { + return URI.revive(value as Parameters[0]); + } + if (key === 'data') { + if (typeof value === 'string' && value.startsWith('__base64__:')) { + try { + const base64 = value.substring('__base64__:'.length); + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } catch { + return value; + } + } + if (Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === 'number' && v >= 0 && v <= 255)) { + return new Uint8Array(value as number[]); + } + } + return value; +}; + +export const parseChatThreadsFromStorage = (threadsStr: string): T => + JSON.parse(threadsStr, reviveChatThreadStorageValue) as T; diff --git a/src/vs/workbench/contrib/cortexide/common/providerToolFormat.ts b/src/vs/workbench/contrib/cortexide/common/providerToolFormat.ts index 8bd50de08749..bb6af34843d7 100644 --- a/src/vs/workbench/contrib/cortexide/common/providerToolFormat.ts +++ b/src/vs/workbench/contrib/cortexide/common/providerToolFormat.ts @@ -111,6 +111,19 @@ export const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => { } } +export type SpecialToolFormat = 'openai-style' | 'anthropic-style' | 'gemini-style' | undefined + +/** + * Local inference (Ollama, vLLM, LM Studio, loopback openAICompatible) parses tool calls from + * XML/JSON text in the message body — never via native provider tool APIs. When both XML tool + * definitions (system prompt) and a native `tools[]` array are sent, servers like llama.cpp reject + * the request (issue #45). Force XML/text mode by clearing specialToolFormat for local providers. + */ +export const effectiveSpecialToolFormat = ( + specialToolFormat: SpecialToolFormat, + isLocalInference: boolean, +): SpecialToolFormat => isLocalInference ? undefined : specialToolFormat + /** * The running accumulator for an OpenAI-compatible streaming chat response: text, reasoning, and the * single tool call (name / args-JSON-string / id) assembled across deltas. The OpenAI streaming diff --git a/src/vs/workbench/contrib/cortexide/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/cortexide/electron-main/llmMessage/sendLLMMessage.impl.ts index cfd339b580bd..25434ca4e7fa 100644 --- a/src/vs/workbench/contrib/cortexide/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/cortexide/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -15,7 +15,7 @@ import { GoogleAuth } from 'google-auth-library' /* eslint-enable */ import { GeminiLLMChatMessage, LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText, RawToolCallObj } from '../../common/sendLLMMessageTypes.js'; -import { rawToolCallObjOfParamsStr, buildRawToolCallObj, sanitizeOpenAIMessagesForEmptyContent, toOpenAICompatibleTool, accumulateOpenAIChatDelta, buildTypedToolProperties, extractToolCallFromNonStreamingChoice, reduceGeminiChunk, finalizeGeminiToolId } from '../../common/providerToolFormat.js'; +import { rawToolCallObjOfParamsStr, buildRawToolCallObj, sanitizeOpenAIMessagesForEmptyContent, toOpenAICompatibleTool, accumulateOpenAIChatDelta, buildTypedToolProperties, extractToolCallFromNonStreamingChoice, reduceGeminiChunk, finalizeGeminiToolId, effectiveSpecialToolFormat } from '../../common/providerToolFormat.js'; import { formatGeminiRateLimitError } from '../../common/providerErrorFormat.js'; import { ChatMode, displayInfoOfProviderName, FeatureName, ModelSelectionOptions, OverridesOfModel, ProviderName, SettingsOfProvider } from '../../common/cortexideSettingsTypes.js'; import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities, defaultProviderSettings, getReservedOutputTokenSpace } from '../../common/modelCapabilities.js'; @@ -452,6 +452,13 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE additionalOpenAIPayload, } = getModelCapabilities(providerName, modelName_, overridesOfModel) + // Detect local inference early — local models use XML/JSON tool text, not native tool APIs. + const isExplicitLocalProviderChat = providerName === 'ollama' || providerName === 'vLLM' || providerName === 'lmStudio' + const isLocalhostEndpointChat = (providerName === 'openAICompatible' || providerName === 'liteLLM') + && isLoopbackEndpoint(settingsOfProvider[providerName]?.endpoint) + const isLocalChat = isExplicitLocalProviderChat || isLocalhostEndpointChat + const toolFormat = effectiveSpecialToolFormat(specialToolFormat, isLocalChat) + // APIs like Vertex/Pollinations require non-empty content except for the optional final assistant message const messagesToSend = sanitizeOpenAIMessagesForEmptyContent(messages) @@ -468,7 +475,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE // tools const potentialTools = openAITools(chatMode, mcpTools) - const nativeToolsObj = potentialTools && specialToolFormat === 'openai-style' ? + const nativeToolsObj = potentialTools && toolFormat === 'openai-style' ? { tools: potentialTools } as const : {} @@ -489,7 +496,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE } // manually parse out tool results if XML - if (!specialToolFormat) { + if (!toolFormat) { const { newOnText, newOnFinalMessage } = extractXMLToolsWrapper(onText, onFinalMessage, chatMode, mcpTools) onText = newOnText onFinalMessage = newOnFinalMessage @@ -504,11 +511,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE let isRetrying = false // Flag to prevent processing streaming chunks during retry let timeoutDeliveredPartial = false // Set when stall timeout fires with partial; outer catch skips onError - // Detect if this is a local provider for timeout optimization - const isExplicitLocalProviderChat = providerName === 'ollama' || providerName === 'vLLM' || providerName === 'lmStudio' - const isLocalhostEndpointChat = (providerName === 'openAICompatible' || providerName === 'liteLLM') - && isLoopbackEndpoint(settingsOfProvider[providerName]?.endpoint) - const isLocalChat = isExplicitLocalProviderChat || isLocalhostEndpointChat + // isLocalChat computed above for tool-format routing; reused for timeout tuning below. // Helper function to process streaming response const processStreamingResponse = async (response: any) => { diff --git a/src/vs/workbench/contrib/cortexide/test/common/attachFileToChat.test.ts b/src/vs/workbench/contrib/cortexide/test/common/attachFileToChat.test.ts new file mode 100644 index 000000000000..cf87d0a400a2 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/attachFileToChat.test.ts @@ -0,0 +1,43 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { URI } from '../../../../../base/common/uri.js'; +import { collectAttachableUris, SUPPORTED_ATTACH_SCHEMES } from '../../common/attachFileToChat.js'; + +suite('attachFileToChat', () => { + test('collectAttachableUris returns file URIs from action args', () => { + const file = URI.file('/workspace/src/app.ts'); + const result = collectAttachableUris([file], undefined); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].toString(), file.toString()); + }); + + test('collectAttachableUris ignores unsupported schemes in args', () => { + const http = URI.parse('https://example.com/a.ts'); + assert.strictEqual(collectAttachableUris([http], undefined).length, 0); + }); + + test('collectAttachableUris falls back to active editor URI', () => { + const active = URI.file('/workspace/readme.md'); + const result = collectAttachableUris([], active); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].fsPath, active.fsPath); + }); + + test('collectAttachableUris prefers explicit args over active editor', () => { + const arg = URI.file('/workspace/a.ts'); + const active = URI.file('/workspace/b.ts'); + const result = collectAttachableUris([arg], active); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].fsPath, arg.fsPath); + }); + + test('SUPPORTED_ATTACH_SCHEMES includes file and untitled', () => { + assert.ok(SUPPORTED_ATTACH_SCHEMES.has('file')); + assert.ok(SUPPORTED_ATTACH_SCHEMES.has('untitled')); + }); +}); diff --git a/src/vs/workbench/contrib/cortexide/test/common/chatThreadStorageReviver.test.ts b/src/vs/workbench/contrib/cortexide/test/common/chatThreadStorageReviver.test.ts new file mode 100644 index 000000000000..e8221d2487a3 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/chatThreadStorageReviver.test.ts @@ -0,0 +1,52 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { URI } from '../../../../../base/common/uri.js'; +import { parseChatThreadsFromStorage, reviveChatThreadStorageValue } from '../../common/chatThreadStorageReviver.js'; + +suite('chatThreadStorageReviver', () => { + test('reviveChatThreadStorageValue restores marshalled URIs via URI.revive', () => { + const uri = URI.file('/workspace/foo.ts'); + const marshalled = uri.toJSON(); + const revived = reviveChatThreadStorageValue('uri', marshalled); + assert.ok(revived instanceof URI); + assert.strictEqual((revived as URI).fsPath, uri.fsPath); + }); + + test('reviveChatThreadStorageValue decodes base64 image data', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const b64 = `__base64__:${btoa(String.fromCharCode(...bytes))}`; + const revived = reviveChatThreadStorageValue('data', b64); + assert.ok(revived instanceof Uint8Array); + assert.deepStrictEqual(Array.from(revived as Uint8Array), Array.from(bytes)); + }); + + test('parseChatThreadsFromStorage round-trips staging file URIs', () => { + const uri = URI.file('/project/src/main.ts'); + const payload = { + thread1: { + id: 'thread1', + createdAt: 1, + lastModified: 1, + messages: [], + state: { + stagingSelections: [{ + type: 'File', + uri: uri.toJSON(), + language: 'typescript', + state: { wasAddedAsCurrentFile: false }, + }], + isBeingEdited: false, + }, + }, + }; + const parsed = parseChatThreadsFromStorage(JSON.stringify(payload)); + const selection = parsed.thread1.state.stagingSelections[0]; + assert.ok(selection.uri instanceof URI); + assert.strictEqual(selection.uri.fsPath, uri.fsPath); + }); +}); diff --git a/src/vs/workbench/contrib/cortexide/test/common/providerToolFormat.test.ts b/src/vs/workbench/contrib/cortexide/test/common/providerToolFormat.test.ts index 6ba9b7293b4d..fe978c5fc5af 100644 --- a/src/vs/workbench/contrib/cortexide/test/common/providerToolFormat.test.ts +++ b/src/vs/workbench/contrib/cortexide/test/common/providerToolFormat.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { suite, test } from 'mocha'; -import { buildRawToolCallObj, rawToolCallObjOfParamsStr, sanitizeOpenAIMessagesForEmptyContent as _sanitize, EMPTY_CONTENT_PLACEHOLDER, toOpenAICompatibleTool, accumulateOpenAIChatDelta, OpenAIChatAccumulator, OpenAIStreamDelta } from '../../common/providerToolFormat.js'; +import { buildRawToolCallObj, rawToolCallObjOfParamsStr, sanitizeOpenAIMessagesForEmptyContent as _sanitize, EMPTY_CONTENT_PLACEHOLDER, toOpenAICompatibleTool, accumulateOpenAIChatDelta, OpenAIChatAccumulator, OpenAIStreamDelta, effectiveSpecialToolFormat } from '../../common/providerToolFormat.js'; import { LLMChatMessage } from '../../common/sendLLMMessageTypes.js'; import type { InternalToolInfo } from '../../common/prompt/prompts.js'; @@ -13,6 +13,20 @@ const m = (o: unknown): LLMChatMessage => o as LLMChatMessage; // LLMChatMessage is a union (the Gemini variant has `parts`, not `content`); return any[] so tests can read `.content`. const sanitizeOpenAIMessagesForEmptyContent = (msgs: LLMChatMessage[]): any[] => _sanitize(msgs); +suite('effectiveSpecialToolFormat', () => { + test('local inference clears native tool format (XML/text mode only)', () => { + assert.strictEqual(effectiveSpecialToolFormat('openai-style', true), undefined); + assert.strictEqual(effectiveSpecialToolFormat('anthropic-style', true), undefined); + }); + test('cloud inference keeps native tool format', () => { + assert.strictEqual(effectiveSpecialToolFormat('openai-style', false), 'openai-style'); + assert.strictEqual(effectiveSpecialToolFormat(undefined, false), undefined); + }); + test('loopback local inference always uses XML/text tool mode', () => { + assert.strictEqual(effectiveSpecialToolFormat('openai-style', true), undefined); + }); +}); + suite('buildRawToolCallObj', () => { test('object args -> RawToolCallObj with id/name/rawParams/doneParams/isDone', () => { assert.deepStrictEqual(buildRawToolCallObj('id1', 'read_file', { uri: '/a', line: '3' }), { From 961f54cccff2d4b4c2855e61cdb0bb32ac5f4604 Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Mon, 20 Jul 2026 22:05:37 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(cortexide):=20Phase=200=20Sprint=202=20?= =?UTF-8?q?=E2=80=94=20remaining=20open=20issues=20(#12=E2=80=93#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix Void install path conflicts, llama-server onboarding, Ollama IPC freeze, theme scoping, v0 multimodal routing, autocomplete defaults, and onboarding UI. Includes provider validation helpers and unit tests. Co-authored-by: Cursor --- .../cortexide/browser/autocompleteService.ts | 4 +- .../browser/extensionTransferService.ts | 60 ++++++++++--------- .../cortexide/browser/media/cortexide.css | 46 +++++++++----- .../browser/ollamaEmbeddingProviderService.ts | 5 +- .../src/onboarding/ExpressOnboardingFlow.tsx | 3 + .../react/src/onboarding/VoidOnboarding.tsx | 41 +++++++++---- .../cortexide/browser/react/src/styles.css | 4 +- .../common/cortexideSettingsService.ts | 5 +- .../common/extensionTransferPaths.ts | 8 +++ .../cortexide/common/localSetupService.ts | 28 ++++++++- .../cortexide/common/modelCapabilities.ts | 13 ++++ .../cortexide/common/onboardingHelpers.ts | 58 ++++++++++++++++++ .../common/providerSettingsValidation.ts | 40 +++++++++++++ .../cortexide/common/sendLLMMessageService.ts | 17 ++++-- .../test/common/modelCapabilities.test.ts | 8 +++ .../test/common/onboardingHelpers.test.ts | 22 +++++++ .../common/providerSettingsValidation.test.ts | 47 +++++++++++++++ 17 files changed, 341 insertions(+), 68 deletions(-) create mode 100644 src/vs/workbench/contrib/cortexide/common/extensionTransferPaths.ts create mode 100644 src/vs/workbench/contrib/cortexide/common/onboardingHelpers.ts create mode 100644 src/vs/workbench/contrib/cortexide/common/providerSettingsValidation.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/onboardingHelpers.test.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/providerSettingsValidation.test.ts diff --git a/src/vs/workbench/contrib/cortexide/browser/autocompleteService.ts b/src/vs/workbench/contrib/cortexide/browser/autocompleteService.ts index bf00e42725be..d2af05cc2595 100644 --- a/src/vs/workbench/contrib/cortexide/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/autocompleteService.ts @@ -269,7 +269,9 @@ const filterNonCodeContent = (text: string, languageId?: string): string => { filteredLines.push(line); } - return filteredLines.join('\n'); + const result = filteredLines.join('\n'); + // If filtering removed everything, keep the raw model output (issue #27). + return result || text; }; // postprocesses the result diff --git a/src/vs/workbench/contrib/cortexide/browser/extensionTransferService.ts b/src/vs/workbench/contrib/cortexide/browser/extensionTransferService.ts index b8843e98b96c..4e9042a6a64f 100644 --- a/src/vs/workbench/contrib/cortexide/browser/extensionTransferService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/extensionTransferService.ts @@ -11,6 +11,7 @@ import { IFileService } from '../../../../platform/files/common/files.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { TransferEditorType, TransferFilesInfo } from './extensionTransferTypes.js'; +import { CORTEXIDE_APP_DATA_DIR, CORTEXIDE_DATA_FOLDER } from '../common/extensionTransferPaths.js'; export interface IExtensionTransferService { @@ -37,11 +38,12 @@ const extensionBlacklist = [ 'codeium.codeium', 'saoudrizwan.claude-dev', // cline 'rooveterinaryinc.roo-cline', // roo - 'supermaven.supermaven' // supermaven + 'supermaven.supermaven', // supermaven + 'void.void', + 'void.voidai', // 'github.copilot', ]; - const isBlacklisted = (fsPath: string | undefined) => { return extensionBlacklist.find(bItem => fsPath?.includes(bItem)) } @@ -195,37 +197,37 @@ const transferTheseFilesOfOS = (os: 'mac' | 'windows' | 'linux' | null, fromEdit if (fromEditor === 'VS Code') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Code', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Code', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.vscode', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } else if (fromEditor === 'Cursor') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Cursor', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.cursor', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } else if (fromEditor === 'Windsurf') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Windsurf', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Windsurf', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, 'Library', 'Application Support', CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.windsurf', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } @@ -238,37 +240,37 @@ const transferTheseFilesOfOS = (os: 'mac' | 'windows' | 'linux' | null, fromEdit if (fromEditor === 'VS Code') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Code', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Code', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.vscode', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } else if (fromEditor === 'Cursor') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Cursor', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Cursor', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.cursor', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } else if (fromEditor === 'Windsurf') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Windsurf', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Windsurf', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.config', CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.windsurf', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), homeDir, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } @@ -283,37 +285,37 @@ const transferTheseFilesOfOS = (os: 'mac' | 'windows' | 'linux' | null, fromEdit if (fromEditor === 'VS Code') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Code', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Code', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, '.vscode', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } else if (fromEditor === 'Cursor') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Cursor', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Cursor', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, '.cursor', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } else if (fromEditor === 'Windsurf') { return [{ from: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Windsurf', 'User', 'settings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Void', 'User', 'settings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, CORTEXIDE_APP_DATA_DIR, 'User', 'settings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Windsurf', 'User', 'keybindings.json'), - to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, 'Void', 'User', 'keybindings.json'), + to: URI.joinPath(URI.from({ scheme: 'file' }), appdata, CORTEXIDE_APP_DATA_DIR, 'User', 'keybindings.json'), }, { from: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, '.windsurf', 'extensions'), - to: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, '.void-editor', 'extensions'), + to: URI.joinPath(URI.from({ scheme: 'file' }), userprofile, CORTEXIDE_DATA_FOLDER, 'extensions'), isExtensions: true, }] } diff --git a/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css b/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css index 1a8001025de4..666b1294b586 100644 --- a/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css +++ b/src/vs/workbench/contrib/cortexide/browser/media/cortexide.css @@ -15,7 +15,9 @@ * or via a theme extension. All values below are defaults only. * =========================================================================== */ -:root { +.void-scope, +.cortex-onboarding-root, +.monaco-workbench .part.auxiliarybar > .content { /* -- Core surfaces (dark theme - light overrides below) ----------------- */ /* Stepped from near-black to visible dark grey so depth reads clearly. */ --cortex-surface-0: #0a0a0d; /* deepest background (onboarding overlay) */ @@ -430,11 +432,11 @@ body.monaco-workbench, * Rounded, blurred, elevated above the editor surface. * =========================================================================== */ .monaco-workbench .quick-input-widget { - background: color-mix(in srgb, var(--cortex-surface-2) 94%, #000008 6%) !important; - color: var(--cortex-text-base) !important; + background: var(--vscode-quickInput-background, var(--vscode-editorWidget-background)) !important; + color: var(--vscode-quickInput-foreground, var(--vscode-foreground)) !important; border-radius: 20px !important; - border: 1px solid var(--cortex-border-base) !important; - box-shadow: var(--cortex-shadow-soft), inset 0 0 0 1px rgba(255, 255, 255, 0.03) !important; + border: 1px solid var(--vscode-widget-border, var(--vscode-input-border)) !important; + box-shadow: var(--vscode-widget-shadow, 0 8px 32px rgba(0, 0, 0, 0.35)) !important; backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); transform: none !important; @@ -449,20 +451,20 @@ body.monaco-workbench, .monaco-workbench .quick-input-widget .quick-input-header .monaco-inputbox, .monaco-workbench .quick-input-widget .monaco-inputbox { - background: color-mix(in srgb, var(--cortex-surface-1) 92%, #000005 8%) !important; - border: 1px solid var(--cortex-border-weak) !important; + background: var(--vscode-input-background) !important; + border: 1px solid var(--vscode-input-border) !important; border-radius: 12px !important; transition: border-color var(--cortex-transition-fast) !important; } .monaco-workbench .quick-input-widget .quick-input-header .monaco-inputbox:focus-within, .monaco-workbench .quick-input-widget .monaco-inputbox:focus-within { - border-color: var(--cortex-brand) !important; - box-shadow: 0 0 0 1px var(--cortex-brand-soft) !important; + border-color: var(--vscode-focusBorder) !important; + box-shadow: 0 0 0 1px var(--vscode-focusBorder) !important; } .monaco-workbench .quick-input-widget .monaco-inputbox input { background: transparent !important; - color: var(--cortex-text-base) !important; + color: var(--vscode-input-foreground) !important; } .monaco-workbench .quick-input-widget .quick-input-list { @@ -590,9 +592,13 @@ body.monaco-workbench, * correctly on a light background. All other CSS rules stay the same. * =========================================================================== */ -body.vscode-light :root, +body.vscode-light .void-scope, +body.vscode-light .cortex-onboarding-root, +body.vscode-light .monaco-workbench .part.auxiliarybar > .content, body.vscode-light, -.vscode-light :root, +.vscode-light .void-scope, +.vscode-light .cortex-onboarding-root, +.vscode-light .monaco-workbench .part.auxiliarybar > .content, .vscode-light { --cortex-surface-0: #ffffff; --cortex-surface-1: #f7f7fa; @@ -642,9 +648,13 @@ body.vscode-light, } /* High-contrast light theme */ -body.vscode-high-contrast-light :root, +body.vscode-high-contrast-light .void-scope, +body.vscode-high-contrast-light .cortex-onboarding-root, +body.vscode-high-contrast-light .monaco-workbench .part.auxiliarybar > .content, body.vscode-high-contrast-light, -.vscode-high-contrast-light :root, +.vscode-high-contrast-light .void-scope, +.vscode-high-contrast-light .cortex-onboarding-root, +.vscode-high-contrast-light .monaco-workbench .part.auxiliarybar > .content, .vscode-high-contrast-light { --cortex-surface-0: #ffffff; --cortex-surface-1: #f0f0f0; @@ -673,9 +683,13 @@ body.vscode-high-contrast-light, } /* High-contrast dark theme */ -body.vscode-high-contrast :root, +body.vscode-high-contrast .void-scope, +body.vscode-high-contrast .cortex-onboarding-root, +body.vscode-high-contrast .monaco-workbench .part.auxiliarybar > .content, body.vscode-high-contrast, -.vscode-high-contrast :root, +.vscode-high-contrast .void-scope, +.vscode-high-contrast .cortex-onboarding-root, +.vscode-high-contrast .monaco-workbench .part.auxiliarybar > .content, .vscode-high-contrast { --cortex-border-weak: #444444; --cortex-border-base: #666666; diff --git a/src/vs/workbench/contrib/cortexide/browser/ollamaEmbeddingProviderService.ts b/src/vs/workbench/contrib/cortexide/browser/ollamaEmbeddingProviderService.ts index fcb682ec98df..ff22174a905f 100644 --- a/src/vs/workbench/contrib/cortexide/browser/ollamaEmbeddingProviderService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/ollamaEmbeddingProviderService.ts @@ -38,7 +38,10 @@ export class OllamaEmbeddingProviderContribution extends Disposable implements I @ICortexideSettingsService private readonly _settingsService: ICortexideSettingsService, ) { super(); - void this._sync(); + // Defer embedding probe so Ollama IPC does not block the UI during startup (issue #12). + const deferMs = 8_000; + const deferHandle = setTimeout(() => { void this._sync(); }, deferMs); + this._register({ dispose: () => clearTimeout(deferHandle) }); // Re-evaluate when the embedding model setting changes... this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('cortexide.rag.embeddingModel')) { void this._sync(); } diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/ExpressOnboardingFlow.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/ExpressOnboardingFlow.tsx index 1c5d8aee8b1b..66fcde65fc12 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/ExpressOnboardingFlow.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/ExpressOnboardingFlow.tsx @@ -149,6 +149,9 @@ export const ExpressOnboardingFlow = ({ onCustomize, onDismiss }: ExpressOnboard // non-empty default.) settingsService.addModel('ollama', pack.tag); await settingsService.setModelSelectionOfFeature('Chat', { providerName: 'ollama', modelName: pack.tag }); + const fimTag = pack.tag.includes('coder') ? pack.tag : 'qwen2.5-coder:7b'; + settingsService.addModel('ollama', fimTag); + await settingsService.setModelSelectionOfFeature('Autocomplete', { providerName: 'ollama', modelName: fimTag }); settingsService.setGlobalSetting('isOnboardingComplete', true); setPhase('ready'); } catch (e) { diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx index 4a466f11cce5..ab8ce1b1f492 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/onboarding/VoidOnboarding.tsx @@ -18,10 +18,11 @@ import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'; import { FileAccess } from '../../../../../../../base/common/network.js'; import { LocalSetupWizard } from './LocalSetupWizard.js'; import { ExpressOnboardingFlow } from './ExpressOnboardingFlow.js'; +import { applyLlamaServerPreset, tryAutoAssignAutocompleteModel, tryAutoAssignChatModel } from '../../../../common/onboardingHelpers.js'; const OVERRIDE_VALUE = false -const getHeroLogoUri = () => FileAccess.asBrowserUri('vs/workbench/browser/media/cortexide-main.png').toString(true) +const getHeroLogoUri = () => FileAccess.asBrowserUri('vs/workbench/browser/media/code-icon.svg').toString(true) const welcomeHighlights = [ 'Chat + Quick Edit', @@ -72,14 +73,14 @@ export const VoidOnboarding = () => {
@@ -167,10 +168,12 @@ const cloudProviders: ProviderName[] = ['googleVertex', 'liteLLM', 'microsoftAzu const freeProviders: ProviderName[] = ['gemini', 'openRouter', 'pollinations', 'moonshot']; +const localTabProviders: ProviderName[] = [...localProviderNames, 'openAICompatible']; + // Data structures for provider tabs const providerNamesOfTab: Record = { Free: freeProviders, - Local: localProviderNames, + Local: localTabProviders, Paid: providerNames.filter(pn => !([...freeProviders, ...localProviderNames, ...cloudProviders] as string[]).includes(pn)) as ProviderName[], 'Cloud/Other': cloudProviders, }; @@ -194,6 +197,8 @@ const featureNameMap: { display: string, featureName: FeatureName }[] = [ const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setPageIndex: (index: number) => void }) => { const [currentTab, setCurrentTab] = useState('Free'); const settingsState = useSettingsState(); + const accessor = useAccessor(); + const settingsService = accessor.get('ICortexideSettingsService'); const [errorMessage, setErrorMessage] = useState(null); const [showLocalWizard, setShowLocalWizard] = useState(false); @@ -290,6 +295,19 @@ const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setP )} + {currentTab === 'Local' && !showLocalWizard && ( + + )} {currentTab === 'Local' && showLocalWizard && ( Local models auto-detect when possible. Add custom entries to fine tune routing.
)} - {currentTab === 'Local' && } + {currentTab === 'Local' && } {currentTab === 'Cloud/Other' && }
)} @@ -358,8 +376,11 @@ const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setP
setPageIndex(pageIndex - 1)} /> { - const isDisabled = isFeatureNameDisabled('Chat', settingsState) + onClick={async () => { + let state = settingsState; + state = await tryAutoAssignChatModel(settingsService, state); + await tryAutoAssignAutocompleteModel(settingsService, state); + const isDisabled = isFeatureNameDisabled('Chat', settingsService.state); if (!isDisabled) { setPageIndex(pageIndex + 1); setErrorMessage(null); @@ -596,8 +617,8 @@ const PrimaryActionButton = ({ children, className = '', ringSize, ...props }: {