feat(cli): add Linux filesystem sandbox

This commit is contained in:
marius-kilocode
2026-06-23 17:50:51 +02:00
parent 588335ef12
commit 209e7a71d5
20 changed files with 1111 additions and 24 deletions
+1 -1
View File
@@ -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.
+14
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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 })
}
})
+16 -9
View File
@@ -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()
}
+244
View File
@@ -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 -1
View File
@@ -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"
+1 -1
View File
@@ -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)),
}
+56 -2
View File
@@ -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 -1
View File
@@ -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)
+6 -1
View File
@@ -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)
+2 -1
View File
@@ -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", () => {
+8 -2
View File
@@ -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}
+9
View File
@@ -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}`)
}
+14
View File
@@ -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) {
+6 -4
View File
@@ -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",