From 42fc297d30c3bdffb442b09cac512a479c9e3bee Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 17 Aug 2026 16:18:28 -0400 Subject: [PATCH] feat(tui): inherit terminal environment per session (#42957) --- packages/cli/src/commands/handlers/default.ts | 5 ++- packages/cli/src/env.ts | 9 +++++ packages/cli/src/mini.ts | 4 +++ packages/cli/src/run/run.ts | 2 ++ packages/cli/src/session-target.ts | 7 ++++ packages/cli/test/env.test.ts | 24 ++++++++++++++ packages/cli/test/session-target.test.ts | 20 +++++++++++ packages/client/src/effect/api/api.ts | 5 +++ .../client/src/effect/generated/client.ts | 11 +++++++ .../client/src/promise/generated/client.ts | 14 ++++++++ .../client/src/promise/generated/types.ts | 7 ++++ packages/core/src/session.ts | 20 ++++++++++- packages/core/src/session/environment.ts | 33 +++++++++++++++++++ packages/core/src/shell.ts | 20 +++++++++-- packages/core/test/session-create.test.ts | 11 +++++-- .../core/test/session-environment.test.ts | 30 +++++++++++++++++ packages/core/test/session-remove.test.ts | 7 ++++ packages/core/test/tool-shell.test.ts | 30 +++++++++++++++++ packages/protocol/src/groups/session.ts | 14 ++++++++ packages/server/src/handlers/session.ts | 16 +++++++++ packages/tui/src/app.tsx | 14 ++++++++ packages/tui/src/component/prompt/index.tsx | 13 ++++++++ packages/tui/src/context/runtime.tsx | 1 + 23 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 packages/cli/test/env.test.ts create mode 100644 packages/core/src/session/environment.ts create mode 100644 packages/core/test/session-environment.test.ts diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 4b0800fc22c..492a571cc51 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -10,15 +10,17 @@ import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode-ai/util/npm" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version" +import { Env } from "../../env" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { const requestedDirectory = Option.getOrUndefined(input.directory) + const requestedServer = Option.getOrUndefined(input.server) if (requestedDirectory !== undefined) process.chdir(requestedDirectory) const preflight = UpdatePreflight.make() yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), + server: requestedServer, standalone: input.standalone, mismatch: "replace", onStart: (reason, previousVersion) => { @@ -75,6 +77,7 @@ export default Runtime.handler(Commands, (input) => resolve: (spec) => runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))), }, + environment: requestedServer === undefined ? Env.session() : undefined, terminalHandoff: () => preflight.finish(), log: (level, message, tags) => { const effect = diff --git a/packages/cli/src/env.ts b/packages/cli/src/env.ts index 6cc76b793fe..45340c1e0c2 100644 --- a/packages/cli/src/env.ts +++ b/packages/cli/src/env.ts @@ -12,4 +12,13 @@ export const password = Config.redacted("OPENCODE_PASSWORD").pipe( Config.withDefault(undefined), ) +export function session() { + return Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => + entry[1] !== undefined && entry[0] !== "OPENCODE_PASSWORD" && entry[0] !== "OPENCODE_SERVER_PASSWORD", + ), + ) +} + export * as Env from "./env" diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index 2ebcee6fd0a..c6735e922a6 100644 --- a/packages/cli/src/mini.ts +++ b/packages/cli/src/mini.ts @@ -5,6 +5,7 @@ import { setTimeout } from "node:timers/promises" import { readStdin } from "./util/io" import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host" import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target" +import { Env } from "./env" export type MiniCommandInput = { server: { @@ -38,6 +39,7 @@ export async function runMini(input: MiniCommandInput) { const directory = localDirectory() const connection = createMiniConnection(input.server) const sdk = connection.sdk + const environment = input.server.reconnect ? Env.session() : undefined const requested = parseModel(input.model) const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined const prepare = prepareTarget(input.agent) @@ -55,6 +57,7 @@ export async function runMini(input: MiniCommandInput) { fork: input.fork, model: requested, agent: input.agent, + environment, prepare, signal, }).catch((error) => { @@ -89,6 +92,7 @@ export async function runMini(input: MiniCommandInput) { client, location: { directory: next.location.directory, workspace: next.location.workspaceID }, agent: next.agent, + environment, model: next.model ? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } : undefined, diff --git a/packages/cli/src/run/run.ts b/packages/cli/src/run/run.ts index 0336d55d682..9aea2b9d330 100644 --- a/packages/cli/src/run/run.ts +++ b/packages/cli/src/run/run.ts @@ -9,6 +9,7 @@ import { parseSessionTargetModel, resolveSessionTarget } from "../session-target import { toolInlineInfo } from "@opencode-ai/tui/mini/tool" import { runNonInteractivePrompt } from "./noninteractive" import { UI } from "./ui" +import { Env } from "../env" export type RunCommandInput = { server: ServerConnection.Resolved @@ -91,6 +92,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End ? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant } : undefined, agent: input.agent, + environment: input.server.service ? Env.session() : undefined, prepare: async (next) => { const selected = next.model ?? diff --git a/packages/cli/src/session-target.ts b/packages/cli/src/session-target.ts index 4bd670a85d0..4d6a7b20258 100644 --- a/packages/cli/src/session-target.ts +++ b/packages/cli/src/session-target.ts @@ -36,6 +36,7 @@ export async function resolveSessionTarget(input: { fork?: boolean model?: ModelRef agent?: string + environment?: Readonly> prepare: SessionTargetPreparation signal?: AbortSignal }): Promise { @@ -70,6 +71,12 @@ export async function resolveSessionTarget(input: { .catch((error) => { throw new SessionTargetMutationError(error) })) + if (input.environment !== undefined && location.workspaceID === undefined) + await input.client.session + .environment({ sessionID: session.id, variables: input.environment }, ...requestOptions(input.signal)) + .catch((error) => { + throw new SessionTargetMutationError(error) + }) return { session, location, diff --git a/packages/cli/test/env.test.ts b/packages/cli/test/env.test.ts new file mode 100644 index 00000000000..2cbfcac6692 --- /dev/null +++ b/packages/cli/test/env.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test" +import { Env } from "../src/env" + +test("session environment omits server credentials", () => { + const previousPassword = process.env.OPENCODE_PASSWORD + const previousLegacyPassword = process.env.OPENCODE_SERVER_PASSWORD + const previousValue = process.env.OPENCODE_SESSION_ENV_TEST + process.env.OPENCODE_PASSWORD = "password" + process.env.OPENCODE_SERVER_PASSWORD = "legacy" + process.env.OPENCODE_SESSION_ENV_TEST = "included" + + const environment = Env.session() + + if (previousPassword === undefined) delete process.env.OPENCODE_PASSWORD + else process.env.OPENCODE_PASSWORD = previousPassword + if (previousLegacyPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = previousLegacyPassword + if (previousValue === undefined) delete process.env.OPENCODE_SESSION_ENV_TEST + else process.env.OPENCODE_SESSION_ENV_TEST = previousValue + + expect(environment.OPENCODE_PASSWORD).toBeUndefined() + expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined() + expect(environment.OPENCODE_SESSION_ENV_TEST).toBe("included") +}) diff --git a/packages/cli/test/session-target.test.ts b/packages/cli/test/session-target.test.ts index 0a5bf8c55d9..a40e8819c84 100644 --- a/packages/cli/test/session-target.test.ts +++ b/packages/cli/test/session-target.test.ts @@ -55,6 +55,26 @@ describe("session target resolver", () => { expect(target.session.id).toBe("ses_implicit") }) + test("attaches the terminal environment to the resolved local Session", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const selected = session("ses_resume", "/session") + spyOn(client.session, "get").mockResolvedValue(selected) + spyOn(client.location, "get").mockResolvedValue(location("/session")) + const environment = spyOn(client.session, "environment").mockResolvedValue() + + await resolveSessionTarget({ + client, + session: selected.id, + environment: { PATH: "/terminal/bin" }, + prepare, + }) + + expect(environment).toHaveBeenCalledWith({ + sessionID: selected.id, + variables: { PATH: "/terminal/bin" }, + }) + }) + test("prepares a fresh Session at the server Location before creation", async () => { const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const order: string[] = [] diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index b8f8753383d..71d2410e083 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -914,6 +914,10 @@ export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messa export type Endpoint5_34Output = SessionMessage.Info export type SessionMessageOperation = (input: Endpoint5_34Input) => Effect.Effect +export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } } +export type Endpoint5_35Output = void +export type SessionEnvironmentOperation = (input: Endpoint5_35Input) => Effect.Effect + export interface SessionApi { readonly list: SessionListOperation readonly create: SessionCreateOperation @@ -958,6 +962,7 @@ export interface SessionApi { readonly interrupt: SessionInterruptOperation readonly background: SessionBackgroundOperation readonly message: SessionMessageOperation + readonly environment: SessionEnvironmentOperation } export type Endpoint6_0Input = { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index eee41f22980..b1aa1f29d2f 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -86,6 +86,8 @@ import type { Endpoint5_33Output, Endpoint5_34Input, Endpoint5_34Output, + Endpoint5_35Input, + Endpoint5_35Output, Endpoint6_0Input, Endpoint6_0Output, Endpoint7_0Input, @@ -610,6 +612,14 @@ const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34I ), ) +const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) => + preserveEffect()( + raw["session.environment"]({ + params: { sessionID: input["sessionID"] }, + payload: { variables: input["variables"] }, + }).pipe(Effect.mapError(mapClientError)), + ) + const adaptGroup5 = (raw: RawClient["server.session"]) => ({ list: Endpoint5_0(raw), create: Endpoint5_1(raw), @@ -639,6 +649,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({ interrupt: Endpoint5_32(raw), background: Endpoint5_33(raw), message: Endpoint5_34(raw), + environment: Endpoint5_35(raw), }) const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 9daeb90f223..ec9beacca91 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -80,6 +80,8 @@ import type { SessionBackgroundOutput, SessionMessageInput, SessionMessageOutput, + SessionEnvironmentInput, + SessionEnvironmentOutput, MessageListInput, MessageListOutput, ModelListInput, @@ -896,6 +898,18 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/session/${encodeURIComponent(input.sessionID)}/environment`, + body: { variables: input["variables"] }, + successStatus: 204, + declaredStatuses: [404, 401, 400], + empty: true, + }, + requestOptions, + ), }, message: { list: (input: MessageListInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1a080b3432b..32532297870 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -3936,6 +3936,13 @@ export type SessionMessageInput = { export type SessionMessageOutput = { data: SessionMessageInfo }["data"] +export type SessionEnvironmentInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"] +} + +export type SessionEnvironmentOutput = void + export type MessageListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly limit?: { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index c6cfc6728a8..3ecf02305e7 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -51,6 +51,7 @@ import { Global } from "@opencode-ai/util/global" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex.js" import { fileURLToPath } from "url" +import { SessionEnvironment } from "./session/environment.js" // get project -> project.locations // @@ -165,6 +166,10 @@ export interface Interface { input: ForkInput, ) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect + readonly environment: (input: { + readonly sessionID: SessionSchema.ID + readonly variables?: SessionEnvironment.Variables + }) => Effect.Effect readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { sessionID: SessionSchema.ID @@ -304,6 +309,7 @@ const layer = Layer.effect( const locations = yield* LocationServiceMap.Service const fs = yield* FSUtil.Service const jobs = yield* Job.Service + const environments = yield* SessionEnvironment.Service const scope = yield* Scope.Scope const activeShells = new Set() const shellLocks = KeyedMutex.makeUnsafe() @@ -447,6 +453,11 @@ const layer = Layer.effect( if (!session) return yield* new NotFoundError({ sessionID }) return session }), + environment: Effect.fn("Session.environment")(function* (input) { + yield* result.get(input.sessionID) + if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables) + return yield* environments.get(input.sessionID) + }), remove: Effect.fn("Session.remove")(function* (sessionID) { const session = yield* result.get(sessionID) yield* execution.interrupt(sessionID) @@ -454,6 +465,7 @@ const layer = Layer.effect( yield* closeTransport(session) const children = yield* result.list({ parentID: sessionID }) yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true }) + yield* environments.clear(sessionID) yield* bus.publish(SessionEvent.Deleted, { sessionID }) yield* bus.remove(sessionID) }), @@ -652,7 +664,12 @@ const layer = Layer.effect( const started = yield* Effect.gen(function* () { const shell = yield* Shell.Service return yield* shell - .create({ command: input.command, cwd: session.location.directory, timeout: 0 }) + .create({ + command: input.command, + cwd: session.location.directory, + timeout: 0, + metadata: { sessionID: input.sessionID }, + }) .pipe(Effect.orDie) }).pipe(Effect.provide(locations.get(session.location))) yield* bus.publish( @@ -1102,6 +1119,7 @@ export const node = makeGlobalNode({ layer: layer.pipe(Layer.orDie), deps: [ Job.node, + SessionEnvironment.node, Database.node, Bus.node, Project.node, diff --git a/packages/core/src/session/environment.ts b/packages/core/src/session/environment.ts new file mode 100644 index 00000000000..d6671703ad2 --- /dev/null +++ b/packages/core/src/session/environment.ts @@ -0,0 +1,33 @@ +export * as SessionEnvironment from "./environment.js" + +import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" +import { SessionSchema } from "./schema.js" + +export type Variables = Readonly> + +export interface Interface { + readonly get: (sessionID: SessionSchema.ID) => Effect.Effect + readonly set: (sessionID: SessionSchema.ID, variables: Variables) => Effect.Effect + readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionEnvironment") {} + +const layer = Layer.sync(Service, () => { + const environments = new Map() + + return Service.of({ + get: (sessionID) => Effect.sync(() => environments.get(sessionID)), + set: (sessionID, variables) => + Effect.sync(() => { + environments.set(sessionID, { ...variables }) + }), + clear: (sessionID) => + Effect.sync(() => { + environments.delete(sessionID) + }), + }) +}) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 74532379800..a2d52893fff 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -15,6 +15,8 @@ import { Global } from "@opencode-ai/util/global" import { ShellSelect } from "./shell/select.js" import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell" import { PluginHooks } from "./plugin/hooks.js" +import { SessionEnvironment } from "./session/environment.js" +import { SessionSchema } from "./session/schema.js" export class NotFoundError extends Schema.TaggedErrorClass()("Shell.NotFoundError", { id: Shell.ID, @@ -76,6 +78,7 @@ export const layer = (options?: ShellSelect.Options) => const global = yield* Global.Service const environment = yield* Environment.Service const hooks = yield* PluginHooks.Service + const environments = yield* SessionEnvironment.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const sessions = new Map() @@ -184,13 +187,18 @@ export const layer = (options?: ShellSelect.Options) => input: Shell.CreateInput, before?: (input: ShellCreateBefore) => Effect.Effect, ) { + const sessionID = input.metadata?.sessionID + const sessionEnvironment = + location.workspaceID === undefined && Schema.is(SessionSchema.ID)(sessionID) + ? yield* environments.get(sessionID) + : undefined const invocation: ShellCreateBefore = { command: input.command, cwd: input.cwd ?? location.directory, timeout: input.timeout, shell: yield* resolve(), env: { - ...process.env, + ...(sessionEnvironment ?? process.env), TERM: "xterm-256color", OPENCODE_TERMINAL: "1", }, @@ -349,7 +357,15 @@ export function configured(options?: ShellSelect.Options) { return makeLocationNode({ service: Service, layer: layer(options), - deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node], + deps: [ + Bus.node, + Location.node, + Config.node, + Global.node, + Environment.node, + PluginHooks.node, + SessionEnvironment.node, + ], }) } diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 47f9434f59f..127170b72f7 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -679,13 +679,18 @@ describe("Session.create", () => { const created = yield* session.create({ location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), }) + yield* session.environment({ sessionID: created.id, variables: { OPENCODE_SESSION_ENV_TEST: "attached" } }) - yield* session.shell({ sessionID: created.id, command: "echo hello" }) + const command = + process.platform === "win32" + ? "[Console]::Out.Write($env:OPENCODE_SESSION_ENV_TEST)" + : 'printf %s "$OPENCODE_SESSION_ENV_TEST"' + yield* session.shell({ sessionID: created.id, command }) const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") - expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 }) - expect(shell?.output?.output).toContain("hello") + expect(shell).toMatchObject({ type: "shell", command, status: "exited", exit: 0 }) + expect(shell?.output?.output).toContain("attached") expect(shell?.output?.truncated).toBe(false) expect(shell?.time.completed).toBeDefined() }), diff --git a/packages/core/test/session-environment.test.ts b/packages/core/test/session-environment.test.ts new file mode 100644 index 00000000000..ee1db0736e8 --- /dev/null +++ b/packages/core/test/session-environment.test.ts @@ -0,0 +1,30 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Session } from "@opencode-ai/core/session" +import { SessionEnvironment } from "@opencode-ai/core/session/environment" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(SessionEnvironment.node)) + +describe("SessionEnvironment", () => { + it.effect("stores replacement snapshots by session", () => + Effect.gen(function* () { + const environments = yield* SessionEnvironment.Service + const first = Session.ID.make("ses_environment_first") + const second = Session.ID.make("ses_environment_second") + + yield* environments.set(first, { TOOLCHAIN: "first", PATH: "/first/bin" }) + yield* environments.set(second, { TOOLCHAIN: "second" }) + yield* environments.set(first, { TOOLCHAIN: "updated" }) + + expect(yield* environments.get(first)).toEqual({ TOOLCHAIN: "updated" }) + expect(yield* environments.get(second)).toEqual({ TOOLCHAIN: "second" }) + + yield* environments.clear(first) + + expect(yield* environments.get(first)).toBeUndefined() + expect(yield* environments.get(second)).toEqual({ TOOLCHAIN: "second" }) + }), + ) +}) diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index b57e8f4e004..f8fcc0645f9 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -12,6 +12,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionModelTransport } from "@opencode-ai/core/session/model-transport" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionEnvironment } from "@opencode-ai/core/session/environment" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { testEffect } from "./lib/effect" import { globalProjectLayer } from "./lib/project" @@ -32,6 +33,7 @@ const it = testEffect( Bus.node, SessionProjector.node, SessionStore.node, + SessionEnvironment.node, Session.node, LocationServiceMap.node, ]), @@ -50,6 +52,8 @@ describe("Session.remove", () => { const session = yield* Session.Service const parent = yield* session.create({ location }) const child = yield* session.create({ parentID: parent.id }) + yield* session.environment({ sessionID: parent.id, variables: { SESSION_ENV: "parent" } }) + yield* session.environment({ sessionID: child.id, variables: { SESSION_ENV: "child" } }) yield* (yield* LocationServiceMap.Service).contextEffect(location) closed.length = 0 @@ -57,6 +61,9 @@ describe("Session.remove", () => { expect((yield* session.list()).data).toEqual([]) expect(closed).toEqual([parent.id, child.id]) + const environments = yield* SessionEnvironment.Service + expect(yield* environments.get(parent.id)).toBeUndefined() + expect(yield* environments.get(child.id)).toBeUndefined() expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" }) expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" }) }), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index aaa42e96749..aa6134fd688 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -260,6 +260,36 @@ describe("ShellTool", () => { { timeout: 15_000 }, ) + productionIt.live( + "uses the session environment instead of the server environment", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const sessions = yield* Session.Service + yield* sessions.environment({ + sessionID, + variables: { OPENCODE_SESSION_ENV_TEST: "from-session" }, + }) + const command = isWindows + ? "[Console]::Out.Write($env:OPENCODE_SESSION_ENV_TEST)" + : 'printf %s "$OPENCODE_SESSION_ENV_TEST"' + + const settled = yield* executeTool(registry, call({ command })) + + expect(settled.status).toBe("completed") + expect(settled.content?.[0]).toEqual({ type: "text", text: "from-session" }) + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + { timeout: 15_000 }, + ) + it.live("resolves a relative workdir from the active Location", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 6cd75d41d66..5c6c9eb18b8 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -693,6 +693,20 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.put("session.environment", "/api/session/:sessionID/environment", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ variables: Schema.Record(Schema.String, Schema.String) }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.environment", + summary: "Set session environment", + description: "Replace the process environment used by local shell commands for this session.", + }), + ), + ) .annotateMerge( OpenApi.annotations({ title: "session", diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 5813663bf54..e5e71eef312 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -196,6 +196,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.environment", + Effect.fn(function* (ctx) { + yield* session.environment({ sessionID: ctx.params.sessionID, variables: ctx.payload.variables }).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.fork", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9caea2ad3a7..d1b7ced16b4 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -41,6 +41,7 @@ import { useTuiApp, useTuiPaths, useTuiStartup, + useTuiTerminalEnvironment, type TuiApp, } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" @@ -185,6 +186,7 @@ export type TuiInput = { args: Args config: Config.Interface packages: PackageResolver + environment?: Readonly> terminalHandoff?: () => Promise< | { readonly renderer: CliRenderer @@ -332,6 +334,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { : process.env.DISPLAY ? "x11" : undefined, + variables: input.environment, }} > { + if (client.connection.status() !== "connected") return + if (route.data.type !== "session") return + const session = data.session.get(route.data.sessionID) + if (!session) return + if (session.location.workspaceID !== undefined || terminalEnvironment.variables === undefined) return + void client.api.session + .environment({ sessionID: session.id, variables: terminalEnvironment.variables }) + .catch(toast.error) + }) const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", { initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH }, }) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 233ccb0e17c..e3e37358a8d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1205,6 +1205,19 @@ export function Prompt(props: PromptProps) { sessionID = created.id session = created + if (created.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) { + const error = await client.api.session + .environment({ sessionID, variables: terminalEnvironment.variables }) + .then( + () => undefined, + (error) => error, + ) + if (error) { + if (finishMoveProgress) move.finishSubmit() + toast.show({ title: "Failed to set session environment", message: errorMessage(error), variant: "error" }) + return true + } + } } // Capture mode before it gets reset diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx index d59a63abe6e..80aca29ddea 100644 --- a/packages/tui/src/context/runtime.tsx +++ b/packages/tui/src/context/runtime.tsx @@ -17,6 +17,7 @@ export type TuiTerminalEnvironment = Readonly<{ platform: string multiplexer?: "tmux" | "screen" displayServer?: "wayland" | "x11" + variables?: Readonly> }> export type TuiStartup = Readonly<{