fix(server): apply plugin pty environment (#32296)
This commit is contained in:
parent
8cc2276dba
commit
7ad68f8150
8 changed files with 135 additions and 3 deletions
|
|
@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
|
||||||
**Generation Controls**:
|
**Generation Controls**:
|
||||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||||
|
|
||||||
|
**PTY Environment**:
|
||||||
|
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||||
|
|
@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
||||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||||
|
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||||
|
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||||
|
|
|
||||||
|
|
@ -197,8 +197,6 @@ export const layer = Layer.effect(
|
||||||
const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
||||||
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||||
const cwd = input.cwd || location.directory
|
const cwd = input.cwd || location.directory
|
||||||
// TODO: Apply plugin shell.env environment augmentation once V2 plugin hooks exist; legacy
|
|
||||||
// routes merge plugin-provided values into input.env at the boundary.
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
...input.env,
|
...input.env,
|
||||||
|
|
|
||||||
24
packages/opencode/src/plugin/pty-environment.ts
Normal file
24
packages/opencode/src/plugin/pty-environment.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export * as PluginPtyEnvironment from "./pty-environment"
|
||||||
|
|
||||||
|
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
|
import { Plugin } from "."
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
PtyEnvironment.Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const instances = yield* InstanceStore.Service
|
||||||
|
return PtyEnvironment.Service.of({
|
||||||
|
get: Effect.fn("PtyEnvironment.get")(function* (input) {
|
||||||
|
return yield* instances.provide(
|
||||||
|
{ directory: input.directory },
|
||||||
|
plugin
|
||||||
|
.trigger("shell.env", { cwd: input.cwd }, { env: {} as Record<string, string> })
|
||||||
|
.pipe(Effect.map((result) => result.env)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
@ -21,6 +21,7 @@ import { MCP } from "@/mcp"
|
||||||
import { McpAuth } from "@/mcp/auth"
|
import { McpAuth } from "@/mcp/auth"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
|
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { Vcs } from "@/project/vcs"
|
import { Vcs } from "@/project/vcs"
|
||||||
|
|
@ -166,6 +167,7 @@ const instanceRoutes = instanceApiRoutes.pipe(
|
||||||
)
|
)
|
||||||
const serverRoutes = HttpApiBuilder.layer(Api).pipe(
|
const serverRoutes = HttpApiBuilder.layer(Api).pipe(
|
||||||
Layer.provide(handlers),
|
Layer.provide(handlers),
|
||||||
|
Layer.provide(PluginPtyEnvironment.layer),
|
||||||
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
|
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { Context, Config as EffectConfig, Effect, Layer, Queue, Schema } from "e
|
||||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||||
import * as Socket from "effect/unstable/socket/Socket"
|
import * as Socket from "effect/unstable/socket/Socket"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import { mkdir } from "fs/promises"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Pty } from "@opencode-ai/core/pty"
|
import { Pty } from "@opencode-ai/core/pty"
|
||||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||||
|
|
@ -171,4 +174,78 @@ describe("v2 pty HttpApi", () => {
|
||||||
expect(removed.status).toBe(204)
|
expect(removed.status).toBe(204)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
|
||||||
|
"applies plugin shell environment before forced PTY values",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const plugin = path.join(dir, "plugin.ts")
|
||||||
|
const cwd = path.join(dir, "child")
|
||||||
|
yield* Effect.promise(() => mkdir(cwd))
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
plugin,
|
||||||
|
[
|
||||||
|
"export default async () => ({",
|
||||||
|
' "shell.env": (input, output) => {',
|
||||||
|
' output.env.SHARED = "plugin"',
|
||||||
|
' output.env.PLUGIN = "plugin"',
|
||||||
|
' output.env.TERM = "plugin"',
|
||||||
|
" output.env.HOOK_CWD = input.cwd",
|
||||||
|
" },",
|
||||||
|
"})",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: [pathToFileURL(plugin).href], formatter: false, lsp: false }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const created = yield* HttpClientRequest.post("/api/pty").pipe(
|
||||||
|
directoryHeader(dir),
|
||||||
|
HttpClientRequest.bodyJson({
|
||||||
|
command: "/bin/sh",
|
||||||
|
args: [
|
||||||
|
"-c",
|
||||||
|
'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; sleep 5',
|
||||||
|
],
|
||||||
|
cwd,
|
||||||
|
env: { CALLER: "caller", SHARED: "caller", TERM: "caller" },
|
||||||
|
}),
|
||||||
|
Effect.flatMap(HttpClient.execute),
|
||||||
|
)
|
||||||
|
expect(created.status).toBe(200)
|
||||||
|
const info = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)).data
|
||||||
|
|
||||||
|
const socket = yield* Socket.makeWebSocket(
|
||||||
|
`${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&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), 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(yield* takeUntil(`caller|plugin|plugin|xterm-256color|${cwd}`)).toContain(
|
||||||
|
`caller|plugin|plugin|xterm-256color|${cwd}`,
|
||||||
|
)
|
||||||
|
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
|
||||||
|
yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { CorsConfig, isAllowedRequestOrigin } from "../cors"
|
||||||
import { ForbiddenError, PtyNotFoundError } from "../errors"
|
import { ForbiddenError, PtyNotFoundError } from "../errors"
|
||||||
import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty"
|
import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty"
|
||||||
import { response } from "../groups/location"
|
import { response } from "../groups/location"
|
||||||
|
import { PtyEnvironment } from "../pty-environment"
|
||||||
|
|
||||||
const ticketScope = Effect.gen(function* () {
|
const ticketScope = Effect.gen(function* () {
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
@ -21,6 +22,7 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const tickets = yield* PtyTicket.Service
|
const tickets = yield* PtyTicket.Service
|
||||||
const cors = yield* CorsConfig
|
const cors = yield* CorsConfig
|
||||||
|
const environment = yield* PtyEnvironment.Service
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
.handle(
|
.handle(
|
||||||
|
|
@ -33,11 +35,17 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
||||||
"pty.create",
|
"pty.create",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
const pty = yield* Pty.Service
|
const pty = yield* Pty.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const cwd = ctx.payload.cwd || location.directory
|
||||||
return yield* response(
|
return yield* response(
|
||||||
pty.create({
|
pty.create({
|
||||||
...ctx.payload,
|
...ctx.payload,
|
||||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
cwd,
|
||||||
|
env: {
|
||||||
|
...ctx.payload.env,
|
||||||
|
...(yield* environment.get({ directory: location.directory, cwd })),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
16
packages/server/src/pty-environment.ts
Normal file
16
packages/server/src/pty-environment.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export * as PtyEnvironment from "./pty-environment"
|
||||||
|
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (input: { directory: string; cwd: string }) => Effect.Effect<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ServerPtyEnvironment") {}
|
||||||
|
|
||||||
|
export const defaultLayer = Layer.succeed(
|
||||||
|
Service,
|
||||||
|
Service.of({
|
||||||
|
get: () => Effect.succeed({}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
@ -9,10 +9,12 @@ import { ServerAuth } from "./auth"
|
||||||
import { handlers } from "./handlers"
|
import { handlers } from "./handlers"
|
||||||
import { authorizationLayer } from "./middleware/authorization"
|
import { authorizationLayer } from "./middleware/authorization"
|
||||||
import { schemaErrorLayer } from "./middleware/schema-error"
|
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||||
|
import { PtyEnvironment } from "./pty-environment"
|
||||||
|
|
||||||
export function createRoutes(password?: string) {
|
export function createRoutes(password?: string) {
|
||||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||||
Layer.provide(handlers),
|
Layer.provide(handlers),
|
||||||
|
Layer.provide(PtyEnvironment.defaultLayer),
|
||||||
Layer.provide(authorizationLayer),
|
Layer.provide(authorizationLayer),
|
||||||
Layer.provide(schemaErrorLayer),
|
Layer.provide(schemaErrorLayer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue