mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge branch 'main' into fix/disable-suggest-in-non-interactive-runs
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/kilo-ui": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix flickering and sticky scrolling when scrolling up in Agent Manager and chat sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the permission approval reason for reads and writes outside the workspace, matching other tools
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Surface process exit signals and structured spawn failure details in server startup diagnostics
|
||||
@@ -110,7 +110,19 @@ Before using Kilo for Slack:
|
||||
|
||||
## Setup
|
||||
|
||||
To install Kilo for Slack, go to the Integrations menu in the sidebar at [app.kilo.ai](https://app.kilo.ai) and set up the Slack integration.
|
||||
To install Kilo for Slack, go to the Integrations menu in the sidebar at [app.kilo.ai](https://app.kilo.ai) and set up the Slack integration:
|
||||
|
||||
{% image src="/docs/img/connect/slack/slackbot-integrations.webp" alt="Kilo Integrations page where you install the Slack integration" width="800" /%}
|
||||
|
||||
Then hit the Configure button, which will take you to this page:
|
||||
{% image src="/docs/img/connect/slack/slackbot-integrations-2.webp" alt="Kilo Integrations page where you install the Slack integration, step 2" width="800" /%}
|
||||
|
||||
Once you press "Connect with Slack", the Slack OAuth authorization page appears:
|
||||
{% image src="/docs/img/connect/slack/slackbot-slack-authorize.webp" alt="Slack OAuth authorization page" width="800" /%}
|
||||
|
||||
Once this is completed you're good to go!
|
||||
|
||||
To make proper use of all of the Kilo Slack bot's capabilities, it's important that you also connect your source control provider with Kilo:
|
||||
|
||||
| Platform | Integration Type | Details |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+2786
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -508,8 +508,8 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
|
||||
}
|
||||
|
||||
/* "why was this allowed" line inside a tool's expanded body. Styled like
|
||||
[data-component="tool-hint"] (muted, italic) so it reads as ambient
|
||||
context rather than a call to action, and recedes the way reasoning text does. */
|
||||
[data-component="tool-hint"] (muted) so it reads as ambient context rather
|
||||
than a call to action, and recedes the way reasoning text does. */
|
||||
[data-slot="tool-approval-line"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -518,7 +518,6 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
|
||||
padding: 4px 0 6px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: italic;
|
||||
line-height: var(--line-height-normal);
|
||||
color: var(--text-weak);
|
||||
opacity: 0.9;
|
||||
@@ -528,6 +527,11 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="tool-approval-decision"] {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
[data-slot="tool-approval-rule"] {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { ToolApprovalProvider, resolveToolApproval } from "./tool-approval"
|
||||
import { ToolApprovalProvider, resolveToolApproval, useToolApproval } from "./tool-approval"
|
||||
export { ToolApprovalProvider, resolveToolApproval, ToolApprovalVisibilityProvider } from "./tool-approval"
|
||||
import { GrowBox } from "./grow-box"
|
||||
import { COLLAPSIBLE_SPRING } from "./motion"
|
||||
@@ -1888,10 +1888,13 @@ ToolRegistry.register({
|
||||
const pending = createMemo(() => busy(props.status))
|
||||
const images = createMemo(() => (props.attachments ?? []).filter((f) => f.mime.startsWith("image/") && f.url))
|
||||
const preview = (url: string, alt?: string) => dialog.show(() => <ImagePreview src={url} alt={alt} />)
|
||||
// Read is high-frequency and low-risk, so details stay hidden unless the target was outside
|
||||
// the workspace, in which case the approval reason explains what looks like an "agent escape".
|
||||
const approval = useToolApproval()
|
||||
return (
|
||||
<>
|
||||
<BasicTool
|
||||
hideDetails
|
||||
hideDetails={!approval()?.approval.outsideWorkspace}
|
||||
{...props}
|
||||
icon="glasses"
|
||||
onSubtitleClick={
|
||||
|
||||
@@ -54,4 +54,28 @@ describe("resolveToolApproval", () => {
|
||||
expect(out?.source).toBe("ui.approval.source.agent(agent=code)")
|
||||
expect(out?.rule).toBeUndefined()
|
||||
})
|
||||
|
||||
test("adds the outsideWorkspace text with just the filename when a path is known", () => {
|
||||
const approval = {
|
||||
source: "agent" as const,
|
||||
agent: "code",
|
||||
outsideWorkspace: true,
|
||||
outsideWorkspacePath: "/etc/secrets/hello.txt",
|
||||
}
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.outsideWorkspace).toBe("ui.approval.outsideWorkspace(file=hello.txt)")
|
||||
})
|
||||
|
||||
test("omits the outsideWorkspace text for an ordinary in-workspace approval", () => {
|
||||
const approval = { source: "agent" as const, agent: "code" }
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.outsideWorkspace).toBeUndefined()
|
||||
})
|
||||
|
||||
test("omits the outsideWorkspace text when outsideWorkspace is set but no path is known", () => {
|
||||
// e.g. a bash command scanning multiple external directories has no single filepath to show.
|
||||
const approval = { source: "agent" as const, agent: "code", outsideWorkspace: true }
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.outsideWorkspace).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useContext, Show, type Accessor, type ParentProps } from "solid-js"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,10 @@ export type ToolApproval = {
|
||||
source: "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default"
|
||||
agent?: string
|
||||
rule?: { permission: string; pattern: string; action: string }
|
||||
/** True when the tool call's target path was outside the workspace/worktree. */
|
||||
outsideWorkspace?: boolean
|
||||
/** The target file path, when known, for display as a filename next to the note above. */
|
||||
outsideWorkspacePath?: string
|
||||
}
|
||||
|
||||
/** Pre-resolved, localized text plus the raw approval, supplied by the caller. */
|
||||
@@ -20,6 +25,7 @@ export type ToolApprovalDisplay = {
|
||||
decision: string
|
||||
source?: string
|
||||
rule?: string
|
||||
outsideWorkspace?: string
|
||||
}
|
||||
|
||||
const SOURCE_KEYS = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const
|
||||
@@ -82,11 +88,16 @@ export function resolveToolApproval(
|
||||
rule && !(rule.permission === "*" && rule.pattern === "*")
|
||||
? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern })
|
||||
: undefined
|
||||
// Only worth calling out when we know which file it was; a bare "outside your workspace" note
|
||||
// without a filename (e.g. a bash command touching several directories) isn't actionable.
|
||||
const filename = approval.outsideWorkspacePath ? getFilename(approval.outsideWorkspacePath) : undefined
|
||||
return {
|
||||
approval,
|
||||
decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"),
|
||||
source: sourceText(),
|
||||
rule: ruleText,
|
||||
outsideWorkspace:
|
||||
approval.outsideWorkspace && filename ? t("ui.approval.outsideWorkspace", { file: filename }) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +112,9 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
|
||||
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
|
||||
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
|
||||
</Show>
|
||||
<Show when={props.display.outsideWorkspace}>
|
||||
{(text) => <span data-slot="tool-approval-outside-workspace">{text()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,4 +216,50 @@ describe("createAutoScroll non-scrollable layouts", () => {
|
||||
expect(ctx.el.scrollTop).toBe(300)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("does not snap to bottom on content resize after user scrolls up while idle", () => {
|
||||
const ctx = setup({ working: false })
|
||||
ctx.el.scrollHeight = 1000
|
||||
ctx.el.clientHeight = 200
|
||||
ctx.el.scrollTop = 800 // at bottom
|
||||
|
||||
// User wheels up
|
||||
const event = new FakeWheelEvent(-50, ctx.el)
|
||||
ctx.el.fire("wheel", event as unknown as Event)
|
||||
ctx.el.scrollTop = 750
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
|
||||
// Virtual list re-measures / resizes content
|
||||
ctx.el.scrollHeight = 1100
|
||||
ctx.resize()
|
||||
|
||||
// Must NOT snap to bottom (1100), must remain at user scroll position (750)
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(750)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("does not snap to bottom when dragging scrollbar up while idle", () => {
|
||||
const ctx = setup({ working: false })
|
||||
ctx.el.scrollHeight = 1000
|
||||
ctx.el.clientHeight = 200
|
||||
ctx.el.scrollTop = 800 // at bottom
|
||||
|
||||
// User presses pointerdown on scrollbar and drags up
|
||||
ctx.el.fire("pointerdown", new Event("pointerdown"))
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
|
||||
// Content resize during drag
|
||||
ctx.el.scrollHeight = 1050
|
||||
ctx.resize()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
ctx.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { canScroll, distanceFromBottom } from "./auto-scroll"
|
||||
import { createUserActivity } from "./scroll-user-activity"
|
||||
|
||||
const DEBOUNCE_MS = 100
|
||||
// Grace window after a real pointer/key/touch interaction during which a
|
||||
// ResizeObserver or non-user scroll event must not snap the view back to the
|
||||
// bottom. Upward wheel intent pauses immediately in its capture handler.
|
||||
@@ -14,6 +13,7 @@ export interface AutoScrollOptions {
|
||||
working: () => boolean
|
||||
onUserInteracted?: () => void
|
||||
bottomThreshold?: number
|
||||
overflowAnchor?: "none" | "auto" | "dynamic"
|
||||
}
|
||||
|
||||
export function createAutoScroll(options: AutoScrollOptions) {
|
||||
@@ -24,7 +24,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
let scroll: HTMLElement | undefined
|
||||
let settling = false
|
||||
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let stopTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
@@ -100,7 +99,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
const handleScroll = () => {
|
||||
if (!scroll) return
|
||||
|
||||
const input = userActivity.consumeScroll()
|
||||
userActivity.consumeScroll()
|
||||
const distance = distanceFromBottom(scroll)
|
||||
|
||||
if (!canScroll(scroll)) return
|
||||
@@ -110,52 +109,25 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.userScrolled && !input) {
|
||||
// Only explicit user input can pause following. Treat unclassified
|
||||
// scroll events from virtualization or layout changes as programmatic.
|
||||
if (userActivity.isRecent()) {
|
||||
stop()
|
||||
} else {
|
||||
bottom()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce to avoid layout-induced scroll shifts (e.g. images loading,
|
||||
// virtual-list reflows) from incorrectly breaking auto-follow.
|
||||
if (stopTimer) clearTimeout(stopTimer)
|
||||
stopTimer = setTimeout(() => {
|
||||
stopTimer = undefined
|
||||
if (!scroll) return
|
||||
if (distanceFromBottom(scroll) < threshold()) return
|
||||
stop()
|
||||
}, DEBOUNCE_MS)
|
||||
stop()
|
||||
}
|
||||
|
||||
const onContentResize = () => {
|
||||
if (scroll && !canScroll(scroll)) return
|
||||
if (!active()) {
|
||||
if (!store.userScrolled && scroll && distanceFromBottom(scroll) > threshold()) {
|
||||
bottom()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if (store.userScrolled) {
|
||||
return
|
||||
}
|
||||
// Virtualized lists (virtua) re-measure items during user scroll, firing
|
||||
// resize events that race ahead of handleScroll's DEBOUNCE_MS window.
|
||||
// If the user just interacted with the scroller and is no longer near
|
||||
// the bottom, treat the resize as a layout reflow on top of their
|
||||
// scroll — pause auto-follow instead of snapping back to the bottom.
|
||||
if (scroll && userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
|
||||
if (!scroll || !canScroll(scroll)) return
|
||||
if (store.userScrolled) return
|
||||
|
||||
if (userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
// ResizeObserver fires after layout, before paint.
|
||||
// Keep the bottom locked in the same frame to avoid visible
|
||||
// "jump up then catch up" artifacts while streaming content.
|
||||
|
||||
if (!active()) {
|
||||
if (!userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
|
||||
bottom()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
follow()
|
||||
}
|
||||
|
||||
@@ -173,6 +145,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
createResizeObserver(() => store.contentRef, onContentResize)
|
||||
createResizeObserver(() => store.scrollRef, onViewportResize)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => store.userScrolled,
|
||||
() => {
|
||||
if (scroll) updateOverflowAnchor(scroll)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(options.working, (working: boolean) => {
|
||||
settling = false
|
||||
@@ -195,6 +176,19 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const updateOverflowAnchor = (el: HTMLElement) => {
|
||||
const mode = options.overflowAnchor ?? "none"
|
||||
if (mode === "none") {
|
||||
el.style.overflowAnchor = "none"
|
||||
return
|
||||
}
|
||||
if (mode === "auto") {
|
||||
el.style.overflowAnchor = "auto"
|
||||
return
|
||||
}
|
||||
el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
|
||||
}
|
||||
|
||||
const setScroll = (el: HTMLElement | undefined) => {
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
@@ -206,13 +200,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
|
||||
if (!el) return
|
||||
|
||||
el.style.overflowAnchor = "auto"
|
||||
updateOverflowAnchor(el)
|
||||
cleanup = userActivity.listen(el)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (settleTimer) clearTimeout(settleTimer)
|
||||
if (stopTimer) clearTimeout(stopTimer)
|
||||
if (cleanup) cleanup()
|
||||
})
|
||||
|
||||
|
||||
@@ -18,13 +18,16 @@ export const createUserActivity = (options: UserActivityOptions) => {
|
||||
// do not get mistaken for the user leaving auto-follow mode.
|
||||
const mark = (event: Event) => {
|
||||
if (!isPotentialScrollInput(event)) return
|
||||
if (scroll && scroll.scrollHeight - scroll.clientHeight <= 1) return
|
||||
marked = true
|
||||
time = performance.now()
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY >= 0 || !scroll || scroll.scrollTop <= 0) return
|
||||
time = performance.now()
|
||||
if (!isPotentialScrollInput(event)) return
|
||||
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
|
||||
mark(event)
|
||||
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
|
||||
options.onWheelUp()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { watchFontSizeConfig } from "./kilo-provider/font-size"
|
||||
import { mapSSEEventToWebviewMessage } from "./kilo-provider-utils"
|
||||
import { resolvePanelProjectDirectory } from "./project-directory"
|
||||
import { seedSessionStatuses } from "./session-status"
|
||||
import type { KiloConnectionService } from "./services/cli-backend"
|
||||
import { type KiloConnectionService, ServerStartupError } from "./services/cli-backend"
|
||||
import { MarketplaceService } from "./services/marketplace"
|
||||
import {
|
||||
fetchMarketplaceData,
|
||||
@@ -123,7 +123,15 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
)
|
||||
this.subscriptions.push(
|
||||
this.connection.onStateChange((state, err) => {
|
||||
this.post({ type: "connectionState", state, ...(err ? { error: err.message } : {}) })
|
||||
this.post({
|
||||
type: "connectionState",
|
||||
state,
|
||||
...(err ? { error: err.message } : {}),
|
||||
...(err instanceof ServerStartupError && {
|
||||
userMessage: err.userMessage,
|
||||
userDetails: err.userDetails,
|
||||
}),
|
||||
})
|
||||
if (state === "connected") void this.sync(false)
|
||||
}),
|
||||
this.connection.onLanguageChanged((locale) => this.post({ type: "languageChanged", locale })),
|
||||
@@ -155,7 +163,15 @@ export class MarketplacePanelProvider implements vscode.Disposable {
|
||||
await this.connection.connect(this.directory())
|
||||
await this.sync(this.statuses.size === 0)
|
||||
} catch (err) {
|
||||
this.post({ type: "connectionState", state: "error", error: err instanceof Error ? err.message : String(err) })
|
||||
this.post({
|
||||
type: "connectionState",
|
||||
state: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
...(err instanceof ServerStartupError && {
|
||||
userMessage: err.userMessage,
|
||||
userDetails: err.userDetails,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ function slimEdit(state: Record<string, unknown>): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
if (meta.diagnostics) result.diagnostics = meta.diagnostics
|
||||
if (meta.approval) result.approval = meta.approval
|
||||
next.metadata = result
|
||||
return next
|
||||
}
|
||||
@@ -84,6 +85,7 @@ function slimPatch(state: Record<string, unknown>): Record<string, unknown> {
|
||||
if (isObj(meta)) {
|
||||
const slim: Record<string, unknown> = {}
|
||||
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
|
||||
if (meta.approval) slim.approval = meta.approval
|
||||
if (Array.isArray(meta.files)) {
|
||||
slim.files = (meta.files as Record<string, unknown>[]).map((f) => {
|
||||
const diff = patch(f.patch) ?? patch(f.diff)
|
||||
@@ -115,6 +117,7 @@ function slimMultiedit(state: Record<string, unknown>): Record<string, unknown>
|
||||
if (isObj(meta)) {
|
||||
const slim: Record<string, unknown> = {}
|
||||
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
|
||||
if (meta.approval) slim.approval = meta.approval
|
||||
if (Array.isArray(meta.results)) {
|
||||
slim.results = (meta.results as Record<string, unknown>[]).map((r) => {
|
||||
const rs: Record<string, unknown> = {}
|
||||
@@ -149,6 +152,7 @@ function slimWrite(state: Record<string, unknown>): Record<string, unknown> {
|
||||
if (meta.filepath) slim.filepath = meta.filepath
|
||||
if (meta.exists !== undefined) slim.exists = meta.exists
|
||||
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
|
||||
if (meta.approval) slim.approval = meta.approval
|
||||
const fd = meta.filediff
|
||||
if (isObj(fd)) {
|
||||
slim.filediff = {
|
||||
|
||||
@@ -282,3 +282,25 @@ describe("KiloConnectionService drainPendingPrompts", () => {
|
||||
expect(cleared).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService server exit handling", () => {
|
||||
test("reports signal name when process is killed by signal", () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
let stateErr: Error | undefined
|
||||
service.onStateChange((state, err) => {
|
||||
if (state === "error") stateErr = err
|
||||
})
|
||||
;(service as any).handleServerExit(null, "SIGSEGV")
|
||||
expect(stateErr?.message).toBe("CLI background process exited with signal SIGSEGV. Retry to reconnect.")
|
||||
})
|
||||
|
||||
test("reports exit code when process exits normally with code", () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
let stateErr: Error | undefined
|
||||
service.onStateChange((state, err) => {
|
||||
if (state === "error") stateErr = err
|
||||
})
|
||||
;(service as any).handleServerExit(1, null)
|
||||
expect(stateErr?.message).toBe("CLI background process exited with code 1. Retry to reconnect.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,7 +138,7 @@ export class KiloConnectionService {
|
||||
update: async () => undefined,
|
||||
} satisfies Pick<vscode.Memento, "get" | "update">)
|
||||
this.sandboxPreference = new SandboxPreference(state)
|
||||
this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code))
|
||||
this.serverManager = new ServerManager(context, (code, signal) => this.handleServerExit(code, signal))
|
||||
this.active = vscode.window.state.focused
|
||||
this.windowStateDisposable = vscode.window.onDidChangeWindowState((ws) => {
|
||||
this.active = ws.focused
|
||||
@@ -790,13 +790,11 @@ export class KiloConnectionService {
|
||||
this.questionRevision += 1
|
||||
}
|
||||
|
||||
private handleServerExit(code: number | null): void {
|
||||
console.warn("[Kilo New] ConnectionService: CLI background process exited:", code)
|
||||
private handleServerExit(code: number | null, signal: NodeJS.Signals | null): void {
|
||||
const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`
|
||||
console.warn(`[Kilo New] ConnectionService: CLI background process exited with ${reason}`)
|
||||
this.resetConnection()
|
||||
this.setState(
|
||||
"error",
|
||||
new Error(`CLI background process exited with code ${code ?? "unknown"}. Retry to reconnect.`),
|
||||
)
|
||||
this.setState("error", new Error(`CLI background process exited with ${reason}. Retry to reconnect.`))
|
||||
}
|
||||
|
||||
private async doConnect(workspaceDir: string): Promise<void> {
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "انتهت عملية CLI بالرمز {{code}} قبل بدء الخادم",
|
||||
"server.startupTimeout": "انتهت مهلة بدء تشغيل الخادم بعد {{seconds}} ثانية",
|
||||
"server.processExited": "تم إنهاء عملية CLI بالرمز {{code}} قبل بدء الخادم",
|
||||
"server.processSignaled": "تم إنهاء عملية CLI بواسطة الإشارة {{signal}} قبل بدء الخادم",
|
||||
"server.spawnFailed": "فشل في تشغيل الملف الثنائي لـ CLI ({{code}})",
|
||||
"server.startupTimeout": "انتهت مهلة بدء تشغيل الخادم بعد {{seconds}} ثوانٍ",
|
||||
"remote.connected": "Kilo Remote: متصل",
|
||||
"remote.connecting": "Kilo Remote: جارٍ الاتصال\u2026",
|
||||
} as const
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "O processo da CLI foi encerrado com o código {{code}} antes que o servidor fosse iniciado",
|
||||
"server.startupTimeout": "Tempo limite de inicialização do servidor esgotado após {{seconds}} segundos",
|
||||
"server.processExited": "O processo da CLI foi encerrado com o código {{code}} antes da inicialização do servidor",
|
||||
"server.processSignaled": "O processo da CLI foi finalizado pelo sinal {{signal}} antes da inicialização do servidor",
|
||||
"server.spawnFailed": "Falha ao gerar o binário da CLI ({{code}})",
|
||||
"server.startupTimeout": "Tempo limite de inicialização do servidor atingido após {{seconds}} segundos",
|
||||
"remote.connected": "Kilo Remote: Conectado",
|
||||
"remote.connecting": "Kilo Remote: Conectando\u2026",
|
||||
} as const
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI proces je izašao sa kodom {{code}} prije nego što se server pokrenuo",
|
||||
"server.startupTimeout": "Vrijeme pokretanja servera je isteklo nakon {{seconds}} sekundi",
|
||||
"server.processExited": "CLI proces je izašao s kodom {{code}} prije nego što je server pokrenut",
|
||||
"server.processSignaled": "CLI proces je prekinut signalom {{signal}} prije pokretanja servera",
|
||||
"server.spawnFailed": "Neuspjelo pokretanje CLI binarne datoteke ({{code}})",
|
||||
"server.startupTimeout": "Isteklo je vrijeme za pokretanje servera nakon {{seconds}} sekundi",
|
||||
"remote.connected": "Kilo Remote: Povezano",
|
||||
"remote.connecting": "Kilo Remote: Povezivanje\u2026",
|
||||
} as const
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI-processen afsluttede med kode {{code}} før serveren startede",
|
||||
"server.startupTimeout": "Serverens opstartstid udløb efter {{seconds}} sekunder",
|
||||
"server.processExited": "CLI-processen afsluttedes med kode {{code}}, før serveren startede",
|
||||
"server.processSignaled": "CLI-processen blev afbrudt af signal {{signal}}, før serveren startede",
|
||||
"server.spawnFailed": "Kunne ikke starte CLI-binærfil ({{code}})",
|
||||
"server.startupTimeout": "Timeout for serverstart efter {{seconds}} sekunder",
|
||||
"remote.connected": "Kilo Remote: Forbundet",
|
||||
"remote.connecting": "Kilo Remote: Forbinder\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Der CLI-Prozess wurde mit dem Code {{code}} beendet, bevor der Server gestartet wurde",
|
||||
"server.processSignaled":
|
||||
"Der CLI-Prozess wurde durch das Signal {{signal}} beendet, bevor der Server gestartet wurde",
|
||||
"server.spawnFailed": "Fehler beim Starten der CLI-Binärdatei ({{code}})",
|
||||
"server.startupTimeout": "Zeitüberschreitung beim Serverstart nach {{seconds}} Sekunden",
|
||||
"remote.connected": "Kilo Remote: Verbunden",
|
||||
"remote.connecting": "Kilo Remote: Verbindung wird hergestellt\u2026",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI process exited with code {{code}} before server started",
|
||||
"server.processSignaled": "CLI process terminated by signal {{signal}} before server started",
|
||||
"server.spawnFailed": "Failed to spawn CLI binary ({{code}})",
|
||||
"server.startupTimeout": "Server startup timeout after {{seconds}} seconds",
|
||||
"remote.connected": "Kilo Remote: Connected",
|
||||
"remote.connecting": "Kilo Remote: Connecting\u2026",
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "El proceso de la CLI finalizó con el código {{code}} antes de que se iniciara el servidor",
|
||||
"server.startupTimeout": "Tiempo de espera de inicio del servidor agotado después de {{seconds}} segundos",
|
||||
"server.processExited": "El proceso CLI finalizó con el código {{code}} antes de que se iniciara el servidor",
|
||||
"server.processSignaled": "El proceso CLI fue terminado por la señal {{signal}} antes de que se iniciara el servidor",
|
||||
"server.spawnFailed": "Error al generar el binario CLI ({{code}})",
|
||||
"server.startupTimeout": "Tiempo de espera agotado para el inicio del servidor después de {{seconds}} segundos",
|
||||
"remote.connected": "Kilo Remote: Conectado",
|
||||
"remote.connecting": "Kilo Remote: Conectando\u2026",
|
||||
} as const
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "فرآیند CLI با کد {{code}} قبل از راهاندازی سرور خاتمه یافت",
|
||||
"server.processExited": "فرایند CLI قبل از شروع سرور با کد {{code}} خارج شد",
|
||||
"server.processSignaled": "فرایند CLI با سیگنال {{signal}} قبل از شروع سرور متوقف شد",
|
||||
"server.spawnFailed": "اجرای فایل باینری CLI ناموفق بود ({{code}})",
|
||||
"server.startupTimeout": "زمان راهاندازی سرور پس از {{seconds}} ثانیه به پایان رسید",
|
||||
"remote.connected": "Kilo Remote: متصل شد",
|
||||
"remote.connecting": "Kilo Remote: در حال اتصال…",
|
||||
"remote.connecting": "Kilo Remote: در حال اتصال\u2026",
|
||||
} as const
|
||||
|
||||
+6
-4
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Le processus CLI s'est terminé avec le code {{code}} avant le démarrage du serveur",
|
||||
"server.startupTimeout": "Délai de démarrage du serveur dépassé après {{seconds}} secondes",
|
||||
"remote.connected": "Kilo Remote\u00a0: Connecté",
|
||||
"remote.connecting": "Kilo Remote\u00a0: Connexion\u2026",
|
||||
"server.processExited": "Le processus CLI a quitté avec le code {{code}} avant le démarrage du serveur",
|
||||
"server.processSignaled": "Le processus CLI a été arrêté par le signal {{signal}} avant le démarrage du serveur",
|
||||
"server.spawnFailed": "Échec du lancement du binaire CLI ({{code}})",
|
||||
"server.startupTimeout": "Délai d'attente du démarrage du serveur dépassé après {{seconds}} secondes",
|
||||
"remote.connected": "Kilo Remote : Connecté",
|
||||
"remote.connecting": "Kilo Remote : Connexion en cours\u2026",
|
||||
} as const
|
||||
|
||||
+5
-3
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Il processo CLI è uscito con codice {{code}} prima dell'avvio del server",
|
||||
"server.processExited": "Il processo CLI è terminato con il codice {{code}} prima dell'avvio del server",
|
||||
"server.processSignaled": "Il processo CLI è stato terminato dal segnale {{signal}} prima dell'avvio del server",
|
||||
"server.spawnFailed": "Impossibile avviare il binario CLI ({{code}})",
|
||||
"server.startupTimeout": "Timeout di avvio del server dopo {{seconds}} secondi",
|
||||
"remote.connected": "Kilo Remote: connesso",
|
||||
"remote.connecting": "Kilo Remote: connessione...",
|
||||
"remote.connected": "Kilo Remote: Connesso",
|
||||
"remote.connecting": "Kilo Remote: Connessione in corso\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict = {
|
||||
"server.processExited": "サーバーが起動する前に、CLI プロセスがコード {{code}} で終了しました",
|
||||
"server.processSignaled": "サーバーが起動する前に、CLI プロセスがシグナル {{signal}} で終了しました",
|
||||
"server.spawnFailed": "CLI バイナリの起動に失敗しました ({{code}})",
|
||||
"server.startupTimeout": "サーバーの起動が {{seconds}} 秒後にタイムアウトしました",
|
||||
"remote.connected": "Kilo Remote: 接続済み",
|
||||
"remote.connecting": "Kilo Remote: 接続中\u2026",
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "서버가 시작되기 전에 CLI 프로세스가 코드 {{code}}로 종료되었습니다",
|
||||
"server.startupTimeout": "{{seconds}}초 후 서버 시작 시간이 초과되었습니다",
|
||||
"server.processExited": "서버가 시작되기 전에 CLI 프로세스가 코드 {{code}}(으)로 종료되었습니다",
|
||||
"server.processSignaled": "서버가 시작되기 전에 CLI 프로세스가 신호 {{signal}}(으)로 종료되었습니다",
|
||||
"server.spawnFailed": "CLI 바이너리를 생성하지 못했습니다 ({{code}})",
|
||||
"server.startupTimeout": "{{seconds}}초 후 서버 시작 시간 초과",
|
||||
"remote.connected": "Kilo Remote: 연결됨",
|
||||
"remote.connecting": "Kilo Remote: 연결 중\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI-proces is afgesloten met code {{code}} voordat de server is gestart",
|
||||
"server.processSignaled": "CLI-proces is beëindigd door signaal {{signal}} voordat de server is gestart",
|
||||
"server.spawnFailed": "Kan CLI-binair bestand niet starten ({{code}})",
|
||||
"server.startupTimeout": "Time-out bij opstarten van server na {{seconds}} seconden",
|
||||
"remote.connected": "Kilo Remote: Verbonden",
|
||||
"remote.connecting": "Kilo Remote: Verbinden\u2026",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI-prosessen avsluttet med kode {{code}} før serveren startet",
|
||||
"server.processSignaled": "CLI-prosessen ble avsluttet av signal {{signal}} før serveren startet",
|
||||
"server.spawnFailed": "Kunne ikke starte CLI-binærfil ({{code}})",
|
||||
"server.startupTimeout": "Tidsavbrudd for serveroppstart etter {{seconds}} sekunder",
|
||||
"remote.connected": "Kilo Remote: Tilkoblet",
|
||||
"remote.connecting": "Kilo Remote: Kobler til\u2026",
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Proces CLI zakończył się z kodem {{code}} przed uruchomieniem serwera",
|
||||
"server.processExited": "Proces CLI zakończył działanie z kodem {{code}} przed uruchomieniem serwera",
|
||||
"server.processSignaled": "Proces CLI został przerwany przez sygnał {{signal}} przed uruchomieniem serwera",
|
||||
"server.spawnFailed": "Nie udało się uruchomić pliku binarnego CLI ({{code}})",
|
||||
"server.startupTimeout": "Przekroczono limit czasu uruchamiania serwera po {{seconds}} sekundach",
|
||||
"remote.connected": "Kilo Remote: Połączono",
|
||||
"remote.connecting": "Kilo Remote: Łączenie\u2026",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Процесс CLI завершился с кодом {{code}} до запуска сервера",
|
||||
"server.processSignaled": "Процесс CLI был завершен сигналом {{signal}} до запуска сервера",
|
||||
"server.spawnFailed": "Не удалось запустить исполняемый файл CLI ({{code}})",
|
||||
"server.startupTimeout": "Время ожидания запуска сервера истекло через {{seconds}} секунд",
|
||||
"remote.connected": "Kilo Remote: Подключено",
|
||||
"remote.connecting": "Kilo Remote: Подключение\u2026",
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "กระบวนการ CLI ออกด้วยรหัส {{code}} ก่อนที่เซิร์ฟเวอร์จะเริ่มทำงาน",
|
||||
"server.startupTimeout": "หมดเวลาการเริ่มต้นเซิร์ฟเวอร์หลังจาก {{seconds}} วินาที",
|
||||
"server.processExited": "กระบวนการ CLI ออกด้วยรหัส {{code}} ก่อนที่เซิร์ฟเวอร์จะเริ่มต้น",
|
||||
"server.processSignaled": "กระบวนการ CLI ถูกยุติโดยสัญญาณ {{signal}} ก่อนที่เซิร์ฟเวอร์จะเริ่มต้น",
|
||||
"server.spawnFailed": "ไม่สามารถเริ่มต้นไบนารี CLI ({{code}})",
|
||||
"server.startupTimeout": "หมดเวลาเริ่มต้นเซิร์ฟเวอร์หลังจาก {{seconds}} วินาที",
|
||||
"remote.connected": "Kilo Remote: เชื่อมต่อแล้ว",
|
||||
"remote.connecting": "Kilo Remote: กำลังเชื่อมต่อ\u2026",
|
||||
} as const
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI işlemi sunucu başlamadan önce {{code}} koduyla çıktı",
|
||||
"server.processExited": "CLI işlemi sunucu başlatılmadan önce {{code}} koduyla çıktı",
|
||||
"server.processSignaled": "CLI işlemi sunucu başlatılmadan önce {{signal}} sinyali ile sonlandırıldı",
|
||||
"server.spawnFailed": "CLI ikili dosyası başlatılamadı ({{code}})",
|
||||
"server.startupTimeout": "{{seconds}} saniye sonra sunucu başlatma zaman aşımı",
|
||||
"remote.connected": "Kilo Remote: Bağlandı",
|
||||
"remote.connecting": "Kilo Remote: Bağlanıyor\u2026",
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Процес CLI завершився з кодом {{code}} до запуску сервера",
|
||||
"server.startupTimeout": "Час очікування запуску сервера вичерпано після {{seconds}} секунд",
|
||||
"server.processSignaled": "Процес CLI був завершений сигналом {{signal}} до запуску сервера",
|
||||
"server.spawnFailed": "Не вдалося запустити двійковий файл CLI ({{code}})",
|
||||
"server.startupTimeout": "Час очікування запуску сервера минув через {{seconds}} секунд",
|
||||
"remote.connected": "Kilo Remote: Підключено",
|
||||
"remote.connecting": "Kilo Remote: Підключення\u2026",
|
||||
} as const
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "在服务器启动之前,CLI 进程已退出,代码为 {{code}}",
|
||||
"server.startupTimeout": "服务器启动在 {{seconds}} 秒后超时",
|
||||
"server.processExited": "CLI 进程在服务器启动前以代码 {{code}} 退出",
|
||||
"server.processSignaled": "CLI 进程在服务器启动前被信号 {{signal}} 终止",
|
||||
"server.spawnFailed": "生成 CLI 二进制文件失败 ({{code}})",
|
||||
"server.startupTimeout": "{{seconds}} 秒后服务器启动超时",
|
||||
"remote.connected": "Kilo Remote: 已连接",
|
||||
"remote.connecting": "Kilo Remote: 正在连接\u2026",
|
||||
} as const
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
export const dict = {
|
||||
"server.processExited": "在伺服器啟動之前,CLI 處理程序已退出,代碼為 {{code}}",
|
||||
"server.startupTimeout": "伺服器啟動在 {{seconds}} 秒後逾時",
|
||||
"server.processExited": "CLI 程序在伺服器啟動前以代碼 {{code}} 退出",
|
||||
"server.processSignaled": "CLI 程序在伺服器啟動前被信號 {{signal}} 終止",
|
||||
"server.spawnFailed": "生成 CLI 二進位檔案失敗 ({{code}})",
|
||||
"server.startupTimeout": "{{seconds}} 秒後伺服器啟動逾時",
|
||||
"remote.connected": "Kilo Remote: 已連線",
|
||||
"remote.connecting": "Kilo Remote: 正在連線\u2026",
|
||||
} as const
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface ServerInstance {
|
||||
const STARTUP_TIMEOUT_SECONDS = 30
|
||||
|
||||
type WorkspaceFolderLike = { uri: { fsPath: string } }
|
||||
type ServerExitListener = (code: number | null) => void
|
||||
type ServerExitListener = (code: number | null, signal: NodeJS.Signals | null) => void
|
||||
|
||||
export function resolveServerCwd(folders: readonly WorkspaceFolderLike[] | undefined, storage: string): string {
|
||||
return folders?.[0]?.uri.fsPath ?? storage
|
||||
@@ -182,25 +182,36 @@ export class ServerManager {
|
||||
stderrLines.push(errorOutput)
|
||||
})
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
console.error("[Kilo New] ServerManager: ❌ Process error:", error)
|
||||
serverProcess.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.error("[Kilo New] ServerManager: ❌ Process error:", err)
|
||||
if (!resolved) {
|
||||
reject(error)
|
||||
const spawnErr = err as NodeJS.ErrnoException & { spawnargs?: string[] }
|
||||
const code = err.code || err.name || "UNKNOWN"
|
||||
const header = t("server.spawnFailed", { code })
|
||||
const lines = [
|
||||
`Error: ${err.message}`,
|
||||
...(err.code ? [`Code: ${err.code}`] : []),
|
||||
...(err.errno !== undefined ? [`Errno: ${err.errno}`] : []),
|
||||
...(err.syscall ? [`Syscall: ${err.syscall}`] : []),
|
||||
...(err.path ? [`Path: ${err.path}`] : []),
|
||||
...(Array.isArray(spawnErr.spawnargs) ? [`Spawn args: ${JSON.stringify(spawnErr.spawnargs)}`] : []),
|
||||
]
|
||||
const { userMessage, userDetails } = toErrorMessage(header, [...lines, ...stderrLines], cliPath)
|
||||
reject(new ServerStartupError(userMessage, userDetails))
|
||||
}
|
||||
})
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
console.log("[Kilo New] ServerManager: 🛑 Process exited with code:", code)
|
||||
serverProcess.on("exit", (code, signal) => {
|
||||
console.warn("[Kilo New] ServerManager: 🛑 Process exited:", { code, signal })
|
||||
if (this.instance?.process === serverProcess) {
|
||||
this.instance = null
|
||||
this.onExit?.(code)
|
||||
this.onExit?.(code, signal)
|
||||
}
|
||||
if (!resolved) {
|
||||
const { userMessage, userDetails } = toErrorMessage(
|
||||
t("server.processExited", { code: code ?? "null" }),
|
||||
stderrLines,
|
||||
cliPath,
|
||||
)
|
||||
const msg = signal
|
||||
? t("server.processSignaled", { signal })
|
||||
: t("server.processExited", { code: code ?? "unknown" })
|
||||
const { userMessage, userDetails } = toErrorMessage(msg, stderrLines, cliPath)
|
||||
reject(new ServerStartupError(userMessage, userDetails))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -295,6 +295,34 @@ describe("toErrorMessage", () => {
|
||||
const result = toErrorMessage("startup failed", ["some output"])
|
||||
expect(result.error).toBe("startup failed")
|
||||
})
|
||||
|
||||
it("formats structured spawn error details with syscall, errno, and args", () => {
|
||||
const spawnLines = [
|
||||
"Error: spawn UNKNOWN",
|
||||
"Code: UNKNOWN",
|
||||
"Errno: -86",
|
||||
"Syscall: spawn",
|
||||
"Path: /path/to/bin/kilo",
|
||||
'Spawn args: ["serve","--port","0"]',
|
||||
]
|
||||
const result = toErrorMessage("Failed to spawn CLI binary (UNKNOWN)", spawnLines, "/path/to/bin/kilo")
|
||||
expect(result.userMessage).toBe("spawn UNKNOWN")
|
||||
expect(result.userDetails).toContain("CLI path: /path/to/bin/kilo")
|
||||
expect(result.userDetails).toContain("Failed to spawn CLI binary (UNKNOWN)")
|
||||
expect(result.userDetails).toContain("Syscall: spawn")
|
||||
expect(result.userDetails).toContain("Errno: -86")
|
||||
})
|
||||
|
||||
it("handles signal termination without stderr lines cleanly", () => {
|
||||
const result = toErrorMessage(
|
||||
"CLI process terminated by signal SIGSEGV before server started",
|
||||
[],
|
||||
"/path/to/bin/kilo",
|
||||
)
|
||||
expect(result.userMessage).toBe("CLI process terminated by signal SIGSEGV before server started")
|
||||
expect(result.userDetails).toContain("CLI path: /path/to/bin/kilo")
|
||||
expect(result.userDetails).toContain("CLI process terminated by signal SIGSEGV before server started")
|
||||
})
|
||||
})
|
||||
|
||||
describe("server workspace helpers", () => {
|
||||
|
||||
@@ -30,6 +30,10 @@ const BIG = "x".repeat(200_000) // 200 KB — typical file content size
|
||||
const DIAG = [
|
||||
{ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, message: "err", severity: 1 },
|
||||
]
|
||||
// Regression for #13001: slimmers used to rebuild `metadata` from an explicit allowlist that
|
||||
// didn't include `approval`, silently dropping the auto-approval reason (and the
|
||||
// outside-workspace note) before it ever reached the webview.
|
||||
const APPROVAL = { source: "agent", agent: "code", outsideWorkspace: true, outsideWorkspacePath: "/tmp/a.ts" }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
@@ -96,6 +100,7 @@ describe("slimPart", () => {
|
||||
diff: BIG,
|
||||
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 3, deletions: 1 },
|
||||
diagnostics: { "/a.ts": DIAG },
|
||||
approval: APPROVAL,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -103,7 +108,7 @@ describe("slimPart", () => {
|
||||
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
|
||||
})
|
||||
|
||||
it("keeps filediff counts and diagnostics", () => {
|
||||
it("keeps filediff counts, diagnostics, and approval", () => {
|
||||
const slim = slimPart(heavy) as Record<string, any>
|
||||
const meta = slim.state.metadata
|
||||
expect(meta.filediff.file).toBe("/a.ts")
|
||||
@@ -111,6 +116,7 @@ describe("slimPart", () => {
|
||||
expect(meta.filediff.additions).toBe(3)
|
||||
expect(meta.filediff.deletions).toBe(1)
|
||||
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
|
||||
expect(meta.approval).toEqual(APPROVAL)
|
||||
})
|
||||
|
||||
it("keeps output and input intact", () => {
|
||||
@@ -176,6 +182,7 @@ describe("slimPart", () => {
|
||||
},
|
||||
],
|
||||
diagnostics: { "/a.ts": DIAG },
|
||||
approval: APPROVAL,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -183,7 +190,7 @@ describe("slimPart", () => {
|
||||
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
|
||||
})
|
||||
|
||||
it("keeps file summary fields and diagnostics", () => {
|
||||
it("keeps file summary fields, diagnostics, and approval", () => {
|
||||
const slim = slimPart(heavy) as Record<string, any>
|
||||
const meta = slim.state.metadata
|
||||
expect(meta.files[0].filePath).toBe("/a.ts")
|
||||
@@ -193,6 +200,7 @@ describe("slimPart", () => {
|
||||
expect(meta.files[0].additions).toBe(5)
|
||||
expect(meta.files[1].type).toBe("add")
|
||||
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
|
||||
expect(meta.approval).toEqual(APPROVAL)
|
||||
})
|
||||
|
||||
it("drops unknown heavy metadata fields", () => {
|
||||
@@ -266,6 +274,7 @@ describe("slimPart", () => {
|
||||
},
|
||||
{ filediff: { file: "/b.ts", before: BIG, after: BIG, additions: 2, deletions: 0 }, diagnostics: {} },
|
||||
],
|
||||
approval: APPROVAL,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -273,7 +282,7 @@ describe("slimPart", () => {
|
||||
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
|
||||
})
|
||||
|
||||
it("keeps filediff counts and per-result diagnostics", () => {
|
||||
it("keeps filediff counts, per-result diagnostics, and approval", () => {
|
||||
const slim = slimPart(heavy) as Record<string, any>
|
||||
const meta = slim.state.metadata
|
||||
expect(meta.results[0].filediff.file).toBe("/a.ts")
|
||||
@@ -282,6 +291,7 @@ describe("slimPart", () => {
|
||||
expect(meta.results[0].diagnostics).toEqual({ "/a.ts": DIAG })
|
||||
expect(meta.results[1].filediff.file).toBe("/b.ts")
|
||||
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
|
||||
expect(meta.approval).toEqual(APPROVAL)
|
||||
})
|
||||
|
||||
it("drops unknown heavy metadata fields", () => {
|
||||
@@ -318,6 +328,7 @@ describe("slimPart", () => {
|
||||
diff: BIG,
|
||||
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 100, deletions: 0 },
|
||||
diagnostics: { "/a.ts": DIAG },
|
||||
approval: APPROVAL,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -325,7 +336,7 @@ describe("slimPart", () => {
|
||||
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
|
||||
})
|
||||
|
||||
it("keeps filepath, exists, filediff counts, diagnostics", () => {
|
||||
it("keeps filepath, exists, filediff counts, diagnostics, and approval", () => {
|
||||
const slim = slimPart(heavy) as Record<string, any>
|
||||
const meta = slim.state.metadata
|
||||
expect(meta.filepath).toBe("/a.ts")
|
||||
@@ -335,6 +346,7 @@ describe("slimPart", () => {
|
||||
expect(meta.filediff.additions).toBe(100)
|
||||
expect(meta.filediff.deletions).toBe(0)
|
||||
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
|
||||
expect(meta.approval).toEqual(APPROVAL)
|
||||
})
|
||||
|
||||
it("drops unknown heavy metadata fields", () => {
|
||||
|
||||
+1
@@ -297,6 +297,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)",
|
||||
"ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة",
|
||||
"ui.approval.source.default": "افتراضيًا",
|
||||
"ui.approval.outsideWorkspace": "(خارج مساحة العمل: {{file}})",
|
||||
|
||||
"session.tab.review": "مراجعة",
|
||||
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
|
||||
|
||||
+1
@@ -307,6 +307,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "pelo modo de aprovação automática (YOLO)",
|
||||
"ui.approval.source.session": "por uma regra de aprovação automática da sessão",
|
||||
"ui.approval.source.default": "por padrão",
|
||||
"ui.approval.outsideWorkspace": "(fora do seu espaço de trabalho: {{file}})",
|
||||
|
||||
"session.tab.review": "Revisão",
|
||||
"session.review.filesChanged": "{{count}} Arquivos Alterados",
|
||||
|
||||
+1
@@ -305,6 +305,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)",
|
||||
"ui.approval.source.session": "pravilom automatskog odobravanja sesije",
|
||||
"ui.approval.source.default": "podrazumevano",
|
||||
"ui.approval.outsideWorkspace": "(izvan vašeg radnog prostora: {{file}})",
|
||||
|
||||
"session.tab.review": "Pregled",
|
||||
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
|
||||
|
||||
+1
@@ -304,6 +304,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "af automatisk godkendelse (YOLO)",
|
||||
"ui.approval.source.session": "af en session-autogodkendelsesregel",
|
||||
"ui.approval.source.default": "som standard",
|
||||
"ui.approval.outsideWorkspace": "(uden for dit arbejdsområde: {{file}})",
|
||||
|
||||
"session.tab.review": "Gennemgang",
|
||||
"session.review.filesChanged": "{{count}} Filer ændret",
|
||||
|
||||
@@ -313,6 +313,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)",
|
||||
"ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel",
|
||||
"ui.approval.source.default": "standardmäßig",
|
||||
"ui.approval.outsideWorkspace": "(außerhalb deines Arbeitsbereichs: {{file}})",
|
||||
|
||||
"session.tab.review": "Überprüfung",
|
||||
"session.review.filesChanged": "{{count}} Dateien geändert",
|
||||
|
||||
@@ -302,6 +302,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "by auto-approve (YOLO) mode",
|
||||
"ui.approval.source.session": "by a session auto-approve rule",
|
||||
"ui.approval.source.default": "by default",
|
||||
"ui.approval.outsideWorkspace": "(outside your workspace: {{file}})",
|
||||
|
||||
"session.tab.review": "Review",
|
||||
"session.review.filesChanged": "{{count}} Files Changed",
|
||||
|
||||
+1
@@ -308,6 +308,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "por el modo de aprobación automática (YOLO)",
|
||||
"ui.approval.source.session": "por una regla de aprobación automática de sesión",
|
||||
"ui.approval.source.default": "de forma predeterminada",
|
||||
"ui.approval.outsideWorkspace": "(fuera de tu espacio de trabajo: {{file}})",
|
||||
|
||||
"session.tab.review": "Revisión",
|
||||
"session.review.filesChanged": "{{count}} Archivos Cambiados",
|
||||
|
||||
+1
@@ -302,6 +302,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)",
|
||||
"ui.approval.source.session": "توسط قانون تأیید خودکار جلسه",
|
||||
"ui.approval.source.default": "بهطور پیشفرض",
|
||||
"ui.approval.outsideWorkspace": "(خارج از فضای کاری شما: {{file}})",
|
||||
|
||||
"session.tab.review": "بررسی",
|
||||
"session.review.filesChanged": "{{count}} فایل تغییر یافته",
|
||||
|
||||
+1
@@ -307,6 +307,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "par le mode d'approbation automatique (YOLO)",
|
||||
"ui.approval.source.session": "par une règle d'approbation automatique de session",
|
||||
"ui.approval.source.default": "par défaut",
|
||||
"ui.approval.outsideWorkspace": "(hors de votre espace de travail : {{file}})",
|
||||
|
||||
"session.tab.review": "Revue",
|
||||
"session.review.filesChanged": "{{count}} fichiers modifiés",
|
||||
|
||||
+1
@@ -219,6 +219,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "dalla modalità di approvazione automatica (YOLO)",
|
||||
"ui.approval.source.session": "da una regola di approvazione automatica della sessione",
|
||||
"ui.approval.source.default": "per impostazione predefinita",
|
||||
"ui.approval.outsideWorkspace": "(fuori dall'area di lavoro: {{file}})",
|
||||
"session.tab.review": "Revisione",
|
||||
"session.review.filesChanged": "{{count}} file modificati",
|
||||
"session.review.loadingChanges": "Caricamento modifiche...",
|
||||
|
||||
+1
@@ -304,6 +304,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "自動承認(YOLO)モードによって",
|
||||
"ui.approval.source.session": "セッションの自動承認ルールによって",
|
||||
"ui.approval.source.default": "デフォルトで",
|
||||
"ui.approval.outsideWorkspace": "(ワークスペース外:{{file}})",
|
||||
|
||||
"session.tab.review": "レビュー",
|
||||
"session.review.filesChanged": "{{count}} ファイル変更",
|
||||
|
||||
+1
@@ -305,6 +305,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "자동 승인(YOLO) 모드에 의해",
|
||||
"ui.approval.source.session": "세션 자동 승인 규칙에 의해",
|
||||
"ui.approval.source.default": "기본값으로",
|
||||
"ui.approval.outsideWorkspace": "(작업 영역 외부: {{file}})",
|
||||
|
||||
"session.tab.review": "검토",
|
||||
"session.review.filesChanged": "{{count}}개 파일 변경됨",
|
||||
|
||||
+1
@@ -308,6 +308,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)",
|
||||
"ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel",
|
||||
"ui.approval.source.default": "standaard",
|
||||
"ui.approval.outsideWorkspace": "(buiten je werkruimte: {{file}})",
|
||||
|
||||
"session.tab.review": "Beoordelen",
|
||||
"session.review.filesChanged": "{{count}} bestanden gewijzigd",
|
||||
|
||||
+1
@@ -311,6 +311,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "av automatisk godkjenning (YOLO)",
|
||||
"ui.approval.source.session": "av en økt-autogodkjenningsregel",
|
||||
"ui.approval.source.default": "som standard",
|
||||
"ui.approval.outsideWorkspace": "(utenfor arbeidsområdet ditt: {{file}})",
|
||||
|
||||
"session.tab.review": "Gjennomgang",
|
||||
"session.review.filesChanged": "{{count}} filer endret",
|
||||
|
||||
+1
@@ -305,6 +305,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)",
|
||||
"ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji",
|
||||
"ui.approval.source.default": "domyślnie",
|
||||
"ui.approval.outsideWorkspace": "(poza obszarem roboczym: {{file}})",
|
||||
|
||||
"session.tab.review": "Przegląd",
|
||||
"session.review.filesChanged": "Zmieniono {{count}} plików",
|
||||
|
||||
+1
@@ -303,6 +303,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "режимом автоодобрения (YOLO)",
|
||||
"ui.approval.source.session": "правилом автоодобрения сессии",
|
||||
"ui.approval.source.default": "по умолчанию",
|
||||
"ui.approval.outsideWorkspace": "(за пределами вашей рабочей области: {{file}})",
|
||||
|
||||
"session.tab.review": "Обзор",
|
||||
"session.review.filesChanged": "{{count}} файлов изменено",
|
||||
|
||||
+1
@@ -302,6 +302,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "โดยโหมดอนุมัติอัตโนมัติ (YOLO)",
|
||||
"ui.approval.source.session": "โดยกฎอนุมัติอัตโนมัติของเซสชัน",
|
||||
"ui.approval.source.default": "ตามค่าเริ่มต้น",
|
||||
"ui.approval.outsideWorkspace": "(นอกพื้นที่ทำงานของคุณ: {{file}})",
|
||||
|
||||
"session.tab.review": "ตรวจสอบ",
|
||||
"session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
|
||||
|
||||
+1
@@ -303,6 +303,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "otomatik onay (YOLO) modu tarafından",
|
||||
"ui.approval.source.session": "bir oturum otomatik onay kuralı tarafından",
|
||||
"ui.approval.source.default": "varsayılan olarak",
|
||||
"ui.approval.outsideWorkspace": "(çalışma alanınızın dışında: {{file}})",
|
||||
|
||||
"session.tab.review": "İnceleme",
|
||||
"session.review.filesChanged": "{{count}} Dosya Değişti",
|
||||
|
||||
+1
@@ -307,6 +307,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "режимом автосхвалення (YOLO)",
|
||||
"ui.approval.source.session": "правилом автосхвалення сесії",
|
||||
"ui.approval.source.default": "за замовчуванням",
|
||||
"ui.approval.outsideWorkspace": "(за межами вашого робочого простору: {{file}})",
|
||||
|
||||
"session.tab.review": "Огляд",
|
||||
"session.review.filesChanged": "{{count}} файлів змінено",
|
||||
|
||||
+1
@@ -292,6 +292,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "由自动批准(YOLO)模式",
|
||||
"ui.approval.source.session": "由会话自动批准规则",
|
||||
"ui.approval.source.default": "默认",
|
||||
"ui.approval.outsideWorkspace": "(工作区之外:{{file}})",
|
||||
|
||||
"session.tab.review": "审查",
|
||||
"session.review.filesChanged": "{{count}} 个文件变更",
|
||||
|
||||
+1
@@ -290,6 +290,7 @@ export const dict = {
|
||||
"ui.approval.source.yolo": "由自動核准(YOLO)模式",
|
||||
"ui.approval.source.session": "由工作階段自動核准規則",
|
||||
"ui.approval.source.default": "預設",
|
||||
"ui.approval.outsideWorkspace": "(工作區之外:{{file}})",
|
||||
|
||||
"session.tab.review": "審查",
|
||||
"session.review.filesChanged": "{{count}} 個檔案變更",
|
||||
|
||||
@@ -27,6 +27,21 @@ export namespace PermissionProvenance {
|
||||
agent?: string
|
||||
/** The winning rule, omitted for manual replies and the ask fallback. */
|
||||
rule?: { permission: string; pattern: string; action: Permission.Action }
|
||||
/** True when the ask's target path was outside the workspace/worktree (an `external_directory` ask). */
|
||||
outsideWorkspace?: boolean
|
||||
/** The target file path, when the `external_directory` ask carried one, for display as a filename. */
|
||||
outsideWorkspacePath?: string
|
||||
}
|
||||
|
||||
/** The `filepath` an `external_directory` ask's metadata carries, if any (see `Tool.assertExternalDirectory`). */
|
||||
export function filepathOf(metadata: Record<string, unknown> | undefined): string | undefined {
|
||||
return typeof metadata?.filepath === "string" ? metadata.filepath : undefined
|
||||
}
|
||||
|
||||
/** Tag an approval as outside-workspace when it answers an `external_directory` ask. */
|
||||
export function tagOutsideWorkspace(approval: Approval, permission: string, path?: string): Approval {
|
||||
if (permission !== "external_directory") return approval
|
||||
return { ...approval, outsideWorkspace: true, ...(path ? { outsideWorkspacePath: path } : {}) }
|
||||
}
|
||||
|
||||
export type Scope = "global" | "local"
|
||||
@@ -71,13 +86,29 @@ export namespace PermissionProvenance {
|
||||
* The approval is written once during `ask()`, but tools freely overwrite `state.metadata`
|
||||
* during execution and on completion. Carry the prior `approval` onto the replacement unless
|
||||
* the replacement sets its own.
|
||||
*
|
||||
* A file tool that crosses the workspace boundary issues *two* asks for one call: the generic
|
||||
* `external_directory` ask first, then its own `read`/`write`/`edit` ask. Both write `approval`
|
||||
* metadata, so the second ask's `outsideWorkspace` marker would otherwise clobber the first's
|
||||
* even though `"approval" in next` is true. Merge that marker forward so it survives.
|
||||
*/
|
||||
export function carryApproval(
|
||||
prev: Record<string, unknown> | undefined,
|
||||
next: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (!next || !prev?.approval || "approval" in next) return next
|
||||
return { ...next, approval: prev.approval }
|
||||
if (!next) return next
|
||||
const prior = prev?.approval as Approval | undefined
|
||||
if (!("approval" in next)) return prior ? { ...next, approval: prior } : next
|
||||
const current = next.approval as Approval | undefined
|
||||
if (!prior?.outsideWorkspace || !current || current.outsideWorkspace) return next
|
||||
return {
|
||||
...next,
|
||||
approval: {
|
||||
...current,
|
||||
outsideWorkspace: true,
|
||||
...(prior.outsideWorkspacePath ? { outsideWorkspacePath: prior.outsideWorkspacePath } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,18 +97,32 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
},
|
||||
}).pipe(
|
||||
// record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask
|
||||
Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })),
|
||||
Effect.tap((approval) =>
|
||||
input.processor.metadata(options.toolCallId, {
|
||||
metadata: {
|
||||
approval: PermissionProvenance.tagOutsideWorkspace(
|
||||
approval,
|
||||
req.permission,
|
||||
PermissionProvenance.filepathOf(req.metadata),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
// record why the call was denied too, so JSON exports and clients can explain the denial
|
||||
Effect.tapErrorTag("PermissionDeniedError", (err) =>
|
||||
input.processor.metadata(options.toolCallId, {
|
||||
metadata: {
|
||||
approval: PermissionProvenance.classifyDenial({
|
||||
ruleset: err.ruleset,
|
||||
permission: req.permission,
|
||||
patterns: req.patterns,
|
||||
agent: input.agent.name,
|
||||
origins: permissionOrigins,
|
||||
}),
|
||||
approval: PermissionProvenance.tagOutsideWorkspace(
|
||||
PermissionProvenance.classifyDenial({
|
||||
ruleset: err.ruleset,
|
||||
permission: req.permission,
|
||||
patterns: req.patterns,
|
||||
agent: input.agent.name,
|
||||
origins: permissionOrigins,
|
||||
}),
|
||||
req.permission,
|
||||
PermissionProvenance.filepathOf(req.metadata),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -121,6 +121,80 @@ describe("PermissionProvenance.carryApproval", () => {
|
||||
test("returns the replacement as-is when it is undefined", () => {
|
||||
expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("merges outsideWorkspace onto a replacement's own approval instead of clobbering it", () => {
|
||||
// A file tool crossing the workspace boundary asks twice: external_directory first, then its
|
||||
// own read/write/edit ask. The second ask's approval must not lose the outsideWorkspace marker.
|
||||
const outside = { source: "manual" as const, outsideWorkspace: true }
|
||||
const next = { approval: { source: "agent" as const, agent: "build" } }
|
||||
expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({
|
||||
approval: { source: "agent", agent: "build", outsideWorkspace: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("also carries the outsideWorkspacePath forward alongside the marker", () => {
|
||||
const outside = { source: "manual" as const, outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" }
|
||||
const next = { approval: { source: "agent" as const, agent: "build" } }
|
||||
expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({
|
||||
approval: { source: "agent", agent: "build", outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not add outsideWorkspace when the prior approval was not outside the workspace", () => {
|
||||
const next = { approval: { source: "agent" as const, agent: "build" } }
|
||||
expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next)
|
||||
})
|
||||
|
||||
test("leaves a replacement's own outsideWorkspace marker untouched", () => {
|
||||
const outside = { source: "manual" as const, outsideWorkspace: true }
|
||||
const next = { approval: { source: "agent" as const, agent: "build", outsideWorkspace: true } }
|
||||
expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toBe(next)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionProvenance.tagOutsideWorkspace", () => {
|
||||
test("marks an external_directory approval as outsideWorkspace", () => {
|
||||
const approval = { source: "manual" as const }
|
||||
expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({
|
||||
source: "manual",
|
||||
outsideWorkspace: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves other permissions' approvals untouched", () => {
|
||||
const approval = { source: "manual" as const }
|
||||
expect(PermissionProvenance.tagOutsideWorkspace(approval, "read")).toBe(approval)
|
||||
})
|
||||
|
||||
test("carries the target path when one is given", () => {
|
||||
const approval = { source: "manual" as const }
|
||||
expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory", "/tmp/secret.txt")).toEqual({
|
||||
source: "manual",
|
||||
outsideWorkspace: true,
|
||||
outsideWorkspacePath: "/tmp/secret.txt",
|
||||
})
|
||||
})
|
||||
|
||||
test("omits outsideWorkspacePath when no path is given", () => {
|
||||
const approval = { source: "manual" as const }
|
||||
expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({
|
||||
source: "manual",
|
||||
outsideWorkspace: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionProvenance.filepathOf", () => {
|
||||
test("reads the filepath an external_directory ask's metadata carries", () => {
|
||||
expect(PermissionProvenance.filepathOf({ filepath: "/tmp/secret.txt", parentDir: "/tmp" })).toBe(
|
||||
"/tmp/secret.txt",
|
||||
)
|
||||
})
|
||||
|
||||
test("returns undefined when there is no filepath, e.g. a bash directory scan", () => {
|
||||
expect(PermissionProvenance.filepathOf({ command: "cat /tmp/secret.txt", access: "read" })).toBeUndefined()
|
||||
expect(PermissionProvenance.filepathOf(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("askPermission returns provenance", () => {
|
||||
|
||||
Reference in New Issue
Block a user