diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 683813f13..53c89eed4 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -302,6 +302,7 @@ jobs: packages/ui/src/components/prompt-input/usePromptState.test.ts packages/ui/src/components/session/provider-usage-panel.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts + packages/ui/src/lib/sdk-manager.test.ts packages/ui/src/stores/app-tabs.test.ts packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts @@ -403,6 +404,8 @@ jobs: node --import tsx --test packages/server/src/workspaces/__tests__/spawn.test.ts packages/server/src/workspaces/__tests__/binary-probe.test.ts + packages/server/src/workspaces/session-environment.test.ts + packages/server/src/server/__tests__/session-environment.test.ts "packages/server/src/workspaces/*opencode*.test.ts" packages/server/src/server/routes/binary-validation.test.ts "packages/server/src/opencode-update/*.test.ts" diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index dc74c0bf1..cc14ac2f4 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -23,7 +23,7 @@ description: | - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. - OpenCode owns session APIs, native Forms, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. Forms list through `client.form.list({ location })` and settle through `client.session.form.reply/cancel`; global Forms use the encoded directory header. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. - CodeNomad owns explicit Stop Workspace eviction, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. Tab/window close only detaches local UI and never evicts. -- OpenCode owns the global daemon's standard state and database. Allowed configured environment variables apply only to `service start` for a missing daemon; an existing daemon is unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. +- OpenCode owns the global daemon's standard state and database. Configured environment variables are passed to `service start` for a missing daemon; an existing daemon is unchanged. Before each session prompt/command/shell send, the authorized proxy also applies the profile's complete execution-host environment via `session.environment`. `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. See `dev-docs/SESSION_ENVIRONMENT.md` for scope and limitations. - Native desktop identity is channel plus config profile: one singleton process/backend per profile and multiple UUID windows. A second launch opens another window by default; Advanced settings can restore MRU focus, while `--new-window` always requests another window. Stable/dev/non-default profiles isolate native state; OpenCode sessions/messages are shared while tabs/drafts/views are per-window. - Client-state V3 is a per-window envelope over the V2 content-addressed partition graph with atomic publication/migration, ownership-fenced writes, and conservative post-commit GC. Iframe preview fallbacks are sandboxed without same-origin access; native browser guests use isolated storage and no application capabilities. DOM comment inspection is web-only. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index 411870fb5..a42c03924 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -21,6 +21,7 @@ Do not replace the shared network service with `@opencode-ai/sdk` unless CodeNom | Location | `client.location.get`, `client.debug.location.evict` | shared service wrapper | | Events | `client.event.subscribe()` | `packages/server/src/workspaces/instance-events.ts` | | Sessions | `list/get/create/fork/remove/update/prompt/command/shell/interrupt` | UI session stores | +| Session environment | `client.session.environment({ sessionID, variables })` replaces a complete snapshot before prompt/command/shell admission | guarded server proxy; `workspaces/session-environment.ts` builds host/WSL values | | Instructions | `client.session.instructions.entry.put/remove` | conversation-mode prompt setup | | Permissions | `permission.request.list`, `permission.reply` | UI and server Yolo replier | | Forms | `client.form.list`, `client.session.form.reply`, `client.session.form.cancel` | `packages/ui/src/stores/instances.ts`, `forms.ts` | diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index de4e5d7da..0933ca54d 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -23,7 +23,7 @@ - WSL requires Windows localhost forwarding, executes lifecycle commands inside Linux, and never uses cross-namespace PID operations. - A workspace stop evicts its location; it does not stop a dedicated process or the global daemon. - The worktree deletion fence covers OpenCode proxy writes plus CodeNomad file and Git mutations for the same canonical worktree identity. -- OpenCode owns standard state/database. Allowed configured environment variables apply only when starting a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` are ignored. +- OpenCode owns standard state/database. Allowed configured environment variables are passed when starting a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` are ignored. Independently, before each session prompt/command/shell send, the authorized proxy awaits `session.environment` with the profile's complete execution-host snapshot. A failure blocks the send; it is never silently skipped or retried. - The native event stream is volatile. Reconnect must reconcile authoritative state; use current `session.*`, `filesystem.changed`, and `config.updated` names rather than obsolete event aliases. ## Ownership Matrix diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index c827538e9..4002445a4 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -4,7 +4,7 @@ `WorkspaceManager` owns one `OpenCodeSharedService`. Production runs the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle, validates the authenticated loopback endpoint, creates one Promise client, and invalidates failed connections. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and performs no cross-namespace PID operations. -OpenCode owns standard state/database. Allowed configured environment variables apply only to `service start` for a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership variables are ignored. +OpenCode owns standard state/database. Allowed configured environment variables are passed to `service start` for a missing daemon; existing daemons are unchanged, and `OPENCODE_DB`/`XDG_STATE_HOME` ownership variables are ignored. The guarded proxy also applies a complete profile environment through `session.environment` before every session prompt/command/shell send. Reads do not mutate it. See `dev-docs/SESSION_ENVIRONMENT.md`. ## Locations And Directories diff --git a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md index e26cb2ac0..167211537 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md +++ b/.opencode/skills/codenomad-architecture-guide/references/server-conventions.md @@ -14,7 +14,7 @@ - Model workspaces with native `LocationRef`/directories in `packages/server/src/workspaces/manager.ts`. - Never spawn or stop OpenCode per workspace or add general plugin installation/packaging. The narrow integrations are the bundled `codenomad.automation` plugin and the bundled session-pruning RPC under `src/opencode/session-pruning/`. Bundled definitions follow backend presence through native V2 discovery; execution still validates the invoking session's current location. Tool availability must not depend on the source checkout or Developer Mode. - Explicit Stop Workspace evicts the location; ordinary UI close never calls workspace deletion. WSL requires localhost forwarding and no cross-namespace PID operations. -- Leave global service state/database ownership to OpenCode. Pass allowed environment only when starting a missing daemon; leave an existing daemon unchanged and ignore `OPENCODE_DB`/`XDG_STATE_HOME`. +- Leave global service state/database ownership to OpenCode. Pass startup environment when starting a missing daemon; leave an existing daemon unchanged and ignore `OPENCODE_DB`/`XDG_STATE_HOME`. Apply the profile's complete execution-host environment via `session.environment` before each authorized session prompt/command/shell send; see `dev-docs/SESSION_ENVIRONMENT.md`. ## Trust Boundaries diff --git a/AGENTS.md b/AGENTS.md index 5c57b387b..c63606c29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ ## Coding Principles +- Profile environment variables are applied server-side before each native session prompt, custom command or session shell request, after ownership and worktree-mutation admission. Build a complete execution-host snapshot with `workspaces/session-environment.ts`; never send the profile environment through the browser or skip the per-send write using a cache. Reads and settings edits do not mutate native sessions. Keep native environment failures fail-closed and redact SDK request bodies. See `dev-docs/SESSION_ENVIRONMENT.md`. + - One bundled `codenomad.automation` V2 plugin uses native discovery and backend presence. All browser/developer tools are available without a Developer Mode toggle; native instrumentation starts with the desktop host. Loading, tool availability and execution targeting are separate: retain the authenticated bridge and session/window fences. Tauri preview children receive no application capabilities; primary-renderer reload must dispose them and final-window checks must count native windows. See `dev-docs/BROWSER_AUTOMATION.md`. - Desktop automation and pruning provisioning follows the authenticated OpenCode connection and its `config.get` global discovery directory, including reconnects. Never derive a running daemon's roots from backend/startup environment or CLI `debug paths`. OpenCode watches the entire discovery root recursively: keep changing presence leases outside it, in the sibling `.codenomad//` namespace. Retain existing outside-root storage; migrate inside-root storage while reading older backends' leases without writing there. WSL translates the daemon-reported paths through the selected distro only for filesystem access. Verify heartbeat stability with the isolated native automation fixture. diff --git a/dev-docs/SESSION_ENVIRONMENT.md b/dev-docs/SESSION_ENVIRONMENT.md new file mode 100644 index 000000000..c5a82b715 --- /dev/null +++ b/dev-docs/SESSION_ENVIRONMENT.md @@ -0,0 +1,74 @@ +# Profile environment at send time + +The profile's existing `server.environmentVariables` setting is applied before +every native `session.prompt`, `session.command`, and `session.shell` request +sent through CodeNomad. The server awaits `session.environment({ sessionID, +variables })` before forwarding the original request. Electron, Tauri and web +clients use the same server path. + +Opening, reading or creating a conversation does not set its environment. Saving +settings does not walk existing sessions. Every conversation receives the current +profile settings on its next send, including resumed conversations and forks +that the user subsequently prompts. There is no polling, per-session settings +store, applied-version cache, plugin or daemon restart. + +## Implementation + +- The guarded instance proxy applies the environment only after ownership checks + and acquisition of the worktree mutation fence. Both writes use the acquired + connection; a stale connection or disconnected request prevents forwarding. +- `WorkspaceManager.getSessionEnvironment` reads current settings for each send. +- `workspaces/session-environment.ts` builds the **complete** snapshot required by + the native replacement API. On the host this is the backend process environment + plus profile overrides, merging keys case-insensitively on Windows. +- With WSL, an asynchronous, bounded `wsl.exe --distribution --exec env -0` + reads the execution environment for each send. Windows PATH/HOME are not copied + into Linux. Configured paths must be meaningful inside the selected distro. +- Clearing an override restores the execution host's inherited value (or removes + the key when absent there), rather than retaining the previous session value. +- Internal server authentication variables and OpenCode storage ownership + variables are omitted. Full snapshots never pass through the browser. Native + request errors are not logged or reflected, since they can include these values. +- A failed or unsupported environment call fails the send with a localized error; + it does not silently send using stale values or retry the mutation. Existing UI + send failure/draft handling remains responsible for restoring the user's input. + +For host execution the additional work is an in-memory merge and one local API +request per send. WSL additionally launches the environment reader. No performance +claim is made without measurement. + +## Scope + +This controls the targeted conversation's **local shell commands**. It does not +reconfigure providers, MCP/LSP servers, independent PTYs, location-only shells, +or the daemon's own temporary files. Existing startup-variable behavior remains: +allowed variables are also passed when CodeNomad starts a stopped service. + +The environment belongs to the native session, not the client or the individual +prompt. Other clients can replace it. A send while a session is already working +can affect its subsequent command launches; running processes keep their existing +environment. Queued prompts do not capture separate environment snapshots. + +OpenCode-created child sessions that execute without a CodeNomad send are outside +this interception point. Do not claim automatic subagent inheritance. The native +API is not an atomic environment-plus-prompt transaction; no global cross-client +isolation is added by this change. + +## Validation + +Unit/HTTP integration tests cover full snapshots, Windows key casing, WSL host +selection, credential exclusion, per-send updates/removal, prompt/command/shell +ordering, read-only behavior, denied sessions, failure redaction, stale connection +fencing, worktree deletion and disconnect cancellation. + +Run the real native regression against an explicitly selected CLI: + +```text +node scripts/test-session-environment-native.mjs +``` + +It creates isolated service configuration, state and database with its own port; +uses the real WorkspaceManager, proxy and native shells; checks two conversations, +settings changes/removal, read-only behavior and an unchanged daemon PID; and +stops only that isolated service. Windows also exercises Git Bash at its standard +installation path. No provider call or existing conversation is used. diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 60fff4963..cf4190ffb 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -363,6 +363,7 @@ export interface BinaryUpdateRequest { } export const OPENCODE_V2_REQUIRED_ERROR_CODE = "opencode_v2_required" as const +export const SESSION_ENVIRONMENT_FAILED_ERROR_CODE = "session_environment_failed" as const export interface BinaryValidationResult { valid: boolean diff --git a/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts index 8c2faed52..a228c9ba9 100644 --- a/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy-legacy.test.ts @@ -26,6 +26,7 @@ test("legacy authority precedes translation for Forms, session writes, cursors a getSharedServiceEndpoint: async () => { throw new Error("Pinned request must not reacquire endpoint") }, getSharedServiceConnection: async () => connection, getSharedServiceClient: async () => { throw new Error("Pinned request must not reacquire client") }, + getSessionEnvironment: async () => ({}), getInstanceAuthorizationHeader: () => undefined, getServiceDirectory: () => "/native", getServiceDirectoryForPath: async (_id, directory) => directories.has(directory) ? "/native" : undefined, @@ -90,6 +91,7 @@ test("the first legacy-context proxy request after reconnect negotiates before a get: () => ({ path: "/repo" }) as never, getSharedServiceConnection: () => service.acquire(), getSharedServiceEndpoint: async () => endpoint, getSharedServiceClient: () => service.client(), getInstanceAuthorizationHeader: () => undefined, + getSessionEnvironment: async () => ({}), getWorktreeIdentityForPath: async () => "root", ownsDirectory: async () => true, ownsPath: async () => false, ownsLocation: async (_id, location) => { assert.deepEqual(calls, ["schema"], "contract must be selected before profile-dependent authorization") diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 61dc1b3d0..eba0249d3 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -81,6 +81,7 @@ async function harness( ], }, session: { + environment: async () => {}, get: async ({ sessionID }: { sessionID: string }) => { sessionGets.push(sessionID) const location = sessionLocations[sessionID] ?? sessionDirectory @@ -142,6 +143,7 @@ async function harness( return pathMappings[candidate] ?? candidate }, getSharedServiceClient: async () => client, + getSessionEnvironment: async () => ({}), ownsLocation: async (_id, location) => owned.has(location.directory) && location.workspaceID === undefined, ownsDirectory: async (_id, directory) => owned.has(directory), diff --git a/packages/server/src/server/__tests__/session-environment.test.ts b/packages/server/src/server/__tests__/session-environment.test.ts new file mode 100644 index 000000000..bfa610a3e --- /dev/null +++ b/packages/server/src/server/__tests__/session-environment.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict" +import { afterEach, test } from "node:test" +import Fastify, { type FastifyInstance } from "fastify" +import { OpenCode } from "@opencode/client" +import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server" +import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { sessionEnvironment } from "../../workspaces/session-environment" +import { SESSION_ENVIRONMENT_FAILED_ERROR_CODE } from "../../api-types" + +const apps: FastifyInstance[] = [] +afterEach(async () => { await Promise.all(apps.splice(0).map(app => app.close())) }) +const prefix = "/workspaces/w/instance/api/session/" + +async function harness() { + const calls: string[] = [] + const snapshots = new Map>() + const sends: Array<{ sessionID: string; variables?: Record; body: unknown }> = [] + const control = { + configured: { TEMP: "T:/", TMP: "T:/", TMPDIR: "T:/" } as Record, + environmentStatus: 204, + beforeEnvironment: async () => {}, + afterEnvironment: () => {}, + current: true, + } + const upstream = Fastify() + apps.push(upstream) + upstream.get("/api/session/:id", async request => { + const { id } = request.params as { id: string } + return { data: { id, location: { directory: id === "foreign" ? "/other" : "/repo" } } } + }) + upstream.put("/api/session/:id/environment", async (request, reply) => { + const { id } = request.params as { id: string } + calls.push(`environment:${id}`) + await control.beforeEnvironment() + if (control.environmentStatus !== 204) return reply.code(control.environmentStatus).send({ secret: "DO-NOT-LEAK" }) + snapshots.set(id, (request.body as { variables: Record }).variables) + control.afterEnvironment() + return reply.code(204).send() + }) + upstream.all("/api/session/:id/:action", async request => { + const { id, action } = request.params as { id: string; action: string } + calls.push(`${request.method}:${action}:${id}`) + sends.push({ sessionID: id, variables: snapshots.get(id), body: request.body }) + return { data: [] } + }) + await upstream.listen({ host: "127.0.0.1", port: 0 }) + const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` } + const assertCurrent = () => { if (!control.current) throw new Error("Stale connection") } + const fetcher: typeof fetch = (input, init) => { assertCurrent(); return fetch(input, init) } + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: fetcher }) + const manager: InstanceProxyWorkspaceManager = { + get: () => ({ id: "w", path: "/repo" }) as never, + getSharedServiceEndpoint: async () => endpoint, + getSharedServiceClient: async () => { throw new Error("Must use the pinned client") }, + getSharedServiceConnection: async () => ({ endpoint, client, fetch: fetcher, assertCurrent, invalidate() {}, profile: async () => "modern" }), + getInstanceAuthorizationHeader: () => undefined, + getWorktreeIdentityForPath: async () => "root", + ownsLocation: async (_id, location) => location.directory === "/repo", + ownsDirectory: async (_id, directory) => directory === "/repo", + ownsPath: async () => true, + getSessionEnvironment: async () => sessionEnvironment(control.configured, { + platform: "win32", environment: { Path: "C:/tools", TEMP: "C:/base", SystemRoot: "C:/Windows" }, + }), + } + const app = Fastify() + apps.push(app) + const fence = new WorktreeDeletionFence() + const logs: unknown[] = [] + registerInstanceProxyRoutes(app, { + workspaceManager: manager, worktreeDeletionFence: fence, + logger: { debug() {}, error: (...args: unknown[]) => logs.push(args), isLevelEnabled: () => false } as never, + }) + await app.ready() + return { app, calls, sends, control, manager, fence, logs } +} + +test("every prompt applies current profile values before forwarding, including removal and other sessions", async () => { + const { app, calls, sends, control } = await harness() + const send = (id: string) => app.inject({ method: "POST", url: `${prefix}${id}/prompt`, payload: { text: "hello" } }) + assert.equal((await send("one")).statusCode, 200) + control.configured = { TEMP: "U:/" } + assert.equal((await send("one")).statusCode, 200) + assert.equal((await send("two")).statusCode, 200) + control.configured = {} + assert.equal((await send("one")).statusCode, 200) + assert.deepEqual(calls, ["environment:one", "POST:prompt:one", "environment:one", "POST:prompt:one", "environment:two", "POST:prompt:two", "environment:one", "POST:prompt:one"]) + assert.deepEqual(sends.map(send => send.variables?.TEMP), ["T:/", "U:/", "U:/", "C:/base"]) + assert.equal(sends[1].variables?.TMP, undefined) + assert.equal(sends[3].variables?.Path, "C:/tools") + assert.deepEqual(sends[0].body, { text: "hello" }) +}) + +test("session shell and custom commands also apply the environment, but reads and other writes do not", async () => { + const { app, calls } = await harness() + for (const action of ["shell", "command"]) { + assert.equal((await app.inject({ method: "POST", url: `${prefix}one/${action}`, payload: {} })).statusCode, 200) + } + assert.equal((await app.inject({ method: "GET", url: `${prefix}one/message` })).statusCode, 200) + assert.equal((await app.inject({ method: "POST", url: `${prefix}one/interrupt`, payload: {} })).statusCode, 200) + assert.deepEqual(calls, ["environment:one", "POST:shell:one", "environment:one", "POST:command:one", "GET:message:one", "POST:interrupt:one"]) +}) + +test("waits for the environment acknowledgement before admitting a prompt", async () => { + const { app, control, sends } = await harness() + let release!: () => void + let started!: () => void + const ready = new Promise(resolve => { started = resolve }) + control.beforeEnvironment = () => { started(); return new Promise(resolve => { release = resolve }) } + const pending = app.inject({ method: "POST", url: `${prefix}one/prompt`, payload: { text: "hello" } }).then(value => value) + await ready + assert.equal(sends.length, 0) + release() + assert.equal((await pending).statusCode, 200) + assert.equal(sends.length, 1) +}) + +test("unauthorized sessions and direct environment writes never set an environment", async () => { + const { app, calls } = await harness() + assert.equal((await app.inject({ method: "POST", url: `${prefix}foreign/prompt`, payload: { text: "no" } })).statusCode, 403) + assert.equal((await app.inject({ method: "PUT", url: `${prefix}one/environment`, payload: { variables: { TEMP: "untrusted" } } })).statusCode, 403) + assert.deepEqual(calls, []) +}) + +test("environment failures stop admission, expose no environment data and release the mutation fence", async () => { + const { app, control, sends, logs, fence } = await harness() + for (const status of [400, 401, 404, 500]) { + control.environmentStatus = status + const response = await app.inject({ method: "POST", url: `${prefix}one/prompt`, payload: { text: "hello" } }) + assert.equal(response.statusCode, 502) + assert.deepEqual(response.json(), { error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE }) + assert.equal(sends.length, 0) + } + assert.equal(JSON.stringify(logs).includes("DO-NOT-LEAK"), false) + await fence.run("delete", ["root"], async () => {}) + control.environmentStatus = 204 + assert.equal((await app.inject({ method: "POST", url: `${prefix}one/prompt`, payload: {} })).statusCode, 200) +}) + +test("a connection replaced while preparing or applying the environment cannot receive the prompt", async () => { + const { app, control, manager, calls, sends } = await harness() + const original = manager.getSessionEnvironment + manager.getSessionEnvironment = async (...args) => { const env = await original(...args); control.current = false; return env } + assert.equal((await app.inject({ method: "POST", url: `${prefix}one/prompt`, payload: {} })).statusCode, 502) + assert.deepEqual(calls, []) + manager.getSessionEnvironment = original + control.current = true + control.afterEnvironment = () => { control.current = false } + assert.equal((await app.inject({ method: "POST", url: `${prefix}one/prompt`, payload: {} })).statusCode, 502) + assert.equal(sends.length, 0) +}) + +test("worktree deletion waits for environment admission and blocks further sends", async () => { + const { app, control, fence, calls } = await harness() + let release!: () => void + let started!: () => void + const ready = new Promise(resolve => { started = resolve }) + control.beforeEnvironment = () => { started(); return new Promise(resolve => { release = resolve }) } + const pending = app.inject({ method: "POST", url: `${prefix}one/prompt`, payload: {} }).then(value => value) + await ready + let deleted = false + const deletion = fence.run("delete", ["root"], async () => { deleted = true }) + assert.equal((await app.inject({ method: "POST", url: `${prefix}two/prompt`, payload: {} })).statusCode, 409) + assert.equal(deleted, false) + assert.deepEqual(calls, ["environment:one"]) + release() + assert.equal((await pending).statusCode, 200) + await deletion + assert.equal(deleted, true) +}) + +test("disconnect during environment preparation cancels admission before any native write", async () => { + const { app, manager, calls } = await harness() + let ready!: () => void + let cancelled!: () => void + const preparing = new Promise(resolve => { ready = resolve }) + const aborted = new Promise(resolve => { cancelled = resolve }) + manager.getSessionEnvironment = async (_id, signal) => { + ready() + return new Promise((_resolve, reject) => signal!.addEventListener("abort", () => { + cancelled() + reject(signal!.reason) + }, { once: true })) + } + await app.listen({ host: "127.0.0.1", port: 0 }) + const port = (app.server.address() as { port: number }).port + const controller = new AbortController() + const request = fetch(`http://127.0.0.1:${port}${prefix}one/prompt`, { + method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: controller.signal, + }) + const rejected = assert.rejects(request) + await preparing + controller.abort() + await rejected + await aborted + assert.deepEqual(calls, []) +}) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 9f989aab2..031063855 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -32,7 +32,7 @@ import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerSideCarRoutes } from "./routes/sidecars" import { registerPreviewRoutes } from "./routes/previews" import { registerUsageRoutes } from "./routes/usage" -import { ServerMeta } from "../api-types" +import { ServerMeta, SESSION_ENVIRONMENT_FAILED_ERROR_CODE } from "../api-types" import { InstanceStore } from "../storage/instance-store" import type { AutoAcceptManager } from "../permissions/auto-accept-manager" import type { AuthManager } from "../auth/manager" @@ -422,6 +422,7 @@ export interface InstanceProxyWorkspaceManager { getWorktreeIdentityForPath(id: string, directory: string): Promise getServicePathForPath?(id: string, candidate: string): Promise getSharedServiceClient(): Promise + getSessionEnvironment(id: string, signal?: AbortSignal): Promise> ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): ReturnType ownsDirectory(id: string, directory: string): Promise ownsPath(id: string, candidate: string): Promise @@ -924,6 +925,29 @@ async function proxyWorkspaceRequest(args: { try { connection?.assertCurrent() + if (request.method === "POST" && /^\/api\/session\/[^/]+\/(?:prompt|command|shell)\/?$/.test(pathname)) { + const disconnected = new AbortController() + const onDisconnect = () => disconnected.abort() + reply.raw.once("close", onDisconnect) + try { + const signal = AbortSignal.any([disconnected.signal, AbortSignal.timeout(15_000)]) + if (reply.raw.destroyed || request.raw.aborted) disconnected.abort() + signal.throwIfAborted() + const variables = await workspaceManager.getSessionEnvironment(workspaceId, signal) + signal.throwIfAborted() + connection?.assertCurrent() + await (await clientForRequest()).session.environment({ sessionID: sessionId!, variables }, { signal }) + signal.throwIfAborted() + connection?.assertCurrent() + } catch { + // Never log the SDK error: it can contain the complete environment body. + releaseMutation?.() + logger.error({ workspaceId, sessionId }, "Failed to apply profile environment") + return reply.code(502).send({ error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE }) + } finally { + reply.raw.off("close", onDisconnect) + } + } if (connection || workspaceManager.getSharedServiceFetch) { const headers = sanitizeInstanceProxyRequestHeaders(request.headers, instanceAuthHeader) delete headers[LOCATION_CONTEXT_HEADER] diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 190cadc0e..59a5e4ca9 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -33,6 +33,7 @@ import { WslOpenCodeService } from "./wsl-opencode-service" import { invalidateWorktreeCache, isPathOwnedByWorktree, isPathWithinWorktree, resolveOwnedWorktreePath } from "./worktree-directory" import { listNativeWorktrees, createNativeWorktree, removeNativeWorktree } from "./native-worktrees" import { WorktreeInventory } from "./worktree-inventory" +import { sessionEnvironment } from "./session-environment" import { resolveRepoRoot, sharesGitCommonDirectory } from "./git-worktrees" import { locationRequestOptions, readLocationRef, sameLocation } from "../opencode/compatibility/location" @@ -998,6 +999,13 @@ export class WorkspaceManager { }, { startFile: this.options.startServiceCommand }) } + async getSessionEnvironment(id: string, signal?: AbortSignal): Promise> { + const record = this.workspaces.get(id) + if (!record || record.status !== "ready") throw new Error("Workspace is not ready") + const configured = this.options.settings.getOwner("config", "server").environmentVariables + return sessionEnvironment(configured, { distro: record.wslDistro, platform: this.options.platform, signal }) + } + private serviceStartupEnvironment(): NodeJS.ProcessEnv { const configured = this.options.settings.getOwner("config", "server").environmentVariables const environment: NodeJS.ProcessEnv = {} diff --git a/packages/server/src/workspaces/session-environment.test.ts b/packages/server/src/workspaces/session-environment.test.ts new file mode 100644 index 000000000..9d5cfad60 --- /dev/null +++ b/packages/server/src/workspaces/session-environment.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { sessionEnvironment } from "./session-environment" + +test("Windows overrides replace case-insensitive keys without losing the base environment", async () => { + const base = { Path: "C:\\tools", TEMP: "C:\\temp", SystemRoot: "C:\\Windows" } + assert.deepEqual(await sessionEnvironment({ temp: "T:/", TMP: "T:/" }, { environment: base, platform: "win32" }), { + Path: "C:\\tools", temp: "T:/", TMP: "T:/", SystemRoot: "C:\\Windows", + }) + assert.deepEqual(await sessionEnvironment({}, { environment: base, platform: "win32" }), base) + assert.equal(base.TEMP, "C:\\temp") +}) + +test("POSIX variable names retain their case and values are not shell interpolated", async () => { + const result = await sessionEnvironment({ Path: "custom", VALUE: "a=b\n$HOME; echo nope", EMPTY: "" }, { + environment: { PATH: "/bin", HOME: "/home/test" }, platform: "linux", + }) + assert.deepEqual(result, { PATH: "/bin", HOME: "/home/test", Path: "custom", VALUE: "a=b\n$HOME; echo nope", EMPTY: "" }) +}) + +test("internal authentication and storage variables cannot leak through the base or overrides", async () => { + const variables = { + OPENCODE_PASSWORD: "secret", opencode_server_password: "secret", + CODENOMAD_SERVER_PASSWORD: "secret", CODENOMAD_AUTOMATION_BRIDGE_TOKEN: "secret", + CODENOMAD_BOOTSTRAP_TOKEN: "secret", OPENCODE_DB: "private.db", XDG_STATE_HOME: "/private", + PROVIDER_API_KEY: "intended", PATH: "/bin", + } + assert.deepEqual(await sessionEnvironment(variables, { environment: variables }), { PROVIDER_API_KEY: "intended", PATH: "/bin" }) +}) + +test("invalid configured entries fail without reflecting their values", async () => { + for (const variables of [{ "": "secret" }, { "BAD=KEY": "secret" }, { BAD: "secret\0value" }]) { + await assert.rejects(sessionEnvironment(variables, { environment: {} }), { message: "Invalid session environment variable" }) + } +}) + +test("WSL uses the selected distro's full environment rather than the Windows host", async () => { + const signal = new AbortController().signal + const result = await sessionEnvironment({ TMPDIR: "/tmp/profile" }, { + platform: "win32", distro: "Ubuntu", environment: { Path: "C:\\Windows", HOME: "C:\\Users\\test" }, signal, + readWsl: async (distro, receivedSignal) => { + assert.equal(distro, "Ubuntu") + assert.equal(receivedSignal, signal) + return { PATH: "/usr/bin:/bin", HOME: "/home/test", TMPDIR: "/tmp" } + }, + }) + assert.deepEqual(result, { PATH: "/usr/bin:/bin", HOME: "/home/test", TMPDIR: "/tmp/profile" }) + await assert.rejects(sessionEnvironment({}, { distro: "Ubuntu", readWsl: async () => { throw new Error("unavailable") } }), /unavailable/) +}) diff --git a/packages/server/src/workspaces/session-environment.ts b/packages/server/src/workspaces/session-environment.ts new file mode 100644 index 000000000..7e67c506b --- /dev/null +++ b/packages/server/src/workspaces/session-environment.ts @@ -0,0 +1,56 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +const execute = promisify(execFile) +const PRIVATE_VARIABLES = new Set([ + "OPENCODE_PASSWORD", "OPENCODE_SERVER_PASSWORD", "CODENOMAD_SERVER_PASSWORD", + "CODENOMAD_AUTOMATION_BRIDGE_TOKEN", "CODENOMAD_BOOTSTRAP_TOKEN", +]) +const STORAGE_VARIABLES = new Set(["OPENCODE_DB", "XDG_STATE_HOME"]) + +// session.environment replaces the whole snapshot, not just the configured keys. +// Build it on the execution host; a WSL shell must never receive Windows PATH/HOME. +export async function sessionEnvironment( + configured: unknown, + options: { + distro?: string + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv + signal?: AbortSignal + readWsl?: typeof readWslEnvironment + } = {}, +): Promise> { + if (configured !== undefined && (!configured || typeof configured !== "object" || Array.isArray(configured))) { + throw new Error("Invalid session environment") + } + const base = options.distro + ? await (options.readWsl ?? readWslEnvironment)(options.distro, options.signal) + : options.environment ?? process.env + const windows = !options.distro && (options.platform ?? process.platform) === "win32" + const values = new Map() + for (const [source, overrides] of [[base, false], [configured ?? {}, true]] as const) { + for (const [key, value] of Object.entries(source)) { + const upper = key.toUpperCase() + if (value === undefined || PRIVATE_VARIABLES.has(upper) || STORAGE_VARIABLES.has(upper)) continue + if (typeof value !== "string" || !key || key.includes("=") || key.includes("\0") || value.includes("\0")) { + if (overrides) throw new Error("Invalid session environment variable") + continue + } + values.set(windows ? upper : key, [key, value]) + } + } + return Object.fromEntries(values.values()) +} + +async function readWslEnvironment(distro: string, signal?: AbortSignal): Promise { + const { stdout } = await execute("wsl.exe", ["--distribution", distro, "--exec", "env", "-0"], { + encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 1024 * 1024, signal, + }) + const entries = stdout.split("\0").filter(Boolean).map(entry => { + const separator = entry.indexOf("=") + if (separator < 1) throw new Error("Invalid WSL environment") + return [entry.slice(0, separator), entry.slice(separator + 1)] + }) + if (!entries.length) throw new Error("Empty WSL environment") + return Object.fromEntries(entries) +} diff --git a/packages/ui/src/lib/i18n/messages/de/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/de/advancedSettings.ts index e8f6b7f06..bcc6facc4 100644 --- a/packages/ui/src/lib/i18n/messages/de/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/de/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "Erweiterte Einstellungen", "advancedSettings.environmentVariables.title": "Umgebungsvariablen", - "advancedSettings.environmentVariables.subtitle": "Wird angewendet, wenn eine neue OpenCode-Instanz startet", + "advancedSettings.environmentVariables.subtitle": "Wird vor jedem Prompt oder Sitzungsbefehl angewendet", "advancedSettings.actions.close": "Schließen", } as const diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 4f7d76051..b25fd98f2 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "Variable entfernen", "envEditor.actions.add.title": "Variable hinzufügen", "envEditor.empty": "Keine Umgebungsvariablen konfiguriert. Fügen Sie oben Variablen hinzu, um die OpenCode-Umgebung anzupassen.", - "envEditor.help": "Diese Variablen sind in der OpenCode-Umgebung beim Starten von Instanzen verfügbar.", + "envEditor.help": "Vor jedem Prompt oder Sitzungsbefehl wendet CodeNomad die Profilvariablen auf die lokale Shell-Umgebung der Unterhaltung an. Änderungen gelten ab dem nächsten Senden. Sie werden auch beim Start eines gestoppten OpenCode-Dienstes übergeben; ein laufender Dienst wird nicht neu gestartet.", + "envEditor.applyFailed": "Die Profilumgebung konnte nicht angewendet werden. Es wurde nichts gesendet. Prüfen Sie die Variablen und Ihre OpenCode-Version und versuchen Sie es erneut.", "contextUsagePanel.headings.tokens": "Token", "contextUsagePanel.headings.context": "Kontext", diff --git a/packages/ui/src/lib/i18n/messages/en/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/en/advancedSettings.ts index c766b2850..b91d43a26 100644 --- a/packages/ui/src/lib/i18n/messages/en/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/en/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "Advanced Settings", "advancedSettings.environmentVariables.title": "Environment Variables", - "advancedSettings.environmentVariables.subtitle": "Applied whenever a new OpenCode instance starts", + "advancedSettings.environmentVariables.subtitle": "Applied before each prompt or session command", "advancedSettings.actions.close": "Close", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 486913cc5..e22929e87 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "Remove variable", "envEditor.actions.add.title": "Add variable", "envEditor.empty": "No environment variables configured. Add variables above to customize the OpenCode environment.", - "envEditor.help": "These variables will be available in the OpenCode environment when starting instances.", + "envEditor.help": "Before each prompt or session command, CodeNomad applies this profile's variables to that conversation's local shell environment. Changes take effect on the next send. They are also passed when starting a stopped OpenCode service; an existing service is not restarted.", + "envEditor.applyFailed": "Could not apply the profile environment. Nothing was sent. Check the variables and your OpenCode version, then try again.", "contextUsagePanel.headings.tokens": "Tokens", "contextUsagePanel.headings.context": "Context", diff --git a/packages/ui/src/lib/i18n/messages/es/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/es/advancedSettings.ts index 08a61eddd..f43dee304 100644 --- a/packages/ui/src/lib/i18n/messages/es/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/es/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "Configuración avanzada", "advancedSettings.environmentVariables.title": "Variables de entorno", - "advancedSettings.environmentVariables.subtitle": "Se aplican cada vez que inicia una nueva instancia de OpenCode", + "advancedSettings.environmentVariables.subtitle": "Se aplican antes de cada prompt o comando de sesión", "advancedSettings.actions.close": "Cerrar", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index b28f7071f..9760d0b9a 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "Quitar variable", "envEditor.actions.add.title": "Agregar variable", "envEditor.empty": "No hay variables de entorno configuradas. Agrega variables arriba para personalizar el entorno de OpenCode.", - "envEditor.help": "Estas variables estarán disponibles en el entorno de OpenCode al iniciar instancias.", + "envEditor.help": "Antes de cada prompt o comando de sesión, CodeNomad aplica las variables de este perfil al entorno de shell local de la conversación. Los cambios se aplican en el siguiente envío. También se pasan al iniciar un servicio OpenCode detenido; no se reinicia un servicio existente.", + "envEditor.applyFailed": "No se pudo aplicar el entorno del perfil. No se envió nada. Revisa las variables y tu versión de OpenCode e inténtalo de nuevo.", "contextUsagePanel.headings.tokens": "Tokens", "contextUsagePanel.headings.context": "Contexto", diff --git a/packages/ui/src/lib/i18n/messages/fr/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/fr/advancedSettings.ts index ecd9c5e7e..2b91a3bda 100644 --- a/packages/ui/src/lib/i18n/messages/fr/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "Paramètres avancés", "advancedSettings.environmentVariables.title": "Variables d'environnement", - "advancedSettings.environmentVariables.subtitle": "Appliquées à chaque démarrage d'une nouvelle instance OpenCode", + "advancedSettings.environmentVariables.subtitle": "Appliquées avant chaque prompt ou commande de session", "advancedSettings.actions.close": "Fermer", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index e772081b9..601ba403c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "Supprimer la variable", "envEditor.actions.add.title": "Ajouter une variable", "envEditor.empty": "Aucune variable d'environnement configurée. Ajoutez des variables ci-dessus pour personnaliser l'environnement OpenCode.", - "envEditor.help": "Ces variables seront disponibles dans l'environnement OpenCode lors du démarrage des instances.", + "envEditor.help": "Avant chaque prompt ou commande de session, CodeNomad applique les variables de ce profil à l’environnement des commandes locales de la conversation. Les modifications prennent effet au prochain envoi. Elles sont aussi transmises au démarrage d’un service OpenCode arrêté ; un service existant n’est pas redémarré.", + "envEditor.applyFailed": "Impossible d’appliquer l’environnement du profil. Rien n’a été envoyé. Vérifiez les variables et votre version d’OpenCode, puis réessayez.", "contextUsagePanel.headings.tokens": "Tokens", "contextUsagePanel.headings.context": "Contexte", diff --git a/packages/ui/src/lib/i18n/messages/he/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/he/advancedSettings.ts index dd0bada57..7d0e21135 100644 --- a/packages/ui/src/lib/i18n/messages/he/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/he/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "הגדרות מתקדמות", "advancedSettings.environmentVariables.title": "משתני סביבה", - "advancedSettings.environmentVariables.subtitle": "מוחלים בכל פעם שמופע OpenCode חדש מופעל", + "advancedSettings.environmentVariables.subtitle": "מוחלים לפני כל הנחיה או פקודת שיחה", "advancedSettings.actions.close": "סגור", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 9331c8a1f..8479f0602 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "הסר משתנה", "envEditor.actions.add.title": "הוסף משתנה", "envEditor.empty": "לא הוגדרו משתני סביבה. הוסף משתנים למעלה להתאמת סביבת OpenCode.", - "envEditor.help": "משתנים אלו יהיו זמינים בסביבת OpenCode בעת הפעלת מופעים.", + "envEditor.help": "לפני כל הנחיה או פקודת שיחה, CodeNomad מחיל את משתני הפרופיל על סביבת המעטפת המקומית של השיחה. השינויים חלים בשליחה הבאה. המשתנים מועברים גם בהפעלת שירות OpenCode שהופסק; שירות פועל אינו מופעל מחדש.", + "envEditor.applyFailed": "לא ניתן להחיל את סביבת הפרופיל. דבר לא נשלח. יש לבדוק את המשתנים ואת גרסת OpenCode ולנסות שוב.", "contextUsagePanel.headings.tokens": "טוקנים", "contextUsagePanel.headings.context": "הקשר", diff --git a/packages/ui/src/lib/i18n/messages/ja/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/ja/advancedSettings.ts index 7796a303e..68a89aa0e 100644 --- a/packages/ui/src/lib/i18n/messages/ja/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "詳細設定", "advancedSettings.environmentVariables.title": "環境変数", - "advancedSettings.environmentVariables.subtitle": "新しい OpenCode インスタンス起動時に適用", + "advancedSettings.environmentVariables.subtitle": "各プロンプトまたはセッションコマンドの送信前に適用", "advancedSettings.actions.close": "閉じる", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index fbc03897f..81fa9e518 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "変数を削除", "envEditor.actions.add.title": "変数を追加", "envEditor.empty": "環境変数が設定されていません。上で変数を追加して OpenCode 環境をカスタマイズしてください。", - "envEditor.help": "これらの変数はインスタンス起動時に OpenCode 環境で利用できます。", + "envEditor.help": "各プロンプトまたはセッションコマンドの送信前に、CodeNomad はこのプロファイルの変数を会話のローカルシェル環境に適用します。変更は次回の送信時に反映されます。停止中の OpenCode サービスの起動時にも渡されますが、既存のサービスは再起動されません。", + "envEditor.applyFailed": "プロファイルの環境を適用できませんでした。何も送信されていません。変数と OpenCode のバージョンを確認して、再試行してください。", "contextUsagePanel.headings.tokens": "トークン", "contextUsagePanel.headings.context": "コンテキスト", diff --git a/packages/ui/src/lib/i18n/messages/ne/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/ne/advancedSettings.ts index 990bb6599..b3a2ad86f 100644 --- a/packages/ui/src/lib/i18n/messages/ne/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "उन्नत सेटिङहरू", "advancedSettings.environmentVariables.title": "वातावरण चरहरू (Environment Variables)", - "advancedSettings.environmentVariables.subtitle": "नयाँ OpenCode उदाहरण सुरु हुँदा लागू हुन्छ", + "advancedSettings.environmentVariables.subtitle": "प्रत्येक प्रम्प्ट वा सत्र आदेशअघि लागू हुन्छ", "advancedSettings.actions.close": "बन्द गर्नुहोस्", } as const diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 85c5b4e76..28f63acd0 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "चर हटाउनुहोस्", "envEditor.actions.add.title": "चर थप्नुहोस्", "envEditor.empty": "कुनै वातावरण चरहरू कन्फिगर गरिएको छैन।", - "envEditor.help": "यी चरहरू उदाहरण सुरु गर्दा OpenCode वातावरणमा उपलब्ध हुनेछन्।", + "envEditor.help": "प्रत्येक प्रम्प्ट वा सत्र आदेशअघि CodeNomad ले यस प्रोफाइलका चरहरू कुराकानीको स्थानीय शेल वातावरणमा लागू गर्छ। परिवर्तनहरू अर्को पठाइमा लागू हुन्छन्। रोकिएको OpenCode सेवा सुरु गर्दा पनि यी चरहरू पठाइन्छन्; चलिरहेको सेवा पुनः सुरु हुँदैन।", + "envEditor.applyFailed": "प्रोफाइलको वातावरण लागू गर्न सकिएन। केही पनि पठाइएन। चरहरू र OpenCode संस्करण जाँचेर फेरि प्रयास गर्नुहोस्।", "contextUsagePanel.headings.tokens": "टोकनहरू", "contextUsagePanel.headings.context": "सन्दर्भ (Context)", diff --git a/packages/ui/src/lib/i18n/messages/ru/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/ru/advancedSettings.ts index fbaab1a77..02dc883ce 100644 --- a/packages/ui/src/lib/i18n/messages/ru/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "Расширенные настройки", "advancedSettings.environmentVariables.title": "Переменные окружения", - "advancedSettings.environmentVariables.subtitle": "Применяются при запуске нового экземпляра OpenCode", + "advancedSettings.environmentVariables.subtitle": "Применяются перед каждым запросом или командой сессии", "advancedSettings.actions.close": "Закрыть", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 70846ee75..3e7db4c83 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "Удалить переменную", "envEditor.actions.add.title": "Добавить переменную", "envEditor.empty": "Переменные окружения не настроены. Добавьте переменные выше, чтобы настроить окружение OpenCode.", - "envEditor.help": "Эти переменные будут доступны в окружении OpenCode при запуске экземпляров.", + "envEditor.help": "Перед каждым запросом или командой сессии CodeNomad применяет переменные профиля к окружению локальной оболочки беседы. Изменения вступают в силу при следующей отправке. Переменные также передаются при запуске остановленного сервиса OpenCode; работающий сервис не перезапускается.", + "envEditor.applyFailed": "Не удалось применить окружение профиля. Ничего не отправлено. Проверьте переменные и версию OpenCode, затем повторите попытку.", "contextUsagePanel.headings.tokens": "Токены", "contextUsagePanel.headings.context": "Контекст", diff --git a/packages/ui/src/lib/i18n/messages/tr/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/tr/advancedSettings.ts index fefd65ccd..34cc3e3c4 100644 --- a/packages/ui/src/lib/i18n/messages/tr/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "Gelişmiş Ayarlar", "advancedSettings.environmentVariables.title": "Ortam Değişkenleri", - "advancedSettings.environmentVariables.subtitle": "Yeni bir OpenCode instance'ı başlatıldığında uygulanır", + "advancedSettings.environmentVariables.subtitle": "Her istem veya oturum komutundan önce uygulanır", "advancedSettings.actions.close": "Kapat", } as const diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index 280826be6..b9002650f 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -78,7 +78,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "Değişkeni kaldır", "envEditor.actions.add.title": "Değişken ekle", "envEditor.empty": "Yapılandırılmış ortam değişkeni yok. OpenCode ortamını özelleştirmek için yukarıya değişken ekleyin.", - "envEditor.help": "Bu değişkenler, instance'lar başlatıldığında OpenCode ortamında kullanılabilir olacak.", + "envEditor.help": "CodeNomad, her istem veya oturum komutundan önce bu profilin değişkenlerini konuşmanın yerel kabuk ortamına uygular. Değişiklikler bir sonraki gönderimde geçerli olur. Durdurulmuş bir OpenCode hizmeti başlatılırken de aktarılır; çalışan hizmet yeniden başlatılmaz.", + "envEditor.applyFailed": "Profil ortamı uygulanamadı. Hiçbir şey gönderilmedi. Değişkenleri ve OpenCode sürümünüzü kontrol edip tekrar deneyin.", "contextUsagePanel.headings.tokens": "Token'lar", "contextUsagePanel.headings.context": "Context", "contextUsagePanel.labels.input": "Girdi", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/advancedSettings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/advancedSettings.ts index dd41c1872..3f3591e92 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/advancedSettings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/advancedSettings.ts @@ -1,6 +1,6 @@ export const advancedSettingsMessages = { "advancedSettings.title": "高级设置", "advancedSettings.environmentVariables.title": "环境变量", - "advancedSettings.environmentVariables.subtitle": "每当启动新的 OpenCode 实例时应用", + "advancedSettings.environmentVariables.subtitle": "每次发送提示或会话命令前应用", "advancedSettings.actions.close": "关闭", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index fae954289..96aaf216b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -83,7 +83,8 @@ export const settingsMessages = { "envEditor.actions.remove.title": "移除变量", "envEditor.actions.add.title": "添加变量", "envEditor.empty": "未配置环境变量。在上方添加变量以自定义 OpenCode 环境。", - "envEditor.help": "启动实例时,这些变量将在 OpenCode 环境中可用。", + "envEditor.help": "每次发送提示或会话命令前,CodeNomad 会将此配置的变量应用于该会话的本地 Shell 环境。更改在下次发送时生效。启动已停止的 OpenCode 服务时也会传入这些变量,但不会重启正在运行的服务。", + "envEditor.applyFailed": "无法应用配置环境,未发送任何内容。请检查变量和 OpenCode 版本后重试。", "contextUsagePanel.headings.tokens": "令牌", "contextUsagePanel.headings.context": "上下文", diff --git a/packages/ui/src/lib/sdk-manager.test.ts b/packages/ui/src/lib/sdk-manager.test.ts index b6b7d9960..e5c6a09a6 100644 --- a/packages/ui/src/lib/sdk-manager.test.ts +++ b/packages/ui/src/lib/sdk-manager.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict" import { afterEach, describe, it } from "node:test" -import { buildInstanceBaseUrl, sdkManager } from "./sdk-manager.ts" +import { buildInstanceBaseUrl, createInstanceFetch, sdkManager } from "./sdk-manager.ts" +import { tGlobal } from "./i18n" afterEach(() => { sdkManager.destroyClientsForInstance("instance-a") @@ -9,6 +10,18 @@ afterEach(() => { }) describe("SDKManager", () => { + it("reports environment admission failures without exposing an upstream error body", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => Response.json({ error: "session_environment_failed" }, { status: 502 }) + const fetcher = createInstanceFetch("https://codenomad.test/workspaces/w/instance/") + await assert.rejects(fetcher("http://localhost/api/session/s/prompt", { method: "POST" }), { + message: tGlobal("envEditor.applyFailed"), + }) + globalThis.fetch = async () => Response.json({ error: "other" }, { status: 502 }) + assert.deepEqual(await (await fetcher("http://localhost/api/session/s/prompt", { method: "POST" })).json(), { error: "other" }) + } finally { globalThis.fetch = original } + }) it("normalizes instance proxy URLs", () => { assert.equal( buildInstanceBaseUrl("workspaces//instance-a/instance///", "https://codenomad.test///"), diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 889a66bc6..769940254 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -1,6 +1,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode/client" import { CODENOMAD_API_BASE } from "./api-client" import { backgroundReads } from "./background-read-queue" +import { SESSION_ENVIRONMENT_FAILED_ERROR_CODE } from "../../../server/src/api-types" class SDKManager { private clients = new Map() @@ -45,10 +46,20 @@ export function createInstanceFetch(baseUrl: string): typeof globalThis.fetch { return (input, init) => { const requestUrl = new URL(input instanceof Request ? input.url : input) const relativeUrl = `${requestUrl.pathname.replace(/^\/+/, "")}${requestUrl.search}` - const read = () => globalThis.fetch(new URL(relativeUrl, baseUrl), { - ...init, - credentials: init?.credentials ?? "include", - }) + const read = async () => { + const response = await globalThis.fetch(new URL(relativeUrl, baseUrl), { + ...init, + credentials: init?.credentials ?? "include", + }) + if (response.status === 502) { + const body = await response.clone().json().catch(() => undefined) + if (body?.error === SESSION_ENVIRONMENT_FAILED_ERROR_CODE) { + const { tGlobal } = await import("./i18n") + throw new Error(tGlobal("envEditor.applyFailed")) + } + } + return response + } const method = init?.method ?? (input instanceof Request ? input.method : "GET") if (method === "GET" && /^\/api\/(?:project|session\/active)\/?$/.test(requestUrl.pathname)) { return backgroundReads.run(init?.signal ?? new AbortController().signal, read) diff --git a/packages/ui/src/stores/permission-lifecycle.test.ts b/packages/ui/src/stores/permission-lifecycle.test.ts index 20de2bf10..7be7bde05 100644 --- a/packages/ui/src/stores/permission-lifecycle.test.ts +++ b/packages/ui/src/stores/permission-lifecycle.test.ts @@ -248,10 +248,11 @@ test("cancelling a bounded pending scan does not launch queued locations", async const sync = syncPendingRequests(instanceId) await new Promise((resolve) => setImmediate(resolve)) - assert.equal(calls, 8) + // The shared background-read budget admits two requests across both scans. + assert.equal(calls, 2) removeInstance(instanceId) await sync await new Promise((resolve) => setImmediate(resolve)) - assert.equal(calls, 8) + assert.equal(calls, 2) }) diff --git a/scripts/test-opencode-location-native.mjs b/scripts/test-opencode-location-native.mjs index 0ad3a520c..391557bd5 100644 --- a/scripts/test-opencode-location-native.mjs +++ b/scripts/test-opencode-location-native.mjs @@ -49,6 +49,7 @@ export async function testNativeLocationIdentity({ client, connection, root }) { assert.equal(resolved.workspaceID, location.workspaceID) } const { registerInstanceProxyRoutes } = await tsImport("../packages/server/src/server/http-server.ts", import.meta.url) + const { sessionEnvironment } = await tsImport("../packages/server/src/workspaces/session-environment.ts", import.meta.url) const { createInstanceFetch } = await tsImport("../packages/ui/src/lib/sdk-manager.ts", import.meta.url) const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url) const { InstanceEventBridge } = await tsImport("../packages/server/src/workspaces/instance-events.ts", import.meta.url) @@ -66,6 +67,7 @@ export async function testNativeLocationIdentity({ client, connection, root }) { getSharedServiceConnection: async () => connection, getSharedServiceEndpoint: async () => connection.endpoint, getSharedServiceClient: async () => client, + getSessionEnvironment: () => sessionEnvironment({}), getInstanceAuthorizationHeader: () => `Basic ${Buffer.from(`${connection.endpoint.auth.username}:${connection.endpoint.auth.password}`).toString("base64")}`, getServiceDirectory: () => rootLocation.directory, getServiceDirectoryForPath: async (_id, candidate) => ownsDirectory(candidate) ? candidate : undefined, diff --git a/scripts/test-opencode-proxy-native.mjs b/scripts/test-opencode-proxy-native.mjs index f8c316332..4cb3d657e 100644 --- a/scripts/test-opencode-proxy-native.mjs +++ b/scripts/test-opencode-proxy-native.mjs @@ -9,6 +9,7 @@ import { tsImport } from "tsx/esm/api" // Called only by the isolated native fixture; no discovery or user storage. export async function testNativeProxy({ client, baseUrl, root, authorization, runtimeFetch, connection, exercise }) { const { registerInstanceProxyRoutes } = await tsImport("../packages/server/src/server/http-server.ts", import.meta.url) + const { sessionEnvironment } = await tsImport("../packages/server/src/workspaces/session-environment.ts", import.meta.url) const { createInstanceFetch } = await tsImport("../packages/ui/src/lib/sdk-manager.ts", import.meta.url) const app = Fastify() await app.register(replyFrom) @@ -21,6 +22,7 @@ export async function testNativeProxy({ client, baseUrl, root, authorization, ru getInstanceAuthorizationHeader: () => authorization, getServiceDirectory: () => root, getSharedServiceClient: async () => client, + getSessionEnvironment: () => sessionEnvironment({}), ...(runtimeFetch ? { getSharedServiceFetch: async () => runtimeFetch } : {}), getWorktreeIdentityForPath: async (_id, directory) => owns(directory) ? root : undefined, ownsDirectory: async (_id, directory) => owns(directory), diff --git a/scripts/test-session-environment-native.mjs b/scripts/test-session-environment-native.mjs new file mode 100644 index 000000000..816f46838 --- /dev/null +++ b/scripts/test-session-environment-native.mjs @@ -0,0 +1,111 @@ +// Explicit isolated CLI only. Never discovers or restarts the user's service. +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises" +import net from "node:net" +import os from "node:os" +import path from "node:path" +import Fastify from "fastify" +import { OpenCode } from "@opencode/client" +import pino from "pino" +import { tsImport } from "tsx/esm/api" + +const cli = process.argv[2] +if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute OpenCode CLI executable path") +const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-session-environment-")) +const exec = promisify(execFile) +for (const key of Object.keys(process.env)) if (/^(OPENCODE_|XDG_|CODENOMAD_)/i.test(key)) delete process.env[key] +for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) process.env[key] = path.join(root, key) +Object.assign(process.env, { + HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root, + OPENCODE_DB: path.join(root, "fixture.db"), OPENCODE_CONFIG_DIR: path.join(root, "config"), + OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1", OPENCODE_CONFIG_CONTENT: "{}", +}) +const project = path.join(root, "project") +await mkdir(project) +await mkdir(process.env.OPENCODE_CONFIG_DIR) +for (const name of ["base", "first", "changed"]) await mkdir(path.join(root, name)) +Object.assign(process.env, { TEMP: path.join(root, "base"), TMP: path.join(root, "base"), TMPDIR: path.join(root, "base") }) +await writeFile(path.join(project, "probe.cjs"), `require('node:fs').writeFileSync(process.argv[2], JSON.stringify({TEMP:process.env.TEMP,TMP:process.env.TMP,TMPDIR:process.env.TMPDIR,tmpdir:require('node:os').tmpdir(),path:!!process.env.PATH}))`) +const run = async args => (await exec(cli, args, { cwd: root, env: process.env, timeout: 45_000, windowsHide: true })).stdout.trim() +const listener = net.createServer() +await new Promise(resolve => listener.listen(0, "127.0.0.1", resolve)) +const port = listener.address().port +await new Promise(resolve => listener.close(resolve)) +console.log(`Isolated fixture: ${root}`) +let started = false +let app, manager +try { + assert.equal(await run(["service", "status"]), "stopped") + await run(["service", "set", "port", String(port)]) + started = true + const url = await run(["service", "start"]) + const registration = JSON.parse(await readFile(path.join(process.env.XDG_STATE_HOME, "opencode", "service.json"), "utf8")) + assert.equal(new URL(url).port, String(port)) + assert.equal(new URL(registration.url).port, String(port)) + const password = await run(["service", "get", "password"]) + const endpoint = { url, auth: { type: "basic", username: "opencode", password } } + const headers = { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` } + const info = await (await fetch(`${url}/api/info`, { headers })).json() + const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url) + rememberRuntime(endpoint, { version: info.version, pid: info.pid, discovery: "info" }) + const { WorkspaceManager } = await tsImport("../packages/server/src/workspaces/manager.ts", import.meta.url) + const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url) + const { registerInstanceProxyRoutes } = await tsImport("../packages/server/src/server/http-server.ts", import.meta.url) + const { WorktreeDeletionFence } = await tsImport("../packages/server/src/workspaces/worktree-session-evacuation.ts", import.meta.url) + let variables = Object.fromEntries(["TEMP", "TMP", "TMPDIR"].map(key => [key, path.join(root, "first")])) + const logger = pino({ level: "silent" }) + manager = new WorkspaceManager({ + rootDir: root, settings: { getOwner: () => ({ environmentVariables: variables }) }, + binaryResolver: { resolveDefault: () => ({ path: cli, label: "Isolated fixture" }) }, + eventBus: new EventBus(), logger, + hostServiceLifecycleFactory: () => ({ discover: async () => endpoint, ensure: async () => endpoint }), + }) + const { workspace } = await manager.create(project) + const native = await manager.getSharedServiceClient() + app = Fastify() + registerInstanceProxyRoutes(app, { workspaceManager: manager, logger, worktreeDeletionFence: new WorktreeDeletionFence() }) + await app.listen({ host: "127.0.0.1", port: 0 }) + const baseUrl = `http://127.0.0.1:${app.server.address().port}/workspaces/${workspace.id}/instance/` + const proxy = OpenCode.make({ baseUrl, fetch: (input, init) => { + const request = new URL(input instanceof Request ? input.url : input) + return fetch(new URL(request.pathname.replace(/^\/+/, "") + request.search, baseUrl), init) + } }) + const a = await proxy.session.create({ location: { directory: project } }) + const b = await proxy.session.create({ location: { directory: project } }) + const probe = async (client, session, label, command) => { + await client.session.shell({ sessionID: session.id, command: command ?? `node probe.cjs ${label}.json` }) + const result = JSON.parse(await readFile(path.join(project, `${label}.json`), "utf8")) + console.log(label, JSON.stringify(result)) + assert.equal(result.path, true) + return result + } + // Creation and viewing do not modify native environment state. + await proxy.session.get({ sessionID: a.id }) + assert.equal((await probe(native, a, "read-only-baseline")).tmpdir, path.join(root, "base")) + for (const [session, label] of [[a, "first-A"], [b, "first-B"]]) { + assert.equal((await probe(proxy, session, label)).tmpdir, path.join(root, "first")) + } + variables = Object.fromEntries(["TEMP", "TMP", "TMPDIR"].map(key => [key, path.join(root, "changed")])) + await proxy.session.get({ sessionID: a.id }) + assert.equal((await probe(native, a, "settings-not-yet-sent")).tmpdir, path.join(root, "first")) + assert.equal((await probe(proxy, a, "changed-A")).tmpdir, path.join(root, "changed")) + assert.equal((await probe(native, b, "B-before-next-send")).tmpdir, path.join(root, "first")) + assert.equal((await probe(proxy, b, "changed-B")).tmpdir, path.join(root, "changed")) + if (process.platform === "win32") { + assert.equal((await probe(proxy, a, "bash-A", "& 'C:/Program Files/Git/bin/bash.exe' -c 'node probe.cjs bash-A.json'")).tmpdir, path.join(root, "changed")) + } + variables = {} + assert.equal((await probe(proxy, a, "removed-overrides")).tmpdir, path.join(root, "base")) + assert.equal((await native.server.status()).pid, info.pid) + console.log(`PASS OpenCode ${info.version}: real manager + proxy + native shells; next-send updates, both conversations, removal, no writes on read, unchanged daemon PID`) +} finally { + await app?.close() + await manager?.shutdown() + if (started) { + await run(["service", "stop"]) + assert.equal(await run(["service", "status"]), "stopped") + console.log("Isolated service stopped") + } +} diff --git a/scripts/test-session-pruning-ui.mjs b/scripts/test-session-pruning-ui.mjs index f93bd03f1..6a6e55859 100644 --- a/scripts/test-session-pruning-ui.mjs +++ b/scripts/test-session-pruning-ui.mjs @@ -30,6 +30,10 @@ export async function testPruningUI({ client, baseUrl, root, location, generate, getInstanceAuthorizationHeader: () => `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`, getServiceDirectory: () => location.directory, getSharedServiceClient: async () => client, + getSessionEnvironment: async () => { + const { sessionEnvironment } = await tsImport("../packages/server/src/workspaces/session-environment.ts", import.meta.url) + return sessionEnvironment({}) + }, getSharedServiceFetch: async () => runtimeFetch, getWorktreeIdentityForPath: async (_id, directory) => owns(directory) ? "isolated-fixture" : undefined, ownsDirectory: async (_id, directory) => owns(directory),