Skip to content

feat(opencode)!: start and stop opencode serve in the handler - #198

Merged
apucacao merged 6 commits into
mainfrom
cursor/opencode-serve-ownership-63ab
Oct 22, 2026
Merged

apucacao merged 6 commits into
mainfrom
cursor/opencode-serve-ownership-63ab

Conversation

@apucacao

Copy link
Copy Markdown
Contributor

Each call starts opencode serve, speaks the OpenCode HTTP API, and stops the process when the stream finishes or the caller closes it.

create_opencode_handler takes the working directory, the OpenCode config file, and the child environment (cwd, config, env), plus the existing optional capture_content, model, and agent. command defaults to opencode; a test passes a fake executable there. There is no base_url, client, or transport. config().stream() is still chunk then done.

A fake opencode proves the handler started the process, sent /event, /session, and /message to it, and stopped it. Closing the stream aborts the session and stops the process. Removing that ownership fails the test.

Open in Web Open in Cursor 

Each call starts opencode serve from the working directory, config file,
and environment, speaks the OpenCode HTTP API itself, and stops the process
when the stream finishes or the caller closes it. Callers no longer pass
base_url. A test can pass a fake command instead of a URL. config().stream()
is unchanged.

Co-authored-by: alexis <alexis@launchdarkly.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

cursoragent and others added 2 commits October 21, 2026 23:19
async with handler starts the process and waits until it is healthy.
The process stops when that context exits, after config().stream()
returns, including when the caller times out or closes the stream.
A wall-clock budget stays around config().stream(), so boot is not
part of it.

Co-authored-by: alexis <alexis@launchdarkly.com>
--print-logs and --log-level are not serve options. A binary that
rejects them exits before it listens, and the handler already
discards the child's output.

Co-authored-by: alexis <alexis@launchdarkly.com>

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

Owning the process in the handler is the right call: callers no longer have to run and secure a server themselves, and the stream/close/timeout tests cover the one-shot path well. Breaking the API is fine here since we're pre-1.0. Two things to fix before merge, then three smaller ones. I checked each on 920a750.

1. Must fix: two concurrent async with handler: blocks leave a process running forever. _active is set only after await active.start() returns, so a second caller that enters during startup also sees None and spawns its own process. Whichever finishes second overwrites _active, and nothing ever stops the first one. I ran this against the PR's fake_opencode.py:

async def user():
    async with handler:
        await asyncio.sleep(0.1)
await asyncio.gather(user(), user())
# spawned: 2  alive after both contexts exited: [8926]

A server that shares one handler across requests hits this whenever two requests arrive together. Fix: self._lock = asyncio.Lock() in __init__, and wrap the bodies of __aenter__ and __aexit__ in async with self._lock:. Please add a test like the snippet above that checks every PID in script.pids is dead.

2. Must fix: the started server accepts requests from any local process. Now that the SDK starts opencode serve, its defaults are ours. It starts with no password, so during a run any process or user on the machine can scan loopback, find the port and drive the agent: shell tools in cwd, with the API keys in env. Shared CI runners and multi-tenant containers are the real case. OpenCode supports HTTP basic auth through OPENCODE_SERVER_PASSWORD (username opencode, or OPENCODE_SERVER_USERNAME; https://opencode.ai/docs/server). Fix: generate secrets.token_urlsafe(32) in start(), set it in _child_env, and pass auth=("opencode", password) to both httpx.AsyncClients (the health probe and session_events in server.py). This also fixes a failure I reasoned through but didn't run: with the README's env=os.environ.copy(), a host that already sets OPENCODE_SERVER_PASSWORD makes the child require auth the SDK never sends, so every call gets 401.

3. Should fix: a failed startup gives no reason. stdout and stderr go to DEVNULL, so a bad config or a missing provider key surfaces only as opencode serve exited during startup (code 1). Send stderr to a tempfile.TemporaryFile() and add its last ~2 KB to the startup error. Don't use PIPE: nothing reads it during the run, so a chatty server fills the pipe buffer and blocks.

4. Nice to have: any 4xx counts as healthy. status_code < 500 passes a 404 from another program that took the port between _free_port() and opencode binding it. With credentials from #2 in place, /global/health returns {healthy: true, version}, so require 200 and response.json().get("healthy") is True.

5. Nice to have: the isinstance(allocated, int) check in _free_port can't fail for AF_INET, so it can go.

Not checked: whether opencode's own children (LSP servers, shell tools) outlive the 5 s kill() fallback; I didn't run a real opencode.

"""Start ``opencode serve`` and return once it answers on loopback."""
if self._active is None:
active = self._serve.spawn()
self._base_url = await active.start()

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.

A second caller that enters while this await is running still sees _active is None and spawns its own process. The later one overwrites _active, and the earlier process is never stopped (finding 1; reproduced with fake_opencode.py: 2 spawned, 1 alive after both contexts exited). An asyncio.Lock around the bodies of __aenter__ and __aexit__ fixes it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Concurrent entries now share one process. __aenter__ and __aexit__ both take an asyncio.Lock, held across start and stop, so a second entry cannot replace _active while the first is still booting. test_two_concurrent_contexts_leave_no_process gathers two async with blocks and asserts the one pid in script.pids is dead. Head is 38533a9f42ef173b293e421bf63a6ac15680ba41.

self._proc = await asyncio.create_subprocess_exec(
*argv,
cwd=cwd,
env=_child_env(self._env, config),

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.

No OPENCODE_SERVER_PASSWORD here, so any local process can drive this server during the run (finding 2). Setting a per-start secrets.token_urlsafe(32) here, and sending it as basic auth from both HTTP clients, closes that. It also overrides a password inherited through env=os.environ.copy(), which today would make every call fail with 401.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

start() sets OPENCODE_SERVER_PASSWORD to secrets.token_urlsafe(32), replacing a password inherited from env. Both httpx clients send auth=("opencode", password). Bugbot then caught an inherited OPENCODE_SERVER_USERNAME, so the child username is forced to opencode as well, the same name those clients send. Head is 38533a9f42ef173b293e421bf63a6ac15680ba41.

cwd=cwd,
env=_child_env(self._env, config),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,

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.

With stderr discarded, a startup failure reports only the exit code (finding 3). A tempfile.TemporaryFile() here, with its tail added to the error, keeps the reason without the pipe-buffer hang PIPE would risk.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

stderr goes to a tempfile.TemporaryFile(), not a pipe. A failed start appends the last 2KB. test_a_startup_failure_includes_the_stderr_tail writes a marker past that window and asserts only the tail is in the error. Head is 38533a9f42ef173b293e421bf63a6ac15680ba41.

response = await http.get(f"{base}/global/health", timeout=2.0)
except httpx.HTTPError:
response = None
if response is not None and response.status_code < 500:

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.

< 500 also accepts a 404 from some other program on this port (finding 4). /global/health returns {healthy: true, version}, so checking status_code == 200 and healthy is tighter.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

/global/health counts as ready only when the status is 200 and response.json().get("healthy") is True. A 404 or a 200 without healthy: true keeps polling. Head is 38533a9f42ef173b293e421bf63a6ac15680ba41.

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
allocated = sock.getsockname()[1]
if not isinstance(allocated, int):

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.

getsockname()[1] is always an int for AF_INET, so this branch can't run (finding 5).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The isinstance(allocated, int) check is gone. _free_port returns the AF_INET port directly. Head is 38533a9f42ef173b293e421bf63a6ac15680ba41.

Concurrent entries share one process, and the last exit stops it. Each
start sets OPENCODE_SERVER_PASSWORD and sends that basic auth. A failed
start includes the stderr tail, and health requires a healthy response.

Co-authored-by: alexis <alexis@launchdarkly.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 021187e. Configure here.

A copied environment can set OPENCODE_SERVER_USERNAME. The clients always
authenticate as opencode, so that inherited name made every request 401.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao
apucacao requested a review from jeffdupont October 22, 2026 15:14

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

Thanks, all five are fixed. I checked each one on 38533a9:

  1. Concurrent contexts: the lock around __aenter__ / __aexit__ works. Two asyncio.gathered async with handler: blocks now start 1 process and leave 0 running (it was 2 started, 1 left running on 920a750). test_two_concurrent_contexts_leave_no_process covers it.
  2. Server password: a generated OPENCODE_SERVER_PASSWORD and a forced OPENCODE_SERVER_USERNAME=opencode replace whatever env carries, and both clients send them. The fake now returns 401 on every route without the right credentials, so the whole suite exercises auth. I checked that it fires: with auth= removed from the session_events client, 44 of 54 tests fail.
  3. Startup errors: stderr goes to a temp file, and a failed start includes the last 2 KB; the file is closed on every path through stop().
  4. Health: only 200 with healthy: true counts.
  5. _free_port: dead check removed.

Non-blocking: test_a_fake_opencode_is_started_spoken_to_and_stopped fails on macOS, because /var is a symlink to /private/var and the child reports the resolved cwd. Comparing against str(script.cwd.resolve()) would fix it. CI on Linux passes, and the assertion predates this push.

macOS reports /var through /private/var. The child cwd is already
resolved, so the assertion has to compare the resolved path.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 22, 2026

Copy link
Copy Markdown

The cwd assertion now compares str(script.cwd.resolve()), so a macOS /var → /private/var symlink matches the path the child reports. Head is ab91afd44400b5bf0a4c20dea715a55af9b9b4dc.

@apucacao
apucacao requested a review from jeffdupont October 22, 2026 15:33
@apucacao
apucacao merged commit 0748dcf into main Oct 22, 2026
7 checks passed
@apucacao
apucacao deleted the cursor/opencode-serve-ownership-63ab branch October 22, 2026 15:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants