refactor(core): canonicalize pty service (#32182)
This commit is contained in:
parent
7efade2d53
commit
f2cf607376
30 changed files with 1132 additions and 504 deletions
|
|
@ -1,30 +0,0 @@
|
|||
export * as PtyPreparation from "./pty-preparation"
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const prepareCreate = Effect.fn("PtyPreparation.prepareCreate")(function* (input: Pty.CreateInput) {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const command = input.command || Shell.preferred((yield* config.get()).shell)
|
||||
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || (yield* InstanceState.context).directory
|
||||
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
...shell.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
if (process.platform === "win32") {
|
||||
env.LC_ALL = "C.UTF-8"
|
||||
env.LC_CTYPE = "C.UTF-8"
|
||||
env.LANG = "C.UTF-8"
|
||||
}
|
||||
return { command, args, cwd, title: input.title, env }
|
||||
})
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { Context } from "effect"
|
||||
|
||||
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
|
||||
|
||||
export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
|
||||
|
||||
export const CorsConfig = Context.Reference<CorsOptions | undefined>("@opencode/ServerCorsConfig", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
|
||||
if (!input) return true
|
||||
if (input.startsWith("http://localhost:")) return true
|
||||
if (input.startsWith("http://127.0.0.1:")) return true
|
||||
if (input.startsWith("oc://renderer")) return true
|
||||
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||
return true
|
||||
if (opencodeOrigin.test(input)) return true
|
||||
return opts?.cors?.includes(input) ?? false
|
||||
}
|
||||
|
||||
export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) {
|
||||
if (!input) return true
|
||||
if (host && sameHost(input, host)) return true
|
||||
return isAllowedCorsOrigin(input, opts)
|
||||
}
|
||||
|
||||
function sameHost(origin: string, host: string) {
|
||||
try {
|
||||
return new URL(origin).host === host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,22 @@
|
|||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { registerDisposer } from "@/effect/instance-registry"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { PtyPreparation } from "@/pty-preparation"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { handlePtyInput } from "@opencode-ai/core/pty/input"
|
||||
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { Effect, Layer, Option, Queue, Schema } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
|
|
@ -36,10 +35,14 @@ const ticketScope = Effect.gen(function* () {
|
|||
return { directory: instance?.directory, workspaceID }
|
||||
})
|
||||
|
||||
// Legacy surface compatibility: before exited-session retention, sessions vanished the moment
|
||||
// their process exited. These routes preserve that observable behavior — exited sessions are
|
||||
// invisible here — while the canonical /api/pty surface exposes them until removal.
|
||||
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
const plugin = yield* Plugin.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const unregister = registerDisposer((directory) =>
|
||||
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
|
|
@ -59,33 +62,42 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
|||
})
|
||||
|
||||
const list = Effect.fn("PtyHttpApi.list")(function* () {
|
||||
return yield* pty(Pty.Service.use((service) => service.list()))
|
||||
const sessions = yield* pty(Pty.Service.use((service) => service.list()))
|
||||
return sessions.filter((info) => info.status === "running")
|
||||
})
|
||||
|
||||
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
||||
const cwd = ctx.payload.cwd || (yield* InstanceState.context).directory
|
||||
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} as Record<string, string> })
|
||||
return yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
Effect.flatMap(
|
||||
PtyPreparation.prepareCreate({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||
}),
|
||||
service.create,
|
||||
),
|
||||
service.create({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
cwd,
|
||||
env: { ...ctx.payload.env, ...shell.env },
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
return yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
(error) =>
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flatMap((info) =>
|
||||
info.status === "running"
|
||||
? Effect.succeed(info)
|
||||
: new ApiError.PtyNotFoundError({
|
||||
ptyID: ctx.params.ptyID,
|
||||
message: `PTY session not found: ${ctx.params.ptyID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -94,6 +106,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
|||
params: { ptyID: PtyID }
|
||||
payload: typeof Pty.UpdateInput.Type
|
||||
}) {
|
||||
yield* get(ctx)
|
||||
return yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
service.update(ctx.params.ptyID, {
|
||||
|
|
@ -102,26 +115,27 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
|||
}),
|
||||
),
|
||||
).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
(error) =>
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
yield* get(ctx)
|
||||
yield* pty(Pty.Service.use((service) => service.remove(ctx.params.ptyID))).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
(error) =>
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return true
|
||||
|
|
@ -131,16 +145,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
|||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
|
||||
return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" })
|
||||
yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new ApiError.PtyNotFoundError({
|
||||
ptyID: error.ptyID,
|
||||
message: `PTY session not found: ${error.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* get(ctx)
|
||||
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
|
||||
})
|
||||
|
||||
|
|
@ -180,7 +185,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
|
|||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const exists = yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe(
|
||||
Effect.as(true),
|
||||
Effect.map((info) => info.status === "running"),
|
||||
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return HttpServerResponse.empty({ status: 404 })
|
||||
|
|
@ -214,48 +219,53 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne
|
|||
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
|
||||
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
|
||||
}
|
||||
let closed = false
|
||||
const adapter = {
|
||||
get readyState() {
|
||||
return closed ? 3 : 1
|
||||
},
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||
if (closed) return
|
||||
writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data))
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
writeScoped(write(new Socket.CloseEvent(code, reason)))
|
||||
},
|
||||
}
|
||||
const handler = yield* pty(
|
||||
Pty.Service.use((service) => service.connect(ctx.params.ptyID, adapter, cursor)),
|
||||
).pipe(
|
||||
Effect.catchTag("Pty.NotFoundError", () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
// The handshake runs inside `socket.runRaw`, after the input callback is
|
||||
// registered, so the client cannot send frames before PTY input is wired.
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
closed = true
|
||||
handler.onClose()
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
// Outbound frames flow through one queue drained by a single writer so replay, live
|
||||
// output, and the close frame keep their order.
|
||||
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
|
||||
const attachment = yield* pty(
|
||||
Pty.Service.use((service) =>
|
||||
service.attach(ctx.params.ptyID, {
|
||||
cursor,
|
||||
onData: (chunk) => Queue.offerUnsafe(outbox, chunk),
|
||||
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Effect.catchTags({
|
||||
"Pty.NotFoundError": () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
"Pty.ExitedError": () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
}),
|
||||
)
|
||||
if (!attachment) return HttpServerResponse.empty()
|
||||
|
||||
for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk)
|
||||
Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor))
|
||||
attachment.activate()
|
||||
|
||||
const drain = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const item = yield* Queue.take(outbox)
|
||||
yield* write(item)
|
||||
if (item instanceof Socket.CloseEvent) return
|
||||
}
|
||||
})
|
||||
|
||||
// The reader runs concurrently with the writer; whichever finishes first ends the
|
||||
// connection and the attachment is always released.
|
||||
yield* Effect.race(
|
||||
drain,
|
||||
socket.runRaw((message) => {
|
||||
const decoded = PtyProtocol.decodeInput(message)
|
||||
if (decoded !== undefined) attachment.write(decoded)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => attachment.detach())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
|||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { HttpApiApp } from "./routes/instance/httpapi/server"
|
|||
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
|
||||
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
|
||||
import { PublicApi } from "./routes/instance/httpapi/public"
|
||||
import type { CorsOptions } from "./cors"
|
||||
import type { CorsOptions } from "@opencode-ai/server/cors"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import { Tool } from "@/tool/tool"
|
|||
import { Permission } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||
bash: { login: true, posix: true },
|
||||
dash: { login: true, posix: true },
|
||||
fish: { deny: true, login: true },
|
||||
ksh: { login: true, posix: true },
|
||||
nu: { deny: true },
|
||||
powershell: { ps: true },
|
||||
pwsh: { ps: true },
|
||||
sh: { login: true, posix: true },
|
||||
zsh: { login: true, posix: true },
|
||||
}
|
||||
|
||||
export type Item = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
if (!pid || opts?.exited?.()) return
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await new Promise<void>((resolve) => {
|
||||
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
killer.once("exit", () => resolve())
|
||||
killer.once("error", () => resolve())
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
proc.kill("SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
proc.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
}
|
||||
if (name(shell) === "bash") return gitbash() || which(shell) || shell
|
||||
return which(shell) || shell
|
||||
}
|
||||
|
||||
function meta(file: string) {
|
||||
return META[name(file)]
|
||||
}
|
||||
|
||||
function ok(file: string) {
|
||||
return meta(file)?.deny !== true
|
||||
}
|
||||
|
||||
function rooted(file: string) {
|
||||
return path.isAbsolute(Filesystem.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string) {
|
||||
const shell = full(file)
|
||||
if (rooted(shell)) {
|
||||
if (Filesystem.stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return which(shell) ?? undefined
|
||||
}
|
||||
|
||||
function win() {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.map(full),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function unix() {
|
||||
const text = await Filesystem.readText("/etc/shells").catch(() => "")
|
||||
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win()[0]!
|
||||
return fallback()
|
||||
}
|
||||
|
||||
export function gitbash() {
|
||||
if (process.platform !== "win32") return
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
export function login(file: string) {
|
||||
return meta(file)?.login === true
|
||||
}
|
||||
|
||||
export function posix(file: string) {
|
||||
return meta(file)?.posix === true
|
||||
}
|
||||
|
||||
export function ps(file: string) {
|
||||
return meta(file)?.ps === true
|
||||
}
|
||||
|
||||
function info(file: string): Item {
|
||||
const item = full(file)
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n) ? n : item,
|
||||
acceptable: ok(item),
|
||||
}
|
||||
}
|
||||
|
||||
export function args(file: string, command: string, cwd: string) {
|
||||
const n = name(file)
|
||||
if (n === "nu" || n === "fish") return ["-c", command]
|
||||
if (n === "zsh") {
|
||||
return [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
]
|
||||
}
|
||||
if (n === "bash") {
|
||||
return [
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
shopt -s expand_aliases
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
cd -- "$1"
|
||||
eval ${JSON.stringify(command)}
|
||||
`,
|
||||
"opencode",
|
||||
cwd,
|
||||
]
|
||||
}
|
||||
if (n === "cmd") return ["/c", command]
|
||||
if (ps(file)) return ["-NoProfile", "-Command", command]
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
const defaultPreferred = lazy(() => select(process.env.SHELL))
|
||||
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
|
||||
export function preferred(configShell?: string) {
|
||||
if (configShell) return select(configShell)
|
||||
return defaultPreferred()
|
||||
}
|
||||
preferred.reset = () => defaultPreferred.reset()
|
||||
|
||||
export function acceptable(configShell?: string) {
|
||||
if (configShell) return select(configShell, { acceptable: true })
|
||||
return defaultAcceptable()
|
||||
}
|
||||
acceptable.reset = () => defaultAcceptable.reset()
|
||||
|
||||
export async function list(): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win() : await unix()
|
||||
return shells.filter((s) => resolve(s)).map(info)
|
||||
}
|
||||
|
||||
export * as Shell from "./shell"
|
||||
|
|
@ -12,7 +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 { Shell } from "@/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellID } from "./shell/id"
|
||||
|
||||
import * as Truncate from "./truncate"
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { PtyPreparation } from "../../src/pty-preparation"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
Shell.preferred.reset()
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, Plugin.defaultLayer))
|
||||
const preparationIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Config.Service)({ get: () => Effect.succeed({}) }),
|
||||
Layer.mock(Plugin.Service)({
|
||||
trigger: <Name extends string, Input, Output>(_name: Name, _input: Input, output: Output) =>
|
||||
Effect.sync(() => {
|
||||
const result = output as { env: Record<string, string> }
|
||||
result.env.INPUT = "plugin"
|
||||
result.env.FROM_PLUGIN = "plugin"
|
||||
result.env.TERM = "plugin"
|
||||
return output
|
||||
}),
|
||||
list: () => Effect.succeed([]),
|
||||
init: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const preparePty = (input: Pty.CreateInput) => PtyPreparation.prepareCreate(input)
|
||||
|
||||
describe("pty shell args", () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const ps = Bun.which("pwsh") || Bun.which("powershell")
|
||||
if (ps) {
|
||||
it.instance(
|
||||
"does not add login args to pwsh",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* preparePty({ command: ps, title: "pwsh" })
|
||||
expect(info.args).toEqual([])
|
||||
}),
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
}
|
||||
|
||||
const bash = (() => {
|
||||
const shell = Shell.preferred()
|
||||
if (Shell.name(shell) === "bash") return shell
|
||||
return Shell.gitbash()
|
||||
})()
|
||||
if (bash) {
|
||||
it.instance(
|
||||
"adds login args to bash",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* preparePty({ command: bash, title: "bash" })
|
||||
expect(info.args).toEqual(["-l"])
|
||||
}),
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("pty configured shell", () => {
|
||||
const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash")
|
||||
|
||||
it.instance(
|
||||
"uses configured shell for default PTY command",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
if (!configured) return
|
||||
|
||||
const info = yield* preparePty({ title: "configured" })
|
||||
if (process.platform === "win32") {
|
||||
expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
|
||||
} else {
|
||||
expect(info.command).toBe(configured)
|
||||
}
|
||||
expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"])
|
||||
}),
|
||||
configured ? { config: { shell: Shell.name(configured) } } : undefined,
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
})
|
||||
|
||||
describe("pty environment preparation", () => {
|
||||
preparationIt.instance("merges plugin environment before forced PTY values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { command: "/bin/sh", args: [] as string[], env: { INPUT: "caller" } }
|
||||
const prepared = yield* preparePty(input)
|
||||
|
||||
expect(input.args).toEqual([])
|
||||
expect(prepared.env.INPUT).toBe("plugin")
|
||||
expect(prepared.env.FROM_PLUGIN).toBe("plugin")
|
||||
expect(prepared.env.TERM).toBe("xterm-256color")
|
||||
expect(prepared.env.OPENCODE_TERMINAL).toBe("1")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -757,6 +757,44 @@ const scenarios: Scenario[] = [
|
|||
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
|
||||
.at((ctx) => ({ path: "/api/fs/find?query=hello&type=file", headers: ctx.headers() }))
|
||||
.json(200, locationData(array)),
|
||||
http.protected.get("/api/pty", "v2.pty.list").json(200, locationData(array)),
|
||||
http.protected
|
||||
.post("/api/pty", "v2.pty.create")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: "/api/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API V2 PTY") }))
|
||||
.json(200, locationData(object)),
|
||||
http.protected
|
||||
.get("/api/pty/{ptyID}", "v2.pty.get")
|
||||
.at((ctx) => ({ path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.put("/api/pty/{ptyID}", "v2.pty.update")
|
||||
.mutating()
|
||||
.at((ctx) => ({
|
||||
path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }),
|
||||
headers: ctx.headers(),
|
||||
body: { title: "missing" },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.delete("/api/pty/{ptyID}", "v2.pty.remove")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/pty/{ptyID}/connect-token", "v2.pty.connectToken")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/pty/{ptyID}/connect-token", { ptyID: "pty_httpapi_missing" }),
|
||||
headers: { ...ctx.headers(), "x-opencode-ticket": "1" },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.get("/api/pty/{ptyID}/connect", "v2.pty.connect")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(404, undefined, "none"),
|
||||
http.protected.get("/api/reference", "v2.reference.list").json(200, object),
|
||||
http.protected
|
||||
.get("/api/provider/{providerID}", "v2.provider.get")
|
||||
|
|
|
|||
|
|
@ -136,6 +136,33 @@ describe("pty HttpApi bridge", () => {
|
|||
})
|
||||
})
|
||||
|
||||
testPty("hides exited sessions on the legacy surface", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const created = await app().request(PtyPaths.create, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 0"] }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const info = await created.json()
|
||||
|
||||
// Exited sessions are retained by core for the canonical surface, but the legacy
|
||||
// routes preserve pre-retention behavior: exited sessions are invisible here.
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline) {
|
||||
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
|
||||
if (found.status === 404) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
|
||||
expect(found.status).toBe(404)
|
||||
|
||||
const list = await app().request(PtyPaths.list, { headers })
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toEqual([])
|
||||
})
|
||||
|
||||
testPty("disposes PTY sessions with their legacy instance", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
|
|
|||
171
packages/opencode/test/server/httpapi-v2-pty.test.ts
Normal file
171
packages/opencode/test/server/httpapi-v2-pty.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context, Config as EffectConfig, Effect, Layer, Queue, Schema } from "effect"
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
function request(route: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost${route}`, {
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
|
||||
}),
|
||||
)
|
||||
|
||||
const servedRoutes: Layer.Layer<never, EffectConfig.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
)
|
||||
|
||||
const effectIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
Socket.layerWebSocketConstructorGlobal,
|
||||
servedRoutes.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
|
||||
|
||||
const serverUrl = () => HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("v2 pty HttpApi", () => {
|
||||
testPty("serves location-wrapped PTY routes and retains exited sessions", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
|
||||
const empty = await request("/api/pty", tmp.path)
|
||||
expect(empty.status).toBe(200)
|
||||
expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Pty.Info)))(await empty.json()).data).toEqual([])
|
||||
|
||||
const created = await request("/api/pty", tmp.path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 4"], title: "v2" }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const body = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json())
|
||||
expect(String(body.location.directory)).toBe(tmp.path)
|
||||
expect(body.data.title).toBe("v2")
|
||||
|
||||
// The canonical surface keeps exited sessions observable with their exit code.
|
||||
const deadline = Date.now() + 5_000
|
||||
let info: { status: string; exitCode?: number } | undefined
|
||||
while (Date.now() < deadline) {
|
||||
const found = await request(`/api/pty/${body.data.id}`, tmp.path)
|
||||
expect(found.status).toBe(200)
|
||||
info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await found.json()).data
|
||||
if (info.status === "exited") break
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
expect(info).toMatchObject({ status: "exited", exitCode: 4 })
|
||||
|
||||
const removed = await request(`/api/pty/${body.data.id}`, tmp.path, { method: "DELETE" })
|
||||
expect(removed.status).toBe(204)
|
||||
|
||||
const missing = await request(`/api/pty/${body.data.id}`, tmp.path)
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toMatchObject({ _tag: "PtyNotFoundError", ptyID: body.data.id })
|
||||
})
|
||||
|
||||
testPty("rejects connect tokens without the CSRF header and connects with a valid ticket", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const created = await request("/api/pty", tmp.path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json()).data
|
||||
|
||||
try {
|
||||
const forbidden = await request(`/api/pty/${info.id}/connect-token`, tmp.path, { method: "POST" })
|
||||
expect(forbidden.status).toBe(403)
|
||||
expect(await forbidden.json()).toMatchObject({ _tag: "ForbiddenError" })
|
||||
|
||||
const token = await request(`/api/pty/${info.id}/connect-token`, tmp.path, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
})
|
||||
expect(token.status).toBe(200)
|
||||
const ticket = Schema.decodeUnknownSync(Location.response(PtyTicket.ConnectToken))(await token.json()).data.ticket
|
||||
expect(ticket).toBeTruthy()
|
||||
|
||||
const invalid = await request(`/api/pty/${info.id}/connect?ticket=not-a-ticket`, tmp.path)
|
||||
expect(invalid.status).toBe(403)
|
||||
} finally {
|
||||
await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" })
|
||||
}
|
||||
})
|
||||
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||
"serves PTY websocket output and input through the canonical route",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||
const created = yield* HttpClientRequest.post("/api/pty").pipe(
|
||||
directoryHeader(dir),
|
||||
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }),
|
||||
Effect.flatMap(HttpClient.execute),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)
|
||||
const info = body.data
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`,
|
||||
{ closeCodeIsError: () => false },
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket
|
||||
.runRaw((message) =>
|
||||
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
|
||||
)
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
|
||||
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
|
||||
if (next.includes(expected)) return next
|
||||
return yield* takeUntil(expected, next)
|
||||
})
|
||||
|
||||
yield* write("ping-v2\n")
|
||||
expect(yield* takeUntil("ping-v2")).toContain("ping-v2")
|
||||
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute)
|
||||
expect(removed.status).toBe(204)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -43,7 +43,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { SystemPrompt } from "../../src/session/system"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||
const prev = process.env.SHELL
|
||||
if (shell === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = shell
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SHELL
|
||||
else process.env.SHELL = prev
|
||||
Shell.acceptable.reset()
|
||||
Shell.preferred.reset()
|
||||
}
|
||||
}
|
||||
|
||||
describe("shell", () => {
|
||||
test("normalizes shell names", () => {
|
||||
expect(Shell.name("/bin/bash")).toBe("bash")
|
||||
if (process.platform === "win32") {
|
||||
expect(Shell.name("C:/tools/NU.EXE")).toBe("nu")
|
||||
expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh")
|
||||
}
|
||||
})
|
||||
|
||||
test("detects login shells", () => {
|
||||
expect(Shell.login("/bin/bash")).toBe(true)
|
||||
expect(Shell.login("C:/tools/pwsh.exe")).toBe(false)
|
||||
})
|
||||
|
||||
test("detects posix shells", () => {
|
||||
expect(Shell.posix("/bin/bash")).toBe(true)
|
||||
expect(Shell.posix("/bin/fish")).toBe(false)
|
||||
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
|
||||
})
|
||||
|
||||
test("falls back when configured shell cannot be resolved", async () => {
|
||||
await withShell(undefined, async () => {
|
||||
const preferred = Shell.preferred()
|
||||
const acceptable = Shell.acceptable()
|
||||
expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
|
||||
expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back for terminal-only acceptable shells", () => {
|
||||
expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
|
||||
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
expect(Shell.name(Shell.acceptable())).not.toBe("nu")
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves /usr/bin/bash from env to Git Bash", async () => {
|
||||
const bash = Shell.gitbash()
|
||||
if (!bash) return
|
||||
await withShell("/usr/bin/bash", async () => {
|
||||
expect(Shell.acceptable()).toBe(bash)
|
||||
expect(Shell.preferred()).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare bash to Git Bash before PATH", async () => {
|
||||
const bash = Shell.gitbash()
|
||||
if (!bash) return
|
||||
expect(Shell.acceptable("bash")).toBe(bash)
|
||||
expect(Shell.preferred("bash")).toBe(bash)
|
||||
await withShell("bash", async () => {
|
||||
expect(Shell.acceptable()).toBe(bash)
|
||||
expect(Shell.preferred()).toBe(bash)
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves bare PowerShell shells", async () => {
|
||||
const shell = which("pwsh") || which("powershell")
|
||||
if (!shell) return
|
||||
await withShell(path.win32.basename(shell), async () => {
|
||||
expect(Shell.preferred()).toBe(shell)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import type * as Scope from "effect/Scope"
|
|||
import os from "os"
|
||||
import path from "path"
|
||||
import { Config } from "@/config/config"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { ShellTool } from "../../src/tool/shell"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue