Repository navigation
fix(cli): keep the service's XDG_STATE_HOME out of tool shells and name the port owner - #58815
Conversation
A saved service env (`opencode service set env XDG_STATE_HOME ...`) was copied into the server's process.env, so every tool shell, PTY, MCP server and LSP inherited it. The server itself had already resolved its registration from the launch environment, so anything started from a tool shell (a nested `opencode`, or Desktop relaunched by an agent) looked for service.json under the saved state directory, found nothing, reported the service "missing", and started a second server that failed with "port already in use". The saved env no longer carries XDG_STATE_HOME, both when the server applies it and when the CLI spawns the service with it. XDG_STATE_HOME is the discovery contract every client uses (Desktop's service-probe fallback, the CLI's Global.state), so a value only the service sees can only split it from its clients. XDG_DATA/CONFIG/CACHE_HOME and OPENCODE_DB still pass through: they don't affect discovery, and tools that run a nested CLI keep finding the same database and config. Fixing this in Desktop instead (ignoring an inherited XDG_STATE_HOME) would break the test roots and isolated mode that set it on purpose, and would leave a nested CLI in a tool shell with the same problem. The port-in-use error now names the listener's PID and command when lsof can find it (1 s timeout, Unix only; Windows keeps the generic message).
There was a problem hiding this comment.
I recommend approving this. The fix is in the right layer and is minimal.
I reproduced it in an isolated XDG root. On the base, opencode service set env XDG_STATE_HOME <dir> followed by service start put the registration under <dir>. start then hung and service status printed stopped while the server was running. With this PR the registration stays under the launch state directory, and start and status both report the service URL.
Tests:
- The targeted
service.test.tstests pass. tsc -binpackages/cliis clean (bun run typecheckfails here only because this Bun has nocheckscript).- The lsof test was skipped because lsof isn't installed in the sandbox, so I only saw the "another process" fallback run, not the
PID … (…)message. - The
service-lifecycleconflict test fails the same way on the base because of zombie processes in the sandbox, so that's unrelated to this change.
Public API: a saved XDG_STATE_HOME in the service env is now ignored (CLI spawn and server process.env), and opencode service set env XDG_STATE_HOME … still saves it without a warning. That only changes what an env var does: before this, a saved value split clients from the service anyway, so nothing that worked before breaks. The port-conflict error message is also more specific now. No HTTP, SDK, plugin or config schema changes.
No blocking concerns. #56708 is related but not fully fixed: a client started with a different XDG_STATE_HOME still can't find the service. The new error naming the process on the port should make that much easier to diagnose.
There was a problem hiding this comment.
I recommend approving the new commits. My earlier review left no open points, and I found nothing new that needs fixing.
lsof isn't installed in this sandbox, so I put a fake lsof on PATH that lists listeners on 127.0.0.1, * and [::1]. A specific host matches its own listener; any other literal IP falls back to the wildcard one; no host, localhost and 0.0.0.0 take any listener. I also re-ran the saved-XDG_STATE_HOME repro: the registration stays under the launch state directory, and service start and service status both report the URL. The targeted service.test.ts tests pass (the real-lsof test is skipped here) and tsc -b is clean.
The public API is unchanged since the last review: a saved XDG_STATE_HOME is ignored and the port-conflict error is more specific. Both are backwards compatible.
## Summary - Qualify OpenCode **2.2.1** (latest stable, 29 October) with the existing locked **2.1.0 client/plugin** dependencies, and **recommend** it. - Keep the technical **minimum at 2.1.2**: no consumed contract changed, so the minimum has no new reason to move. - 2.1.2, 2.1.4 and 2.2.0 daemons stay ready and get an optional upgrade offer. 2.2.1 is the `tested` version. Newer and custom labels keep authenticated contract recognition. - The reviewed-contract fast path lists the 2.1.2 minimum and the 2.2.1 target. - The recommended version is updated in the help (all ten locales), both READMEs, `CONTRIBUTING.md`, `dev-docs/OPENCODE_COMPATIBILITY.md` and the architecture skill. ## Contract review (v2.1.4 → v2.2.0 → v2.2.1; 661 + 134 commits) Reviewed from the exact tags `v2.1.4` (`68bc26b7da`), `v2.2.0` (`1097e2668b`) and `v2.2.1` (`3ebcf9a32c`), the published npm packages, and an authenticated OpenAPI dump from each official CLI running in its own sandbox. The [changelog](https://opencode.ai/changelog) covers 2.2.0. It had no 2.2.1 entry at review time (29 October), so 2.2.1 was reviewed from its commit log. - **Authenticated OpenAPI:** 124 → 130 → **134 paths**, 149 → 156 → **162 operations**. Each dump matches the tagged `packages/protocol/openapi.json`. - **2.2.0 adds** `GET /api/plugin/gui`; `POST /api/experimental/generate/{text,image,decisions}`; `POST`/`DELETE /api/experimental/fs/watch`; `GET /api/experimental/media/{hash}`; and `GET /api/experimental/usage/limits`. - **2.2.0 removes** `POST /api/experimental/voice/session/{sessionID}/instructions`. - **2.2.1 adds** six `/api/experimental/schedule` operations (list, create, get, delete, pause, resume), gated by `experimental.schedules`. - **Changed operations:** - `GET /api/model` gains an optional `types` filter. Without it the route lists chat models only. Image and decision models are new in 2.2.0, so the default listing matches 2.1.4. - `GET /api/event` gains an optional `types` filter. - `GET /api/vcs/diff` gains an optional `untracked` parameter. - `POST /api/experimental/generate` becomes the deprecated alias of `/generate/text`. - Nine session, project and saved-permission routes drop `LocationNotFoundError` from their 404 responses: they no longer boot a Location ([#58997](anomalyco/opencode#58997)). - `GET /api/agent` can now answer 503 while plugins settle ([#59082](anomalyco/opencode#59082)). - In 2.2.1, the session log and persistent-PTY operation IDs move under `experimental.*`. Their paths are unchanged. - **Schemas:** all 103 consumed-family schemas are byte-identical from 2.1.4 to 2.2.1. These are `V2EventEncoded`, `Session.*` (messages, parts, inbox, info), `Form.*`, `Permission.*`, `Prompt*`, `Event.*`, `Location.*` and PTY/Shell. The session prompt, environment, message, form/permission reply and RPC operations are unchanged too. The only `Config.InfoEncoded` changes are additive opt-ins: `experimental.media_blob`, `experimental.image_generate`, `experimental.schedules` and `timezone`. Model `type`, prompt-cache `cache` settings, `FileDiff.Info.more`, `Mcp.Server.title` and `ServerInfo.hostname` are additive fields. - **Admission:** every predicate CodeNomad's `compatibility/negotiate.ts` checks holds on 2.2.0 and 2.2.1. That covers the canonical API, `session_environment` and the 2.1 replay shape (`native` without `state`, compaction `metadata` without `recent`). - **Instance proxy:** none of the new routes is on the allowlist. That includes the GUI plugin bundle, generation, file watch, media blobs, usage limits and schedules. The removed voice route was never exposed. The routes CodeNomad does proxy kept their paths. - **Published declarations** (2.1.0 pins → 2.1.4 → 2.2.1): - **Client:** 143 → 147 → 185 methods. 2.2.1 mirrors the experimental methods under `client.experimental.*`. The only removal is `voice.instructions`, which CodeNomad doesn't use. 2.2.1 doesn't send the `experimental-media-uri` capability, so media reads stay inline. - **Schema:** 2.2.0 adds media refs (`MediaRef`, `Session.Message.MediaUser`, inbox `MediaUserPayload`), `generate`, `usage-limits` and `Model.Type`. 2.2.1 adds `goal` and `schedule`. The one removal since 2.1.0 is `SessionEvent.Source` (2.1.4). - **Plugin:** 2.2.0 adds the `gui/*` SDK (excluded from the host runtime) and `Host.packageID`. 2.2.1 adds `SessionIdle`/`SessionIdleBackground`. The event `subscribe` domain gains an optional `types` filter; CodeNomad's plugins don't subscribe through it. - **Typecheck:** the server and UI typecheck with **0 errors** against the 2.1.4 and 2.2.1 client/schema/plugin declarations, through scratch `paths` overrides. The pinned 2.1.0 control is also clean. - **Host modules:** CodeNomad's three daemon-loaded bundles resolve `@opencode/plugin` (`Plugin`), `/effect` (`Plugin`, `Rpc`, `Location`), `/promise/adapter` (`fromPromise`), `/rpc` (`Rpc`), `effect` and `effect/encoding` (`Base64Url`) from the host. `@opencode/schema` and `@opencode/client` are bundled from the 2.1.0 pins, and `@opencode/schema/location` imports are type-only. A probe plugin loaded in an isolated **2.2.1** daemon resolves every one of these modules and bindings, as on 2.1.4. The host Effect stays **4.0.1**, with the same lockfile hash. - **Private native services:** the pending-request reader and Missions recurrence use keyed native services: `LocationServiceMap`, `Form`, `Permission`, `Location`, `Job`, `Database`, `Session` and `Bus`. Their keys are unchanged at v2.2.1, and the members CodeNomad calls are unchanged. The changes are additive: `Permission.assertNotDenied`, `Session.*WithRefs`, an `image` Job kind, and `LocationServiceMap.canonical` normalizing paths. - **Storage** (migrations from 2.1.4 to 2.2.1): `blob`/`blob_ref` (always created; written only with `experimental.media_blob`) and `schedule` are new. `session_pending` is dropped; CodeNomad never reads it. A `session_v2.allowance` column is added and dropped within 2.2.0. - The tables CodeNomad reads directly (`session_message`, `session_v2`, `session_inbox`, `event`, `event_sequence`, `kv`) keep their shape. - The pruning plugin's `media-blob.ts` already projects `media:<sha256>` refs. - The pruning fixture enables `media_blob` explicitly, so the always-present `blob` table doesn't change its expectation. - Missions reads `session_inbox` only by count and ID in production code. - **Behavior changes checked:** - **Code Mode:** 2.2.1 moves programs onto one job queue ([#57976](anomalyco/opencode#57976)). The run-to-completion regression behind the 2.1.2 minimum passes on 2.1.4, 2.2.0 and 2.2.1: `{ total: 2000, beforeAwait: 1000, count: 1000 }`. - **Session form polling** returns `[]` without booting a cold Location ([#59083](anomalyco/opencode#59083)). Forms are in memory, so nothing pending is hidden. This matches CodeNomad's existing-only pending reader. - **Location keys and `session.create` directories** are canonical on POSIX too: trailing slash removed, `posix.normalize` ([#59327](anomalyco/opencode#59327)). Windows already normalized. This is low risk; it was reviewed in source only, since the location and ownership fixtures run on Windows and the POSIX certification covers migration, Code Mode and Git-degraded. - **Location keep-alive:** the changelog line "keep project services alive … with a 24-hour inactivity cap" ([#57274](anomalyco/opencode#57274)) doesn't match the shipped tree. That commit was superseded before 2.1.4, and no cap exists at v2.2.0 or v2.2.1. From 2.1.4 to 2.2.1, `location-activity.ts` only canonicalizes paths and generalizes Job kinds. Long-waiting Missions sessions keep their Location as on 2.1.4. - **Plugin readiness:** each runner step and tool call now waits for plugin activation ([#51133](anomalyco/opencode#51133)), and agent/provider lists wait for plugins. Presence leases stay outside the discovery root, so this adds no reload. - **Service lifecycle:** registration and config renames are retried on Windows. The managed service ignores a saved `XDG_STATE_HOME` ([#58815](anomalyco/opencode#58815)); CodeNomad never sets it. The `OPENCODE_SERVER_USERNAME` change was added and reverted within 2.2.0. Detached POSIX children are killed when OpenCode exits; service start uses its own spawn path. - **2.2.1 opt-ins:** - schedules, both the API and the agent tool, need `experimental.schedules`; - `image_generate` needs `experimental.image_generate`; - `/goal` runs only when invoked; - the subagent tool description now invites follow-ups to *running* subagents, with the same mechanics as 2.1.4. - **Other changes:** compaction sizing from the provider's count, MCP malformed-tool tolerance, OAuth refresh coordination ([#54023](anomalyco/opencode#54023)) and webfetch redirect rules. They touch no consumed contract and were reviewed in source; of these, the native suites exercise compaction only. - **Upstream #52922** (Bus routing before Location fanout) is still open: keep the discovery and event-relay mitigations. **Verdict:** no consumed contract changed from 2.1.4 to 2.2.1. - The **2.1.0 client/plugin pins can stay**. - The **2.1.2 minimum has no reason to move**. - 2.2.1 is admitted through the existing contract recognition, and nothing found blocks recommending it once the native suites pass on it. - No CodeNomad bug was found. Bumping the pins is optional. It would bring the 2.2 client's discard-tool-result-payload option and its "register running sessions missed while disconnected" fix ([#53012](anomalyco/opencode#53012)). It would also move the replay reducer, so it belongs in its own PR. The UI and server already typecheck against 2.2.1. **Not verified here:** - the full native suites against 2.2.0/2.2.1 (only the Code Mode regression and the host-module probe ran); - the TUI pruning companion under OpenTUI 0.7. It uses only `Plugin.define`, `ctx.data.session` and `ctx.data.listen`, so the 0.7 renderable changes don't apply, but this wasn't run; - POSIX canonicalization on a real Linux/macOS runtime; - the 2.2.1 release notes, which were unpublished at review time. ## Isolated Windows qualification Base: dev `157766206`; Node **25.2.1** (local; CI runs the pinned 24.20.0); exact official CLI **2.2.1**; unchanged locked client/plugin **2.1.0**. - **Build and checks:** production server and native plugin builds (pruning, automation, Missions), root typecheck. - **Native runtime:** Code Mode job ordering (`{ total: 2000, beforeAwait: 1000, count: 1000 }` on 2.2.1), plugin runtime, automation; complete pruning, history, proxy, navigation and media; Location, worktree families/management and event relay; permission receipts; Git init/degraded. - **Missions:** contract, family control, native and trajectory; **all 11 recurring Mission journeys** (`ABCDGELFWQN`). - **Sessions:** environment, fork, aside, interrupt, lifecycle, blank cleanup (`--conditions=browser`, as in CI), compaction isolation, prompt skills, provider usage/accounts/selection, service usage, web search, plugin packages, output limit, Promise-tool failure, subagent depth, temporary workspaces. - **Migrations:** history from **2.1.0, 2.1.2, 2.1.4 and 2.2.0** to 2.2.1. - **Installation:** fresh install, native upgrade and legacy replacement fixtures. - **Units after the recommendation change:** compatibility/transport/update **35/35**; full server **2,628 passed, 8 skipped, 1 cancelled** (`index.test.ts` shutdown test timed out at 5 s under full-suite load and passes **4/4** alone); ten-locale help checks pass. In CI, `latest` resolves to 2.2.1: the compatibility job runs the native suites against the minimum and latest on Windows, and the migration and Git-degraded certification runs them on Ubuntu, Windows and macOS. WSL, packaged desktop, installers/updater end-to-end and real mobile devices keep their separate release gates.
What it does
If the service's saved env sets
XDG_STATE_HOME(opencode service set env XDG_STATE_HOME …), every tool shell the service starts inherited it. Anything started from that shell then looked for the service registration in the wrong place. That includes a nestedopencodeand the desktop app relaunched by an agent. They reported the service "missing" and started a second server, which failed after 15 s with "port … already in use". Retrying failed the same way.This PR stops the saved env from moving the registration. The port-in-use error now also names the process holding the port.
How it works
ServiceConfig.processEnvdropsXDG_STATE_HOMEfrom the saved env. The server uses it when it copies the saved env intoprocess.env, which tool shells, PTYs, MCP servers and LSPs inherit. The CLI uses it when it spawns the service with that env.PortOwner.describerunslsof -nP -iTCP:<port> -sTCP:LISTEN -Fpnandps -o command= -p <pid>(1 s timeout each, SIGKILL on timeout, whitespace collapsed, capped at 200 chars). When the bind host is a literal IP, it only names a listener on that address or a wildcard (*) one. The error then readsalready in use by PID 123 (…). If the lookup fails, and on Windows, it keeps "another process".Why fix it in the server rather than in Desktop (for example, Desktop ignoring an inherited
XDG_STATE_HOME):XDG_STATE_HOMEis how every client finds the service: Desktop'sservice-probefallback, the CLI'sGlobal.stateand the SSH bootstrap. A value only the service's children see can only split them from it.XDG_STATE_HOMEon purpose, so ignoring it there would break them.XDG_DATA/CONFIG/CACHE_HOMEandOPENCODE_DBstill pass through. They don't affect discovery, and a nested CLI keeps finding the same database and config.Risk
opencode servicespawn). A savedXDG_STATE_HOMEis now ignored. Before this, it only worked when the CLI spawned the service, and even then clients couldn't find the registration.global-roots.tsreadsXDG_DATA/CONFIG/CACHE_HOMEat import time, beforeconfig.envis applied. So onlyOPENCODE_*vars reliably relocate server paths. A possible follow-up is to strip all four or document it.Proof
Reviewed by Argon before push; verdict ship, with one nit applied (collapse whitespace in the
psoutput).I ran the tests with any inherited
OPENCODE_*/XDG_*vars unset:bun test test/service.test.ts -t "saved service environment|port owner|unresponsive managed port|service config manages environment": 5 pass. The new tests cover env filtering,options()env and file, and the port owner for a listening and a free port. The existing conflict test now expectsPID <pid> (.bun test test/service-lifecycle.test.ts -t "unrelated process on the port": 1 pass.port owner gives up on a lookup that ignores SIGTERM: a fakelsofthat ignores SIGTERM returns in ~1 s. Without SIGKILL it hangs (fails at the 4 s bound).port owner names the listening processnow also covers the bind host: the specific address,localhost, another loopback address (undefined) and a wildcard listener.unresponsive managed port occupancy reports a bounded conflict: the directly launched service has a savedXDG_STATE_HOME, and a wrapperlsof(a child of the service) records theXDG_STATE_HOMEit inherited. It sees the launch state dir. With the oldObject.assign(process.env, config.env)it fails, receiving the saved dir.service.test.tsandservice-lifecycle.test.ts; typecheck and oxlint clean.bun run typecheck(cli): clean.oxlinton the changed files: clean.Added by the opencode bot (AI-generated).
Related: #56708, #58141
Public API: a saved
XDG_STATE_HOMEin the service env is now ignored; the port-conflict error names the owning PID (backwards compatible)