Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-gh-tz-windows-console.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Set `TZ` when spawning `gh` on Windows to prevent `tzutil.exe` console windows from flashing over fullscreen applications.
47 changes: 43 additions & 4 deletions packages/kilo-vscode/src/agent-manager/shell-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { type ExecFileOptionsWithStringEncoding } from "child_process"
import * as os from "os"
import * as path from "path"
import { exec } from "../util/process"

// Environment variable keys match: letters, digits, underscores, starting with a non-digit.
Expand All @@ -28,6 +29,43 @@ const FALLBACK_TTL = 10_000
let fixing: Promise<boolean> | null = null
let fixed = false

/** Inferred IANA time zone, cached because Intl resolution is not free. */
let inferredTz: string | undefined

function isGh(cmd: string): boolean {
const ext = path.extname(cmd)
const name = ext ? cmd.slice(0, -ext.length) : cmd
return path.basename(name) === "gh"
}

function getTimeZone(): string | undefined {
return process.env.TZ || (inferredTz ??= Intl.DateTimeFormat().resolvedOptions().timeZone)
}

function currentEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (typeof value === "string") env[key] = value
}
return env
}

function withTzEnv(
cmd: string,
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Omit<ExecFileOptionsWithStringEncoding, "encoding"> {
if (process.platform !== "win32") return options ?? {}
if (!isGh(cmd)) return options ?? {}
if (options?.env?.TZ) return options

const tz = getTimeZone()
if (!tz) return options ?? {}

const env = options?.env ? { ...options.env } : currentEnv()
env.TZ = tz
return { ...options, env }
}

/**
* Parse `env` output, handling multiline variable values correctly.
*
Expand Down Expand Up @@ -133,8 +171,9 @@ export async function execWithShellEnv(
args: string[],
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Promise<{ stdout: string; stderr: string }> {
const opts = withTzEnv(cmd, options)
try {
return await exec(cmd, args, options)
return await exec(cmd, args, opts)
} catch (error) {
if (
process.platform !== "darwin" ||
Expand All @@ -148,13 +187,13 @@ export async function execWithShellEnv(
// Already resolved and PATH was actually changed — no point retrying resolution.
// Just retry with the (already-patched) process.env.
if (fixed) {
return await exec(cmd, args, options)
return await exec(cmd, args, opts)
}

// If another caller is already resolving, wait for it then retry.
if (fixing) {
await fixing
return await exec(cmd, args, options)
return await exec(cmd, args, opts)
}

console.log(`[shell-env] "${cmd}" not found, resolving shell environment`)
Expand All @@ -166,7 +205,7 @@ export async function execWithShellEnv(
fixing = null
}

return await exec(cmd, args, options)
return await exec(cmd, args, opts)
}
}

Expand Down
96 changes: 96 additions & 0 deletions packages/kilo-vscode/tests/unit/shell-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import { afterEach, describe, expect, it } from "bun:test"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import { getShellEnvironment, execWithShellEnv, clearShellEnvCache } from "../../src/agent-manager/shell-env"

let platformDesc: PropertyDescriptor | undefined
const originalTz = process.env.TZ

afterEach(() => {
clearShellEnvCache()
if (platformDesc) Object.defineProperty(process, "platform", platformDesc)
if (originalTz === undefined) delete process.env.TZ
else process.env.TZ = originalTz
})

function setPlatform(value: string) {
platformDesc = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", { value, configurable: true })
}

function fakeGhBin(): { dir: string; cleanup: () => void } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-tz-"))
fs.symlinkSync(process.execPath, path.join(dir, "gh"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: fs.symlinkSync may fail for Windows contributors running these tests locally

Creating a file symlink on Windows requires Developer Mode or an elevated process (SeCreateSymbolicLinkPrivilege). A contributor on Windows without either will hit EPERM: operation not permitted, symlink here and the whole TZ suite fails before reaching its assertions. CI only runs these tests on Linux, so it won't catch that.

Consider a small fallback, e.g. try { fs.symlinkSync(...) } catch { fs.copyFileSync(process.execPath, path.join(dir, "gh")) }, or skip the suite when symlink creation isn't permitted. The git symlink in the "non-gh commands" test below has the same exposure.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return {
dir,
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
}
}

describe("getShellEnvironment", () => {
it("returns an object with PATH", async () => {
const env = await getShellEnvironment()
Expand Down Expand Up @@ -74,3 +97,76 @@ describe("clearShellEnvCache", () => {
expect(second.PATH).toBeDefined()
})
})

describe("execWithShellEnv TZ for gh on Windows", () => {
it("injects TZ into gh child processes on Windows", async () => {
setPlatform("win32")
process.env.TZ = "Test/TZ"
const { dir, cleanup } = fakeGhBin()
try {
const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], {
env: { PATH: dir },
})
expect(stdout.trim()).toBe("Test/TZ")
} finally {
cleanup()
}
})

it("infers a TZ value from the system when process.env.TZ is not set", async () => {
setPlatform("win32")
delete process.env.TZ
const { dir, cleanup } = fakeGhBin()
try {
const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], {
env: { PATH: dir },
})
expect(stdout.trim()).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone)
} finally {
cleanup()
}
})

it("does not inject TZ for non-gh commands on Windows", async () => {
setPlatform("win32")
process.env.TZ = "Test/TZ"
const { dir, cleanup } = fakeGhBin()
try {
fs.symlinkSync(process.execPath, path.join(dir, "git"))
const { stdout } = await execWithShellEnv("git", ["-e", "console.log(process.env.TZ)"], {
env: { PATH: dir },
})
expect(stdout.trim()).toBe("undefined")
} finally {
cleanup()
}
})

it("does not inject TZ for gh on non-Windows platforms", async () => {
setPlatform("linux")
process.env.TZ = "Test/TZ"
const { dir, cleanup } = fakeGhBin()
try {
const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], {
env: { PATH: dir },
})
expect(stdout.trim()).toBe("undefined")
} finally {
cleanup()
}
})

it("preserves an explicit TZ in options.env", async () => {
setPlatform("win32")
process.env.TZ = "Test/TZ"
const { dir, cleanup } = fakeGhBin()
try {
const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], {
env: { PATH: dir, TZ: "Custom/Zone" },
})
expect(stdout.trim()).toBe("Custom/Zone")
} finally {
cleanup()
}
})
})