Skip to content

feat(router): add attach endpoint for local clients to connect to router sessions#27039

Closed
mrsimpson wants to merge 617 commits into
anomalyco:devfrom
mrsimpson:feat/attach-to-session
Closed

feat(router): add attach endpoint for local clients to connect to router sessions#27039
mrsimpson wants to merge 617 commits into
anomalyco:devfrom
mrsimpson:feat/attach-to-session

Conversation

@mrsimpson
Copy link
Copy Markdown

Intent

Allow local opencode clients to attach to a running router session without
going through OAuth, so developers can connect their local CLI directly to
a cloud-hosted session.

Decisions

  • Use a dedicated attach server on a separate port (ATTACH_PORT, default 4096)
    that is intentionally not behind oauth2-proxy, so attach subdomain requests
    can bypass OAuth.
  • Authenticate via a per-session password stored in a PVC annotation
    (opencode.ai/attach-password) rather than OAuth tokens. Password is
    generated at session creation and lazy-created for existing sessions.
  • Accept the password via HTTP Basic Auth, ?password= query param, or
    X-Attach-Password header to support different client integration styles.
  • Restrict GET /api/sessions/:hash/attach-info to the session owner only
    (403 for everyone else) so passwords are never leaked across users.

Key changes

  • index.ts — add attach subdomain routing with password auth; extract shared proxyToPod helper used by both HTTP and WS paths; fix wsHandler type (upgrade event signature, not RequestListener); start attachServer on ATTACH_PORT alongside the main server
  • pod-manager.ts — add getOrCreateAttachPassword, generateAttachPassword, getAttachUrl; store attach password in PVC at creation time; expose attachUrl/attachPassword in SessionInfo; fix accidental removal of env block (OPENCODE_POD_SECRET, OPENCODE_SESSION_HASH, OPENCODE_ROUTER_URL) from pod spec
  • api.ts — add GET /api/sessions/:hash/attach-info endpoint returning attachUrl and attachPassword for session owner
  • config.ts — add attachPort and attachRoutePrefix config options
  • session-item.tsx — add Attach button that copies the ready-to-run opencode attach <url> --password <pw> -s <sessionId> command to clipboard
  • router-app/api.ts — expose attachUrl/attachPassword in SessionSchema
  • *.test.ts — unit tests for attach-info API, hostname parsing, password generation, and config defaults
Env var Default Description
ATTACH_PORT 4096 Port for the attach server (must not be behind oauth2-proxy)
ATTACH_ROUTE_PREFIX attach- Subdomain prefix for attach URLs

mrsimpson and others added 30 commits April 15, 2026 11:59
…ly to router

The cloudflare operator was routing session subdomain tunnel traffic directly
to the opencode-router service, bypassing Traefik entirely. This meant the
IngressRoute middleware chain (ForwardAuth → oauth2-chain) never ran, so
X-Auth-Request-Email was never injected and every request returned
'Missing user identity'.

Fix: point ROUTER_SERVICE_URL at traefik-controller so session subdomain
traffic flows: Cloudflare → cloudflared → Traefik → IngressRoute → oauth2-chain
→ ForwardAuth → opencode-router → session pod.
patchNamespacedPod with @kubernetes/client-node v1.4.0 defaults to
Content-Type: application/json-patch+json, which requires an array of
RFC 6902 operations. Sending a plain merge-patch object caused the k8s
API to return 400 'cannot unmarshal object into Go value of type
[]handlers.jsonPatchOp'.

Fix: use op:add with JSON pointer path (RFC 6901 escaping for '/'→'~1')
so both updateLastActivity and deleteIdlePods annotation refreshes work.
0.1.1 adds X-Auth-Request-Token to the ForwardAuth middleware's
authResponseHeaders so oauth2-proxy forwards the GitHub OAuth token
to the router as X-Auth-Request-Token. Without it, ensurePod always
received githubToken=undefined and never created the per-session
GitHub token Secret, leaving GITHUB_TOKEN absent from session pods.
The deployment already sets DEBUG_HEADERS=true but the router never
read it. Add debugHeaders to config and log x-auth-request-token and
x-forwarded-access-token on every /api/* call so we can verify whether
the GitHub OAuth token reaches the router from Traefik ForwardAuth.
The router code reads DEBUG_HEADERS to log incoming auth headers on /api/*
calls, but the env var was missing from the Pulumi deployment spec. Add it
so we can diagnose whether X-Auth-Request-Token reaches the router.
Move debug logging to the top of the request handler so it fires for
every request regardless of URL, and include the host so we can see
both session-subdomain and root-domain traffic. Also add DEBUG_HEADERS
to the Pulumi deployment env so the router actually reads it.

This will confirm whether X-Auth-Request-Token arrives at the router
at all, and whether POST /api/sessions is hitting the expected code path.
oauth2-proxy sets X-Auth-Request-Access-Token (not X-Auth-Request-Token)
when --pass-access-token=true is used. Update getGithubToken() to check
the correct header first, with fallbacks for backward compatibility.
Also fix the debug log to display the correct header name.
0.1.2 fixes the ForwardAuth authResponseHeaders to use
X-Auth-Request-Access-Token (what oauth2-proxy actually sets with
--pass-access-token=true) instead of X-Auth-Request-Token (which
oauth2-proxy never sets). This makes Traefik forward the GitHub
OAuth token to the router so GITHUB_TOKEN appears in session pods.
patchNamespacedSecret with a plain object body fails with HTTP 400
'cannot unmarshal object into Go value of type []handlers.jsonPatchOp'
because the client defaults to application/json-patch+json which
requires an RFC 6902 array. Use replaceNamespacedSecret (PUT) instead
— idempotent for a single-key secret and avoids the patch content-type
issue entirely.
Grants authenticated users a GitHub token with repo, read:org, workflow
and gist scopes — required for typical dev workflows in the code app.
- Add src/models.ts: filterFreeModels/filterPaidModels pure functions +
  fetchFreeModels/fetchPaidModels async helpers that call OpenRouter API
- index.ts: add openrouterApiKey + openrouterFreeApiKey secrets, mount
  OPENROUTER_API_KEY + OPENROUTER_FREE_API_KEY into opencode-api-keys
- index.ts: ConfigMap now uses pulumi.all to embed live model lists at
  deploy time; openrouter (paid, curated) + openrouter-free (custom
  @ai-sdk/openai-compatible, all zero-price models)
- images/opencode/config/opencode.json: static snapshot of both providers
  as fallback when ConfigMap is not mounted
…Router secrets to stack config

Session pods now use OpenRouter providers exclusively; Anthropic key
removed from the mounted secret. Both openrouterApiKey and
openrouterFreeApiKey added to Pulumi.dev.yaml (encrypted).
…dels via jq

- Add jq to opencode image for deep merge functionality
- Simplify ConfigMap to only contain provider.model lists (dynamic)
- Static config (agents, skills, plugins, MCP) stays baked in image
- Init container deep-merges ConfigMap into baked config with jq
- Remove unused code:anthropicApiKey from stack config
- Add session pod configuration docs to README.md
mrsimpson and others added 25 commits May 5, 2026 07:35
The --require path was set in the pod spec, which broke any pod running
an older image that didn't yet have /etc/bind-all-interfaces.cjs baked in.

Fix: move NODE_OPTIONS=--require=/etc/bind-all-interfaces.cjs into the
Dockerfile as an ENV instruction. The file is guaranteed present in the
same image layer (COPYed above), so --require always resolves. Old pods
running old images simply don't have NODE_OPTIONS set — no crash.

Also add a CI test step (before push) that starts a node server inside
the built image with listen(5173, 'localhost') and asserts the bound
address is 0.0.0.0, failing the build if the patch is broken.
…d ports only

Two bugs prevented dev-server port routes from being created:

1. fetchSessionPorts used routerServiceUrl (Traefik/OAuth2) instead of
   routerAdminUrl (direct ClusterIP). The /api/sessions/:hash/ports GET
   endpoint sits behind the oauth2 middleware when accessed via Traefik,
   returning 404 for every poll cycle. Fix: use config.routerAdminUrl
   which bypasses Traefik and hits the router service directly.

2. The port-watcher plugin pushed all listening ports (hundreds of
   ephemeral kernel sockets) instead of only the dev-server allowlist.
   Fix: filter by DEV_PORT_ALLOWLIST before pushing, and additionally
   filter by TCP state 0A (TCP_LISTEN) to exclude established connections.
POST /api/sessions/:hash/progress was missing the same pre-email-check
bypass that POST /ports has. Requests from the in-pod plugin carry only
x-pod-secret, never an oauth2 email header, so they hit the 401 gate
at line 111 before reaching handleApi.

Combined the two pod-push bypasses into one regex (progress|ports) so
the pattern stays consistent. Auth is enforced inside handleApi via
podSecretStore.verify() — same as before for /ports.
…d context

Adds a dev-server skill to the homelab opencode image so agents know how to
start dev servers detached (nohup pattern), which ports are auto-exposed
(3000, 3001, 4321, 5173, 5174, 8000, 8080, 8888), and how to construct the
port-forwarding public URL using OPENCODE_SESSION_HASH.

Also fixes the Makefile build-opencode/push-opencode targets which were using
homelab/ as Docker build context; the Dockerfile COPYs packages/ and
deployment/homelab/ paths that require the monorepo root as context.
Two root causes identified and fixed:

1. Wrong CWD for skills-server: opencode spawns local MCP servers with the
   git workspace as CWD (/home/opencode/repo). @codemcp/skills-server resolves
   .agentskills/skills/ relative to CWD, so it failed to find skills installed
   in ~/.config/opencode/. Fixed by changing the command to use sh -c and
   passing $HOME/.config/opencode as argv so the shell expands the path.

2. Skills not re-synced on pod restart: the init container only seeded config
   and ran setup-skills.sh on first pod start. After image updates adding new
   skills, restarted pods kept the stale config dir and missed new skills.
   Fixed in pod-manager.ts: .agentskills/, .ade/, and skills-lock.json are
   now always synced from /etc/opencode-defaults/ and init-scripts/*.sh is
   always re-run on every pod start (idempotent).
…sionInfo

Commit 1328722 swapped the if/else if order so that a resumed pod
with an initialMessage annotation would always bootstrap a new session
instead of linking to the existing one stored in the PVC's SQLite DB.

Restore the original logic: check activity.sessionId first (link to
existing session on resume), only fall through to bootstrapPodSession
when the pod has no sessions yet (fresh pod).

Adds 4 regression tests covering all combinations of sessionId ×
initialMessage to prevent this from regressing again.
consolidate skill files in the agentskills path
…ter sessions

## Intent

Allow local opencode clients to attach to a running router session without
going through OAuth, so developers can connect their local CLI directly to
a cloud-hosted session.

## Decisions

- Use a dedicated attach server on a separate port (ATTACH_PORT, default 4096)
  that is intentionally not behind oauth2-proxy, so attach subdomain requests
  can bypass OAuth.
- Authenticate via a per-session password stored in a PVC annotation
  (opencode.ai/attach-password) rather than OAuth tokens. Password is
  generated at session creation and lazy-created for existing sessions.
- Accept the password via HTTP Basic Auth, ?password= query param, or
  X-Attach-Password header to support different client integration styles.
- Restrict the /api/sessions/:hash/attach-info endpoint to the session owner
  only (403 for everyone else) so passwords are never leaked across users.

## Key changes

- packages/opencode-router/src/index.ts: add attach subdomain routing with
  password auth; extract shared proxyToPod helper; fix wsHandler type
  (upgrade event signature, not RequestListener); start attachServer on
  ATTACH_PORT alongside the main server
- packages/opencode-router/src/pod-manager.ts: add getOrCreateAttachPassword,
  generateAttachPassword, getAttachUrl; store attach password in PVC at
  creation time; expose attachUrl/attachPassword in SessionInfo; remove unused
  req parameter from getSessionInfo
- packages/opencode-router/src/api.ts: add GET /api/sessions/:hash/attach-info
  endpoint returning attachUrl and attachPassword for session owner
- packages/opencode-router/src/config.ts: add attachPort and attachRoutePrefix
  config options
- packages/opencode-router-app/src/session-item.tsx: add Attach button that
  copies the ready-to-run opencode attach command to clipboard
- packages/opencode-router-app/src/api.ts: expose attachUrl/attachPassword in
  SessionSchema
- packages/opencode-router/src/*.test.ts: add unit tests for attach-info API,
  hostname parsing, password generation, and config defaults
@mrsimpson mrsimpson requested a review from adamdotdevin as a code owner May 12, 2026 10:19
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label May 12, 2026
@github-actions
Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@mrsimpson mrsimpson closed this May 12, 2026
@github-actions
Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Potential Related PR Found:

The other results (PRs #12822 and #9095) don't appear to be related to this feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs:compliance This means the issue will auto-close after 2 hours.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants