fix(cli): apply saved sandbox settings to existing sessions (#12600)

* fix(cli): apply saved sandbox settings to existing sessions

* fix(cli): emit config update event on global overlay saves

* fix(cli): scope live sandbox policy refresh

* fix(cli): refresh project sandbox policies

* test(cli): use external path for sandbox overlay
This commit is contained in:
Marius
2026-08-07 13:37:06 +02:00
committed by GitHub
parent a22d5c02ba
commit 4e36297668
43 changed files with 930 additions and 151 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Apply saved sandbox settings to existing sessions and use the latest settings when enabling sandboxing
@@ -161,9 +161,9 @@ A configured destination is an egress route, not tenant, organization, repositor
The config setting supplies the initial default for new sessions that do not have a saved preference. Use the lock button in the VS Code prompt or `/sandbox` in the CLI to change the current session. Your latest choice is saved as the default for future sessions in that project, takes precedence over the config default, and persists across restarts.
Each initialized session snapshots its network mode, allowed destinations, and additional writable paths. Changing config affects new sessions. The prompt control or `/sandbox` can change the current session's enabled state, but it cannot change these authority lists, and they never expand during an active session.
Each session preserves its enabled or disabled choice. Saving changes through Kilo settings to network mode, allowed destinations, or additional writable paths refreshes existing session policies before their next tool execution. Enabling sandboxing also reads the latest settings. A tool that is already running keeps the policy it started with.
Forked sessions retain the source session's confinement. Subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect.
At creation, forked sessions retain the source session's confinement and subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect. Later trusted sandbox settings replace those network and writable-path limits before the affected session's next tool execution.
Cloud sessions do not expose the local sandbox control because their tools do not run in your local sandbox.
+1 -1
View File
@@ -872,7 +872,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "وجهات الشبكة المسموح بها",
"settings.sandboxing.allowedHosts.description":
"وجهات مضيف ومنفذ DNS لحركة مرور وكيل HTTP وHTTPS المعزولة. يحتاج GitHub CLI وHTTPS Git عادةً إلى github.com:443 وapi.github.com:443. تنطبق التغييرات على الجلسات الجديدة.",
"وجهات مضيف ومنفذ DNS لحركة مرور وكيل HTTP وHTTPS المعزولة. يحتاج GitHub CLI وHTTPS Git عادةً إلى github.com:443 وapi.github.com:443.",
"settings.sandboxing.writablePaths.title": "مسارات قابلة للكتابة إضافية",
"settings.sandboxing.writablePaths.description":
"مسارات نظام ملفات إضافية يسمح صندوق الرمل بالكتابة إليها (مثل /tmp، /var/log). يتم دمجها مع مسارات الكتابة الافتراضية عندما يكون صندوق الرمل نشطًا.",
+1 -1
View File
@@ -903,7 +903,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Destinos de rede permitidos",
"settings.sandboxing.allowedHosts.description":
"Destinos de host e porta DNS para o tráfego de proxy HTTP e HTTPS em sandbox. GitHub CLI e HTTPS Git geralmente precisam de github.com:443 e api.github.com:443. As alterações se aplicam a novas sessões.",
"Destinos de host e porta DNS para o tráfego de proxy HTTP e HTTPS em sandbox. GitHub CLI e HTTPS Git geralmente precisam de github.com:443 e api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Caminhos graváveis adicionais",
"settings.sandboxing.writablePaths.description":
"Caminhos adicionais do sistema de arquivos onde o sandbox permite gravação (por exemplo, /tmp, /var/log). Eles são mesclados com os caminhos graváveis padrão quando o sandbox está ativo.",
+1 -1
View File
@@ -896,7 +896,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Dozvoljena mrežna odredišta",
"settings.sandboxing.allowedHosts.description":
"DNS odredišta hosta i porta za sandboxirani HTTP i HTTPS proxy promet. GitHub CLI i HTTPS Git obično trebaju github.com:443 i api.github.com:443. Promjene se primjenjuju na nove sesije.",
"DNS odredišta hosta i porta za sandboxirani HTTP i HTTPS proxy promet. GitHub CLI i HTTPS Git obično trebaju github.com:443 i api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Dodatne upisive putanje",
"settings.sandboxing.writablePaths.description":
"Dodatne putanje sistema datoteka u koje sandbox dozvoljava upis (npr. /tmp, /var/log). Spajaju se sa zadanim upisivim putanjama kada je sandbox aktivan.",
+1 -1
View File
@@ -895,7 +895,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Tilladte netværksdestinationer",
"settings.sandboxing.allowedHosts.description":
"DNS-værts- og portdestinationer for sandboxet HTTP- og HTTPS-proxytrafik. GitHub CLI og HTTPS Git kræver typisk github.com:443 og api.github.com:443. Ændringer gælder for nye sessioner.",
"DNS-værts- og portdestinationer for sandboxet HTTP- og HTTPS-proxytrafik. GitHub CLI og HTTPS Git kræver typisk github.com:443 og api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Yderligere skrivbare stier",
"settings.sandboxing.writablePaths.description":
"Yderligere filsystemstier, som sandkassen tillader skrivning til (f.eks. /tmp, /var/log). Disse flettes med de standardskrivbare stier, når sandkassen er aktiv.",
@@ -917,7 +917,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Zulässige Netzwerkziele",
"settings.sandboxing.allowedHosts.description":
"DNS-Host- und Portziele für Sandbox-HTTP- und HTTPS-Proxy-Datenverkehr. GitHub CLI und HTTPS Git benötigen üblicherweise github.com:443 und api.github.com:443. Änderungen gelten für neue Sitzungen.",
"DNS-Host- und Portziele für Sandbox-HTTP- und HTTPS-Proxy-Datenverkehr. GitHub CLI und HTTPS Git benötigen üblicherweise github.com:443 und api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Zusätzliche schreibbare Pfade",
"settings.sandboxing.writablePaths.description":
"Zusätzliche Dateisystempfade, in die die Sandbox Schreibvorgänge erlaubt (z. B. /tmp, /var/log). Diese werden mit den Standard-Schreibpfaden zusammengeführt, wenn die Sandbox aktiv ist.",
@@ -875,7 +875,7 @@ export const dict = {
"Block direct outbound access from model-originated commands and HTTP tools. Local and remote MCP tools are unavailable while restricted. Provider traffic and trusted plugin hooks remain outside this restriction.",
"settings.sandboxing.allowedHosts.title": "Allowed Network Destinations",
"settings.sandboxing.allowedHosts.description":
"DNS host and port destinations for sandboxed HTTP and HTTPS proxy traffic. GitHub CLI and HTTPS Git commonly need github.com:443 and api.github.com:443. Changes apply to new sessions.",
"DNS host and port destinations for sandboxed HTTP and HTTPS proxy traffic. GitHub CLI and HTTPS Git commonly need github.com:443 and api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Additional Writable Paths",
"settings.sandboxing.writablePaths.description":
"Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.",
+1 -1
View File
@@ -906,7 +906,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Destinos de red permitidos",
"settings.sandboxing.allowedHosts.description":
"Destinos de host y puerto DNS para el tráfico de proxy HTTP y HTTPS en zona de pruebas. GitHub CLI y HTTPS Git suelen necesitar github.com:443 y api.github.com:443. Los cambios se aplican a las sesiones nuevas.",
"Destinos de host y puerto DNS para el tráfico de proxy HTTP y HTTPS en zona de pruebas. GitHub CLI y HTTPS Git suelen necesitar github.com:443 y api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Rutas de escritura adicionales",
"settings.sandboxing.writablePaths.description":
"Rutas del sistema de archivos adicionales donde el sandbox permite escritura (por ej., /tmp, /var/log). Se combinan con las rutas de escritura predeterminadas cuando el sandbox está activo.",
+1 -1
View File
@@ -918,7 +918,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Destinations réseau autorisées",
"settings.sandboxing.allowedHosts.description":
"Destinations dhôte et de port DNS pour le trafic proxy HTTP et HTTPS isolé. GitHub CLI et HTTPS Git nécessitent généralement github.com:443 et api.github.com:443. Les modifications sappliquent aux nouvelles sessions.",
"Destinations dhôte et de port DNS pour le trafic proxy HTTP et HTTPS isolé. GitHub CLI et HTTPS Git nécessitent généralement github.com:443 et api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Chemins en écriture supplémentaires",
"settings.sandboxing.writablePaths.description":
"Chemins système supplémentaires autorisés en écriture par le bac à sable (par ex. /tmp, /var/log). Ils sont fusionnés avec les chemins en écriture par défaut lorsque le bac à sable est actif.",
+1 -1
View File
@@ -743,7 +743,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Destinazioni di rete consentite",
"settings.sandboxing.allowedHosts.description":
"Destinazioni DNS di host e porta per il traffico proxy HTTP e HTTPS in sandbox. GitHub CLI e HTTPS Git richiedono comunemente github.com:443 e api.github.com:443. Le modifiche si applicano alle nuove sessioni.",
"Destinazioni DNS di host e porta per il traffico proxy HTTP e HTTPS in sandbox. GitHub CLI e HTTPS Git richiedono comunemente github.com:443 e api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Percorsi di scrittura aggiuntivi",
"settings.sandboxing.writablePaths.description":
"Percorsi aggiuntivi del file system in cui la sandbox consente la scrittura (es. /tmp, /var/log). Vengono uniti con i percorsi di scrittura predefiniti quando la sandbox è attiva.",
+1 -1
View File
@@ -888,7 +888,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "許可されたネットワーク接続先",
"settings.sandboxing.allowedHosts.description":
"サンドボックス化された HTTP および HTTPS プロキシトラフィックの DNS ホストとポートの宛先。GitHub CLI と HTTPS Git では通常、github.com:443 と api.github.com:443 が必要です。変更は新しいセッションに適用されます。",
"サンドボックス化された HTTP および HTTPS プロキシトラフィックの DNS ホストとポートの宛先。GitHub CLI と HTTPS Git では通常、github.com:443 と api.github.com:443 が必要です。",
"settings.sandboxing.writablePaths.title": "追加の書き込み可能パス",
"settings.sandboxing.writablePaths.description":
"サンドボックスでの書き込みを許可する追加のファイルシステムパス(例: /tmp、/var/log)。サンドボックス有効時、デフォルトの書き込み可能パスと統合されます。",
+1 -1
View File
@@ -885,7 +885,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "허용된 네트워크 대상",
"settings.sandboxing.allowedHosts.description":
"샌드박스 처리된 HTTP 및 HTTPS 프록시 트래픽의 DNS 호스트 및 포트 대상입니다. GitHub CLI 및 HTTPS Git에는 일반적으로 github.com:443 및 api.github.com:443가 필요합니다. 변경 사항은 새 세션에 적용됩니다.",
"샌드박스 처리된 HTTP 및 HTTPS 프록시 트래픽의 DNS 호스트 및 포트 대상입니다. GitHub CLI 및 HTTPS Git에는 일반적으로 github.com:443 및 api.github.com:443가 필요합니다.",
"settings.sandboxing.writablePaths.title": "추가 쓰기 가능 경로",
"settings.sandboxing.writablePaths.description":
"샌드박스에서 쓰기를 허용하는 추가 파일시스템 경로(예: /tmp, /var/log). 샌드박스가 활성화되면 기본 쓰기 가능 경로와 병합됩니다.",
+1 -1
View File
@@ -896,7 +896,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Toegestane netwerkbestemmingen",
"settings.sandboxing.allowedHosts.description":
"DNS-host- en poortbestemmingen voor HTTP- en HTTPS-proxyverkeer in een sandbox. GitHub CLI en HTTPS Git hebben doorgaans github.com:443 en api.github.com:443 nodig. Wijzigingen gelden voor nieuwe sessies.",
"DNS-host- en poortbestemmingen voor HTTP- en HTTPS-proxyverkeer in een sandbox. GitHub CLI en HTTPS Git hebben doorgaans github.com:443 en api.github.com:443 nodig.",
"settings.sandboxing.writablePaths.title": "Extra schrijfbare paden",
"settings.sandboxing.writablePaths.description":
"Extra bestandssysteempaden waar de sandbox schrijftoestemming voor geeft (bijv. /tmp, /var/log). Deze worden samengevoegd met de standaard schrijfbare paden wanneer de sandbox actief is.",
+1 -1
View File
@@ -856,7 +856,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Tillatte nettverksmål",
"settings.sandboxing.allowedHosts.description":
"DNS-verts- og portdestinasjoner for HTTP- og HTTPS-proxytrafikk i sandkassen. GitHub CLI og HTTPS Git trenger vanligvis github.com:443 og api.github.com:443. Endringer gjelder for nye økter.",
"DNS-verts- og portdestinasjoner for HTTP- og HTTPS-proxytrafikk i sandkassen. GitHub CLI og HTTPS Git trenger vanligvis github.com:443 og api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Ytterligere skrivbare baner",
"settings.sandboxing.writablePaths.description":
"Ytterligere filsystembaner som sandkassen tillater skriving til (f.eks. /tmp, /var/log). Disse flettes med de standardskrivbare banene når sandkassen er aktiv.",
+1 -1
View File
@@ -853,7 +853,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Dozwolone miejsca docelowe sieci",
"settings.sandboxing.allowedHosts.description":
"Docelowe hosty DNS i porty dla ruchu HTTP i HTTPS przez proxy w piaskownicy. GitHub CLI i HTTPS Git zwykle wymagają github.com:443 i api.github.com:443. Zmiany dotyczą nowych sesji.",
"Docelowe hosty DNS i porty dla ruchu HTTP i HTTPS przez proxy w piaskownicy. GitHub CLI i HTTPS Git zwykle wymagają github.com:443 i api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Dodatkowe ścieżki zapisu",
"settings.sandboxing.writablePaths.description":
"Dodatkowe ścieżki systemu plików, do których sandbox zezwala na zapis (np. /tmp, /var/log). Są one łączone z domyślnymi ścieżkami zapisu, gdy sandbox jest aktywny.",
+1 -1
View File
@@ -892,7 +892,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Разрешенные сетевые назначения",
"settings.sandboxing.allowedHosts.description":
"Целевые DNS-хосты и порты для прокси-трафика HTTP и HTTPS в песочнице. GitHub CLI и HTTPS Git обычно требуют github.com:443 и api.github.com:443. Изменения применяются к новым сеансам.",
"Целевые DNS-хосты и порты для прокси-трафика HTTP и HTTPS в песочнице. GitHub CLI и HTTPS Git обычно требуют github.com:443 и api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Дополнительные пути для записи",
"settings.sandboxing.writablePaths.description":
"Дополнительные пути файловой системы, в которые разрешена запись в песочнице (например, /tmp, /var/log). Они объединяются с путями записи по умолчанию при активной песочнице.",
+1 -1
View File
@@ -881,7 +881,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "ปลายทางเครือข่ายที่อนุญาต",
"settings.sandboxing.allowedHosts.description":
"โฮสต์ DNS และพอร์ตปลายทางสำหรับทราฟฟิกพร็อกซี HTTP และ HTTPS ในแซนด์บ็อกซ์ GitHub CLI และ HTTPS Git มักต้องใช้ github.com:443 และ api.github.com:443 การเปลี่ยนแปลงจะมีผลกับเซสชันใหม่",
"โฮสต์ DNS และพอร์ตปลายทางสำหรับทราฟฟิกพร็อกซี HTTP และ HTTPS ในแซนด์บ็อกซ์ GitHub CLI และ HTTPS Git มักต้องใช้ github.com:443 และ api.github.com:443",
"settings.sandboxing.writablePaths.title": "เส้นทางที่เขียนได้เพิ่มเติม",
"settings.sandboxing.writablePaths.description":
"เส้นทางระบบไฟล์เพิ่มเติมที่แซนด์บ็อกซ์อนุญาตให้เขียนได้ (เช่น /tmp, /var/log) จะถูกรวมเข้ากับเส้นทางที่เขียนได้เริ่มต้นเมื่อแซนด์บ็อกซ์เปิดใช้งาน",
+1 -1
View File
@@ -885,7 +885,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "İzin Verilen Ağ Hedefleri",
"settings.sandboxing.allowedHosts.description":
"Korumalı alana alınmış HTTP ve HTTPS proxy trafiği için DNS ana bilgisayar ve bağlantı noktası hedefleri. GitHub CLI ve HTTPS Git genellikle github.com:443 ve api.github.com:443 gerektirir. Değişiklikler yeni oturumlara uygulanır.",
"Korumalı alana alınmış HTTP ve HTTPS proxy trafiği için DNS ana bilgisayar ve bağlantı noktası hedefleri. GitHub CLI ve HTTPS Git genellikle github.com:443 ve api.github.com:443 gerektirir.",
"settings.sandboxing.writablePaths.title": "Ek Yazılabilir Yollar",
"settings.sandboxing.writablePaths.description":
"Sandığın yazılmasına izin veren ek dosya sistemi yolları (ör. /tmp, /var/log). Sandık etkinken varsayılan yazılabilir yollarla birleştirilir.",
+1 -1
View File
@@ -887,7 +887,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "Дозволені мережеві адреси",
"settings.sandboxing.allowedHosts.description":
"DNS-вузли та порти призначення для ізольованого proxy-трафіку HTTP і HTTPS. GitHub CLI та HTTPS Git зазвичай потребують github.com:443 і api.github.com:443. Зміни застосовуються до нових сеансів.",
"DNS-вузли та порти призначення для ізольованого proxy-трафіку HTTP і HTTPS. GitHub CLI та HTTPS Git зазвичай потребують github.com:443 і api.github.com:443.",
"settings.sandboxing.writablePaths.title": "Додаткові шляхи для запису",
"settings.sandboxing.writablePaths.description":
"Додаткові шляхи файлової системи, у які дозволено запис у пісочниці (наприклад, /tmp, /var/log). Вони об'єднуються зі шляхами запису за замовчуванням, коли пісочниця активна.",
+1 -1
View File
@@ -859,7 +859,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "允许的网络目标",
"settings.sandboxing.allowedHosts.description":
"用于沙盒化 HTTP 和 HTTPS 代理流量的 DNS 主机和端口目标。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。更改将应用于新会话。",
"用于沙盒化 HTTP 和 HTTPS 代理流量的 DNS 主机和端口目标。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。",
"settings.sandboxing.writablePaths.title": "额外可写路径",
"settings.sandboxing.writablePaths.description":
"沙盒允许写入的额外文件系统路径(例如 /tmp、/var/log)。沙盒启用后,这些路径会与默认可写路径合并。",
+1 -1
View File
@@ -819,7 +819,7 @@ export const dict = {
"settings.sandboxing.allowedHosts.title": "允許的網路目的地",
"settings.sandboxing.allowedHosts.description":
"適用於沙盒 HTTP 和 HTTPS Proxy 流量的 DNS 主機與連接埠目標。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。變更將套用至新工作階段。",
"適用於沙盒 HTTP 和 HTTPS Proxy 流量的 DNS 主機與連接埠目標。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。",
"settings.sandboxing.writablePaths.title": "額外可寫路徑",
"settings.sandboxing.writablePaths.description":
"沙盒允許寫入的額外檔案系統路徑(例如 /tmp、/var/log)。沙盒啟用後,這些路徑會與預設可寫路徑合併。",
+4 -3
View File
@@ -1014,7 +1014,7 @@ const layer = Layer.effect(
directory: ctx.directory,
payload: {
type: Event.ConfigUpdated.type,
properties: {},
properties: { sandbox: Object.hasOwn(config, "sandbox") },
},
}),
)
@@ -1069,6 +1069,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
const next = result.next
const changed = result.changed
const sandboxChanged = changed && Object.hasOwn(config, "sandbox")
// kilocode_change end
// kilocode_change start - skip dispose when caller opts out
@@ -1080,7 +1081,7 @@ const layer = Layer.effect(
directory: "global",
payload: {
type: Event.ConfigUpdated.type,
properties: {},
properties: { sandbox: sandboxChanged },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
@@ -1097,7 +1098,7 @@ const layer = Layer.effect(
directory: "global",
payload: {
type: Event.ConfigUpdated.type,
properties: {},
properties: { sandbox: sandboxChanged },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
+3 -2
View File
@@ -9,6 +9,7 @@ import { mergeDeep } from "remeda"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { errorMessage } from "@/util/error"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
import * as Formatter from "./formatter"
export const Status = Schema.Struct({
@@ -85,8 +86,8 @@ const layer = Layer.effect(
.run(
ChildProcess.make(replaced[0]!, replaced.slice(1), {
cwd: dir,
env: item.environment,
extendEnv: true,
env: modelEnv(item.environment), // kilocode_change - formatters must not inherit backend credentials
extendEnv: false, // kilocode_change
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
@@ -5,6 +5,7 @@ import { makeRuntime } from "@/effect/run-service"
import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/kilocode/instance"
import { KiloShutdown } from "@/kilocode/cli/shutdown"
import { model as modelEnv } from "@/kilocode/process/env"
import { SessionID } from "@/session/schema"
import { Shell } from "@opencode-ai/core/shell"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -574,14 +575,11 @@ export namespace BackgroundProcess {
}
function env(id?: ID, token?: string) {
const result: NodeJS.ProcessEnv = {
...process.env,
const result: NodeJS.ProcessEnv = modelEnv({
TERM: "dumb",
...(id ? { KILO_BACKGROUND_PROCESS_ID: id } : {}),
...(token ? { KILO_BACKGROUND_PROCESS_TOKEN: token } : {}),
}
delete result.KILO_SERVER_PASSWORD
delete result.KILO_SERVER_USERNAME
})
delete result.KILO_BACKGROUND_PROCESS_PORTS
return result
}
@@ -15,7 +15,12 @@ export namespace KilocodeConfigWriter {
target: KilocodeConfigOverlay.Target
}
export type Result = { ok: true; target: KilocodeConfigOverlay.Target } | Conflict
export type Result = {
ok: true
target: KilocodeConfigOverlay.Target
changed: boolean
sandboxChanged: boolean
} | Conflict
export async function write(input: {
directory: string
@@ -45,7 +50,7 @@ export namespace KilocodeConfigWriter {
}
const patch = KilocodeConfigOverlay.patch({ scope: input.scope, set: input.set, unset: input.unset })
if (Object.keys(patch).length === 0) return { ok: true, target }
if (Object.keys(patch).length === 0) return { ok: true, target, changed: false, sandboxChanged: false }
await mkdir(path.dirname(target.path), { recursive: true })
await input.beforeWrite?.()
const checked = await KilocodeConfigOverlay.target(input)
@@ -77,7 +82,12 @@ export namespace KilocodeConfigWriter {
? 0o600
: undefined
if (updated !== before) await (input.write ?? Filesystem.write)(checked.path, updated, mode)
return { ok: true, target: await KilocodeConfigOverlay.target(input) }
return {
ok: true,
target: await KilocodeConfigOverlay.target(input),
changed: updated !== before,
sandboxChanged: updated !== before && Object.hasOwn(patch, "sandbox"),
}
}
function patchJsonc(input: string, patch: unknown, parts: string[] = []): string {
@@ -3,6 +3,7 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output"
import { model as modelEnv } from "@/kilocode/process/env"
import { Identifier } from "@/id/id"
import { Instance, type InstanceContext } from "@/kilocode/instance"
import { SessionID } from "@/session/schema"
@@ -220,14 +221,10 @@ export namespace InteractiveTerminal {
}
function environment(input: NodeJS.ProcessEnv) {
const env = Object.fromEntries(
Object.entries(input).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
const env = modelEnv(input)
env.TERM = "xterm-256color"
env.KILO_TERMINAL = "1"
env.KILO_INTERACTIVE_TERMINAL = "1"
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
@@ -0,0 +1,13 @@
export function model(extra?: NodeJS.ProcessEnv | null): Record<string, string> {
const env = Object.fromEntries(
Object.entries({ ...process.env, ...(extra ?? {}) }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
delete env.KILO_CONFIG
delete env.KILO_CONFIG_CONTENT
delete env.KILO_CONFIG_DIR
return env
}
+199 -69
View File
@@ -3,8 +3,10 @@ import os from "node:os"
import path from "node:path"
import { Effect, Semaphore } from "effect"
import { Global } from "@opencode-ai/core/global"
import { Database } from "@opencode-ai/core/database/database"
import { backendSupport, run as runSandbox, unrestricted, type Profile } from "@kilocode/sandbox"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Instance } from "@/kilocode/instance"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
@@ -21,14 +23,62 @@ export type Snapshot = SandboxStore.Snapshot
export type Target = { id: SessionID; directory: string }
const snapshots = new Map<string, Snapshot>()
const synced = new Map<string, number>()
const locks = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
const refreshes = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
const gates = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
const permits = 1_000_000
let revision = 0
GlobalBus.on("event", (event) => {
if (event.payload?.type === "global.config.updated" && event.payload.properties?.sandbox === true) revision++
})
function key(directory: string, sessionID: SessionID) {
return directory + "\0" + sessionID
}
function limits(fallback: ReturnType<typeof SandboxConfig.resolve>) {
return {
mode: fallback.mode,
allowedHosts: fallback.allowedHosts,
writablePaths: fallback.writablePaths.map((value) =>
value.startsWith("~") ? path.join(os.homedir(), value.slice(1)) : value,
),
}
}
function apply(current: Snapshot, fallback: ReturnType<typeof SandboxConfig.resolve>) {
return { ...current, ...limits(fallback) }
}
function matches(current: Snapshot, next: Snapshot) {
return (
current.mode === next.mode &&
current.allowedHosts.join("\0") === next.allowedHosts.join("\0") &&
current.writablePaths.join("\0") === next.writablePaths.join("\0")
)
}
function changed(sessionID: SessionID, directory: string, next: Snapshot) {
const support = backendSupport({ mode: next.mode, allowedHosts: next.allowedHosts })
GlobalBus.emit("event", {
directory,
payload: {
id: Bus.createID(),
type: Changed.type,
properties: {
sessionID,
directory,
enabled: next.enabled && support.available,
available: support.available,
reason: support.reason,
version: next.version,
},
},
})
}
function initial(
chosen: boolean | undefined,
pref: boolean | undefined,
@@ -36,11 +86,7 @@ function initial(
fallback: ReturnType<typeof SandboxConfig.resolve>,
): Snapshot {
const state = {
mode: fallback.mode,
allowedHosts: fallback.allowedHosts,
writablePaths: fallback.writablePaths.map((value) =>
value.startsWith("~") ? path.join(os.homedir(), value.slice(1)) : value,
),
...limits(fallback),
version: 0,
}
if (chosen !== undefined) return { ...state, enabled: chosen }
@@ -76,6 +122,23 @@ function lockedAll<A, E, R>(sessions: readonly SessionID[], effect: Effect.Effec
return [...new Set(sessions)].reduceRight((next, sessionID) => locked(sessionID, next), effect)
}
function refreshing<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const entry = refreshes.get(sessionID) ?? { semaphore: Semaphore.makeUnsafe(1), refs: 0 }
entry.refs++
refreshes.set(sessionID, entry)
return entry
}),
(entry) => entry.semaphore.withPermits(1)(effect),
(entry) =>
Effect.sync(() => {
entry.refs--
if (entry.refs === 0 && refreshes.get(sessionID) === entry) refreshes.delete(sessionID)
}),
)
}
function gated<A, E, R>(sessionID: SessionID, count: number, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -166,13 +229,7 @@ export function profile(
allowedHosts,
},
environment: {
deny: [
"KILO_CONFIG",
"KILO_CONFIG_CONTENT",
"KILO_CONFIG_DIR",
"KILO_SERVER_PASSWORD",
"KILO_SERVER_USERNAME",
],
deny: ["KILO_CONFIG", "KILO_CONFIG_CONTENT", "KILO_CONFIG_DIR", "KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"],
set: {
TMPDIR: Global.Path.tmp,
TMP: Global.Path.tmp,
@@ -205,14 +262,31 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi
// session moved or created with an explicit choice keeps that choice instead of resetting. The
// persisted per-directory preference (last toggled state) is the next precedence, so new sessions
// inherit the last /sandbox choice. The config default applies when neither is present.
const version = revision
const next = yield* resolveInitial(directory, sessionID)
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
const id = key(directory, sessionID)
snapshots.set(id, next)
synced.set(id, version)
return { directory, state: next }
}),
)
})
function current(
sessionID: SessionID,
inside = false,
): Effect.Effect<{ directory: string; state: Snapshot }, never, Config.Service | Database.Service> {
return Effect.gen(function* () {
const expected = revision
const state = yield* snapshot(sessionID)
const id = key(state.directory, sessionID)
if (synced.get(id) !== expected) yield* inside ? reconcile(sessionID, expected) : refresh(sessionID, expected)
if (revision !== expected) return yield* current(sessionID, inside)
return yield* snapshot(sessionID)
})
}
export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () {
const cfg = yield* (yield* Config.Service).get()
const state = SandboxConfig.resolve(cfg)
@@ -224,22 +298,53 @@ export function fallback(config: Config.Info) {
}
export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) {
const current = yield* snapshot(sessionID)
const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts })
const active = yield* current(sessionID)
const support = backendSupport({ mode: active.state.mode, allowedHosts: active.state.allowedHosts })
return {
directory: current.directory,
enabled: current.state.enabled && support.available,
directory: active.directory,
enabled: active.state.enabled && support.available,
available: support.available,
reason: support.reason,
version: current.state.version,
version: active.state.version,
}
})
export const networkRestricted = Effect.fn("SandboxPolicy.networkRestricted")(function* (sessionID: SessionID) {
const current = yield* snapshot(sessionID)
return current.state.enabled && current.state.mode !== "allow"
const active = yield* current(sessionID)
return active.state.enabled && active.state.mode !== "allow"
})
const reconcile = Effect.fn("SandboxPolicy.reconcile")(function* (sessionID: SessionID, version = revision) {
const directory = yield* InstanceState.directory
return yield* refreshing(
sessionID,
Effect.gen(function* () {
const id = key(directory, sessionID)
if (synced.get(id) === version) return false
const current = yield* read(directory, sessionID)
if (!current) return false
const config = yield* (yield* Config.Service).get()
const next: Snapshot = {
...apply(current, SandboxConfig.resolve(config)),
version: current.version + 1,
}
if (matches(current, next)) {
synced.set(id, version)
return false
}
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(id, next)
synced.set(id, version)
yield* Effect.sync(() => changed(sessionID, directory, next))
return true
}),
)
})
export const refresh = Effect.fn("SandboxPolicy.refresh")((sessionID: SessionID, version = revision) =>
locked(sessionID, reconcile(sessionID, version)),
)
function change<E, R, F = never, Q = never, P = never, S = never>(
sessionID: SessionID,
guard:
@@ -255,7 +360,10 @@ function change<E, R, F = never, Q = never, P = never, S = never>(
Effect.gen(function* () {
const stored = yield* read(directory, sessionID)
const current = stored ?? (yield* resolveInitial(directory, sessionID))
const support = backendSupport({ mode: current.mode, allowedHosts: current.allowedHosts })
const enabling = !current.enabled
const version = revision
const base = enabling ? apply(current, SandboxConfig.resolve(yield* (yield* Config.Service).get())) : current
const support = backendSupport({ mode: base.mode, allowedHosts: base.allowedHosts })
const status = {
directory,
enabled: current.enabled && support.available,
@@ -263,34 +371,40 @@ function change<E, R, F = never, Q = never, P = never, S = never>(
reason: support.reason,
version: current.version,
}
const enabling = !current.enabled
if (enabling && !status.available) return status
const targets = enabling && family ? yield* family : [{ id: sessionID, directory }]
const sessions = targets.map((target) => target.id)
const update = Effect.gen(function* () {
yield* typeof guard === "function" ? guard(enabling, targets) : guard
const next: Snapshot = { ...current, enabled: enabling, version: status.version + 1 }
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
if (enabling) {
yield* Effect.forEach(
targets,
(target) =>
target.id === sessionID ? Effect.void : inheritSnapshot(target.directory, next, target.id),
{ discard: true },
const update = refreshing(
sessionID,
Effect.gen(function* () {
yield* typeof guard === "function" ? guard(enabling, targets) : guard
const next: Snapshot = { ...base, enabled: enabling, version: status.version + 1 }
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
const id = key(directory, sessionID)
snapshots.set(id, next)
synced.set(id, version)
if (enabling) {
yield* Effect.forEach(
targets,
(target) =>
target.id === sessionID
? Effect.void
: refreshing(target.id, inheritSnapshot(target.directory, next, target.id)),
{ discard: true },
)
}
// The per-session SandboxStore is the authoritative state; the per-directory
// preference only seeds future sessions. A preference write failure must not
// fail the toggle or desync the in-memory cache from the persisted snapshot.
yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe(
Effect.catch(() => Effect.void),
)
}
// The per-session SandboxStore is the authoritative state; the per-directory
// preference only seeds future sessions. A preference write failure must not
// fail the toggle or desync the in-memory cache from the persisted snapshot.
yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe(
Effect.catch(() => Effect.void),
)
const value = { ...status, enabled: next.enabled && support.available, version: next.version }
// Publish through the standalone Bus facade so HTTP handlers do not need Bus.Service.
yield* Effect.promise(() => Bus.publish(Instance.current, Changed, { sessionID, ...value }))
return value
})
const value = { ...status, enabled: next.enabled && support.available, version: next.version }
// Publish through the standalone Bus facade so HTTP handlers do not need Bus.Service.
yield* Effect.promise(() => Bus.publish(Instance.current, Changed, { sessionID, ...value }))
return value
}),
)
if (enabling) {
const children = sessions.filter((id) => id !== sessionID)
return yield* lockedAll(
@@ -339,6 +453,7 @@ const inheritSnapshot = Effect.fn("SandboxPolicy.inheritSnapshot")(function* (
directory: string,
parent: Snapshot,
sessionID: SessionID,
version = revision,
) {
const child = yield* read(directory, sessionID)
const next: Snapshot = child
@@ -357,7 +472,10 @@ const inheritSnapshot = Effect.fn("SandboxPolicy.inheritSnapshot")(function* (
)
return
yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next))
snapshots.set(key(directory, sessionID), next)
const id = key(directory, sessionID)
snapshots.set(id, next)
synced.set(id, version)
yield* Effect.sync(() => changed(sessionID, directory, next))
})
export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
@@ -368,7 +486,7 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
) {
const directory = yield* InstanceState.directory
const source = sourceDirectory ?? directory
yield* locked(
yield* refreshing(
parentID,
Effect.gen(function* () {
const stored = yield* read(source, parentID)
@@ -377,9 +495,9 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* (
// Only persist the parent snapshot when it actually belongs to this directory. A fallback
// carries confinement from another directory (e.g. forking into a worktree) and must not be
// written back under the parent's key here, or it leaks a phantom parent record.
yield* locked(
yield* refreshing(
sessionID,
inheritSnapshot(directory, parent, sessionID),
inheritSnapshot(directory, parent, sessionID, synced.get(key(source, parentID)) ?? -1),
)
}),
)
@@ -404,10 +522,17 @@ export function retire<A, E, R>(
return locked(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.remove(directory, sessionID))
snapshots.delete(key(directory, sessionID))
return result
return yield* refreshing(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.remove(directory, sessionID))
const id = key(directory, sessionID)
snapshots.delete(id)
synced.delete(id)
return result
}),
)
}),
)
}
@@ -416,13 +541,20 @@ export function dispose<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A,
return locked(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.dispose(sessionID))
const suffix = "\0" + sessionID
for (const id of snapshots.keys()) {
if (id.endsWith(suffix)) snapshots.delete(id)
}
return result
return yield* refreshing(
sessionID,
Effect.gen(function* () {
const result = yield* effect
yield* Effect.promise(() => SandboxStore.dispose(sessionID))
const suffix = "\0" + sessionID
for (const id of snapshots.keys()) {
if (!id.endsWith(suffix)) continue
snapshots.delete(id)
synced.delete(id)
}
return result
}),
)
}),
)
}
@@ -436,20 +568,18 @@ function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
sessionID,
1,
Effect.gen(function* () {
const current = yield* snapshot(sessionID)
if (!current.state.enabled) return yield* unrestricted(effect)
const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts })
const active = yield* current(sessionID, true)
if (!active.state.enabled) return yield* unrestricted(effect)
const support = backendSupport({ mode: active.state.mode, allowedHosts: active.state.allowedHosts })
if (!support.available) {
return yield* Effect.fail(
new Error(support.reason ?? "The configured sandbox backend is unavailable"),
)
return yield* Effect.fail(new Error(support.reason ?? "The configured sandbox backend is unavailable"))
}
return yield* runSandbox(
profile(
yield* InstanceState.context,
current.state.mode,
current.state.writablePaths,
current.state.allowedHosts,
active.state.mode,
active.state.writablePaths,
active.state.allowedHosts,
),
effect,
)
@@ -75,10 +75,10 @@ export const clear = Effect.fn("SandboxState.clear")(function* (sessionID: Sessi
.transaction((tx) =>
Effect.gen(function* () {
const row = yield* tx
.select({ metadata: SessionTable.metadata })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.select({ metadata: SessionTable.metadata })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
if (!row) return
yield* tx
.update(SessionTable)
@@ -7,7 +7,7 @@ import type { Profile } from "@kilocode/sandbox"
import type { SessionID } from "@/session/schema"
export namespace SandboxStore {
/** Session confinement authority captured independently from later configuration reloads. */
/** Persisted session confinement authority, refreshed from trusted settings between tool executions. */
export type Snapshot = {
enabled: boolean
mode: Profile["network"]["mode"]
@@ -42,7 +42,8 @@ export namespace SandboxStore {
if (state.allowedHosts !== undefined && !Array.isArray(state.allowedHosts)) return false
if (state.writablePaths !== undefined && !Array.isArray(state.writablePaths)) return false
if (Array.isArray(state.allowedHosts) && state.allowedHosts.some((value) => typeof value !== "string")) return false
if (Array.isArray(state.writablePaths) && state.writablePaths.some((value) => typeof value !== "string")) return false
if (Array.isArray(state.writablePaths) && state.writablePaths.some((value) => typeof value !== "string"))
return false
if (state.mode === "proxy" && (!Array.isArray(state.allowedHosts) || state.allowedHosts.length === 0)) return false
return true
}
@@ -1,5 +1,6 @@
import { Account } from "@/account/account"
import { Auth } from "@/auth"
import { GlobalBus } from "@/bus/global"
import { Config } from "@/config/config"
import * as InstanceState from "@/effect/instance-state"
import { KilocodeConfigOverlay } from "@/kilocode/config/overlay"
@@ -10,6 +11,7 @@ import { ConfigRules } from "@/kilocode/server/routes/config-rules"
import { KilocodeKeybinds } from "@/kilocode/tui/keybinds"
import { KilocodeTuiConfig } from "@/kilocode/tui/config"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { Event } from "@/server/event"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { markInstanceForDisposal } from "@/server/routes/instance/httpapi/lifecycle"
import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors"
@@ -106,8 +108,30 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
const hot = body.scope === "global" && Object.keys(patch).every((key) => key === "console")
if (body.scope === "global") {
yield* config.invalidate()
if (result.changed) {
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.ConfigUpdated.type,
properties: { sandbox: result.sandboxChanged },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
}
} else {
yield* config.update({})
if (result.sandboxChanged) {
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: instance.directory,
payload: {
type: Event.ConfigUpdated.type,
properties: { sandbox: true },
},
}),
).pipe(Effect.catchCause(() => Effect.void))
}
yield* markInstanceForDisposal(instance)
}
const all = yield* auth.all().pipe(Effect.orElseSucceed(() => ({})))
@@ -140,7 +164,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
sources: sources.sources,
}),
)
if (body.scope === "global" && !hot) {
if (body.scope === "global" && result.changed && !hot) {
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe(
Effect.catchCause(() => Effect.void),
)
+3
View File
@@ -1,5 +1,6 @@
import type { ChildProcessWithoutNullStreams } from "child_process"
import { Process } from "@/util/process"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
type Child = Process.Child & ChildProcessWithoutNullStreams
@@ -10,6 +11,8 @@ export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts
const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts
const proc = Process.spawn([cmd, ...args], {
...cfg,
env: modelEnv(cfg?.env), // kilocode_change - language servers must not inherit backend credentials
extendEnv: false, // kilocode_change
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
+5 -3
View File
@@ -39,6 +39,7 @@ import { TuiEvent } from "@/server/tui-event"
import open from "open"
import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -367,11 +368,12 @@ const layer = Layer.effect(
command: cmd,
args: finalArgs, // kilocode_change
cwd,
env: {
...process.env,
// kilocode_change start - local MCPs must not inherit backend credentials
env: modelEnv({
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...mcp.environment,
},
}),
// kilocode_change end
})
// kilocode_change start - a piped stderr stream must be consumed or verbose MCP servers can block
transport.stderr?.on("data", (chunk: Buffer) => {
+2 -4
View File
@@ -12,6 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { model as modelEnv } from "@/kilocode/process/env" // kilocode_change
import { Shell } from "@opencode-ai/core/shell"
import { ShellID } from "./shell/id"
@@ -522,10 +523,7 @@ export const ShellTool = Tool.define(
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
)
return {
...process.env,
...extra.env,
}
return modelEnv(extra.env) // kilocode_change - model shells must not inherit backend credentials
})
const run = Effect.fn("ShellTool.run")(function* (
+3 -1
View File
@@ -10,6 +10,7 @@ export type Shell = boolean | string
export interface Options {
cwd?: string
env?: NodeJS.ProcessEnv | null
extendEnv?: boolean // kilocode_change - allow a complete sanitized environment
stdin?: Stdio
stdout?: Stdio
stderr?: Stdio
@@ -63,7 +64,7 @@ export function spawn(cmd: string[], opts: Options = {}): Child {
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
env: opts.env === null ? {} : opts.env ? (opts.extendEnv === false ? opts.env : { ...process.env, ...opts.env }) : undefined, // kilocode_change
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
windowsHide: process.platform === "win32",
})
@@ -115,6 +116,7 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result>
const proc = spawn(cmd, {
cwd: opts.cwd,
env: opts.env,
extendEnv: opts.extendEnv, // kilocode_change
stdin: opts.stdin,
shell: opts.shell,
abort: opts.abort,
@@ -12,6 +12,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { HttpClient } from "effect/unstable/http"
import { Account } from "../../../src/account/account"
import { Auth } from "../../../src/auth"
import { GlobalBus } from "../../../src/bus/global"
import { Config } from "../../../src/config/config"
import { ConfigMarkdown } from "../../../src/config/markdown"
import { ConfigParse } from "../../../src/config/parse"
@@ -110,6 +111,45 @@ describe("markdown substitutions", () => {
})
describe("global config updates", () => {
test("marks only sandbox updates for live policy refresh", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir()
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
const events: Array<{ payload?: { type?: string; properties?: { sandbox?: boolean } } }> = []
const listener = (event: (typeof events)[number]) => events.push(event)
GlobalBus.on("event", listener)
try {
await provideTestInstance({
directory: tmp.path,
fn: async () => {
await Effect.runPromise(
Config.Service.use((svc) =>
Effect.all([
svc.updateGlobal({ permission: { edit: "ask" } }, { dispose: false }),
svc.updateGlobal({ sandbox: { network: "deny" } }, { dispose: false }),
]),
).pipe(Effect.scoped, Effect.provide(layer)),
)
},
})
expect(
events
.filter((event) => event.payload?.type === "global.config.updated")
.map((event) => event.payload?.properties?.sandbox),
).toEqual([false, true])
} finally {
GlobalBus.off("event", listener)
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("preserves concurrent permission updates", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir()
@@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Database } from "@opencode-ai/core/database/database"
import { assertNetwork, assertWrite, enabled as sandboxed } from "@kilocode/sandbox"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Config } from "@/config/config"
import * as Network from "@/kilocode/sandbox/network"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
@@ -34,7 +35,7 @@ function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>)
return SandboxPolicy.executeTool(sessionID, tool, effect)
}
test("restores the session snapshot after a backend restart", async () => {
test("refreshes the session snapshot after a backend restart", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-sandbox-restart-"))
const directory = path.join(root, "project")
await fs.mkdir(directory)
@@ -98,7 +99,13 @@ test("restores the session snapshot after a backend restart", async () => {
const restored = run({
sandbox: { enabled: false, network: "deny", allowed_hosts: ["evil.example"], writable_paths: ["/tmp/evil"] },
})
expect(restored.state).toEqual(initial.state)
expect(restored.state).toEqual({
enabled: true,
mode: "proxy",
allowedHosts: ["evil.example:443"],
writablePaths: ["/tmp/evil"],
version: 1,
})
expect(restored.status.enabled).toBe(restored.status.available)
} finally {
await fs.rm(root, { recursive: true, force: true })
@@ -178,7 +185,7 @@ linux("reports configured network namespace availability", async () => {
}
})
it.instance("snapshots the primary kilo config for the session lifetime", () =>
it.instance("does not let project config weaken an initialized policy", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const password = Flag.KILO_SERVER_PASSWORD
@@ -208,6 +215,7 @@ it.instance("snapshots the primary kilo config for the session lifetime", () =>
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
expect(yield* execute(id, sandboxed)).toBe(true)
expect(Exit.isFailure(yield* execute(id, assertNetwork("https://example.com").pipe(Effect.exit)))).toBe(true)
expect(yield* SandboxPolicy.peek(test.directory, id)).toMatchObject({ mode: "deny", version: 0 })
const next = SessionID.make("ses_sandbox_config_next")
expect((yield* SandboxPolicy.status(next)).enabled).toBe(false)
@@ -265,6 +273,182 @@ it.instance("applies configured writable paths during tool execution", () =>
}),
)
it.instance("refreshes an initialized policy from current settings", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const id = SessionID.make("ses_sandbox_refresh")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, id, {
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
yield* SandboxPolicy.peek(test.directory, id)
const changed = yield* SandboxPolicy.refresh(id).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () =>
Effect.succeed({
sandbox: { network: "allow", writable_paths: ["~/sandbox-refresh"] },
}),
}),
),
)
expect(changed).toBe(true)
expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({
enabled: false,
mode: "allow",
allowedHosts: [],
writablePaths: [path.join(os.homedir(), "sandbox-refresh")],
version: 1,
})
}),
)
it.instance("uses current settings when enabling an initialized policy", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const id = SessionID.make("ses_sandbox_enable_refresh")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, id, {
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
const status = yield* SandboxPolicy.toggle(id).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () =>
Effect.succeed({
sandbox: { enabled: true, network: "allow", writable_paths: ["/sandbox-enable-refresh"] },
}),
}),
),
)
if (!status.available) {
expect(status.enabled).toBe(false)
expect(status.version).toBe(0)
expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
})
return
}
expect(status.enabled).toBe(true)
expect(status.version).toBe(1)
expect(yield* SandboxPolicy.peek(test.directory, id)).toEqual({
enabled: true,
mode: "allow",
allowedHosts: [],
writablePaths: ["/sandbox-enable-refresh"],
version: 1,
})
}),
)
it.instance("applies trusted settings to inherited sessions", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const parent = SessionID.make("ses_sandbox_refresh_parent")
const child = SessionID.make("ses_sandbox_refresh_child")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, parent, {
enabled: true,
mode: "deny",
allowedHosts: [],
writablePaths: ["/shared"],
version: 0,
}),
)
yield* Effect.promise(() =>
SandboxStore.write(test.directory, child, {
enabled: false,
mode: "deny",
allowedHosts: [],
writablePaths: ["/shared"],
version: 0,
}),
)
yield* SandboxPolicy.peek(test.directory, parent)
yield* SandboxPolicy.peek(test.directory, child)
const config = Layer.mock(Config.Service, {
get: () =>
Effect.succeed({
sandbox: { network: "allow", writable_paths: ["/shared", "/new"] },
}),
})
yield* SandboxPolicy.refresh(parent).pipe(Effect.provide(config))
yield* SandboxPolicy.refresh(child).pipe(Effect.provide(config))
expect(yield* SandboxPolicy.peek(test.directory, parent)).toMatchObject({
enabled: true,
mode: "allow",
writablePaths: ["/shared", "/new"],
})
expect(yield* SandboxPolicy.peek(test.directory, child)).toEqual({
enabled: false,
mode: "allow",
allowedHosts: [],
writablePaths: ["/shared", "/new"],
version: 1,
})
}),
)
it.instance("emits a sandbox status event after refreshing policy", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const id = SessionID.make("ses_sandbox_refresh_event")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, id, {
enabled: true,
mode: "deny",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
const events: Array<{ directory?: string; payload: { type?: string; properties?: { sessionID?: string } } }> = []
const listener = (event: (typeof events)[number]) => events.push(event)
GlobalBus.on("event", listener)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))
yield* SandboxPolicy.refresh(id).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () => Effect.succeed({ sandbox: { network: "allow" } }),
}),
),
)
expect(events).toContainEqual(
expect.objectContaining({
directory: test.directory,
payload: expect.objectContaining({
id: expect.any(String),
type: "sandbox.status.changed",
properties: expect.objectContaining({ sessionID: id }),
}),
}),
)
}),
)
it.instance(
"runs sandboxed when config is on and no override exists",
() =>
@@ -412,6 +596,45 @@ it.instance("serializes activation with unrestricted tool start", () =>
}),
)
it.instance("refreshes queued tools after config changes", () =>
(() => {
const config = { sandbox: { enabled: true, network: "allow" as "allow" | "deny" } }
return Effect.gen(function* () {
const id = SessionID.make("ses_sandbox_queued_refresh")
if (!(yield* SandboxPolicy.status(id)).available) return
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const running = yield* execute(
id,
Effect.gen(function* () {
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
return false
}),
).pipe(Effect.forkChild)
yield* Deferred.await(entered)
const queued = yield* execute(id, assertNetwork("https://example.com").pipe(Effect.exit)).pipe(Effect.forkChild)
config.sandbox.network = "deny"
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.config.updated", properties: { sandbox: true } },
})
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(running)
expect(Exit.isFailure(yield* Fiber.join(queued))).toBe(true)
expect(yield* SandboxPolicy.peek((yield* TestInstance).directory, id)).toMatchObject({ mode: "deny" })
}).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () => Effect.succeed(config),
}),
),
)
})(),
)
it.instance("prevents a queued toggle from restoring a retired override", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -501,6 +724,56 @@ it.instance("intersects inherited network and write authority", () =>
}),
)
it.instance("refreshes a child inherited while its parent policy is stale", () =>
(() => {
const config = { sandbox: { enabled: true, network: "allow" as "allow" | "deny" } }
return Effect.gen(function* () {
const parent = SessionID.make("ses_sandbox_stale_parent")
const child = SessionID.make("ses_sandbox_stale_child")
yield* SandboxPolicy.status(parent)
config.sandbox.network = "deny"
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.config.updated", properties: { sandbox: true } },
})
yield* SandboxPolicy.inherit(parent, child)
yield* SandboxPolicy.status(child)
expect(yield* SandboxPolicy.peek((yield* TestInstance).directory, child)).toMatchObject({ mode: "deny" })
}).pipe(
Effect.provide(
Layer.mock(Config.Service, {
get: () => Effect.succeed(config),
}),
),
)
})(),
)
it.instance("refreshes a cold child inherited from an untracked stored parent", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const parent = SessionID.make("ses_sandbox_cold_parent")
const child = SessionID.make("ses_sandbox_cold_child")
yield* Effect.promise(() =>
SandboxStore.write(test.directory, parent, {
enabled: true,
mode: "allow",
allowedHosts: [],
writablePaths: [],
version: 0,
}),
)
yield* SandboxPolicy.inherit(parent, child)
yield* SandboxPolicy.status(child)
expect(yield* SandboxPolicy.peek(test.directory, child)).toMatchObject({ mode: "deny" })
}),
{ config: { sandbox: { enabled: true, network: "deny" } } },
)
it.instance("enforces writes only while the macOS session override is active", () =>
Effect.gen(function* () {
if (process.platform !== "darwin") return
@@ -10,6 +10,9 @@ import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay"
import { KilocodeConfigWriter } from "../../../src/kilocode/config/writer"
import { Permission } from "../../../src/permission"
import { PtyPaths } from "../../../src/server/routes/instance/httpapi/groups/pty"
import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session"
import { SandboxStore } from "../../../src/kilocode/sandbox/store"
import type { Session } from "../../../src/session/session"
import { Filesystem } from "../../../src/util/filesystem"
import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -673,30 +676,28 @@ describe("config overlay routes", () => {
expect(saved.mcp).toEqual({ shared: { enabled: false } })
})
test.serial(
"refreshes effective config after project permission update",
async () => {
await using global = await tmpdir()
await using project = await tmpdir()
await setGlobal(global.path, { permission: { edit: "allow" } })
test.serial("refreshes effective config after project permission update", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
await setGlobal(global.path, { permission: { edit: "allow" } })
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(
Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("allow")
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"allow",
)
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=project"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=project"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask")
expect(
@@ -738,6 +739,121 @@ describe("config overlay routes", () => {
)
})
test.serial(
"applies saved global sandbox settings to initialized sessions",
async () => {
await using global = await tmpdir()
await using project = await tmpdir({ git: true })
await using writable = await tmpdir()
await setGlobal(global.path, { sandbox: { enabled: true, network: "deny" } })
const session = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
await json(await req(project.path, `/session/${session.id}/sandbox`))
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "deny", version: 0 })
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scope: "global",
set: { sandbox: { enabled: true, network: "allow", writable_paths: [writable.path] } },
}),
}),
)
// The global update disposes instances asynchronously. Poll the sandbox status
// until the reloaded instance applies the saved policy, mirroring how the
// extension re-checks status after saving settings.
for (let i = 0; i < 40; i++) {
await json(await req(project.path, `/session/${session.id}/sandbox`))
const snap = await SandboxStore.read(project.path, session.id)
if (snap && snap.mode === "allow" && snap.version === 1) break
await Bun.sleep(250)
}
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({
enabled: true,
mode: "allow",
writablePaths: [writable.path],
version: 1,
})
},
20_000,
)
test.serial("applies saved project sandbox settings to initialized sessions", async () => {
await using global = await tmpdir()
await using project = await tmpdir({ git: true })
await setGlobal(global.path, { sandbox: { enabled: true, network: "allow" } })
const session = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
await json(await req(project.path, `/session/${session.id}/sandbox`))
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "allow", version: 0 })
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { sandbox: { enabled: true, network: "deny" } } }),
}),
)
await json(await req(project.path, `/session/${session.id}/sandbox`))
expect(await SandboxStore.read(project.path, session.id)).toMatchObject({ mode: "deny", version: 1 })
})
test.serial("does not relax inherited sandbox policy after unrelated global saves", async () => {
await using global = await tmpdir()
await using project = await tmpdir({ git: true })
await setGlobal(global.path, { sandbox: { enabled: true, network: "deny" } })
const parent = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}),
)
await json(await req(project.path, `/session/${parent.id}/sandbox`))
const child = await json<Session.Info>(
await req(project.path, SessionPaths.create, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parentID: parent.id }),
}),
)
await json(await req(project.path, `/session/${child.id}/sandbox`))
expect(await SandboxStore.read(project.path, child.id)).toMatchObject({ mode: "deny" })
// Simulate config changing while the backend is unaware. The unrelated save below
// must not treat that wider policy as a trusted sandbox settings update.
await Bun.write(
path.join(global.path, "kilo.json"),
JSON.stringify({ sandbox: { enabled: true, network: "allow" } }, null, 2),
)
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "global", set: { permission: { edit: "ask" } } }),
}),
)
await json(await req(project.path, `/session/${child.id}/sandbox`))
expect(await SandboxStore.read(project.path, child.id)).toMatchObject({ mode: "deny" })
})
terminal("preserves active terminals after updating global console preferences", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
@@ -0,0 +1,98 @@
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Plugin } from "@/plugin"
import { MessageID, SessionID } from "@/session/schema"
import { ShellTool } from "@/tool/shell"
import { Truncate } from "@/tool/truncate"
import type { Tool } from "@/tool/tool"
import { InstanceStore } from "@/project/instance-store"
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const layer = Layer.mergeAll(
AppNodeBuilder.build(CrossSpawnSpawner.node),
AppNodeBuilder.build(FSUtil.node),
AppNodeBuilder.build(Plugin.node),
AppNodeBuilder.build(Truncate.node),
AppNodeBuilder.build(Config.node),
AppNodeBuilder.build(Agent.node),
AppNodeBuilder.build(RuntimeFlags.node),
testInstanceStoreLayer,
)
const it = testEffect(layer)
type Services =
| (typeof layer extends Layer.Layer<infer ROut, infer _E, infer _RIn> ? ROut : never)
| InstanceStore.Service
| Scope.Scope
const ctx = {
sessionID: SessionID.make("ses_shell_env"),
messageID: MessageID.make("msg_shell_env"),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const run = Effect.fn("ShellEnvTest.run")(function* (args: Tool.InferParameters<typeof ShellTool>) {
const info = yield* ShellTool
const tool = yield* info.init()
return yield* tool.execute(args, ctx)
})
it.effect("does not expose backend credentials or config to model shell commands", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const values = {
password: process.env.KILO_SERVER_PASSWORD,
username: process.env.KILO_SERVER_USERNAME,
config: process.env.KILO_CONFIG,
content: process.env.KILO_CONFIG_CONTENT,
directory: process.env.KILO_CONFIG_DIR,
}
process.env.KILO_SERVER_PASSWORD = "secret"
process.env.KILO_SERVER_USERNAME = "kilo"
process.env.KILO_CONFIG = "/secret/config.json"
process.env.KILO_CONFIG_CONTENT = '{"provider":{"apiKey":"secret"}}'
process.env.KILO_CONFIG_DIR = "/secret/config"
return values
}),
() =>
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
provideInstance(tmp)(
run({
command:
process.platform === "win32"
? "if ($env:KILO_SERVER_PASSWORD -or $env:KILO_SERVER_USERNAME -or $env:KILO_CONFIG -or $env:KILO_CONFIG_CONTENT -or $env:KILO_CONFIG_DIR) { 'set' } else { 'unset' }"
: 'test -z "$KILO_SERVER_PASSWORD" && test -z "$KILO_SERVER_USERNAME" && test -z "$KILO_CONFIG" && test -z "$KILO_CONFIG_CONTENT" && test -z "$KILO_CONFIG_DIR" && printf unset',
description: "Check backend credential isolation",
}),
),
),
Effect.map((result) => expect(result.output.trim()).toBe("unset")),
) as Effect.Effect<void, never, Services>,
(values) =>
Effect.sync(() => {
if (values.password === undefined) delete process.env.KILO_SERVER_PASSWORD
else process.env.KILO_SERVER_PASSWORD = values.password
if (values.username === undefined) delete process.env.KILO_SERVER_USERNAME
else process.env.KILO_SERVER_USERNAME = values.username
if (values.config === undefined) delete process.env.KILO_CONFIG
else process.env.KILO_CONFIG = values.config
if (values.content === undefined) delete process.env.KILO_CONFIG_CONTENT
else process.env.KILO_CONFIG_CONTENT = values.content
if (values.directory === undefined) delete process.env.KILO_CONFIG_DIR
else process.env.KILO_CONFIG_DIR = values.directory
}),
),
)
+31
View File
@@ -5,6 +5,37 @@ import { spawn } from "../../src/lsp/launch"
import { tmpdir } from "../fixture/fixture"
describe("lsp.launch", () => {
// kilocode_change start
test("does not expose backend credentials or config", async () => {
const keys = [
"KILO_SERVER_PASSWORD",
"KILO_SERVER_USERNAME",
"KILO_CONFIG",
"KILO_CONFIG_CONTENT",
"KILO_CONFIG_DIR",
] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
for (const key of keys) process.env[key] = "secret"
try {
const proc = spawn(process.execPath, ["-e", `console.log(${JSON.stringify(keys)}.some((key) => process.env[key]))`])
const output = await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = []
proc.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
proc.on("error", reject)
proc.on("close", () => resolve(Buffer.concat(chunks).toString().trim()))
})
expect(output).toBe("false")
} finally {
for (const key of keys) {
const value = saved[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
})
// kilocode_change end
test("spawns cmd scripts with spaces on Windows", async () => {
if (process.platform !== "win32") return
@@ -45,9 +45,25 @@ type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
const appCache: Partial<Record<string, CachedApp>> = {}
export async function disposeApps() {
const apps = Object.values(appCache)
// kilocode_change start - an in-flight SSE fiber can leave the in-process router scope unable
// to close; bound disposal so a completed scenario run cannot wedge the exerciser or CI
const apps = Object.entries(appCache)
for (const key of Object.keys(appCache)) delete appCache[key]
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
await Promise.all(
apps.flatMap(([key, app]) =>
app === undefined
? []
: [
Promise.race([
app.dispose(),
Bun.sleep(3_000).then(() => {
console.error(`httpapi-exercise: router dispose did not settle for ${JSON.stringify(key)} after 3s`)
}),
]),
],
),
)
// kilocode_change end
}
function app(modules: Runtime, options: CallOptions) {
@@ -77,6 +77,25 @@ describe("util.process", () => {
expect(out.stdout.toString()).toBe("set")
})
// kilocode_change start
test("can use a complete environment without inherited values", async () => {
const key = "KILO_TEST_INHERITED_ENV"
const saved = process.env[key]
process.env[key] = "secret"
try {
const out = await Process.run(node(`process.stdout.write(process.env.${key} ?? "unset")`), {
env: { PATH: process.env.PATH },
extendEnv: false,
})
expect(out.stdout.toString()).toBe("unset")
} finally {
if (saved === undefined) delete process.env[key]
else process.env[key] = saved
}
})
// kilocode_change end
test("uses shell in run on Windows", async () => {
if (process.platform !== "win32") return