diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.test.ts b/extensions/cli/src/stream/streamChatResponse.helpers.test.ts new file mode 100644 index 00000000000..3532a4abb06 --- /dev/null +++ b/extensions/cli/src/stream/streamChatResponse.helpers.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import type { ToolCall } from "../tools/types.js"; + +import { processToolCallDelta } from "./streamChatResponse.helpers.js"; + +function freshMaps() { + return { + toolCallsMap: new Map(), + indexToIdMap: new Map(), + }; +} + +describe("processToolCallDelta", () => { + it("creates an entry for an id-less delta carrying only an index (Ollama)", () => { + const { toolCallsMap, indexToIdMap } = freshMaps(); + + processToolCallDelta( + { index: 0, function: { name: "read_file", arguments: '{"filepath":' } }, + toolCallsMap, + indexToIdMap, + ); + + const entry = toolCallsMap.get("call_0"); + expect(entry).toBeDefined(); + expect(entry?.name).toBe("read_file"); + expect(indexToIdMap.get(0)).toBe("call_0"); + }); + + it("accumulates later id-less fragments for the same index", () => { + const { toolCallsMap, indexToIdMap } = freshMaps(); + + processToolCallDelta( + { index: 1, function: { name: "read_file", arguments: '{"filepath":' } }, + toolCallsMap, + indexToIdMap, + ); + processToolCallDelta( + { index: 1, function: { arguments: '"convex/schema.ts"}' } }, + toolCallsMap, + indexToIdMap, + ); + + expect(toolCallsMap.size).toBe(1); + expect(toolCallsMap.get("call_1")?.arguments).toEqual({ + filepath: "convex/schema.ts", + }); + }); + + it("keeps provider-supplied IDs untouched", () => { + const { toolCallsMap, indexToIdMap } = freshMaps(); + + processToolCallDelta( + { + id: "call_abc123", + index: 0, + function: { name: "read_file", arguments: '{"filepath":"x"}' }, + }, + toolCallsMap, + indexToIdMap, + ); + + expect(toolCallsMap.get("call_abc123")?.name).toBe("read_file"); + expect(toolCallsMap.has("call_0")).toBe(false); + expect(indexToIdMap.get(0)).toBe("call_abc123"); + }); + + it("still drops deltas with neither id nor index", () => { + const { toolCallsMap, indexToIdMap } = freshMaps(); + + processToolCallDelta( + { function: { name: "x" } }, + toolCallsMap, + indexToIdMap, + ); + + expect(toolCallsMap.size).toBe(0); + }); +}); diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index 776af67f554..ca5ce94262c 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -197,6 +197,14 @@ export function processToolCallDelta( } else if (toolCallDelta.index !== undefined) { // No ID, but we have an index - look up the ID from our map toolCallId = indexToIdMap.get(toolCallDelta.index); + if (!toolCallId) { + // Some providers (e.g. Ollama) stream tool-call deltas with an index + // but no id. Generate a stable synthetic ID so the entry is created on + // the first delta and later fragments accumulate instead of being + // dropped as plain assistant text. + toolCallId = `call_${toolCallDelta.index}`; + indexToIdMap.set(toolCallDelta.index, toolCallId); + } } if (!toolCallId) { diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 4712ebc4226..f3da5121d9a 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -14,6 +14,14 @@ async function dynamicImportAndActivate(context: vscode.ExtensionContext) { } export function activate(context: vscode.ExtensionContext) { + // Register viewLogs command early so it's available even if + // the dynamic import/activation fails. + context.subscriptions.push( + vscode.commands.registerCommand("continue.viewLogs", () => { + vscode.commands.executeCommand("workbench.action.toggleDevTools"); + }), + ); + return dynamicImportAndActivate(context).catch((e) => { console.log("Error activating extension: ", e); vscode.window diff --git a/packages/terminal-security/src/evaluateTerminalCommandSecurity.ts b/packages/terminal-security/src/evaluateTerminalCommandSecurity.ts index 693eef25f9c..d763f8e3b22 100644 --- a/packages/terminal-security/src/evaluateTerminalCommandSecurity.ts +++ b/packages/terminal-security/src/evaluateTerminalCommandSecurity.ts @@ -428,10 +428,17 @@ function isCriticalCommand(baseCommand: string, args: string[]): boolean { arg === "/etc" || arg === "/bin" || arg === "/sbin" || + arg === "/home" || + arg === "/root" || + arg === "$HOME" || arg.startsWith("/usr/") || arg.startsWith("/etc/") || arg.startsWith("/bin/") || - arg.startsWith("/sbin/"), + arg.startsWith("/sbin/") || + arg.startsWith("/home/") || + arg.startsWith("/root/") || + arg.startsWith("$HOME") || + arg.startsWith("${HOME}"), ); // If we have rm flags with dangerous paths, it's critical regardless of command @@ -457,6 +464,10 @@ function isCriticalCommand(baseCommand: string, args: string[]): boolean { "/usr/sbin/", "/lib/", "/lib64/", + "/home/", + "/root/", + "$HOME", + "${HOME}", ]; if (args.some((arg) => criticalPaths.some((path) => arg.includes(path)))) { return true; @@ -492,6 +503,24 @@ function isCriticalCommand(baseCommand: string, args: string[]): boolean { } } + // Find -delete (destructive file deletion via find) + if (baseCommand === "find" && args.some((arg) => arg === "-delete")) { + return true; + } + + // Destructive disk/file commands + if ( + baseCommand === "shred" || + baseCommand === "wipefs" + ) { + return true; + } + + // pkexec privilege escalation + if (baseCommand === "pkexec") { + return true; + } + // Privilege escalation const privEscCommands = ["sudo", "su", "doas", "runas", "gsudo", "psexec"]; if (privEscCommands.includes(baseCommand)) {