Skip to content

Session routes throw raw ParseError when id carries opencode: deep-link scheme prefix (opencode%3Ases_...) #31145

@RobinVivant

Description

@RobinVivant

Description

Session routes throw an unguarded Effect Schema ParseError when a session id arrives carrying the opencode: deep-link scheme prefix (URL-encoded as opencode%3A). The server logs:

ERROR service=server ref=err_cee4144f error=Expected a string starting with "ses", got "opencode%3Ases_169659db9ffeX6V47C8c3Sclz7" cause=Error: Expected a string starting with "ses", got "opencode%3Ases_169659db9ffeX6V47C8c3Sclz7"

opencode%3Ases_... is URL-encoded opencode:ses_.... Note the value contains a valid ses_... id - it just has the opencode://session/<id> deep-link scheme prefix prepended (added in #6232) and is never stripped/decoded before reaching the SessionID validator.

This is distinct from the existing placeholder-id reports (#28486 / #29262 got "dummy", #29868 got "%7Bid%7D" = uninterpolated {id}): those are placeholder strings, whereas this is a real id wrapped in the deep-link scheme. They do share a common theme - several code paths feed un-normalized strings straight into SessionID.make, which throws a raw Effect ParseError instead of degrading gracefully.

Root cause

The opencode: scheme prefix is never stripped (nor is the path segment decodeURIComponent'd) before the id reaches Schema.isStartsWith("ses"). Two paths fail:

1. Validator - packages/core/src/session/schema.ts:

export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
  Schema.brand("SessionID"),
  ...
)

(re-exported as SessionV2.ID, aliased SessionID in packages/opencode/src/session/schema.ts). The error text itself is formatted by Effect's ParseResult.ts (getDefaultTypeMessage -> Expected ${description}, actual ${value}), with the filter description a string starting with "ses".

2. Workspace-routing middleware - packages/opencode/src/server/shared/workspace-routing.ts (getWorkspaceRouteSessionID):

const id =
  url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] ??
  url.pathname.match(/^\/experimental\/session\/([^/]+)\/background$/)?.[1]
if (!id) return null
return SessionID.make(id)   // id still carries the opencode: prefix -> throws

Called from .../httpapi/middleware/workspace-routing.ts for every /session/* route, so the throw fires before the handler.

3. Effect HttpApi path param - .../httpapi/groups/session.ts, every endpoint with params: { sessionID: SessionID } validates the raw :sessionID path segment the same way.

The only existing decodeURIComponent in the server (.../middleware/instance-context.ts) decodes the directory param, not session ids. There is no code that strips the opencode: scheme from a session id.

(Contrast: the TUI route TuiHttpApi.selectSession guards with if (!sessionID.startsWith("ses")) return BadRequest{}, so it fails cleanly there - but the workspace-routing / HttpApi paths surface the raw ParseError.)

Steps to reproduce

  1. Run opencode serve.
  2. Issue a session request whose path segment carries the deep-link scheme, e.g. a request derived from an opencode://session/<id> deep link, so the path contains opencode%3Ases_<id> (URL-encoded opencode:ses_<id>).
  3. The server responds with an unexpected error; logs show Expected a string starting with "ses", got "opencode%3Ases_...".

Suggested fix

Normalize (decode + strip scheme) before validation. Primary site - getWorkspaceRouteSessionID:

if (!id) return null
const decoded = decodeURIComponent(id)
const bare = decoded.startsWith("opencode:") ? decoded.slice("opencode:".length) : decoded
return SessionID.make(bare)

And apply the equivalent for the HttpApi params: { sessionID } path (e.g. a SessionIDFromPath transform schema that decodes + strips the scheme), so both entry points normalize identically. More broadly, consider having session routes return 400 BadRequest instead of letting SessionID.make throw a raw ParseError to the server error handler (the same hardening would improve #28486 / #29262 / #29868).

Related

OpenCode version

Observed on a build from 2026-06-05 (analysis pinned to commit 4519a1da on main); CLI 1.16.2 installed locally.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions