Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,17 @@ jobs:
"$RUNNER_TEMP/ripgrep/rg.exe" --version
echo "$RUNNER_TEMP/ripgrep" >> "$GITHUB_PATH"

- name: Cache downloaded tool binaries
# Ripgrep tests download rg into ~/.cache/opencode/bin on first use;
# on GitHub-hosted windows runners that download has blown the test
# timeout. Cache it so only version bumps re-download.
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/opencode/bin
key: opencode-bin-${{ runner.os }}-${{ hashFiles('packages/core/src/ripgrep/binary.ts') }}
- name: Run unit tests
timeout-minutes: 20
# opencode#test alone takes ~20 minutes on GitHub-hosted windows runners
timeout-minutes: 35
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/filesystem/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,35 @@ export const ripgrepLayer = Layer.effect(
files: [] as string[],
directories: [] as string[],
}
const files = new Set<string>()
const directories = new Set<string>()
// The scan fills the find() index incrementally. A transient spawn failure
// or hung rg process would otherwise kill this forked fiber silently and
// leave find() empty forever (LAC-2693), so bound each attempt, retry, and
// log a scan that never completes. Dedupe keeps retries from re-adding
// entries already indexed by an interrupted attempt.
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
if (files.has(entry.path)) return
files.add(entry.path)
state.files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
.pipe(
Effect.timeout("120 seconds"),
Effect.retry({ times: 2 }),
Effect.catch((error) => Effect.logWarning("file index scan failed", { error })),
Effect.asVoid,
Effect.forkIn(scope),
)
return Service.of({
glob: (input) =>
Effect.gen(function* () {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/test/filesystem/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { testEffect } from "../lib/effect"

const it = testEffect(LayerNode.compile(Ripgrep.node))

// first Ripgrep use may download and extract the rg binary, which exceeds the
// default 5s test timeout on Windows CI runners (PowerShell Expand-Archive)
const RG_DOWNLOAD = { timeout: 120_000 }

const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand All @@ -26,6 +30,7 @@ describe("Ripgrep", () => {
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
}),
),
RG_DOWNLOAD,
)

it.live("greps files with include filtering", () =>
Expand All @@ -40,5 +45,6 @@ describe("Ripgrep", () => {
expect(result[0]?.submatches[0]?.text).toBe("needle")
}),
),
RG_DOWNLOAD,
)
})
14 changes: 14 additions & 0 deletions packages/core/test/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { testEffect } from "./lib/effect"

const it = testEffect(AppNodeBuilder.build(ProjectV2.node))

// Every test spawns several git subprocesses via initRepo; on loaded
// GitHub-hosted windows runners a single spawn can blow the 5s default.
const GIT_SPAWN = { timeout: 30_000 }

function remoteID(remote: string) {
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
}
Expand Down Expand Up @@ -54,6 +58,7 @@ describe("ProjectV2.resolve", () => {
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
GIT_SPAWN,
)

it.live("returns git global for repo with no commits and no remote", () =>
Expand All @@ -72,6 +77,7 @@ describe("ProjectV2.resolve", () => {
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
GIT_SPAWN,
)

it.live("falls back to root commit when origin is missing", () =>
Expand All @@ -90,6 +96,7 @@ describe("ProjectV2.resolve", () => {
expect(result.previous).toBeUndefined()
expect(result.vcs?.type).toBe("git")
}),
GIT_SPAWN,
)

it.live("prefers normalized origin over root commit", () =>
Expand All @@ -108,6 +115,7 @@ describe("ProjectV2.resolve", () => {
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
GIT_SPAWN,
)

it.live("normalizes ssh and https remotes to the same id", () =>
Expand All @@ -130,6 +138,7 @@ describe("ProjectV2.resolve", () => {
expect(a.id).toBe(remoteID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
GIT_SPAWN,
)

it.live("ignores file remotes and falls back to root commit", () =>
Expand All @@ -145,6 +154,7 @@ describe("ProjectV2.resolve", () => {

expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
}),
GIT_SPAWN,
)

it.live("returns previous cached id from common dir", () =>
Expand All @@ -162,6 +172,7 @@ describe("ProjectV2.resolve", () => {
expect(result.previous).toBe(ProjectV2.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
}),
GIT_SPAWN,
)

it.live("does not write the cache while resolving", () =>
Expand All @@ -177,6 +188,7 @@ describe("ProjectV2.resolve", () => {

expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
}),
GIT_SPAWN,
)

it.live("resolves from nested directories to repo root", () =>
Expand All @@ -193,6 +205,7 @@ describe("ProjectV2.resolve", () => {

expect(result.directory).toBe(yield* real(tmp.path))
}),
GIT_SPAWN,
)

it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
Expand All @@ -217,5 +230,6 @@ describe("ProjectV2.resolve", () => {
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.vcs?.type).toBe("git")
}),
GIT_SPAWN,
)
})
8 changes: 8 additions & 0 deletions packages/core/test/ripgrep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { testEffect } from "./lib/effect"

const it = testEffect(LayerNode.compile(Ripgrep.node))

// first Ripgrep use may download and extract the rg binary; on a cold cache a
// loaded Windows CI runner has been observed to need over 120s for the
// download plus PowerShell Expand-Archive (CI caches ~/.cache/opencode/bin,
// so this budget only applies on cache misses)
const RG_DOWNLOAD = { timeout: 240_000 }

describe("Ripgrep", () => {
it.live("keeps ignored files out of catch-all find results", () =>
Effect.acquireUseRelease(
Expand All @@ -29,6 +35,7 @@ describe("Ripgrep", () => {
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
RG_DOWNLOAD,
)

it.live("never includes git metadata", () =>
Expand Down Expand Up @@ -61,5 +68,6 @@ describe("Ripgrep", () => {
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
RG_DOWNLOAD,
)
})
21 changes: 20 additions & 1 deletion packages/opencode/plugin/shell-mode/command-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Shell } from "@opencode-ai/core/shell"
import { which } from "@opencode-ai/core/util/which"
import { spawn as nodeSpawn } from "node:child_process"
import { realpathSync } from "node:fs"
import path from "path"

/**
Expand Down Expand Up @@ -444,7 +445,25 @@ function commandExists(cmd: string): boolean {
if (SHELL_BUILTINS.has(cmd)) return true

// PATH executables (synchronous, no shell spawn)
if (which(cmd) !== null) return true
const found = which(cmd)
if (found !== null) return process.platform !== "win32" || matchesCanonicalCase(cmd, found)

return false
}

/**
* Windows PATH lookup is case-insensitive, which erases the casing signal this
* heuristic relies on: a capitalized first word ("Help me fix this bug") is a
* strong natural-language marker, and on POSIX it already fails the lookup.
* Require the typed name to match the executable's on-disk casing so routing
* behaves the same across platforms (LAC-2693).
*/
function matchesCanonicalCase(cmd: string, found: string): boolean {
try {
const base = path.win32.basename(realpathSync.native(found))
const name = base.slice(0, base.length - path.win32.extname(base).length)
return cmd === name || cmd === base
} catch {
return true
}
}
33 changes: 33 additions & 0 deletions packages/opencode/plugin/shell-mode/cwd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,36 @@ export function setCwd(dir: string): void {
export function resetCwd(): void {
currentCwd = null
}

export interface CwdSentinelResult {
exitCode: number | null
cwd: string | null
}

/**
* Parse the payload that follows the cwd sentinel in wrapped shell output:
* "<exitCode>:<cwd>", or legacy "<cwd>" with no exit code.
*
* Shells always report their working directory as an absolute path. A
* non-absolute value means the wrapper template was not expanded by the
* shell that ran it (e.g. cmd.exe echoing a bash-style "$(pwd -P ...)"
* literally, or bash echoing "%CD%"). Such values must never reach
* setCwd — the poisoned cwd is shared process-wide and makes every
* subsequent spawn fail its cwd access check (LAC-2693).
*/
export function parseCwdSentinelPayload(payload: string): CwdSentinelResult {
let exitCode: number | null = null
let cwd: string | null = payload || null
const colonIndex = payload.indexOf(":")
if (colonIndex !== -1) {
const code = parseInt(payload.slice(0, colonIndex), 10)
// A non-numeric prefix means the colon belongs to the cwd itself
// (e.g. a bare "D:\foo" drive path), not an exit-code separator.
if (!isNaN(code)) {
exitCode = code
cwd = payload.slice(colonIndex + 1).trim() || null
}
}
if (cwd && !path.win32.isAbsolute(cwd) && !path.posix.isAbsolute(cwd)) cwd = null
return { exitCode, cwd }
}
2 changes: 1 addition & 1 deletion packages/opencode/plugin/shell-mode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

export { ExecutionMode, ModeController, getModeController, getModeDisplay, type ModeDisplay } from "./mode"
export { shouldRouteToShell } from "./command-check"
export { getCwd, setCwd, resetCwd, CwdEvent } from "./cwd"
export { getCwd, setCwd, resetCwd, parseCwdSentinelPayload, type CwdSentinelResult, CwdEvent } from "./cwd"
export {
execute as SessionShellExecute,
dispose as SessionShellDispose,
Expand Down
14 changes: 4 additions & 10 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { Permission } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { Shell } from "@opencode-ai/core/shell"
import { getCwd, setCwd, detectNaturalLanguage } from "@shell-mode"
import { getCwd, setCwd, parseCwdSentinelPayload, detectNaturalLanguage } from "@shell-mode"
import { ShellID } from "@/tool/shell/id"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Truncate } from "@/tool/truncate"
Expand Down Expand Up @@ -627,15 +627,9 @@ export const layer = Layer.effect(
const afterSentinel = output.slice(sentinelIndex + cwdSentinel.length)
const newlineIndex = afterSentinel.indexOf("\n")
const payload = (newlineIndex !== -1 ? afterSentinel.slice(0, newlineIndex) : afterSentinel).trim()
const colonIndex = payload.indexOf(":")
if (colonIndex !== -1) {
const code = parseInt(payload.slice(0, colonIndex), 10)
if (!isNaN(code)) commandExitCode = code
const newCwd = payload.slice(colonIndex + 1).trim()
if (newCwd) setCwd(newCwd)
} else if (payload) {
setCwd(payload)
}
const parsed = parseCwdSentinelPayload(payload)
if (parsed.exitCode !== null) commandExitCode = parsed.exitCode
if (parsed.cwd) setCwd(parsed.cwd)
cleanOutput = stripSentinel(output)
}

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/test/cli/acp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ describe("opencode acp lifecycle subprocess", () => {
const acp = yield* opencode.acp()
acp.close()

const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
// Generous window: on windows CI runners the CLI's bun startup alone
// can exceed 5s, and it only handles the stdin EOF once booted (LAC-2693)
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(30)))
expect(code).toBe(0)
}),
60_000,
Expand Down
Loading
Loading