Skip to content

feat(cli): add ocx opencode launcher - #461

Closed
mihneaptu wants to merge 1 commit into
lidge-jun:devfrom
mihneaptu:feat/ocx-opencode
Closed

feat(cli): add ocx opencode launcher#461
mihneaptu wants to merge 1 commit into
lidge-jun:devfrom
mihneaptu:feat/ocx-opencode

Conversation

@mihneaptu

@mihneaptu mihneaptu commented Jul 25, 2026

Copy link
Copy Markdown

Summary

Adds ocx opencode [opencode args...], a third client surface alongside ocx claude and the native Codex injection.

opencode reads providers from a JSON config rather than env slots, so the ocx claude env-injection pattern does not transfer. This launcher instead:

  1. ensures the proxy is running (same findLiveProxy + detached-start path as ocx claude),
  2. generates an opencodex provider block from the proxy's visible catalog (fetchAllModelsfilterCatalogVisibleModels + visibleNativeSlugs, the same pair ocx claude desktop uses),
  3. points OPENCODE_CONFIG at the generated file and execs opencode with stdio inherited.

The user's own opencode.json is never written to. Their effective config is read (an explicit OPENCODE_CONFIG first, then the XDG global path), merged forward into a generated copy under the opencodex config dir, and only the opencodex provider key is overwritten — other providers and unrelated top-level fields (model, agents, keybinds, mcp, …) survive verbatim. Carrying the base config forward keeps the command correct whether opencode merges the OPENCODE_CONFIG layer or replaces it, and leaves plain opencode completely unchanged.

The admission key is never serialized to disk. The generated block carries opencode's documented {env:VAR} reference and the real value is passed only through the child environment. The key resolves OPENCODEX_API_AUTH_TOKEN before config.apiKeys, matching fetchClaudeContextWindows — a non-loopback bind requires the env token and may have no apiKeys at all, where a placeholder would 401 every request.

Other behavior worth a reviewer's eye:

  • Model limits. limit.context is emitted only from an authoritative context window, including native slugs via nativeOpenAiContextWindow. opencode's schema rejects a limit block carrying context without output and CatalogModel has no authoritative output field, so a documented budget (32_000, matching REASONING_MAX_TOKENS_CEILING) rides along, clamped to the context window so a small-context model is never emitted with output > context.
  • JSONC. opencode documents opencode.json as JSONC. Strict JSON.parse runs first and untouched; a small string-aware stripper is attempted only on failure, so a commented config launches instead of being rejected as malformed. No new dependency.
  • Project layer. opencode loads a project-level opencode.json after the OPENCODE_CONFIG layer, so a project-level provider.opencodex outranks the generated block. The launcher cannot win that without writing to a user file, so it detects and warns rather than reporting a wiring the child will not honour.
  • Pre-launch messages go to stderr, so ocx opencode run "…" > out.txt keeps stdout clean for the child.

Docs: new guides/opencode.md plus its sidebar entry (English only; the label is a proper noun in every locale).

Verification

  • bun run typecheck — clean
  • bun run test4252 pass, 4 skip, 2 fail
  • bun run lint:gui, bun run privacy:scan — clean
  • bun test tests/opencode-cli.test.ts — 36 pass (new)

The 2 failures are pre-existing and unrelated to this change. Both are in tests/oauth-refresh.test.ts:

(fail) oauth refresh hardening > fresh Kiro CLI SQLite token is imported before refresh endpoint
(fail) oauth refresh hardening > failed refresh recovers from a now-fresh Kiro CLI SQLite token

I confirmed this by stashing the branch and running the full suite on a clean upstream/dev checkout — the same two tests fail there (4216 pass, 2 fail). They also pass when tests/oauth-refresh.test.ts is run on its own. tests/oauth-refresh.test.ts and tests/kiro-oauth.test.ts both mutate the process-global process.env.HOME in beforeEach/afterEach, which looks like the cross-file race, but I have left it alone as out of scope. Happy to open a separate issue.

Manual verification against a live proxy on 127.0.0.1:10100 (Windows 11, opencode 1.18.5):

  • ocx opencode --version → proxy detected, 76 models registered, generated config written, child launched
  • opencode models opencodex → all 76 listed (this is what caught the limit.output schema requirement — unit tests alone would not have)
  • 47 models carry authoritative limits, with 0 output > context violations
  • generated apiKey is {env:OPENCODEX_OPENCODE_API_KEY}; no literal secret in the file
  • opencode run --model opencodex/kiro/claude-haiku-4.5 "…" → completion returned end to end
  • existing ~/.config/opencode/opencode.json byte-for-byte untouched across runs; its hand-written providers and default model reappear intact in the generated copy
  • ocx help opencode renders

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

On the last point: the generated config contains no credential — only an {env:…} reference — and is still written 0o600 with the config dir created 0o700. No token is logged; the launcher prints only the port, the model count and the two file paths.

@github-actions github-actions Bot added the enhancement New feature or request label Jul 25, 2026
@coderabbitai

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

chatgpt-codex-connector[bot]

This comment was marked as outdated.

opencode reads providers from a JSON config rather than env slots, so the
`ocx claude` env-injection pattern does not transfer. This adds a launcher that
generates a provider block from the proxy's visible catalog and points
OPENCODE_CONFIG at it.

The user's own opencode.json is never written to. Their effective config is read
(explicit OPENCODE_CONFIG first, then the XDG global path), merged forward into
a generated copy under the opencodex config dir, and only the `opencodex`
provider key is overwritten. Carrying the base config forward keeps the command
correct whether opencode merges the OPENCODE_CONFIG layer or replaces it, and
leaves plain `opencode` completely unchanged.

Credential handling: the generated file is written to disk and outlives the
child, so it carries opencode's documented `{env:VAR}` reference instead of the
admission key, and the real value is passed only through the child environment.
The key resolves OPENCODEX_API_AUTH_TOKEN before config.apiKeys, matching
fetchClaudeContextWindows — a non-loopback bind requires the env token and may
have no apiKeys at all, where a placeholder would 401 every request.

Model limits: limit.context is emitted only from an authoritative context
window, including native slugs via nativeOpenAiContextWindow. opencode's schema
rejects a limit block carrying context without output and CatalogModel has no
authoritative output field, so a documented budget rides along, clamped to the
context window so a small-context model is never emitted with output > context.

Robustness: opencode.json is parsed as JSONC (strict JSON first, tolerant only
on failure) because opencode documents that syntax and a commented config would
otherwise be rejected as malformed; the generated file is written atomically so
a concurrent launch cannot read a torn file; the detached proxy-start child gets
an error listener so a failed spawn reports through the health poll instead of
throwing; and a project-level opencode.json defining provider.opencodex is
detected and warned about, since opencode loads that layer last and it outranks
the generated block.

Verified against a live proxy: 76 models registered, 47 carrying authoritative
limits with no output > context violations, `opencode models opencodex` lists
all of them, and an end-to-end run through opencodex/kiro/claude-haiku-4.5
returns a completion.
@mihneaptu
mihneaptu force-pushed the feat/ocx-opencode branch from 01a74b9 to 5ef84f4 Compare July 25, 2026 21:19
@mihneaptu

Copy link
Copy Markdown
Author

Thanks both — this was a genuinely useful review, and four of these were real bugs rather than style.

Force-pushed an amended commit (01a74b95ef84f4) and replied to each thread individually. Summary:

Fixed (7)

  • P1 admission key no longer serialized — the block carries {env:OPENCODEX_OPENCODE_API_KEY} and the value travels in the child env
  • P1 OPENCODEX_API_AUTH_TOKEN now resolved before config.apiKeys (non-loopback binds would have 401'd on every request)
  • P2 limit.output clamped to limit.context (sub-32k models were being emitted with output > context)
  • P2 opencode.json parsed as JSONC — a valid commented config was causing a hard refusal to launch
  • P2 native slugs now carry authoritative windows via nativeOpenAiContextWindow (42 → 47 models with limits)
  • P2 generated config written atomically (temp + rename), removing the torn-read race
  • CodeRabbit: error listener on the detached spawn, and the redundant loadConfig() threaded through

Not changed, reasoning in-thread (2, both awaiting a maintainer call)

  • Hardcoded 127.0.0.1 base URL — buildClaudeEnv (src/cli/claude.ts:35) makes the identical assumption, so fixing only this launcher would make the two disagree about non-loopback binds. Happy to fix both together.
  • Per-launch generated files — the atomic write covers torn reads; per-launch paths would orphan files on crash since the child is detached and unwaited. Will switch if you prefer.

Partially addressed (1)

  • Project-layer precedence is real but not winnable without writing to a user file, so it is now detected and warned about, plus documented.

Tests went 21 → 36. Full suite 4252 pass with the same 2 pre-existing oauth-refresh.test.ts failures I documented in the description (verified identical on clean dev).

One note: the heavy CI matrix has not run yet — I believe it needs a maintainer to approve workflows for a first-time contributor.

coderabbitai[bot]

This comment was marked as outdated.

@lidge-jun

Copy link
Copy Markdown
Owner

🔒 Under maintainer review — detailed feedback incoming

@lidge-jun (maintainer) has this PR in an active review pass. Please do not merge, rebase, or
close it
until the detailed review lands; a full comment with specific file:line findings,
failure modes, and suggested fixes is being prepared.

This is a claim marker so two maintainers do not review or land the same PR at once. If you are a
maintainer and need this PR urgently, please reply here first instead of merging.

Baseline for this pass: dev is at bef0dfbe (yesterday's integration batch ebc62d1f plus
#458, #456, #391, #392, #463, #465). If this PR touches the same files, the upcoming review will
name the exact overlapping paths so you only have to rebase once.

No action needed from you until then. Thanks for the contribution and for your patience.

Review tracker: devlog/_plan/260726_pr_review_pass · marker posted by the maintainer review pass

@lidge-jun

Copy link
Copy Markdown
Owner

Thanks for the ocx opencode launcher — wiring OpenCode through the local proxy is a good addition, and the process-spawn side of this is done carefully. Two credential-lifecycle problems block merge.

1. High — the generated file duplicates arbitrary user secrets. src/cli/opencode.ts:284 reads the entire user configuration, :267 merges every field into the generated object, and :418 persists the result to ~/.opencodex/opencode-config.json. Any literal provider API keys, tokens, or MCP credentials in the source config are copied into a second persistent file and stay there after the user removes or rotates them in the original. Mode 0600 limits who can read it but does not fix the duplicated-secret lifecycle. Please keep OpenCode's existing config layers intact and inject only the generated provider.opencodex block through OPENCODE_CONFIG_CONTENT — OpenCode documents merged config layers with the inline runtime layer taking higher precedence, so nothing else needs to be materialized. See https://opencode.ai/docs/config

2. High — copying the config silently changes what relative {file:...} references resolve to. src/cli/opencode.ts:293 parses the source config but :421 writes it under a different directory, and OpenCode resolves relative file references against the containing config file. A {file:./secret} or relative instruction path that worked under ~/.config/opencode/ now resolves under ~/.opencodex/, so the launcher can fail authentication, read the wrong file, or silently drop instructions. The same fix applies: don't relocate the base config, add only the generated runtime override.

3. Medium — project-override detection doesn't match OpenCode's lookup rules. src/cli/opencode.ts:308 checks only ${cwd}/opencode.json, but OpenCode also supports JSONC and walks upward toward the nearest Git directory. A parent opencode.json, or any opencode.jsonc, can override provider.opencodex without triggering your warning while the launcher still tells the user it's wired to the local proxy. Either mirror the upward JSON/JSONC search or rely on the inline runtime layer, which outranks project config.

What I checked and found clean: no argument-injection defect — src/cli/opencode.ts:439 routes through the existing commandInvocation() helper, which preserves argument boundaries and handles Windows .cmd/.bat shims via escaped cmd.exe. PATH failure handling covers POSIX ENOENT and Windows exit 9009. The Node compatibility imports match existing Bun-native usage in this repo and introduce no compile-step assumption.

Overlap with dev: 50 commits behind the bef0dfbe baseline. A synthetic merge finds no textual conflict, but please rebase — GitHub currently reports UNSTABLE and only enforce-target and label have run on this head. The required macOS/Ubuntu/Windows and npm-global matrix has not executed at all yet.

Test gap: tests/opencode-cli.test.ts exercises pure helpers but never invokes cmdOpencode(). There is no command-level regression for proxy startup failure, config write failure, exact child argv and environment, ENOENT, signal exit behavior, or cleanup after a failed spawn. A dependency-injected launcher test plus cross-platform smoke coverage would be the right addition once the config-copy behavior is removed.


Reviewed as part of a maintainer review pass against dev at bef0dfbe, snapshot 2026-07-26T08:35+09:00, at head 5ef84f48.

@Wibias
Wibias marked this pull request as draft July 26, 2026 02:38
@Wibias
Wibias marked this pull request as ready for review July 27, 2026 19:05
@Wibias
Wibias self-requested a review as a code owner July 27, 2026 19:05
@Wibias

Wibias commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Maintainer takeover update for PR #461:

Thanks @mihneaptu for the ocx opencode launcher — the spawn/proxy side is solid. I rebased onto current dev (131573e3) and addressed @lidge-jun's credential-lifecycle review from the maintainer pass.

Could not force-update the contributor head (maintainerCanModify=false; push to mihneaptu:feat/ocx-opencode was rejected). The rebased takeover lives on upstream:

  • Branch: lidge-jun/opencodex:maintainer-takeover/461-feat-ocx-opencode
  • Head: 91537cee

Please either enable “Allow edits from maintainers” on this PR so we can repoint the head, or reset feat/ocx-opencode to that commit.

Review fixes landed

  1. High — no duplicated user secrets. Removed the read/merge/write path that copied the user's full opencode config into ~/.opencodex/opencode-config.json. The launcher now injects only the generated provider.opencodex block through OpenCode's inline runtime layer (OPENCODE_CONFIG_CONTENT), which outranks project/global/custom config and never materializes API keys, MCP credentials, or other user secrets to disk.
  2. High — {file:…} paths stay valid. User config files are left untouched, so relative file references keep resolving against their original containing config.
  3. Medium — project override detection. projectConfigOverridesProvider() now walks upward from cwd through both opencode.json and opencode.jsonc to the nearest Git root (informational note only; the runtime layer wins for this launch).
  4. fetchAllModels failure is caught in cmdOpencode() with a clear stderr message and exit 1.
  5. Rebase conflicts in docs-site/astro.config.mjs and src/cli/help.ts resolved by keeping both the Grok Build and opencode sidebar entries.

Still open (non-blocking for the review blockers above)

  • Command-level cmdOpencode() regression tests with dependency injection (spawn/proxy/catalog seams) — helpers are covered (30 tests in tests/opencode-cli.test.ts); integration tests remain a follow-up.

Local verification on 91537cee

  • bun test tests/opencode-cli.test.ts — 30 pass
  • bun run typecheck — pass
  • bun run privacy:scan — pass

Fresh Cross-platform CI must run on the rebased head before merge; the green matrix on 5ef84f48 is stale relative to current dev. A Service lifecycle run already passed on 91537cee; the full matrix will execute once this PR's head is repointed.

Docs updated in docs-site/src/content/docs/guides/opencode.md to describe the runtime-layer behavior (no on-disk config copy).

@Wibias Wibias mentioned this pull request Jul 27, 2026
4 tasks
@Wibias Wibias closed this Jul 27, 2026
@Wibias

Wibias commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Closing in favor of maintainer takeover #568, which rebases your work onto current \dev\ and applies the credential-lifecycle fixes from the maintainer review (runtime \OPENCODE_CONFIG_CONTENT\ injection instead of copying user config to disk).

Thank you @mihneaptu — the launcher design and spawn/proxy wiring were solid, and the original review thread helped catch real issues. Your authorship is called out in #568; sorry we couldn't update the fork head directly (\maintainerCanModify\ was off).

Please follow #568 for merge.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ef84f4853

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cli/opencode.ts
* fetchClaudeContextWindows in src/cli/claude.ts.
*/
export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = process.env): string {
return env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key || "ocx";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Load the persisted service admission token

When a non-loopback proxy runs as an installed service and ocx opencode is invoked from a fresh shell, the admission token exists only in ~/.opencodex/service-api-token; this resolver checks only the shell environment and config.apiKeys, so it passes ocx and every request receives 401. Fresh evidence after the earlier fix is that loadServiceTokenFromFile is still called only by handleStart in src/cli/index.ts, not by this launcher. Resolve the persisted service token through the shared service-secret path before falling back to a configured key.

AGENTS.md reference: AGENTS.md:L65-L71

Useful? React with 👍 / 👎.

Comment thread src/cli/opencode.ts
contextWindow: m.contextWindow,
displayName: m.displayName,
}));
const nativeSlugs = [...visibleNativeSlugs(config)];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Hide native models when OpenAI uses Direct mode

When providers.openai.codexAccountMode is direct, every native slug advertised here is unusable from opencode: the generated provider supplies only the proxy admission key (or ocx) as Authorization, while handleChatCompletions preserves that header for Direct routes and handleResponses treats it as the caller's ChatGPT bearer, yielding either the admission-credential 401 or an upstream authentication failure. Omit native entries in Direct mode, or provide a separate valid Direct bearer and admission channel, and cover this supported mode with a focused regression test.

AGENTS.md reference: AGENTS.md:L75-L77

Useful? React with 👍 / 👎.

Comment thread src/cli/opencode.ts
};
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
const context = Math.floor(contextWindow);
entry.limit = { context, output: Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reserve input capacity when clamping output

For any model whose context window is at most 32,000 tokens, this sets limit.output equal to limit.context, leaving opencode no usable input budget once it reserves the advertised maximum output. Fresh evidence after the prior clamp fix is the equality produced by Math.min(..., context), and the new 8,192-token test now codifies that broken value. Clamp below the context window using a safe input reserve, or omit the pair when no defensible output limit is available, and update the regression accordingly.

AGENTS.md reference: AGENTS.md:L75-L77

Useful? React with 👍 / 👎.

Comment thread src/cli/opencode.ts
* native OpenAI entries (which the proxy accepts unprefixed).
*/
export function opencodeModelKey(provider: string, id: string): string {
return provider === "native" ? id : `${provider}/${id}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid treating a real native provider as the sentinel

Provider names are user-defined and repository validation permits native, but this helper treats every routed model from such a provider as a built-in native model and removes its namespace. For example, native/gpt-5-custom becomes bare gpt-5-custom, which the router sends to the canonical OpenAI provider rather than the configured native provider. Represent built-in candidates with a separate discriminator instead of overloading a valid provider name, and add a focused collision test.

AGENTS.md reference: AGENTS.md:L75-L77

Useful? React with 👍 / 👎.

Comment thread src/cli/opencode.ts
const generated = buildOpencodeConfig(port, nativeSlugs, routed, nativeOpenAiContextWindow);
const merged = mergeOpencodeConfig(base.config, generated);
try {
writeGeneratedConfig(generatedPath, JSON.stringify(merged, null, 2) + "\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not duplicate user provider secrets

When the user's base opencode config contains literal credentials such as provider.<id>.options.apiKey, the full merge is serialized into the persistent generated file, duplicating arbitrary third-party secrets even though the proxy admission key itself was moved to an environment reference. Removing or rotating the credential in the original config still leaves the stale copy here until another launch overwrites it; mode 0600 does not eliminate that expanded credential footprint. Preserve secret-bearing values through environment references, or rely on opencode's configuration layers instead of serializing the entire base config.

AGENTS.md reference: AGENTS.md:L65-L71

Useful? React with 👍 / 👎.

Comment thread src/cli/opencode.ts
Comment on lines +403 to +407
const routed = filterCatalogVisibleModels(allModels, config).map(m => ({
provider: m.provider,
id: m.id,
contextWindow: m.contextWindow,
displayName: m.displayName,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve combo aliases in generated model keys

When a configured combo has an alias, fetchAllModels returns a CatalogModel containing { provider: "combo", id, alias }, but this projection discards alias, so the generated picker exposes combo/<id> instead of the configured public model ID. This also bypasses the catalog's established rule that a combo alias shadows a provider model with the same public slug. Carry the alias through and key the entry with the catalog's public-slug helper, with a focused alias regression test.

AGENTS.md reference: AGENTS.md:L75-L77

Useful? React with 👍 / 👎.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants