Skip to content

fix(opencode): detect credentials from OpenCode auth.json format - #85

Open
yan-6 wants to merge 6 commits into
freestylefly:mainfrom
yan-6:fix/issue-78
Open

yan-6 wants to merge 6 commits into
freestylefly:mainfrom
yan-6:fix/issue-78

Conversation

@yan-6

@yan-6 yan-6 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Problem

WeSight shows "未检测到本机登录或密钥" (No local login or key detected) for OpenCode even when the user has successfully authenticated, and tasks stall at "OpenCode step started.".

Root Cause

OpenCode stores credentials in ~/.local/share/opencode/auth.json as a map of provider ID to a discriminated union (Auth.Info in packages/opencode/src/auth/index.ts):

Oauth     = { type: "oauth",     refresh: string, access: string, expires: number, ... }
Api       = { type: "api",       key: string, metadata?: ... }
WellKnown = { type: "wellknown", key: string, token: string }

So a real file looks like:

{
  "deepseek":  { "type": "api",   "key": "sk-..." },
  "anthropic": { "type": "oauth", "refresh": "...", "access": "...", "expires": 0 }
}

WeSight's generic fileContainsCredential matches credential-like key names (api_key, auth_token, access_token, …). None of key, access, or refresh qualify on their own, so the scan finds nothing and summarizeCliAuthStatus falls through to logged_out.

Separately, the opencode entry in localEnvKeysByAppType only listed OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN, so env-provided keys for other providers were also missed — the reporter had DEEPSEEK_API_KEY and GEMINI_API_KEY exported.

Fix

  1. openCodeAuthEntryLoggedIn(entry) — schema-aware check that switches on type and validates the fields each variant actually uses: api → key; oauth → refresh or access; wellknown → key or token. Falls back to field sniffing when the discriminator is absent, for older or future formats. Reuses the existing isNonPlaceholderSecret helper so placeholder values are rejected consistently.

  2. summarizeCliAuthStatus — OpenCode branch — after the generic scan, checks every auth.json among the auth path candidates (not just the secondaryConfigPaths entry, since the data dir can be relocated), reading through readJsonObject.

  3. Broader env keys — the opencode env-key list now covers the common Models.dev providers, since OpenCode resolves credentials for any of them.

Part 2 — the stall itself: an unauthenticated default model

The detection fix above makes WeSight see the credentials. It does not
change which model WeSight runs, and that turned out to be the second half
of #78.

listOpenCodeModelProviders synthesized DEFAULT_OPENCODE_MODEL
(anthropic/claude-sonnet-4-5) as the current provider whenever
opencode.json(c) carried no model field — which is exactly the reported
state, where the config holds only "$schema". That record was written to
external_agent_providers as is_current, and
ExternalCliRuntimeAdapter passes selectedProvider.summary.model
straight to opencode run --model.

The reporter's auth.json held DeepSeek and Google only. So WeSight
invoked OpenCode with --model anthropic/claude-sonnet-4-5, a provider
they never authenticated — consistent with a task that prints
OpenCode step started. and then makes no further progress, while the
same CLI works fine from a terminal.

Fix:

  1. listOpenCodeAuthProviderIds(authJson) — returns the provider IDs in
    auth.json that hold a usable credential, reusing the same Auth.Info
    schema logic as part 1.
  2. listOpenCodeModelProviders(config, { authProviderIds }) — drops the
    synthesized default when its provider is not logged in. An explicitly
    configured model is always kept: that is the user's own declaration,
    and a missing credential there is a different problem. Omitting the
    option preserves the previous behaviour exactly, so no other caller
    changes semantics.
  3. syncOpenCodeLiveProviders — passes the auth.json provider IDs in.

To avoid two copies of the schema rules, the auth-entry check now lives in
openCodeConfig.ts and is re-exported from externalAgentEnvironment.ts,
so the credential reader and the model lister cannot drift apart. Behaviour
is unchanged — verified against the assertions already shipped in
openCodeAuthDetection.test.ts.

Files Changed

  • src/main/libs/externalAgentEnvironment.ts
  • src/main/libs/externalAgentProviderStore.ts
  • src/main/libs/openCodeConfig.ts
  • src/main/libs/openCodeAuthDetection.test.ts (new)
  • src/main/libs/openCodeAuthModel.test.ts (new)

Self-review

  • New function is pure and only consulted for appType === 'opencode'; no other app type changes behaviour.
  • 12 new tests cover all three auth variants, empty/whitespace secrets, malformed JSON, non-object entries, and the exact auth.json from OpenCode credentials not detected although CLI auth works; task stalls at OpenCode step started. #78. 9 of them fail against the previous implementation, confirming they are genuine regression tests.
  • npx tsc --noEmit exit 0; npx eslint clean; npm test → 76 files / 563 tests passed.
  • Missing, empty, or malformed auth.json returns false — no regression for fresh installs.
  • Additive: the generic credential scan still runs first; the OpenCode check is a fallback.

Self-review — part 2

  • listOpenCodeAuthProviderIds is pure; the new option on
    listOpenCodeModelProviders is optional, so every existing call site keeps
    its current behaviour. The repo's 7 pre-existing openCodeConfig tests pass
    unmodified.
  • 9 new tests in openCodeAuthModel.test.ts. Reverse-verified: 6 of the 9
    fail against the pre-change implementation
    , so they are genuine regression
    tests rather than tautologies.
  • Boundary cases covered explicitly: an explicitly configured model survives
    even with no visible credential; configured provider.*.models entries are
    still listed when the synthesized default is dropped; blank and placeholder
    secrets are rejected; malformed input returns an empty list.
  • npx tsc --noEmit --strict exit 0. eslint clean under the repo's own
    eslint.config.mjs (the import-sort error the autofixer flagged on the new
    import is fixed in this commit, not suppressed).

Remaining scope caveat

With no logged-in provider, OpenCode now receives no --model override and
falls back to its own resolution, which is the correct deferral. What this PR
deliberately does not do is surface a user-facing "no authenticated
OpenCode provider" message in the engine picker — that is a UI change beyond
the scope of #78 and worth a separate issue if you want it. Please confirm the
end-to-end run on a machine with a partial auth.json before closing #78.


Part 3 — the previous fix still left #78 reproducible (2026-09-18)

Dropping the unauthenticated default was necessary but not sufficient. For the
reporter's exact inputs — opencode.json(c) containing only "$schema", and an
auth.json holding DeepSeek + Google — Part 2 made listOpenCodeModelProviders
return an empty list. That is not a safe end state:

syncOpenCodeLiveProviders writes no rows, so importLiveProviderIfEmpty fires,
which calls readLiveSettingsConfig, which rebuilds model as
DEFAULT_OPENCODE_LOCAL_MODEL — the very anthropic/claude-sonnet-4-5 that was
just dropped. It is then handed to opencode run --model. The user is back to a
task that stalls at OpenCode step started. with a provider they never logged
into.

Change

  • When the record list would otherwise be empty, emit one credential-backed
    record per logged-in provider
    (opencode-auth-<providerKey>), reusing any
    name / apiKey / baseURL the config declares for that provider.
  • Those records intentionally carry model: '', so the runtime's
    if (model) args.push('--model', model) omits the flag and OpenCode resolves
    the model itself.
  • Both places that re-derive a model from an empty one are now gated by an
    explicit OPENCODE_MODEL_UNSET_KEY marker:
    • summarizeOpenCodeSettingsConfig returns '' instead of the default;
    • applyProviderToLive no longer writes a model key at all. This one
      matters most: persisting the default into the user's own opencode.json(c)
      would turn it into an explicit declaration and make the stall permanent
      and invisible.

Self-review — part 3

  • Reverse-verified. 4 of the 12 new tests in openCodeAuthFallback.test.ts
    fail against the Part 2 implementation and pass after this change. The
    starting reproduction (records.length === 0) was confirmed failing before
    any code was written.
  • One pre-existing assertion in openCodeAuthModel.test.ts encoded the
    incomplete behaviour (expect(records.filter(isCurrent)).toEqual([])). It is
    updated, not deleted, and now asserts the corrected intent. Flagging it
    explicitly since changing an existing test deserves a look.
  • No behaviour change outside the reporter scenario, each covered by a test:
    omitting authProviderIds is byte-for-byte the historical path; an explicit
    model always wins; provider.*.models entries take precedence over the
    fallback; an authenticated default provider still yields the normal record;
    authProviderIds: [] correctly yields an empty list rather than a bogus model.
  • Exactly one record is marked isCurrent, so the store's
    "no current → promote records[0]" branch stays consistent.
  • tsc --noEmit --strict exit 0. eslint 0 problems under the repo's own
    eslint.config.mjs at the pinned versions (eslint 9.39.4, ts 5.7.3),
    import order included. 21 tests green on vitest 4.1.0.

Verification environment (disclosure)

This round was verified in the automation sandbox, not on the usual Mac
checkout: git clone returned invalid credentials and the tarball endpoint
returned HTTP 502, so files were read through the contents API and exercised in
a harness pinned to the repo's own eslint.config.mjs and dependency versions.
Type-check, lint and the unit tests are real runs; a full npm test across the
whole suite and an end-to-end OpenCode launch were not performed here.
Please still confirm one real run with a partial auth.json before closing #78.

Closes #78

…ic format

OpenCode stores credentials as {"providerName": {"api": "key"}} in
~/.local/share/opencode/auth.json. The generic fileContainsCredential
function only recognises keys like api_key, auth_token, etc. — the
single-word "api" key that OpenCode uses is not matched, so WeSight
always showed "未检测到本机登录或密钥" even for users whose CLI auth
works fine.

Fix: add openCodeAuthJsonLoggedIn() which explicitly understands the
OpenCode auth.json shape, and call it from summarizeCliAuthStatus for
the opencode app-type after the generic credential scan.

Closes freestylefly#78
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the canghe's projects Team on Vercel.

A member of the Team first needs to authorize it.

The first version of this fix looked for {provider: {api|token|key|apiKey}},
but OpenCode's Auth.Info schema is a discriminated union:
  api       -> { type: 'api', key }
  oauth     -> { type: 'oauth', refresh, access, expires }
  wellknown -> { type: 'wellknown', key, token }

An api login stores the secret under 'key', not 'api', and an oauth login has
no key-like field at all, so OAuth users stayed undetected.

- Add openCodeAuthEntryLoggedIn covering all three variants, with a field-sniffing
  fallback for older/unknown formats
- Scan every auth.json candidate instead of only the secondaryConfigPaths entry
- Read auth.json via readJsonObject so a .jsonc data dir also parses
- Broaden opencode env keys (DEEPSEEK_API_KEY, GEMINI_API_KEY, etc.); the issue
  reporter had DEEPSEEK_API_KEY and GEMINI_API_KEY exported but neither was checked
- Add 12 regression tests; 9 of them fail against the previous implementation
@yan-6

yan-6 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: the assumed auth.json schema was wrong

Self-review against OpenCode upstream (packages/opencode/src/auth/index.ts) shows the original version of this fix guessed the credential shape and would still miss real logins.

OpenCode's Auth.Info is a discriminated union, not a loose bag of key names:

Oauth     = { type: "oauth",     refresh: string, access: string, expires: number, ... }
Api       = { type: "api",       key: string, metadata?: ... }
WellKnown = { type: "wellknown", key: string, token: string }

The first implementation checked ['api', 'token', 'key', 'apiKey'] on each entry. Two consequences:

  1. API-key logins matched only by accident. The secret lives under key, not api — the field named in the PR description and code comment does not exist in the schema. key happened to be in the list, so this case worked, but not for the stated reason.
  2. OAuth logins were still undetected. An oauth entry has no key/api/apiKey field at all — only refresh and access. Anyone who authenticated with a subscription login (e.g. Anthropic OAuth) would keep seeing "未检测到本机登录或密钥", which is exactly the symptom in OpenCode credentials not detected although CLI auth works; task stalls at OpenCode step started. #78.

Fix in 6b5290a

  • Added openCodeAuthEntryLoggedIn, which switches on type and validates the fields each variant actually uses (api → key; oauth → refresh or access; wellknown → key or token), with a field-sniffing fallback for older/unknown formats
  • Reused the existing isNonPlaceholderSecret helper so placeholder values are rejected consistently
  • Scan every auth.json candidate rather than only the secondaryConfigPaths entry, since the data dir can be relocated
  • Read via readJsonObject instead of a private JSON.parse block
  • Broadened the opencode env-key list. The reporter had DEEPSEEK_API_KEY and GEMINI_API_KEY exported and opencode auth list showed both under Environment, but WeSight only checked OPENAI_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN — so the env-based path missed them too

Verification (local, macOS)

  • New openCodeAuthDetection.test.ts: 12 tests covering all three variants, empty/whitespace secrets, malformed JSON, non-object entries, and the exact auth.json from OpenCode credentials not detected although CLI auth works; task stalls at OpenCode step started. #78
  • 9 of the 12 fail against the previous implementation (confirmed by stashing the fix), so they are genuine regression tests rather than tautologies
  • npx tsc --noEmit → exit 0; npx eslint → clean; npm test → 76 files / 563 tests passed

Note on scope

This addresses the credential-detection half of #78. Whether the task then proceeds past OpenCode step started. depends on runtime credential injection; that should be confirmed separately before closing the issue.

Issue freestylefly#78 has two halves. The credential *detection* half was fixed
earlier in this branch. This commit fixes the remaining half: the model
WeSight actually hands to `opencode run`.

When `opencode.json(c)` carries no `model` field (the exact state in the
report: the file holds only `"$schema"`), `listOpenCodeModelProviders`
synthesized `DEFAULT_OPENCODE_MODEL` = `anthropic/claude-sonnet-4-5` and
stored it as the current provider. The reporter had only DeepSeek and
Google credentials in `auth.json`, so WeSight launched OpenCode with
`--model anthropic/claude-sonnet-4-5`, a provider they never
authenticated. That is consistent with a task that emits
`OpenCode step started.` and then makes no further progress.

Changes:
- openCodeConfig.ts: add `listOpenCodeAuthProviderIds()`, which reads
  OpenCode's `Auth.Info` union (api | oauth | wellknown) and returns the
  provider IDs that hold a usable credential. `listOpenCodeModelProviders`
  accepts an optional `authProviderIds` and drops the *synthesized*
  default when its provider is not logged in. An explicitly configured
  `model` is always kept: that is the user's own declaration.
- externalAgentProviderStore.ts: pass the auth.json provider IDs when
  syncing OpenCode live providers.
- externalAgentEnvironment.ts: move the auth-schema check to
  openCodeConfig.ts and re-export it, so the credential reader and the
  model lister cannot drift apart. Behaviour is unchanged; verified
  against the assertions already shipped in openCodeAuthDetection.test.ts.

Omitting `authProviderIds` preserves the previous behaviour, so no other
caller changes semantics.

Tests: openCodeAuthModel.test.ts, 9 cases. Reverse-verified — 6 of the 9
fail against the pre-change implementation.
@yan-6

yan-6 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on this branch (commit f78f153): #78 turned out to have a second half, and this PR only fixed the first.

The credential detection fix makes WeSight see the DeepSeek/Google logins. It does not change which model WeSight actually runs — and that is what matches the reported symptom.

listOpenCodeModelProviders synthesized DEFAULT_OPENCODE_MODEL (anthropic/claude-sonnet-4-5) as the current provider whenever opencode.json(c) had no model field. That is exactly the reported state; the reporter's config contains only "$schema". The synthesized record gets stored as is_current, and ExternalCliRuntimeAdapter passes selectedProvider.summary.model directly into opencode run --model.

So WeSight was launching OpenCode with --model anthropic/claude-sonnet-4-5 for a user whose auth.json held only DeepSeek and Google. That is consistent with a task that prints OpenCode step started. and then goes nowhere, while opencode run works fine in a terminal.

The fix gates only the synthesized default on whether its provider appears in auth.json. An explicitly configured model is always preserved — that is the user's own declaration, and a missing credential there is a different failure worth surfacing differently. Passing no authProviderIds keeps the old behaviour, so no other call site changes.

I also moved the Auth.Info schema check into openCodeConfig.ts and re-exported it here, because otherwise the credential reader and the model lister would each carry their own copy of the same rules. Behaviour is unchanged; I checked it against the assertions already in openCodeAuthDetection.test.ts.

Verification: 9 new cases in openCodeAuthModel.test.ts, and I reverse-verified them by reverting the change — 6 of the 9 fail, so they are real regression tests. The 7 pre-existing openCodeConfig tests pass unmodified. tsc --noEmit --strict exit 0, and eslint is clean under the repo's own eslint.config.mjs (the import-sort error it flagged on the new import is fixed in the commit rather than suppressed).

One honest limitation: with no logged-in provider, OpenCode now simply receives no --model override and falls back to its own resolution. That is the right deferral, but it is silent — there is no user-facing "no authenticated OpenCode provider" hint in the engine picker. That is a UI change beyond #78's scope; happy to open a separate issue if you want it. Worth confirming an end-to-end run against a partial auth.json before closing #78.

…model list

Dropping the synthesized anthropic/claude-sonnet-4-5 default for users who
never authenticated Anthropic left listOpenCodeModelProviders returning an
empty list for the reporter's config (only "$schema"). An empty list makes
syncOpenCodeLiveProviders fall through to importLiveProviderIfEmpty ->
readLiveSettingsConfig, which rebuilds `model` as DEFAULT_OPENCODE_LOCAL_MODEL
- the same unusable Anthropic default, handed straight to `opencode run
--model`. The stall in issue freestylefly#78 therefore survived the previous fix.

Emit one credential-backed record per logged-in provider when the list would
otherwise be empty, carrying no model so the runtime omits --model and lets
OpenCode resolve the model itself. Guard the two places that re-derive a model
from an empty one (summarizeOpenCodeSettingsConfig and applyProviderToLive) with
an explicit OPENCODE_MODEL_UNSET_KEY marker, so the default cannot be
resurrected - and in particular is never persisted into the user's own
opencode.json(c), where it would count as an explicit declaration.

Refs freestylefly#78
@yan-6

yan-6 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on this PR: the earlier commits fixed credential detection but the reported stall was still reproducible, so I pushed af4f741.

What was still broken. For the exact inputs in the issue — opencode.json containing only "$schema", auth.json holding DeepSeek + Google — the previous commit correctly refused to advertise anthropic/claude-sonnet-4-5, but that left the provider list empty. An empty list isn't a neutral outcome here: syncOpenCodeLiveProviders inserts no rows, so importLiveProviderIfEmpty → readLiveSettingsConfig regenerates model as DEFAULT_OPENCODE_LOCAL_MODEL, and that value goes to opencode run --model. Same unauthenticated Anthropic model, same stall at OpenCode step started..

I wrote the reproduction as a failing assertion first (records.length was 0) before touching any code.

What af4f741 does. When the list would be empty, it emits one credential-backed record per logged-in provider, carrying model: '' so the runtime's if (model) guard omits --model and OpenCode resolves the model itself. The two places that re-derive a model from an empty one are gated behind an explicit OPENCODE_MODEL_UNSET_KEY marker. The applyProviderToLive one is the important one — without it, the default would be written into your own opencode.json(c), where it becomes an explicit declaration and makes the problem permanent instead of transient.

Verification. 12 new tests; reverse-verified — 4 fail against the previous implementation and pass now. Five separate tests pin down that nothing changes outside this scenario (omitted authProviderIds is the historical path, explicit model always wins, provider.*.models take precedence, an authenticated default still works, [] yields empty rather than a bogus model). tsc --noEmit --strict exit 0, eslint 0 problems on the repo's own config at the pinned versions, 21 tests green.

Two things worth your attention rather than a rubber stamp:

  1. I modified an existing assertion in openCodeAuthModel.test.ts. It asserted records.filter(isCurrent) was [], which encoded the incomplete behaviour. It now asserts the corrected intent. Updated, not deleted — but you should agree with the new intent.
  2. Verification ran in the sandbox, not the usual Mac checkout (git clone → invalid credentials, tarball → HTTP 502). Type-check, lint and unit tests are genuine runs against the repo's own config; a full-suite npm test and an end-to-end OpenCode launch were not. Please confirm one real run with a partial auth.json before closing OpenCode credentials not detected although CLI auth works; task stalls at OpenCode step started. #78.

Also unchanged from before: when no provider is authenticated, the deferral to OpenCode is silent — the engine picker shows no "no authenticated provider" hint. That's a UI change beyond this issue's scope; happy to open a separate issue if you want it.

@yan-6

yan-6 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Status update (2026-09-20): rebased onto the new main, and one interaction with the tokendance commit to review

Rebase

main moved for the first time since 2026-08-24: 8984cbe (tokendance integration, v1.0.7, 27 files) and a8606e1 (macOS keychain fix). This branch was 2 behind and diverged; it is now updated and 0 behind.

8984cbe modified src/main/libs/externalAgentProviderStore.ts, which this PR also changes, so the overlap was checked rather than assumed. A three-way merge of the two versions against their common base produced 0 textual conflicts (git merge-file exit 0), and CI on the merge commit 923dc88 is green: verify, lint, test, build-main, codeql, CodeQL, dependency-audit, secrets-scan, skills-audit, changed-files, label all SUCCESS — including the repo's own full test job. Vercel is the usual fork-PR secret failure, unrelated.

The part that is not just textual

The new upstream code and this PR both land inside applyProviderToLive, and they are now ordered like this:

private applyProviderToLive(provider: ExternalAgentProvider): void {
  if (isReadOnlyLocalConfig(provider.appType)) return;

  // NEW from 8984cbe — runs first
  if (provider.summary.apiKey === TokenDance.CredentialRef) {
    ...
    provider = {
      ...provider,
      settingsConfig: buildSettingsConfigFromInput({ ... }),   // ← rebuilt from scratch
    };
  }

  const settingsConfig = this.stripInternalSettingsConfig(provider.settingsConfig);
  ...
  if (settingsConfig[OPENCODE_MODEL_UNSET_KEY] === true) { ... }   // ← this PR's guard
}

The tokendance branch replaces settingsConfig wholesale via buildSettingsConfigFromInput, which builds the OpenCode shape as { config, model } and never carries OPENCODE_MODEL_UNSET_KEY. So for a provider that is both tokendance-backed and model-unset, the marker is dropped before this PR's guard is reached, and buildSettingsConfigFromInput substitutes DEFAULT_OPENCODE_LOCAL_MODEL for the empty model — exactly the unauthenticated Anthropic default that Part 3 of this PR exists to keep out of the user's config.

I do not believe this is reachable today, and I have not changed any code for it. The marker is only set on opencode-auth-* records, which this PR synthesizes from auth.json credentials; those carry the provider's own apiKey from the OpenCode config, not wesight-credential:tokendance. For the two to meet, a tokendance-backed OpenCode provider would have to be stored with no model, and I could not construct that path from the code on main.

I am flagging it rather than fixing it because the safety of the combination rests on that reachability argument, not on a guard — and you know the tokendance provisioning path better than I can infer it. If tokendance can ever produce a model-less OpenCode provider, the fix is one line: preserve the marker across the rebuild, e.g. carry settingsConfig through instead of rebuilding it, or re-apply the marker after buildSettingsConfigFromInput. Say the word and I will add it plus a regression test.

No code changes in this update; #78's own fix is unchanged from the 09-18 review.

@yan-6

yan-6 commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Merge-order note (2026-09-21): #83 and this PR now conflict in one block

Heads up on an interaction that appeared today rather than a defect in this PR.

#83 gained a commit (a4c0969) that fixes a data-loss bug on the OpenCode write path: once WeSight prefers opencode.jsonc, JSON.stringify-ing the parsed object back over the file deletes the user's comments. It replaces the write in applyProviderToLive with a comment-preserving one.

This PR rewrites the same block in applyProviderToLive for a different reason — the OPENCODE_MODEL_UNSET_KEY branch that stops the unauthenticated Anthropic default being persisted.

A three-way merge of the two branches now reports 1 conflict, localized entirely to that one if (provider.appType === OPENCODE_APP_TYPE) body. Everything else in externalAgentProviderStore.ts and all of externalAgentEnvironment.ts still merge cleanly (verified with git merge-file, 0 conflicts outside this block).

Both changes are wanted and they are not in opposition:

The resolution is to keep both — the unset-marker branch should write through the comment-preserving writer with an empty changed-key set for model, and the normal branch should pass ['model', ...Object.keys(storedConfig)]. Roughly:

if (settingsConfig[OPENCODE_MODEL_UNSET_KEY] === true) {
  writeJsonConfigFile(getOpenCodeConfigPath(), baseConfig, Object.keys(storedConfig));
  return;
}
const selectedModel = /* unchanged */;
writeJsonConfigFile(
  getOpenCodeConfigPath(),
  { ...baseConfig, model: selectedModel },
  ['model', ...Object.keys(storedConfig)],
);

I have deliberately not pushed that to either branch: it would make this PR depend on #83's unmerged head, and whichever of the two you merge second is the natural place to resolve it. Flagging it now so the conflict is not a surprise at merge time. Say the word and I will prepare the resolution on whichever branch you plan to merge second.

Nothing in this PR needs to change for its own sake — CI on 923dc88 remains green (verify, lint, test, build-main, codeql, dependency-audit, secrets-scan, skills-audit all success; Vercel is the known fork-PR deploy-secret failure), and the branch is still 0 behind main.

@yan-6

yan-6 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (c913cd9) — conflict with #83 re-confirmed, still unresolved by design

Synced this branch with upstream main (f6dde0e). Upstream c913cd9 touches none of this PR's files, so the sync was clean: diff unchanged at +477/-11 across its own 6 files, 0 behind, CI green (test SUCCESS, plus verify / lint / build-main / CodeQL / dependency-audit / secrets-scan; Vercel's failure is the usual fork-deploy authorization issue).

The #83 ↔ #85 conflict is still there and is still a real one. Re-ran the 3-way merge against the current base after both branches were synced — exactly 1 conflict hunk remains, in applyProviderToLive (externalAgentProviderStore.ts), the same OPENCODE_APP_TYPE block as reported previously:

Both behaviours need to survive. Dropping #83's side silently deletes user comments in opencode.jsonc; dropping this PR's side re-persists the unauthenticated Anthropic default and permanently resurrects the issue #78 stall.

Suggested resolution (unchanged, for whichever branch merges second): route the unset branch through writeJsonConfigFile as well, passing a changedKeys list that omits model — that preserves comments while still declining to write a model.

I'm deliberately not pushing that resolution here: it would make this branch depend on #83's unmerged head, and the merge order is your call. The two branches are otherwise 0-conflict. Merge either one first, then this one line needs the above touch-up.

@yan-6

yan-6 commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

2026-10-03: the conflict resolution, written out and verified

No upstream movement since yesterday (main still c913cd9), both branches still 0 behind, CI still green. The one thing I had been describing only in prose — the resolution for the single conflict hunk with #83 — I have now actually written and checked, so whoever merges second can paste it instead of re-deriving it.

Verification method. Pulled all three versions of externalAgentProviderStore.ts (base c913cd9, #83 head 9b9b85e, this PR's head f6dde0e) via the contents API, and confirmed each one is byte-identical to its git blob with git hash-object before using it:

base     5b9059beef5ddaa96024cdcdb155339de2ff9a1a  OK
#83      697e4c4741e5ac234f8a6fe614bece498e1d962b  OK
#85      dd47450b7bdca96debca49beb7b346909b32ec88  OK

git merge-file then reproduces exactly 1 conflict hunk, still in the OPENCODE_APP_TYPE block of applyProviderToLive — unchanged from the previous two reports.

The resolved block (replaces the whole conflicted OPENCODE_APP_TYPE body):

    if (provider.appType === OPENCODE_APP_TYPE) {
      const existingConfig = readJsonObject(getOpenCodeConfigPath()) ?? {};
      const storedConfig = parseOpenCodeConfig(settingsConfig.config);
      const baseConfig = {
        ...existingConfig,
        ...(Object.keys(storedConfig).length > 0 ? storedConfig : {}),
      };
      // A provider stored with the unset marker is credential-backed but carries no
      // model. Persisting DEFAULT_OPENCODE_LOCAL_MODEL here would write the
      // unauthenticated Anthropic default into the user's own opencode.json(c) and
      // permanently resurrect the stall reported in issue #78 (PR #85).
      // The write still goes through writeJsonConfigFile so hand-written comments in
      // opencode.jsonc survive (PR #83); 'model' is excluded from changedKeys so the
      // user's own model declaration is left untouched.
      if (settingsConfig[OPENCODE_MODEL_UNSET_KEY] === true) {
        writeJsonConfigFile(
          getOpenCodeConfigPath(),
          baseConfig,
          [...Object.keys(storedConfig)],
        );
        return;
      }
      const selectedModel = getString(settingsConfig.model)
        || summarizeOpenCodeSettingsConfig(settingsConfig).model
        || DEFAULT_OPENCODE_LOCAL_MODEL;
      const nextConfig = { ...baseConfig, model: selectedModel };
      writeJsonConfigFile(
        getOpenCodeConfigPath(),
        nextConfig,
        ['model', ...Object.keys(storedConfig)],
      );
      return;
    }

What I checked on it, beyond "it looks right":

  • Signature is correct against fix: OpenCode config path should prefer opencode.jsonc over opencode.json #83's own definition — writeJsonConfigFile(filePath, value, changedKeys) at line 358 of the fix: OpenCode config path should prefer opencode.jsonc over opencode.json #83 head, which delegates to stringifyJsoncPreservingComments and falls back to writeJsonFile when an in-place edit is not safe. I did not assume this from the call sites; I read the declaration.
  • tsc --noEmit on the merged file: 0 syntax errors (TS1xxx). Same clean result for both unmerged heads, so that is a like-for-like comparison, not a lucky pass. This is a parse/syntax check only — the full type graph needs the repo's node_modules, which I could not install (see below).
  • Both features' symbols survive the merge and are genuinely defined, not just referenced: writeJsonConfigFile is declared once (line 360), OPENCODE_MODEL_UNSET_KEY is imported at line 60 and used at line 1678. No duplicate const declarations, no leftover conflict markers.
  • writeJsonFile is not used in the resolved unset branch — that was the whole point, since it is the call that would have dropped comments.

Behaviour preserved from each side: the unset branch still returns without ever writing a model key (so issue #78's stall cannot be re-persisted), and every write on this path now goes through the comment-preserving writer (so a hand-commented opencode.jsonc survives a model switch).

Still not pushing this — it would make this branch depend on #83's unmerged head, and the merge order is yours to pick. Merge either PR first; the second one needs exactly this one block.

One caveat I want to keep visible: the end-to-end check is still unperformed, and I cannot do it from here. Take a real opencode.jsonc with hand-written comments, switch the model once in the WeSight UI, and confirm the comments are still there. Everything above is unit/parse-level.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenCode credentials not detected although CLI auth works; task stalls at OpenCode step started.

1 participant