Files
omnigent-ai--omnigent/omnigent/onboarding/opencode_auth.py
T
Dhruv Gupta bf2e1e9454 feat(harness): add OpenCode (native-server: serve + SSE forwarder + TUI takeover) (#576)
* docs(design): opencode harness + unified harness-interface (draft)

* docs(design): full opencode-native + unified harness-interface design

Covers: harness core (HTTP+SSE), opencode TUI attach takeover, ap-web
integration, opencode optional+runtime-selectable for polly & debby,
and the unified HarnessDescriptor/NativeServerHarness interface.
Supersedes the v1 draft.

* feat(opencode): harness core + unified native-server interface (fronts A, E)

Add the opencode-native harness and the HarnessDescriptor single-registration
that the scattered registries now derive from.

Front A (opencode core):
- opencode_native_bridge/state: per-session bridge dir, XDG roots, auth
  secret, durable launch state.
- opencode_native_client: typed HTTP+SSE client shaped from the pinned
  opencode 1.17.x OpenAPI (sessions/prompt/abort/fork/permission + /event).
- opencode_native_app_server: opencode serve process manager (loopback,
  version-check, readiness) + attach argv/env builders.
- opencode_native_forwarder: SSE -> Omnigent event translation per the
  design table (session.next.* text/tool/step, permission.v2.asked), dedupe,
  reconnect.
- opencode_native_permissions: normalize + once/always/reject mapping.
- inner/opencode_native_executor + harness: thin create_app wrapper built on
  the shared NativeServerHarness base.

Front E (unified interface):
- runtime/harness_descriptors: HarnessDescriptor + HARNESS_DESCRIPTORS, the
  single source of truth; _HARNESS_MODULES / OMNIGENT_HARNESSES /
  HARNESS_ALIASES / NATIVE_HARNESSES now derive from it.
- native_server_transport: NativeServerTransport protocol + dataclasses.
- native_server_harness: shared Executor base for native-server harnesses.
- opencode_http_transport + codex_ws_transport: two concrete transports
  proving the abstraction.

Registries wired for opencode-native: spec allowlist, runtime modules,
aliases, native set, model-override (via native), install metadata,
readiness gating, wrapper label, native_coding_agents, built-in agent
seeding, and runner harness spawn-env.

Co-authored-by: Isaac

* feat(opencode): runner-owned serve + attach terminal takeover (front B)

Add the runner-side native terminal auto-create for opencode-native,
mirroring _auto_create_codex_terminal:
- _opencode_native_launch_config: fetch + validate the session snapshot.
- _auto_create_opencode_terminal: boot opencode serve, resume-or-create the
  OpenCode session, persist external_session_id + bridge state, start the
  SSE forwarder (supervised so the server is closed on teardown), and
  register the `opencode attach` TUI as a streamable terminal resource.
- ensure_native_terminal dispatch branch for terminal_name == "opencode".
- OPENCODE_NATIVE_TERMINAL_ROLE constant.

The forwarder stays live independent of TUI process lifetime, so human
TUI actions keep mirroring into the web transcript.

Co-authored-by: Isaac

* feat(opencode): optional worker for polly/debby + allowlisted args.harness (front D)

Short-term (declared optional worker):
- examples/polly/agents/opencode and examples/debby/agents/opencode: optional
  opencode-native workers, default-off (gated by `opencode` CLI presence).
- polly config: roster up to FOUR sub-agents, preflight probes `opencode`,
  cross-review tracks harness AND model provider (opencode = 4th vendor, not
  independent of same-provider implementers).
- debby config: optional third "OpenCode perspective", default fanout stays
  Claude + GPT; three-way debate only on explicit request.

Long-term (runtime harness override):
- sys_session_send args gains an optional `harness` field.
- tool_dispatch validates it against the sub-agent's
  executor.config.allowed_harnesses allowlist + OMNIGENT_HARNESSES and threads
  it as harness_override into the child create (rejected on by-session-id mode).
- examples/polly/agents/codex opts in via allowed_harnesses:
  [codex-native, opencode-native].
- conversation.harness_override docstring: a sub-agent may carry its OWN
  create-time override (it still never inherits the parent brain's).

The server create route already validates + persists harness_override and the
runner already honors it, so the long-term path works end to end.

Co-authored-by: Isaac

* test(opencode): harness test matrix + conformance suite + scaffold generator (front E)

- tests/harness_conformance/: drift tests asserting every scattered registry
  derives from HARNESS_DESCRIPTORS, plus the NativeServerTransport contract
  driving NativeServerHarness over a fake transport AND both real transports
  (OpenCodeHttpTransport via a fake HTTP server, CodexWsTransport via a fake
  app-server client) — two implementations proving the abstraction.
- opencode unit tests mirroring the codex matrix: bridge state, launch state,
  permissions mapping, HTTP/SSE client (httpx.MockTransport fake server, SSE
  framing), app-server arg/env/version/start, forwarder translation table
  (text/tool/step/permission/dedupe/filter/reconnect), executor turn lifecycle
  (inject/abort/enqueue/image-block/mismatch).
- omnigent/scaffold_harness.py: dev generator for new-harness boilerplate +
  the extension-point checklist.

104 new tests, all green.

Co-authored-by: Isaac

* feat(opencode): wire OpenCode into ap-web native UI (front C)

Mirror codex/pi native-agent wiring for OpenCode:
- OpenCodeIcon (@lobehub/icons/es/OpenCode); "opencode" added to the
  NativeCodingAgentIconKind / ConversationIconKind unions.
- nativeCodingAgents.ts: OpenCode entry (opencode-native-ui / opencode-native,
  sortRank 25, approvalMode) — derived lookup maps pick it up.
- NewChatDialog (display order + builtin set), SubagentsPanel (child icon +
  subagent wrapper label), AgentCard (icon), sidebarNav (icon kind),
  useTerminals (terminal_opencode_main excluded from the shell inventory).
- test-setup.ts: global OpenCodeIcon mock paralleling the Claude/Codex mocks
  (the @lobehub icon import chain breaks under vitest otherwise).
- Tests extended across nativeCodingAgents / AgentCard / useAvailableAgents /
  SubagentsPanel / sidebarNav / useTerminals.

tsc -b clean; vitest 2838 passed / 3 expected-fail / 2 skipped.

Co-authored-by: Isaac

* test(opencode): front D worker discovery + args.harness dispatch + readiness map

- test_opencode_polly_debby_worker: polly/debby specs declare the opencode
  worker; codex worker allowlists the opencode-native override; preflight
  probes opencode; debby keeps it optional.
- test_subagent_harness_override: args.harness extraction + allowlist
  canonicalization helpers.
- harness_readiness test: opencode-native / native-opencode spellings added to
  the configured-harness-map coverage assertion.

Co-authored-by: Isaac

* fix(opencode): eliminate mypy no-any-return at the transport/forwarder JSON boundary

Wrap the opaque JSON-RPC / SSE return values so the typed return contracts
hold (bool / str / Mapping), leaving only the explicit-any annotations the
repo sanctions for opaque JSON payloads (matching the existing codex modules).

Co-authored-by: Isaac

* test: update polly/debby worker-set expectations for the opencode worker

The optional opencode worker joins polly (4 workers, 4 vendors, 7 function
policies) and debby (3 workers, 3 vendors; default fanout still claude+gpt).
Update the brain-harness-override test and the example-bundle parse tests
accordingly.

Co-authored-by: Isaac

* fix(opencode): allowlist-gate args.harness schema + reconcile CI

Front D advertised args.harness unconditionally in the sys_session_send
schema, which broke two tests pinning the base args object to
{input, purpose, model} and diverged from design D.4 (the runtime harness
override is allowlist-gated, opt-in only).

- spawn.py: advertise `harness` in the args object only when at least one
  declared sub-agent opts in via executor.config.allowed_harnesses (mirrors
  the per-child dispatch guard in tool_dispatch.py). Specs without the
  opt-in keep the base {input, purpose, model} contract, so the two pinned
  schema tests stay correct as-is.
- test_sys_session.py: add a test asserting `harness` is present for an
  opted-in sub-agent and absent otherwise (and that a mix opts the tool in).
- test_run_harness_without_agent_e2e.py: exclude opencode-native from the
  live `omnigent run --harness` matrix. It is a terminal-takeover
  native-server harness (same shape as claude/codex-native), so it cannot
  round-trip through this gateway-backed no-AGENT matrix. Fixes E2E shard 1/4.
- test_start_session.py: add a hermetic e2e_ui Playwright test covering the
  OpenCode agent in the new-chat picker (harness-derived "OpenCode" label,
  not the raw "opencode-native-ui") and the terminal-first wrapper labels on
  create.

Co-authored-by: Isaac

* fix(opencode): wire permission policy gate + per-prompt model pin

Addresses blocking cross-vendor review findings on the OpenCode harness.

BLOCKING #1 — security: OpenCode permissions no longer silently auto-approve.
- opencode_native_forwarder.py: the permission ``default_decision`` flips
  from ``allow_once`` to ``reject``. An unconfigured or unreachable policy
  now FAILS CLOSED — a headless OpenCode turn can never silently approve a
  sensitive op. Only an explicit policy ``allow`` reaches ``once``/``always``.
- runner/app.py: wire a real ``policy_evaluator`` at forwarder
  instantiation. ``_build_opencode_policy_evaluator`` POSTs each
  ``permission.v2.asked`` to the session's ``/v1/sessions/{id}/policies/evaluate``
  endpoint as a ``PHASE_TOOL_CALL`` event — the SAME server-side gate
  codex-native's policy hook uses, where an ``ask`` verdict is parked as a
  human approval card and blocks until resolved. Unreachable / non-200 /
  malformed / unresolved-ask all fail closed to deny.
- tests: assert no auto-approve absent policy, explicit allow → once,
  allow_always → always, deny/ask → reject, the evaluator receives the
  normalized policy input, and the runner evaluator's request shape +
  verdict mapping + fail-closed paths.

BLOCKING #2 — OpenCode model override now governs the run from turn one.
- Verified against the OpenCode SDK that ``POST /session`` does NOT accept a
  model (the stale client docstring is corrected); the model is a per-prompt
  field ``{providerID, modelID}``. OpenCodeNativeExecutor now threads the
  session's ``model_override`` (from bridge state) onto every injected
  prompt. OpenCode persists the last-used model as the session default, so
  pinning the first turn also governs later TUI-typed turns — the override
  controls the run from the start, not only a later web turn.
- test asserts the resolved model reaches the prompt body as
  ``{"providerID","modelID"}`` (and is absent when no override is set).

NON-BLOCKING — tighten OpenCode server env isolation.
- opencode_native_app_server.py: drop ``OPENCODE_CONFIG`` /
  ``OPENCODE_CONFIG_CONTENT`` from the env passthrough so the parent shell's
  GLOBAL OpenCode config can't defeat the per-session XDG isolation. Other
  ``OPENCODE_*`` vars (and the server password we set) are unaffected.

BLOCKING #3 (NativeServerHarness migration of codex-native) is NOT included:
a behavior-preserving migration is not safely landable here — see the PR
discussion. codex-native is unchanged; its executor tests stay green.

Co-authored-by: Isaac

* fix(opencode): address AI-review static-analysis nits + add deferral note

Resolve all 11 github-code-quality[bot]/CodeQL findings on PR #576,
all low-severity static-analysis nits with no behavior change:

- opencode_native_executor.py: rename subclass methods so they no longer
  shadow the base NativeServerHarness instance attributes set from the
  injected callbacks (_build_prompt -> _build_prompt_with_model_override,
  _resolve_session_id -> _resolve_opencode_session_id). Bodies unchanged.
- native_server_transport.py: replace every `...` Protocol-method body
  with `raise NotImplementedError` so CodeQL's "statement has no effect"
  doesn't re-flag the stragglers. Interface semantics unchanged.
- opencode_native_bridge.py: document the two intentionally-ignored read
  errors in ensure_auth_secret (missing/unreadable secret => regenerate).

Also append a "Deferred to a follow-up PR" section to the design doc
documenting that codex-native is not yet migrated onto NativeServerHarness
and CodexWsTransport is defined but not wired into any production path.

* fix(opencode): address CodeQL static-analysis nits

- test_opencode_native_forwarder: import the forwarder module one way only
  (consolidate to `import ... as fwd_mod`, drop the duplicate import-from),
  clearing CodeQL "module imported with import and import-from".
- codex_ws_transport / opencode_http_transport: export the client-factory
  type aliases (`CodexClientFactory`, `ClientFactory`) via `__all__`. They are
  the documented annotation for each transport's `client_factory` param, but
  PEP 563 stringifies that use so CodeQL saw them as unused globals.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

* docs: drop opencode design doc from the PR (kept locally)

The 2k-line design doc inflated the PR diff without being code under
review. Untracked from the PR tree; it stays on disk locally for reference.

Co-authored-by: Isaac

* feat(opencode): web-UI terminal auto-create + Databricks-gateway provider wiring

Two gaps surfaced by a full-stack host e2e (isolated $HOME, real opencode serve):

1. Web-UI terminal auto-create: opencode-native was MISSING from the runner's
   session-creation terminal dispatch (claude/codex/pi/cursor each have a
   branch; opencode only had the on-demand ensure_native_terminal path). A
   host/web-UI opencode session therefore never booted its opencode serve + SSE
   forwarder + opencode attach terminal, so the UI had no terminal+chat view to
   embed. Add the opencode-native branch alongside the other natives (idempotent
   with the on-demand path via the existing per-session lock).

2. Databricks-gateway provider config: unlike codex/claude/pi (which consume
   HARNESS_*_GATEWAY_* env their CLI translates), opencode reads provider/auth
   from its own config file. Add omnigent/opencode_native_provider.py to resolve
   a gateway from the spec's Databricks profile (via databricks-sdk) and
   synthesize an opencode.json (custom @ai-sdk/openai-compatible provider at
   {host}/serving-endpoints) into the per-session XDG config dir at spawn, with
   the per-prompt model pinned to provider/endpoint. Best-effort: no profile or
   no SDK -> opencode falls back to its ambient provider config.

Tests:
- tests/test_opencode_native_provider.py (13): synthesis shape, 0600 write,
  model normalization, SDK-absent/no-token/success resolution.
- tests/e2e/test_host_opencode_native_e2e.py (opt-in OMNIGENT_E2E_OPENCODE_NATIVE):
  built-in agent registered + host session auto-creates terminal_opencode_main.

Validated against the real Databricks AI gateway (databricks-claude-sonnet-4-6):
resolve -> synthesized opencode.json -> prompt round-trip returns assistant text.

Co-authored-by: Isaac

* fix(opencode): mirror assistant output to the web chat view + add `opencode` alias

#2 (chat view): the SSE forwarder was keyed on a `session.next.*` /
`permission.v2.asked` event vocabulary that opencode 1.17.x never emits, so every
real assistant-text/tool event hit `_HANDLERS.get(...) -> None` and was silently
dropped — the TUI showed the turn but nothing reached the web chat view (the
durable items the chat reads). The old unit tests passed only because they fed
the same fake event names.

Rewrite the handlers against opencode's real PART-based model (verified by
capturing a live `opencode serve` turn):
- text: `message.part.updated`(type=text, role-filtered to assistant) finalized
  into a durable conversation item on `step-finish`/`session.idle`, plus
  `message.part.delta`(field=text) streamed live (ephemeral);
- tools: `message.part.updated`(type=tool) — call posted once its `state.input`
  is populated, output once `state.status` is completed/error (deduped by callID);
- lifecycle: `message.updated`(info.role), `session.status`(busy), `session.idle`;
- permissions: register both `permission.asked` (1.17.x) and `permission.v2.asked`.
Resume-dedupe is made type-aware so a reconnect never re-posts finalized parts.

Validated against a real Databricks-gateway turn: assistant text + bash tool
call/output now post as durable chat items; 17 forwarder unit tests rewritten to
the real event shapes (incl. user-text-not-mirrored + tool-snapshot dedup).

#3 (alias): accept `opencode` as a friendly alias for `opencode-native` (no
separate SDK `opencode` harness exists, so the bare name is free); added to the
descriptor `aliases` + `runtime_aliases`.

Co-authored-by: Isaac

* feat(opencode): show OpenCode in the `omni setup` harness picker

#1 (setup picker): OpenCode was absent from the `omni setup` harness overview, so
there was no obvious place to set it up. Add an OpenCode row (readiness = is the
`opencode` CLI installed) plus a `_manage_opencode_harness` drill-in that installs
the CLI when missing and explains where its credential actually lives — OpenCode
is a native-server harness with no Omnigent-stored key of its own; it routes
through the bound agent's Databricks gateway profile (synthesized into opencode's
per-session config) or ambient OpenAI-/Anthropic-compatible env vars.

Co-authored-by: Isaac

* feat(opencode): `omni opencode` CLI launcher + pin the setup install to 1.17.x

#4 (CLI launcher): `omni --harness opencode-native` errored "No native terminal
launcher wired" because opencode had no `run_*_native` launcher (every native
harness ships its own). Add one, mirroring `omnigent codex` / `omnigent pi`:

- `run_opencode_native` (omnigent/opencode_native.py): ensure a local daemon +
  runner, create-or-resume the `opencode-native-ui` session (whose runner
  auto-creates the `opencode serve` + `opencode attach` terminal — the branch
  added that dispatch), then attach this TTY directly to the runner-owned tmux
  pane. Reuses the shared `native_terminal` / `host.daemon_launch` helpers and
  the same direct-tmux attach codex/pi use.
- An `omnigent opencode` command (resume/--model/passthrough args), and the
  missing `native_agent.key == "opencode"` dispatch arm so
  `omni run --harness opencode-native` routes here too.

Install version pin: `omni setup` → install OpenCode ran `npm install -g
opencode-ai`, but that package's npm `latest` is a broken `0.0.0-beta-*`
pre-release — so it installed a version the runtime version-check rejects. Pin
the install spec to `opencode-ai@~1.17.7` (mirrors the runtime
>=1.17.7,<1.18.0 range), so setup installs a working opencode.

Validated on an isolated-home daemon: the host-created opencode session
auto-creates `terminal_opencode_main` with the `tmux_socket`/`tmux_target`
metadata the launcher attaches to.

Co-authored-by: Isaac

* fix(opencode): stop emitting unreconciled live text deltas to the web chat

Follow-up to the forwarder rewrite. Posting `external_output_text_delta` for
opencode's `message.part.delta` left the web chat view broken: the UI builds a
`live:<message_id>` streaming-preview block from text deltas and only retires it
via a finalize/retire handshake (a `final=True` delta / authoritative done +
itemId reconciliation). The forwarder never completed that handshake and the
committed item carried no correlating id, so the live preview lingered alongside
the separate committed message — duplicated / garbled assistant text in chat
(the terminal/TUI was unaffected).

Drop the live-delta path: forward only the durable `external_conversation_item`
(role=assistant, full text), exactly the codex-native finalized-message path
that renders correctly today. The assistant message now appears cleanly when
each step completes. Removed the now-dead `_on_part_delta` / `_post_text_delta`
/ `next_text_index` / `_EXTERNAL_TEXT_DELTA`.

Live token-by-token streaming is deferred to a follow-up: it must match the web
UI's live-preview retire protocol (claude-native style) and be verified against
the real chat renderer, which can't be checked from a headless harness.

Reproduced via a real gateway turn: before, the forwarder posted a delta
(message_id `opencode:ses:text:prt`) AND a committed item (response_id `ses`)
with no correlation; after, only `running` → assistant item → `idle`.

Co-authored-by: Isaac

* fix(opencode): per-turn response_id so chat messages keep conversation order

Reported symptom: in the web chat, all assistant messages clustered together,
separated from the user messages, instead of interleaving per turn.

Cause: the forwarder stamped EVERY mirrored item with
``response_id = opencode_session_id`` — a single constant for the whole
session. The chat view groups items into a "response" by ``response_id``, so a
constant id collapsed every turn's assistant text/tool items into one response
block, which the renderer placed at the first item's position — pulling all
assistant output above the later user messages. (codex-native avoids this by
stamping a per-turn response id.)

Fix: stamp each item with opencode's per-assistant-message ``messageID`` as the
``response_id`` (falling back to the session id only when unknown), so each
turn is its own response group and items order by position as a normal
conversation. Threaded the messageID through `_post_assistant_text` /
`_post_tool_call` / `_post_tool_output` and the text/tool handlers.

Verified on a real 2-turn gateway conversation: the two assistant messages now
carry two DISTINCT response_ids (were one shared id before). Added a unit test
asserting per-turn response_ids + response_id assertions on the existing
text/tool tests.

Co-authored-by: Isaac

* fix(opencode): mirror user messages in the forwarder so chat keeps turn order

Reported: the web chat showed every assistant message clustered first, then the
user messages out of order (and one missing) — while the TUI was correct.

Root cause: for native-server harnesses the forwarder is the SOLE source of the
conversation transcript — omnigent does NOT separately persist a user item for
these sessions (the runner mirrors the native transcript; cf. runner/app.py's
`is_native_harness` history gate, and codex-native's `_post_user_message` /
`_ensure_user_message_posted`, which exist precisely because omnigent doesn't
record it). The opencode forwarder SKIPPED user-role text, so user messages were
never durably recorded; the chat only showed transient optimistic echoes —
inconsistent and unordered. (The earlier per-turn response_id fix was necessary
but not sufficient: the user items weren't being persisted at all.)

Fix: mirror the user message in the forwarder. On a user-role `message.part.updated`
text part, post a `role=user` conversation item EAGERLY (deduped by part id) so it
takes an earlier position than its assistant reply — matching codex-native. User +
assistant now interleave by turn. Resume dedupe pre-marks user-text parts too.

Unit-tested (forwarder now posts user-before-assistant, deduped, with a per-turn
response_id). The full multi-turn render is covered by the opt-in host e2e
(`test_opencode_native_multiturn_item_order`, asserts strict user/assistant
interleaving) for CI + manual QA.

Co-authored-by: Isaac

* chore(opencode): drop the 35k-line vendored OpenAPI dump from the PR

The vendored `omnigent/opencode/openapi-1.17.7.json` (34,576 lines) was ~80% of
the PR diff and made it unreviewable (goose's comparable harness PR is ~5k). It
was added to make the descriptor's `openapi_schema` reference real, but the
typed client is hand-maintained and the live wire-contract e2e
(`test_opencode_native_wire_contract_e2e`, opt-in) validates it against a real
`opencode serve` — a far better drift guard than a checked-in schema dump.

Remove the file and the descriptor's `openapi_schema` field (defaults to None).
The conformance check that vendored schemas exist still guards any future
descriptor that sets the field; it just skips when none do.

Co-authored-by: Isaac

* feat(opencode): make the `omni setup` OpenCode section manage providers

Before, the OpenCode setup drill-in just printed a static note — it did nothing
useful. Now it mirrors the Goose/Qwen pattern.

New read-only reporter `omnigent/onboarding/opencode_auth.py`
(`opencode_auth_summary`): reads OpenCode's own credential state — stored
providers from `~/.local/share/opencode/auth.json` (XDG_DATA_HOME-aware, JSON
keyed by provider id per the OpenCode source) + detected provider env keys
(OPENAI_API_KEY / ANTHROPIC_API_KEY / …). Robust: reads auth.json directly
rather than scraping `opencode auth list` output.

The drill-in now reports which providers OpenCode can reach and offers
`opencode auth login`, `opencode auth list`, and a help note — never storing a
key through Omnigent (OpenCode owns its auth; the Databricks-gateway path stays
the agent profile synthesized into opencode's per-session config). The setup
overview row's ✓/✗ now reflects real readiness (CLI installed AND a provider
reachable), not just the binary being present.

+ unit tests for the reporter (auth.json parsing, env detection, readiness).

Co-authored-by: Isaac

* refactor(opencode): ship the harness the scattered way; defer the unified interface

Splits PR #576 in two. This PR adds OpenCode as a harness exactly like
goose/qwen/cursor-native were added — scattered registration across the
hand-maintained registries — and DEFERS the unified-interface refactor
(the single-source ``HarnessDescriptor`` registry, the descriptor-parity
conformance suite, and the harness scaffold generator) to a follow-up so this
PR can be reviewed as a focused harness addition.

Removed (moves to the follow-up):
- omnigent/runtime/harness_descriptors.py — the HarnessDescriptor registry.
- omnigent/scaffold_harness.py — the new-harness scaffold generator.
- omnigent/codex_ws_transport.py — the (unused) codex WS transport that
  generalized the native-server transport for a future codex migration.
- tests/harness_conformance/ — the descriptor-parity / transport-contract /
  scaffold conformance suite.

Re-scattered the registration that Front E had made descriptor-derived, adding
OpenCode the old way alongside the existing harnesses:
- runtime/harnesses/__init__.py: ``_HARNESS_MODULES`` back to a literal dict
  (+ ``opencode-native`` and its ``opencode`` runtime alias).
- harness_aliases.py: ``HARNESS_ALIASES`` / ``NATIVE_HARNESSES`` back to
  literals (+ ``opencode`` / ``native-opencode`` → ``opencode-native``).
- spec/_omnigent_compat.py: ``OMNIGENT_HARNESSES`` / ``OMNIGENT_HARNESS_ALIASES``
  back to literals (+ opencode id and aliases).
- onboarding/harness_install.py: ``_HARNESS_NAME_TO_KEY`` back to the
  alias-keyed map (+ opencode), ``required_cli_for_harness`` back to the direct
  lookup (no ``descriptor_for``).

Decoupled the kept OpenCode runtime from the descriptor registry:
- native_server_harness.py: take ``harness_id`` + ``supports_enqueue`` directly
  instead of a ``HarnessDescriptor``.
- inner/opencode_native_executor.py: pass those literals.
- native_server_transport.py / opencode_http_transport.py: drop the
  CodexWsTransport docstring references.

The OpenCode harness itself (executor, forwarder, typed client, app-server,
bridge, permissions, provider, ``omni opencode`` launcher, ap-web wiring,
``omni setup`` section, examples, and its test matrix) is unchanged. ruff
clean; opencode + registry + spec + dispatch suites green.

Co-authored-by: Isaac

* style(opencode): apply ruff format + prettier

Green the pre-commit (`ruff format`) and npm-test (`prettier --check`) CI gates:
- ruff format: opencode_native.py, opencode_native_provider.py,
  test_host_opencode_native_e2e.py, test_opencode_auth.py (line-wrapping only).
- prettier: ap-web/src/lib/nativeCodingAgents.ts.

Formatting only — no behavior change.

Co-authored-by: Isaac

* fix(opencode): recover native-server coverage + fix enqueue harness-id

The split removed tests/harness_conformance/, which had been the coverage for
the *kept* native-server runtime (native_server_harness.py +
opencode_http_transport.py), dropping total coverage below the CI gate. Add
focused, Front-E-free unit tests:
- tests/test_native_server_harness.py — drives the transport-agnostic base over
  an in-memory fake transport (run-turn boot-poll / model pin / error branches,
  interrupt, enqueue, capabilities).
- tests/test_opencode_http_transport.py — the prompt-payload builder + every
  transport method over an injected fake OpenCodeClient.

The base test caught a real regression from the descriptor de-coupling: the
enqueue-failure path still referenced the removed ``self.descriptor.id`` (an
AttributeError on that error branch) — now ``self._harness_id``.

Co-authored-by: Isaac

* feat(opencode): pick a default model from `omni setup`

`omni opencode` spawns `opencode serve` with a per-session XDG config (the
user's global ~/.config/opencode is intentionally ignored), so with no model
configured opencode falls back to its built-in default (opencode/big-pickle)
even after `opencode auth login` adds a provider. Add a way to choose the
launch model:

- `omni setup` → OpenCode → "Set default model": lists `opencode models`,
  persists the pick as the `opencode_model` global-config key (+ a Clear
  option). New helpers `_list_opencode_models` / `_set_opencode_default_model`.
- `omni opencode` (no --model) now prefers `opencode_model`, falling back to the
  shared `model` key for back-compat.
- Runner: write the resolved model into the per-session opencode.json at spawn
  (build_opencode_model_default_config) so the TUI and the first turn launch on
  it, not big-pickle — for both the user-provider and Databricks-gateway paths.
- Register `opencode_model` in `_GLOBAL_CONFIG_KEYS` so `omni config` accepts it.

Also registers the `opencode` command in `_CLICK_SUBCOMMANDS` (it was registered
on the CLI group but unreachable from main(), which failed
test_click_subcommands_allowlist_covers_registered_commands).

+ unit tests (provider helper, model picker persist/clear/cancel/empty).

Co-authored-by: Isaac

* test(opencode): cover the `omni opencode` launcher helpers

opencode_native.py (the `omni opencode` launcher) had no direct unit tests —
556 lines of spec-materialization, payload parsing, tmux-attach gating, and
httpx session/terminal helpers sitting uncovered (the biggest single coverage
sink in the harness, and part of why dropping the well-covered Front E modules
pushed total coverage under the gate).

Add tests/test_opencode_native.py covering the unit-testable surface over a
fake AsyncClient: `_materialize_opencode_agent_spec` (model on/off),
`_launched_opencode_terminal_from_payload`, `_direct_tmux_unavailable_reason`,
`_resolve_session_id_for_resume`, and the session/terminal helpers
(`_create_opencode_session`, `_fetch_opencode_session`,
`_ensure_opencode_terminal_on_runner`, `_find_running_opencode_terminal` incl.
404 / not-running / offline-runner branches). Launcher coverage 0% → 56%; the
daemon/tmux attach plumbing stays for the live host e2e.

Co-authored-by: Isaac

* test(opencode): smoke-test the opencode-native harness create_app/factory

inner/opencode_native_harness.py (the `harness: opencode-native` entry point)
was at 0% — add a create_app() FastAPI smoke test + an executor-factory test
(builds OpenCodeNativeExecutor from the spawn env). 0% -> 100%.

Co-authored-by: Isaac

* fix(opencode): seed user auth into the session server so the chosen model works

The runner spawns `opencode serve` with a per-session XDG_DATA_HOME (isolating
session state), which also hid the user's `opencode auth login` credentials
(~/.local/share/opencode/auth.json). Without them the server could only reach
OpenCode's no-auth default (opencode/big-pickle), so `omni opencode` ignored
the selected provider/model — even with the model pinned into opencode.json.

- bridge: `seed_opencode_auth()` copies the user's auth.json into the
  per-session XDG_DATA_HOME at spawn (0600, refreshed each launch); the runner
  calls it before `opencode serve` starts. No-op on a remote runner / the
  Databricks-gateway path (no local auth.json).
- setup: the "Set default model" picker listed every models.dev model
  (hundreds) — overflowing the menu viewport and flickering. Filter to models
  whose provider the user can authenticate (stored auth.json + env keys) via
  the new `reachable_provider_ids()`; fall back to the full list only if that
  filter would hide everything.

+ tests (auth-seed copy/no-op, reachable provider ids).

Co-authored-by: Isaac

* fix(setup): scrolling viewport for the OpenCode model picker (no more flicker)

The model picker still flickered when the reachable-provider model list was
longer than the terminal: select() rendered every row and redrew in place, so a
frame taller than the screen overflowed and flickered.

Add an opt-in scrolling viewport to select(max_visible=...): when set and the
list is longer, it renders only a window of rows that follows the cursor (with
"↑ N more" / "↓ N more" markers), bounding the frame to one screen. Default
(None) renders every row, so all other menus are unchanged. The OpenCode "Set
default model" picker sizes the viewport to the terminal height.

+ tests for the windowed vs full render.

Co-authored-by: Isaac

* test(opencode): raise coverage — test tractable gaps + pragma e2e-only orchestration

The split dropped Front E's well-covered code, dipping total coverage past the
code-coverage ratchet's 0.5% tolerance. Recover it honestly — real unit tests
for the testable surface, and `# pragma: no cover` only on integration-only
orchestration that the live host e2e exercises but unit tests can't.

Unit tests:
- launcher: _preflight_local_tools, _update_startup_progress,
  _direct_tmux_unavailable_reason (tmux-missing / all-present),
  _wait_for_opencode_terminal_ready (found / timeout).
- app-server: find_opencode_cli (absolute exe) + resolve_opencode_version
  (parse / run-error / unparseable).
- client: error + edge branches (non-object bodies, HTTP errors).
- forwarder: seed_dedupe_from_history (resume seeding + best-effort failure).

pragma (e2e-covered, not unit-testable — see tests/e2e/test_host_opencode_native_e2e.py):
- launcher daemon/tmux flow: run_opencode_native, _run_with_remote_server,
  _prepare_opencode_terminal_via_daemon, _attach_terminal_resource,
  _attach_direct_tmux, and the SDK resume picker.
- OpenCodeNativeServer.close().

Co-authored-by: Isaac

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-06-23 19:04:37 -07:00

137 lines
5.3 KiB
Python

"""OpenCode readiness + credential reporting for ``omnigent setup``.
Like :mod:`omnigent.onboarding.goose_auth`, Omnigent stores **no** OpenCode
credentials: OpenCode owns its own provider auth via ``opencode auth login``
(stored in ``~/.local/share/opencode/auth.json``) or ambient provider env vars
(``OPENAI_API_KEY`` / ``ANTHROPIC_API_KEY`` / …). This module is a thin,
read-only reporter so ``omnigent setup`` can show which providers OpenCode can
reach and offer to run its native login — without ever touching its secrets.
It reads ``auth.json`` directly (a JSON object keyed by provider id — see
``packages/opencode/src/auth`` in the OpenCode source) rather than scraping
``opencode auth list`` output, and checks a curated set of common provider env
vars. Both are best-effort: a missing/unreadable file or unknown env var simply
reports "nothing configured", never raises.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from omnigent.onboarding.harness_install import OPENCODE_KEY, harness_cli_installed
# Common OpenCode providers → (provider id, display label, env var). The
# provider id matches OpenCode's own id (the ``auth.json`` key and the
# ``provider/model`` prefix in ``opencode models``). Not exhaustive (OpenCode
# resolves many providers from models.dev); this is the set worth surfacing in
# setup, including the ``OPENAI_*`` pair the Databricks-gateway path uses.
_ENV_PROVIDER_VARS: tuple[tuple[str, str, str], ...] = (
("openai", "OpenAI", "OPENAI_API_KEY"),
("anthropic", "Anthropic", "ANTHROPIC_API_KEY"),
("google", "Google Gemini", "GEMINI_API_KEY"),
("google", "Google Gemini", "GOOGLE_GENERATIVE_AI_API_KEY"),
("groq", "Groq", "GROQ_API_KEY"),
("openrouter", "OpenRouter", "OPENROUTER_API_KEY"),
("xai", "xAI", "XAI_API_KEY"),
("mistral", "Mistral", "MISTRAL_API_KEY"),
("deepseek", "DeepSeek", "DEEPSEEK_API_KEY"),
)
def opencode_auth_path() -> Path:
"""Return OpenCode's ``auth.json`` path for this process's HOME.
Honors ``XDG_DATA_HOME``; defaults to ``~/.local/share/opencode/auth.json``
(OpenCode's ``Global.Path.data``).
"""
xdg = os.environ.get("XDG_DATA_HOME", "").strip()
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
return base / "opencode" / "auth.json"
def _stored_providers() -> tuple[str, ...]:
"""Return provider ids with stored credentials in ``auth.json``.
Best-effort: a missing/unreadable/non-object file yields ``()``.
"""
try:
data = json.loads(opencode_auth_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return ()
if not isinstance(data, dict):
return ()
return tuple(str(k) for k in data)
def _env_providers(environ: dict[str, str] | None = None) -> tuple[str, ...]:
"""Return provider labels whose API-key env var is present."""
env = os.environ if environ is None else environ
seen: list[str] = []
for _provider_id, label, var in _ENV_PROVIDER_VARS:
if env.get(var, "").strip() and label not in seen:
seen.append(label)
return tuple(seen)
def reachable_provider_ids(environ: dict[str, str] | None = None) -> frozenset[str]:
"""Return OpenCode provider ids reachable from stored auth + env keys.
Ids match OpenCode's own (the ``provider/model`` prefix), so callers can
filter a model list down to what the user can actually authenticate.
"""
env = os.environ if environ is None else environ
ids = set(_stored_providers())
for provider_id, _label, var in _ENV_PROVIDER_VARS:
if env.get(var, "").strip():
ids.add(provider_id)
return frozenset(ids)
@dataclass(frozen=True)
class OpenCodeAuthSummary:
"""What setup needs to know about the local OpenCode credentials.
:param installed: ``opencode`` binary present on ``PATH``.
:param stored_providers: Provider ids with credentials in ``auth.json``.
:param env_providers: Provider labels whose API-key env var is set.
"""
installed: bool
stored_providers: tuple[str, ...]
env_providers: tuple[str, ...]
@property
def has_provider(self) -> bool:
"""Whether any provider is reachable (stored credential or env key)."""
return bool(self.stored_providers or self.env_providers)
@property
def ready(self) -> bool:
"""Launchable when the CLI is installed AND a provider is configured."""
return self.installed and self.has_provider
def describe(self) -> str:
"""A short human summary of configured providers, e.g.
``"2 stored (anthropic, openai) + env: OpenAI"``.
"""
parts: list[str] = []
if self.stored_providers:
parts.append(
f"{len(self.stored_providers)} stored ({', '.join(sorted(self.stored_providers))})"
)
if self.env_providers:
parts.append(f"env: {', '.join(self.env_providers)}")
return " · ".join(parts) if parts else "no provider configured yet"
def opencode_auth_summary() -> OpenCodeAuthSummary:
"""Summarize the local OpenCode credential state for setup display."""
return OpenCodeAuthSummary(
installed=harness_cli_installed(OPENCODE_KEY),
stored_providers=_stored_providers(),
env_providers=_env_providers(),
)