mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
Merge branch 'main' into feature/improve-slash-command-sorting
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Animate tool-call and reasoning details when expanding and collapsing.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep turns responsive when snapshot infrastructure stalls and prevent transient snapshot progress from appearing in forked sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Enable autocomplete in Jupyter notebooks.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Run Windows PowerShell tool commands without `-EncodedCommand` to reduce antivirus false positives.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/kilo-gateway": patch
|
||||
---
|
||||
|
||||
Use the matching FIM model for chat autocomplete when Next Edit is selected.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Create the default `.kilo/plans` directory automatically when Plan mode starts.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Use the correct High and Max thinking variants for GLM 5.2 on OpenCode Go and compatible providers.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Enforce the sandbox network restriction for agent commands on Linux, including TCP, UDP, IPv4, IPv6, and descendant processes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Highlight rendered prompt file mentions and open them on click.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show JetBrains file mention suggestions immediately for empty `@` mentions and keep the completion popup stable while typing quickly.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Hide raw file contents from mentioned files in JetBrains chat messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix @git-changes mentions causing JetBrains chat sessions to fail.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show file type icons and keep predefined mentions first in JetBrains mention completions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Add `/` slash commands and `@` file/git-changes mentions to the JetBrains chat prompt with native completion.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show a concise single-line hint in the JetBrains prompt placeholder.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix prompt undo/redo, clean mid-token mention completion, and show unresolved file mentions in the JetBrains chat prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Attach hand-typed prompt file mentions when sending immediately.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Restore prompt focus after returning from session history in JetBrains.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support VS Code slash-command aliases in the JetBrains prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Keep JetBrains prompt mention suggestions stable while typing fuzzy file matches.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Block outbound network access from agent commands and in-process HTTP tools with the optional macOS sandbox, with a Sandboxing setting to allow network access when needed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Browse skills, agents, and MCP servers together and filter them by category.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show sandbox state changes as concise VS Code notifications.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve unchanged codebase indexes when extension or VS Code updates interrupt an incremental scan.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep attention sounds silent for permission requests handled by auto-approve.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS and Linux sandboxes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent sandboxed file tools from escaping project write roots through concurrent symlink replacement on macOS.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Confine sandboxed worktree sessions to their active worktree instead of allowing writes to sibling or primary checkouts.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the concrete model reported for routed Kilo auto-model steps in CLI and VS Code session timelines.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Let users hide the codebase indexing button while indexing is off.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"@kilocode/sdk": minor
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Add session-local macOS sandbox controls, show the effective active state, and confirm toggles in the CLI and VS Code extension.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Stop shipping the local-only `.cli-version` build marker in packaged VSIX installs, which previously made production installs detect as local builds and inject a dev-only bwrap fallback.
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
- name: Check Effect Promise facade allowlist
|
||||
run: bun run script/check-opencode-promise-facades.ts
|
||||
|
||||
- name: Check model tool network boundary
|
||||
run: bun run script/check-model-tool-network.ts
|
||||
|
||||
- name: Check workflow allowlist
|
||||
run: bun run script/check-workflows.ts
|
||||
# kilocode_change end
|
||||
|
||||
@@ -84,6 +84,16 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
# kilocode_change start
|
||||
- name: Setup Zig for Linux sandbox helpers
|
||||
run: |
|
||||
curl --fail --location --retry 3 \
|
||||
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
|
||||
--output "$RUNNER_TEMP/zig.tar.xz"
|
||||
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
|
||||
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
|
||||
# kilocode_change end
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
@@ -179,6 +189,14 @@ jobs:
|
||||
smoke_host() {
|
||||
binary="$1"
|
||||
"$binary" --version
|
||||
helper="$(dirname "$binary")/bwrap"
|
||||
if [[ "${{ matrix.target }}" == linux-* ]]; then
|
||||
test -x "$helper"
|
||||
grep -q '^SPDX-License-Identifier: LGPL-2.0-or-later$' "$(dirname "$binary")/licenses/bubblewrap/NOTICE"
|
||||
"$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
|
||||
(
|
||||
@@ -212,8 +230,10 @@ jobs:
|
||||
# as optional packages and must be installed for any Bun-compiled musl binary to run.
|
||||
apk add --no-cache libstdc++ libgcc
|
||||
# kilocode_change end
|
||||
binary="/dist/$PACKAGE/bin/kilo"
|
||||
"$binary" --version
|
||||
binary="/dist/$PACKAGE/bin/kilo" # kilocode_change
|
||||
"$binary" --version # kilocode_change
|
||||
"/dist/$PACKAGE/bin/bwrap" --version # kilocode_change
|
||||
grep -q '^SPDX-License-Identifier: LGPL-2.0-or-later$' "/dist/$PACKAGE/bin/licenses/bubblewrap/NOTICE" # kilocode_change
|
||||
root="$(mktemp -d)"
|
||||
trap '\''rm -rf "$root"'\'' EXIT
|
||||
unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR
|
||||
|
||||
@@ -30,6 +30,8 @@ jobs:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404 # kilocode_change
|
||||
- name: macos
|
||||
host: macos-15 # kilocode_change
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025 # kilocode_change
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
@@ -61,6 +63,23 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
# kilocode_change start
|
||||
- name: Setup Zig for Linux sandbox helper
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
curl --fail --location --retry 3 \
|
||||
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
|
||||
--output "$RUNNER_TEMP/zig.tar.xz"
|
||||
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
|
||||
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
|
||||
|
||||
- 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"
|
||||
# kilocode_change end
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com"
|
||||
@@ -79,13 +98,16 @@ jobs:
|
||||
run: bun turbo test:ci --filter='!@kilocode/kilo-jetbrains'
|
||||
env:
|
||||
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
KILO_TEST_PROFILE: ${{ runner.os == 'macOS' && github.event_name == 'pull_request' && 'darwin' || '' }} # kilocode_change
|
||||
|
||||
# kilocode_change start
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
# kilocode_change end
|
||||
|
||||
- name: Publish unit reports
|
||||
- name: Publish unit reports # kilocode_change
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -62,6 +62,18 @@ Pay special attention to:
|
||||
|
||||
When in doubt, add a finding. A human will verify it. Compiling code is not proof the chain is intact.
|
||||
|
||||
## CONFIG_REGRESSION.md
|
||||
|
||||
Check whether this PR introduces or re-introduces fallback logic for `opencode` config files, or accidentally breaks code that now correctly expects only `.kilo`-based configuration.
|
||||
|
||||
Kilo removed fallback support for `opencode` config directories. Look for:
|
||||
|
||||
- Any new or restored code that reads from `opencode` config paths.
|
||||
- Upstream additions to config discovery, loading, or path resolution that add `opencode` fallback candidates we stripped.
|
||||
- Changes that break `.kilo`-only config lookup by removing or reordering it in a multi-path search.
|
||||
|
||||
When in doubt, add a finding. A human should verify config path changes manually.
|
||||
|
||||
## TESTS.md
|
||||
|
||||
Check whether this PR removed any Kilo-specific tests.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
---
|
||||
name: kilocode-merge-minimizer
|
||||
description: Use when changing shared upstream-owned files, editing or reviewing `kilocode_change` markers, or moving additive Kilo-specific behavior into Kilo-owned code to reduce future merge conflicts.
|
||||
description: Use when changing shared upstream-owned files to add Kilo-specific behavior, editing `kilocode_change` markers in shared code, or moving additive behavior out of shared code to reduce upstream merge conflicts. Do not use for changes confined to Kilo-owned paths such as `packages/kilo-vscode/` or `packages/kilo-ui/`.
|
||||
---
|
||||
|
||||
# Kilo Merge Minimizer
|
||||
|
||||
Use this skill whenever a normal development task touches shared upstream-owned code and includes Kilo-specific behavior, especially for marker cleanup, extraction work, or `kilocode_change` annotations.
|
||||
|
||||
Do not use this skill when all changes are confined to Kilo-owned paths, including `packages/kilo-vscode/`, `packages/kilo-ui/`, and paths with `kilocode` in their name. Those files are not merged from upstream and do not need merge-minimization guidance. If a task also touches shared upstream-owned code, use this skill for the shared portion only.
|
||||
|
||||
Do not use this as the primary guide for upstream merge resolution. Upstream merges have their own instructions and should not duplicate that workflow here.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -57,6 +57,7 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@kilocode/sandbox": "workspace:*",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
@@ -92,7 +93,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -106,7 +107,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -119,7 +120,7 @@
|
||||
},
|
||||
"packages/kilo-console": {
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-web-ui": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
@@ -141,7 +142,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -171,7 +172,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
@@ -207,7 +208,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -217,7 +218,7 @@
|
||||
},
|
||||
"packages/kilo-indexing": {
|
||||
"name": "@kilocode/kilo-indexing",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
|
||||
"@aws-sdk/credential-provider-ini": "3.972.31",
|
||||
@@ -249,11 +250,24 @@
|
||||
},
|
||||
"packages/kilo-jetbrains": {
|
||||
"name": "@kilocode/kilo-jetbrains",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
},
|
||||
"packages/kilo-sandbox": {
|
||||
"name": "@kilocode/sandbox",
|
||||
"version": "7.3.52",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"posthog-node": "4.4.0",
|
||||
@@ -267,7 +281,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -304,7 +318,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
@@ -371,7 +385,7 @@
|
||||
},
|
||||
"packages/kilo-web-ui": {
|
||||
"name": "@kilocode/kilo-web-ui",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -388,7 +402,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -406,7 +420,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -446,6 +460,7 @@
|
||||
"@kilocode/kilo-telemetry": "workspace:*",
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@kilocode/plugin-atomic-chat": "workspace:*",
|
||||
"@kilocode/sandbox": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
@@ -555,7 +570,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -583,7 +598,7 @@
|
||||
},
|
||||
"packages/plugin-atomic-chat": {
|
||||
"name": "@kilocode/plugin-atomic-chat",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
},
|
||||
@@ -597,7 +612,7 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3",
|
||||
},
|
||||
@@ -608,7 +623,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -623,7 +638,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -646,7 +661,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -1311,6 +1326,8 @@
|
||||
|
||||
"@kilocode/plugin-atomic-chat": ["@kilocode/plugin-atomic-chat@workspace:packages/plugin-atomic-chat"],
|
||||
|
||||
"@kilocode/sandbox": ["@kilocode/sandbox@workspace:packages/kilo-sandbox"],
|
||||
|
||||
"@kilocode/sdk": ["@kilocode/sdk@workspace:packages/sdk/js"],
|
||||
|
||||
"@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-fx0fkaOWlqpDumMd0B7te7jK4dbRg1aU20tWj7ThPrs=",
|
||||
"aarch64-linux": "sha256-0PPAEe4FDeFMBDde+8Kp+/QhX6wdUzHXZCvjHkSlaAk=",
|
||||
"aarch64-darwin": "sha256-G5saUo5Ho9+w9+7aVMmMYZVQZXPulhDA2uQU7qSiCRE=",
|
||||
"x86_64-darwin": "sha256-vjWhZTHS8V7jf3YBm0FKH7tz7Cw6Pqw7fbo7XZXS6PA="
|
||||
"x86_64-linux": "sha256-WYLCq59M4c6MsNjIeBmgV66v6LBvsTVGA1xV2oY935s=",
|
||||
"aarch64-linux": "sha256-kpJ1SFCmBrhDwuJNJNoDU5NK6mDvEG4Yd41w9YJnsOs=",
|
||||
"aarch64-darwin": "sha256-junSolEGOe+xUmPY4SAJGj+XKuytxvD0IWQWtLiaIEs=",
|
||||
"x86_64-darwin": "sha256-p+3Jtb5CZexwx0UlhVeqeMr25R+jkjahFp9YTZYK0rE="
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
};
|
||||
|
||||
+1
-1
@@ -148,6 +148,6 @@
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
|
||||
},
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@kilocode/sandbox": "workspace:*",
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type * as Arr from "effect/Array"
|
||||
import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
|
||||
import * as NodePath from "@effect/platform-node/NodePath"
|
||||
import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
@@ -362,24 +363,29 @@ export const make = Effect.gen(function* () {
|
||||
function* (command) {
|
||||
switch (command._tag) {
|
||||
case "StandardCommand": {
|
||||
const sin = stdin(command.options)
|
||||
const sout = stdio(command.options, "stdout")
|
||||
const serr = stdio(command.options, "stderr")
|
||||
const extra = fds(command.options)
|
||||
const dir = yield* cwd(command.options)
|
||||
// kilocode_change start - prepare agent-scoped commands through the selected sandbox backend
|
||||
const target = yield* prepareSandbox(command, dir, env(command.options))
|
||||
const sin = stdin(target.options)
|
||||
const sout = stdio(target.options, "stdout")
|
||||
const serr = stdio(target.options, "stderr")
|
||||
const extra = fds(target.options)
|
||||
// kilocode_change end
|
||||
|
||||
const [proc, signal] = yield* Effect.acquireRelease(
|
||||
spawn(command, {
|
||||
// kilocode_change start - spawn the prepared command and options
|
||||
spawn(target, {
|
||||
cwd: dir,
|
||||
env: env(command.options),
|
||||
env: env(target.options),
|
||||
stdio: stdios(sin, sout, serr, extra),
|
||||
detached: command.options.detached ?? process.platform !== "win32",
|
||||
shell: command.options.shell,
|
||||
detached: target.options.detached ?? process.platform !== "win32",
|
||||
shell: target.options.shell,
|
||||
// kilocode_change end
|
||||
windowsHide: process.platform === "win32",
|
||||
}),
|
||||
Effect.fnUntraced(function* ([proc, signal]) {
|
||||
const done = yield* Deferred.isDone(signal)
|
||||
const kill = timeout(proc, command, command.options)
|
||||
const kill = timeout(proc, command, target.options) // kilocode_change
|
||||
if (done) {
|
||||
const [code] = yield* Deferred.await(signal)
|
||||
if (process.platform === "win32") return yield* Effect.void
|
||||
@@ -388,14 +394,16 @@ export const make = Effect.gen(function* () {
|
||||
}
|
||||
const send = (s: NodeJS.Signals) =>
|
||||
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
||||
const sig = command.options.killSignal ?? "SIGTERM"
|
||||
// kilocode_change start - preserve kill options from the prepared command
|
||||
const sig = target.options.killSignal ?? "SIGTERM"
|
||||
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
||||
const escalated = command.options.forceKillAfter
|
||||
const escalated = target.options.forceKillAfter
|
||||
? Effect.timeoutOrElse(attempt, {
|
||||
duration: command.options.forceKillAfter,
|
||||
duration: target.options.forceKillAfter,
|
||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
: attempt
|
||||
// kilocode_change end
|
||||
return yield* Effect.ignore(escalated)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { decorateFileSystem, ensureDirectory } from "@kilocode/sandbox" // kilocode_change
|
||||
import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path" // kilocode_change - harden containment checks
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
@@ -8,28 +9,6 @@ import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
|
||||
// kilocode_change start - Windows-resilient mkdir -p.
|
||||
// fs.mkdir(dir, { recursive: true }) should be idempotent, but on Windows
|
||||
// with NTFS reparse points (OneDrive), directory junctions, or WSL-served
|
||||
// paths, libuv can still throw EEXIST. This wrapper catches that specific
|
||||
// error so callers get the promised directory-exists semantics.
|
||||
//
|
||||
// https://github.com/Kilo-Org/kilocode/issues/9618
|
||||
// https://github.com/Kilo-Org/kilocode/issues/9755
|
||||
function isEexist(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && "code" in err && (err as NodeJS.ErrnoException).code === "EEXIST"
|
||||
}
|
||||
|
||||
async function mkdirSafe(dir: string): Promise<void> {
|
||||
try {
|
||||
await NFS.mkdir(dir, { recursive: true })
|
||||
} catch (err: unknown) {
|
||||
if (isEexist(err)) return
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export namespace AppFileSystem {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
@@ -67,7 +46,7 @@ export namespace AppFileSystem {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const fs = decorateFileSystem(yield* FileSystem.FileSystem) // kilocode_change
|
||||
|
||||
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
@@ -116,12 +95,7 @@ export namespace AppFileSystem {
|
||||
})
|
||||
|
||||
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
||||
// kilocode_change start - use mkdirSafe to tolerate Windows EEXIST
|
||||
yield* Effect.tryPromise({
|
||||
try: () => mkdirSafe(path),
|
||||
catch: (cause) => new FileSystemError({ method: "ensureDir", cause }),
|
||||
})
|
||||
// kilocode_change end
|
||||
yield* ensureDirectory(fs, path) // kilocode_change - mutate through the sandbox-confined filesystem
|
||||
})
|
||||
|
||||
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
||||
@@ -136,12 +110,7 @@ export namespace AppFileSystem {
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// kilocode_change start - use mkdirSafe to tolerate Windows EEXIST
|
||||
yield* Effect.tryPromise({
|
||||
try: () => mkdirSafe(dirname(path)),
|
||||
catch: (cause) => new FileSystemError({ method: "writeWithDirs:mkdir", cause }),
|
||||
})
|
||||
// kilocode_change end
|
||||
yield* ensureDirectory(fs, dirname(path)) // kilocode_change - sandbox-confined mkdir
|
||||
yield* write
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createSocket } from "node:dgram"
|
||||
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 linuxIPv6 = process.platform === "linux" && supportsIPv6() ? test : test.skip
|
||||
|
||||
function profile(
|
||||
allow: ReadonlyArray<string>,
|
||||
denyNames: ReadonlyArray<string> = [],
|
||||
mode: Profile["network"]["mode"] = "allow",
|
||||
): Profile {
|
||||
return {
|
||||
filesystem: {
|
||||
allowWrite: allow.map((path) => ({ path, kind: "subtree" })),
|
||||
denyWrite: [],
|
||||
denyNames,
|
||||
},
|
||||
network: { mode, 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 }
|
||||
}
|
||||
|
||||
function requireNetwork() {
|
||||
const support = backendSupport({ mode: "deny", allowedHosts: [] })
|
||||
expect(support.available, support.reason).toBe(true)
|
||||
}
|
||||
|
||||
function tcp(hostname = "127.0.0.1") {
|
||||
let accepted = 0
|
||||
const listener = Bun.listen({
|
||||
hostname,
|
||||
port: 0,
|
||||
socket: {
|
||||
open(socket) {
|
||||
accepted++
|
||||
socket.write("sandbox-tcp-ok")
|
||||
socket.end()
|
||||
},
|
||||
data() {},
|
||||
},
|
||||
})
|
||||
return { listener, accepted: () => accepted }
|
||||
}
|
||||
|
||||
function supportsIPv6() {
|
||||
if (process.platform !== "linux") return false
|
||||
try {
|
||||
const probe = tcp("::1")
|
||||
probe.listener.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function udp() {
|
||||
let received = 0
|
||||
const socket = createSocket("udp4")
|
||||
socket.on("message", (_message, peer) => {
|
||||
received++
|
||||
socket.send("sandbox-udp-ok", peer.port, peer.address)
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("error", reject)
|
||||
socket.bind(0, "127.0.0.1", () => {
|
||||
socket.off("error", reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = socket.address()
|
||||
if (typeof address === "string") throw new Error("UDP server did not expose an IP address")
|
||||
return { socket, port: address.port, received: () => received }
|
||||
}
|
||||
|
||||
function tcpClient(port: number, expected: boolean, hostname = "127.0.0.1") {
|
||||
return [
|
||||
'const net = require("node:net")',
|
||||
`const socket = net.connect({ host: ${JSON.stringify(hostname)}, port: ${port} })`,
|
||||
`const expected = ${expected}`,
|
||||
'socket.on("data", (data) => process.exit(expected && data.toString() === "sandbox-tcp-ok" ? 0 : 2))',
|
||||
'socket.on("error", () => process.exit(expected ? 3 : 0))',
|
||||
"setTimeout(() => process.exit(expected ? 4 : 0), 1000)",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function udpClient(port: number, expected: boolean) {
|
||||
return [
|
||||
'const dgram = require("node:dgram")',
|
||||
'const socket = dgram.createSocket("udp4")',
|
||||
`const expected = ${expected}`,
|
||||
'socket.on("message", (data) => process.exit(expected && data.toString() === "sandbox-udp-ok" ? 0 : 2))',
|
||||
'socket.on("error", () => process.exit(expected ? 3 : 0))',
|
||||
`socket.send("probe", ${port}, "127.0.0.1", (error) => { if (error) process.exit(expected ? 4 : 0) })`,
|
||||
"setTimeout(() => process.exit(expected ? 5 : 0), 500)",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
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("allows host loopback TCP in network allow mode and blocks it in deny mode", async () => {
|
||||
requireNetwork()
|
||||
const root = await fixture()
|
||||
const allowed = tcp()
|
||||
const blocked = tcp()
|
||||
|
||||
try {
|
||||
const allow = profile([root.project], [], "allow")
|
||||
const deny = profile([root.project], [], "deny")
|
||||
expect(Number(await Effect.runPromise(spawn(tcpClient(allowed.listener.port, true), root.project, allow)))).toBe(0)
|
||||
expect(Number(await Effect.runPromise(spawn(tcpClient(blocked.listener.port, false), root.project, deny)))).toBe(0)
|
||||
expect(allowed.accepted()).toBe(1)
|
||||
expect(blocked.accepted()).toBe(0)
|
||||
} finally {
|
||||
allowed.listener.stop(true)
|
||||
blocked.listener.stop(true)
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("blocks UDP datagrams in network deny mode", async () => {
|
||||
requireNetwork()
|
||||
const root = await fixture()
|
||||
const allowed = await udp()
|
||||
const blocked = await udp()
|
||||
|
||||
try {
|
||||
const allow = profile([root.project], [], "allow")
|
||||
const deny = profile([root.project], [], "deny")
|
||||
expect(Number(await Effect.runPromise(spawn(udpClient(allowed.port, true), root.project, allow)))).toBe(0)
|
||||
expect(Number(await Effect.runPromise(spawn(udpClient(blocked.port, false), root.project, deny)))).toBe(0)
|
||||
expect(allowed.received()).toBe(1)
|
||||
expect(blocked.received()).toBe(0)
|
||||
} finally {
|
||||
allowed.socket.close()
|
||||
blocked.socket.close()
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("blocks localhost connections in network deny mode", async () => {
|
||||
requireNetwork()
|
||||
const root = await fixture()
|
||||
const listener = tcp()
|
||||
const deny = profile([root.project], [], "deny")
|
||||
|
||||
try {
|
||||
expect(
|
||||
Number(await Effect.runPromise(spawn(tcpClient(listener.listener.port, false, "localhost"), root.project, deny))),
|
||||
).toBe(0)
|
||||
expect(listener.accepted()).toBe(0)
|
||||
} finally {
|
||||
listener.listener.stop(true)
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linuxIPv6("blocks IPv6 loopback connections in network deny mode", async () => {
|
||||
requireNetwork()
|
||||
const root = await fixture()
|
||||
const listener = tcp("::1")
|
||||
const deny = profile([root.project], [], "deny")
|
||||
|
||||
try {
|
||||
expect(
|
||||
Number(await Effect.runPromise(spawn(tcpClient(listener.listener.port, false, "::1"), root.project, deny))),
|
||||
).toBe(0)
|
||||
expect(listener.accepted()).toBe(0)
|
||||
} finally {
|
||||
listener.listener.stop(true)
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("keeps loopback available between processes inside the denied network namespace", async () => {
|
||||
requireNetwork()
|
||||
const root = await fixture()
|
||||
const child = [
|
||||
'const net = require("node:net")',
|
||||
"const port = Number(process.argv[1])",
|
||||
'const socket = net.connect({ host: "127.0.0.1", port })',
|
||||
'socket.on("connect", () => socket.write("sandbox-internal-ok"))',
|
||||
'socket.on("data", (data) => process.exit(data.toString() === "sandbox-internal-ok" ? 0 : 2))',
|
||||
'socket.on("error", () => process.exit(3))',
|
||||
].join("\n")
|
||||
const script = [
|
||||
'const child = require("node:child_process")',
|
||||
'const net = require("node:net")',
|
||||
'const server = net.createServer((socket) => socket.on("data", (data) => socket.end(data)))',
|
||||
'server.listen(0, "127.0.0.1", () => {',
|
||||
` const proc = child.spawn(process.execPath, ["-e", ${JSON.stringify(child)}, String(server.address().port)])`,
|
||||
' proc.on("exit", (code) => process.exit(code ?? 4))',
|
||||
"})",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [], "deny"))))).toBe(0)
|
||||
} finally {
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("keeps descendants in the denied network namespace", async () => {
|
||||
requireNetwork()
|
||||
const root = await fixture()
|
||||
const blocked = tcp()
|
||||
const child = tcpClient(blocked.listener.port, false)
|
||||
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], [], "deny"))))).toBe(0)
|
||||
expect(blocked.accepted()).toBe(0)
|
||||
} finally {
|
||||
blocked.listener.stop(true)
|
||||
await fs.rm(root.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
linux("preserves filesystem confinement in network deny mode", async () => {
|
||||
requireNetwork()
|
||||
const root = await fixture()
|
||||
const allowed = path.join(root.project, "network-deny.txt")
|
||||
const sentinel = path.join(root.outside, "network-deny.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], [], "deny"))))).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("reports network namespace support separately and fails deny mode closed", async () => {
|
||||
const root = await fixture()
|
||||
const source = process.env.KILO_BWRAP_PATH ?? "/usr/bin/bwrap"
|
||||
const helper = path.join(root.outside, "bwrap-no-network")
|
||||
await fs.writeFile(
|
||||
helper,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'for arg in "$@"; do',
|
||||
' if [ "$arg" = "--unshare-net" ]; then echo "network namespaces blocked" >&2; exit 42; fi',
|
||||
"done",
|
||||
`exec ${JSON.stringify(source)} "$@"`,
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await fs.chmod(helper, 0o755)
|
||||
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"',
|
||||
'const allow = backendSupport({ mode: "allow", allowedHosts: [] })',
|
||||
'const deny = backendSupport({ mode: "deny", allowedHosts: [] })',
|
||||
"if (!allow.available) process.exit(2)",
|
||||
'if (deny.available || !deny.reason?.includes("Linux network sandbox")) process.exit(3)',
|
||||
'const profile = { filesystem: { allowWrite: [], denyWrite: [], denyNames: [] }, network: { mode: "deny", 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(4) } catch { process.exit(0) }",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
const result = spawnSync(process.execPath, ["-e", script], {
|
||||
cwd: import.meta.dir,
|
||||
env: { ...process.env, KILO_BWRAP_PATH: helper },
|
||||
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)
|
||||
})
|
||||
@@ -368,7 +368,7 @@ describe("util.effect-flock", () => {
|
||||
const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
|
||||
|
||||
try {
|
||||
await waitForFile(ready, 5_000)
|
||||
await waitForFile(ready, 20_000) // kilocode_change - hosted macOS can start this worker slowly after stress tests
|
||||
await stopWorker(proc) // kilocode_change - stopWorker now awaits close before returning
|
||||
|
||||
// Backdate lock files so they're past STALE_MS (60s)
|
||||
@@ -387,6 +387,6 @@ describe("util.effect-flock", () => {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
}),
|
||||
30_000,
|
||||
60_000, // kilocode_change - match the wider worker readiness window
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "7.3.53"
|
||||
version = "7.3.54"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/Kilo-Org/kilocode"
|
||||
@@ -11,26 +11,26 @@ name = "Kilo"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.53/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.53/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.53/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.53/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.53/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.54/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CustomizeNav: NavSection[] = [
|
||||
title: "Customization",
|
||||
links: [
|
||||
{ href: "/customize", children: "Overview" },
|
||||
{ href: "/customize/marketplace", children: "Marketplace", platform: "new" },
|
||||
{
|
||||
href: "/customize/custom-modes",
|
||||
children: "Custom Modes",
|
||||
|
||||
@@ -10,6 +10,6 @@ export const DeploySecureNav: NavSection[] = [
|
||||
},
|
||||
{
|
||||
title: "Security",
|
||||
links: [{ href: "/deploy-secure/security-reviews", children: "Security Reviews" }],
|
||||
links: [{ href: "/deploy-secure/security-reviews", children: "Security Agent" }],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -7,6 +7,8 @@ description: "Overview of the Model Context Protocol"
|
||||
|
||||
The Model Context Protocol (MCP) is a standard for extending Kilo Code's capabilities by connecting to external tools and services. MCP servers provide additional tools and resources that help Kilo Code accomplish tasks beyond its built-in capabilities, such as accessing databases, custom APIs, and specialized functionality.
|
||||
|
||||
You can install curated MCP servers from the Kilo Marketplace. See the [Marketplace guide](/docs/customize/marketplace) to understand project and global installs, which files change, and what to review before installing.
|
||||
|
||||
## MCP Documentation
|
||||
|
||||
This documentation is organized into several sections:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Marketplace"
|
||||
description: "Install agents, skills, and MCP servers from the Kilo Marketplace"
|
||||
---
|
||||
|
||||
# Marketplace
|
||||
|
||||
The Kilo Marketplace provides reusable extensions for Kilo. Open **Marketplace** from the Kilo sidebar to browse, install, and remove items.
|
||||
|
||||
Marketplace items are configuration and instruction files, not VS Code extensions. Installing an item adds files to either the current project or your user configuration. Kilo then discovers those files through its normal configuration system.
|
||||
|
||||
## What you can install
|
||||
|
||||
| Type | What it adds | What happens after installation |
|
||||
|---|---|---|
|
||||
| **Agent** | A reusable role with its own prompt, behavior, and permissions. | The agent becomes available in Kilo's agent selector. |
|
||||
| **Skill** | Task-specific instructions and resources that Kilo can load when relevant. | Kilo can discover and load the skill during a session. |
|
||||
| **MCP server** | Tools supplied by an external service or a program running on your machine. | Kilo starts or connects to the configured server when it loads the MCP configuration. |
|
||||
|
||||
MCP stands for **Model Context Protocol**, a standard that lets AI applications use external tools. For example, an MCP server might let Kilo query a database, work with GitHub, or interact with a browser. See [What is MCP?](/docs/automate/mcp/what-is-mcp) for a fuller explanation.
|
||||
|
||||
## Project or global
|
||||
|
||||
Every installation has a scope:
|
||||
|
||||
| Scope | Availability | Use it when |
|
||||
|---|---|---|
|
||||
| **Project** | Only the current project. Files are stored under the project's `.kilo/` directory. | The whole team should use the item, or it is specific to this repository. |
|
||||
| **Global** | Every project you open on this machine. Files are stored in your user configuration. | The item is part of your personal workflow across repositories. |
|
||||
|
||||
Project files can be committed to version control and shared with teammates. Global files remain on your machine and do not travel with a repository.
|
||||
|
||||
When the same configuration exists at both scopes, project configuration takes precedence over global configuration. Learn more about [Kilo's configuration files and precedence](/docs/getting-started/settings#config-file-precedence).
|
||||
|
||||
## Files changed by installation
|
||||
|
||||
The install dialog shows the destination before it changes anything.
|
||||
|
||||
| Type | Project destination | Global destination |
|
||||
|---|---|---|
|
||||
| Agent | `.kilo/agents/<name>.md` | `~/.config/kilo/agents/<name>.md` |
|
||||
| Skill | `.kilo/skills/<name>/` | `~/.kilo/skills/<name>/` |
|
||||
| MCP server | `.kilo/kilo.json` | `~/.config/kilo/kilo.json` |
|
||||
|
||||
Installing an MCP server adds an entry under the `mcp` key without replacing your other Kilo settings. Installing an agent or skill creates its own file or directory. Removing an item deletes its marketplace-managed entry from the selected scope.
|
||||
|
||||
{% callout type="warning" title="Keep credentials out of version control" %}
|
||||
Some MCP servers require API keys, access tokens, or connection strings. Project configuration may be committed to your repository. Prefer environment-variable references for secrets, and review `.kilo/kilo.json` before committing it.
|
||||
{% /callout %}
|
||||
|
||||
## MCP security and permissions
|
||||
|
||||
An MCP server can expose tools that read data, modify external systems, or run local operations:
|
||||
|
||||
- A **local** MCP server runs a command as a child process on your machine.
|
||||
- A **remote** MCP server sends requests to an external service.
|
||||
- Installing the server makes its tools available; it does not automatically approve every tool call.
|
||||
- MCP tools follow Kilo's `allow`, `ask`, and `deny` permission rules. The default experience may prompt you before a tool runs, depending on your configuration.
|
||||
|
||||
Review the item's author, source link, prerequisites, requested parameters, and available tools before installing it. See [Using MCP in Kilo Code](/docs/automate/mcp/using-in-kilo-code) for configuration, transport, authentication, and permission details.
|
||||
|
||||
## Removing an item
|
||||
|
||||
An item can be installed at both project and global scope. Its Marketplace card shows it as installed and offers a separate remove action for each installed scope. Removing the project copy does not remove the global copy, and vice versa.
|
||||
|
||||
After an install or removal, Kilo reloads the affected configuration. Running sessions may be interrupted so they do not continue with an outdated set of agents, skills, or tools.
|
||||
|
||||
## Contributing
|
||||
|
||||
Marketplace entries are maintained in the [Kilo Marketplace repository](https://github.com/Kilo-Org/kilo-marketplace). Contributions should document prerequisites, parameters, available tools, and any platform-specific requirements.
|
||||
@@ -1,199 +1,491 @@
|
||||
---
|
||||
title: "Security Reviews"
|
||||
description: "Contextualize dependency vulnerabilities with AI"
|
||||
title: "Security Agent"
|
||||
description: "Manage and remediate GitHub Dependabot alerts with AI"
|
||||
---
|
||||
|
||||
# Security Reviews
|
||||
# Security Agent
|
||||
|
||||
Most teams are drowning in Dependabot alerts. The majority of reported CVEs aren't actually exploitable because the vulnerable code path is never used — but figuring that out manually doesn't scale.
|
||||
Security Agent syncs dependency vulnerability alerts from selected GitHub repositories and stores them as Security Findings in Kilo. It uses AI to triage each finding and runs sandbox analysis when project-specific evidence is needed. It also helps you dismiss or remediate findings, send notifications, and review recorded activity.
|
||||
|
||||
Kilo's Security Agent fixes this. It syncs your Dependabot alerts, triages them with AI, and performs deep codebase analysis to determine whether each vulnerability is actually reachable in your code. Non-exploitable findings can be auto-dismissed and synced back to GitHub.
|
||||
|
||||
Available for all users.
|
||||
|
||||
---
|
||||
Use Security Agent to identify reachable Dependabot alerts, prioritize findings, and see which findings the analysis recommends dismissing or remediating with a pull request.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need three things before enabling Security Reviews:
|
||||
Before enabling Security Agent, make sure you have:
|
||||
|
||||
1. The [KiloConnect GitHub App](/docs/automate/integrations#connecting-github) installed with `vulnerability_alerts` permission
|
||||
2. [Dependabot alerts](https://docs.github.com/en/code-security/dependabot/dependabot-alerts) enabled on your target repositories
|
||||
3. Kilo Code credits for AI model usage
|
||||
1. The [Kilo GitHub App](/docs/automate/integrations#connecting-github) installed with the `vulnerability_alerts` permission.
|
||||
2. [Dependabot alerts](https://docs.github.com/en/code-security/dependabot/dependabot-alerts) enabled on target repositories.
|
||||
3. Kilo Code credits for AI model usage.
|
||||
|
||||
---
|
||||
Security Agent currently works with GitHub Dependabot alerts. If the GitHub App loses required permissions, Security Agent prompts you to re-authorize the app before syncing or analyzing findings.
|
||||
|
||||
## Get started
|
||||
|
||||
1. Go to the **Security Agent** page — either from your [personal dashboard](https://app.kilo.ai/security-agent) or your organization's dashboard
|
||||
2. Connect GitHub if you haven't already via the [Integrations page](/docs/automate/integrations)
|
||||
3. Choose which repositories the agent should monitor (all or specific ones)
|
||||
4. Toggle the agent on — this kicks off an initial sync of your Dependabot alerts
|
||||
1. Open Security Agent from your personal dashboard or an organization dashboard.
|
||||
2. If GitHub is not connected, connect it from the Integrations page.
|
||||
3. Choose a repository scope: all repositories available to the Kilo GitHub App or selected repositories.
|
||||
4. Turn on Security Agent.
|
||||
5. Review the General, Automation, Notifications, and SLA settings.
|
||||
|
||||
The agent syncs alerts every 6 hours automatically after that. You can trigger a manual sync at any time from the Findings page.
|
||||
Turning on Security Agent queues an initial sync for the selected repository scope. It then syncs Dependabot alerts every 6 hours. You can also trigger a manual sync from the dashboard or Findings page.
|
||||
|
||||
---
|
||||
## Organization permissions
|
||||
|
||||
## Understand the pipeline
|
||||
Organization members can view the Security Agent dashboard, findings, and analysis evidence. They can also trigger manual syncs, start or retry analysis, start or retry remediation, and request remediation cancellation.
|
||||
|
||||
The Security Agent processes each vulnerability alert through four stages.
|
||||
Organization owners and billing managers can change Security Agent settings, turn the agent on or off, dismiss findings, clear orphaned findings, and access organization audit reports. Kilo platform admins can also access these reports for audited support and operations.
|
||||
|
||||
**Sync** pulls Dependabot alerts from your connected repositories on a 6-hour cycle.
|
||||
Access to organization notifications is more restricted than access to settings: Security Agent emails go only to current organization owners.
|
||||
|
||||
**Triage** runs a quick LLM assessment of the alert metadata — the advisory, severity, package, and version range. Each finding gets classified as **Safe to Dismiss**, **Needs Analysis**, or **Needs Review**.
|
||||
## How Security Agent works
|
||||
|
||||
**Deep analysis** kicks in for findings that warrant it. The Cloud Agent performs a full codebase search for actual usage of the vulnerable package, checks whether the vulnerable code paths are reachable, and suggests fixes when possible.
|
||||
Security Agent processes dependency vulnerability alerts through several stages. The main finding flow is:
|
||||
|
||||
**Auto-dismiss** (when enabled) automatically closes non-exploitable findings and syncs that dismissal back to GitHub with a `[Kilo Code auto-dismiss]` prefix.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
alert["Dependabot alert"] --> sync["Sync into Kilo"]
|
||||
sync --> triage["AI triage"]
|
||||
triage --> action["Dismiss, review, or remediate"]
|
||||
triage -. "When needed" .-> analysis["Sandbox analysis"]
|
||||
analysis --> action
|
||||
```
|
||||
|
||||
---
|
||||
Notifications and audit recording run alongside this workflow.
|
||||
|
||||
## Choose an analysis mode
|
||||
1. **Sync** pulls Dependabot alerts from repositories in scope and stores them as Security Findings in Kilo.
|
||||
2. **Triage** quickly assesses advisory metadata with AI, including package, severity, vulnerable range, patched version, and advisory text.
|
||||
3. **Sandbox analysis** uses Cloud Agent to inspect the codebase when Security Agent needs project-specific evidence.
|
||||
4. **Auto-dismiss** can dismiss findings that analysis determines are not exploitable, then sync the dismissal back to GitHub.
|
||||
5. **Remediation** can ask Cloud Agent to create a pull request for an eligible exploitable finding.
|
||||
6. **Notifications** can email eligible recipients about new findings and SLA events.
|
||||
7. **Audit reports** show recorded Security Finding activity for a selected reporting period.
|
||||
|
||||
You control how much analysis the agent performs via three modes:
|
||||
|
||||
| Mode | What happens |
|
||||
|---|---|
|
||||
| **Auto** | Triage first, then deep analysis only when triage recommends it |
|
||||
| **Shallow** | Triage only — no deep analysis |
|
||||
| **Deep** | Full codebase analysis for every finding, regardless of triage result |
|
||||
|
||||
**Auto** is the default. It gives you the best balance between thoroughness and credit usage — deep analysis only runs where triage says it's needed.
|
||||
|
||||
---
|
||||
Security Agent treats Dependabot alerts as source data and Kilo Security Findings as the working record. A finding can stay open even after a remediation PR exists. The finding closes only when GitHub reports it fixed or someone dismisses it.
|
||||
|
||||
## Use the dashboard
|
||||
|
||||
The dashboard is the Security Agent's landing page. It gives you a high-level view of your security posture, and every widget links through to the Findings page with the relevant filters applied. Use the repository filter at the top to scope everything to specific repos.
|
||||
The dashboard shows your current security posture and can be filtered by repository. Use the repository filter to show metrics for one repository or all repositories.
|
||||
|
||||
**SLA compliance** is the hero metric — your overall compliance percentage with a per-severity breakdown, linking directly to any overdue findings.
|
||||
When SLA tracking is enabled, the dashboard shows:
|
||||
|
||||
**Severity breakdown** shows open finding counts across Critical, High, Medium, and Low in a 2×2 grid. Click any severity to see those findings.
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| SLA compliance | Percentage of open findings whose deadlines remain within SLA. |
|
||||
| Deadline passed | Open findings whose persisted SLA deadlines have passed. |
|
||||
| Due this week | Open findings approaching their deadline, including confirmed exploitable findings. |
|
||||
| No deadline | Open findings without evidence of an assigned SLA deadline. |
|
||||
|
||||
**Finding status** is a donut chart of Open, Fixed, and Dismissed findings. Click a segment to filter the Findings page.
|
||||
When SLA tracking is disabled, the dashboard focuses on action posture:
|
||||
|
||||
**Analysis coverage** shows a progress bar of analyzed vs. total findings, with an outcome breakdown (Exploitable, Not Exploitable, Safe to Dismiss, etc.).
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| Open findings | Current open finding count by severity. |
|
||||
| Confirmed exploitable | Findings where sandbox analysis confirmed project risk. |
|
||||
| Needs your review | Findings that need a human decision. |
|
||||
| Analysis not complete | Findings whose project-specific risk is still unknown. |
|
||||
|
||||
**Mean time to resolution** compares your average resolution time per severity against your configured SLA targets.
|
||||
|
||||
**Overdue findings** lists the top 10 findings past their SLA deadline — severity, title, repo, package, and how many days overdue.
|
||||
|
||||
**Repository health** is a per-repo summary with severity counts, overdue count, and SLA compliance percentage.
|
||||
|
||||
---
|
||||
The dashboard also highlights one finding to address first. It prioritizes overdue findings, findings needing analysis, exploitable findings, and findings needing human review. Dashboard links open the Findings page with matching filters.
|
||||
|
||||
## Browse findings
|
||||
|
||||
The Findings page is where you work through your vulnerability backlog. At the top, a summary bar shows open/closed counts, your current analysis capacity, when the last sync ran, and a **Sync** button for manual refreshes.
|
||||
The Findings page is where you work through the vulnerability backlog.
|
||||
|
||||
Filter findings by repository, severity, outcome, or sort order to focus on what matters most. Each row shows a severity badge, the finding title and package name, its current outcome label, and an action button — **Analyze**, **Retry**, **Review**, or **View Details** depending on state.
|
||||
Use filters and sorting to focus the list:
|
||||
|
||||
Findings past their SLA deadline are highlighted in red so they're easy to spot. The page paginates at 20 results and auto-refreshes every 5 seconds when analyses are running.
|
||||
| Control | Options |
|
||||
|---|---|
|
||||
| Repository | All repositories or one repository in Security Agent scope. |
|
||||
| Severity | Critical, High, Medium, Low. |
|
||||
| Outcome | Not analyzed, Analysis failed, Exploitable, Not exploitable, Safe to dismiss, Needs review, Triage complete, Fixed, Dismissed. |
|
||||
| Sort | Severity descending, severity ascending, or SLA due date when SLA tracking is enabled. |
|
||||
|
||||
---
|
||||
Each row shows severity, title, package, analysis outcome, remediation status, and next action. Common actions include Analyze, Retry, Review, Fix, View PR, Retry fix, Cancel, and View details.
|
||||
|
||||
The Findings page shows current analysis capacity and the last sync time. It displays 20 findings per page and refreshes automatically while analysis or remediation is active.
|
||||
|
||||
## Inspect a finding
|
||||
|
||||
Click any finding to open its detail dialog. There are three tabs.
|
||||
Click a finding to open its detail dialog, which has three tabs.
|
||||
|
||||
The **Details** tab shows the vulnerability metadata — package name and ecosystem, CVE and GHSA IDs, the vulnerable and patched version ranges, manifest path, and a full description. You'll also find a **View on GitHub** link to the original Dependabot alert, plus detection and last sync dates.
|
||||
### Details
|
||||
|
||||
The **Triage** tab shows the agent's initial assessment: a suggested action badge (Safe to Dismiss, Needs Analysis, or Needs Review), a confidence level, and the reasoning behind the decision. If triage hasn't run yet, you can start it here. If it failed, you can retry.
|
||||
The Details tab shows source vulnerability metadata:
|
||||
|
||||
The **Analysis** tab shows the deep analysis results when available — whether the vulnerability is exploitable or not, a summary, up to 5 usage locations found in your codebase, a suggested fix, and full analysis details. There's also a link to continue the investigation in Cloud Agent if you want to dig deeper.
|
||||
- package name and ecosystem;
|
||||
- CVE and GHSA identifiers;
|
||||
- vulnerable version range and patched version;
|
||||
- manifest path;
|
||||
- severity and status;
|
||||
- repository and Dependabot source link;
|
||||
- detection, sync, and SLA timing when available.
|
||||
|
||||
---
|
||||
If another finding superseded it, the Details tab links to the current canonical finding.
|
||||
|
||||
### Analysis
|
||||
|
||||
The Analysis tab shows evidence from triage and sandbox analysis.
|
||||
|
||||
Triage can classify a finding as Safe to dismiss, Needs analysis, or Needs review. Depending on the evidence, sandbox analysis can classify a finding as Exploitable, Not exploitable, Monitor, Manual review, or Open PR.
|
||||
|
||||
The Analysis tab shows current progress, failure state, model details, reasoning, usage locations, the suggested fix, and the next action. You can start analysis, retry a failed analysis, restart an active analysis when supported, or start remediation when the finding is eligible.
|
||||
|
||||
### Remediation
|
||||
|
||||
The Remediation tab explains whether Security Agent can start a remediation attempt and why. It also shows remediation history, active attempts, PR outcomes, reasons for failure or blocking, validation evidence, risk notes, cancellation state, and the model used.
|
||||
|
||||
Available remediation actions depend on server-side safety checks. You may see Start remediation, Retry remediation, Cancel remediation, or View PR.
|
||||
|
||||
## Understand statuses and outcomes
|
||||
|
||||
Every finding has a **primary status** and an **outcome label**. The status tracks the overall lifecycle, while the outcome reflects what the AI determined.
|
||||
|
||||
**Primary status:**
|
||||
A finding's primary status tracks its source lifecycle:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| Open | Active vulnerability that needs attention |
|
||||
| Fixed | Resolved — detected from the Dependabot alert state |
|
||||
| Dismissed | Closed by a user or by auto-dismiss |
|
||||
| Open | Active Security Finding that still needs resolution or dismissal. |
|
||||
| Fixed | Dependabot reports the alert as fixed. |
|
||||
| Dismissed | Manual dismissal or auto-dismiss closed the finding and synced the dismissal to GitHub. |
|
||||
| Superseded | Finding was replaced by a canonical finding after duplicate consolidation. |
|
||||
|
||||
**Outcome labels:**
|
||||
The analysis outcome reflects what the AI analysis determined:
|
||||
|
||||
| Outcome | Meaning |
|
||||
|---|---|
|
||||
| Not Analyzed | No analysis has run yet |
|
||||
| Analyzing | Analysis is currently in progress |
|
||||
| Analysis Failed | Something went wrong during analysis |
|
||||
| Exploitable | Deep analysis confirmed it's exploitable |
|
||||
| Not Exploitable | Deep analysis confirmed it's not reachable |
|
||||
| Safe to Dismiss | Triage recommends dismissing this finding |
|
||||
| Needs Review | Triage recommends manual review |
|
||||
| Triage Complete | Triage is done, no deep analysis needed |
|
||||
| Not analyzed | No analysis has completed. |
|
||||
| Queued | Analysis is waiting to run. |
|
||||
| Analyzing | Analysis is running. |
|
||||
| Analysis failed | Analysis did not complete. |
|
||||
| Exploitable | Sandbox analysis confirmed reachable project risk. |
|
||||
| Not exploitable | Sandbox analysis found no reachable vulnerable path. |
|
||||
| Safe to dismiss | Triage recommends dismissal. |
|
||||
| Needs review | Human decision required. |
|
||||
| Triage complete | Triage finished and no sandbox result is present. |
|
||||
|
||||
---
|
||||
The remediation status tracks Cloud Agent fix attempts:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| Queued | The remediation attempt has been accepted and is waiting to run. |
|
||||
| Starting | Cloud Agent launch is being prepared. |
|
||||
| Running | Cloud Agent is working on the remediation. |
|
||||
| Cancellation requested | User asked Cloud Agent to stop; cancellation is best effort. |
|
||||
| PR opened | Security Agent verified a remediation PR for the expected repository and branch. |
|
||||
| No changes needed | Cloud Agent found no code change to make. Finding remains open. |
|
||||
| Blocked | Security Agent or Cloud Agent could not proceed safely. |
|
||||
| Failed | Attempt ended with failure. |
|
||||
| Cancelled | Attempt stopped without opening a PR. |
|
||||
|
||||
## Dismiss findings
|
||||
|
||||
There are two ways findings get dismissed.
|
||||
You can dismiss a finding manually from its details. Manual dismissal requires a reason:
|
||||
|
||||
**Manually**, you select a finding and choose **Dismiss**. You'll pick a reason — Fix started, No bandwidth, Tolerable risk, Inaccurate, or Not used — and optionally add a comment. The dismissal syncs back to GitHub and closes the corresponding Dependabot alert.
|
||||
- Fix started
|
||||
- No bandwidth
|
||||
- Tolerable risk
|
||||
- Inaccurate
|
||||
- Not used
|
||||
|
||||
**Automatically**, when auto-dismiss is enabled, the agent closes findings on its own. After deep analysis, any finding determined to be not exploitable is dismissed immediately. After triage, findings with a "dismiss" recommendation are dismissed if they meet your configured confidence threshold. All auto-dismissed alerts are written back to GitHub with a `[Kilo Code auto-dismiss]` prefix.
|
||||
You can optionally add a comment. A manual dismissal syncs to GitHub and closes the matching Dependabot alert.
|
||||
|
||||
---
|
||||
When enabled, auto-dismiss automatically dismisses findings that sandbox analysis determines are not exploitable and recommends dismissing. It can also dismiss triage-only findings that recommend dismissal and meet the configured confidence threshold. Security Agent attempts to write each auto-dismissed finding back to GitHub with a `[Kilo Code auto-dismiss]` prefix. See [Configure auto-dismiss](#configure-auto-dismiss) for threshold behavior.
|
||||
|
||||
## Configure the agent
|
||||
## Remediate findings
|
||||
|
||||
All settings are on the Security Agent configuration page.
|
||||
Remediation creates a Security Remediation Attempt and asks Cloud Agent to prepare a fix. If Cloud Agent can make a safe change, it opens a pull request in the affected repository.
|
||||
|
||||
**Repository selection** lets you monitor all repositories accessible to the KiloConnect App or pick specific ones from a list.
|
||||
Starting remediation does not mark the Security Finding fixed. A remediation PR is evidence of work in progress. The finding remains open until Dependabot reports it fixed or someone dismisses it.
|
||||
|
||||
**AI models** can be configured separately for triage and deep analysis. The default is Claude Opus 4.6.
|
||||
### Manual remediation
|
||||
|
||||
**Analysis mode** controls the pipeline — Auto (triage then selective deep analysis), Shallow (triage only), or Deep (full analysis on everything). See [Choose an analysis mode](#choose-an-analysis-mode) for details.
|
||||
You can start manual remediation from an eligible finding even when Auto Remediation is disabled. Manual remediation does not have to meet the Auto Remediation severity threshold.
|
||||
|
||||
**Auto-analysis** toggles whether new findings are analyzed automatically. When on, you set a minimum severity threshold (Critical only, High+, Medium+, or All) and whether to include findings that existed before you enabled the feature.
|
||||
The following safety gates still apply to manual remediation:
|
||||
|
||||
**Auto-dismiss** toggles automatic dismissal of non-exploitable findings. You configure a confidence threshold: High only, Medium+, or Any. The "Any" option dismisses at any confidence level — use it with caution.
|
||||
- the finding is open;
|
||||
- Security Agent is enabled;
|
||||
- the repository is still in Security Agent scope;
|
||||
- sandbox analysis is complete and fresh for the current finding data;
|
||||
- analysis provides a concrete fix path;
|
||||
- no active remediation attempt exists;
|
||||
- no known remediation PR exists for the finding.
|
||||
|
||||
**SLA deadlines** set how many days your team has to remediate findings at each severity level:
|
||||
Manual remediation can proceed when exploitability is unknown or the result requires manual review, provided a concrete fix path is available. Monitor-only findings remain ineligible for one-click remediation.
|
||||
|
||||
| Severity | Default |
|
||||
### Auto remediation
|
||||
|
||||
Auto Remediation is off by default. When enabled, Security Agent can start remediation automatically only for findings that pass all these gates:
|
||||
|
||||
- the finding is open;
|
||||
- the repository is in Security Agent scope;
|
||||
- sandbox analysis is complete and fresh;
|
||||
- the finding is exploitable;
|
||||
- analysis recommends opening a PR;
|
||||
- analysis provides a concrete fix path;
|
||||
- severity meets the configured Auto Remediation threshold;
|
||||
- there is no active attempt, known PR, or duplicate automatic terminal result.
|
||||
|
||||
Auto Remediation applies to future findings after their analysis completes. If Include existing findings is enabled, it also queues eligible findings that have already been analyzed. Duplicate PRs and duplicate automatic attempts for the same analysis result remain suppressed.
|
||||
|
||||
### Retry and cancel
|
||||
|
||||
You can retry an attempt that failed, was blocked or cancelled, or ended with no changes needed, provided the finding still passes the safety gates and no PR has opened.
|
||||
|
||||
You can cancel a queued or running attempt. Cancellation is best effort. If Cloud Agent opens a verified PR before cancellation completes, Security Agent shows the status as PR opened.
|
||||
|
||||
## Configure Security Agent
|
||||
|
||||
Settings are split into four tabs: General, Automation, Notifications, and SLA.
|
||||
|
||||
### General
|
||||
|
||||
General settings include:
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
| Security Agent enabled | Off until you turn it on | Turning it on queues an initial sync for the selected repository scope. |
|
||||
| Repository selection | Selected repositories during setup | Choose all accessible repositories or selected repositories. |
|
||||
| Triage model | Kilo Balanced | Used for initial triage and exploitability recommendations. |
|
||||
| Analysis model | Kilo Balanced | Used for sandbox analysis and result extraction. |
|
||||
| Remediation model | Kilo Balanced | Used by Cloud Agent for remediation PR work. |
|
||||
| Analysis mode | Auto | Auto, Shallow, or Deep. |
|
||||
|
||||
#### Turn Security Agent on or off
|
||||
|
||||
Turning on Security Agent checks the GitHub App permissions and queues an initial sync for the current repository scope. Scheduled syncs then run every 6 hours.
|
||||
|
||||
Turning it off stops scheduled syncs and prevents new automatic analysis, remediation, and notification work. It does not delete existing findings or audit history, and it does not cancel a remediation attempt that is already running. Authorized users can still open existing [audit reports](#audit-reports).
|
||||
|
||||
#### Choose repository scope
|
||||
|
||||
Repository scope controls which Dependabot alerts Security Agent syncs:
|
||||
|
||||
- **All repositories** includes every repository currently available to the Kilo GitHub App.
|
||||
- **Selected repositories** includes only the repositories you choose.
|
||||
|
||||
Changing the scope affects future syncs and remediation eligibility. Removing a repository from scope does not close its existing findings. Open findings remain eligible for SLA tracking until they are fixed, dismissed, superseded, or deleted. If the GitHub App can no longer access a repository, its findings become orphaned and can be removed through [Clear orphaned findings](#clear-orphaned-findings).
|
||||
|
||||
#### Choose models
|
||||
|
||||
Security Agent uses a separate model for each stage:
|
||||
|
||||
- The Triage model reviews advisory metadata and makes the initial exploitability recommendation.
|
||||
- The Analysis model runs sandbox analysis and extracts the result.
|
||||
- The Remediation model is used by Cloud Agent to prepare remediation pull requests.
|
||||
|
||||
Kilo Balanced is the default for all three stages. You can change each model independently. The model recorded in finding details is the model used when that analysis or remediation attempt ran. AI triage, sandbox analysis, and remediation consume Kilo Code credits.
|
||||
|
||||
#### Choose an analysis mode
|
||||
|
||||
Analysis mode controls how Security Agent analyzes a finding after syncing it.
|
||||
|
||||
| Mode | What happens |
|
||||
|---|---|
|
||||
| Critical | 15 days |
|
||||
| High | 30 days |
|
||||
| Medium | 45 days |
|
||||
| Low | 90 days |
|
||||
| Auto | Run triage first, then sandbox analysis only when triage recommends it. |
|
||||
| Shallow | Run triage only. Sandbox analysis does not run automatically. |
|
||||
| Deep | Run sandbox analysis for every finding. |
|
||||
|
||||
You can adjust these per your organization's policies and reset to defaults at any time.
|
||||
Auto is the default. It runs sandbox analysis only when triage determines that codebase-level analysis is needed, which limits sandbox-analysis credit usage.
|
||||
|
||||
---
|
||||
### Automation
|
||||
|
||||
Automation settings include:
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
| Auto-analysis | Off | Automatically analyzes synced findings. |
|
||||
| Auto-analysis minimum severity | High and above | Critical only, High and above, Medium and above, or All severities. |
|
||||
| Auto-analysis include existing | Off | Queues previously synced eligible findings. |
|
||||
| Auto-remediation | Off | Automatically starts remediation for eligible exploitable findings; successful attempts open PRs. |
|
||||
| Auto-remediation minimum severity | High and above | Critical only, High and above, Medium and above, or All severities. |
|
||||
| Auto-remediation include existing | Off | Queues already-analyzed eligible findings; duplicate PRs stay suppressed. |
|
||||
| Auto-dismiss | Off | Automatically dismisses findings that analysis determines are not exploitable and recommends dismissing. |
|
||||
| Auto-dismiss confidence threshold | High confidence only | High confidence only, Medium or higher, or Any confidence. |
|
||||
|
||||
#### Configure auto-analysis
|
||||
|
||||
Auto-analysis is off by default. When enabled, it queues open findings that are first synced after activation and meet the minimum severity:
|
||||
|
||||
- **Critical only** analyzes critical findings.
|
||||
- **High and above** analyzes high and critical findings.
|
||||
- **Medium and above** analyzes medium, high, and critical findings.
|
||||
- **All severities** analyzes every finding, including findings whose severity is unknown.
|
||||
|
||||
Without Include existing findings, previously synced findings are not added to the automatic-analysis backlog. Turning on Include existing findings queues eligible open findings that already exist in Kilo. Re-enabling Auto-analysis while Include existing findings remains on also checks that backlog. This can use additional credits.
|
||||
|
||||
The selected [analysis mode](#choose-an-analysis-mode) controls the depth of each automatic analysis. Auto-analysis shares per-owner analysis capacity with manual analysis, so a large backlog may take time to finish.
|
||||
|
||||
#### Configure auto-remediation
|
||||
|
||||
Auto-remediation acts only on eligible findings that meet its minimum severity, using the same cumulative severity bands as Auto-analysis. Include existing findings also applies the policy to eligible findings whose sandbox analysis is already complete. Re-enabling Auto-remediation or lowering its severity threshold while Include existing findings is on checks the existing backlog again. Duplicate PRs and duplicate automatic attempts remain suppressed.
|
||||
|
||||
The automatic severity threshold does not restrict manual remediation. See [Auto remediation](#auto-remediation) for the complete eligibility and safety gates, or [Manual remediation](#manual-remediation) for user-triggered fixes.
|
||||
|
||||
#### Configure auto-dismiss
|
||||
|
||||
Auto-dismiss uses the confidence threshold only for triage-only findings that AI recommends dismissing:
|
||||
|
||||
- **High confidence only** accepts only high-confidence recommendations.
|
||||
- **Medium or higher** accepts medium- and high-confidence recommendations.
|
||||
- **Any confidence** accepts low-, medium-, and high-confidence recommendations. Use this option with caution.
|
||||
|
||||
The confidence threshold does not apply after sandbox analysis. When sandbox analysis determines that a finding is not exploitable and recommends dismissal, Auto-dismiss can dismiss it regardless of confidence. Enabling Auto-dismiss does not start analysis by itself; it applies when an analysis produces an eligible result.
|
||||
|
||||
Security Agent records the dismissal in Kilo and attempts to sync it to GitHub with a `[Kilo Code auto-dismiss]` prefix. A GitHub write-back failure does not reopen the local finding. See [Dismiss findings](#dismiss-findings) for manual dismissal behavior.
|
||||
|
||||
### Notifications
|
||||
|
||||
The Notifications tab controls New-finding Notifications.
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
| New-finding Notifications | Off | Sends an email when Kilo first inserts an eligible open finding. |
|
||||
| New-finding minimum severity | High and above | Critical only, High and above, Medium and above, or Low and above. |
|
||||
|
||||
#### New-finding notifications
|
||||
|
||||
A New-finding Notification is eligible only when Kilo first inserts an open finding whose severity meets the configured minimum. Severity thresholds are cumulative: for example, High and above includes high and critical findings. Later updates, severity changes, and reopening do not make an existing finding new again. Lowering the severity threshold also does not replay earlier findings.
|
||||
|
||||
Existing Dependabot alerts discovered during the first Security Agent sync count as new because that sync is the first time Kilo inserts them. Enabling New-finding Notifications later does not replay those historical insertions.
|
||||
|
||||
For a personal Security Agent, emails go to the owning user. For an organization Security Agent, they go only to current organization owners. Members and billing managers receive these notifications only if they are also organization owners. See [Notification delivery](#notification-delivery) for delivery checks and deduplication behavior.
|
||||
|
||||
### SLA
|
||||
|
||||
The SLA tab controls remediation deadlines and SLA notifications.
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
| SLA tracking | On | Controls SLA dashboard posture and warning or breach eligibility. |
|
||||
| Critical deadline | 15 days | Editable from 1 to 365 days. |
|
||||
| High deadline | 30 days | Editable from 1 to 365 days. |
|
||||
| Medium deadline | 45 days | Editable from 1 to 365 days. |
|
||||
| Low deadline | 90 days | Editable from 1 to 365 days. |
|
||||
| SLA notifications | Off | Sends warning and breach emails. |
|
||||
| SLA notification minimum severity | High and above | Critical only, High and above, Medium and above, or Low and above. |
|
||||
| SLA warning lead time | 3 days | Whole number from 1 to 365 days. |
|
||||
|
||||
#### How SLA deadlines work
|
||||
|
||||
Security Agent calculates a finding's deadline from the date GitHub first detected the Dependabot alert and the configured number of days for its severity. When a later sync changes the finding's severity or uses updated deadline settings, Security Agent refreshes the persisted deadline.
|
||||
|
||||
Turning off SLA tracking removes SLA posture from the dashboard and makes findings ineligible for SLA Warning and SLA Breach Notifications. It does not close findings, remove their persisted deadlines, or erase recorded activity. Security Agent continues to refresh deadlines when it syncs findings.
|
||||
|
||||
#### SLA notifications
|
||||
|
||||
SLA notifications require both SLA tracking and SLA notifications to be enabled. The minimum severity is cumulative, and warning lead time controls how many whole days before the persisted deadline a warning becomes eligible. See [Notification delivery](#notification-delivery) for exact timing, deduplication, and setting-change behavior.
|
||||
|
||||
## Notification delivery
|
||||
|
||||
Security Agent currently sends notifications only by email.
|
||||
|
||||
Notification kinds:
|
||||
|
||||
| Kind | When eligible |
|
||||
|---|---|
|
||||
| New-finding Notification | Kilo first inserts an eligible open finding. |
|
||||
| SLA Warning Notification | Eligible open finding enters configured warning window before persisted SLA deadline. |
|
||||
| SLA Breach Notification | Eligible open finding reaches or passes persisted SLA deadline. |
|
||||
|
||||
A finding that is already breached does not receive a stale warning. A warning sent earlier does not suppress the later breach notification. If you enable SLA notifications later, an eligible open finding can produce its current warning or breach during the next evaluation.
|
||||
|
||||
Security Agent creates at most one notification of each kind per finding and recipient. Syncs and sweeps do not intentionally create duplicate notification events. Sent notifications are not replayed when settings change.
|
||||
|
||||
Delivery is asynchronous. Before sending an email, Kilo rechecks the current finding state, Security Agent settings, severity thresholds, SLA state, and recipient authorization. Kilo cancels unsent notification work if the finding is fixed, dismissed, superseded, deleted, or no longer meets the configured threshold, or if the recipient is no longer authorized.
|
||||
|
||||
Email subjects are:
|
||||
|
||||
| Kind | Subject |
|
||||
|---|---|
|
||||
| New finding | Kilo Security Agent: New finding |
|
||||
| SLA warning | Kilo Security Agent: SLA warning |
|
||||
| SLA breach | Kilo Security Agent: SLA breached |
|
||||
|
||||
Emails include finding severity, repository, title, description, CVE/GHSA/CVSS metadata when available, the SLA deadline for SLA emails, a link to Security Agent findings, and a link to the relevant notification settings.
|
||||
|
||||
## Audit reports
|
||||
|
||||
The audit report shows Security Finding activity recorded for an owner during a selected reporting period. Open it from Security Agent navigation at the applicable route:
|
||||
|
||||
- `/security-agent/audit-report`
|
||||
- `/organizations/:organizationId/security-agent/audit-report`
|
||||
|
||||
The audit report is based on activity recorded by Kilo. It does not prove that every historical event is present, show repository scan coverage, or provide aggregate SLA compliance.
|
||||
|
||||
### Report period and filters
|
||||
|
||||
By default, the report covers the last 90 calendar days, ending today. A reporting period cannot exceed 90 inclusive calendar days. Kilo rejects future or reversed ranges.
|
||||
|
||||
Filters:
|
||||
|
||||
| Filter | Options |
|
||||
|---|---|
|
||||
| Reporting period | Date range up to 90 inclusive calendar days. |
|
||||
| Severity | All, Critical, High, Medium, Low. |
|
||||
| Recorded state | All, Open, Fixed, Dismissed, Superseded, Deleted. |
|
||||
| Repository | All repositories or one repository recorded in report evidence. |
|
||||
|
||||
For each matching finding group, filters retain the complete timeline within the selected period.
|
||||
|
||||
### Report content
|
||||
|
||||
The report starts with summary counts for findings, events, superseded findings, and severity. It then groups activity by Security Finding, combining relevant repository, package, advisory, status, and SLA context with a timeline for the selected period.
|
||||
|
||||
Each timeline event shows when Kilo recorded or applied the activity, who or what performed it, and the evidence needed to understand the outcome. Superseded and deleted findings retain their recorded identity and lifecycle context.
|
||||
|
||||
### Reportable activity
|
||||
|
||||
The audit report includes material Security Finding activity recorded during the selected period:
|
||||
|
||||
- a finding imported into Kilo;
|
||||
- a severity change;
|
||||
- a status change, including reopened and fixed;
|
||||
- manual dismissal, auto-dismissal, supersession, or deletion;
|
||||
- terminal analysis completion or failure;
|
||||
- a remediation request;
|
||||
- remediation ending with PR opened, failed, blocked, cancelled, or no changes needed.
|
||||
|
||||
The audit report excludes reads, page views, unchanged sync observations, queue claims, heartbeats, and retries with no new finding-level outcome. It also excludes notification delivery history, repository scan-coverage appendices, configuration timelines, and report-generation events within the report itself.
|
||||
|
||||
If a report query fails, times out, or exceeds its budget, Kilo returns no report content rather than partial results. Choose a shorter reporting period and generate the report again.
|
||||
|
||||
### Report access
|
||||
|
||||
Personal audit reports are available to the owning user.
|
||||
|
||||
Organization audit reports are available to organization owners, billing managers, and Kilo platform admins. Under current route permissions, organization members who are not owners or billing managers can use other Security Agent surfaces but cannot access organization audit reports.
|
||||
|
||||
Security Agent does not need to be enabled to view an existing report. The report page is available after GitHub integration and the initial Security Agent configuration have been set up. Setup-only states redirect to Settings.
|
||||
|
||||
## Clear orphaned findings
|
||||
|
||||
If repositories are removed from your GitHub integration or become inaccessible, their findings become orphaned. When this happens, a card appears on the settings page to permanently delete them.
|
||||
If repositories are removed from GitHub integration or become inaccessible, their findings can become orphaned. The Settings page shows a cleanup card when orphaned repositories exist.
|
||||
|
||||
{% callout type="warning" %}
|
||||
Clearing orphaned findings is permanent and cannot be undone. Only do this when you're sure the repositories won't be reconnected.
|
||||
Clearing orphaned findings permanently deletes findings for the selected repository. You cannot undo this action. Use it only if the repository will not be reconnected.
|
||||
{% /callout %}
|
||||
|
||||
---
|
||||
|
||||
## Compare with Code Reviews
|
||||
|
||||
Kilo offers two complementary security features that work best together.
|
||||
Security Agent and Code Reviews cover different security surfaces.
|
||||
|
||||
[**Code Reviews**](/docs/automate/code-reviews/overview) analyzes PR diffs for code quality issues, including security patterns like `innerHTML` usage and hardcoded secrets. It catches problems in new code as it's written.
|
||||
| Feature | What it covers |
|
||||
|---|---|
|
||||
| Code Reviews | Pull request diffs, including security patterns in new code. |
|
||||
| Security Agent | Dependency vulnerability alerts across selected repositories, including reachability analysis and remediation for Dependabot findings. |
|
||||
|
||||
**Security Reviews** takes a different angle — it contextualizes dependency vulnerability alerts across your entire codebase to determine whether Dependabot-reported CVEs are actually exploitable based on how your code uses the affected packages.
|
||||
|
||||
Together, Code Reviews covers your new code surface and Security Reviews covers your dependency vulnerability surface.
|
||||
|
||||
---
|
||||
Use Code Reviews to catch risky changes before they are merged. Use Security Agent to manage the dependency vulnerability backlog and determine which Dependabot alerts are exploitable in your codebase.
|
||||
|
||||
## Limitations
|
||||
|
||||
Security Reviews currently works with **GitHub only** — GitLab support is not yet available.
|
||||
The following capabilities are not yet implemented but are being considered for upcoming releases of the Security Agent.
|
||||
|
||||
The only data source right now is **Dependabot alerts**. Additional sources like npm audit and SBOM analysis are planned.
|
||||
|
||||
There is a **per-account limit** on concurrent analyses. If you have a large backlog, findings will be queued and processed in order.
|
||||
- GitLab support for Security Agent findings and remediation.
|
||||
- Security Finding sources beyond Dependabot alerts, such as npm audit and SBOM analysis.
|
||||
- Notification channels beyond email.
|
||||
- Historical replay when you enable New-finding Notifications.
|
||||
- Analysis and remediation without queue delays. This work currently runs through queues and can be delayed by account capacity or worker backlog.
|
||||
- Automatic finding updates based on the full remediation PR lifecycle. A finding currently closes only when Dependabot reports it fixed or someone dismisses it.
|
||||
- Planned remediations that combine multiple Security Findings.
|
||||
- Repository write-permission checks when you configure Security Agent.
|
||||
- Audit report periods longer than 90 days.
|
||||
- Complete legacy-history reconstruction and aggregate historical SLA compliance percentages.
|
||||
- Server-stored report artifacts, PDFs, report caching, and exhaustive notification delivery history.
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7338febdf802b5ce501773be164e9da152fb7903e9b16807bd8751fa38f208ed
|
||||
size 10310
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f7e075b4961d3a62c8deda6b481f64cb8777d05183a9c10d0dd2373b7972d3ab
|
||||
size 53678
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b0dd1dc26b28d354ecd14c53dd1b9e6158a161f47152f30299ba4c0827ce1674
|
||||
size 50904
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6eb9d7670f55c5529dce90d11fceb5c9da7a0d3a5f7270fdfd27e66ca1f3d72f
|
||||
size 9851
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1edca81fb2c946f87df5436684418e63b7a9c761079e44f024971a81316b8263
|
||||
size 6549
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ce4ba314abb23bb43694d512ade273d99760f250b061e235899d86e170bb5f9a
|
||||
size 52892
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:631d41ecf6216249943722a797f75c3aefb2f84ebdb7ee80529662a26fa35af5
|
||||
size 50206
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4a7bcef3102838812625393f0cead3a10067ae32e67dd3f174d0e74d03116c8c
|
||||
size 54735
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9f722d2782f69170a3efca6ed64ab02d9140f845bb47059ba193e238c768d0a5
|
||||
size 4852
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c35bc13f78745ab41bf39e732c3945578fc2cb2cc47ef231e51b1357bc0b2817
|
||||
size 54160
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:52c6eeef2d393b159fe9e88a9d283308d969b7311fe68ea23667561d21a2ed87
|
||||
size 51338
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:88d2598f448e68b32b3daef5420a3e31ae5fdafe77ced20aadbc4fdf4c1ab84a
|
||||
size 4682
|
||||
oid sha256:4f395ec688aa5adc800b2145615691ee9bfcc1b5a365600105dd1a17f28e343c
|
||||
size 6118
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:add00c319d54f53c7c9859428b18182fa91da1b17e4c03b1ee8015ed08f73d19
|
||||
size 48653
|
||||
oid sha256:4d5db71fbed0542dd8ef3101d7b56b9538b9e6411921150d0957996acd620fde
|
||||
size 51161
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d9fbce155629b97a30ee9f95f226ef883b6febf8002a657516ff439e3fae0f6c
|
||||
size 51829
|
||||
oid sha256:c501793cb8d0ad50b2d8d283a6314ce4b21dabdc0e95f63174c4a7e475523e32
|
||||
size 54055
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3330276aded477430e615ababb2caa7eeb2fa7f7406974b9b829e6092d5a619a
|
||||
size 51792
|
||||
oid sha256:8d280dc15c3e64ddf365e1bdaa572d07da0199d0cb9d37acb174ae70db5ae746
|
||||
size 57329
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bb78008c0fcbc47cb4b73fcea0739ce975c64b31d58852d74fd31078f3774694
|
||||
size 54958
|
||||
oid sha256:16b0e2bed3c576328a5565ae957c3af4abbc884a0db894472f587b29e1c355b4
|
||||
size 56194
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a817c2b87e1d7b28a7c28deaa8fd8d1e9c51aa2daabda1a2de4a0db0a33fcc17
|
||||
size 27768
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type AutocompleteProviderID = "kilo" | "mistral" | "inception"
|
||||
export type DirectAutocompleteProviderID = Exclude<AutocompleteProviderID, "kilo">
|
||||
|
||||
export interface AutocompleteModelDef {
|
||||
interface AutocompleteModelBase {
|
||||
/** Stable combined value for internal comparisons. */
|
||||
readonly id: string
|
||||
/** Model value stored in settings and sent to the autocomplete API. */
|
||||
@@ -18,14 +18,23 @@ export interface AutocompleteModelDef {
|
||||
readonly directProvider?: DirectAutocompleteProviderID
|
||||
/** Request temperature. */
|
||||
readonly temperature: number
|
||||
/**
|
||||
* Which gateway endpoint this model targets. Defaults to "fim" if omitted
|
||||
* (back-compat with existing entries). Models with `kind: "edit"` route
|
||||
* through `/kilo/edit` and use Mercury's Next Edit pipeline.
|
||||
*/
|
||||
readonly kind?: "fim" | "edit"
|
||||
}
|
||||
|
||||
export type AutocompleteModelDef = AutocompleteModelBase &
|
||||
(
|
||||
| {
|
||||
/** Route through `/kilo/edit` using the Next Edit pipeline. */
|
||||
readonly kind: "edit"
|
||||
/** Stable combined ID of the FIM model used where Next Edit is unsupported. */
|
||||
readonly fimModelID: string
|
||||
}
|
||||
| {
|
||||
/** Route through the FIM endpoint. */
|
||||
readonly kind?: "fim"
|
||||
readonly fimModelID?: never
|
||||
}
|
||||
)
|
||||
|
||||
const models: AutocompleteModelDef[] = [
|
||||
{
|
||||
id: "kilo/mistralai/codestral-2508",
|
||||
@@ -57,6 +66,7 @@ const models: AutocompleteModelDef[] = [
|
||||
requestModel: "inception/mercury-edit-2",
|
||||
temperature: 0,
|
||||
kind: "edit",
|
||||
fimModelID: "kilo/inception/mercury-edit-2",
|
||||
},
|
||||
{
|
||||
id: "mistral/codestral-2508",
|
||||
@@ -91,6 +101,7 @@ const models: AutocompleteModelDef[] = [
|
||||
directProvider: "inception",
|
||||
temperature: 0,
|
||||
kind: "edit",
|
||||
fimModelID: "inception/mercury-edit-2",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -18,3 +18,15 @@ describe("DEFAULT_AUTOCOMPLETE_MODEL", () => {
|
||||
expect(DEFAULT_AUTOCOMPLETE_MODEL.kind).toBe("edit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Next Edit FIM models", () => {
|
||||
test("reference a FIM model from the same provider", () => {
|
||||
for (const model of AUTOCOMPLETE_MODELS) {
|
||||
if (model.kind !== "edit") continue
|
||||
const sibling = AUTOCOMPLETE_MODELS.find((candidate) => candidate.id === model.fimModelID)
|
||||
expect(sibling).toBeDefined()
|
||||
expect(sibling?.kind).not.toBe("edit")
|
||||
expect(sibling?.providerID).toBe(model.providerID)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-indexing",
|
||||
"version": "7.3.53",
|
||||
"version": "7.3.54",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Standalone indexing engine and host helpers for Kilo Code",
|
||||
|
||||
@@ -277,6 +277,8 @@ export class CodeIndexOrchestrator {
|
||||
|
||||
private async _runScan(mode: IndexingTelemetryMode, trigger: IndexingTelemetryTrigger): Promise<void> {
|
||||
if (this._cancelRequested) {
|
||||
if (mode === "incremental") await this.vectorStore.markIndexingComplete()
|
||||
this.stateManager.setSystemState("Standby", "Indexing cancelled.")
|
||||
log.info("scan skipped: cancellation was requested", { workspacePath: this.workspacePath, mode })
|
||||
return
|
||||
}
|
||||
@@ -319,6 +321,10 @@ export class CodeIndexOrchestrator {
|
||||
})
|
||||
|
||||
if (this._cancelRequested || this.scanner.isCancelled) {
|
||||
if (mode === "incremental" && result.stats.processed === 0 && batchErrors.length === 0) {
|
||||
await this.vectorStore.markIndexingComplete()
|
||||
log.info("preserved unchanged index after cancelled scan", { workspacePath: this.workspacePath })
|
||||
}
|
||||
this._isProcessing = false
|
||||
if (this.stateManager.state !== "Error") {
|
||||
this.stateManager.setSystemState("Standby", "Indexing cancelled.")
|
||||
|
||||
@@ -18,7 +18,9 @@ import { Emitter } from "../../../src/indexing/runtime"
|
||||
class Store {
|
||||
public clearCount = 0
|
||||
public closeCount = 0
|
||||
public completeCount = 0
|
||||
public deleteCount = 0
|
||||
public incompleteCount = 0
|
||||
|
||||
constructor(
|
||||
private readonly existing: boolean,
|
||||
@@ -57,8 +59,12 @@ class Store {
|
||||
async hasIndexedData(): Promise<boolean> {
|
||||
return this.existing
|
||||
}
|
||||
async markIndexingComplete(): Promise<void> {}
|
||||
async markIndexingIncomplete(): Promise<void> {}
|
||||
async markIndexingComplete(): Promise<void> {
|
||||
this.completeCount += 1
|
||||
}
|
||||
async markIndexingIncomplete(): Promise<void> {
|
||||
this.incompleteCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
class Scanner {
|
||||
@@ -270,6 +276,31 @@ describe("CodeIndexOrchestrator telemetry", () => {
|
||||
|
||||
expect(scanner.finished).toBe(true)
|
||||
expect(store.closeCount).toBe(1)
|
||||
expect(store.incompleteCount).toBe(1)
|
||||
expect(store.completeCount).toBe(0)
|
||||
})
|
||||
|
||||
test("preserves an unchanged index when an incremental scan is interrupted", async () => {
|
||||
const scanner = new BlockingScanner()
|
||||
const store = new Store(true)
|
||||
const orchestrator = new CodeIndexOrchestrator(
|
||||
createConfig(),
|
||||
new CodeIndexStateManager(),
|
||||
"/tmp/ws",
|
||||
{ async clearCacheFile() {}, async flush() {} } as unknown as CacheManager,
|
||||
store as unknown as IVectorStore,
|
||||
scanner as unknown as DirectoryScanner,
|
||||
new Watcher() as unknown as IFileWatcher,
|
||||
)
|
||||
|
||||
const active = orchestrator.startIndexing("background")
|
||||
await scanner.started.promise
|
||||
await orchestrator.shutdown()
|
||||
await active
|
||||
|
||||
expect(store.incompleteCount).toBe(1)
|
||||
expect(store.completeCount).toBe(1)
|
||||
expect(store.clearCount).toBe(0)
|
||||
})
|
||||
|
||||
test("clears stale vectors and hashes before rebuilding an incomplete store", async () => {
|
||||
|
||||
@@ -88,6 +88,23 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [7.0.1-rc.13] - 2026-06-23
|
||||
|
||||
### Added
|
||||
|
||||
- Add slash command and file mention completion in the prompt.
|
||||
- Add support for clickable and explainable `@file` mentions in the prompt.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix prompt undo/redo behavior and restore prompt focus after history navigation.
|
||||
- Fix lazy session creation to avoid duplicate initialization.
|
||||
- Fix prompt-training model disclosure.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update the bundled CLI to include upstream OpenCode 1.15.13 changes.
|
||||
|
||||
## [7.0.1-rc.12] - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
+8
-1
@@ -168,7 +168,8 @@ class KiloBackendChatManager(
|
||||
val raw = response.body?.string()
|
||||
log.warn("prompt_async failed: HTTP $code")
|
||||
raw?.let { log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async error=${ChatLogSummary.body(it)}" } }
|
||||
throw RuntimeException("prompt_async failed: HTTP $code")
|
||||
val detail = raw?.takeIf { it.isNotBlank() }?.let { ": ${ChatLogSummary.body(it)}" }.orEmpty()
|
||||
throw RuntimeException("prompt_async failed: HTTP $code$detail")
|
||||
}
|
||||
log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async ok=true code=$code" }
|
||||
}
|
||||
@@ -180,6 +181,12 @@ class KiloBackendChatManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun command(id: String, dir: String, command: String, args: String, prompt: PromptDto) {
|
||||
log.info("${ChatLogSummary.sid(id)} kind=command command=$command args=${args.length} parts=${prompt.parts.size}")
|
||||
val body = KiloCliDataParser.buildCommandJson(command, args, prompt)
|
||||
post("/session/$id/command?directory=${encode(dir)}", body, "command", "${ChatLogSummary.sid(id)} kind=command command=$command")
|
||||
}
|
||||
|
||||
// ------ abort ------
|
||||
|
||||
fun abort(id: String, dir: String) {
|
||||
|
||||
+4
@@ -88,14 +88,18 @@ class KiloBackendCliManager(
|
||||
val platform = platform()
|
||||
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
|
||||
val worker = File(target.parentFile, "kilo-sandbox-mutation-worker.js")
|
||||
|
||||
if (forceExtract) {
|
||||
log.info("Force re-extracting CLI resources under ${target.parentFile.absolutePath}")
|
||||
if (target.exists()) target.delete()
|
||||
if (worker.exists()) worker.delete()
|
||||
forceExtract = false
|
||||
}
|
||||
|
||||
extractResource("cli/$platform/$exe", target, executable = true)
|
||||
if (worker.exists()) worker.delete()
|
||||
extractResource("cli/$platform/kilo-sandbox-mutation-worker.js", worker, executable = false)
|
||||
return target
|
||||
}
|
||||
|
||||
|
||||
+59
@@ -23,6 +23,8 @@ import ai.kilocode.rpc.dto.ModelLimitDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.ModelStateDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PartSourceDto
|
||||
import ai.kilocode.rpc.dto.PartSourceTextDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionFileDiffDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
@@ -586,6 +588,7 @@ object KiloCliDataParser {
|
||||
part.mime?.let { fields += "\"mime\":${escape(it)}" }
|
||||
part.url?.let { fields += "\"url\":${escape(it)}" }
|
||||
part.filename?.let { fields += "\"filename\":${escape(it)}" }
|
||||
part.source?.let { fields += "\"source\":${sourceJson(it)}" }
|
||||
return "{${fields.joinToString(",")}}"
|
||||
}
|
||||
fields += "\"text\":${escape(part.text.orEmpty())}"
|
||||
@@ -598,6 +601,24 @@ object KiloCliDataParser {
|
||||
fun buildSummarizeJson(model: ModelSelectionDto): String =
|
||||
"""{"providerID":${escape(model.providerID)},"modelID":${escape(model.modelID)}}"""
|
||||
|
||||
fun buildCommandJson(command: String, args: String, prompt: PromptDto): String {
|
||||
val fields = mutableListOf(
|
||||
"\"command\":${escape(command)}",
|
||||
"\"arguments\":${escape(args)}",
|
||||
)
|
||||
prompt.agent?.let { fields += "\"agent\":${escape(it)}" }
|
||||
prompt.variant?.let { fields += "\"variant\":${escape(it)}" }
|
||||
val pid = prompt.providerID
|
||||
val mid = prompt.modelID
|
||||
if (pid != null && mid != null) {
|
||||
val model = "$pid/$mid"
|
||||
fields += "\"model\":${escape(model)}"
|
||||
}
|
||||
val parts = prompt.parts.filter { it.type == "file" }.joinToString(",") { buildPromptPartJson(it) }
|
||||
if (parts.isNotEmpty()) fields += "\"parts\":[$parts]"
|
||||
return "{${fields.joinToString(",")}}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the partial JSON body for `PATCH /global/config`.
|
||||
*/
|
||||
@@ -772,6 +793,8 @@ object KiloCliDataParser {
|
||||
mime = obj.str("mime"),
|
||||
url = obj.str("url"),
|
||||
filename = obj.str("filename"),
|
||||
synthetic = obj.flagOrNull("synthetic"),
|
||||
source = parseSource(obj["source"]),
|
||||
tool = obj.str("tool"),
|
||||
callID = obj.str("callID"),
|
||||
state = state?.str("status"),
|
||||
@@ -799,6 +822,37 @@ object KiloCliDataParser {
|
||||
return READ_TOOL_PATH.containsMatchIn(line)
|
||||
}
|
||||
|
||||
private fun parseSource(raw: JsonElement?): PartSourceDto? {
|
||||
val obj = raw.obj() ?: return null
|
||||
val type = obj.str("type") ?: return null
|
||||
val text = obj["text"].obj() ?: return null
|
||||
val value = text.str("value") ?: return null
|
||||
val start = text.num("start") ?: return null
|
||||
val end = text.num("end") ?: return null
|
||||
return PartSourceDto(
|
||||
type = type,
|
||||
text = PartSourceTextDto(value = value, start = start, end = end),
|
||||
path = obj.str("path"),
|
||||
clientName = obj.str("clientName"),
|
||||
uri = obj.str("uri"),
|
||||
name = obj.str("name"),
|
||||
kind = obj.long("kind")?.safeInt(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sourceJson(source: PartSourceDto): String {
|
||||
val fields = mutableListOf(
|
||||
"\"type\":${escape(source.type)}",
|
||||
"\"text\":{\"value\":${escape(source.text.value)},\"start\":${source.text.start},\"end\":${source.text.end}}",
|
||||
)
|
||||
source.path?.let { fields += "\"path\":${escape(it)}" }
|
||||
source.clientName?.let { fields += "\"clientName\":${escape(it)}" }
|
||||
source.uri?.let { fields += "\"uri\":${escape(it)}" }
|
||||
source.name?.let { fields += "\"name\":${escape(it)}" }
|
||||
source.kind?.let { fields += "\"kind\":$it" }
|
||||
return "{${fields.joinToString(",")}}"
|
||||
}
|
||||
|
||||
internal fun parseTodos(raw: JsonElement?): List<TodoDto> {
|
||||
return parseTodosOrNull(raw) ?: emptyList()
|
||||
}
|
||||
@@ -1293,6 +1347,11 @@ private fun JsonObject.flag(key: String, default: Boolean): Boolean {
|
||||
return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull() ?: default
|
||||
}
|
||||
|
||||
private fun JsonObject.flagOrNull(key: String): Boolean? {
|
||||
val prim = this[key]?.jsonPrimitive ?: return null
|
||||
return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull()
|
||||
}
|
||||
|
||||
private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
|
||||
|
||||
private fun JsonObject?.map(key: String): Map<String, String> {
|
||||
|
||||
+6
@@ -110,6 +110,12 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
|
||||
chat.prompt(id, directory, prompt)
|
||||
}
|
||||
|
||||
override suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto) {
|
||||
app.requireReady()
|
||||
LOG.info("command RPC: session=$id, dir=$directory, command=$command, parts=${prompt.parts.size}")
|
||||
chat.command(id, directory, command, arguments, prompt)
|
||||
}
|
||||
|
||||
override suspend fun abort(id: String, directory: String) =
|
||||
ready { chat.abort(id, directory) }
|
||||
|
||||
|
||||
+174
-11
@@ -1,5 +1,3 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
package ai.kilocode.backend.rpc
|
||||
|
||||
import ai.kilocode.backend.app.KiloAppState
|
||||
@@ -16,19 +14,36 @@ import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.jetbrains.api.model.Agent
|
||||
import ai.kilocode.rpc.KiloWorkspaceRpcApi
|
||||
import ai.kilocode.rpc.dto.ConfigTargetDto
|
||||
import ai.kilocode.rpc.dto.FileSearchResultDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
|
||||
import ai.kilocode.rpc.dto.WorkspaceFileDto
|
||||
import com.intellij.execution.configurations.GeneralCommandLine
|
||||
import com.intellij.execution.process.CapturingProcessHandler
|
||||
import com.intellij.ide.actions.searcheverywhere.FoundItemDescriptor
|
||||
import com.intellij.ide.util.gotoByName.ChooseByNameInScopeItemProvider
|
||||
import com.intellij.ide.util.gotoByName.ChooseByNamePopup
|
||||
import com.intellij.ide.util.gotoByName.ChooseByNameViewModel
|
||||
import com.intellij.ide.util.gotoByName.GotoFileModel
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.application.readAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.progress.EmptyProgressIndicator
|
||||
import com.intellij.openapi.project.DumbService
|
||||
import com.intellij.openapi.project.IndexNotReadyException
|
||||
import com.intellij.openapi.fileEditor.OpenFileDescriptor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.roots.ProjectFileIndex
|
||||
import com.intellij.openapi.util.io.FileUtil
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.navigation.NavigationItem
|
||||
import com.intellij.psi.PsiFileSystemItem
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import com.intellij.util.indexing.FindSymbolParameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -46,6 +61,7 @@ import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
@@ -63,6 +79,8 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
|
||||
private val LEGACY = listOf("opencode.jsonc", "opencode.json")
|
||||
private val GLOBAL = MODERN + LEGACY + "config.json"
|
||||
private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode")
|
||||
private const val SEARCH_CAP = 2_000
|
||||
private const val DIFF_CAP = 200_000
|
||||
private val CONFIG = """{
|
||||
"${'$'}schema": "$SCHEMA"
|
||||
}
|
||||
@@ -71,6 +89,8 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
|
||||
|
||||
private val app: KiloBackendAppService get() = service()
|
||||
|
||||
private val gitCache = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
private val manager: KiloBackendWorkspaceManager
|
||||
get() = app.workspaces
|
||||
|
||||
@@ -173,6 +193,31 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
|
||||
return found.values.toList()
|
||||
}
|
||||
|
||||
override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto {
|
||||
val base = file(clean(directory) ?: directory) ?: return FileSearchResultDto()
|
||||
val git = withContext(Dispatchers.IO) { gitAvailable(base) }
|
||||
val project = project(base) ?: return FileSearchResultDto(git = git)
|
||||
if (DumbService.getInstance(project).isDumb) return FileSearchResultDto(indexing = true, git = git)
|
||||
return try {
|
||||
val files = readAction { search(project, base, query, limit.coerceIn(1, 200)) }
|
||||
FileSearchResultDto(files = files, git = git)
|
||||
} catch (e: IndexNotReadyException) {
|
||||
FileSearchResultDto(indexing = true, git = git)
|
||||
} catch (e: LinkageError) {
|
||||
LOG.warn("file search API unavailable; returning no suggestions", e)
|
||||
FileSearchResultDto(git = git)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun gitChanges(directory: String): String? = withContext(Dispatchers.IO) {
|
||||
val base = file(clean(directory) ?: directory) ?: return@withContext null
|
||||
if (!gitAvailable(base)) return@withContext null
|
||||
val unstaged = git(base, "diff")
|
||||
val staged = git(base, "diff", "--staged")
|
||||
val text = listOf(unstaged, staged).filter { it.isNotBlank() }.joinToString("\n")
|
||||
text.takeIf { it.isNotBlank() }?.take(DIFF_CAP)
|
||||
}
|
||||
|
||||
override suspend fun openFile(path: String): Boolean {
|
||||
val item = clean(path) ?: return false
|
||||
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
|
||||
@@ -240,15 +285,9 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
|
||||
}
|
||||
|
||||
private fun clean(path: String): String? {
|
||||
val raw = path.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return try {
|
||||
val cut = raw.substringBefore('#').substringBefore('?')
|
||||
val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8)
|
||||
Path.of(decoded.replace('\\', '/')).normalize().toString()
|
||||
} catch (e: Exception) {
|
||||
LOG.debug { "Failed to normalize workspace file path: $path (${e.message})" }
|
||||
null
|
||||
}
|
||||
val result = normalizeWorkspacePath(path)
|
||||
if (result == null && path.isNotBlank()) LOG.debug { "Failed to normalize workspace file path: $path" }
|
||||
return result
|
||||
}
|
||||
|
||||
private fun file(path: String): Path? = try {
|
||||
@@ -273,6 +312,91 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
|
||||
} ?: projects.firstOrNull()
|
||||
}
|
||||
|
||||
// Uses the IDE Go-to-File engine (com.intellij.ide.util.gotoByName.*). These are public but
|
||||
// unstable lang-impl classes (not @ApiStatus.Internal) -- the same engine behind Search Everywhere,
|
||||
// chosen for proven large-repo performance. searchFiles() degrades gracefully on LinkageError.
|
||||
@Suppress("UnstableApiUsage")
|
||||
private fun search(project: Project, base: Path, query: String, limit: Int): List<WorkspaceFileDto> {
|
||||
val text = query.trim()
|
||||
if (text.isBlank()) return roots(project, base, limit)
|
||||
val scope = GlobalSearchScope.projectScope(project)
|
||||
val model = object : GotoFileModel(project) {
|
||||
override fun acceptItem(item: NavigationItem): Boolean {
|
||||
val psi = item as? PsiFileSystemItem ?: return false
|
||||
val path = file(psi.virtualFile.path) ?: return false
|
||||
return path.startsWith(base) && super.acceptItem(item)
|
||||
}
|
||||
|
||||
override fun loadInitialCheckBoxState(): Boolean = false
|
||||
|
||||
override fun saveInitialCheckBoxState(state: Boolean) {}
|
||||
}
|
||||
val view = object : ChooseByNameViewModel {
|
||||
override fun getProject(): Project = project
|
||||
|
||||
override fun getModel() = model
|
||||
|
||||
override fun isSearchInAnyPlace(): Boolean = model.useMiddleMatching()
|
||||
|
||||
override fun transformPattern(pattern: String): String = ChooseByNamePopup.getTransformedPattern(pattern, model)
|
||||
|
||||
override fun canShowListForEmptyPattern(): Boolean = false
|
||||
|
||||
override fun getMaximumListSizeLimit(): Int = limit
|
||||
}
|
||||
val provider = model.getItemProvider(null)
|
||||
val params = FindSymbolParameters.wrap(text, scope)
|
||||
val found = mutableListOf<FoundItemDescriptor<*>>()
|
||||
val indicator = EmptyProgressIndicator()
|
||||
if (provider is ChooseByNameInScopeItemProvider) {
|
||||
provider.filterElementsWithWeights(view, params, indicator) { item ->
|
||||
found += item
|
||||
found.size < SEARCH_CAP
|
||||
}
|
||||
} else {
|
||||
provider.filterElements(view, text, false, indicator) { item ->
|
||||
found += FoundItemDescriptor(item, 0)
|
||||
found.size < SEARCH_CAP
|
||||
}
|
||||
}
|
||||
return found.asSequence()
|
||||
.sortedByDescending { it.weight }
|
||||
.mapNotNull { item -> (item.item as? PsiFileSystemItem)?.virtualFile }
|
||||
.mapNotNull { vf -> fileDto(base, vf) }
|
||||
.distinctBy { it.path }
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun roots(project: Project, base: Path, limit: Int): List<WorkspaceFileDto> {
|
||||
val root = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(base) ?: return emptyList()
|
||||
val index = ProjectFileIndex.getInstance(project)
|
||||
return root.children.asSequence()
|
||||
.filter { it.name != ".git" }
|
||||
.filterNot { index.isExcluded(it) }
|
||||
.mapNotNull { fileDto(base, it) }
|
||||
.sortedWith(
|
||||
compareByDescending<WorkspaceFileDto> { it.directory }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
|
||||
)
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun fileDto(base: Path, vf: VirtualFile): WorkspaceFileDto? {
|
||||
val path = file(vf.path) ?: return null
|
||||
val rel = relativeWithinBase(base, path) ?: return null
|
||||
return WorkspaceFileDto(rel, vf.name, vf.isDirectory)
|
||||
}
|
||||
|
||||
private fun gitAvailable(base: Path): Boolean {
|
||||
return workspaceGitAvailable(base, gitCache)
|
||||
}
|
||||
|
||||
private fun git(base: Path, vararg args: String): String {
|
||||
return runWorkspaceGit(base, *args)
|
||||
}
|
||||
|
||||
private fun agent(a: Agent) = AgentInfo(
|
||||
name = a.name,
|
||||
displayName = a.displayName,
|
||||
@@ -309,3 +433,42 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
|
||||
}
|
||||
|
||||
private fun encode(value: String) = URLEncoder.encode(value, Charsets.UTF_8)
|
||||
|
||||
internal fun normalizeWorkspacePath(path: String): String? {
|
||||
val raw = path.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return try {
|
||||
val cut = raw.substringBefore('#').substringBefore('?')
|
||||
val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8)
|
||||
Path.of(decoded.replace('\\', '/')).normalize().toString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap<String, Boolean> = ConcurrentHashMap()): Boolean {
|
||||
if (Files.exists(base.resolve(".git"))) return true
|
||||
return cache.getOrPut(base.toString()) {
|
||||
runWorkspaceGit(base, "rev-parse", "--is-inside-work-tree").trim() == "true"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun runWorkspaceGit(base: Path, vararg args: String): String {
|
||||
return try {
|
||||
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile())
|
||||
val out = CapturingProcessHandler(cmd).runProcess(5_000)
|
||||
out.stdout.takeIf { !out.isTimeout && out.exitCode == 0 }.orEmpty()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relativizes [target] against [base], returning the forward-slash relative path, or null if
|
||||
* [target] is not strictly inside [base] (path-traversal guard) or equals the base itself.
|
||||
*/
|
||||
internal fun relativeWithinBase(base: Path, target: Path): String? {
|
||||
val path = target.normalize()
|
||||
if (!path.startsWith(base)) return null
|
||||
val rel = base.relativize(path).toString().replace('\\', '/')
|
||||
return rel.ifBlank { null }
|
||||
}
|
||||
|
||||
+18
@@ -3,6 +3,8 @@ package ai.kilocode.backend.app
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -75,6 +77,22 @@ class KiloBackendChatManagerTest {
|
||||
assertEquals("Enhance prompt failed: HTTP 500", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prompt failure includes CLI response body summary`() {
|
||||
val port = mock.start()
|
||||
val chat = KiloBackendChatManager(scope, TestLog())
|
||||
chat.start(OkHttpClient(), port, MutableSharedFlow())
|
||||
mock.promptStatus = 400
|
||||
mock.promptResponse = """{"issues":[{"message":"invalid source type"}]}"""
|
||||
|
||||
val error = assertFailsWith<RuntimeException> {
|
||||
chat.prompt("ses_abc", "/test/project", PromptDto(parts = listOf(PromptPartDto(type = "text", text = "hello"))))
|
||||
}
|
||||
|
||||
assertTrue(error.message!!.contains("prompt_async failed: HTTP 400"), error.message)
|
||||
assertTrue(error.message!!.contains("chars="), error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enhance prompt cancels the HTTP request with its coroutine`() = runBlocking {
|
||||
val port = mock.start()
|
||||
|
||||
+4
-1
@@ -29,15 +29,18 @@ class KiloBackendSessionManagerTest {
|
||||
private val mock = MockCliServer()
|
||||
private val log = TestLog()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val apps = mutableListOf<KiloBackendAppService>()
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
apps.forEach { it.dispose() }
|
||||
apps.clear()
|
||||
scope.cancel()
|
||||
mock.close()
|
||||
}
|
||||
|
||||
private fun setup(): KiloBackendAppService {
|
||||
return KiloBackendAppService.create(scope, FakeCliServer(mock), log)
|
||||
return KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) }
|
||||
}
|
||||
|
||||
private suspend fun ready(app: KiloBackendAppService) {
|
||||
|
||||
+137
@@ -10,6 +10,8 @@ import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.ModelStateDto
|
||||
import ai.kilocode.rpc.dto.PartSourceDto
|
||||
import ai.kilocode.rpc.dto.PartSourceTextDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
@@ -190,6 +192,42 @@ class KiloCliDataParserTest {
|
||||
assertEquals("a.png", result.part.filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - part preserves synthetic flag and source metadata`() {
|
||||
val data = globalEvent("""
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "ses_1",
|
||||
"part": {
|
||||
"id": "file_1",
|
||||
"sessionID": "ses_1",
|
||||
"messageID": "msg_1",
|
||||
"type": "file",
|
||||
"mime": "text/plain",
|
||||
"url": "file:///tmp/a.kt",
|
||||
"filename": "a.kt",
|
||||
"synthetic": true,
|
||||
"source": {
|
||||
"type": "file",
|
||||
"path": "src/a.kt",
|
||||
"text": { "value": "@src/a.kt", "start": 4, "end": 13 }
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data)
|
||||
|
||||
assertNotNull(result)
|
||||
assertTrue(result is ChatEventDto.PartUpdated)
|
||||
assertEquals(true, result.part.synthetic)
|
||||
assertEquals("file", result.part.source?.type)
|
||||
assertEquals("src/a.kt", result.part.source?.path)
|
||||
assertEquals("@src/a.kt", result.part.source?.text?.value)
|
||||
assertEquals(4.0, result.part.source?.text?.start)
|
||||
assertEquals(13.0, result.part.source?.text?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - user part updated sanitizes text`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
@@ -1112,6 +1150,25 @@ class KiloCliDataParserTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseMessages - preserves synthetic and source metadata`() {
|
||||
val raw = """[
|
||||
{
|
||||
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
|
||||
"parts": [
|
||||
{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "hidden", "synthetic": true },
|
||||
{ "id": "f1", "sessionID": "s1", "messageID": "m1", "type": "file", "mime": "text/plain", "url": "file:///tmp/a.kt", "source": { "type": "file", "path": "src/a.kt", "text": { "value": "@src/a.kt", "start": 0, "end": 9 } } }
|
||||
]
|
||||
}
|
||||
]"""
|
||||
|
||||
val result = KiloCliDataParser.parseMessages(raw).single()
|
||||
|
||||
assertEquals(true, result.parts[0].synthetic)
|
||||
assertEquals("src/a.kt", result.parts[1].source?.path)
|
||||
assertEquals("@src/a.kt", result.parts[1].source?.text?.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseMessages - message with tool parts`() {
|
||||
val raw = """[{
|
||||
@@ -1635,6 +1692,86 @@ class KiloCliDataParserTest {
|
||||
assertTrue(result.contains(""""filename":"a \"b\".txt""""), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - file part includes source metadata`() {
|
||||
val prompt = PromptDto(parts = listOf(PromptPartDto(
|
||||
type = "file",
|
||||
mime = "text/plain",
|
||||
url = "file:///tmp/a.kt",
|
||||
filename = "a.kt",
|
||||
source = PartSourceDto(
|
||||
type = "file",
|
||||
path = "src/a.kt",
|
||||
text = PartSourceTextDto("@src/a.kt", 4.0, 13.0),
|
||||
),
|
||||
)))
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertEquals(
|
||||
"""{"parts":[{"type":"file","mime":"text/plain","url":"file:///tmp/a.kt","filename":"a.kt","source":{"type":"file","text":{"value":"@src/a.kt","start":4.0,"end":13.0},"path":"src/a.kt"}}]}""",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - data file part includes source metadata`() {
|
||||
val prompt = PromptDto(parts = listOf(PromptPartDto(
|
||||
type = "file",
|
||||
mime = "text/plain",
|
||||
url = "data:text/plain;charset=utf-8,diff%20content",
|
||||
filename = "git-changes.txt",
|
||||
source = PartSourceDto(
|
||||
type = "file",
|
||||
text = PartSourceTextDto("@git-changes", 7.0, 19.0),
|
||||
path = "git-changes",
|
||||
),
|
||||
)))
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertEquals(
|
||||
"""{"parts":[{"type":"file","mime":"text/plain","url":"data:text/plain;charset=utf-8,diff%20content","filename":"git-changes.txt","source":{"type":"file","text":{"value":"@git-changes","start":7.0,"end":19.0},"path":"git-changes"}}]}""",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildCommandJson - file part includes source metadata`() {
|
||||
val prompt = PromptDto(parts = listOf(PromptPartDto(
|
||||
type = "file",
|
||||
mime = "text/plain",
|
||||
url = "file:///tmp/a.kt",
|
||||
source = PartSourceDto(
|
||||
type = "file",
|
||||
path = "src/a.kt",
|
||||
text = PartSourceTextDto("@src/a.kt", 0.0, 9.0),
|
||||
),
|
||||
)))
|
||||
|
||||
val result = KiloCliDataParser.buildCommandJson("review", "", prompt)
|
||||
|
||||
assertTrue(result.contains(""""source":{"type":"file","text":{"value":"@src/a.kt","start":0.0,"end":9.0},"path":"src/a.kt"}"""), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildCommandJson - includes agent variant model and arguments`() {
|
||||
val prompt = PromptDto(
|
||||
parts = emptyList(),
|
||||
agent = "code",
|
||||
variant = "high",
|
||||
providerID = "kilo",
|
||||
modelID = "gpt-5",
|
||||
)
|
||||
|
||||
val result = KiloCliDataParser.buildCommandJson("review", "src/", prompt)
|
||||
|
||||
assertEquals(
|
||||
"""{"command":"review","arguments":"src/","agent":"code","variant":"high","model":"kilo/gpt-5"}""",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- buildSummarizeJson ----
|
||||
|
||||
@Test
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package ai.kilocode.backend.rpc
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WorkspacePathScopingTest {
|
||||
// Derive an absolute, OS-portable base from the real home dir so the test runs identically on
|
||||
// Windows, macOS, and Linux (no hardcoded POSIX "/home/..." literals).
|
||||
private val base: Path = Path.of(System.getProperty("user.home")).resolve("kilo-scope-test").normalize()
|
||||
|
||||
private fun at(vararg segments: String): Path = segments.fold(base) { acc, s -> acc.resolve(s) }
|
||||
|
||||
@Test
|
||||
fun `in-base file returns forward-slash relative path`() {
|
||||
assertEquals("src/A.kt", relativeWithinBase(base, at("src", "A.kt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested file returns nested relative path`() {
|
||||
assertEquals("a/b/c.kt", relativeWithinBase(base, at("a", "b", "c.kt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base itself is rejected as blank`() {
|
||||
assertNull(relativeWithinBase(base, base))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sibling directory outside base is rejected`() {
|
||||
assertNull(relativeWithinBase(base, base.resolveSibling("other").resolve("A.kt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parent directory is rejected`() {
|
||||
assertNull(relativeWithinBase(base, base.parent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `traversal that escapes base is rejected after normalization`() {
|
||||
assertNull(relativeWithinBase(base, base.resolve("..").resolve("secret").resolve("A.kt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `traversal that stays inside base is kept after normalization`() {
|
||||
assertEquals("src/A.kt", relativeWithinBase(base, base.resolve("x").resolve("..").resolve("src").resolve("A.kt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefix sibling is not treated as inside base`() {
|
||||
val sibling = base.resolveSibling(base.fileName.toString() + "-2")
|
||||
assertNull(relativeWithinBase(base, sibling.resolve("A.kt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizes encoded file URLs`() {
|
||||
val path = base.resolve("dir with spaces").resolve("A.kt")
|
||||
val url = path.toUri().toString() + "?query#fragment"
|
||||
|
||||
assertEquals(path.normalize().toString(), normalizeWorkspacePath(url))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizes escaped relative paths`() {
|
||||
assertEquals("src/A.kt", normalizeWorkspacePath("src%2Ftmp%2F..%2FA.kt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects blank and invalid paths`() {
|
||||
assertNull(normalizeWorkspacePath(" "))
|
||||
assertNull(normalizeWorkspacePath("file://%"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `git availability detects temp repository`() {
|
||||
val dir = repo() ?: return
|
||||
try {
|
||||
assertTrue(workspaceGitAvailable(dir))
|
||||
} finally {
|
||||
delete(dir)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `git changes returns capped large diff without blocking`() = runBlocking {
|
||||
val dir = repo() ?: return@runBlocking
|
||||
try {
|
||||
val file = dir.resolve("large.txt")
|
||||
file.writeText("base\n")
|
||||
git(dir, "add", "large.txt")
|
||||
git(dir, "commit", "-m", "base")
|
||||
file.writeText((1..40_000).joinToString("\n") { "line-$it" } + "\n")
|
||||
|
||||
val diff = assertNotNull(KiloWorkspaceRpcApiImpl().gitChanges(dir.toString()))
|
||||
assertTrue(diff.startsWith("diff --git"))
|
||||
assertEquals(200_000, diff.length)
|
||||
} finally {
|
||||
delete(dir)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `git changes returns null outside git repositories`() = runBlocking {
|
||||
val dir = Files.createTempDirectory("kilo-non-git")
|
||||
try {
|
||||
assertNull(KiloWorkspaceRpcApiImpl().gitChanges(dir.toString()))
|
||||
} finally {
|
||||
delete(dir)
|
||||
}
|
||||
}
|
||||
|
||||
private fun repo(): Path? {
|
||||
if (!gitInstalled()) return null
|
||||
val dir = Files.createTempDirectory("kilo-git")
|
||||
git(dir, "init")
|
||||
git(dir, "config", "user.email", "test@example.com")
|
||||
git(dir, "config", "user.name", "Test User")
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun gitInstalled(): Boolean {
|
||||
return try {
|
||||
ProcessBuilder("git", "--version").start().waitFor() == 0
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun git(dir: Path, vararg args: String) {
|
||||
val proc = ProcessBuilder(listOf("git") + args)
|
||||
.directory(dir.toFile())
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val out = proc.inputStream.bufferedReader().readText()
|
||||
val code = proc.waitFor()
|
||||
assertEquals(0, code, out)
|
||||
}
|
||||
|
||||
private fun delete(dir: Path) {
|
||||
Files.walk(dir).use { paths ->
|
||||
paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -93,6 +93,10 @@ class MockCliServer : AutoCloseable {
|
||||
@Volatile var summarizeStatus = 200
|
||||
@Volatile var lastSummarizePath: String? = null
|
||||
@Volatile var lastSummarizeBody: String? = null
|
||||
@Volatile var promptStatus = 200
|
||||
@Volatile var promptResponse = "true"
|
||||
@Volatile var lastPromptPath: String? = null
|
||||
@Volatile var lastPromptBody: String? = null
|
||||
@Volatile var enhanced = """{"text":"Enhanced prompt"}"""
|
||||
@Volatile var enhanceStatus = 200
|
||||
@Volatile var lastEnhancePath: String? = null
|
||||
@@ -342,11 +346,11 @@ class MockCliServer : AutoCloseable {
|
||||
bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses)
|
||||
bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions)
|
||||
bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate)
|
||||
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "GET" ->
|
||||
bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" ->
|
||||
respond(output, sessionGetStatus, sessionCreate)
|
||||
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "DELETE" ->
|
||||
bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" ->
|
||||
respond(output, sessionDeleteStatus, "true")
|
||||
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "PATCH" -> {
|
||||
bare.matches(Regex("/session/ses_[^/]+")) && method == "PATCH" -> {
|
||||
lastSessionRenamePath = path
|
||||
lastSessionRenameBody = body
|
||||
lastSessionRenameMethod = method
|
||||
@@ -357,6 +361,11 @@ class MockCliServer : AutoCloseable {
|
||||
lastSummarizeBody = body
|
||||
respond(output, summarizeStatus, summarizeResponse)
|
||||
}
|
||||
bare.matches(Regex("/session/ses_[^/]+/prompt_async")) && method == "POST" -> {
|
||||
lastPromptPath = path
|
||||
lastPromptBody = body
|
||||
respond(output, promptStatus, promptResponse)
|
||||
}
|
||||
bare == "/enhance-prompt" && method == "POST" -> {
|
||||
lastEnhancePath = path
|
||||
lastEnhanceBody = body
|
||||
|
||||
+31
-13
@@ -2,11 +2,15 @@ package ai.kilocode.backend.workspace
|
||||
|
||||
import ai.kilocode.backend.app.KiloAppState
|
||||
import ai.kilocode.backend.app.KiloBackendAppService
|
||||
import ai.kilocode.backend.app.KiloBackendSessionManager
|
||||
import ai.kilocode.backend.app.SseEvent
|
||||
import ai.kilocode.backend.cli.KiloBackendHttpClients
|
||||
import ai.kilocode.backend.workspace.KiloBackendWorkspace
|
||||
import ai.kilocode.backend.workspace.KiloWorkspaceState
|
||||
import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -14,6 +18,7 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -31,15 +36,18 @@ class KiloBackendWorkspaceTest {
|
||||
private val mock = MockCliServer()
|
||||
private val log = TestLog()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val apps = mutableListOf<KiloBackendAppService>()
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
apps.forEach { it.dispose() }
|
||||
apps.clear()
|
||||
scope.cancel()
|
||||
mock.close()
|
||||
}
|
||||
|
||||
private fun setup(): KiloBackendAppService =
|
||||
KiloBackendAppService.create(scope, FakeCliServer(mock), log)
|
||||
KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) }
|
||||
|
||||
private suspend fun ready(app: KiloBackendAppService): KiloBackendWorkspace {
|
||||
app.connect()
|
||||
@@ -296,11 +304,14 @@ class KiloBackendWorkspaceTest {
|
||||
|
||||
@Test
|
||||
fun `agents response filters hidden and subagent`() = runBlocking {
|
||||
mock.providers = PROVIDERS_JSON
|
||||
mock.agents = """[
|
||||
{"name":"code","mode":"primary","permission":[],"options":{}},
|
||||
{"name":"helper","mode":"subagent","permission":[],"options":{}},
|
||||
{"name":"secret","mode":"primary","hidden":true,"permission":[],"options":{}}
|
||||
]"""
|
||||
mock.commands = COMMANDS_JSON
|
||||
mock.skills = SKILLS_JSON
|
||||
val app = setup()
|
||||
val ws = ready(app)
|
||||
|
||||
@@ -382,20 +393,27 @@ class KiloBackendWorkspaceTest {
|
||||
|
||||
@Test
|
||||
fun `concurrent get for same directory returns same instance`() = runBlocking {
|
||||
val app = setup()
|
||||
app.connect()
|
||||
withTimeout(10_000) { app.appState.first { it is KiloAppState.Ready } }
|
||||
val port = mock.start()
|
||||
val http = KiloBackendHttpClients.api(mock.password)
|
||||
val api = DefaultApi(basePath = "http://127.0.0.1:$port", client = http)
|
||||
val events = MutableSharedFlow<SseEvent>()
|
||||
val sessions = KiloBackendSessionManager(scope, log)
|
||||
val manager = KiloBackendWorkspaceManager(scope, sessions, log)
|
||||
manager.start(api, http, port, events)
|
||||
|
||||
// Launch many concurrent get() calls for the same directory
|
||||
val results = (1..10).map {
|
||||
async(Dispatchers.Default) {
|
||||
app.workspaces.get("/same/dir")
|
||||
}
|
||||
}.awaitAll()
|
||||
try {
|
||||
val results = (1..10).map {
|
||||
async(Dispatchers.Default) {
|
||||
manager.get("/same/dir")
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
// All must return the exact same instance
|
||||
val first = results[0]
|
||||
results.forEach { assertTrue(it === first) }
|
||||
val first = results[0]
|
||||
results.forEach { assertTrue(it === first) }
|
||||
} finally {
|
||||
manager.stop()
|
||||
KiloBackendHttpClients.shutdown(http)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+6
@@ -171,6 +171,12 @@ class KiloSessionService internal constructor(
|
||||
LOG.info("${ChatLogSummary.sid(id)} kind=prompt ok=true")
|
||||
}
|
||||
|
||||
suspend fun command(id: String, dir: String, command: String, args: String, dto: PromptDto) {
|
||||
LOG.info("${ChatLogSummary.sid(id)} kind=command command=$command parts=${dto.parts.size}")
|
||||
call { command(id, dir, command, args, dto) }
|
||||
LOG.info("${ChatLogSummary.sid(id)} kind=command ok=true")
|
||||
}
|
||||
|
||||
/** Abort ongoing processing for a session. */
|
||||
suspend fun abort(id: String, dir: String) {
|
||||
call { abort(id, dir) }
|
||||
|
||||
+22
@@ -4,6 +4,7 @@ package ai.kilocode.client.app
|
||||
|
||||
import ai.kilocode.rpc.KiloWorkspaceRpcApi
|
||||
import ai.kilocode.rpc.dto.ConfigTargetDto
|
||||
import ai.kilocode.rpc.dto.FileSearchResultDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
|
||||
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
|
||||
import ai.kilocode.rpc.dto.LoadErrorDto
|
||||
@@ -13,6 +14,7 @@ import com.intellij.openapi.components.Service
|
||||
import ai.kilocode.log.KiloLog
|
||||
import fleet.rpc.client.durable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -126,6 +128,26 @@ class KiloWorkspaceService internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto {
|
||||
return try {
|
||||
call { searchFiles(directory, query, limit) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("workspace file search failed for directory=$directory query=$query", e)
|
||||
FileSearchResultDto()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun gitChanges(directory: String): String? {
|
||||
return try {
|
||||
call { gitChanges(directory) }
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("git changes lookup failed for directory=$directory", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openPath(directory: String, path: String): Boolean {
|
||||
val match = files(directory, path).firstOrNull() ?: return false
|
||||
return try {
|
||||
|
||||
+11
-7
@@ -35,6 +35,11 @@ class SessionSidePanelManager(
|
||||
private val status: () -> Map<String, SessionActivityKind> = { project.service<KiloSessionService>().activity() },
|
||||
private val history: ((Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
|
||||
private val timers: UiTimerSource = UiTimers,
|
||||
private val request: (JComponent) -> Unit = { focus ->
|
||||
ApplicationManager.getApplication().invokeLater({
|
||||
IdeFocusManager.getInstance(project).requestFocusInProject(focus, project)
|
||||
}, ModalityState.defaultModalityState())
|
||||
},
|
||||
) : SessionManager, Disposable {
|
||||
val component: JPanel = object : JPanel(BorderLayout()), DataProvider {
|
||||
override fun getData(dataId: String): Any? {
|
||||
@@ -120,7 +125,7 @@ class SessionSidePanelManager(
|
||||
val view = cached ?: createHistory().also { panel = it }
|
||||
if (cached != null && view is HistoryPanel) view.refresh()
|
||||
if (current == null && component.componentCount == 1 && component.getComponent(0) === view) {
|
||||
focusHistory(view)
|
||||
focus((view as? HistoryPanel)?.defaultFocusedComponent)
|
||||
return
|
||||
}
|
||||
current = null
|
||||
@@ -128,14 +133,12 @@ class SessionSidePanelManager(
|
||||
component.add(view, BorderLayout.CENTER)
|
||||
component.revalidate()
|
||||
component.repaint()
|
||||
focusHistory(view)
|
||||
focus((view as? HistoryPanel)?.defaultFocusedComponent)
|
||||
}
|
||||
|
||||
private fun focusHistory(view: JComponent) {
|
||||
val focus = (view as? HistoryPanel)?.defaultFocusedComponent ?: return
|
||||
ApplicationManager.getApplication().invokeLater({
|
||||
IdeFocusManager.getInstance(project).requestFocusInProject(focus, project)
|
||||
}, ModalityState.defaultModalityState())
|
||||
private fun focus(component: JComponent?) {
|
||||
val focus = component ?: return
|
||||
request(focus)
|
||||
}
|
||||
|
||||
private fun createHistory(): JComponent {
|
||||
@@ -181,6 +184,7 @@ class SessionSidePanelManager(
|
||||
component.add(ui, BorderLayout.CENTER)
|
||||
component.revalidate()
|
||||
component.repaint()
|
||||
focus(ui.defaultFocusedComponent)
|
||||
}
|
||||
|
||||
private fun register(ui: SessionUi?) {
|
||||
|
||||
+81
@@ -8,6 +8,7 @@ import ai.kilocode.client.migration.KiloMigrationService
|
||||
import ai.kilocode.client.migration.MigrationUiController
|
||||
import ai.kilocode.client.migration.MigrationUiState
|
||||
import ai.kilocode.client.migration.ui.MigrationOverlayPanel
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
@@ -18,7 +19,11 @@ import ai.kilocode.client.session.ui.LoadingPanel
|
||||
import ai.kilocode.client.session.ui.ReasoningPicker
|
||||
import ai.kilocode.client.session.ui.mode.ModePicker
|
||||
import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider
|
||||
import ai.kilocode.client.session.ui.prompt.MentionAction
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.prompt.SlashAction
|
||||
import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts
|
||||
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
|
||||
import ai.kilocode.client.session.ui.SessionDropOverlay
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
@@ -40,6 +45,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.LoginRequiredView
|
||||
import ai.kilocode.client.session.views.permission.PermissionView
|
||||
import ai.kilocode.client.session.views.question.QuestionView
|
||||
import ai.kilocode.client.settings.KiloSettingsConfigurable
|
||||
import ai.kilocode.client.settings.profile.UserProfileConfigurable
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
@@ -67,6 +73,7 @@ import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.options.ConfigurableWithId
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.progress.runBlockingCancellable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
@@ -168,6 +175,7 @@ class SessionUi(
|
||||
private lateinit var connection: ConnectionPanel
|
||||
|
||||
private lateinit var prompt: PromptPanel
|
||||
private lateinit var completion: KiloPromptCompletionProvider
|
||||
private lateinit var load: LoadingPanel
|
||||
private lateinit var migrationOverlay: MigrationOverlayPanel
|
||||
private var empty: EmptySessionPanel? = null
|
||||
@@ -336,12 +344,21 @@ class SessionUi(
|
||||
scroll.onScroll = overlay::clear
|
||||
connection = ConnectionPanel(this, controller)
|
||||
|
||||
completion = KiloPromptCompletionProvider(
|
||||
workspace = workspace,
|
||||
service = workspaces,
|
||||
actions = slashActions(),
|
||||
mentions = mentionActions(),
|
||||
scope = cs,
|
||||
)
|
||||
prompt = PromptPanel(
|
||||
project = project,
|
||||
selection = selection,
|
||||
onSend = { text, files -> sendPrompt(text, files) },
|
||||
onAbort = { controller.abort() },
|
||||
onEnhance = controller::enhancePrompt,
|
||||
onMentions = ::mentionParts,
|
||||
completion = completion,
|
||||
)
|
||||
|
||||
drop = SessionDropOverlay()
|
||||
@@ -417,6 +434,7 @@ class SessionUi(
|
||||
prompt.reasoning.setItems(m.variants.map { ReasoningPicker.Item(it, variantTitle(it)) }, m.variant)
|
||||
prompt.setResetVisible(m.modelOverride)
|
||||
prompt.setReady(m.isReady())
|
||||
prompt.refreshHighlights()
|
||||
}
|
||||
|
||||
is SessionControllerEvent.ViewChanged.ShowProgress -> {
|
||||
@@ -590,10 +608,63 @@ class SessionUi(
|
||||
}
|
||||
prompt.clear()
|
||||
val follow = scroll.atBottom()
|
||||
val action = completion.clientAction(text)
|
||||
if (action != null) {
|
||||
action.action()
|
||||
scroll.followBottom(follow)
|
||||
return
|
||||
}
|
||||
val command = completion.serverCommand(text)
|
||||
if (command != null) {
|
||||
controller.command(command.first, command.second, files)
|
||||
scroll.followBottom(follow)
|
||||
return
|
||||
}
|
||||
controller.prompt(text, files)
|
||||
scroll.followBottom(follow)
|
||||
}
|
||||
|
||||
private fun slashActions(): List<SlashAction> {
|
||||
val fns: Map<SlashAction.Spec, () -> Unit> = mapOf(
|
||||
SlashAction.NEW to { manager?.newSession() },
|
||||
SlashAction.SESSIONS to { manager?.showHistory() },
|
||||
SlashAction.MODELS to { prompt.model.open() },
|
||||
SlashAction.AGENTS to { prompt.mode.open() },
|
||||
SlashAction.VARIANT to { prompt.reasoning.open() },
|
||||
SlashAction.COMPACT to { controller.compact() },
|
||||
SlashAction.SETTINGS to { openKiloSettings() },
|
||||
SlashAction.HELP to { BrowserUtil.browse("https://kilo.ai/docs") },
|
||||
)
|
||||
return SlashAction.ALL.map { spec -> bind(spec, fns.getValue(spec)) }
|
||||
}
|
||||
|
||||
private fun bind(spec: SlashAction.Spec, action: () -> Unit) = SlashAction(
|
||||
spec.name,
|
||||
KiloBundle.message(spec.descriptionKey),
|
||||
spec.hints,
|
||||
action,
|
||||
)
|
||||
|
||||
private fun mentionActions(): List<MentionAction> = MentionAction.ALL.map(::bind)
|
||||
|
||||
private fun bind(spec: MentionAction.Spec) = MentionAction(
|
||||
spec.name,
|
||||
KiloBundle.message(spec.descriptionKey),
|
||||
spec.hints,
|
||||
spec.available,
|
||||
)
|
||||
|
||||
private fun mentionParts(text: String): List<PromptPartDto> = runBlockingCancellable {
|
||||
val names = MentionAction.ALL.mapTo(mutableSetOf()) { it.name }
|
||||
promptMentionParts(
|
||||
text = text,
|
||||
directory = workspace.directory,
|
||||
reserved = names,
|
||||
resolve = { path -> workspaces.files(workspace.directory, path).isNotEmpty() },
|
||||
gitChanges = { workspaces.gitChanges(workspace.directory) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun openFile(path: String) {
|
||||
cs.launch {
|
||||
workspaces.openPath(workspace.directory, path)
|
||||
@@ -708,6 +779,16 @@ class SessionUi(
|
||||
)
|
||||
}
|
||||
|
||||
private fun openKiloSettings() {
|
||||
ShowSettingsUtil.getInstance().showSettingsDialog(
|
||||
project,
|
||||
Predicate { cfg: Configurable ->
|
||||
cfg is ConfigurableWithId && cfg.getId() == KiloSettingsConfigurable.ID
|
||||
},
|
||||
{ _: Configurable -> },
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
disposed = true
|
||||
hide.stop()
|
||||
|
||||
+81
-29
@@ -54,6 +54,7 @@ import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
@@ -97,6 +98,14 @@ class SessionController(
|
||||
private data class OrganizationTarget(val org: String?)
|
||||
private data class Followup(val dir: String, val time: Long)
|
||||
private data class Pref(val agent: String?, val model: String?, val variants: List<String>, val variant: String?, val reset: Boolean)
|
||||
private data class Dispatch(
|
||||
val kind: String,
|
||||
val source: String,
|
||||
val text: String,
|
||||
val props: Map<String, String>,
|
||||
val start: String,
|
||||
val exists: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(SessionController::class.java)
|
||||
@@ -134,6 +143,7 @@ class SessionController(
|
||||
private var tool: String? = null
|
||||
private var eventJob: Job? = null
|
||||
private var drainJob: Job? = null
|
||||
private var creating: CompletableDeferred<String?>? = null
|
||||
private val childJobs: MutableMap<String, Job> = mutableMapOf()
|
||||
private val childIds: MutableSet<String> = mutableSetOf()
|
||||
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
|
||||
@@ -239,40 +249,41 @@ class SessionController(
|
||||
val dto = promptDto(text, files)
|
||||
val props = promptProps(files)
|
||||
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" }
|
||||
dispatch(Dispatch("prompt", "user", text, props, start, exists)) { id ->
|
||||
sessions.prompt(id, directory, dto)
|
||||
}
|
||||
}
|
||||
|
||||
fun command(command: String, args: String, files: List<PromptPartDto> = emptyList()) {
|
||||
assertEdt()
|
||||
val start = sid ?: ref?.key ?: "pending"
|
||||
val exists = sid != null
|
||||
val dto = promptDto("", files)
|
||||
val props = promptProps(files)
|
||||
LOG.debug { "${ChatLogSummary.sid(start)} kind=command command=$command args=${args.length} ${ChatLogSummary.dir(directory)}" }
|
||||
dispatch(Dispatch("command", "command", args, props, start, exists)) { id ->
|
||||
sessions.command(id, directory, command, args, dto)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatch(data: Dispatch, send: suspend (String) -> Unit) {
|
||||
assertEdt()
|
||||
capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf(
|
||||
"source" to "user",
|
||||
"hasExistingSession" to exists.toString(),
|
||||
"textLength" to bucket(text),
|
||||
) + props)
|
||||
"source" to data.source,
|
||||
"hasExistingSession" to data.exists.toString(),
|
||||
"textLength" to bucket(data.text),
|
||||
) + data.props)
|
||||
showSession()
|
||||
val pending = sid?.let { CompletableDeferred(it) } ?: session()
|
||||
cs.launch {
|
||||
try {
|
||||
val id = sid ?: run {
|
||||
val session = sessions.create(directory)
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
ref = SessionRef.Local(session)
|
||||
setRecentSessionsState(RecentsState.Idle)
|
||||
updateModel {
|
||||
model.setSession(session)
|
||||
}
|
||||
}
|
||||
if (disposed) return@launch
|
||||
val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session"
|
||||
LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true")
|
||||
capture("Task Created", sessionProps(session.id) + mapOf("source" to "jetbrains"))
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
subscribeEvents()
|
||||
}
|
||||
session.id
|
||||
}
|
||||
sessions.prompt(id, directory, dto)
|
||||
capture("Conversation Message", sessionProps(id) + mapOf("source" to "user", "hasExistingSession" to exists.toString()) + props)
|
||||
LOG.debug { "${ChatLogSummary.sid(id)} kind=prompt dispatched=true" }
|
||||
val id = pending.await() ?: return@launch
|
||||
send(id)
|
||||
capture("Conversation Message", sessionProps(id) + mapOf("source" to data.source, "hasExistingSession" to data.exists.toString()) + data.props)
|
||||
LOG.debug { "${ChatLogSummary.sid(id)} kind=${data.kind} dispatched=true" }
|
||||
} catch (e: Exception) {
|
||||
capture("Session Error", sessionProps(sid ?: ref?.key ?: start) + mapOf("context" to "prompt", "errorClass" to e::class.java.name))
|
||||
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: start)} kind=prompt dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
|
||||
capture("Session Error", sessionProps(sid ?: ref?.key ?: data.start) + mapOf("context" to data.kind, "errorClass" to e::class.java.name))
|
||||
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: data.start)} kind=${data.kind} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
|
||||
edt {
|
||||
if (disposed) return@edt
|
||||
val msg = e.message ?: KiloBundle.message("session.error.prompt")
|
||||
@@ -284,6 +295,47 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun session(): CompletableDeferred<String?> {
|
||||
assertEdt()
|
||||
val pending = creating
|
||||
if (pending != null) return pending
|
||||
val next = CompletableDeferred<String?>()
|
||||
creating = next
|
||||
cs.launch {
|
||||
try {
|
||||
next.complete(createSession())
|
||||
} catch (e: Exception) {
|
||||
next.completeExceptionally(e)
|
||||
} finally {
|
||||
edt {
|
||||
if (creating === next) creating = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private suspend fun createSession(): String? {
|
||||
val session = sessions.create(directory)
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
ref = SessionRef.Local(session)
|
||||
setRecentSessionsState(RecentsState.Idle)
|
||||
updateModel {
|
||||
model.setSession(session)
|
||||
}
|
||||
}
|
||||
if (disposed) return null
|
||||
val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session"
|
||||
LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true")
|
||||
capture("Task Created", sessionProps(session.id) + mapOf("source" to "jetbrains"))
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
subscribeEvents()
|
||||
}
|
||||
return session.id
|
||||
}
|
||||
|
||||
fun abort() {
|
||||
assertEdt()
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=abort" }
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.client.session.model
|
||||
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.PartSourceDto
|
||||
import ai.kilocode.rpc.dto.PartTimeDto
|
||||
import ai.kilocode.rpc.dto.TodoDto
|
||||
import ai.kilocode.rpc.dto.TodoViewDto
|
||||
@@ -71,6 +72,7 @@ class FileAttachment(id: String) : Content(id) {
|
||||
var mime: String = "application/octet-stream"
|
||||
var url: String = ""
|
||||
var filename: String? = null
|
||||
var source: PartSourceDto? = null
|
||||
}
|
||||
|
||||
/** Tool invocation with lifecycle state. */
|
||||
|
||||
+25
@@ -41,6 +41,7 @@ class SessionModel {
|
||||
|
||||
private val entries = LinkedHashMap<String, Message>()
|
||||
private val turnEntries = LinkedHashMap<String, Turn>()
|
||||
private val hiddenText = mutableSetOf<Pair<String, String>>()
|
||||
|
||||
var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
|
||||
var version: String? = null
|
||||
@@ -140,6 +141,7 @@ class SessionModel {
|
||||
@RequiresEdt
|
||||
fun removeMessage(id: String) {
|
||||
if (entries.remove(id) == null) return
|
||||
hiddenText.removeAll { it.first == id }
|
||||
fire(SessionModelEvent.MessageRemoved(id))
|
||||
regroup()
|
||||
updateHeader()
|
||||
@@ -147,6 +149,7 @@ class SessionModel {
|
||||
|
||||
@RequiresEdt
|
||||
fun removeContent(messageId: String, contentId: String) {
|
||||
hiddenText.remove(messageId to contentId)
|
||||
val msg = entries[messageId] ?: return
|
||||
if (msg.parts.remove(contentId) == null) return
|
||||
fire(SessionModelEvent.ContentRemoved(messageId, contentId))
|
||||
@@ -157,6 +160,16 @@ class SessionModel {
|
||||
fun updateContent(messageId: String, dto: PartDto) {
|
||||
if (dto.type in SILENT_PART_TYPES) return
|
||||
val msg = entries[messageId] ?: return
|
||||
val key = messageId to dto.id
|
||||
if (hiddenSynthetic(msg, dto)) {
|
||||
hiddenText.add(key)
|
||||
if (msg.parts.remove(dto.id) != null) {
|
||||
fire(SessionModelEvent.ContentRemoved(messageId, dto.id))
|
||||
updateHeader()
|
||||
}
|
||||
return
|
||||
}
|
||||
hiddenText.remove(key)
|
||||
val existing = msg.parts[dto.id]
|
||||
if (empty(dto)) {
|
||||
if (existing is Text) removeContent(messageId, dto.id)
|
||||
@@ -175,6 +188,7 @@ class SessionModel {
|
||||
@RequiresEdt
|
||||
fun appendDelta(messageId: String, contentId: String, delta: String) {
|
||||
val msg = entries[messageId] ?: return
|
||||
if (hiddenText.contains(messageId to contentId)) return
|
||||
val existing = msg.parts[contentId]
|
||||
val created = existing == null
|
||||
if (existing != null) {
|
||||
@@ -238,6 +252,7 @@ class SessionModel {
|
||||
@RequiresEdt
|
||||
fun loadHistory(history: List<MessageWithPartsDto>) {
|
||||
entries.clear()
|
||||
hiddenText.clear()
|
||||
session = null
|
||||
state = SessionState.Idle
|
||||
diff = emptyList()
|
||||
@@ -247,6 +262,10 @@ class SessionModel {
|
||||
val item = Message(msg.info)
|
||||
for (part in msg.parts) {
|
||||
if (part.type in SILENT_PART_TYPES) continue
|
||||
if (hiddenSynthetic(item, part)) {
|
||||
hiddenText.add(msg.info.id to part.id)
|
||||
continue
|
||||
}
|
||||
if (empty(part)) continue
|
||||
val content = fromDto(part, part.text)
|
||||
item.parts[content.id] = content
|
||||
@@ -262,6 +281,7 @@ class SessionModel {
|
||||
fun clear() {
|
||||
entries.clear()
|
||||
turnEntries.clear()
|
||||
hiddenText.clear()
|
||||
session = null
|
||||
state = SessionState.Idle
|
||||
diff = emptyList()
|
||||
@@ -382,6 +402,7 @@ class SessionModel {
|
||||
existing.mime = dto.mime ?: "application/octet-stream"
|
||||
existing.url = dto.url ?: ""
|
||||
existing.filename = dto.filename
|
||||
existing.source = dto.source
|
||||
}
|
||||
is Tool -> {
|
||||
existing.kind = toolKind(dto.tool)
|
||||
@@ -410,6 +431,9 @@ class SessionModel {
|
||||
|
||||
private fun empty(dto: PartDto) = dto.type == "text" && dto.text?.isNotBlank() != true
|
||||
|
||||
private fun hiddenSynthetic(msg: Message, dto: PartDto) =
|
||||
msg.info.role == "user" && dto.type == "text" && dto.synthetic == true
|
||||
|
||||
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
|
||||
val content = text ?: dto.text
|
||||
return when (dto.type) {
|
||||
@@ -424,6 +448,7 @@ class SessionModel {
|
||||
mime = dto.mime ?: "application/octet-stream"
|
||||
url = dto.url ?: ""
|
||||
filename = dto.filename
|
||||
source = dto.source
|
||||
}
|
||||
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
|
||||
state = parseToolState(dto.state)
|
||||
|
||||
+5
@@ -80,6 +80,11 @@ class ReasoningPicker : PickerButton() {
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
}
|
||||
|
||||
fun open() {
|
||||
if (!isEnabled || items.isEmpty()) return
|
||||
showPopup()
|
||||
}
|
||||
|
||||
private fun showPopup() {
|
||||
val step = object : BaseListPopupStep<Item>("", items) {
|
||||
override fun getTextFor(value: Item) = value.display
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user