diff --git a/.changeset/add-anaconda-desktop-provider.md b/.changeset/add-anaconda-desktop-provider.md new file mode 100644 index 00000000000..e399797ce42 --- /dev/null +++ b/.changeset/add-anaconda-desktop-provider.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": minor +"@kilocode/sdk": minor +"kilo-code": minor +--- + +Connect to a local Anaconda Desktop text-generation model server from the CLI or VS Code. diff --git a/.changeset/add-parakeet-speech.md b/.changeset/add-parakeet-speech.md new file mode 100644 index 00000000000..55405494590 --- /dev/null +++ b/.changeset/add-parakeet-speech.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support NVIDIA Parakeet TDT 0.6B v3 for speech-to-text input. diff --git a/.changeset/agent-manager-pr-status-icon.md b/.changeset/agent-manager-pr-status-icon.md new file mode 100644 index 00000000000..bdb2257257f --- /dev/null +++ b/.changeset/agent-manager-pr-status-icon.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Color Agent Manager PR badges by pull request state (open, draft, merged, closed) and show CI and review status as a separate icon, so a failing check is no longer mistaken for a closed PR. diff --git a/.changeset/agent-manager-sandbox-default.md b/.changeset/agent-manager-sandbox-default.md new file mode 100644 index 00000000000..f93953ab147 --- /dev/null +++ b/.changeset/agent-manager-sandbox-default.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remember the Agent Manager new-worktree sandbox toggle for future sessions. diff --git a/.changeset/agent-manager-tool-models.md b/.changeset/agent-manager-tool-models.md new file mode 100644 index 00000000000..6b56423fddf --- /dev/null +++ b/.changeset/agent-manager-tool-models.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": minor +"@kilocode/sdk": minor +"kilo-code": minor +--- + +Allow Agent Manager chat tools to discover available models and reasoning variants by model name, then start each session with the chosen model and reasoning effort. Agent Manager resolves the provider for a named model automatically, preferring the provider behind the current default model and falling back to the Kilo Gateway. diff --git a/.changeset/agent-manager-worktree-sandbox.md b/.changeset/agent-manager-worktree-sandbox.md new file mode 100644 index 00000000000..75c4a0cab18 --- /dev/null +++ b/.changeset/agent-manager-worktree-sandbox.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a sandbox toggle to the Agent Manager New Worktree modal so each worktree session can start sandboxed diff --git a/.changeset/agent-requirements.md b/.changeset/agent-requirements.md new file mode 100644 index 00000000000..2065246f1c0 --- /dev/null +++ b/.changeset/agent-requirements.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Gate experimental agents on their declared skill, MCP, and VS Code extension requirements. VS Code shows requirement groups with Marketplace shortcuts, and the CLI stops before sending when requirements are unmet. diff --git a/.changeset/anaconda-desktop-translations.md b/.changeset/anaconda-desktop-translations.md new file mode 100644 index 00000000000..12d9c6d0ef9 --- /dev/null +++ b/.changeset/anaconda-desktop-translations.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Translate the Anaconda Desktop provider strings into all supported languages instead of falling back to English. diff --git a/.changeset/animate-tool-card-reveals.md b/.changeset/animate-tool-card-reveals.md new file mode 100644 index 00000000000..a273d7ae45e --- /dev/null +++ b/.changeset/animate-tool-card-reveals.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Animate tool-call and reasoning details when expanding and collapsing. diff --git a/.changeset/auto-model-picker-details.md b/.changeset/auto-model-picker-details.md new file mode 100644 index 00000000000..78dd225b9b2 --- /dev/null +++ b/.changeset/auto-model-picker-details.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open the model picker expanded by default, keep the preview pinned while hovering, separate Kilo Auto models into their own marked group with summary tooltips, and show backend-provided Auto Efficient model choices. diff --git a/.changeset/background-process-lifetimes.md b/.changeset/background-process-lifetimes.md new file mode 100644 index 00000000000..f061069dc3a --- /dev/null +++ b/.changeset/background-process-lifetimes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Allow background processes to transfer from subagents to parent sessions, or remain accessible from every session in their project after Kilo restarts. diff --git a/.changeset/bound-snapshot-turns.md b/.changeset/bound-snapshot-turns.md new file mode 100644 index 00000000000..7a2cb9871eb --- /dev/null +++ b/.changeset/bound-snapshot-turns.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep turns responsive when snapshot infrastructure stalls and prevent transient snapshot progress from appearing in forked sessions. diff --git a/.changeset/bright-notebooks-complete.md b/.changeset/bright-notebooks-complete.md new file mode 100644 index 00000000000..afe6eb43733 --- /dev/null +++ b/.changeset/bright-notebooks-complete.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Enable autocomplete in Jupyter notebooks. diff --git a/.changeset/bright-terminals-help.md b/.changeset/bright-terminals-help.md new file mode 100644 index 00000000000..f69749b8fc9 --- /dev/null +++ b/.changeset/bright-terminals-help.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Run commands that require human interaction in an embedded CLI terminal dialog and return their output to the model when complete. diff --git a/.changeset/clear-marketplace-installs.md b/.changeset/clear-marketplace-installs.md new file mode 100644 index 00000000000..717a39f4910 --- /dev/null +++ b/.changeset/clear-marketplace-installs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Explain Marketplace item types, installation scopes, destination files, and MCP security before installation. diff --git a/.changeset/cli-sidebar-balance.md b/.changeset/cli-sidebar-balance.md new file mode 100644 index 00000000000..55a94b93127 --- /dev/null +++ b/.changeset/cli-sidebar-balance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Show personal credits, team credits, and Kilo Pass in the CLI sidebar, and refresh the balance immediately after switching teams. diff --git a/.changeset/collapse-agent-sessions.md b/.changeset/collapse-agent-sessions.md new file mode 100644 index 00000000000..5f799bbb427 --- /dev/null +++ b/.changeset/collapse-agent-sessions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Start the Agent Manager with the Sessions list collapsed while preserving each workspace's saved choice. diff --git a/.changeset/expose-sandbox-controls.md b/.changeset/expose-sandbox-controls.md new file mode 100644 index 00000000000..b08efed482a --- /dev/null +++ b/.changeset/expose-sandbox-controls.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show experimental sandbox controls by default for non-Windows users. diff --git a/.changeset/filter-relevant-marketplace.md b/.changeset/filter-relevant-marketplace.md new file mode 100644 index 00000000000..65d1fb644b8 --- /dev/null +++ b/.changeset/filter-relevant-marketplace.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Filter marketplace items to those relevant to the current workspace's files and installed VS Code extensions. diff --git a/.changeset/fix-autocomplete-error-messages.md b/.changeset/fix-autocomplete-error-messages.md new file mode 100644 index 00000000000..43edd744812 --- /dev/null +++ b/.changeset/fix-autocomplete-error-messages.md @@ -0,0 +1,7 @@ +--- +"kilo-code": patch +--- + +Improve autocomplete error messages to clarify BYOK and credits issues + +When autocomplete is paused due to a payment or auth error, the messages now explain all possible causes: no Kilo credits, API key (BYOK) quota exhausted, not signed in, or invalid/missing API key. diff --git a/.changeset/fix-curl-upgrade-url.md b/.changeset/fix-curl-upgrade-url.md new file mode 100644 index 00000000000..6e54f7e6e03 --- /dev/null +++ b/.changeset/fix-curl-upgrade-url.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix `kilo upgrade` for curl installs by pointing at the install script instead of the install landing page. diff --git a/.changeset/fix-kiloclaw-slash.md b/.changeset/fix-kiloclaw-slash.md new file mode 100644 index 00000000000..a6b2f113ec9 --- /dev/null +++ b/.changeset/fix-kiloclaw-slash.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +"kilo-code": patch +--- + +Fix opening KiloClaw from the CLI and VS Code slash commands. diff --git a/.changeset/fix-slash-mention-highlight.md b/.changeset/fix-slash-mention-highlight.md new file mode 100644 index 00000000000..cf02db9f5ff --- /dev/null +++ b/.changeset/fix-slash-mention-highlight.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep @mentions highlighted on the correct text after slash commands expand. diff --git a/.changeset/image-generation.md b/.changeset/image-generation.md new file mode 100644 index 00000000000..4c0c22bd289 --- /dev/null +++ b/.changeset/image-generation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add experimental AI image generation tool. Enable via `experimental.image_generation` in config. Supports text-to-image generation and image editing through the Kilo Gateway or a BYO OpenRouter API key. diff --git a/.changeset/import-cloud-session-first.md b/.changeset/import-cloud-session-first.md new file mode 100644 index 00000000000..b2e0accef86 --- /dev/null +++ b/.changeset/import-cloud-session-first.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Allow the default TUI to import cloud-only sessions without rejecting their IDs as missing locally. diff --git a/.changeset/isolate-linux-network.md b/.changeset/isolate-linux-network.md new file mode 100644 index 00000000000..13122540b8d --- /dev/null +++ b/.changeset/isolate-linux-network.md @@ -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. diff --git a/.changeset/jetbrains-profile-balance.md b/.changeset/jetbrains-profile-balance.md new file mode 100644 index 00000000000..6cb17979a7a --- /dev/null +++ b/.changeset/jetbrains-profile-balance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show Kilo Pass usage, bonus credits, renewal date, and top-up actions in the JetBrains user profile. diff --git a/.changeset/jetbrains-restart-loading.md b/.changeset/jetbrains-restart-loading.md new file mode 100644 index 00000000000..eb87e2bcb68 --- /dev/null +++ b/.changeset/jetbrains-restart-loading.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Recover the JetBrains backend when startup or restart loading is interrupted by stale connection failures. diff --git a/.changeset/jetbrains-session-error-logs.md b/.changeset/jetbrains-session-error-logs.md new file mode 100644 index 00000000000..0b93c49b02c --- /dev/null +++ b/.changeset/jetbrains-session-error-logs.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains session error logs so nested CLI error messages are visible. diff --git a/.changeset/jetbrains-sse-reconnect-timeout.md b/.changeset/jetbrains-sse-reconnect-timeout.md new file mode 100644 index 00000000000..97170589582 --- /dev/null +++ b/.changeset/jetbrains-sse-reconnect-timeout.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Reconnect the JetBrains plugin when its event stream stalls during startup. diff --git a/.changeset/keep-sandbox-toggle-drafts.md b/.changeset/keep-sandbox-toggle-drafts.md new file mode 100644 index 00000000000..d1fd585ab57 --- /dev/null +++ b/.changeset/keep-sandbox-toggle-drafts.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve new-chat prompts, attachments, and scroll position when toggling sandbox, and keep sandbox status stable while switching sessions. diff --git a/.changeset/kilo-pass-profile-contract.md b/.changeset/kilo-pass-profile-contract.md new file mode 100644 index 00000000000..c8b975f015d --- /dev/null +++ b/.changeset/kilo-pass-profile-contract.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +"@kilocode/sdk": patch +--- + +Expose Kilo Pass state on the Kilo profile API contract. diff --git a/.changeset/marketplace-match-notification.md b/.changeset/marketplace-match-notification.md new file mode 100644 index 00000000000..1ea0f8b87db --- /dev/null +++ b/.changeset/marketplace-match-notification.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Notify when a marketplace item matches your workspace, with a one-click install button and a "Don't show again" option per suggestion. diff --git a/.changeset/mix-marketplace-categories.md b/.changeset/mix-marketplace-categories.md new file mode 100644 index 00000000000..f922d06b6b7 --- /dev/null +++ b/.changeset/mix-marketplace-categories.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Browse skills, agents, and MCP servers together and filter them by category. diff --git a/.changeset/model-selector-auto-icon.md b/.changeset/model-selector-auto-icon.md new file mode 100644 index 00000000000..a156218670b --- /dev/null +++ b/.changeset/model-selector-auto-icon.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use the models sparkle icon for Auto models in the model selector. diff --git a/.changeset/native-notebook-tools.md b/.changeset/native-notebook-tools.md new file mode 100644 index 00000000000..3d47f0ef1e0 --- /dev/null +++ b/.changeset/native-notebook-tools.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support safely reading, editing, and executing Jupyter notebook cells directly from Kilo in VS Code, including absolute workspace paths and content-based edit conflict detection. diff --git a/.changeset/notebook-create-action.md b/.changeset/notebook-create-action.md new file mode 100644 index 00000000000..50df0c170ba --- /dev/null +++ b/.changeset/notebook-create-action.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Let the notebook tools create a new empty notebook. diff --git a/.changeset/notify-sandbox-state.md b/.changeset/notify-sandbox-state.md new file mode 100644 index 00000000000..7088847fc59 --- /dev/null +++ b/.changeset/notify-sandbox-state.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show sandbox state changes as concise VS Code notifications. diff --git a/.changeset/open-model-selector-faster.md b/.changeset/open-model-selector-faster.md new file mode 100644 index 00000000000..789dabdc2a2 --- /dev/null +++ b/.changeset/open-model-selector-faster.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open the model selector instantly, even with large model catalogs. diff --git a/.changeset/prefer-kilo-config.md b/.changeset/prefer-kilo-config.md new file mode 100644 index 00000000000..6fe9a3d09cb --- /dev/null +++ b/.changeset/prefer-kilo-config.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop loading `.opencode` config directories and use `.kilo` instead, while retaining `.kilocode` as a legacy fallback. diff --git a/.changeset/release-disconnected-streams.md b/.changeset/release-disconnected-streams.md new file mode 100644 index 00000000000..651417c7aaf --- /dev/null +++ b/.changeset/release-disconnected-streams.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Release disconnected event streams so long-running servers do not retain queued session diffs. diff --git a/.changeset/remember-sandbox-sessions.md b/.changeset/remember-sandbox-sessions.md new file mode 100644 index 00000000000..ec0c8a8833e --- /dev/null +++ b/.changeset/remember-sandbox-sessions.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/sdk": patch +"kilo-code": patch +--- + +Remember sandbox choices per session and start new sessions with the last selected sandbox state. diff --git a/.changeset/reset-read-notifications.md b/.changeset/reset-read-notifications.md new file mode 100644 index 00000000000..bbf0cf929be --- /dev/null +++ b/.changeset/reset-read-notifications.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support resetting read notifications from the VS Code extension settings. diff --git a/.changeset/sandbox-agent-writes.md b/.changeset/sandbox-agent-writes.md index ea727050768..638c8ac4e52 100644 --- a/.changeset/sandbox-agent-writes.md +++ b/.changeset/sandbox-agent-writes.md @@ -3,4 +3,4 @@ "kilo-code": minor --- -Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS sandbox. +Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS and Linux sandboxes. diff --git a/.changeset/secure-sandbox-config.md b/.changeset/secure-sandbox-config.md new file mode 100644 index 00000000000..01b88fc7c5a --- /dev/null +++ b/.changeset/secure-sandbox-config.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent confined sessions and delegated agents from weakening their sandbox policy through configuration changes or unauthenticated server control. diff --git a/.changeset/secure-sandbox-mutations.md b/.changeset/secure-sandbox-mutations.md new file mode 100644 index 00000000000..63eb8fdb087 --- /dev/null +++ b/.changeset/secure-sandbox-mutations.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent sandboxed file tools from escaping project write roots through concurrent symlink replacement on macOS. diff --git a/.changeset/send-inline-review-comments.md b/.changeset/send-inline-review-comments.md new file mode 100644 index 00000000000..0a9172ab11a --- /dev/null +++ b/.changeset/send-inline-review-comments.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support sending inline review draft comments directly from the diff without first saving them as pending comments. diff --git a/.changeset/show-external-permission-paths.md b/.changeset/show-external-permission-paths.md new file mode 100644 index 00000000000..1061c6ce960 --- /dev/null +++ b/.changeset/show-external-permission-paths.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show full external directory paths in permission auto-approve rules. diff --git a/.changeset/show-routed-step-models.md b/.changeset/show-routed-step-models.md index 04d06d28a4f..700643bf9ff 100644 --- a/.changeset/show-routed-step-models.md +++ b/.changeset/show-routed-step-models.md @@ -1,6 +1,7 @@ --- "@kilocode/cli": patch +"@kilocode/sdk": patch "kilo-code": patch --- -Show the concrete model reported for routed Kilo auto-model steps in CLI and VS Code session timelines. +Show the concrete model reported for routed Kilo auto-model steps in CLI and VS Code session timelines, and break down TUI sidebar token usage, cache rate, and cost by model across subagent sessions. diff --git a/.changeset/show-sandbox-network.md b/.changeset/show-sandbox-network.md new file mode 100644 index 00000000000..28fefe64b35 --- /dev/null +++ b/.changeset/show-sandbox-network.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show filesystem and network restriction states in the sandbox tooltip. diff --git a/.changeset/slash-command-sorting.md b/.changeset/slash-command-sorting.md new file mode 100644 index 00000000000..bda54cfc00f --- /dev/null +++ b/.changeset/slash-command-sorting.md @@ -0,0 +1,4 @@ +--- +"kilo-code": patch +--- +Sort slash-command dropdown results by relevance. Exact matches now appear first, followed by prefix matches, then substring matches. diff --git a/.changeset/toggle-extension-sandbox.md b/.changeset/toggle-extension-sandbox.md new file mode 100644 index 00000000000..86879752b99 --- /dev/null +++ b/.changeset/toggle-extension-sandbox.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Toggle the current session sandbox with `/sandbox` in the sidebar and Agent Manager. diff --git a/.changeset/toggle-tui-sandbox.md b/.changeset/toggle-tui-sandbox.md new file mode 100644 index 00000000000..a616acbc636 --- /dev/null +++ b/.changeset/toggle-tui-sandbox.md @@ -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. diff --git a/.changeset/vscode-cli-version-marker-packaging.md b/.changeset/vscode-cli-version-marker-packaging.md new file mode 100644 index 00000000000..0e418c11a27 --- /dev/null +++ b/.changeset/vscode-cli-version-marker-packaging.md @@ -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. diff --git a/.changeset/vscode-model-usage.md b/.changeset/vscode-model-usage.md new file mode 100644 index 00000000000..ae71b9ea5d5 --- /dev/null +++ b/.changeset/vscode-model-usage.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/sdk": patch +"kilo-code": patch +--- + +Include session-tree IDs in model usage API responses and show full task token usage with a provider-grouped model breakdown in the VS Code session header. diff --git a/.gitattributes b/.gitattributes index a53ef3c352f..cc3347a080f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,15 +10,27 @@ packages/kilo-ui/tests/**/*.png filter=lfs diff=lfs merge=lfs -text packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text packages/kilo-docs/public/img/screenshot-tests/**/*.png filter=lfs diff=lfs merge=lfs -text -# Hide non-English localization files by default in GitHub PR diffs. +# Hide most localization files by default in GitHub PR diffs. Show English as the source +# language and German so reviewers can catch untranslated copy-pasted English strings. **/i18n/*.ts linguist-generated=true +**/i18n/**/*.ts linguist-generated=true **/i18n/en.ts linguist-generated=false **/i18n/en*.ts linguist-generated=false +**/i18n/**/en.ts linguist-generated=false +**/i18n/**/en*.ts linguist-generated=false +**/i18n/de.ts linguist-generated=false +**/i18n/de*.ts linguist-generated=false +**/i18n/**/de.ts linguist-generated=false +**/i18n/**/de*.ts linguist-generated=false **/i18n/package-nls-en.ts linguist-generated=false +**/i18n/package-nls-de.ts linguist-generated=false **/i18n/index.ts linguist-generated=false +**/i18n/**/index.ts linguist-generated=false **/i18n/parity.test.ts linguist-generated=false +**/i18n/**/parity.test.ts linguist-generated=false packages/kilo-i18n/src/*.ts linguist-generated=true packages/kilo-i18n/src/en.ts linguist-generated=false +packages/kilo-i18n/src/de.ts linguist-generated=false # Auto-generated CLI reference docs packages/kilo-docs/markdoc/partials/cli-commands-table.md linguist-generated=true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c2f0f56de15..115e10e59f8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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,18 @@ 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 + # The live user-namespace bootstrap depends on the runner's kernel/AppArmor policy + # (GitHub-hosted Ubuntu 24.04 sets kernel.apparmor_restrict_unprivileged_userns=1), not on + # the shipped artifact. The runtime probe degrades gracefully, so keep this check non-fatal. + "$helper" --unshare-user --disable-userns --unshare-pid --die-with-parent --new-session \ + --ro-bind / / --dev /dev --proc /proc -- "$helper" --version \ + || echo "unprivileged user namespaces unavailable on this runner; skipping live sandbox check" + fi root="$(mktemp -d)" trap 'rm -rf "$root"' RETURN ( @@ -212,8 +234,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 diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml index 8469b98776a..12f23871615 100644 --- a/.github/workflows/test-jetbrains.yml +++ b/.github/workflows/test-jetbrains.yml @@ -33,6 +33,7 @@ jobs: filters: | jetbrains: - '**' + - '!.changeset/**' - '!packages/kilo-vscode/**' - '!packages/kilo-docs/**' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b59bbb1208..96c15f41d0b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,31 +22,73 @@ env: jobs: # kilocode_change start + changes: + name: detect general unit test changes + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: read + outputs: + settings: ${{ steps.matrix.outputs.settings }} + steps: + - name: Checkout repository + if: github.event_name != 'workflow_dispatch' + uses: actions/checkout@v6 + + - name: Detect general unit test changes + if: github.event_name != 'workflow_dispatch' + id: filter + uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3 + with: + predicate-quantifier: every + filters: | + general: + - '**' + - '!.changeset/**' + - '!packages/kilo-jetbrains/**' + - '!packages/kilo-vscode/**' + - '!packages/kilo-docs/**' + - '!packages/extensions/zed/**' + - '!specs/**' + - '!perf/**' + + - name: Configure unit matrix + id: matrix + env: + GENERAL: ${{ github.event_name != 'pull_request' || steps.filter.outputs.general == 'true' }} + run: | + if [ "$GENERAL" = "true" ]; then + echo 'settings=[{"name":"linux","host":"blacksmith-4vcpu-ubuntu-2404","run":true},{"name":"macos","host":"macos-15","run":true},{"name":"windows","host":"blacksmith-4vcpu-windows-2025","run":true}]' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo 'settings=[{"name":"linux","host":"blacksmith-4vcpu-ubuntu-2404","run":false}]' >> "$GITHUB_OUTPUT" + unit: name: unit (${{ matrix.settings.name }}) + needs: changes strategy: fail-fast: false matrix: - 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 + settings: ${{ fromJSON(needs.changes.outputs.settings) }} runs-on: ${{ matrix.settings.host }} timeout-minutes: 45 # kilocode_change defaults: run: shell: bash steps: + - name: Skip unchanged general unit tests + if: ${{ !matrix.settings.run }} + run: echo "Only isolated product, documentation, or metadata files changed; general unit tests are unchanged." + - name: Checkout repository + if: matrix.settings.run uses: actions/checkout@v6 # kilocode_change with: token: ${{ secrets.GITHUB_TOKEN }} # kilocode_change start - name: Setup Node + if: matrix.settings.run id: setup-node continue-on-error: ${{ runner.os == 'Windows' }} uses: actions/setup-node@v6 # kilocode_change @@ -54,21 +96,41 @@ jobs: node-version: "24" - name: Retry Setup Node on Windows - if: runner.os == 'Windows' && steps.setup-node.outcome == 'failure' + if: matrix.settings.run && runner.os == 'Windows' && steps.setup-node.outcome == 'failure' uses: actions/setup-node@v6 with: node-version: "24" # kilocode_change end - name: Setup Bun + if: matrix.settings.run uses: ./.github/actions/setup-bun + # kilocode_change start + - name: Setup Zig for Linux sandbox helper + if: matrix.settings.run && 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: matrix.settings.run && 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 + if: matrix.settings.run run: | git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com" git config --global user.name "kilo-maintainer[bot]" - name: Cache Turbo + if: matrix.settings.run uses: actions/cache@v5 # kilocode_change with: path: node_modules/.cache/turbo @@ -78,18 +140,21 @@ jobs: turbo-${{ runner.os }}- - name: Run unit tests + if: matrix.settings.run 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' # kilocode_change + if: matrix.settings.run && runner.os == 'Linux' working-directory: packages/opencode run: bun run test:httpapi + # kilocode_change end - - name: Publish unit reports - if: always() + - name: Publish unit reports # kilocode_change + if: always() && matrix.settings.run uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: report_paths: packages/*/.artifacts/unit/junit.xml @@ -99,7 +164,7 @@ jobs: fail_on_failure: false - name: Upload unit artifacts - if: always() + if: always() && matrix.settings.run uses: actions/upload-artifact@v7 # kilocode_change with: name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 11ef4e1ab75..b5eee50cb81 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -27,8 +27,33 @@ jobs: # kilocode_change end # kilocode_change start + jetbrains-changes: + name: detect JetBrains changes + runs-on: blacksmith-4vcpu-ubuntu-2404 + outputs: + jetbrains: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.jetbrains }} + steps: + - name: Checkout repository + if: github.event_name != 'workflow_dispatch' + uses: actions/checkout@v6 + + - name: Detect JetBrains changes + if: github.event_name != 'workflow_dispatch' + id: filter + uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3 + with: + predicate-quantifier: every + filters: | + jetbrains: + - '**' + - '!.changeset/**' + - '!packages/kilo-vscode/**' + - '!packages/kilo-docs/**' + typecheck-jetbrains: name: typecheck-jetbrains + needs: jetbrains-changes + if: github.event_name == 'workflow_dispatch' || needs.jetbrains-changes.outputs.jetbrains == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository @@ -57,13 +82,21 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 needs: - typecheck-js + - jetbrains-changes - typecheck-jetbrains if: always() steps: - name: Verify typecheck jobs passed run: | echo "typecheck-js=${{ needs.typecheck-js.result }}" + echo "jetbrains-changes=${{ needs.jetbrains-changes.result }}" echo "typecheck-jetbrains=${{ needs.typecheck-jetbrains.result }}" test "${{ needs.typecheck-js.result }}" = "success" - test "${{ needs.typecheck-jetbrains.result }}" = "success" + test "${{ needs.jetbrains-changes.result }}" = "success" + if [ "${{ needs.jetbrains-changes.outputs.jetbrains }}" = "true" ]; then + test "${{ needs.typecheck-jetbrains.result }}" = "success" + else + test "${{ needs.jetbrains-changes.outputs.jetbrains }}" = "false" + test "${{ needs.typecheck-jetbrains.result }}" = "skipped" + fi # kilocode_change end diff --git a/.kilo/command/review-upstream-merge.md b/.kilo/command/review-upstream-merge.md index 36237d81c1c..ce2625a7116 100644 --- a/.kilo/command/review-upstream-merge.md +++ b/.kilo/command/review-upstream-merge.md @@ -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. diff --git a/.kilo/plans/1782130204245-image-generation-tool.md b/.kilo/plans/1782130204245-image-generation-tool.md new file mode 100644 index 00000000000..785ac508af0 --- /dev/null +++ b/.kilo/plans/1782130204245-image-generation-tool.md @@ -0,0 +1,154 @@ +# Image Generation Tool — Port from `kilocode-legacy` + +Port the legacy `generate_image` tool to the opencode-based CLI as a Kilo-owned tool, gated by an experimental flag. The tool lets the LLM generate or edit images via the Kilo Gateway (or BYO OpenRouter key), writing the result to disk and rendering it inline in the chat. + +## Status: IMPLEMENTED + +All core tasks are complete. Build artifacts (CLI binary + VSIX) are produced. Remaining items are CI/compliance and manual verification. + +## Context + +The pre-opencode Kilo (`Kilo-Org/kilocode-legacy`, the Cline/Roo-derived VS Code extension) shipped a `generate_image` native tool with a hardcoded model catalog. It was never ported during the migration to the opencode base. This plan resurrects the feature with a key improvement: **dynamic model discovery from the Kilo Gateway** instead of a static list. + +**Current architecture mapping:** +- Kilo tools live in `packages/opencode/src/kilocode/tool/` (no `kilocode_change` markers) and register via `KiloToolRegistry.extra()`. +- `Tool.define(id, Effect.gen(...))` returns `{ description, parameters, execute }`; `execute` returns `{ title, metadata, output, attachments? }`. +- `attachments` is `MessageV2.FilePart[]` — the native mechanism to surface an image inline. +- Config gating uses `experimental.` in `Config.Info`; Kilo keys need `kilocode_change` markers in shared files. +- The opencode server exposes `/kilo/*` routes via an Effect `HttpApiGroup` in `packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts` with handlers in `handlers/kilo-gateway.ts`. The VS Code extension fetches these via the local `kilo serve` child process. +- The gateway package (`packages/kilo-gateway`) provides `fetchKiloImageModels()` which calls `https://api.kilo.ai/api/openrouter/models` and filters for `output_modalities.includes("image")`. + +## Decisions + +1. **Provider routing** — Kilo Gateway default (zero-config for logged-in users) + BYO OpenRouter key fallback (`OPENROUTER_API_KEY` env var). +2. **Result surface** — write image to disk at the LLM-chosen path **and** return a `FilePart` attachment for inline rendering. +3. **Editing** — v1 includes the optional `image` input param (read existing file as raw bytes, base64-encode, send multimodal text+image message). +4. **Gating** — `experimental.image_generation` (off by default), all clients. +5. **API mechanism** — chat-completions with `modalities: ["image", "text"]`. +6. **Model catalog** — **dynamic fetch from Kilo Gateway** (not hardcoded). The tool reads `experimental.image_generation_model` from config as the default; the VS Code settings UI fetches the live list via `/kilo/models/images`. A hardcoded `FALLBACK_IMAGE_MODELS` list is kept for offline/error resilience and test stability. Default model: `google/gemini-2.5-flash-image`. +7. **Permission** — `write` permission for the output file; `assertExternalDirectoryEffect` for input image path traversal protection. + +## Completed Tasks + +### 1. Tool implementation (Kilo-owned) +- [x] `packages/opencode/src/kilocode/tool/generate-image.ts` — `Tool.define("generate_image")` with params `{ prompt, path, image?, model? }`. Resolves provider via `Auth.Service`, builds chat-completions fetch with `modalities`, parses data-URL response, writes to disk via `AppFileSystem.writeWithDirs`, returns `FilePart` attachment. +- [x] Input image read via `fs/promises.readFile()` (raw bytes, not text-encoding) + `assertExternalDirectoryEffect` path guard. +- [x] Reads `experimental.image_generation_model` config key for default model. +- [x] `packages/opencode/src/kilocode/tool/generate-image.txt` — tool description. + +### 2. Gateway model API +- [x] `packages/kilo-gateway/src/api/models.ts` — refactored shared `fetchRawKiloModels()`, added `fetchKiloImageModels()` (inverse filter: keeps only `output_modalities.includes("image")`). +- [x] Exported `fetchKiloImageModels`, `KiloImageModel`, `KiloImageModelsResult` from `packages/kilo-gateway/src/index.ts`. + +### 3. Server endpoints (opencode HttpApiGroup) +- [x] `packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts` — added `GET /kilo/models/images` endpoint + `ImageModel` schema. +- [x] `packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts` — added `imageModels` handler calling `fetchKiloImageModels()` with proxy auth. +- [x] `packages/kilo-gateway/src/server/routes.ts` — added `POST /image/generations` Hono route (for standalone gateway usage) + `GET /models/images` route. + +### 4. Registry wiring +- [x] `packages/opencode/src/kilocode/tool/registry.ts` — added `GenerateImageTool` to `infos()`, `build()`, `extra()` (gated by `experimental.image_generation`). +- [x] `packages/opencode/src/tool/registry.ts` (shared, `kilocode_change` markers) — added `Auth.Service` dependency + `Auth.defaultLayer` provision. + +### 5. Config +- [x] `packages/opencode/src/config/config.ts` (shared, `kilocode_change` markers) — added `image_generation` and `image_generation_model` to the `experimental` struct. + +### 6. VS Code extension +- [x] `packages/kilo-vscode/webview-ui/src/types/messages/config.ts` — added `image_generation` + `image_generation_model` to `ExperimentalConfig`. +- [x] `packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx` — Image Generation toggle + conditional model dropdown (fetched live via context). +- [x] `packages/kilo-vscode/webview-ui/src/context/image-models.tsx` — `ImageModelsProvider` context that sends `requestImageModels` and listens for `imageModelsLoaded`. +- [x] `packages/kilo-vscode/webview-ui/src/App.tsx` — registered `ImageModelsProvider` in the provider tree. +- [x] `packages/kilo-vscode/src/image-generation/models.ts` — `fetchImageModels()` helper (calls `/kilo/models/images` via the local connection service). +- [x] `packages/kilo-vscode/src/KiloProvider.ts` — `fetchAndSendImageModels()` handler + `requestImageModels` case + cached message. +- [x] Message types: `RequestImageModelsMessage` (webview→extension) + `ImageModelsLoadedMessage` (extension→webview). +- [x] i18n strings added to all 20 locales (`imageGeneration.*` + `imageGenerationModel.*`). + +### 7. Tests (TDD) +- [x] `packages/opencode/test/kilocode/tool/generate-image.test.ts` — 21 tests: response parser (data-URL extraction, format detection, edge cases), provider resolver (kilo-auth/api-key/BYO-key/none/preference), path extension logic, model catalog. +- [x] Updated affected test files for `image` tool field + `Auth.defaultLayer` dependency: `tool-registry-indexing.test.ts`, `tool-registry-indexing-import-failure.test.ts`, `tool-registry-semantic-import-failure.test.ts`, `registry.test.ts`, `prompt.test.ts`, `snapshot-tool-race.test.ts`, `session-compaction-cap.test.ts`, `session-prompt-compaction-safety.test.ts`, `session-prompt-permission-refresh.test.ts`. + +### 8. Changeset +- [x] `.changeset/image-generation.md` — `minor` for `kilo-code`. + +## Remaining Tasks + +### CI / Contributing compliance +- [ ] **SDK regen**: `./script/generate.ts` — the new `/kilo/models/images` endpoint was added to the HttpApiGroup; regenerate SDK types. +- [ ] **Source links**: `bun run script/extract-source-links.ts` — new files under `packages/opencode/src/` and `packages/kilo-vscode/`. +- [ ] **opencode annotation check**: `bun run script/check-opencode-annotations.ts` — verify markers on shared file edits. +- [ ] **Cloud config-schema mirror**: add `image_generation` + `image_generation_model` to `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud). +- [ ] **Issue-first**: create/link a feature issue in the PR. + +### Manual verification (before PR) +- [ ] Confirm the model dropdown populates in VS Code (requires Kilo login + running `kilo serve`). +- [ ] Confirm an actual image generation call succeeds end-to-end (prompt → image file on disk + inline render). +- [ ] Confirm image editing works (input image → transformed output). +- [ ] Confirm the BYO OpenRouter key path works with `OPENROUTER_API_KEY` set. +- [ ] Confirm the tool does NOT appear when the flag is off. +- [ ] Verify TUI behavior: inline image render may degrade to a path note. + +## Risks & Assumptions + +- **Cloud backend dependency:** The Kilo cloud must support chat-completions-with-modalities at `${KILO_API_BASE}/api/openrouter/chat/completions`. The BYO-OpenRouter-key path works regardless. +- **TUI image rendering:** Unverified whether the TUI renders `FilePart` image attachments inline. The file write + path output guarantees correctness regardless. +- **Cost:** Image generation is a paid external call. The opt-in flag (off by default) is the primary guard. + +## Architecture (as implemented) + +``` +User enables flag (VS Code settings or kilo.json) + │ + ├── VS Code: toggle writes experimental.image_generation to config + │ model dropdown fetches GET /kilo/models/images → fetchKiloImageModels() + │ + └── CLI: reads experimental.image_generation from config + +LLM calls generate_image tool + │ + ├── resolveProvider(): Auth.Service → Kilo token? → Kilo cloud URL + │ else OPENROUTER_API_KEY → openrouter.ai URL + │ + ├── fetch chat-completions with modalities:["image","text"] + │ (+ optional input image as base64 data URL for editing) + │ + ├── parse response → choices[0].message.images[0].image_url.url (data URL) + │ + ├── write image bytes to disk (AppFileSystem.writeWithDirs) + │ + └── return { output: path, attachments: [FilePart] } +``` + +## Files Changed + +**New files (Kilo-owned):** +| File | Purpose | +|---|---| +| `packages/opencode/src/kilocode/tool/generate-image.ts` | Tool implementation | +| `packages/opencode/src/kilocode/tool/generate-image.txt` | Tool description | +| `packages/opencode/test/kilocode/tool/generate-image.test.ts` | TDD tests (21) | +| `packages/kilo-vscode/src/image-generation/models.ts` | VS Code fetch helper | +| `packages/kilo-vscode/webview-ui/src/context/image-models.tsx` | Image models context provider | +| `.changeset/image-generation.md` | Changeset | + +**Shared files (with `kilocode_change` markers):** +| File | Change | +|---|---| +| `packages/opencode/src/config/config.ts` | `image_generation` + `image_generation_model` config keys | +| `packages/opencode/src/tool/registry.ts` | `Auth.Service` dependency + layer | + +**Kilo-owned files modified:** +| File | Change | +|---|---| +| `packages/opencode/src/kilocode/tool/registry.ts` | Tool registration + flag gating | +| `packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts` | `GET /kilo/models/images` endpoint | +| `packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts` | `imageModels` handler | +| `packages/kilo-gateway/src/api/models.ts` | `fetchKiloImageModels()` + refactored shared fetch | +| `packages/kilo-gateway/src/server/routes.ts` | Standalone gateway routes | +| `packages/kilo-gateway/src/index.ts` | Exports | +| `packages/kilo-vscode/webview-ui/src/types/messages/config.ts` | Config type | +| `packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts` | `RequestImageModelsMessage` | +| `packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts` | `ImageModelsLoadedMessage` | +| `packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx` | Toggle + dropdown UI | +| `packages/kilo-vscode/webview-ui/src/App.tsx` | Provider registration | +| `packages/kilo-vscode/src/KiloProvider.ts` | Handler + cache | +| `packages/kilo-vscode/webview-ui/src/i18n/*.ts` | 20 locale files | +| 9 test files | `Auth.defaultLayer` + `image` tool field | diff --git a/.kilo/skills/kilocode-merge-minimizer/SKILL.md b/.kilo/skills/kilocode-merge-minimizer/SKILL.md index 84dfaf4917b..9c4798de73b 100644 --- a/.kilo/skills/kilocode-merge-minimizer/SKILL.md +++ b/.kilo/skills/kilocode-merge-minimizer/SKILL.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index f36d4aea2fd..a54eb6aa140 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ All products are clients of the **CLI** (`packages/opencode/`), which contains t In each VS Code extension host, one `KiloConnectionService` is created for the sidebar, every Kilo editor tab, and Agent Manager; it lazily starts and reuses one current `kilo serve` backend at a time. Agent Manager worktree sessions pass a directory context to this shared backend rather than starting one per worktree. State captured by the active service layer, such as Snapshot `trackState`, is shared across those requests; only directory-keyed `InstanceState` data is isolated. -Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide. +Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide. Experimental flags should follow existing flag patterns, not VS Code settings; they usually belong in the Kilo Experimental settings section. ## Package Instructions diff --git a/README.md b/README.md index f61baf7c88a..e26b90a6bba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | Українська | বাংলা | Ελληνικά | Tiếng Việt + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | Українська | বাংলা | Ελληνικά | Tiếng Việt

@@ -168,7 +168,7 @@ MIT. You're free to use, modify, and distribute this code, including commerciall

Where did Kilo CLI come from? -Kilo CLI is a fork of [OpenCode](https://github.com/Kilo-Org/kilocode), enhanced to work within the Kilo agentic engineering platform. +Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform.
diff --git a/nix/kilo.nix b/nix/kilo.nix index 9d01a0ee26a..8ad056e09d7 100644 --- a/nix/kilo.nix +++ b/nix/kilo.nix @@ -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; }; diff --git a/packages/core/package.json b/packages/core/package.json index 8a56507dd3d..b5c0f814c26 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "test": "bun test", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --dots --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "bin": { diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 33711002f3d..758e60e78ee 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,5 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" -import { assertWrite, decorateFileSystem } from "@kilocode/sandbox" // kilocode_change +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" @@ -9,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 { - 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", { method: Schema.String, @@ -117,13 +95,7 @@ export namespace AppFileSystem { }) const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { - // kilocode_change start - enforce the active sandbox and tolerate Windows EEXIST - yield* assertWrite(path) - 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* ( @@ -138,13 +110,7 @@ export namespace AppFileSystem { (e) => e.reason._tag === "NotFound", () => Effect.gen(function* () { - // kilocode_change start - enforce the active sandbox and tolerate Windows EEXIST - yield* assertWrite(dirname(path)) - 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 }), ), diff --git a/packages/core/test/kilocode/linux-sandbox.test.ts b/packages/core/test/kilocode/linux-sandbox.test.ts new file mode 100644 index 00000000000..487d83ff8cb --- /dev/null +++ b/packages/core/test/kilocode/linux-sandbox.test.ts @@ -0,0 +1,763 @@ +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, + denyNames: ReadonlyArray = [], + 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((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("prevents renaming denied policy state while sibling state remains writable", async () => { + const root = await fixture() + const state = path.join(root.project, "state") + const store = path.join(root.project, "policy") + const sibling = path.join(state, "sibling.txt") + const moved = path.join(state, "moved") + await fs.mkdir(state) + await fs.mkdir(store) + const policy = denied(profile([state]), [{ path: store, kind: "subtree" }]) + const script = [ + 'const fs = require("node:fs")', + `fs.writeFileSync(${JSON.stringify(sibling)}, "allowed")`, + `try { fs.renameSync(${JSON.stringify(store)}, ${JSON.stringify(moved)}); process.exit(2) } catch {}`, + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0) + expect(await fs.readFile(sibling, "utf8")).toBe("allowed") + expect((await fs.stat(store)).isDirectory()).toBe(true) + } 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) +}) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 9980f65e547..db408f52271 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -119,14 +119,14 @@ describe("ProjectV2.resolve", () => { Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) - yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" })) - yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" })) + yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@example.com:owner/repo.git" })) + yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://example.com/owner/repo.git" })) const project = yield* Project.Service const a = yield* project.resolve(abs(ssh.path)) const b = yield* project.resolve(abs(https.path)) - expect(a.id).toBe(remoteID("github.com/owner/repo")) + expect(a.id).toBe(remoteID("example.com/owner/repo")) expect(b.id).toBe(a.id) }), ) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index c6b65536b58..412d3b5b196 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --dots --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 3569325edd5..06051bb861e 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --dots --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/kilo-docs/__tests__/content-integrity.test.ts b/packages/kilo-docs/__tests__/content-integrity.test.ts new file mode 100644 index 00000000000..37ee6760113 --- /dev/null +++ b/packages/kilo-docs/__tests__/content-integrity.test.ts @@ -0,0 +1,127 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const pages = path.join(root, "pages") +const images = path.join(root, "public/img") +const removed = [ + "getting-started/settings/auto-cleanup", + "getting-started/settings/system-notifications", + "getting-started/faq/known-issues", + "customize/context/large-projects", + "automate/extending/auto-launch", + "code-with-ai/features/fast-edits", + "ai-providers/claude-code", + "ai-providers/glama", + "ai-providers/human-relay", + "ai-providers/virtual-quota-fallback", + "ai-providers/vscode-lm", + "automate/tools/access-mcp-resource", + "automate/tools/apply-diff", + "automate/tools/ask-followup-question", + "automate/tools/attempt-completion", + "automate/tools/browser-action", + "automate/tools/delete-file", + "automate/tools/execute-command", + "automate/tools/list-code-definition-names", + "automate/tools/list-files", + "automate/tools/new-task", + "automate/tools/read-file", + "automate/tools/search-files", + "automate/tools/switch-mode", + "automate/tools/update-todo-list", + "automate/tools/use-mcp-tool", + "automate/tools/write-to-file", +] + +function markdown(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(dir, entry.name) + if (entry.isDirectory()) return markdown(target) + return entry.name.endsWith(".md") ? [target] : [] + }) +} + +const files = markdown(pages) + +describe("active documentation integrity", () => { + it("contains no legacy platform metadata or tab labels", () => { + const violations = files.flatMap((file) => { + const content = fs.readFileSync(file, "utf8") + return /platform:\s*["']?legacy|VS\s?Code \(Legacy\)|VSCode \(Legacy\)/.test(content) + ? [path.relative(root, file)] + : [] + }) + + expect(violations).toEqual([]) + }) + + it("does not retain removed legacy page files", () => { + for (const route of removed) { + expect(fs.existsSync(path.join(pages, `${route}.md`)), route).toBe(false) + expect(fs.existsSync(path.join(pages, route, "index.md")), route).toBe(false) + } + }) + + it("does not link to removed legacy routes", () => { + const violations: string[] = [] + for (const file of files) { + const content = fs.readFileSync(file, "utf8") + for (const route of removed) { + if (content.includes(`/docs/${route}`)) violations.push(`${path.relative(root, file)} -> ${route}`) + } + } + + expect(violations).toEqual([]) + }) + + it("has balanced tab and callout tags", () => { + const paired = new Set(["callout", "tab", "tabs"]) + const violations: string[] = [] + + for (const file of files) { + const stack: { tag: string; line: number }[] = [] + const lines = fs.readFileSync(file, "utf8").split("\n") + lines.forEach((line, index) => { + const tag = line.trim().match(/^\{% (\/?)([a-z-]+)(?: [^%]*)?%\}$/) + if (!tag || !paired.has(tag[2]) || line.trim().endsWith("/%}")) return + if (!tag[1]) { + stack.push({ tag: tag[2], line: index + 1 }) + return + } + + const open = stack.pop() + if (!open || open.tag !== tag[2]) { + violations.push(`${path.relative(root, file)}:${index + 1} closes ${tag[2]} without a matching opener`) + } + }) + + for (const open of stack) { + violations.push(`${path.relative(root, file)}:${open.line} leaves ${open.tag} unclosed`) + } + } + + expect(violations).toEqual([]) + }) + + it("preserves current IDE migration anchors", () => { + const jetbrains = fs.readFileSync(path.join(root, "markdoc/partials/install-jetbrains.md"), "utf8") + expect(jetbrains).toContain("{% #jetbrains-early-access %}") + expect(fs.existsSync(path.join(pages, "code-with-ai/platforms/vscode/whats-new.md"))).toBe(true) + }) + + it("references existing local images, including PNG files", () => { + const missing = new Set() + for (const file of files) { + const content = fs.readFileSync(file, "utf8").replace(//g, "") + for (const match of content.matchAll(/\/docs\/img\/([^\s"')]+)/g)) { + const image = decodeURIComponent(match[1]) + if (!fs.existsSync(path.join(images, image))) missing.add(`${path.relative(root, file)} -> ${image}`) + } + } + + expect([...missing]).toEqual([]) + }) +}) diff --git a/packages/kilo-docs/__tests__/previous-docs-redirects.spec.ts b/packages/kilo-docs/__tests__/previous-docs-redirects.spec.ts index 1c37b406df3..34308c39f67 100644 --- a/packages/kilo-docs/__tests__/previous-docs-redirects.spec.ts +++ b/packages/kilo-docs/__tests__/previous-docs-redirects.spec.ts @@ -1,12 +1,7 @@ -/** - * Tests for redirect loop detection in previous-docs-redirects.js - * - * This test suite verifies that the redirect configuration has no loops: - * 1. Direct loops: A path redirecting to itself (source === destination) - * 2. Indirect loops: A chain of redirects leading back to a starting point (A → B → C → A) - */ - -import { expect, describe, it } from "vitest" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" import redirects from "../previous-docs-redirects.js" interface Redirect { @@ -16,120 +11,132 @@ interface Redirect { permanent?: boolean } +const entries = redirects as Redirect[] +const pages = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../pages") +const archive = "https://github.com/Kilo-Org/kilocode-legacy/blob/main/docs/legacy-ides/" + describe("previous-docs-redirects", () => { - describe("direct loop detection", () => { - it("should not have any redirects where source equals destination", () => { - const directLoops: Redirect[] = [] - - for (const redirect of redirects as Redirect[]) { - if (redirect.source === redirect.destination) { - directLoops.push(redirect) - } - } + it("has unique sources", () => { + const seen = new Set() + const duplicates = new Set() - if (directLoops.length > 0) { - const loopDetails = directLoops.map((r) => ` - "${r.source}" redirects to itself`).join("\n") - expect.fail(`Found ${directLoops.length} direct redirect loop(s):\n${loopDetails}`) - } - }) - }) + for (const redirect of entries) { + if (seen.has(redirect.source)) duplicates.add(redirect.source) + seen.add(redirect.source) + } - describe("indirect loop detection", () => { - it("should not have any redirect chains that form a cycle", () => { - // Build a map of source -> destination for quick lookup - // Note: We only consider exact path matches, not wildcard patterns like :path* - // Also skip direct loops (source === destination) as they're caught by the direct loop test - const redirectMap = new Map() - - for (const redirect of redirects as Redirect[]) { - // Skip wildcard redirects as they don't form exact chains - // Skip direct loops as they're caught by the direct loop test - if ( - !redirect.source.includes(":") && - !redirect.source.includes("*") && - redirect.source !== redirect.destination - ) { - redirectMap.set(redirect.source, redirect.destination) - } - } + expect([...duplicates]).toEqual([]) + }) - const cycles: string[][] = [] - - /** - * Detects if following redirects from a starting path leads back to any path in the chain. - * Uses a visited set to track the current chain and detect cycles. - */ - function detectCycle(startPath: string): string[] | null { - const visited = new Set() - const chain: string[] = [startPath] - let currentPath = startPath - - while (redirectMap.has(currentPath)) { - const nextPath = redirectMap.get(currentPath)! - - if (visited.has(nextPath)) { - // Found a cycle - return the chain from the cycle start - const cycleStartIndex = chain.indexOf(nextPath) - if (cycleStartIndex !== -1) { - return [...chain.slice(cycleStartIndex), nextPath] - } - return null - } - - visited.add(currentPath) - chain.push(nextPath) - currentPath = nextPath - } - - return null - } + it("has valid redirect objects", () => { + for (const redirect of entries) { + expect(redirect.source).toMatch(/^\//) + expect(redirect.destination).toMatch(/^(?:\/|https:\/\/)/) + expect(redirect.basePath).toBe(false) + expect(redirect.permanent).toBe(true) + } + }) - // Check each redirect source for potential cycles - for (const source of redirectMap.keys()) { - const cycle = detectCycle(source) - if (cycle) { - // Avoid duplicate cycle reports by checking if we've already found this cycle - const cycleKey = [...cycle].sort().join(" -> ") - const isDuplicate = cycles.some((existingCycle) => [...existingCycle].sort().join(" -> ") === cycleKey) - if (!isDuplicate) { - cycles.push(cycle) - } - } - } + it("preserves wildcard parameters", () => { + for (const redirect of entries) { + const params = redirect.source.match(/:[A-Za-z]+\*/g) ?? [] + for (const param of params) expect(redirect.destination).toContain(param) + } + }) - if (cycles.length > 0) { - const cycleDetails = cycles.map((cycle) => ` - ${cycle.join(" → ")}`).join("\n") - expect.fail(`Found ${cycles.length} indirect redirect cycle(s):\n${cycleDetails}`) + it("has no direct or indirect cycles", () => { + const exact = new Map( + entries + .filter((redirect) => !redirect.source.includes(":") && redirect.source !== redirect.destination) + .map((redirect) => [redirect.source, redirect.destination]), + ) + + for (const source of exact.keys()) { + const seen = new Set([source]) + const chain = [source] + let current = source + + while (exact.has(current)) { + current = exact.get(current)! + chain.push(current) + expect(seen.has(current), `Redirect cycle: ${chain.join(" -> ")}`).toBe(false) + seen.add(current) } - }) + } }) - describe("redirect structure validation", () => { - it("should have valid redirect objects with required properties", () => { - const invalidRedirects: { index: number; issues: string[] }[] = [] - - ;(redirects as Redirect[]).forEach((redirect, index) => { - const issues: string[] = [] + it("points internal destinations at existing pages", () => { + for (const redirect of entries) { + const destination = redirect.destination.split("#", 1)[0] + if (!destination.startsWith("/docs") || destination.includes(":")) continue + + const route = destination.replace(/^\/docs\/?/, "") + const candidates = route + ? [path.join(pages, `${route}.md`), path.join(pages, route, "index.md")] + : [path.join(pages, "index.md")] + expect(candidates.some((candidate) => fs.existsSync(candidate)), `${redirect.source} -> ${destination}`).toBe(true) + } + }) - if (typeof redirect.source !== "string" || redirect.source.trim() === "") { - issues.push("missing or invalid 'source' property") - } + it("redirects removed legacy routes directly to the archive", () => { + const expected = new Map([ + [ + "/docs/getting-started/settings/auto-cleanup", + `${archive}getting-started/settings/auto-cleanup.md`, + ], + ["/docs/advanced-usage/large-projects", `${archive}customize/context/large-projects.md`], + ["/docs/features/tools/read-file", `${archive}automate/tools/read-file.md`], + ["/docs/providers/claude-code", `${archive}ai-providers/claude-code.md`], + ["/docs/jetbrains-troubleshooting", `${archive}getting-started/troubleshooting/jetbrains.md`], + ]) + const actual = new Map(entries.map((redirect) => [redirect.source, redirect.destination])) + + for (const [source, destination] of expected) expect(actual.get(source)).toBe(destination) + }) - if (typeof redirect.destination !== "string" || redirect.destination.trim() === "") { - issues.push("missing or invalid 'destination' property") - } + it("keeps aliases for current pages on the active docs site", () => { + const expected = new Map([ + ["/docs/providers", "/docs/ai-providers"], + ["/docs/providers/:path*", "/docs/ai-providers/:path*"], + ["/docs/providers/openai-codex", "/docs/ai-providers/openai-chatgpt-plus-pro"], + ["/docs/basic-usage/using-modes", "/docs/code-with-ai/agents/using-agents"], + ["/docs/features/slash-commands", "/docs/customize/workflows"], + ["/docs/features/slash-commands/workflows", "/docs/customize/workflows"], + ["/docs/features/custom-instructions", "/docs/customize/custom-instructions"], + ["/docs/advanced-usage/custom-instructions", "/docs/customize/custom-instructions"], + ["/docs/advanced-usage/custom-rules", "/docs/customize/custom-rules"], + ["/docs/features/skills", "/docs/customize/skills"], + ["/docs/features/shell-integration", "/docs/automate/extending/shell-integration"], + ]) + const actual = new Map(entries.map((redirect) => [redirect.source, redirect.destination])) + + for (const [source, destination] of expected) expect(actual.get(source)).toBe(destination) + }) - if (issues.length > 0) { - invalidRedirects.push({ index, issues }) - } - }) + it("orders exact provider routes before the provider wildcard", () => { + const wildcard = entries.findIndex((redirect) => redirect.source === "/docs/providers/:path*") + const exact = [ + "/docs/providers", + "/docs/providers/claude-code", + "/docs/providers/glama", + "/docs/providers/human-relay", + "/docs/providers/virtual-quota-fallback", + "/docs/providers/vscode-lm", + "/docs/providers/openai-codex", + ] + + expect(wildcard).toBeGreaterThan(-1) + for (const source of exact) { + const index = entries.findIndex((redirect) => redirect.source === source) + expect(index, source).toBeGreaterThan(-1) + expect(index, source).toBeLessThan(wildcard) + } + }) - if (invalidRedirects.length > 0) { - const details = invalidRedirects - .map((r) => ` - Redirect at index ${r.index}: ${r.issues.join(", ")}`) - .join("\n") - expect.fail(`Found ${invalidRedirects.length} invalid redirect(s):\n${details}`) - } - }) + it("uses well-formed GitHub Markdown destinations", () => { + for (const redirect of entries) { + if (!redirect.destination.startsWith(archive)) continue + expect(redirect.destination).toMatch(/\.md(?:#.*)?$/) + } }) }) diff --git a/packages/kilo-docs/__tests__/sitemap.test.ts b/packages/kilo-docs/__tests__/sitemap.test.ts index ba2a0161a10..37e814cb1b9 100644 --- a/packages/kilo-docs/__tests__/sitemap.test.ts +++ b/packages/kilo-docs/__tests__/sitemap.test.ts @@ -23,10 +23,31 @@ describe("sitemap.xml", () => { expect(xml).toContain("https://kilo.ai/docs") }) - it("includes known pages from the nav", () => { + it("includes representative current product pages", () => { const xml = buildSitemapXml() expect(xml).toContain("https://kilo.ai/docs/getting-started/installing") - expect(xml).toContain("https://kilo.ai/docs/getting-started") + expect(xml).toContain("https://kilo.ai/docs/code-with-ai/platforms/vscode") + expect(xml).toContain("https://kilo.ai/docs/code-with-ai/platforms/vscode/whats-new") + expect(xml).toContain("https://kilo.ai/docs/code-with-ai/platforms/cli") + expect(xml).toContain("https://kilo.ai/docs/code-with-ai/platforms/jetbrains") + expect(xml).toContain("https://kilo.ai/docs/automate/tools") + expect(xml).toContain("https://kilo.ai/docs/automate/tools/semantic-search") + }) + + it("excludes removed legacy product pages", () => { + const xml = buildSitemapXml() + const removed = [ + "/getting-started/settings/auto-cleanup", + "/getting-started/settings/system-notifications", + "/getting-started/faq/known-issues", + "/customize/context/large-projects", + "/automate/extending/auto-launch", + "/automate/tools/read-file", + "/code-with-ai/features/fast-edits", + "/ai-providers/vscode-lm", + ] + + for (const route of removed) expect(xml).not.toContain(`https://kilo.ai/docs${route}`) }) it("has no duplicate entries", () => { diff --git a/packages/kilo-docs/components/PageVersionSwitcher.tsx b/packages/kilo-docs/components/PageVersionSwitcher.tsx index 2c793355602..14d798630c0 100644 --- a/packages/kilo-docs/components/PageVersionSwitcher.tsx +++ b/packages/kilo-docs/components/PageVersionSwitcher.tsx @@ -8,15 +8,10 @@ interface Props { export function PageVersionSwitcher({ platform }: Props) { if (!platform || platform === "all") return null - const legacy = platform === "legacy" return (
- {legacy ? "\u24D8" : "\u2728"} - - {legacy - ? "This page applies to the legacy VSCode extension." - : "This page applies to the current VSCode extension & CLI."} - + {"\u2728"} + This page applies to the current VSCode extension & CLI.