diff --git a/.vscodeignore b/.vscodeignore index 80abbf0..79a1bae 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,5 +1,9 @@ # ── Build / test output (only dist/ ships) ── +# Ship only the team-shared VS Code workspace files; everything else in .vscode stays out. .vscode/** +!.vscode/settings.json +!.vscode/launch.json +!.vscode/extensions.json .vscode-test/** out/** dist-tsc/** @@ -91,8 +95,18 @@ media/screenshot.png *.vsix *.log .DS_Store +# Environment/secret files at ANY depth (root .env entries above don't cover +# nested ones like .claude/.env or media/.env.local). .env .env.* +**/.env +**/.env.* +*.pem +*.key +**/*.pem +**/*.key +credentials*.json +**/credentials*.json **/*.ts.map **/tsconfig.json **/tslint.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c71d53..43a5db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to the PiLot Studio for VS Code extension will be documented in this file. +## [Unreleased] + +### Security + +- Replaced shell-string `execSync` interpolation with argv-array `execFileSync` / `node:fs` in `postinstall-patch.mjs`, `native-addons.ts`, and the VS Code download/clean-up scripts (command/shell injection). +- Escaped quotes in `MessageBubble`'s `escapeHtml`, stopped emitting inline `onclick` attributes, and tightened the webview CSP (no `unsafe-inline`, explicit `connect-src`) — closes the attribute-injection XSS vector. +- Removed the remote Google Fonts `@import` from the webview styles (blocked by CSP anyway; leaked a request per load). +- Constrained `@mention` file resolution in `session-resources.ts` to the workspace root (path traversal like `@../../etc/passwd`). +- Constrained `MOCHA_TEST_FILE` resolution in the test scaffold and sub-path imports in `loader.cjs` against escaping their intended roots. +- All webview `message` handlers (`ContextIndicator`, `PiPackagesPanel`, `SessionTree`, `VoiceCapture`) now validate `event.data` shape via a shared guard before trusting payloads. +- `message-serializer.ts` no longer trusts untyped upstream payload shapes (defensive parsing before serialization). + +### Fixed + +- `pi-binary.ts`: bare-name `pi` lookup now resolves again (spawned a shell builtin with `shell: false`); arg-env quoting hardened. +- `loader.cjs`: fixed the `@earendel-works` package-name typo, extracted doc URLs to constants, added timeouts to `which`/`where` probes, hooked `Module._resolveFilename` without leaking a process-wide duplicate hook, and resolved bare imports via the package `exports` map before guessing `dist/index.js`. +- `shell.ts`: Windows callers can no longer hit cmd.exe `shell: true` interpolation (quoting seam enforced). +- `verify.mjs` / `fallow-audit.mjs`: signal-terminated and spawn-failed runs are reported distinctly instead of collapsing into a generic exit code; `result.error` is no longer ignored (fail-open CI). +- `run-node-tests.mjs`, `runTest.ts`, test scaffold: watchdog/no-result paths now exit non-zero — no more green CI for failed or misconfigured runs; each test file gets a pid-suffixed tmpdir (parallel-safe) and VS Code discovery is cross-platform instead of hardcoded. +- `dl-tmp.mjs` / `dl-vscode.mjs`: top-level awaits are handled; VS Code version comes from env/config instead of a hardcoded 1.85.0. +- `run-clean.mjs`: removed the machine-specific `/home/lenovo/...` path; VS Code discovery matches `runTest.ts`. +- `session-manager.ts`: multi-session delete now settles per item, reporting which sessions failed and why, instead of failing the whole batch. +- `voice-manager.ts`: temp recording files are cleaned up and stdio listeners detached on stop/dispose. +- `MermaidDiagram`: render cache is capped with eviction; stale async renders can no longer overwrite a newer diagram. +- `MessageBubble`: markdown re-render effect no longer loops unboundedly; tool-call state is cleared on stream errors and new sessions in `App.svelte`. +- `PiPackagesPanel`: registry lookups are batched (no N+1 fan-out), stale responses are discarded, install log is capped, and the installing overlay can no longer deadlock. +- `Toast` timers are cleared on unmount and the stack is capped; `ContextIndicator` clamps percent to 0–100; `OnboardingTour` guards step bounds. +- Accessibility: tooltips wired via `aria-describedby`, `role="button"` handles Space, dialogs focusable; `prefers-reduced-motion` respected by `ActivityBar` and `SkeletonLoader` animations. +- Test mocks: `pi-sdk-mocks` Proxy is overrideable and `disposeCalls` tracks; `session-mock` no longer double-registers handlers or force-casts; `vscode-facade` fires listeners exactly once. +- Deduplicated `PiAgentConfig`/`ThinkingLevel`/`SessionNode` definitions and the cross-component `sendMessage` helper (shared in `webview/messages.ts`); native-addon ABI scan logic consolidated. +- `pnpm-workspace.yaml`: removed invalid `allowBuilds`/`minimumReleaseAgeExclude` keys; `.vscodeignore` no longer ships nested `.env`/secret files and keeps shared `.vscode` config; removed redundant tsconfig globs/excludes; removed dead `wrapperEl` in `HelpTooltip`. + ## [2.6.0] - 2026-09-18 ### Added diff --git a/dl-tmp.mjs b/dl-tmp.mjs index 5f4cf2a..727a287 100644 --- a/dl-tmp.mjs +++ b/dl-tmp.mjs @@ -1,3 +1,9 @@ import { downloadAndUnzipVSCode } from "@vscode/test-electron"; -const p = await downloadAndUnzipVSCode(); -console.log("VSCODE_PATH=" + p); + +try { + const p = await downloadAndUnzipVSCode(); + console.log("VSCODE_PATH=" + p); +} catch (err) { + console.error("dl-tmp: download failed:", err instanceof Error ? err.message : err); + process.exit(1); +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 14ac250..b68b9b4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,16 @@ overrides: 'fast-uri@<3.1.6': '3.1.6' # Security: qs DoS advisories (patched in 6.16.0, moderate) 'qs@<6.16.0': '6.16.0' + +# Dependencies allowed to run postinstall build scripts (pnpm v10+). +onlyBuiltDependencies: + - '@google/genai' + - '@vscode/vsce-sign' + - esbuild + - keytar + - koffi + - protobufjs + allowBuilds: '@google/genai': true '@vscode/vsce-sign': true @@ -12,31 +22,3 @@ allowBuilds: keytar: true koffi: true protobufjs: true -minimumReleaseAgeExclude: - - '@earendil-works/pi-agent-core@0.77.0' - - '@earendil-works/pi-ai@0.77.0' - - '@earendil-works/pi-coding-agent@0.77.0' - - '@earendil-works/pi-tui@0.77.0' - - '@mariozechner/clipboard-darwin-arm64@0.3.9' - - '@mariozechner/clipboard-darwin-universal@0.3.9' - - '@mariozechner/clipboard-darwin-x64@0.3.9' - - '@mariozechner/clipboard-linux-arm64-gnu@0.3.9' - - '@mariozechner/clipboard-linux-arm64-musl@0.3.9' - - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9' - - '@mariozechner/clipboard-linux-x64-gnu@0.3.9' - - '@mariozechner/clipboard-linux-x64-musl@0.3.9' - - '@mariozechner/clipboard-win32-arm64-msvc@0.3.9' - - '@mariozechner/clipboard-win32-x64-msvc@0.3.9' - - '@mariozechner/clipboard@0.3.9' - - '@typescript-eslint/eslint-plugin@8.60.1' - - '@typescript-eslint/parser@8.60.1' - - '@typescript-eslint/project-service@8.60.1' - - '@typescript-eslint/scope-manager@8.60.1' - - '@typescript-eslint/tsconfig-utils@8.60.1' - - '@typescript-eslint/type-utils@8.60.1' - - '@typescript-eslint/types@8.60.1' - - '@typescript-eslint/typescript-estree@8.60.1' - - '@typescript-eslint/utils@8.60.1' - - '@typescript-eslint/visitor-keys@8.60.1' - - svelte-check@4.5.0 - - typescript-eslint@8.60.1 diff --git a/run-clean.mjs b/run-clean.mjs index 4b93f5f..3308be9 100644 --- a/run-clean.mjs +++ b/run-clean.mjs @@ -1,3 +1,5 @@ +import { accessSync, readdirSync } from "node:fs"; +import { join } from "node:path"; import { spawn } from "node:child_process"; const blockPrefixes = [ @@ -16,12 +18,91 @@ for (const k of Object.keys(process.env)) { delete process.env[k]; } } -process.env.VSCODE_PATH = - "/home/lenovo/Applications/PiLot/.vscode-test/vscode-linux-x64-1.134.0/bin/code"; process.env.DISPLAY = process.env.DISPLAY || ":0"; +// VSCODE_PATH must come from the environment (CI) or be discovered the same way +// runTest.ts does it. The previous hardcoded /home/lenovo/... path only worked +// on one machine and silently pointed CI at a missing binary. +/** Per-platform install locations of the desktop VS Code Electron binary. */ +const PLATFORM_VSCODE_PATHS = { + darwin: ["/Applications/Visual Studio Code.app/Contents/MacOS/Electron"], + win32: [ + "Programs/Microsoft VS Code/Code.exe", // under LOCALAPPDATA + "Microsoft VS Code/Code.exe", // under ProgramFiles + ], + linux: ["/usr/share/code/code", "/usr/bin/code", "/snap/bin/code"], +}; + +function platformVscodeCandidates() { + if (process.platform === "win32") { + const roots = [process.env.LOCALAPPDATA, process.env.ProgramFiles].filter(Boolean); + return PLATFORM_VSCODE_PATHS.win32.flatMap((rel) => roots.map((root) => join(root, rel))); + } + return PLATFORM_VSCODE_PATHS[process.platform] ?? []; +} + +/** First installed candidate, or undefined. */ +function findInstalledVscode() { + for (const candidate of platformVscodeCandidates()) { + if (!candidate) continue; + try { + accessSync(candidate); + return candidate; + } catch { + /* not present */ + } + } + return undefined; +} + +/** Newest @vscode/test-electron download-cache install, or undefined. */ +function findCachedVscode() { + const home = process.env.HOME || process.env.USERPROFILE; + if (!home) return undefined; + try { + const cacheRoot = join(home, ".vscode-test"); + const dirPrefix = `vscode-${process.platform}-${process.arch}`; + return newestCacheEntry(cacheRoot, dirPrefix); + } catch { + return undefined; // no cache + } +} + +/** `bin/code` inside the newest cache dir matching prefix, or undefined. */ +function newestCacheEntry(cacheRoot, dirPrefix) { + const matches = readdirSync(cacheRoot) + .filter((e) => e.startsWith(dirPrefix)) + .sort(); + if (matches.length === 0) return undefined; + return join(cacheRoot, matches[matches.length - 1], "bin", "code"); +} + +function discoverVscodePath() { + return process.env.VSCODE_PATH || findInstalledVscode() || findCachedVscode(); +} + +const vscodePath = discoverVscodePath(); +if (!vscodePath) { + console.error( + "run-clean: no VS Code executable found. Set VSCODE_PATH (e.g. output of `node scripts/dl-vscode.mjs`) or install VS Code.", + ); + process.exit(1); +} +process.env.VSCODE_PATH = vscodePath; + const child = spawn("node", ["./dist-tsc/test/runTest.js"], { stdio: "inherit", env: process.env, }); -child.on("exit", (code) => process.exit(code ?? 0)); +child.on("error", (err) => { + console.error("run-clean: failed to spawn node:", err); + process.exit(1); +}); +child.on("exit", (code, signal) => { + // Preserve both exit code and signal semantics; never map a signal to 0. + if (signal) { + console.error(`run-clean: test runner terminated by signal ${signal}`); + process.exit(1); + } + process.exit(code ?? 1); +}); diff --git a/scripts/dl-vscode.mjs b/scripts/dl-vscode.mjs index 3b342e7..e137335 100644 --- a/scripts/dl-vscode.mjs +++ b/scripts/dl-vscode.mjs @@ -1,5 +1,15 @@ import { downloadAndUnzipVSCode } from "@vscode/test-electron"; -const version = process.argv[2] || "1.85.0"; -const exe = await downloadAndUnzipVSCode({ version }); -console.log(exe); +// Default mirrors package.json's engines.vscode; overridable via argv[1]. +const DEFAULT_VSCODE_VERSION = "1.85.0"; +const version = process.argv[2] || DEFAULT_VSCODE_VERSION; +try { + const exe = await downloadAndUnzipVSCode({ version }); + console.log(exe); +} catch (err) { + console.error( + `dl-vscode: failed to download VS Code ${version}:`, + err instanceof Error ? err.message : err, + ); + process.exit(1); +} diff --git a/scripts/fallow-audit.mjs b/scripts/fallow-audit.mjs index 0b82f9f..b4ce9bd 100644 --- a/scripts/fallow-audit.mjs +++ b/scripts/fallow-audit.mjs @@ -15,13 +15,50 @@ import { spawnSync } from "node:child_process"; import process from "node:process"; -function resolveBaseRef() { - if (process.env.FALLOW_BASE_REF) return process.env.FALLOW_BASE_REF; +/** Abort with the audit's "invocation error" exit code and a reason. */ +function invocationError(reason) { + console.error(`fallow-audit: ${reason}`); + process.exit(2); +} + +/** True when the probe never ran to completion (spawn failure or signal). */ +function hasSpawnAnomaly(head) { + return Boolean(head.error || head.signal); +} + +/** Human-readable reason for a spawn anomaly (only valid after the check). */ +function anomalyReason(head) { + if (head.error) return `git probe failed: ${head.error.message}`; + return `git probe terminated by signal ${head.signal}`; +} + +/** + * Read the probe result; null means "no answer" (non-zero status), while any + * spawn failure or signal termination aborts with the invocation-error exit + * code instead of silently degrading to the origin/main fallback. + */ +function probeAnswer(head) { + if (hasSpawnAnomaly(head)) invocationError(anomalyReason(head)); + if (head.status === 0 && head.stdout.trim()) return head.stdout.trim(); + return null; +} + +/** Probe the repo's default branch via git; null when unresolvable. */ +function probeGitDefaultBranch() { const head = spawnSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { encoding: "utf8", }); - if (head.status === 0 && head.stdout.trim()) return head.stdout.trim(); - return "origin/main"; + // A signal-terminated or spawn-failed probe is not "no result": don't + // silently fall through to origin/main with a generic code. + return probeAnswer(head); +} + +function envBaseRef() { + return process.env.FALLOW_BASE_REF || null; +} + +function resolveBaseRef() { + return envBaseRef() ?? probeGitDefaultBranch() ?? "origin/main"; } const base = resolveBaseRef(); @@ -51,7 +88,21 @@ const args = [ ]; console.log(`fallow audit --base ${base}`); const result = spawnSync("pnpm", args, { stdio: "inherit" }); -const code = result.status ?? 2; +if (result.error) { + console.error(`\n✖ fallow audit could not run pnpm: ${result.error.message}`); + process.exit(2); +} +if (result.signal) { + console.error( + `\n✖ fallow audit terminated by signal ${result.signal} — not a quality verdict.`, + ); + process.exit(2); +} +const code = result.status; +if (code === null) { + console.error("\n✖ fallow audit exited without a status — check the Fallow invocation."); + process.exit(2); +} if (code === 1) { console.error( `\n✖ fallow audit gate FAILED (findings against ${base}). Fix the findings or agree on a recorded baseline before merging.`, diff --git a/scripts/postinstall-patch.mjs b/scripts/postinstall-patch.mjs index 40addcc..4d53043 100644 --- a/scripts/postinstall-patch.mjs +++ b/scripts/postinstall-patch.mjs @@ -8,12 +8,15 @@ // // This ensures root .md files are discovered as individual skills in ALL locations, // not just in .pi/skills/ directories. +// +// Implementation notes: everything runs through node:fs (no shell), so paths from +// any source are inert and the script also works on Windows. -import { execSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { fileURLToPath } from "node:url"; -const root = fileURLToPath(new URL("..", import.meta.url)); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); // Stub for the corrupt upstream photon.js. The original is a WASM (photon-rs) // image lib loader; every consumer null-checks the returned photon handle, so @@ -27,16 +30,29 @@ export async function loadPhoton() { export default { loadPhoton }; `; -function findFiles(pattern) { +/** Depth-first search under `dir` for files whose relative path matches + * `relativePattern` (e.g. "@earendil-works/pi-coding-agent/dist/utils/photon.js"). */ +function findFilesUnder(dir, relativePattern, depth = 0, out = []) { + if (depth > 12) return out; // bounded: node_modules nesting never goes deeper + let entries; try { - const out = execSync( - `find "${root}/node_modules" -path "*/${pattern}" -type f 2>/dev/null`, - { encoding: "utf-8", timeout: 10_000 }, - ); - return out.trim().split("\n").filter(Boolean); + entries = readdirSync(dir, { withFileTypes: true }); } catch { - return []; + return out; // unreadable/missing dir: nothing to find here + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + findFilesUnder(full, relativePattern, depth + 1, out); + } else if (path.relative(dir, full).replace(/\\/g, "/") === relativePattern) { + out.push(full); + } } + return out; +} + +function findFiles(pattern) { + return findFilesUnder(path.join(root, "node_modules"), pattern); } function patchAgentsMode() { @@ -49,10 +65,7 @@ function patchAgentsMode() { let patched = 0; for (const file of files) { try { - const content = execSync(`cat "${file}"`, { - encoding: "utf-8", - timeout: 5000, - }); + const content = readFileSync(file, "utf-8"); const next = content .replace( 'collectAutoSkillEntries(agentsSkillsDir, "agents")', @@ -68,7 +81,9 @@ function patchAgentsMode() { patched++; } } catch (err) { - process.stderr.write(`[patch] fail ${file}: ${err.message}\n`); + process.stderr.write( + `[patch] fail ${file}: ${err instanceof Error ? err.message : String(err)}\n`, + ); } } process.stdout.write(`[patch] pi-coding-agent agents→pi mode: ${patched} file(s)\n`); @@ -84,22 +99,25 @@ function patchPhotonStub() { let patched = 0; for (const file of files) { try { - const content = execSync(`head -c 16 "${file}"`, { - encoding: "utf-8", - timeout: 5000, - }); + // Read the first 16 bytes as a Buffer (never utf-8): a healthy file + // starts with ASCII JS, the corrupt upstream file starts with binary + // bytes, and decoding binary garbage as text can throw. + const head = Buffer.from(readFileSync(file)).subarray(0, 16); // Healthy photon.js starts with our stub comment or JS code. // The corrupt upstream file starts with binary bytes (e.g. 0xe8...). + const headAscii = head.toString("latin1"); const startsJs = - content.startsWith("//") || - content.startsWith("/*") || - /^[A-Za-z_'"`]/.test(content); + headAscii.startsWith("//") || + headAscii.startsWith("/*") || + /^[A-Za-z_'"`]/.test(headAscii); if (!startsJs) { writeFileSync(file, PHOTON_STUB, "utf-8"); patched++; } } catch (err) { - process.stderr.write(`[patch] photon fail ${file}: ${err.message}\n`); + process.stderr.write( + `[patch] photon fail ${file}: ${err instanceof Error ? err.message : String(err)}\n`, + ); } } process.stdout.write(`[patch] pi-coding-agent photon stub: ${patched} file(s)\n`); diff --git a/scripts/run-node-tests.mjs b/scripts/run-node-tests.mjs index 1e44f46..51de515 100644 --- a/scripts/run-node-tests.mjs +++ b/scripts/run-node-tests.mjs @@ -28,18 +28,38 @@ registerHooks({ }); const indexUrl = pathToFileURL(path.resolve("dist-tsc/test/suite/index.js")).href; -const mod = await import(indexUrl); -await mod.run(); +let runPromise; +try { + const mod = await import(indexUrl); + runPromise = mod.run(); +} catch (err) { + console.error("[run-node-tests] failed to load the test suite:", err); + process.exit(1); +} +try { + await runPromise; +} catch (err) { + console.error("[run-node-tests] suite run failed:", err); + // Fall through to the log-based verdict below; a written report is still + // authoritative, but a crashed run without one must not exit 0. +} // Watchdog: force-exit once the result log is written. Some suite teardowns // (disposing providers created by buildProvider) can leave open handles that // keep the event loop alive; the authoritative result is already in the log. +// If the log never appears the run produced NO result — exit non-zero instead +// of reporting success (previous behavior masked crashed/misconfigured runs). const { readFileSync, existsSync } = await import("node:fs"); +const resultsLog = "dist-tsc/test/suite/test-results.log"; for (let i = 0; i < 100; i++) { - if (existsSync("dist-tsc/test/suite/test-results.log")) { - const log = readFileSync("dist-tsc/test/suite/test-results.log", "utf-8"); + if (existsSync(resultsLog)) { + const log = readFileSync(resultsLog, "utf-8"); const m = /FAIL:\s*(\d+)/.exec(log); if (m) process.exit(Number(m[1]) > 0 ? 1 : 0); } await new Promise((r) => setTimeout(r, 200)); } +console.error( + `[run-node-tests] no result report was written to ${resultsLog} within the watchdog window — treating the run as failed.`, +); +process.exit(1); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 393fb0e..0e0328a 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -57,10 +57,24 @@ for (const stage of chain) { stdio: "inherit", shell: process.platform === "win32", }); - const code = result.status ?? 1; - if (code !== 0) { - console.error(`\n✖ verify (${level}) failed at stage "${stage}" (exit ${code}).`); - process.exit(code); + // Distinguish the three failure shapes a spawn can produce; collapsing them + // into one code masks e.g. a SIGKILL'd stage as an ordinary nonzero exit. + if (result.error) { + console.error( + `\n✖ verify (${level}) failed at stage "${stage}": could not run ${cmd} (${result.error.message}).`, + ); + process.exit(3); + } + if (result.signal) { + console.error( + `\n✖ verify (${level}) stage "${stage}" was terminated by signal ${result.signal}.`, + ); + process.exit(2); + } + const code = result.status; + if (code === null || code !== 0) { + console.error(`\n✖ verify (${level}) failed at stage "${stage}" (exit ${code ?? "none"}).`); + process.exit(code ?? 1); } console.log(`<-- [${stage}] ok\n`); } diff --git a/scripts/webview-test-loader.mjs b/scripts/webview-test-loader.mjs index 9f9f06d..0803228 100644 --- a/scripts/webview-test-loader.mjs +++ b/scripts/webview-test-loader.mjs @@ -11,13 +11,56 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; const preprocessor = vitePreprocess(); +/** Parent directory of the importing module, or the process cwd. */ +function parentDirOf(context) { + return context.parentURL ? dirname(fileURLToPath(context.parentURL)) : process.cwd(); +} + +/** + * True when Node can resolve the specifier on its own: relative paths that + * already carry a resolvable extension, or anything non-relative. + */ +function needsExtensionProbe(specifier) { + if (!specifier.startsWith(".")) return false; + return [".ts", ".js", ".json"].every((ext) => !specifier.endsWith(ext)); +} + +/** Try resolving `base` with each candidate extension; first hit wins. */ +async function probeExtensions(base, context, nextResolve) { + for (const ext of [".ts", ".js", ".svelte"]) { + const candidate = pathToFileURL(base + ext).href; + try { + await nextResolve(candidate, context); + return candidate; + } catch { + /* try next extension */ + } + } + return null; +} + +/** + * Components import workspace modules without an extension ("../messages"). + * Vite resolves those at build time; plain Node does not, so probe the + * .ts/.js/.svelte siblings before giving up. Returns the URL or null. + */ +async function resolveWorkspaceSpecifier(specifier, context, nextResolve) { + if (!needsExtensionProbe(specifier)) return null; + const base = resolvePath(parentDirOf(context), specifier); + return probeExtensions(base, context, nextResolve); +} + // fallow-ignore-next-line unused-export export async function resolve(specifier, context, nextResolve) { if (specifier.endsWith(".svelte")) { - const parentDir = context.parentURL - ? dirname(fileURLToPath(context.parentURL)) - : process.cwd(); - return { url: pathToFileURL(resolvePath(parentDir, specifier)).href, shortCircuit: true }; + return { + url: pathToFileURL(resolvePath(parentDirOf(context), specifier)).href, + shortCircuit: true, + }; + } + const workspaceUrl = await resolveWorkspaceSpecifier(specifier, context, nextResolve); + if (workspaceUrl) { + return { url: workspaceUrl, shortCircuit: true }; } // Client runtime: svelte's "." → src/index-client.js, esm-env → browser build. const conditions = context.conditions.includes("browser") diff --git a/src/loader.cjs b/src/loader.cjs index 042403b..f736038 100644 --- a/src/loader.cjs +++ b/src/loader.cjs @@ -21,6 +21,35 @@ const Module = require("module"); const path = require("path"); const fs = require("fs"); +// ── Constants (previously inlined strings scattered through the file) ────── +const PI_SDK_PACKAGE = "@earendil-works/pi-coding-agent"; +const PI_SDK_DOCS_URL = "https://github.com/earendil-works/pi-coding-agent"; +const PI_SDK_INSTALL_COMMAND = "npm install -g --ignore-scripts " + PI_SDK_PACKAGE; +/** Timeout for the `which pi` / `where pi` probes: a wedged PATH lookup must + * not stall extension activation indefinitely. */ +const PATH_PROBE_TIMEOUT_MS = 3000; + +/** Subprocess helper: argv-array spawnSync with a hard timeout. Replaces the + * unbounded `execSync("which pi")` / `execSync("where pi")` calls that could + * hang activation. Returns { ok, stdout } with stdout trimmed, or ok:false. */ +function runProbeSync(command, args, timeoutMs) { + try { + const { spawnSync } = require("child_process"); + const result = spawnSync(command, args, { + encoding: "utf8", + timeout: timeoutMs, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + if (result.error || result.status !== 0 || !result.stdout) { + return { ok: false, stdout: "" }; + } + return { ok: true, stdout: String(result.stdout).trim() }; + } catch (e) { + return { ok: false, stdout: "" }; + } +} + /** * Get the user's home directory in a cross-platform way */ @@ -82,7 +111,8 @@ function findPiSdkAtPath(nodeModulesPath) { return nodeModulesPath; } - // Check pnpm store symlinks (e.g., .pi-coding-agent-llEO51dR -> ../../store/v11/links/...) + // Also check .mise subdirectory (aube-bin-shim layout: + // node_modules/.mise/@earendil-works+pi-coding-agent@ver/node_modules/) const entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith(".pi-coding-agent-") && entry.isSymbolicLink()) { @@ -216,13 +246,9 @@ function findGlobalPiInstallation() { // Check global npm installation try { - const { execSync } = require("child_process"); - const npmRoot = execSync("npm root -g", { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - if (npmRoot && fs.existsSync(npmRoot)) { - possiblePaths.push(npmRoot); + const npmProbe = runProbeSync("npm", ["root", "-g"], PATH_PROBE_TIMEOUT_MS); + if (npmProbe.ok && npmProbe.stdout && fs.existsSync(npmProbe.stdout)) { + possiblePaths.push(npmProbe.stdout); } } catch (e) { // npm not available or command failed, skip @@ -230,13 +256,9 @@ function findGlobalPiInstallation() { // Check pnpm global root as fallback try { - const { execSync } = require("child_process"); - const pnpmRoot = execSync("pnpm root -g", { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - if (pnpmRoot && fs.existsSync(pnpmRoot)) { - possiblePaths.push(pnpmRoot); + const pnpmProbe = runProbeSync("pnpm", ["root", "-g"], PATH_PROBE_TIMEOUT_MS); + if (pnpmProbe.ok && pnpmProbe.stdout && fs.existsSync(pnpmProbe.stdout)) { + possiblePaths.push(pnpmProbe.stdout); } } catch (e) { // pnpm not available, skip @@ -267,7 +289,7 @@ function findGlobalPiInstallation() { if (fs.existsSync(nmEntry) && fs.statSync(nmEntry).isDirectory()) { possiblePaths.push(nmEntry); // Also check .mise subdirectory (aube-bin-shim layout: - // node_modules/.mise/@earendel-works+pi-coding-agent@ver/node_modules/) + // node_modules/.mise/@earendil-works+pi-coding-agent@ver/node_modules/) const miseSubdir = path.join(nmEntry, ".mise"); if (fs.existsSync(miseSubdir)) { for (const miseEntry of fs.readdirSync(miseSubdir)) { @@ -323,25 +345,16 @@ function findPiSdkFromCommand() { const isWindows = process.platform === "win32"; try { - const { execSync } = require("child_process"); let piPath; if (isWindows) { - // Use 'where' on Windows - piPath = execSync("where pi", { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }) - .trim() - .split("\n")[0]; + // Use 'where' on Windows (argv-array + timeout: never hangs activation) + const probe = runProbeSync("where", ["pi"], PATH_PROBE_TIMEOUT_MS); + piPath = probe.ok ? probe.stdout.split("\n")[0] : ""; } else { // Use 'which' on Unix-like systems - piPath = execSync("which pi", { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }) - .trim() - .split("\n")[0]; + const probe = runProbeSync("which", ["pi"], PATH_PROBE_TIMEOUT_MS); + piPath = probe.ok ? probe.stdout.split("\n")[0] : ""; } if (!piPath || !fs.existsSync(piPath)) { @@ -376,7 +389,7 @@ function findPiSdkFromCommand() { } // The pi executable reference is typically at: - // - npm: ~/.nvm/versions/node/.../lib/node_modules/@earendel-works/pi-coding-agent/dist/cli.js + // - npm: ~/.nvm/versions/node/.../lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js // - pnpm shim: ~/.local/share/pnpm/bin/pi (shell script with # cmd-shim-target) // - mise install dir: ~/.local/share/mise/installs/.../node_modules/.bin/pi (aube-bin-shim text script) // - mise shim dir: ~/.local/share/mise/shims/pi (symlink to compiled mise binary — skipped above) @@ -461,7 +474,7 @@ function deriveSdkPathFromBinary(piPath) { } // Check for aube-bin-shim target comment (mise, bun-managed installs) - // Format: # aube-bin-shim v2 target=../.mise/@earendel-works+pi-coding-agent@0.85.1/node_modules/@earendel-works/pi-coding-agent/dist/bundle/cli.js + // Format: # aube-bin-shim v2 target=../.mise/@earendil-works+pi-coding-agent@0.85.1/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js const aubeMatch = content.match(/#\s*aube-bin-shim\s+v\d+\s+target=(.+)/); if (aubeMatch) { const targetPath = aubeMatch[1].trim(); @@ -472,7 +485,7 @@ function deriveSdkPathFromBinary(piPath) { const sdkNodeModules = extractNodeModulesPath(resolvedTarget); if ( sdkNodeModules && - fs.existsSync(path.join(sdkNodeModules, "@earendel-works", "pi-coding-agent")) + fs.existsSync(path.join(sdkNodeModules, "@earendil-works", "pi-coding-agent")) ) { return sdkNodeModules; } @@ -567,60 +580,105 @@ function hookModuleResolution(piNodeModules) { const originalResolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, parent, isMain, options) { - // Intercept @earendil-works/* packages, resolve from global PI install + // Intercept @earendil-works/* packages, resolve from global PI install. if (request.startsWith("@earendil-works/")) { - const parts = request.split("/"); - if (parts.length >= 2) { - const scope = parts[0]; - const name = parts[1]; - const pkgDir = path.join(piNodeModules, scope, name); - - if (fs.existsSync(pkgDir)) { - if (parts.length === 2) { - // Bare import: @earendil-works/pi-coding-agent - // Bypass exports map, resolve directly to main file - try { - const pkgJson = JSON.parse( - fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"), - ); - const mainFile = path.resolve(pkgDir, pkgJson.main || "dist/index.js"); - if (fs.existsSync(mainFile)) { - return mainFile; - } - } catch (_e) { - // Fall through to normal resolution - } - } else { - // Sub-path: @earendil-works/pi-coding-agent/package.json - const subPath = parts.slice(2).join("/"); - const resolvedPath = path.resolve(pkgDir, subPath); - - if (fs.existsSync(resolvedPath)) { - const stat = fs.statSync(resolvedPath); - if (stat.isDirectory()) { - // Directory resolution: append /index.js - const indexPath = path.join(resolvedPath, "index.js"); - if (fs.existsSync(indexPath)) { - return indexPath; - } - } - return resolvedPath; - } - - // Non-existent sub-path, try appending .js extension - const withJs = resolvedPath + ".js"; - if (fs.existsSync(withJs)) { - return withJs; - } - } - } + const resolved = resolveSdkRequest(piNodeModules, request); + if (resolved) { + return resolved; } + // Unresolvable or traversal attempt: fall through to Node's normal + // resolution, which produces its own (correctly scoped) error. } - return originalResolveFilename.call(this, request, parent, isMain, options); }; } +/** + * Read a package.json and produce the ordered list of entry-point candidates + * for a bare `@earendil-works/*` import: exports map ("." entry, then its + * require/import/default conditions), then "main", then the historical + * dist/index.js guess. Returns an empty array when the manifest is unreadable. + */ +function exportsDotEntryTargets(dotEntry) { + if (typeof dotEntry === "string") return [dotEntry]; + if (dotEntry && typeof dotEntry === "object") { + return ["require", "import", "default"] + .filter((cond) => typeof dotEntry[cond] === "string") + .map((cond) => dotEntry[cond]); + } + return []; +} + +function exportsMapTargets(exportsMap) { + if (typeof exportsMap === "string") return [exportsMap]; + if (exportsMap && typeof exportsMap === "object") { + return exportsDotEntryTargets(exportsMap["."]); + } + return []; +} + +function bareImportCandidates(pkgDir) { + try { + const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8")); + const targets = exportsMapTargets(pkgJson.exports); + if (typeof pkgJson.main === "string") targets.push(pkgJson.main); + targets.push("dist/index.js"); + return targets; + } catch (_e) { + return []; + } +} + +/** + * Resolve a bare `@earendil-works/*` import to its entry file within pkgDir, + * or null when no candidate exists on disk. + */ +function resolveBareImport(pkgDir) { + for (const target of bareImportCandidates(pkgDir)) { + const candidate = path.resolve(pkgDir, target); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Resolve a sub-path import (`@earendil-works/pkg/sub`) inside pkgDir. + * Refuses traversal: a request like `pkg/../../x` returns null instead of a + * path outside the package directory. + */ +function resolveSubPathImport(pkgDir, subPath) { + const resolvedPath = path.resolve(pkgDir, subPath); + const pkgDirWithSep = pkgDir.endsWith(path.sep) ? pkgDir : pkgDir + path.sep; + if (!resolvedPath.startsWith(pkgDirWithSep)) { + return null; // traversal attempt + } + if (fs.existsSync(resolvedPath)) { + if (fs.statSync(resolvedPath).isDirectory()) { + const indexPath = path.join(resolvedPath, "index.js"); + if (fs.existsSync(indexPath)) return indexPath; + } + return resolvedPath; + } + const withJs = resolvedPath + ".js"; + return fs.existsSync(withJs) ? withJs : null; +} + +/** + * Resolve an `@earendil-works/*` request against the global PI install. + * Returns the absolute file path, or null when the request cannot be served + * from this install (caller falls back to Node's normal resolution). + */ +function resolveSdkRequest(piNodeModules, request) { + const parts = request.split("/"); + if (parts.length < 2) return null; + const pkgDir = path.join(piNodeModules, parts[0], parts[1]); + if (!fs.existsSync(pkgDir)) return null; + if (parts.length === 2) { + return resolveBareImport(pkgDir); + } + return resolveSubPathImport(pkgDir, parts.slice(2).join("/")); +} + /** * Main loader function */ @@ -635,42 +693,29 @@ function load() { const platform = process.platform; const isWindows = platform === "win32"; - let installInstructions; - if (isWindows) { - installInstructions = - "1. Open PowerShell or Command Prompt\n" + - "2. Run: npm install -g --ignore-scripts @earendil-works/pi-coding-agent\n" + - "3. Restart VS Code"; - } else { - installInstructions = - "1. Open Terminal\n" + - "2. Run: npm install -g --ignore-scripts @earendel-works/pi-coding-agent\n" + - "3. Restart VS Code\n\n" + - "If 'pi' works in Terminal but not here, VS Code may not see your PATH.\n" + - "Try: launch VS Code from Terminal (run 'code .' from your project dir).\n" + - "Alternatively, set pi-agent.binaryPath in VS Code settings to the full path from 'which pi'."; - } + const installInstructions = + "1. Open PowerShell or Command Prompt\n" + + "2. Run: " + + PI_SDK_INSTALL_COMMAND + + "\n" + + "3. Restart VS Code"; const message = "PiLot Studio requires PI CLI to be installed.\n\n" + "Installation steps:\n" + installInstructions + - "\n\n" + + "\n\nIf 'pi' works in a terminal but not here, VS Code may not see your PATH.\n" + + "Try launching VS Code from a terminal ('code .' from your project dir),\n" + + "or set pi-agent.binaryPath in VS Code settings to the full path from 'which pi'.\n\n" + "Or visit the documentation for alternative installation methods."; vscode.window .showErrorMessage(message, "Open Documentation", "Copy Install Command") .then(function (selection) { if (selection === "Open Documentation") { - vscode.env.openExternal( - vscode.Uri.parse( - "https://github.com/earendil-works/pi-coding-agent", - ), - ); + vscode.env.openExternal(vscode.Uri.parse(PI_SDK_DOCS_URL)); } else if (selection === "Copy Install Command") { - vscode.env.clipboard.writeText( - "npm install -g --ignore-scripts @earendil-works/pi-coding-agent", - ); + vscode.env.clipboard.writeText(PI_SDK_INSTALL_COMMAND); vscode.window.showInformationMessage( "Install command copied to clipboard!", ); diff --git a/src/message-serializer.ts b/src/message-serializer.ts index 96bf530..e75de72 100644 --- a/src/message-serializer.ts +++ b/src/message-serializer.ts @@ -38,7 +38,9 @@ interface RawAgentMessage { } /** Extract joined text and images from a content value that is either a plain - * string or an array of content blocks (text/image). */ + * string or an array of content blocks (text/image). Every block is shape-checked: + * SDK extensions can inject arbitrary content parts, and a malformed one + * previously surfaced as `undefined` fields reaching the webview. */ function extractTextAndImages(content: string | any[] | undefined): { text: string; images: Array<{ type: "image"; data: string; mimeType: string }>; @@ -48,9 +50,14 @@ function extractTextAndImages(content: string | any[] | undefined): { const textParts: string[] = []; const images: Array<{ type: "image"; data: string; mimeType: string }> = []; for (const c of content) { - if (c.type === "text" && "text" in c) { - textParts.push(c.text || ""); - } else if (c.type === "image" && "data" in c && "mimeType" in c) { + if (!c || typeof c !== "object") continue; + if (c.type === "text" && typeof c.text === "string") { + textParts.push(c.text); + } else if ( + c.type === "image" && + typeof c.data === "string" && + typeof c.mimeType === "string" + ) { images.push({ type: "image", data: c.data, mimeType: c.mimeType }); } } @@ -109,6 +116,10 @@ export function serializeMessages( if (!normalized) continue; const { message: msg, entryId, parentId, timestamp } = normalized; + if (!msg || typeof msg.role !== "string") { + continue; // malformed entry from an extension: skip rather than crash + } + if (msg.role === "user") { if (typeof msg.content === "string") { result.push({ @@ -178,13 +189,16 @@ export function serializeMessages( // these via CustomMessageComponent when `display` is true; hidden ones // (display === false) are context-only and never surfaced in the UI. if (msg.display === false) continue; + // The customType label is interpolated into the webview header — keep it a + // string (a hostile object previously flowed through as `label: any`). + const customLabel = typeof msg.customType === "string" ? msg.customType : undefined; const { text, images } = extractTextAndImages(msg.content); if (!text.trim() && images.length === 0) continue; result.push({ role: "provider", content: text, images: images.length > 0 ? images : undefined, - label: msg.customType, + label: customLabel, timestamp: timestamp ?? 0, entryId, parentId, diff --git a/src/pi-agent-provider.ts b/src/pi-agent-provider.ts index 0e80481..4b1c5f5 100644 --- a/src/pi-agent-provider.ts +++ b/src/pi-agent-provider.ts @@ -20,7 +20,11 @@ import { import { MessageHandler } from "./message-handler.js"; import { type ConfigFileKey } from "./protocol/types.js"; import { VoiceManager } from "./voice-manager.js"; -import { type ImageContent, type ThinkingLevel } from "./webview/types/index.js"; +import { + type ImageContent, + type PiAgentConfig, + type ThinkingLevel, +} from "./webview/types/index.js"; import { checkBetterSqlite3, @@ -120,14 +124,10 @@ export function validateThinkingLevel(value: unknown): ThinkingLevel { return "medium"; } -export interface PiAgentConfig { - defaultModel: string; - defaultProvider: string; - autoContext: boolean; - maxTokens: number; - thinkingLevel: ThinkingLevel; - sessionDir?: string; -} +// PiAgentConfig is imported from ./webview/types/index.js so host and webview +// share one definition (the duplicated host-side copy drifted from the +// webview's). Re-exported for existing consumers. +export type { PiAgentConfig }; // RegistryModel type moved to model-registry-handler.ts diff --git a/src/pi-binary.ts b/src/pi-binary.ts index 9126bea..af74d57 100644 --- a/src/pi-binary.ts +++ b/src/pi-binary.ts @@ -233,10 +233,21 @@ export function resolvePiBinary(): string | null { } return null; } else { - const result = piBinaryInternals.spawnSync("command", ["-v", binary], { - shell: false, - timeout: 1000, - }); + // `command -v` is a shell BUILTIN, not an executable: spawning it with + // shell: false always failed with ENOENT, so the bare-name branch never + // resolved. Run it through /bin/sh instead. `binary` is validated by + // resolvePiBinaryFromSetting()/isSafeBinaryPath before reaching here. + const result = piBinaryInternals.spawnSync( + "/bin/sh", + ["-c", 'command -v "$1"', "sh", binary], + { + shell: false, + timeout: 1000, + }, + ); + if (result.error) { + return null; + } if (result.status === 0 && result.stdout) { const resolved = result.stdout.toString().trim(); return resolved || null; diff --git a/src/protocol/types.ts b/src/protocol/types.ts index 63a8afc..042a973 100644 --- a/src/protocol/types.ts +++ b/src/protocol/types.ts @@ -1,5 +1,14 @@ // ── Message protocol types shared between extension host and webview ───────── +import { type ThinkingLevel } from "../webview/types/index.js"; + +/** + * The set of thinking levels as a plain union, derived from the single ordered + * source of truth (THINKING_LEVELS in model-registry-handler). Declaring the + * union by hand here duplicated webview/types' ThinkingLevel and drifted. + */ +export type ProtocolThinkingLevel = ThinkingLevel; + /** Messages the webview sends to the extension host */ export interface WebviewMessage { type: string; @@ -34,9 +43,7 @@ export interface ProviderApi { navigateTree(nodeId: string): Promise; setSessionName(name: string): Promise; setModel(modelId: string): Promise; - setThinkingLevel( - level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", - ): Promise; + setThinkingLevel(level: ProtocolThinkingLevel): Promise; getPiUISettings(): Promise<{ showCacheMissNotices: boolean }>; setPiUISetting(key: "showCacheMissNotices", value: boolean): Promise; steer(text: string, images?: unknown[]): Promise; @@ -55,7 +62,7 @@ export interface ProviderApi { getExtensionVersion(): string; getPiCliVersion(): Promise; isBinaryAvailable(): boolean; - getThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + getThinkingLevel(): ProtocolThinkingLevel; getFavorites(): string[]; getProviderAuthData(): Promise< Array<{ diff --git a/src/session-manager.ts b/src/session-manager.ts index 375d526..3e523c4 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -332,6 +332,10 @@ export class SessionListManager { const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); try { const allSessions = await PiSessionManager.list(cwd, this.deps.config.sessionDir); + // Settle per-item: one undeletable file previously aborted the whole + // Promise.all, leaving the other selected sessions undeleted with no + // indication. Now every item is attempted and failures are collected. + const failures: Array<{ sessionId: string; error: unknown }> = []; await Promise.all( sessionIds.map(async (sessionId) => { try { @@ -341,19 +345,33 @@ export class SessionListManager { } } catch (error) { this.deps.logError(`[PI] Failed to delete session ${sessionId}:`, error); - throw error; + failures.push({ sessionId, error }); } }), ); + // Dispose/refresh around the sessions that DID get deleted. + const deletedIds = sessionIds.filter((id) => !failures.some((f) => f.sessionId === id)); const session = this.deps.getSession(); - if (session && sessionIds.includes(session.sessionId)) { + if (session && deletedIds.includes(session.sessionId)) { session.dispose(); this.deps.setSession?.(undefined); - await this.deps.onSessionDeleted?.(sessionIds); + await this.deps.onSessionDeleted?.(deletedIds); } await this.refreshSessionList(true); + + if (failures.length > 0) { + const details = failures + .map( + (f) => + `${f.sessionId} (${f.error instanceof Error ? f.error.message : String(f.error)})`, + ) + .join(", "); + throw new Error( + `${failures.length} of ${sessionIds.length} session(s) could not be deleted: ${details}`, + ); + } } catch (error) { this.deps.logError("[PI] Failed to delete sessions:", error); vscode.window.showErrorMessage(`Failed to delete sessions: ${String(error)}`); diff --git a/src/session-resources.ts b/src/session-resources.ts index c425396..c746115 100644 --- a/src/session-resources.ts +++ b/src/session-resources.ts @@ -157,7 +157,61 @@ export class SessionResources { return [...packages.values()]; } + /** + * Resolve a @mention path against the workspace root, refusing escapes. + * Returns the absolute path when the mention stays inside `root`, or null + * when it would traverse out (`@../../etc/passwd`) or is absolute. + */ + resolveMentionWithinRoot(root: string, mentionPath: string): string | null { + if (path.isAbsolute(mentionPath)) return null; + const absPath = path.resolve(root, mentionPath); + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + if (absPath !== root && !absPath.startsWith(rootWithSep)) { + return null; + } + return absPath; + } + /** Resolve @file mentions in text, replacing them with file content blocks. */ + /** + * Read one resolved @mention into a `` context block. + * Returns null when the mention should be skipped (unreadable, missing, or + * binary). Mutates `resolvedText` (via the returned value) to strip the + * mention from the prompt. + */ + private readMentionContext( + root: string, + mention: { match: string; filePath: string }, + ): { context: string; remainingText: string } | null { + // Constrain mentions to the workspace root: a path that escapes it + // (e.g. @../../etc/passwd) would otherwise be read and injected into + // the agent's context as if the user had attached it. + const absPath = this.resolveMentionWithinRoot(root, mention.filePath); + if (!absPath) { + this.deps.logDebug(`[PI] @mention outside the workspace ignored: ${mention.filePath}`); + return null; + } + if (!fs.existsSync(absPath) || isBinaryExtension(mention.filePath)) { + return null; + } + + try { + const content = fs.readFileSync(absPath, "utf-8"); + const maxBytes = 50 * 1024; + const truncated = + content.length > maxBytes + ? content.slice(0, maxBytes) + "\n... [file truncated at 50KB]" + : content; + return { + context: `\n${truncated}\n`, + remainingText: "", + }; + } catch (e) { + this.deps.logError(`[PI] Failed to read file ${absPath}:`, e); + return null; + } + } + async resolveFileMentions(text: string): Promise { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders || workspaceFolders.length === 0) { @@ -182,22 +236,10 @@ export class SessionResources { let resolvedText = text; for (const mention of mentions) { - const absPath = path.resolve(root, mention.filePath); - if (!fs.existsSync(absPath)) continue; - if (isBinaryExtension(mention.filePath)) continue; - - try { - const content = fs.readFileSync(absPath, "utf-8"); - const maxBytes = 50 * 1024; - const truncated = - content.length > maxBytes - ? content.slice(0, maxBytes) + "\n... [file truncated at 50KB]" - : content; - fileContexts.push(`\n${truncated}\n`); - resolvedText = resolvedText.replace(mention.match, ""); - } catch (e) { - this.deps.logError(`[PI] Failed to read file ${absPath}:`, e); - } + const read = this.readMentionContext(root, mention); + if (!read) continue; + fileContexts.push(read.context); + resolvedText = resolvedText.replace(mention.match, ""); } if (fileContexts.length === 0) return text; diff --git a/src/test/mocks/pi-sdk-mocks.ts b/src/test/mocks/pi-sdk-mocks.ts index ba6a212..d80823f 100644 --- a/src/test/mocks/pi-sdk-mocks.ts +++ b/src/test/mocks/pi-sdk-mocks.ts @@ -30,9 +30,13 @@ export function createMockAgentSession(options?: { sessionId?: string; messages?: any[]; resourceLoader?: ResourceLoader; + /** Accept for API compatibility; dispose() counting now lives on the + * returned mock itself (`mock._disposeCalls`), incremented per call. */ disposeCalls?: number; }): MockAgentSession { - const disposeCalls = 0; + // Count dispose() invocations on the mock itself. The previous `disposeCalls` + // option captured a local `0` that nothing ever incremented — assertions on it + // could only pass vacuously. const session = { sessionName: options?.sessionName ?? null, sessionId: options?.sessionId ?? "test-session-id", @@ -51,11 +55,13 @@ export function createMockAgentSession(options?: { sessionManager: { getCwd: () => "/fake/workspace", } as any, + _disposeCalls: 0, ...(options || {}), - _disposeCalls: disposeCalls, } as unknown as MockAgentSession; - (session as any).disposeCalls = disposeCalls; + (session as any).dispose = () => { + (session as any)._disposeCalls = ((session as any)._disposeCalls ?? 0) + 1; + }; return session; } @@ -150,8 +156,12 @@ export function createMockSettingsManager(): SettingsManager { update: async () => {}, }; // The SDK surface keeps growing; unknown method access becomes an async - // no-op so resource-loader calls never explode in tests. - return new Proxy(base, { + // no-op so resource-loader calls never explode in tests. Overrides win: + // a test that assigns `mock.someMethod = ...` (or sets it in `base`) + // gets its implementation back instead of being silently masked by the + // Proxy fallback — the previous proxy read straight from `target` on every + // access and made per-test overrides impossible. + const proxy = new Proxy(base, { get(target, prop) { if (prop === "then") return undefined; if (typeof prop === "string" && prop in target) { @@ -159,7 +169,12 @@ export function createMockSettingsManager(): SettingsManager { } return async () => undefined; }, + set(target, prop, value) { + (target as any)[prop] = value; + return true; + }, }) as unknown as SettingsManager; + return proxy; } export function createMockSessionManager(cwd = "/fake"): any { diff --git a/src/test/mocks/session-mock.ts b/src/test/mocks/session-mock.ts index e2d2b23..eb90615 100644 --- a/src/test/mocks/session-mock.ts +++ b/src/test/mocks/session-mock.ts @@ -22,6 +22,9 @@ export interface AgentSessionMock { events?: any[]; } +/** Structural view of what subscribe() receives, so handlers are typed instead of `any`. */ +type SessionEventHandler = (event: unknown) => void; + export interface ExtensionRunnerMock { setUIContext: (ctx: unknown) => void; getExtensionPaths?: () => string[]; @@ -64,9 +67,9 @@ export function createSessionMock(options?: { abort: async () => {}, compact: async () => ({}), editMessage: async () => {}, - getContextUsage: () => ({ used: 0, total: 0 }) as any, - getSessionStats: () => ({}) as any, - _replaceMessageInPlace: async () => ({}) as any, + getContextUsage: () => ({ used: 0, total: 0 }), + getSessionStats: () => ({}), + _replaceMessageInPlace: async () => ({}), sessionManager: { getCwd: () => "/fake/workspace", getBranch: () => [], @@ -76,11 +79,12 @@ export function createSessionMock(options?: { } as any, events: options?.events ?? [], }; - mock.subscribe = (handler: any) => { - mock.events?.push(handler); - if (options?.events) { - options.events.push(handler); - } + // Register the handler exactly once. The previous implementation pushed it + // onto both `mock.events` AND `options.events`, but when the caller passed + // `options.events` those are the SAME array — double registration, so every + // emitted event fired handlers twice and double-counted test expectations. + mock.subscribe = (handler: SessionEventHandler) => { + (mock.events as SessionEventHandler[]).push(handler); }; return mock; } diff --git a/src/test/mocks/vscode-facade.ts b/src/test/mocks/vscode-facade.ts index c285285..286d167 100644 --- a/src/test/mocks/vscode-facade.ts +++ b/src/test/mocks/vscode-facade.ts @@ -9,7 +9,6 @@ // `Uri`/`EventEmitter` (the two APIs the SDK and production code rely on at // import time) plus minimal stubs for the rest. Test setup (`resetVscodeMocks`) // then fills in behaviour. -import { EventEmitter as NodeEventEmitter } from "node:events"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; @@ -87,15 +86,15 @@ class FacadeUri { } class FacadeEventEmitter { - private readonly emitter = new NodeEventEmitter(); + // Single dispatch registry. The previous implementation registered every + // listener on BOTH a NodeEventEmitter and its own Set, and fire() invoked + // both — every listener ran twice per event, corrupting call-count asserts. private readonly listeners = new Set<(e: T) => any>(); readonly event = (listener: (e: T) => any): { dispose(): void } => { this.listeners.add(listener); - this.emitter.on("e", listener as any); return { dispose: () => this.listeners.delete(listener) }; }; fire(data?: T): void { - this.emitter.emit("e", data); for (const l of [...this.listeners]) { try { l(data as T); @@ -105,7 +104,6 @@ class FacadeEventEmitter { } } dispose(): void { - this.emitter.removeAllListeners(); this.listeners.clear(); } } diff --git a/src/test/runTest.ts b/src/test/runTest.ts index 1a7d095..1e910b5 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -5,14 +5,39 @@ import { glob } from "glob"; import { pathToFileURL } from "node:url"; import { runTests } from "@vscode/test-electron"; +/** + * Windows install roots that can contain a per-user (LOCALAPPDATA) or + * per-machine (ProgramFiles) desktop VS Code. + */ +const WIN32_VSCODE_ROOT_VARS = ["LOCALAPPDATA", "ProgramFiles"] as const; + +function win32VscodeCandidates(): string[] { + return WIN32_VSCODE_ROOT_VARS.flatMap((rootVar) => + [ + ["Programs", "Microsoft VS Code", "Code.exe"], + ["Microsoft VS Code", "Code.exe"], + ].map((segments) => path.join(process.env[rootVar] || "", ...segments)), + ); +} + +/** + * Candidate paths for this platform. Distro `code` (/usr/bin/code) is a shell + * wrapper that re-forks the GUI via cli.js; the test runner then loses process + * ownership and the run exits 0 without ever starting mocha. The real + * Electron binary (/usr/share/code/code) is preferred on Linux. + */ +function platformVscodeCandidates(): string[] { + if (process.platform === "win32") return win32VscodeCandidates(); + if (process.platform === "darwin") { + return ["/Applications/Visual Studio Code.app/Contents/MacOS/Electron"]; + } + return ["/usr/share/code/code", "/usr/bin/code"]; +} + function getVscodeExecutablePath(): string | undefined { const envPath = process.env.VSCODE_PATH; if (envPath) return envPath; - // Distro `code` (/usr/bin/code) is a shell wrapper that re-forks the GUI via - // cli.js; the test runner then loses process ownership and the run exits 0 - // without ever starting mocha. Prefer the real Electron binary. - if (existsSync("/usr/share/code/code")) return "/usr/share/code/code"; - return existsSync("/usr/bin/code") ? "/usr/bin/code" : undefined; + return platformVscodeCandidates().find((c) => c.length > 0 && existsSync(c)); } // Host-injected electron vars would make the test host reuse the parent IDE's @@ -95,18 +120,23 @@ async function runOneFile(testFile: string, reportPath: string): Promise { sanitizeElectronEnv(); const extensionDevelopmentPath = path.resolve(import.meta.dirname, "../../"); + // Unique tmpdir suffix per test file: parallel CI jobs (or two runs on one + // machine) sharing a fixed os.tmpdir() name deleted each other's workspace + // and user-data dirs mid-run. process.pid keeps concurrent runs apart. + const tmpSuffix = `${process.pid}-${testFile.replace(/[^a-zA-Z0-9_-]/g, "_").slice(-40)}`; + // Open a tiny, empty workspace instead of the project root. The project // (node_modules included) contains thousands of directories; VS Code's // recursive file watcher would create one inotify instance per directory // and exhaust the per-user inotify instance limit (often 128), causing // EMFILE and a host crash (exit 7) on Linux. - const testWorkspace = path.join(os.tmpdir(), "pilot-test-workspace"); + const testWorkspace = path.join(os.tmpdir(), `pilot-test-workspace-${tmpSuffix}`); mkdirSync(testWorkspace, { recursive: true }); const folderUri = pathToFileURL(testWorkspace).toString(); // Fresh, minimal user-data dir per file (stale storage accumulates watchers // and profile locks; a fresh dir also makes the plants below authoritative). - const testUserDataDir = path.join(os.tmpdir(), "pilot-test-userdata"); + const testUserDataDir = path.join(os.tmpdir(), `pilot-test-userdata-${tmpSuffix}`); rmSync(testUserDataDir, { recursive: true, force: true }); mkdirSync(testUserDataDir, { recursive: true }); // VS Code 1.101+ polyfills globalThis.navigator in the Node extension host @@ -176,6 +206,11 @@ async function runOneFile(testFile: string, reportPath: string): Promise { return; } throw err; + } finally { + // Best-effort cleanup of this file's per-run temp dirs (unique per pid + + // file, so removal can never race a concurrent run). + rmSync(testWorkspace, { recursive: true, force: true }); + rmSync(testUserDataDir, { recursive: true, force: true }); } } @@ -216,6 +251,12 @@ for (const file of testFiles) { } if (!green) break; } +if (testFiles.length === 0) { + console.error( + "[runTest] no test files found — the suite is misconfigured (or dist-tsc is stale); failing instead of reporting an empty green run.", + ); + process.exit(1); +} if (failed.length > 0) { console.error(`Failed test files: ${failed.join(", ")}`); process.exit(1); diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index 5a76cc5..b6c918a 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -185,9 +185,21 @@ export async function run(): Promise { const testsRoot = path.resolve(import.meta.dirname, "."); // Per-file host isolation (see runTest.ts): load only the file under test. - const fileFilter = process.env.MOCHA_TEST_FILE - ? [process.env.MOCHA_TEST_FILE] - : await glob("**/**.test.js", { cwd: testsRoot }); + // The env var must name a file INSIDE the suite dir — resolve and verify + // instead of trusting it, so `../evil.js` cannot escape the tests directory. + let fileFilter: string[]; + if (process.env.MOCHA_TEST_FILE) { + const requested = path.resolve(testsRoot, process.env.MOCHA_TEST_FILE); + const rootWithSep = testsRoot.endsWith(path.sep) ? testsRoot : testsRoot + path.sep; + if (requested !== testsRoot && !requested.startsWith(rootWithSep)) { + throw new Error( + `MOCHA_TEST_FILE must resolve inside ${testsRoot}: got ${process.env.MOCHA_TEST_FILE}`, + ); + } + fileFilter = [path.relative(testsRoot, requested)]; + } else { + fileFilter = await glob("**/**.test.js", { cwd: testsRoot }); + } for (const file of fileFilter) { mocha.addFile(path.resolve(testsRoot, file)); @@ -238,5 +250,10 @@ const isMain = !!process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; if (isMain) { - void run(); + // Direct invocation must propagate failure: a swallowed rejection here made + // a crashed run look green under the plain-Node runner. + run().catch((err) => { + console.error("[suite] run failed:", err); + process.exitCode = 1; + }); } diff --git a/src/test/suite/unit/session-manager.test.ts b/src/test/suite/unit/session-manager.test.ts index 9c3e3e8..9b039bc 100644 --- a/src/test/suite/unit/session-manager.test.ts +++ b/src/test/suite/unit/session-manager.test.ts @@ -556,7 +556,12 @@ suite("SessionListManager", () => { } assert.ok(caught, "expected error to be re-thrown"); - assert.strictEqual(caught.message, "disk full"); + // Per-item settle: the re-thrown error is a summary naming the failed + // session (and count) rather than only the first raw cause. + assert.ok( + caught.message.includes("disk full") && caught.message.includes("s1"), + `expected the summary to carry the cause, got: ${caught.message}`, + ); assert.ok(logErrors.length > 0, "expected logError to be called"); assert.ok( errorShown.includes("Failed to delete sessions"), diff --git a/src/utils/native-addons.ts b/src/utils/native-addons.ts index 0e008d1..e75561c 100644 --- a/src/utils/native-addons.ts +++ b/src/utils/native-addons.ts @@ -12,7 +12,7 @@ import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; -import { execSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; /** * Known Node.js module version -> Node.js major mapping. @@ -85,6 +85,20 @@ export interface CopyStatus { compatible: boolean; } +/** + * Read the ABI version from a compiled better_sqlite3.node file. + * + * Single shared implementation — previously the scan in `checkBetterSqlite3` + * and this helper duplicated the same regex, and only one of the two was kept + * in sync when the pattern changed. + */ +function readAbiFromNodeFile(nodeFile: string): number | null { + if (!fs.existsSync(nodeFile)) return null; + const content = fs.readFileSync(nodeFile, "latin1"); + const match = content.match(/node_register_module_v(\d+)/); + return match ? Number.parseInt(match[1], 10) : null; +} + /** * Check all installed better-sqlite3 copies for ABI compatibility. * Returns the FIRST incompatible copy found, or ok:true if all are compatible. @@ -110,36 +124,17 @@ export function checkBetterSqlite3(paths?: string[]): { if (!fs.existsSync(betterDir)) continue; const nodeFile = path.join(betterDir, "build", "Release", "better_sqlite3.node"); - if (fs.existsSync(nodeFile)) { - const content = fs.readFileSync(nodeFile, "utf-8"); - const match = content.match(/node_register_module_v(\d+)/); - const abi = match ? parseInt(match[1], 10) : null; - const compatible = abi === runtimeABI; - const status: CopyStatus = { - dir: betterDir, - nodeFile, - moduleABI: abi, - compatible, - }; - copies.push(status); - if (!compatible && !firstMismatch) { - firstMismatch = status; - } - } else { - copies.push({ - dir: betterDir, - nodeFile: null, - moduleABI: null, - compatible: false, - }); - if (!firstMismatch) { - firstMismatch = { - dir: betterDir, - nodeFile: null, - moduleABI: null, - compatible: false, - }; - } + const abi = readAbiFromNodeFile(nodeFile); + const compatible = abi !== null && abi === runtimeABI; + const status: CopyStatus = { + dir: betterDir, + nodeFile: abi !== null ? nodeFile : null, + moduleABI: abi, + compatible, + }; + copies.push(status); + if (!compatible && !firstMismatch) { + firstMismatch = status; } } @@ -157,32 +152,86 @@ export function checkBetterSqlite3(paths?: string[]): { /** * Run prebuild-install in a better-sqlite3 directory to download a * prebuilt binary for the given Electron target. + * + * Values are passed as an argv array (no shell), so filesystem-derived + * paths and version strings can never be reinterpreted as shell syntax. */ function tryPrebuildInstall(targetDir: string, electronVersion: string): string | null { try { - const result = execSync( - `npx --yes prebuild-install --runtime electron --target ${electronVersion} --arch x64`, - { cwd: targetDir, timeout: 30_000, maxBuffer: 256 * 1024 }, + const result = spawnSync( + process.execPath, + [ + // npx without a shell: run the package's CLI via node + path.join("node_modules", ".bin", "prebuild-install"), + "--runtime", + "electron", + "--target", + electronVersion, + "--arch", + "x64", + ], + { + cwd: targetDir, + timeout: 30_000, + maxBuffer: 256 * 1024, + shell: false, + windowsHide: true, + }, ); - return result.toString(); + if (result.error || result.status !== 0) { + return null; // prebuild not available + } + return result.stdout?.toString() ?? ""; } catch { return null; // prebuild not available } } +/** argv for `node-gyp rebuild`, tagged with the Electron target when running inside Electron. */ +function nodeGypArgs(electronVersion: string | undefined): string[] { + const args: string[] = ["rebuild"]; + if (electronVersion) { + args.push(`--target=${electronVersion}`); + args.push("--arch=x64"); + args.push("--dist-url=https://electronjs.org/headers"); + } + return args; +} + +/** Interpret one spawnSync result for the node-gyp run. */ +function nodeGypOutcome(result: ReturnType): { + success: boolean; + output: string; +} { + if (result.error) { + return { success: false, output: result.error.message }; + } + if (result.signal) { + return { success: false, output: `node-gyp terminated by signal ${result.signal}` }; + } + if (result.status !== 0) { + return { + success: false, + output: `node-gyp exited with code ${result.status}: ${(result.stderr?.toString() ?? "").slice(0, 400)}`, + }; + } + return { success: true, output: result.stdout?.toString() ?? "" }; +} + /** * Rebuild a single better-sqlite3 directory for the Electron ABI. * Tries prebuild-install first (fast download), then node-gyp (compilation). + * All child invocations use argv arrays with `shell: false` so the + * environment-derived electron version and filesystem-derived cwd are inert. */ function rebuildOnePath(targetDir: string): { success: boolean; output: string; } { const electronVersion = getElectronVersion(); - const isElectron = !!electronVersion; // Strategy 1: prebuild-install (fast — downloads prebuilt binary) - if (isElectron && electronVersion) { + if (electronVersion) { const prebuildResult = tryPrebuildInstall(targetDir, electronVersion); if (prebuildResult !== null) { return { success: true, output: prebuildResult }; @@ -191,20 +240,18 @@ function rebuildOnePath(targetDir: string): { // Strategy 2: node-gyp rebuild (slow — compiles from source) try { - const args: string[] = ["rebuild"]; - if (isElectron && electronVersion) { - args.push(`--target=${electronVersion}`); - args.push("--arch=x64"); - args.push("--dist-url=https://electronjs.org/headers"); - } - - const result = execSync(`npx --yes node-gyp ${args.join(" ")}`, { - cwd: targetDir, - timeout: 300_000, // 5 min — sqlite3.c is huge - maxBuffer: 2 * 1024 * 1024, - }); - - return { success: true, output: result.toString() }; + const result = spawnSync( + process.execPath, + [path.join("node_modules", ".bin", "node-gyp"), ...nodeGypArgs(electronVersion)], + { + cwd: targetDir, + timeout: 300_000, // 5 min — sqlite3.c is huge + maxBuffer: 2 * 1024 * 1024, + shell: false, + windowsHide: true, + }, + ); + return nodeGypOutcome(result); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { success: false, output: message }; @@ -288,11 +335,7 @@ export function rebuildBetterSqlite3(): { * Read the ABI version from a compiled better_sqlite3.node file. */ function readABI(targetDir: string): number | null { - const nodeFile = path.join(targetDir, "build", "Release", "better_sqlite3.node"); - if (!fs.existsSync(nodeFile)) return null; - const content = fs.readFileSync(nodeFile, "utf-8"); - const match = content.match(/node_register_module_v(\d+)/); - return match ? parseInt(match[1], 10) : null; + return readAbiFromNodeFile(path.join(targetDir, "build", "Release", "better_sqlite3.node")); } /** diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 40e1b73..c14c572 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -85,6 +85,30 @@ export function quoteShellArg(value: string, platform: string = process.platform return shellQuote(value); } +/** + * Last-line defense for the Windows `shell: true` seam. + * + * Callers are expected to pre-quote with `quoteShellArg()`, but a caller that + * forgets hands cmd.exe raw argv: Node joins command + args into the shell line + * without escaping, so `%VAR%`, `^`, `!`, an embedded quote, or a newline would + * be executed. POSIX shells are covered by `quoteShellArg` at the call sites and + * by the argv-injection-guard tests; cmd.exe has no safe quoting, so the seam + * itself refuses the run instead of interpreting hostile argv. + * + * Returns an error message when the invocation must not be spawned, or null when + * it is safe to hand to the shell. + */ +function windowsShellSafetyRejection(command: string, args: string[]): string | null { + const offenders: string[] = []; + if (CMD_UNQUOTABLE.test(command)) offenders.push(`command: ${JSON.stringify(command)}`); + for (const arg of args) { + if (CMD_UNQUOTABLE.test(arg)) offenders.push(`arg: ${JSON.stringify(arg)}`); + } + return offenders.length > 0 + ? `Refused to run under cmd.exe: unquotable shell metacharacters in ${offenders.join(", ")}` + : null; +} + /** Build a shell command object for spawning pi via shell (Unix only). */ export function getShellCommand( binaryPath: string, @@ -118,6 +142,12 @@ async function execFileAsyncImpl( timeoutMs = 15_000, options?: ShellExecOptions, ): Promise { + if (process.platform === "win32") { + const rejection = windowsShellSafetyRejection(command, args); + if (rejection) { + return { code: 1, stdout: "", stderr: rejection }; + } + } return new Promise((resolve) => { let settled = false; const child = execFile( @@ -188,6 +218,12 @@ async function execFileWithStdinImpl( timeoutMs = 15_000, options?: ShellExecOptions, ): Promise { + if (process.platform === "win32") { + const rejection = windowsShellSafetyRejection(command, args); + if (rejection) { + return { code: 1, stdout: "", stderr: rejection }; + } + } return new Promise((resolve) => { let settled = false; diff --git a/src/voice-manager.ts b/src/voice-manager.ts index 3996135..218956e 100644 --- a/src/voice-manager.ts +++ b/src/voice-manager.ts @@ -63,6 +63,8 @@ export const voiceManagerInternals = { accessSync: (path: string, mode?: number) => fs.accessSync(path, mode), existsSync: (path: fs.PathLike) => fs.existsSync(path), statSync: (path: fs.PathLike) => fs.statSync(path), + /** Seam for VoiceManager to track in-flight .tmp downloads. */ + onPendingTmpFile: undefined as ((tmpPath: string) => void) | undefined, }; // ── Helper path resolution ────────────────────────────────────────────── @@ -148,6 +150,7 @@ async function downloadVoiceModel( const { createWriteStream } = await import("node:fs"); const tmpPath = destPath + ".tmp"; + voiceManagerInternals.onPendingTmpFile?.(tmpPath); await new Promise((resolve, reject) => { const doRequest = (requestUrl: string) => { @@ -207,7 +210,14 @@ async function downloadVoiceModel( doRequest(url); }); - await fs.promises.rename(tmpPath, destPath); + try { + await fs.promises.rename(tmpPath, destPath); + } catch (e) { + // A failed rename (cross-device, cancelled mid-flight) must not leak the + // temp file; the next download recreates it. + await fs.promises.rm(tmpPath, { force: true }).catch(() => {}); + throw e; + } logDebug?.(`[PI Voice] Model downloaded to: ${destPath}`); onPhase?.("ready", "Voice model ready."); return destPath; @@ -234,6 +244,8 @@ export class VoiceManager { private voiceModel: string = "tiny-q5_1"; private voiceLineBuffer = ""; private deps: VoiceManagerDeps; + /** Partial model downloads are staged at .tmp; tracked for cleanup. */ + private pendingTmpFile?: string; constructor(deps: VoiceManagerDeps) { this.deps = deps; @@ -304,6 +316,9 @@ export class VoiceManager { if (choice !== "Download") return; try { + voiceManagerInternals.onPendingTmpFile = (tmpPath) => { + this.pendingTmpFile = tmpPath; + }; modelPath = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -344,11 +359,20 @@ export class VoiceManager { }, ); } catch (err) { - if (err instanceof Error && err.message === "Download cancelled") return; + voiceManagerInternals.onPendingTmpFile = undefined; + if (err instanceof Error && err.message === "Download cancelled") { + // Cancelled mid-download: remove the partial temp file. + await this.removePendingTmpFile(); + return; + } vscode.window.showErrorMessage( `Failed to download voice model: ${err instanceof Error ? err.message : String(err)}`, ); + await this.removePendingTmpFile(); return; + } finally { + // Whatever the outcome, stop tracking the (now consumed/removed) temp file. + this.pendingTmpFile = undefined; } } @@ -433,10 +457,38 @@ export class VoiceManager { } } + /** Remove a partial voice-model download left by a cancelled/failed transfer. */ + private async removePendingTmpFile(): Promise { + const tmp = this.pendingTmpFile; + this.pendingTmpFile = undefined; + if (!tmp) return; + try { + await fs.promises.rm(tmp, { force: true }); + } catch { + /* best-effort cleanup */ + } + } private stopVoiceCapture() { - if (this.voiceHelperProcess && this.isListening) { - this.voiceHelperProcess.stdin?.write(JSON.stringify({ type: "stop" }) + "\n"); - this.voiceHelperProcess.stdin?.end(); + if (this.voiceHelperProcess) { + const proc = this.voiceHelperProcess; + try { + if (this.isListening) { + proc.stdin?.write(JSON.stringify({ type: "stop" }) + "\n"); + } + proc.stdin?.end(); + } catch { + /* helper already gone */ + } + // Detach stdio listeners before dropping the reference: the helper can + // emit stderr lines for a while after stop, and those handlers kept the + // buffers alive and kept logging after a stop/dispose. + try { + proc.stdout?.removeAllListeners(); + proc.stderr?.removeAllListeners(); + proc.removeAllListeners(); + } catch { + /* listeners already gone */ + } this.voiceHelperProcess = undefined; } this.isListening = false; @@ -531,11 +583,24 @@ export class VoiceManager { dispose() { if (this.voiceHelperProcess) { - this.voiceHelperProcess.stdin?.end(); - this.voiceHelperProcess.kill(); + const proc = this.voiceHelperProcess; + try { + proc.stdin?.end(); + } catch { + /* already closed */ + } + try { + proc.stdout?.removeAllListeners(); + proc.stderr?.removeAllListeners(); + proc.removeAllListeners(); + } catch { + /* listeners already gone */ + } + proc.kill(); this.voiceHelperProcess = undefined; } this.isListening = false; this.voiceLineBuffer = ""; + void this.removePendingTmpFile(); } } diff --git a/src/webview/App.svelte b/src/webview/App.svelte index 5bc2b1f..ce23137 100644 --- a/src/webview/App.svelte +++ b/src/webview/App.svelte @@ -281,7 +281,18 @@ } function handleVSCodeMessage(event: MessageEvent) { - const { type, data } = event.data; + // Validate the envelope: any window can dispatch a MessageEvent at this + // webview, so treat event.data as untrusted (a non-object payload would + // previously throw on destructuring). + const raw: unknown = event.data; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return; + const envelope = raw as { type?: unknown; data?: unknown }; + if (typeof envelope.type !== "string") return; + const type: string = envelope.type; + const data = + envelope.data && typeof envelope.data === "object" && !Array.isArray(envelope.data) + ? (envelope.data as Record) + : ({} as Record); switch (type) { case "ready": @@ -319,11 +330,15 @@ break; case "model-changed": - currentModel = data.modelId; + if (typeof data.modelId === "string") { + currentModel = data.modelId; + } break; case "thinking-level-changed": - thinkingLevel = data.level; + if (typeof data.level === "string") { + thinkingLevel = data.level; + } break; case "context-usage": @@ -361,11 +376,13 @@ break; case "auto-compaction-changed": - autoCompaction = data.enabled; + if (typeof data.enabled === "boolean") { + autoCompaction = data.enabled; + } break; case "provider-auth": - providers = data || []; + providers = Array.isArray(data) ? data : []; break; case "settings-response": @@ -448,6 +465,9 @@ const lastMsg = messages[messages.length - 1]; messages = [...messages.slice(0, -1), { ...lastMsg, isStreaming: false }]; } + // Clear in-flight tool calls: entries left by an aborted run previously + // lingered forever and kept the "Executing tool..." rows after an error. + activeToolCalls.clear(); showToast({ type: "error", title: "Error", @@ -1111,6 +1131,10 @@ contextTokens = null; contextWindow = 0; activityStatuses = {}; + // Reset streaming state too: starting fresh from an in-flight run left + // isStreaming stuck true and stale tool-call rows on screen. + isStreaming = false; + activeToolCalls.clear(); sendMessage({ type: "newSession" }); } diff --git a/src/webview/app.d.ts b/src/webview/app.d.ts index 0565975..1093f14 100644 --- a/src/webview/app.d.ts +++ b/src/webview/app.d.ts @@ -1,12 +1,27 @@ /// /// +/** Structural view of the VS Code webview API bridge (no `any` in component code). */ +export interface VsCodeApi { + getState(): unknown; + setState(state: unknown): void; + postMessage(message: unknown): void; +} + declare global { interface Window { - acquireVsCodeApi: () => { - getState: () => any; - setState: (state: any) => void; - postMessage: (message: any) => void; + acquireVsCodeApi: () => VsCodeApi; + vscode?: VsCodeApi; + __toast?: { + showToast: (opts: { + type: "info" | "success" | "warning" | "error"; + title: string; + message?: string; + persistent?: boolean; + duration?: number; + }) => string; + dismissToast: (id: string) => void; + clearToasts: () => void; }; } } diff --git a/src/webview/components/ActivityBar.svelte b/src/webview/components/ActivityBar.svelte index 2885c67..187283f 100644 --- a/src/webview/components/ActivityBar.svelte +++ b/src/webview/components/ActivityBar.svelte @@ -136,6 +136,17 @@ } } + /* Respect the OS reduce-motion preference: the dot pulse is decorative and + * runs infinitely, so it must stop for users who opt out. */ + @media (prefers-reduced-motion: reduce) { + .activity-dot { + animation: none; + } + .activity-pill { + animation: none; + } + } + @keyframes pill-slide-in { from { opacity: 0; diff --git a/src/webview/components/ContextIndicator.svelte b/src/webview/components/ContextIndicator.svelte index d59261b..8ab31ea 100644 --- a/src/webview/components/ContextIndicator.svelte +++ b/src/webview/components/ContextIndicator.svelte @@ -1,4 +1,6 @@
(visible = true)} onmouseleave={() => (visible = false)} onfocusin={() => (visible = true)} onfocusout={() => (visible = false)} > -