diff --git a/packages/kilo-vscode/script/bwrap-helper.ts b/packages/kilo-vscode/script/bwrap-helper.ts new file mode 100644 index 00000000000..a66cc838c12 --- /dev/null +++ b/packages/kilo-vscode/script/bwrap-helper.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto" +import { + chmodSync, + constants, + copyFileSync, + lstatSync, + mkdirSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" +import { dirname } from "node:path" +import { localBwrapDigest, localBwrapPath, validLocalBwrap } from "../src/services/cli-backend/cli-resources" + +export function currentBwrapTarget(): string { + const os = process.platform === "win32" ? "win32" : process.platform + return `${os}-${process.arch}` +} + +function arch(target: string): "x64" | "arm64" { + if (target === "linux-x64") return "x64" + if (target === "linux-arm64") return "arm64" + throw new Error(`No Bubblewrap helper configured for target ${target}`) +} + +function source() { + const configured = process.env.KILO_BWRAP_PATH + if (configured) return realpathSync(configured) + + const found = Bun.which("bwrap") + if (!found) return + const target = realpathSync(found) + const entry = statSync(target) + const uid = process.getuid?.() + const groups = process.getgroups?.() ?? [] + const writable = + (entry.mode & 0o002) !== 0 || + (uid !== undefined && entry.uid === uid && (entry.mode & 0o200) !== 0) || + (groups.includes(entry.gid) && (entry.mode & 0o020) !== 0) + if (writable) { + throw new Error(`Refusing writable Bubblewrap executable at ${target}; set KILO_BWRAP_PATH to trust it explicitly`) + } + return target +} + +function secure(dir: string) { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const entry = lstatSync(dir) + if ( + entry.isSymbolicLink() || + !entry.isDirectory() || + entry.uid !== process.getuid?.() || + (entry.mode & 0o077) !== 0 + ) { + throw new Error(`Bubblewrap cache directory is not private: ${dir}`) + } +} + +function stage(source: string, dest: string, digest: string) { + const root = dirname(dirname(dest)) + const dir = dirname(dest) + secure(root) + secure(dir) + + const token = `${process.pid}-${randomUUID()}` + const executable = `${dest}.${token}.tmp` + const checksum = `${executable}.sha256` + try { + copyFileSync(source, executable, constants.COPYFILE_EXCL) + chmodSync(executable, 0o755) + writeFileSync(checksum, `${digest}\n`, { flag: "wx", mode: 0o600 }) + renameSync(executable, dest) + renameSync(checksum, `${dest}.sha256`) + } finally { + rmSync(executable, { force: true }) + rmSync(checksum, { force: true }) + } + + if (!validLocalBwrap(dest)) throw new Error(`Could not validate staged Bubblewrap executable at ${dest}`) +} + +export async function ensureBwrapForTarget(target: string, root?: string): Promise { + const dest = localBwrapPath(target, root) + if (!dest) return + + const executable = source() + if (executable) { + const digest = localBwrapDigest(executable) + if (validLocalBwrap(dest) && localBwrapDigest(dest) === digest) return dest + stage(executable, dest, digest) + return dest + } + if (validLocalBwrap(dest)) return dest + + const { buildBubblewrap } = await import("../../opencode/script/kilocode/bubblewrap") + const built = await buildBubblewrap(arch(target)) + stage(built.executable, dest, built.digest) + return dest +} diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 673501564be..177a2d40778 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -9,7 +9,9 @@ import { hasKiloSandboxWorker, hasTreeSitterResources, kiloSandboxWorkerForBinary, + sanitizeSandboxResources, } from "../src/services/cli-backend/cli-resources" +import { currentBwrapTarget, ensureBwrapForTarget } from "./bwrap-helper" import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") @@ -183,6 +185,13 @@ async function bundleKiloSandboxWorker() { await Bun.write(kiloSandboxWorkerForBinary(targetBinPath), result.outputs[0]) } +async function ensureLocalHelpers() { + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) + if (process.env.KILO_SKIP_BUNDLED_BWRAP === "1") return + if (await sanitizeSandboxResources(targetBinDir, true)) return + await ensureBwrapForTarget(currentBwrapTarget()) +} + async function writeSourceWrapper() { if (process.platform === "win32") { throw new Error("Compiled CLI build failed and source wrapper fallback is not supported on Windows.") @@ -202,7 +211,7 @@ async function writeSourceWrapper() { ) chmodSync(targetBinPath, 0o755) await bundleKiloSandboxWorker() - await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) + await ensureLocalHelpers() const hash = await cliSourceHash() if (hash) await Bun.write(versionFile, hash + "\n") @@ -224,7 +233,7 @@ async function main() { log( `CLI binary already present at ${relative(kiloVscodeDir, targetBinPath)} (${Math.round(st.size / 1024 / 1024)}MB). Use --force to rebuild.`, ) - await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) + await ensureLocalHelpers() return } @@ -257,7 +266,7 @@ async function main() { await copySandboxResources(sourceBinPath, targetBinPath) await copyKiloSandboxWorker(sourceBinPath, targetBinPath) chmodSync(targetBinPath, 0o755) - await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) + await ensureLocalHelpers() const hash = await cliSourceHash() if (hash) await Bun.write(versionFile, hash + "\n") diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts index 3b9c0a23365..ee78be1774e 100644 --- a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -1,9 +1,14 @@ import * as fs from "fs" +import * as crypto from "crypto" +import * as os from "os" import * as path from "path" const dir = "tree-sitter" const runtime = "tree-sitter.wasm" const kiloSandboxWorker = "kilo-sandbox-mutation-worker.js" +const bwrap = "bwrap" +const bwrapLicense = path.join("licenses", "bubblewrap") +const bwrapLicenseFiles = ["NOTICE", "COPYING", "MUSL-COPYRIGHT", "build.ts"] function paths(file: string) { if (/^[a-z]:[\\/]/i.test(file) || file.includes("\\")) return path.win32 @@ -51,17 +56,17 @@ export async function copyTreeSitterResources(source: string, target: string): P export async function copySandboxResources(source: string, target: string): Promise { const from = path.dirname(source) const to = path.dirname(target) - const helper = path.join(to, "bwrap") - const destination = path.join(to, "licenses", "bubblewrap") + const helper = path.join(to, bwrap) + const destination = path.join(to, bwrapLicense) await fs.promises.rm(helper, { force: true }) await fs.promises.rm(destination, { recursive: true, force: true }) - const bwrap = path.join(from, "bwrap") - if (!fs.existsSync(bwrap)) return - await fs.promises.copyFile(bwrap, helper) + const executable = path.join(from, bwrap) + if (!fs.existsSync(executable)) return + await fs.promises.copyFile(executable, helper) await fs.promises.chmod(helper, 0o755) - const licenses = path.join(from, "licenses", "bubblewrap") + const licenses = path.join(from, bwrapLicense) if (!fs.existsSync(licenses)) return await fs.promises.cp(licenses, destination, { recursive: true }) } @@ -72,3 +77,78 @@ export async function copyKiloSandboxWorker(source: string, target: string): Pro if (!fs.existsSync(from)) throw new Error(`Kilo sandbox mutation worker not found at ${from}`) await fs.promises.copyFile(from, to) } + +function cacheRoot() { + const root = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache") + return path.join(root, "kilo-vscode", "bwrap") +} + +function bwrapLicenseDir(bin: string) { + return path.join(bin, bwrapLicense) +} + +function hasBwrapSource(dir: string) { + try { + return fs.readdirSync(dir).some((file) => /^bubblewrap-[a-f0-9]+\.tar\.gz$/.test(file)) + } catch { + return false + } +} + +function hasProductionBwrap(bin: string) { + const executable = path.join(bin, bwrap) + const dir = bwrapLicenseDir(bin) + try { + const entry = fs.statSync(executable) + if (!entry.isFile()) return false + if (!bwrapLicenseFiles.every((file) => fs.existsSync(path.join(dir, file)))) return false + return hasBwrapSource(dir) + } catch { + return false + } +} + +export function localBwrapPath(target: string, root = cacheRoot()): string | undefined { + if (target !== "linux-x64" && target !== "linux-arm64") return undefined + return path.join(root, target, bwrap) +} + +export function localBwrapDigest(file: string): string { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") +} + +export function validLocalBwrap(file: string): boolean { + try { + const entry = fs.lstatSync(file) + if (entry.isSymbolicLink() || !entry.isFile() || (entry.mode & 0o6000) !== 0) return false + if ((entry.mode & 0o022) !== 0 || (entry.mode & 0o111) === 0) return false + const digest = fs.readFileSync(`${file}.sha256`, "utf8").trim() + if (!/^[a-f0-9]{64}$/.test(digest)) return false + return localBwrapDigest(file) === digest + } catch { + return false + } +} + +export function resolveLocalBwrapEnv( + extension: string, + local: boolean, + target = `${process.platform === "win32" ? "win32" : process.platform}-${process.arch}`, + root?: string, +): Record { + if (hasProductionBwrap(path.join(extension, "bin"))) return {} + if (!local) return {} + + const executable = localBwrapPath(target, root) + if (!executable || !validLocalBwrap(executable)) return {} + return { KILO_BWRAP_PATH: executable } +} + +export async function sanitizeSandboxResources(bin: string, local: boolean): Promise { + if (hasProductionBwrap(bin)) return true + if (!local) return false + + await fs.promises.rm(path.join(bin, bwrap), { force: true }) + await fs.promises.rm(bwrapLicenseDir(bin), { recursive: true, force: true }) + return false +} diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index a2657b33c77..d988b5de65b 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -4,7 +4,7 @@ import * as crypto from "crypto" import * as fs from "fs" import * as path from "path" import * as vscode from "vscode" -import { resolveTreeSitterEnv } from "./cli-resources" +import { resolveLocalBwrapEnv, resolveTreeSitterEnv } from "./cli-resources" import { t } from "./i18n" import { parseServerPort } from "./server-utils" @@ -94,6 +94,10 @@ export class ServerManager { const spawnCwd = resolveServerCwd(folders, this.context.globalStorageUri.fsPath) fs.mkdirSync(spawnCwd, { recursive: true }) const indexingEnv = resolveIndexingEnv(folders) + const localCli = + this.context.extensionMode === vscode.ExtensionMode.Development || + fs.existsSync(path.join(this.context.extensionPath, "bin", ".cli-version")) + const bwrapEnv = process.env.KILO_BWRAP_PATH ? {} : resolveLocalBwrapEnv(this.context.extensionPath, localCli) // TLS / corporate-proxy support: // - Default NODE_USE_SYSTEM_CA=1 so the bundled Bun CLI trusts the OS // trust store (Windows cert store, macOS keychain, Linux /etc/ssl). @@ -141,6 +145,7 @@ export class ServerManager { KILOCODE_EDITOR_NAME: `${vscode.env.appName} ${vscode.version}`, ...(!claudeCompat && { KILO_DISABLE_CLAUDE_CODE: "true" }), ...resolveTreeSitterEnv(this.context.extensionPath), + ...bwrapEnv, }, stdio: ["ignore", "pipe", "pipe"], detached: true, diff --git a/packages/kilo-vscode/tests/unit/bwrap-helper.test.ts b/packages/kilo-vscode/tests/unit/bwrap-helper.test.ts new file mode 100644 index 00000000000..5fe28adffe3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/bwrap-helper.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { ensureBwrapForTarget } from "../../script/bwrap-helper" +import { + localBwrapPath, + resolveLocalBwrapEnv, + sanitizeSandboxResources, + validLocalBwrap, +} from "../../src/services/cli-backend/cli-resources" + +const configured = process.env.KILO_BWRAP_PATH + +afterEach(() => { + if (configured === undefined) { + delete process.env.KILO_BWRAP_PATH + return + } + process.env.KILO_BWRAP_PATH = configured +}) + +describe("local Bubblewrap helper", () => { + it("copies the configured helper to a cache outside the extension", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-bwrap-")) + try { + const source = path.join(root, "source", "bwrap") + const extension = path.join(root, "workspace", "packages", "kilo-vscode") + const cache = path.join(root, "cache") + await fs.mkdir(path.dirname(source), { recursive: true }) + await fs.writeFile(source, "bubblewrap") + process.env.KILO_BWRAP_PATH = source + + const dest = await ensureBwrapForTarget("linux-x64", cache) + + expect(dest?.startsWith(extension)).toBe(false) + expect(await fs.readFile(dest!, "utf8")).toBe("bubblewrap") + expect((await fs.stat(dest!)).mode & 0o111).not.toBe(0) + expect(resolveLocalBwrapEnv(extension, true, "linux-x64", cache)).toEqual({ KILO_BWRAP_PATH: dest }) + expect(resolveLocalBwrapEnv(extension, false, "linux-x64", cache)).toEqual({}) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it("prefers a complete production helper bundled beside the CLI", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-bwrap-")) + try { + const extension = path.join(root, "extension") + const bin = path.join(extension, "bin") + const licenses = path.join(bin, "licenses", "bubblewrap") + await fs.mkdir(licenses, { recursive: true }) + await fs.writeFile(path.join(bin, "bwrap"), "bundled") + for (const file of ["NOTICE", "COPYING", "MUSL-COPYRIGHT", "build.ts"]) { + await fs.writeFile(path.join(licenses, file), file) + } + await fs.writeFile(path.join(licenses, "bubblewrap-deadbeef.tar.gz"), "source") + + expect(resolveLocalBwrapEnv(extension, true, "linux-x64", path.join(root, "cache"))).toEqual({}) + expect(await sanitizeSandboxResources(bin, true)).toBe(true) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it("removes an incomplete helper before local packaging", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-bwrap-")) + try { + const bin = path.join(root, "extension", "bin") + const helper = path.join(bin, "bwrap") + await fs.mkdir(bin, { recursive: true }) + await fs.writeFile(helper, "stale") + + expect(await sanitizeSandboxResources(bin, true)).toBe(false) + expect( + await fs.stat(helper).then( + () => true, + () => false, + ), + ).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it("rejects a symlinked or public cache", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-bwrap-")) + try { + const source = path.join(root, "source") + const cache = path.join(root, "cache") + const dest = localBwrapPath("linux-x64", cache)! + await fs.writeFile(source, "bubblewrap") + await fs.mkdir(path.dirname(dest), { recursive: true }) + await fs.symlink(source, dest) + await fs.writeFile(`${dest}.sha256`, "0".repeat(64)) + expect(validLocalBwrap(dest)).toBe(false) + + await fs.rm(cache, { recursive: true, force: true }) + await fs.mkdir(cache, { recursive: true, mode: 0o777 }) + await fs.chmod(cache, 0o777) + process.env.KILO_BWRAP_PATH = source + await expect(ensureBwrapForTarget("linux-x64", cache)).rejects.toThrow("cache directory is not private") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it("does not stage Bubblewrap for unsupported operating systems", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-bwrap-")) + try { + process.env.KILO_BWRAP_PATH = path.join(root, "missing") + + const dest = await ensureBwrapForTarget("darwin-arm64", path.join(root, "cache")) + + expect(dest).toBeUndefined() + expect(resolveLocalBwrapEnv(path.join(root, "extension"), true, "darwin-arm64", root)).toEqual({}) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) +})