fix(environment): refresh profile variables before each prompt (#714)

## Summary

Apply the existing CodeNomad profile environment **before every prompt,
custom command and session-shell send**, then forward the request only
after OpenCode acknowledges it. This makes profile changes available to
local commands even when the shared daemon is already running.

No per-conversation settings UI, polling, session-opening writes or
daemon restart. The implementation lives in the shared server for
Electron/Tauri/web parity.

close #711; awaiting reporter validation rather than automatically
closing the issue.

## Implementation

- Build a complete execution-host snapshot; preserve PATH and other
inherited values, normalize Windows key collisions, and exclude internal
authentication/storage variables. WSL reads its selected distro's
environment instead of sending Windows PATH/HOME.
- Apply after ownership checks and worktree mutation admission, using
the pinned native connection. Fail the send on environment errors, stale
connection or disconnect; never log the native environment request body.
- Update help and failure messages in every locale. Changes and removals
take effect on each conversation's next send.

## Validation

- Server suite: **586 passed, 2 platform skips**.
- Focused UI send/SDK/authority tests: **112 passed**.
- Server, UI and Electron typechecks; production UI build passed.
- **OpenCode 2.0.9 / Windows native fixture:** real WorkspaceManager +
guarded proxy + native shells, two conversations, changes/removal, no
mutation on read, Git Bash, unchanged daemon PID. Independent
configuration/database/port; only the fixture service was stopped.
- Reproduction: `node scripts/test-session-environment-native.mjs
<absolute-opencode-executable>`.

## Scope

This changes local session command environments, not provider/MCP/LSP
configuration or daemon-internal temporary files. The native environment
belongs to a session: another client or a new send during existing work
can replace it. Queued prompts do not get individual snapshots, and
native-created subagents are not automatically intercepted. Details:
`dev-docs/SESSION_ENVIRONMENT.md`.

Existing oversized files touched narrowly:
`packages/server/src/server/http-server.ts` (~2,300 lines),
`packages/server/src/workspaces/manager.ts` (~1,200 lines), and
`packages/ui/src/lib/i18n/messages/*/settings.ts` (~600 lines each).
This commit is contained in:
Pascal André 2026-09-19 18:18:56 +02:00 • committed by GitHub
parent 333a1c073b
commit bdac05bb25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 604 additions and 32 deletions

View file

@ -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"

View file

@ -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.

View file

@ -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` |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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/<root-hash>/` 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.

View file

@ -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 <distro> --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 <absolute-opencode-executable>
```
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.

View file

@ -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

View file

@ -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")

View file

@ -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),

View file

@ -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<string, Record<string, string>>()
const sends: Array<{ sessionID: string; variables?: Record<string, string>; body: unknown }> = []
const control = {
configured: { TEMP: "T:/", TMP: "T:/", TMPDIR: "T:/" } as Record<string, string>,
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<string, string> }).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<void>(resolve => { started = resolve })
control.beforeEnvironment = () => { started(); return new Promise<void>(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<void>(resolve => { started = resolve })
control.beforeEnvironment = () => { started(); return new Promise<void>(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<void>(resolve => { ready = resolve })
const aborted = new Promise<void>(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, [])
})

View file

@ -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<string | undefined>
getServicePathForPath?(id: string, candidate: string): Promise<string | undefined>
getSharedServiceClient(): Promise<OpenCodeClient>
getSessionEnvironment(id: string, signal?: AbortSignal): Promise<Record<string, string>>
ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): ReturnType<WorkspaceManager["ownsLocation"]>
ownsDirectory(id: string, directory: string): Promise<boolean>
ownsPath(id: string, candidate: string): Promise<boolean>
@ -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]

View file

@ -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<Record<string, string>> {
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 = {}

View file

@ -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/)
})

View file

@ -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<Record<string, string>> {
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<string, [string, string]>()
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<NodeJS.ProcessEnv> {
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)
}

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -1,6 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "הגדרות מתקדמות",
"advancedSettings.environmentVariables.title": "משתני סביבה",
"advancedSettings.environmentVariables.subtitle": "מוחלים בכל פעם שמופע OpenCode חדש מופעל",
"advancedSettings.environmentVariables.subtitle": "מוחלים לפני כל הנחיה או פקודת שיחה",
"advancedSettings.actions.close": "סגור",
} as const

View file

@ -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": "הקשר",

View file

@ -1,6 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "詳細設定",
"advancedSettings.environmentVariables.title": "環境変数",
"advancedSettings.environmentVariables.subtitle": "新しい OpenCode インスタンス起動時に適用",
"advancedSettings.environmentVariables.subtitle": "各プロンプトまたはセッションコマンドの送信前に適用",
"advancedSettings.actions.close": "閉じる",
} as const

View file

@ -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": "コンテキスト",

View file

@ -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

View file

@ -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)",

View file

@ -1,6 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "Расширенные настройки",
"advancedSettings.environmentVariables.title": "Переменные окружения",
"advancedSettings.environmentVariables.subtitle": "Применяются при запуске нового экземпляра OpenCode",
"advancedSettings.environmentVariables.subtitle": "Применяются перед каждым запросом или командой сессии",
"advancedSettings.actions.close": "Закрыть",
} as const

View file

@ -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": "Контекст",

View file

@ -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

View file

@ -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",

View file

@ -1,6 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "高级设置",
"advancedSettings.environmentVariables.title": "环境变量",
"advancedSettings.environmentVariables.subtitle": "每当启动新的 OpenCode 实例时应用",
"advancedSettings.environmentVariables.subtitle": "每次发送提示或会话命令前应用",
"advancedSettings.actions.close": "关闭",
} as const

View file

@ -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": "上下文",

View file

@ -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///"),

View file

@ -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<string, OpenCodeClient>()
@ -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)

View file

@ -248,10 +248,11 @@ test("cancelling a bounded pending scan does not launch queued locations", async
const sync = syncPendingRequests(instanceId)
await new Promise<void>((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<void>((resolve) => setImmediate(resolve))
assert.equal(calls, 8)
assert.equal(calls, 2)
})

View file

@ -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,

View file

@ -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),

View file

@ -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")
}
}

View file

@ -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),