mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat(cli): add Linux filesystem sandbox
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS sandbox.
|
||||
Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS and Linux sandboxes.
|
||||
|
||||
@@ -84,6 +84,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Setup Zig for Linux sandbox helpers
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: 0.14.0
|
||||
use-cache: false
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
@@ -179,6 +185,13 @@ jobs:
|
||||
smoke_host() {
|
||||
binary="$1"
|
||||
"$binary" --version
|
||||
helper="$(dirname "$binary")/bwrap"
|
||||
if [[ "${{ matrix.target }}" == linux-* ]]; then
|
||||
test -x "$helper"
|
||||
"$helper" --version
|
||||
"$helper" --unshare-user --disable-userns --unshare-pid --die-with-parent --new-session \
|
||||
--ro-bind / / --dev /dev --proc /proc -- "$helper" --version
|
||||
fi
|
||||
root="$(mktemp -d)"
|
||||
trap 'rm -rf "$root"' RETURN
|
||||
(
|
||||
@@ -214,6 +227,7 @@ jobs:
|
||||
# kilocode_change end
|
||||
binary="/dist/$PACKAGE/bin/kilo"
|
||||
"$binary" --version
|
||||
"/dist/$PACKAGE/bin/bwrap" --version
|
||||
root="$(mktemp -d)"
|
||||
trap '\''rm -rf "$root"'\'' EXIT
|
||||
unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR
|
||||
|
||||
@@ -63,6 +63,19 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Setup Zig for Linux sandbox helper
|
||||
if: runner.os == 'Linux'
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: 0.14.0
|
||||
use-cache: false
|
||||
|
||||
- name: Build Linux sandbox helper
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
bun packages/opencode/script/kilocode/bubblewrap.ts --arch x64 --output "$RUNNER_TEMP/bwrap"
|
||||
echo "KILO_BWRAP_PATH=$RUNNER_TEMP/bwrap" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com"
|
||||
@@ -83,6 +96,12 @@ jobs:
|
||||
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
KILO_TEST_PROFILE: ${{ runner.os == 'macOS' && github.event_name == 'pull_request' && 'darwin' || '' }} # kilocode_change
|
||||
|
||||
- name: Test nested mount rejection
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo env PATH="$PATH" KILO_BWRAP_PATH="$KILO_BWRAP_PATH" KILO_TEST_PRIVILEGED_MOUNTS=1 \
|
||||
"$(command -v bun)" test packages/core/test/kilocode/linux-sandbox.test.ts -t "nested mount"
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux' # kilocode_change
|
||||
working-directory: packages/opencode
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@
|
||||
stdenvNoCC,
|
||||
callPackage,
|
||||
bun,
|
||||
bubblewrap,
|
||||
nodejs,
|
||||
sysctl,
|
||||
makeBinaryWrapper,
|
||||
@@ -39,6 +40,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
|
||||
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
||||
env.KILO_DISABLE_MODELS_FETCH = true;
|
||||
env.KILO_SKIP_BUNDLED_BWRAP = "1";
|
||||
env.KILO_VERSION = finalAttrs.version;
|
||||
env.KILO_CHANNEL = "local";
|
||||
|
||||
@@ -59,6 +61,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
install -Dm644 schema.json $out/share/kilo/schema.json
|
||||
|
||||
wrapProgram $out/bin/kilo \
|
||||
${lib.optionalString stdenvNoCC.hostPlatform.isLinux "--set KILO_BWRAP_PATH ${bubblewrap}/bin/bwrap"} \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath (
|
||||
[
|
||||
@@ -97,7 +100,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
meta = {
|
||||
description = "AI-powered development tool";
|
||||
homepage = "https://kilo.ai/";
|
||||
license = lib.licenses.mit;
|
||||
license = [ lib.licenses.mit ] ++ lib.optional stdenvNoCC.hostPlatform.isLinux lib.licenses.lgpl2Plus;
|
||||
mainProgram = "kilo";
|
||||
inherit (node_modules.meta) platforms;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { backendSupport, run, type Profile } from "@kilocode/sandbox"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
||||
const linux = process.platform === "linux" ? test : test.skip
|
||||
const privileged = process.platform === "linux" && process.env.KILO_TEST_PRIVILEGED_MOUNTS === "1" ? test : test.skip
|
||||
|
||||
function profile(allow: ReadonlyArray<string>, denyNames: ReadonlyArray<string> = []): Profile {
|
||||
return {
|
||||
filesystem: {
|
||||
allowWrite: allow.map((path) => ({ path, kind: "subtree" })),
|
||||
denyWrite: [],
|
||||
denyNames,
|
||||
},
|
||||
network: { mode: "allow", allowedHosts: [] },
|
||||
environment: { deny: [], set: {} },
|
||||
}
|
||||
}
|
||||
|
||||
function denied(base: Profile, rules: Profile["filesystem"]["denyWrite"]): Profile {
|
||||
return { ...base, filesystem: { ...base.filesystem, denyWrite: rules } }
|
||||
}
|
||||
|
||||
function spawn(script: string, cwd: string, policy: Profile) {
|
||||
return Effect.scoped(
|
||||
run(
|
||||
policy,
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((spawner) =>
|
||||
spawner
|
||||
.spawn(ChildProcess.make(process.execPath, ["-e", script], { cwd }))
|
||||
.pipe(Effect.flatMap((handle) => handle.exitCode)),
|
||||
),
|
||||
).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
)
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-linux-sandbox-"))
|
||||
const project = path.join(root, "project")
|
||||
const outside = path.join(root, "outside")
|
||||
await fs.mkdir(project)
|
||||
await fs.mkdir(outside)
|
||||
return { root, project, outside }
|
||||
}
|
||||
|
||||
linux("confines writes from spawned processes to the profile allowlist", async () => {
|
||||
const support = backendSupport()
|
||||
expect(support.available, support.reason).toBe(true)
|
||||
const root = await fixture()
|
||||
const allowed = path.join(root.project, "allowed.txt")
|
||||
const sentinel = path.join(root.outside, "sentinel.txt")
|
||||
await fs.writeFile(sentinel, "original")
|
||||
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
`fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`,
|
||||
"try {",
|
||||
` fs.writeFileSync(${JSON.stringify(sentinel)}, "escaped")`,
|
||||
" process.exit(2)",
|
||||
"} catch {",
|
||||
" process.exit(0)",
|
||||
"}",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0)
|
||||
expect(await fs.readFile(allowed, "utf8")).toBe("allowed")
|
||||
expect(await fs.readFile(sentinel, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("keeps reads available when no paths are writable", async () => {
|
||||
const root = await fixture()
|
||||
const sentinel = path.join(root.project, "sentinel.txt")
|
||||
await fs.writeFile(sentinel, "original")
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
`if (fs.readFileSync(${JSON.stringify(sentinel)}, "utf8") !== "original") process.exit(2)`,
|
||||
"try {",
|
||||
` fs.writeFileSync(${JSON.stringify(sentinel)}, "escaped")`,
|
||||
" process.exit(3)",
|
||||
"} catch {",
|
||||
" process.exit(0)",
|
||||
"}",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([]))))).toBe(0)
|
||||
expect(await fs.readFile(sentinel, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("keeps existing git metadata read-only under a writable project", async () => {
|
||||
const root = await fixture()
|
||||
const git = path.join(root.project, ".git")
|
||||
const config = path.join(git, "config")
|
||||
const allowed = path.join(root.project, "allowed.txt")
|
||||
await fs.mkdir(git)
|
||||
await fs.writeFile(config, "original")
|
||||
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
`fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`,
|
||||
"try {",
|
||||
` fs.writeFileSync(${JSON.stringify(config)}, "escaped")`,
|
||||
" process.exit(2)",
|
||||
"} catch {",
|
||||
" process.exit(0)",
|
||||
"}",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [".git"]))))).toBe(0)
|
||||
expect(await fs.readFile(allowed, "utf8")).toBe("allowed")
|
||||
expect(await fs.readFile(config, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("keeps existing nested git metadata read-only", async () => {
|
||||
const root = await fixture()
|
||||
const git = path.join(root.project, "packages", "nested", ".git")
|
||||
const config = path.join(git, "config")
|
||||
const allowed = path.join(root.project, "allowed.txt")
|
||||
await fs.mkdir(git, { recursive: true })
|
||||
await fs.writeFile(config, "original")
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
`fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`,
|
||||
"try {",
|
||||
` fs.writeFileSync(${JSON.stringify(config)}, "escaped")`,
|
||||
" process.exit(2)",
|
||||
"} catch {",
|
||||
" process.exit(0)",
|
||||
"}",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [".git"]))))).toBe(0)
|
||||
expect(await fs.readFile(config, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("keeps worktree git marker files read-only", async () => {
|
||||
const root = await fixture()
|
||||
const marker = path.join(root.project, ".git")
|
||||
const renamed = path.join(root.project, ".git-moved")
|
||||
await fs.writeFile(marker, "gitdir: /outside")
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
"let blocked = 0",
|
||||
`try { fs.writeFileSync(${JSON.stringify(marker)}, "escaped") } catch { blocked++ }`,
|
||||
`try { fs.renameSync(${JSON.stringify(marker)}, ${JSON.stringify(renamed)}) } catch { blocked++ }`,
|
||||
"process.exit(blocked === 2 ? 0 : 2)",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [".git"]))))).toBe(0)
|
||||
expect(await fs.readFile(marker, "utf8")).toBe("gitdir: /outside")
|
||||
expect(
|
||||
await fs.stat(renamed).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).toBe(false)
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("applies explicit file and subtree denies after a writable parent", async () => {
|
||||
const root = await fixture()
|
||||
const file = path.join(root.project, "protected.txt")
|
||||
const dir = path.join(root.project, "protected")
|
||||
const nested = path.join(dir, "value.txt")
|
||||
const allowed = path.join(root.project, "allowed.txt")
|
||||
await fs.writeFile(file, "original")
|
||||
await fs.mkdir(dir)
|
||||
await fs.writeFile(nested, "original")
|
||||
const policy = denied(profile([root.project]), [
|
||||
{ path: file, kind: "literal" },
|
||||
{ path: dir, kind: "subtree" },
|
||||
])
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
`fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`,
|
||||
"let blocked = 0",
|
||||
`try { fs.writeFileSync(${JSON.stringify(file)}, "escaped") } catch { blocked++ }`,
|
||||
`try { fs.writeFileSync(${JSON.stringify(nested)}, "escaped") } catch { blocked++ }`,
|
||||
"process.exit(blocked === 2 ? 0 : 2)",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0)
|
||||
expect(await fs.readFile(allowed, "utf8")).toBe("allowed")
|
||||
expect(await fs.readFile(file, "utf8")).toBe("original")
|
||||
expect(await fs.readFile(nested, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("supports writable literal files without opening writable siblings", async () => {
|
||||
const root = await fixture()
|
||||
const allowed = path.join(root.project, "allowed.txt")
|
||||
const sibling = path.join(root.project, "sibling.txt")
|
||||
await fs.writeFile(allowed, "original")
|
||||
await fs.writeFile(sibling, "original")
|
||||
const base = profile([])
|
||||
const policy: Profile = {
|
||||
...base,
|
||||
filesystem: { ...base.filesystem, allowWrite: [{ path: allowed, kind: "literal" }] },
|
||||
}
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
`fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`,
|
||||
"try {",
|
||||
` fs.writeFileSync(${JSON.stringify(sibling)}, "escaped")`,
|
||||
" process.exit(2)",
|
||||
"} catch {",
|
||||
" process.exit(0)",
|
||||
"}",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0)
|
||||
expect(await fs.readFile(allowed, "utf8")).toBe("allowed")
|
||||
expect(await fs.readFile(sibling, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("blocks writes through a project symlink to an outside path", async () => {
|
||||
const root = await fixture()
|
||||
const sentinel = path.join(root.outside, "sentinel.txt")
|
||||
const link = path.join(root.project, "outside")
|
||||
await fs.writeFile(sentinel, "original")
|
||||
await fs.symlink(root.outside, link)
|
||||
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
"try {",
|
||||
` fs.writeFileSync(${JSON.stringify(path.join(link, "sentinel.txt"))}, "escaped")`,
|
||||
" process.exit(2)",
|
||||
"} catch {",
|
||||
" process.exit(0)",
|
||||
"}",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0)
|
||||
expect(await fs.readFile(sentinel, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("allows every profile root including configured temp and cache paths", async () => {
|
||||
const root = await fixture()
|
||||
const temp = path.join(root.root, "temp")
|
||||
const cache = path.join(root.root, "cache")
|
||||
await fs.mkdir(temp)
|
||||
await fs.mkdir(cache)
|
||||
const base = profile([root.project, temp, cache])
|
||||
const policy: Profile = {
|
||||
...base,
|
||||
filesystem: { ...base.filesystem, temporaryDirectory: temp },
|
||||
environment: { ...base.environment, set: { TMPDIR: temp } },
|
||||
}
|
||||
|
||||
const files = [path.join(root.project, "project.txt"), path.join(temp, "temp.txt"), path.join(cache, "cache.txt")]
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
...files.map((file) => `fs.writeFileSync(${JSON.stringify(file)}, "allowed")`),
|
||||
`if (process.env.TMPDIR !== ${JSON.stringify(temp)}) process.exit(2)`,
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0)
|
||||
expect(await Promise.all(files.map((file) => fs.readFile(file, "utf8")))).toEqual(["allowed", "allowed", "allowed"])
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("applies the profile environment without inheriting denied values", async () => {
|
||||
const root = await fixture()
|
||||
const base = profile([root.project])
|
||||
const policy: Profile = {
|
||||
...base,
|
||||
environment: { deny: ["KILO_SANDBOX_DENIED"], set: { KILO_SANDBOX_SET: "expected" } },
|
||||
}
|
||||
const script = [
|
||||
'if (process.env.KILO_SANDBOX_SET !== "expected") process.exit(2)',
|
||||
"if (process.env.KILO_SANDBOX_DENIED !== undefined) process.exit(3)",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
const effect = Effect.scoped(
|
||||
run(
|
||||
policy,
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((spawner) =>
|
||||
spawner
|
||||
.spawn(
|
||||
ChildProcess.make(process.execPath, ["-e", script], {
|
||||
cwd: root.project,
|
||||
env: { KILO_SANDBOX_DENIED: "ambient" },
|
||||
extendEnv: true,
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.flatMap((handle) => handle.exitCode)),
|
||||
),
|
||||
).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
)
|
||||
expect(Number(await Effect.runPromise(effect))).toBe(0)
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("confines writes from descendant processes", async () => {
|
||||
const root = await fixture()
|
||||
const allowed = path.join(root.project, "child.txt")
|
||||
const sentinel = path.join(root.outside, "sentinel.txt")
|
||||
await fs.writeFile(sentinel, "original")
|
||||
const child = [
|
||||
'const fs = require("node:fs")',
|
||||
`fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`,
|
||||
"try {",
|
||||
` fs.writeFileSync(${JSON.stringify(sentinel)}, "escaped")`,
|
||||
" process.exit(2)",
|
||||
"} catch {",
|
||||
" process.exit(0)",
|
||||
"}",
|
||||
].join("\n")
|
||||
const script = [
|
||||
'const child = require("node:child_process")',
|
||||
`const result = child.spawnSync(process.execPath, ["-e", ${JSON.stringify(child)}])`,
|
||||
"process.exit(result.status ?? 3)",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0)
|
||||
expect(await fs.readFile(allowed, "utf8")).toBe("allowed")
|
||||
expect(await fs.readFile(sentinel, "utf8")).toBe("original")
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("terminates daemonized descendants when the command scope closes", async () => {
|
||||
const root = await fixture()
|
||||
const ready = path.join(root.project, "ready")
|
||||
const marker = path.join(root.project, "marker")
|
||||
const child = [
|
||||
'const fs = require("node:fs")',
|
||||
`setInterval(() => fs.writeFileSync(${JSON.stringify(marker)}, String(Date.now())), 20)`,
|
||||
].join("\n")
|
||||
const script = [
|
||||
'const fs = require("node:fs")',
|
||||
'const child = require("node:child_process")',
|
||||
`const proc = child.spawn(process.execPath, ["-e", ${JSON.stringify(child)}], { detached: true, stdio: "ignore" })`,
|
||||
"proc.unref()",
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "ready")`,
|
||||
"setInterval(() => {}, 10_000)",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
run(
|
||||
profile([root.project]),
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((spawner) =>
|
||||
Effect.gen(function* () {
|
||||
yield* spawner.spawn(ChildProcess.make(process.execPath, ["-e", script], { cwd: root.project }))
|
||||
yield* Effect.promise(async () => {
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline) {
|
||||
const started = await Promise.all(
|
||||
[ready, marker].map((file) =>
|
||||
fs.stat(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (started.every(Boolean)) return
|
||||
await Bun.sleep(20)
|
||||
}
|
||||
throw new Error("daemonized child did not start")
|
||||
})
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
||||
),
|
||||
)
|
||||
|
||||
await Bun.sleep(100)
|
||||
const stopped = await fs.readFile(marker, "utf8")
|
||||
await Bun.sleep(150)
|
||||
expect(await fs.readFile(marker, "utf8")).toBe(stopped)
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("rejects a Bubblewrap helper inside a writable root", async () => {
|
||||
const root = await fixture()
|
||||
const source = process.env.KILO_BWRAP_PATH ?? "/usr/bin/bwrap"
|
||||
const helper = path.join(root.project, "bwrap")
|
||||
const link = path.join(root.outside, "bwrap")
|
||||
await fs.copyFile(source, helper)
|
||||
await fs.chmod(helper, 0o755)
|
||||
await fs.symlink(helper, link)
|
||||
const script = [
|
||||
'import { Effect } from "effect"',
|
||||
'import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"',
|
||||
'import { backendSupport, run } from "@kilocode/sandbox"',
|
||||
'import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"',
|
||||
"if (!backendSupport().available) process.exit(2)",
|
||||
`const profile = { filesystem: { allowWrite: [{ path: ${JSON.stringify(root.project)}, kind: "subtree" }], denyWrite: [], denyNames: [] }, network: { mode: "allow", allowedHosts: [] }, environment: { deny: [], set: {} } }`,
|
||||
'const effect = Effect.scoped(run(profile, ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.spawn(ChildProcess.make(process.execPath, ["-e", "process.exit(0)"])))).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)))',
|
||||
"try { await Effect.runPromise(effect); process.exit(3) } catch { process.exit(0) }",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
const result = spawnSync(process.execPath, ["-e", script], {
|
||||
cwd: import.meta.dir,
|
||||
env: { ...process.env, KILO_BWRAP_PATH: link },
|
||||
encoding: "utf8",
|
||||
})
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("fails closed when Bubblewrap is unavailable", () => {
|
||||
const script = [
|
||||
'import { Effect } from "effect"',
|
||||
'import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"',
|
||||
'import { backendSupport, run } from "@kilocode/sandbox"',
|
||||
'import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"',
|
||||
"if (backendSupport().available) process.exit(2)",
|
||||
'const profile = { filesystem: { allowWrite: [], denyWrite: [], denyNames: [] }, network: { mode: "allow", allowedHosts: [] }, environment: { deny: [], set: {} } }',
|
||||
'const effect = Effect.scoped(run(profile, ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.spawn(ChildProcess.make(process.execPath, ["-e", "process.exit(0)"])))).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)))',
|
||||
"try { await Effect.runPromise(effect); process.exit(3) } catch { process.exit(0) }",
|
||||
].join("\n")
|
||||
const result = spawnSync(process.execPath, ["-e", script], {
|
||||
cwd: import.meta.dir,
|
||||
env: { ...process.env, KILO_BWRAP_PATH: "/missing/kilo-bwrap" },
|
||||
encoding: "utf8",
|
||||
})
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
})
|
||||
|
||||
privileged("allows a mounted writable root but rejects its nested mount points", async () => {
|
||||
const root = await fixture()
|
||||
const nested = path.join(root.project, "nested mount")
|
||||
const mounted = spawnSync("mount", ["-t", "tmpfs", "tmpfs", root.project], { encoding: "utf8" })
|
||||
expect(mounted.status, mounted.stderr).toBe(0)
|
||||
|
||||
try {
|
||||
const allowed = path.join(root.project, "allowed.txt")
|
||||
const script = `require("node:fs").writeFileSync(${JSON.stringify(allowed)}, "allowed")`
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0)
|
||||
expect(await fs.readFile(allowed, "utf8")).toBe("allowed")
|
||||
|
||||
await fs.mkdir(nested)
|
||||
const child = spawnSync("mount", ["-t", "tmpfs", "tmpfs", nested], { encoding: "utf8" })
|
||||
expect(child.status, child.stderr).toBe(0)
|
||||
try {
|
||||
await expect(Effect.runPromise(spawn("process.exit(0)", root.project, profile([root.project])))).rejects.toThrow(
|
||||
"nested mount point",
|
||||
)
|
||||
} finally {
|
||||
const unmounted = spawnSync("umount", [nested], { encoding: "utf8" })
|
||||
expect(unmounted.status, unmounted.stderr).toBe(0)
|
||||
}
|
||||
} finally {
|
||||
const unmounted = spawnSync("umount", [root.project], { encoding: "utf8" })
|
||||
expect(unmounted.status, unmounted.stderr).toBe(0)
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect, PlatformError, Scope } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { bubblewrap } from "./bubblewrap"
|
||||
import { current } from "./context"
|
||||
import type { Profile } from "./profile"
|
||||
import { seatbelt } from "./seatbelt"
|
||||
@@ -18,13 +19,16 @@ export interface Support {
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
readonly support: Support
|
||||
readonly prepare: (profile: Profile, launch: Launch) => Effect.Effect<Launch, never, Scope.Scope>
|
||||
readonly support: () => Support
|
||||
readonly prepare: (
|
||||
profile: Profile,
|
||||
launch: Launch,
|
||||
) => Effect.Effect<Launch, PlatformError.PlatformError, Scope.Scope>
|
||||
}
|
||||
|
||||
function unavailable(reason: string): Backend {
|
||||
return {
|
||||
support: { available: false, reason },
|
||||
support: () => ({ available: false, reason }),
|
||||
prepare: (_profile, launch) => Effect.succeed(launch),
|
||||
}
|
||||
}
|
||||
@@ -34,7 +38,7 @@ function select(): Backend {
|
||||
case "darwin":
|
||||
return seatbelt
|
||||
case "linux":
|
||||
return unavailable("The Linux sandbox backend is not available")
|
||||
return bubblewrap
|
||||
case "win32":
|
||||
return unavailable("The Windows sandbox backend is not available")
|
||||
default:
|
||||
@@ -57,18 +61,18 @@ export function prepare(launch: Launch) {
|
||||
const profile = yield* current
|
||||
if (!profile) return launch
|
||||
const next = { ...launch, environment: environment(profile, launch) }
|
||||
if (!backend.support.available) return next
|
||||
if (!backend.support().available) return next
|
||||
return yield* backend.prepare(profile, next)
|
||||
})
|
||||
}
|
||||
|
||||
function unsupported(command: string) {
|
||||
function unsupported(command: string, support: Support) {
|
||||
return PlatformError.systemError({
|
||||
_tag: "PermissionDenied",
|
||||
module: "Sandbox",
|
||||
method: "prepareCommand",
|
||||
pathOrDescriptor: command,
|
||||
description: backend.support.reason ?? "The process sandbox backend is unavailable",
|
||||
description: support.reason ?? "The process sandbox backend is unavailable",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,7 +83,8 @@ export function prepareCommand(
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (!(yield* current)) return command
|
||||
if (!backend.support.available) return yield* Effect.fail(unsupported(command.command))
|
||||
const support = backend.support()
|
||||
if (!support.available) return yield* Effect.fail(unsupported(command.command, support))
|
||||
const launch = yield* prepare({
|
||||
command: command.command,
|
||||
args: command.args,
|
||||
@@ -97,4 +102,6 @@ export function prepareCommand(
|
||||
})
|
||||
}
|
||||
|
||||
export const backendSupport = backend.support
|
||||
export function backendSupport() {
|
||||
return backend.support()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { Effect, PlatformError } from "effect"
|
||||
import type { Backend, Launch, Support } from "./backend"
|
||||
import type { PathRule, Profile } from "./profile"
|
||||
|
||||
declare const KILO_BWRAP_SHA256: string | undefined
|
||||
|
||||
const system = "/usr/bin/bwrap"
|
||||
|
||||
function quote(value: string) {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function command(launch: Launch) {
|
||||
if (!launch.shell) return [launch.command, ...launch.args]
|
||||
const shell = typeof launch.shell === "string" ? launch.shell : "/bin/sh"
|
||||
return [shell, "-c", [launch.command, ...launch.args.map(quote)].join(" ")]
|
||||
}
|
||||
|
||||
function exists(rule: PathRule) {
|
||||
if (!existsSync(rule.path)) return false
|
||||
const entry = statSync(rule.path)
|
||||
if (rule.kind === "literal") return entry.isFile()
|
||||
return entry.isDirectory()
|
||||
}
|
||||
|
||||
function writable(profile: Profile) {
|
||||
const seen = new Set<string>()
|
||||
return profile.filesystem.allowWrite
|
||||
.filter(exists)
|
||||
.filter((rule) => {
|
||||
if (seen.has(rule.path)) return false
|
||||
seen.add(rule.path)
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.path.length - b.path.length)
|
||||
}
|
||||
|
||||
function beneath(root: string, target: string) {
|
||||
const relative = path.relative(root, target)
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function unescape(value: string) {
|
||||
return value.replace(/\\([0-7]{3})/g, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 8)))
|
||||
}
|
||||
|
||||
function mountpoints() {
|
||||
return readFileSync("/proc/self/mountinfo", "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const value = line.split(" ")[4]
|
||||
if (!value) throw new Error("Could not parse /proc/self/mountinfo")
|
||||
return unescape(value)
|
||||
})
|
||||
}
|
||||
|
||||
function validate(allow: ReadonlyArray<PathRule>, executable: string) {
|
||||
if (allow.some((rule) => beneath(rule.path, executable))) {
|
||||
throw new Error(`Bubblewrap executable is writable by the sandbox profile: ${executable}`)
|
||||
}
|
||||
if (process.platform !== "linux") return
|
||||
|
||||
const mounts = mountpoints()
|
||||
for (const rule of allow) {
|
||||
if (rule.kind !== "subtree") continue
|
||||
const nested = mounts.find((mount) => mount !== rule.path && beneath(rule.path, mount))
|
||||
if (nested) throw new Error(`Writable root contains a nested mount point: ${nested}`)
|
||||
}
|
||||
}
|
||||
|
||||
function scan(root: string, names: ReadonlySet<string>, found: Set<string>) {
|
||||
if (names.has(path.basename(root))) {
|
||||
found.add(root)
|
||||
return
|
||||
}
|
||||
if (!statSync(root).isDirectory()) return
|
||||
|
||||
const pending = [root]
|
||||
while (pending.length > 0) {
|
||||
const dir = pending.pop()
|
||||
if (!dir) continue
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const target = path.join(dir, entry.name)
|
||||
if (names.has(entry.name)) {
|
||||
found.add(target)
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory()) pending.push(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function protectedPaths(profile: Profile, allow: ReadonlyArray<PathRule>) {
|
||||
const found = new Set(profile.filesystem.denyWrite.filter((rule) => existsSync(rule.path)).map((rule) => rule.path))
|
||||
if (profile.filesystem.denyNames.length === 0) return [...found]
|
||||
|
||||
const names = new Set(profile.filesystem.denyNames)
|
||||
for (const rule of allow) {
|
||||
if (rule.kind === "subtree") scan(rule.path, names, found)
|
||||
}
|
||||
return [...found].sort((a, b) => a.length - b.length)
|
||||
}
|
||||
|
||||
export function generate(profile: Profile, launch: Launch, executable: string): Launch {
|
||||
const allow = writable(profile)
|
||||
validate(allow, executable)
|
||||
const args = [
|
||||
"--unshare-user",
|
||||
"--disable-userns",
|
||||
"--unshare-pid",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--dev",
|
||||
"/dev",
|
||||
]
|
||||
|
||||
for (const rule of allow) args.push("--bind", rule.path, rule.path)
|
||||
for (const target of protectedPaths(profile, allow)) args.push("--ro-bind", target, target)
|
||||
args.push("--proc", "/proc")
|
||||
if (launch.cwd) args.push("--chdir", launch.cwd)
|
||||
args.push("--", ...command(launch))
|
||||
|
||||
return {
|
||||
...launch,
|
||||
command: executable,
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
function bundled() {
|
||||
return path.join(path.dirname(process.execPath), "bwrap")
|
||||
}
|
||||
|
||||
function digest() {
|
||||
return typeof KILO_BWRAP_SHA256 === "undefined" ? undefined : KILO_BWRAP_SHA256
|
||||
}
|
||||
|
||||
function resolve(executable: string, expected?: string) {
|
||||
try {
|
||||
if (!path.isAbsolute(executable)) return
|
||||
const target = realpathSync.native(executable)
|
||||
const entry = statSync(target)
|
||||
if (!entry.isFile() || (entry.mode & 0o6000) !== 0) return
|
||||
if (expected && createHash("sha256").update(readFileSync(target)).digest("hex") !== expected) return
|
||||
return target
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function probe(executable: string) {
|
||||
const result = spawnSync(
|
||||
executable,
|
||||
[
|
||||
"--unshare-user",
|
||||
"--disable-userns",
|
||||
"--unshare-pid",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--",
|
||||
executable,
|
||||
"--version",
|
||||
],
|
||||
{ encoding: "utf8", timeout: 5_000 },
|
||||
)
|
||||
if (result.status === 0) return undefined
|
||||
const detail = result.error?.message ?? (result.stderr.trim() || `exited with status ${result.status}`)
|
||||
return `${executable} could not create the Linux sandbox: ${detail}`
|
||||
}
|
||||
|
||||
function select() {
|
||||
const override = process.env.KILO_BWRAP_PATH
|
||||
const candidates = override
|
||||
? [{ executable: override }]
|
||||
: [{ executable: system }, { executable: bundled(), expected: digest() }]
|
||||
const failures: Array<string> = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const executable = resolve(candidate.executable, candidate.expected)
|
||||
if (!executable) continue
|
||||
const failure = probe(executable)
|
||||
if (!failure) return { executable, support: { available: true } satisfies Support }
|
||||
failures.push(failure)
|
||||
}
|
||||
|
||||
return {
|
||||
executable: undefined,
|
||||
support: {
|
||||
available: false,
|
||||
reason: failures.at(-1) ?? "No usable Bubblewrap executable is available",
|
||||
} satisfies Support,
|
||||
}
|
||||
}
|
||||
|
||||
type Selection = ReturnType<typeof select>
|
||||
|
||||
let selected: Selection | undefined
|
||||
|
||||
function selection(): Selection {
|
||||
if (selected) return selected
|
||||
selected =
|
||||
process.platform === "linux"
|
||||
? select()
|
||||
: { executable: undefined, support: { available: false, reason: "Bubblewrap requires Linux" } satisfies Support }
|
||||
return selected
|
||||
}
|
||||
|
||||
function setup(cause: unknown, launch: Launch) {
|
||||
return PlatformError.systemError({
|
||||
_tag: "PermissionDenied",
|
||||
module: "Sandbox",
|
||||
method: "prepareCommand",
|
||||
pathOrDescriptor: launch.command,
|
||||
description: cause instanceof Error ? cause.message : "Could not construct the Linux sandbox",
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
export const bubblewrap: Backend = {
|
||||
support: () => selection().support,
|
||||
prepare: (profile, launch) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
const selected = selection()
|
||||
return selected.executable ? generate(profile, launch, selected.executable) : launch
|
||||
},
|
||||
catch: (cause) => setup(cause, launch),
|
||||
}),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { Profile } from "./profile"
|
||||
export { assertWrite, enabled, run } from "./context"
|
||||
export { decorateFileSystem } from "./filesystem"
|
||||
export { prepareCommand } from "./backend"
|
||||
export { backendSupport, prepareCommand } from "./backend"
|
||||
|
||||
@@ -70,6 +70,6 @@ const available: Support = existsSync(executable)
|
||||
: { available: false, reason: `${executable} is not available` }
|
||||
|
||||
export const seatbelt: Backend = {
|
||||
support: available,
|
||||
support: () => available,
|
||||
prepare: (profile, launch) => Effect.succeed(generate(profile, launch)),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { backendSupport, prepare, type Launch } from "../src/backend"
|
||||
import { generate as generateBubblewrap } from "../src/bubblewrap"
|
||||
import { run } from "../src/context"
|
||||
import type { Profile } from "../src/profile"
|
||||
import { generate } from "../src/seatbelt"
|
||||
@@ -54,6 +58,55 @@ describe("sandbox launch preparation", () => {
|
||||
expect(args.args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "printf '%s' 'hello world'"])
|
||||
})
|
||||
|
||||
test("layers Linux writable roots before protected git metadata without changing the network namespace", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-policy-"))
|
||||
const git = path.join(root, ".git")
|
||||
mkdirSync(git)
|
||||
writeFileSync(path.join(git, "config"), "original")
|
||||
const profile: Profile = {
|
||||
...makeProfile(),
|
||||
filesystem: {
|
||||
allowWrite: [{ path: root, kind: "subtree" }],
|
||||
denyWrite: [],
|
||||
denyNames: [".git"],
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const result = generateBubblewrap(profile, { ...launch, cwd: root }, "/opt/kilo/bwrap")
|
||||
const writable = result.args.indexOf("--bind")
|
||||
const protectedPath = result.args.indexOf("--ro-bind", writable + 1)
|
||||
expect(result.command).toBe("/opt/kilo/bwrap")
|
||||
expect(writable).toBeGreaterThan(-1)
|
||||
expect(protectedPath).toBeGreaterThan(writable)
|
||||
expect(result.args.slice(protectedPath, protectedPath + 3)).toEqual(["--ro-bind", git, git])
|
||||
expect(result.args).not.toContain("--unshare-net")
|
||||
expect(result.args.slice(-3)).toEqual(["--", "/bin/echo", "hello"])
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects a Bubblewrap executable inside a writable root", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-helper-"))
|
||||
const helper = path.join(root, "bwrap")
|
||||
writeFileSync(helper, "helper")
|
||||
const profile: Profile = {
|
||||
...makeProfile(),
|
||||
filesystem: {
|
||||
allowWrite: [{ path: root, kind: "subtree" }],
|
||||
denyWrite: [],
|
||||
denyNames: [],
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
expect(() => generateBubblewrap(profile, launch, helper)).toThrow("writable by the sandbox profile")
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("passes the launch through unchanged when no profile is active", async () => {
|
||||
const result = await Effect.runPromise(Effect.scoped(prepare(launch)))
|
||||
expect(result.command).toBe(launch.command)
|
||||
@@ -71,7 +124,8 @@ describe("sandbox launch preparation", () => {
|
||||
})
|
||||
|
||||
test("reports backend support with a reason when unavailable", () => {
|
||||
expect(typeof backendSupport.available).toBe("boolean")
|
||||
if (!backendSupport.available) expect(backendSupport.reason?.length).toBeGreaterThan(0)
|
||||
const support = backendSupport()
|
||||
expect(typeof support.available).toBe("boolean")
|
||||
if (!support.available) expect(support.reason?.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { $ } from "bun"
|
||||
import { join } from "node:path"
|
||||
import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs"
|
||||
import { copyTreeSitterResources } from "../src/services/cli-backend/cli-resources"
|
||||
import { copySandboxResources, copyTreeSitterResources } from "../src/services/cli-backend/cli-resources"
|
||||
import { ensureFfmpegForTarget } from "./ffmpeg-helper"
|
||||
|
||||
const packageJsonPath = join(import.meta.dir, "..", "package.json")
|
||||
@@ -77,6 +77,7 @@ for (const config of targets) {
|
||||
console.log(` 📥 Copying binary from ${config.cliDir}/bin/${config.binary}...`)
|
||||
await $`cp ${sourceBinary} ${targetBinary}`
|
||||
await copyTreeSitterResources(sourceBinary, targetBinary)
|
||||
await copySandboxResources(sourceBinary, targetBinary)
|
||||
|
||||
if (config.binary !== "kilo.exe") {
|
||||
chmodSync(targetBinary, 0o755)
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
import { $ } from "bun"
|
||||
import { join, relative, dirname, basename } from "node:path"
|
||||
import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs"
|
||||
import { copyTreeSitterResources, hasTreeSitterResources } from "../src/services/cli-backend/cli-resources"
|
||||
import {
|
||||
copySandboxResources,
|
||||
copyTreeSitterResources,
|
||||
hasTreeSitterResources,
|
||||
} from "../src/services/cli-backend/cli-resources"
|
||||
import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper"
|
||||
|
||||
const forceRebuild = process.argv.includes("--force")
|
||||
@@ -234,6 +238,7 @@ async function main() {
|
||||
await $`mkdir -p ${targetBinDir}`
|
||||
await $`cp ${sourceBinPath} ${targetBinPath}`
|
||||
await copyTreeSitterResources(sourceBinPath, targetBinPath)
|
||||
await copySandboxResources(sourceBinPath, targetBinPath)
|
||||
chmodSync(targetBinPath, 0o755)
|
||||
await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { watch, chmodSync } from "node:fs"
|
||||
import { join, relative } from "node:path"
|
||||
import { $ } from "bun"
|
||||
import { copyTreeSitterResources } from "../src/services/cli-backend/cli-resources"
|
||||
import { copySandboxResources, copyTreeSitterResources } from "../src/services/cli-backend/cli-resources"
|
||||
|
||||
const kiloVscodeDir = join(import.meta.dir, "..")
|
||||
const packagesDir = join(kiloVscodeDir, "..")
|
||||
@@ -59,6 +59,7 @@ async function rebuild() {
|
||||
await $`mkdir -p ${targetBinDir}`
|
||||
await $`cp ${source} ${targetBinPath}`
|
||||
await copyTreeSitterResources(source, targetBinPath)
|
||||
await copySandboxResources(source, targetBinPath)
|
||||
chmodSync(targetBinPath, 0o755)
|
||||
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(1)
|
||||
|
||||
@@ -37,3 +37,20 @@ export async function copyTreeSitterResources(source: string, target: string): P
|
||||
await fs.promises.rm(to, { recursive: true, force: true })
|
||||
await fs.promises.cp(from, to, { recursive: true })
|
||||
}
|
||||
|
||||
export async function copySandboxResources(source: string, target: string): Promise<void> {
|
||||
const from = path.dirname(source)
|
||||
const to = path.dirname(target)
|
||||
const bwrap = path.join(from, "bwrap")
|
||||
if (!fs.existsSync(bwrap)) return
|
||||
|
||||
const helper = path.join(to, "bwrap")
|
||||
await fs.promises.copyFile(bwrap, helper)
|
||||
await fs.promises.chmod(helper, 0o755)
|
||||
|
||||
const licenses = path.join(from, "licenses")
|
||||
if (!fs.existsSync(licenses)) return
|
||||
const destination = path.join(to, "licenses")
|
||||
await fs.promises.rm(destination, { recursive: true, force: true })
|
||||
await fs.promises.cp(licenses, destination, { recursive: true })
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
toErrorMessage,
|
||||
} from "../../src/services/cli-backend/server-manager"
|
||||
import {
|
||||
copySandboxResources,
|
||||
copyTreeSitterResources,
|
||||
resolveTreeSitterEnv,
|
||||
treeSitterDirForBinary,
|
||||
@@ -105,6 +106,34 @@ describe("cli tree-sitter resources", () => {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("copies the Linux sandbox helper and license resources", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-sandbox-"))
|
||||
try {
|
||||
const source = path.join(root, "dist", "bin", "kilo")
|
||||
const target = path.join(root, "extension", "bin", "kilo")
|
||||
const helper = path.join(path.dirname(source), "bwrap")
|
||||
const license = path.join(path.dirname(source), "licenses", "bubblewrap", "COPYING")
|
||||
|
||||
await fs.mkdir(path.dirname(license), { recursive: true })
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.writeFile(source, "binary")
|
||||
await fs.writeFile(target, "binary")
|
||||
await fs.writeFile(helper, "helper")
|
||||
await fs.writeFile(license, "LGPL")
|
||||
|
||||
await copySandboxResources(source, target)
|
||||
|
||||
const copied = path.join(path.dirname(target), "bwrap")
|
||||
expect(await fs.readFile(copied, "utf8")).toBe("helper")
|
||||
expect((await fs.stat(copied)).mode & 0o111).not.toBe(0)
|
||||
expect(await fs.readFile(path.join(path.dirname(target), "licenses", "bubblewrap", "COPYING"), "utf8")).toBe(
|
||||
"LGPL",
|
||||
)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("toErrorMessage", () => {
|
||||
|
||||
@@ -7,10 +7,16 @@ ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH}
|
||||
RUN apk add libgcc libstdc++ ripgrep
|
||||
|
||||
FROM base AS build-amd64
|
||||
COPY dist/@kilocode/cli-linux-x64-baseline-musl/bin/kilo /usr/local/bin/kilo
|
||||
# kilocode_change start
|
||||
COPY dist/@kilocode/cli-linux-x64-baseline-musl/bin/kilo dist/@kilocode/cli-linux-x64-baseline-musl/bin/bwrap /usr/local/bin/
|
||||
COPY dist/@kilocode/cli-linux-x64-baseline-musl/bin/licenses /usr/local/share/licenses/kilo
|
||||
# kilocode_change end
|
||||
|
||||
FROM base AS build-arm64
|
||||
COPY dist/@kilocode/cli-linux-arm64-musl/bin/kilo /usr/local/bin/kilo
|
||||
# kilocode_change start
|
||||
COPY dist/@kilocode/cli-linux-arm64-musl/bin/kilo dist/@kilocode/cli-linux-arm64-musl/bin/bwrap /usr/local/bin/
|
||||
COPY dist/@kilocode/cli-linux-arm64-musl/bin/licenses /usr/local/share/licenses/kilo
|
||||
# kilocode_change end
|
||||
|
||||
ARG TARGETARCH
|
||||
FROM build-${TARGETARCH}
|
||||
|
||||
@@ -19,6 +19,7 @@ const generated = await import("./generate.ts")
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import pkg from "../package.json"
|
||||
import { stageBubblewrap } from "./kilocode/bubblewrap" // kilocode_change
|
||||
import { LanceDBRuntime } from "../src/kilocode/lancedb" // kilocode_change
|
||||
|
||||
// Load migrations from migration directories
|
||||
@@ -271,6 +272,12 @@ for (const item of targets) {
|
||||
|
||||
console.log(`building ${name}`)
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
// kilocode_change start
|
||||
const bwrap =
|
||||
item.os === "linux" && process.env.KILO_SKIP_BUNDLED_BWRAP !== "1"
|
||||
? await stageBubblewrap(item.arch, path.resolve(dir, `dist/${name}/bin`))
|
||||
: undefined
|
||||
// kilocode_change end
|
||||
|
||||
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
@@ -326,6 +333,7 @@ for (const item of targets) {
|
||||
KILO_INDEXING_WORKER_PATH: indexingWorkerPath, // kilocode_change
|
||||
KILO_CHANNEL: `'${Script.channel}'`,
|
||||
KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
|
||||
KILO_BWRAP_SHA256: bwrap ? `'${bwrap}'` : "undefined", // kilocode_change
|
||||
KILO_BUILD_KIND: Script.release ? `'release'` : `'source'`, // kilocode_change
|
||||
},
|
||||
})
|
||||
@@ -376,6 +384,7 @@ for (const item of targets) {
|
||||
{
|
||||
name,
|
||||
version: Script.version,
|
||||
license: pkg.license, // kilocode_change
|
||||
preferUnplugged: true,
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const version = "0.11.2"
|
||||
const commit = "1b80120ef26a28e065e67f89bfef873f13bdd317"
|
||||
const sourceUrl = `https://codeload.github.com/containers/bubblewrap/tar.gz/${commit}`
|
||||
const sourceSha256 = "55a1f42de8f62f6cd8cc414229ce166ec6128ca4386b8c25dfca4229e44b56aa"
|
||||
const cache = process.env.KILO_BWRAP_CACHE ?? path.join(os.tmpdir(), "kilo-bubblewrap", commit)
|
||||
|
||||
const capability = `#pragma once
|
||||
#include <errno.h>
|
||||
#include <linux/capability.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef struct __user_cap_header_struct *cap_user_header_t;
|
||||
typedef struct __user_cap_data_struct *cap_user_data_t;
|
||||
typedef int cap_value_t;
|
||||
|
||||
static inline int capget(cap_user_header_t header, cap_user_data_t data) {
|
||||
return (int) syscall(SYS_capget, header, data);
|
||||
}
|
||||
|
||||
static inline int capset(cap_user_header_t header, const cap_user_data_t data) {
|
||||
return (int) syscall(SYS_capset, header, data);
|
||||
}
|
||||
|
||||
static inline int cap_from_name(const char *name, cap_value_t *cap) {
|
||||
(void) name;
|
||||
(void) cap;
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
`
|
||||
|
||||
const config = `#pragma once
|
||||
#define PACKAGE_STRING "bubblewrap ${version} for Kilo"
|
||||
#define PACKAGE_VERSION "${version}"
|
||||
`
|
||||
|
||||
function sha256(file: string) {
|
||||
return createHash("sha256").update(readFileSync(file)).digest("hex")
|
||||
}
|
||||
|
||||
async function source() {
|
||||
const archive = path.join(cache, `bubblewrap-${commit}.tar.gz`)
|
||||
if (!existsSync(archive) || sha256(archive) !== sourceSha256) {
|
||||
mkdirSync(cache, { recursive: true })
|
||||
const response = await fetch(sourceUrl)
|
||||
if (!response.ok) throw new Error(`Could not download Bubblewrap source: ${response.status}`)
|
||||
await Bun.write(archive, response)
|
||||
if (sha256(archive) !== sourceSha256) throw new Error("Bubblewrap source digest mismatch")
|
||||
}
|
||||
|
||||
const root = path.join(cache, `bubblewrap-${commit}`)
|
||||
if (!existsSync(root)) {
|
||||
const proc = Bun.spawn(["tar", "-xzf", archive, "-C", cache], { stdout: "inherit", stderr: "inherit" })
|
||||
if ((await proc.exited) !== 0) throw new Error("Could not extract Bubblewrap source")
|
||||
}
|
||||
return { archive, root }
|
||||
}
|
||||
|
||||
function target(arch: "x64" | "arm64") {
|
||||
return arch === "x64" ? "x86_64-linux-musl" : "aarch64-linux-musl"
|
||||
}
|
||||
|
||||
function muslLicense(zig: string) {
|
||||
const result = Bun.spawnSync([zig, "env"])
|
||||
if (result.exitCode !== 0) throw new Error("Could not inspect the Zig toolchain")
|
||||
const match = result.stdout.toString().match(/(?:"lib_dir"\s*:\s*|\.lib_dir\s*=\s*)"([^"]+)"/)
|
||||
if (!match) throw new Error("Could not locate Zig's bundled musl license")
|
||||
const license = path.join(match[1], "libc", "musl", "COPYRIGHT")
|
||||
if (!existsSync(license)) throw new Error(`Zig's bundled musl license is missing at ${license}`)
|
||||
return license
|
||||
}
|
||||
|
||||
async function compile(arch: "x64" | "arm64") {
|
||||
const sourceTree = await source()
|
||||
const out = path.join(cache, `bwrap-${arch}`)
|
||||
const include = path.join(cache, "include", "sys")
|
||||
const generated = path.join(cache, "generated")
|
||||
mkdirSync(include, { recursive: true })
|
||||
mkdirSync(generated, { recursive: true })
|
||||
await Bun.write(path.join(include, "capability.h"), capability)
|
||||
await Bun.write(path.join(generated, "config.h"), config)
|
||||
|
||||
const zig = process.env.ZIG ?? "zig"
|
||||
const args = [
|
||||
zig,
|
||||
"cc",
|
||||
"-target",
|
||||
target(arch),
|
||||
"-static",
|
||||
"-fPIE",
|
||||
"-pie",
|
||||
"-s",
|
||||
"-O2",
|
||||
"-D_GNU_SOURCE",
|
||||
"-I",
|
||||
path.join(cache, "include"),
|
||||
"-I",
|
||||
generated,
|
||||
"-I",
|
||||
sourceTree.root,
|
||||
path.join(sourceTree.root, "bubblewrap.c"),
|
||||
path.join(sourceTree.root, "bind-mount.c"),
|
||||
path.join(sourceTree.root, "network.c"),
|
||||
path.join(sourceTree.root, "utils.c"),
|
||||
"-o",
|
||||
out,
|
||||
]
|
||||
const proc = Bun.spawn(args, { stdout: "inherit", stderr: "inherit" })
|
||||
if ((await proc.exited) !== 0) throw new Error(`Could not build Bubblewrap for Linux ${arch}`)
|
||||
chmodSync(out, 0o755)
|
||||
|
||||
return {
|
||||
executable: out,
|
||||
digest: sha256(out),
|
||||
archive: sourceTree.archive,
|
||||
license: path.join(sourceTree.root, "COPYING"),
|
||||
musl: muslLicense(zig),
|
||||
}
|
||||
}
|
||||
|
||||
const builds = new Map<"x64" | "arm64", ReturnType<typeof compile>>()
|
||||
|
||||
export function buildBubblewrap(arch: "x64" | "arm64") {
|
||||
const cached = builds.get(arch)
|
||||
if (cached) return cached
|
||||
const built = compile(arch)
|
||||
builds.set(arch, built)
|
||||
return built
|
||||
}
|
||||
|
||||
export async function stageBubblewrap(arch: "x64" | "arm64", dir: string) {
|
||||
const built = await buildBubblewrap(arch)
|
||||
const licenses = path.join(dir, "licenses", "bubblewrap")
|
||||
mkdirSync(dir, { recursive: true })
|
||||
rmSync(licenses, { recursive: true, force: true })
|
||||
mkdirSync(licenses, { recursive: true })
|
||||
copyFileSync(built.executable, path.join(dir, "bwrap"))
|
||||
copyFileSync(built.license, path.join(licenses, "COPYING"))
|
||||
copyFileSync(built.musl, path.join(licenses, "MUSL-COPYRIGHT"))
|
||||
copyFileSync(built.archive, path.join(licenses, `bubblewrap-${commit}.tar.gz`))
|
||||
copyFileSync(import.meta.path, path.join(licenses, "build.ts"))
|
||||
chmodSync(path.join(dir, "bwrap"), 0o755)
|
||||
return built.digest
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const arch = process.argv[process.argv.indexOf("--arch") + 1]
|
||||
const output = process.argv[process.argv.indexOf("--output") + 1]
|
||||
if ((arch !== "x64" && arch !== "arm64") || !output) {
|
||||
throw new Error("Usage: bun bubblewrap.ts --arch <x64|arm64> --output <path>")
|
||||
}
|
||||
const built = await buildBubblewrap(arch)
|
||||
mkdirSync(path.dirname(output), { recursive: true })
|
||||
copyFileSync(built.executable, output)
|
||||
chmodSync(output, 0o755)
|
||||
console.log(`${output} sha256:${built.digest}`)
|
||||
}
|
||||
@@ -137,6 +137,20 @@ function copyResources(source) {
|
||||
fs.rmSync(target, { recursive: true, force: true })
|
||||
fs.cpSync(dir, target, { recursive: true })
|
||||
}
|
||||
|
||||
const bwrap = path.join(path.dirname(source), "bwrap")
|
||||
if (fs.existsSync(bwrap)) {
|
||||
const target = path.join(__dirname, "bin", "bwrap")
|
||||
fs.copyFileSync(bwrap, target)
|
||||
fs.chmodSync(target, 0o755)
|
||||
}
|
||||
|
||||
const licenses = path.join(path.dirname(source), "licenses")
|
||||
if (fs.existsSync(licenses)) {
|
||||
const target = path.join(__dirname, "bin", "licenses")
|
||||
fs.rmSync(target, { recursive: true, force: true })
|
||||
fs.cpSync(licenses, target, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function copyBinary(source) {
|
||||
|
||||
@@ -105,7 +105,7 @@ if (!Script.preview) {
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
"url='https://github.com/Kilo-Org/kilocode'",
|
||||
"arch=('aarch64' 'x86_64')",
|
||||
"license=('MIT')",
|
||||
"license=('MIT' 'LGPL-2.0-or-later')", // kilocode_change
|
||||
"provides=('kilo')",
|
||||
"conflicts=('kilo')",
|
||||
"depends=('ripgrep')",
|
||||
@@ -118,8 +118,10 @@ if (!Script.preview) {
|
||||
"",
|
||||
"package() {",
|
||||
' install -Dm755 ./kilo "${pkgdir}/usr/lib/kilo/kilo"', // kilocode_change
|
||||
' install -dm755 "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/kilo/tree-sitter"', // kilocode_change
|
||||
' install -Dm755 ./bwrap "${pkgdir}/usr/lib/kilo/bwrap"', // kilocode_change
|
||||
' install -dm755 "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/kilo/tree-sitter" "${pkgdir}/usr/share/licenses/kilo"', // kilocode_change
|
||||
' cp -r ./tree-sitter/. "${pkgdir}/usr/lib/kilo/tree-sitter/"', // kilocode_change
|
||||
' cp -r ./licenses/. "${pkgdir}/usr/share/licenses/kilo/"', // kilocode_change
|
||||
" printf '%s\\n' '#!/bin/sh' 'export KILO_TREE_SITTER_WASM_DIR=/usr/lib/kilo/tree-sitter' 'exec /usr/lib/kilo/kilo \"$@\"' > \"${pkgdir}/usr/bin/kilo\"", // kilocode_change
|
||||
' chmod 755 "${pkgdir}/usr/bin/kilo"', // kilocode_change
|
||||
"}",
|
||||
@@ -184,7 +186,7 @@ if (!Script.preview) {
|
||||
` url "https://github.com/Kilo-Org/kilocode/releases/download/v${Script.version}/kilo-linux-x64.tar.gz"`,
|
||||
` sha256 "${x64Sha}"`,
|
||||
" def install",
|
||||
' libexec.install "kilo", "tree-sitter"', // kilocode_change
|
||||
' libexec.install "kilo", "bwrap", "tree-sitter", "licenses"', // kilocode_change
|
||||
' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change
|
||||
" end",
|
||||
" end",
|
||||
@@ -192,7 +194,7 @@ if (!Script.preview) {
|
||||
` url "https://github.com/Kilo-Org/kilocode/releases/download/v${Script.version}/kilo-linux-arm64.tar.gz"`,
|
||||
` sha256 "${arm64Sha}"`,
|
||||
" def install",
|
||||
' libexec.install "kilo", "tree-sitter"', // kilocode_change
|
||||
' libexec.install "kilo", "bwrap", "tree-sitter", "licenses"', // kilocode_change
|
||||
' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change
|
||||
" end",
|
||||
" end",
|
||||
|
||||
Reference in New Issue
Block a user