Skip to content

fix(cli): keep the service's XDG_STATE_HOME out of tool shells and name the port owner - #58815

Merged
kitlangton merged 3 commits into
devfrom
fix/service-xdg-inherit
Oct 25, 2026
Merged

kitlangton merged 3 commits into
devfrom
fix/service-xdg-inherit

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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 nested opencode and 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

  1. ServiceConfig.processEnv drops XDG_STATE_HOME from the saved env. The server uses it when it copies the saved env into process.env, which tool shells, PTYs, MCP servers and LSPs inherit. The CLI uses it when it spawns the service with that env.
  2. The service registers under the state directory it was launched with, so its children now see that same directory.
  3. On a bind conflict, PortOwner.describe runs lsof -nP -iTCP:<port> -sTCP:LISTEN -Fpn and ps -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 reads already 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_HOME is how every client finds the service: Desktop's service-probe fallback, the CLI's Global.state and the SSH bootstrap. A value only the service's children see can only split them from it.
  • Desktop test roots and isolated mode set XDG_STATE_HOME on purpose, so ignoring it there would break them.
  • A nested CLI in a tool shell would still have the same problem.

XDG_DATA/CONFIG/CACHE_HOME and OPENCODE_DB still pass through. They don't affect discovery, and a nested CLI keeps finding the same database and config.

Risk

  • CLI only (server process and opencode service spawn). A saved XDG_STATE_HOME is now ignored. Before this, it only worked when the CLI spawned the service, and even then clients couldn't find the registration.
  • When Desktop spawns the service directly, global-roots.ts reads XDG_DATA/CONFIG/CACHE_HOME at import time, before config.env is applied. So only OPENCODE_* vars reliably relocate server paths. A possible follow-up is to strip all four or document it.
  • The port-owner lookup runs only on the failure path, after the 15 s retry window, and is bounded at about 2 s.

Proof

Reviewed by Argon before push; verdict ship, with one nit applied (collapse whitespace in the ps output).

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 expects PID <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 fake lsof that ignores SIGTERM returns in ~1 s. Without SIGKILL it hangs (fails at the 4 s bound).
  • port owner names the listening process now 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 saved XDG_STATE_HOME, and a wrapper lsof (a child of the service) records the XDG_STATE_HOME it inherited. It sees the launch state dir. With the old Object.assign(process.env, config.env) it fails, receiving the saved dir.
  • Rerun after the review fixes: 6 pass across service.test.ts and service-lifecycle.test.ts; typecheck and oxlint clean.
  • bun run typecheck (cli): clean.
  • oxlint on the changed files: clean.

Added by the opencode bot (AI-generated).

Related: #56708, #58141

Public API: a saved XDG_STATE_HOME in the service env is now ignored; the port-conflict error names the owning PID (backwards compatible)

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

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts tests pass.
  • tsc -b in packages/cli is clean (bun run typecheck fails here only because this Bun has no check script).
  • 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-lifecycle conflict 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.

Related: #56708, #58141

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kitlangton
kitlangton merged commit 1564686 into dev Oct 25, 2026
14 checks passed
@kitlangton
kitlangton deleted the fix/service-xdg-inherit branch October 25, 2026 14:15
@amrkmn amrkmn mentioned this pull request Oct 27, 2026
1 task
pascalandr added a commit to NeuralNomadsAI/CodeNomad that referenced this pull request Oct 29, 2026
## 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant