Skip to content

Commit 2ef2c8a

Browse files
anandgupta42claude
andcommitted
fix: attached-TUI auth headers, explicit model authority, dismissed-picker guard
Finishes the TUI half of the PR #1361 review findings: - Attached TUI registration drops auth (`altimate-onboarding.tsx`): the HTTP fallback for `registerAltimateBase()` (used when there's no host-injected worker RPC, i.e. `opencode attach`) called `sdk.fetch` directly with no auth headers, so it 401ed against a password-protected server. `sdk.tsx` now exposes the same `headers` `createOpencodeClient` already bakes into every typed SDK call; the fallback merges them in. New test asserts the Authorization header is actually sent. - Explicit model must stay authoritative (`local.tsx`'s `currentModel()`): the stale-public-Zen -> registered-Base substitution applied uniformly to whatever `fallbackModel()`/a persisted pick/an agent's own `model` resolved to — so an explicit `--model opencode/x` (or config `model`, or an agent's configured `model`) pointing at the now-broken keyless Zen tier got silently rewritten to Base instead of staying put. Split `fallbackModel()`'s explicit args/config checks into their own `explicitFallbackModel()` memo so `currentModel()` can route only the two truly implicit sources (a persisted per-agent pick, and the implicit recents/allowlist fallback) through the substitution. `Provider.defaultModel()`, ACP's `defaultModelFromConfig()`, and `SessionPrompt.lastModel()` were already correct (their explicit sources short-circuit before any substitution logic runs) — verified, not changed. New full-mount test (`explicit-model-authoritative.test.tsx`) exercises `currentModel()` itself with Base actually registered, confirming `--model opencode/x` stays put and — as a control — that the same catalogue substitutes Base when nothing explicit overrides it. - Dismissed picker (`selectAltimateBase()`): registration and bootstrap are both async; if the originating picker was dismissed or replaced while either was in flight, the function still went on to select the model and call `dialog.clear()` — closing whatever the user has open now, not the picker that started this. Snapshots the top-of-stack dialog by reference at entry and re-checks it after every await, bailing out silently the moment it no longer matches. 2 new tests (dismissed during registration, replaced during bootstrap). - Test isolation (`select-altimate-base.test.ts`): `selectAltimateBase()` calls `markSetupComplete()` on every successful path, flipping the module-global `setupComplete` signal that Bun's test runner shares across every file in the run (already handled in `local.test.ts` around its own `markSetupComplete()` calls, but missing here). Added the same `resetSetupComplete()` in `afterEach`. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 41dc4d4 commit 2ef2c8a

5 files changed

Lines changed: 390 additions & 39 deletions

File tree

‎packages/tui/src/component/altimate-onboarding.tsx‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,9 @@ const REGISTER_FAILURE_MESSAGE = "Could not set up Altimate Base. Try again, or
440440
* context/sdk.tsx) when available. An attached TUI has no in-process worker to call
441441
* (cli/cmd/attach.ts never provides it), so it falls back to the server's own
442442
* `POST /altimate/base/register` route over the same transport (`sdk.fetch`/`sdk.url`) everything
443-
* else uses.
443+
* else uses — including `sdk.headers`, the same Basic-auth headers `createOpencodeClient` bakes
444+
* into every typed SDK call, so this raw fetch doesn't 401 against a password-protected attached
445+
* server the way a bare `sdk.fetch` call would.
444446
*/
445447
async function registerAltimateBase(sdk: ReturnType<typeof useSDK>): Promise<RegisterOutcome> {
446448
try {
@@ -449,9 +451,11 @@ async function registerAltimateBase(sdk: ReturnType<typeof useSDK>): Promise<Reg
449451
if (data.ok) return { ok: true }
450452
return { ok: false, result: data.result, message: data.message || REGISTER_FAILURE_MESSAGE }
451453
}
454+
const headers = new Headers(sdk.headers)
455+
headers.set("Content-Type", "application/json")
452456
const response = await sdk.fetch(`${sdk.url}/altimate/base/register`, {
453457
method: "POST",
454-
headers: { "Content-Type": "application/json" },
458+
headers,
455459
body: JSON.stringify({}),
456460
})
457461
const body = (await response.json().catch(() => undefined)) as
@@ -494,6 +498,12 @@ export function useAltimateBaseDisclosureNotice() {
494498
* the provider dialog): register if needed, refresh provider state, confirm the model actually
495499
* came up, then select it. An error at any step is shown via toast and the selection is left
496500
* alone — never a partial/failed switch.
501+
*
502+
* Also guards against the originating picker going away mid-flight (see `stillOpen()` below):
503+
* registration and bootstrap are both async, and if the user dismissed the picker or opened
504+
* something else in the meantime, neither the model switch nor `dialog.clear()` should run —
505+
* `clear()` would otherwise close whatever the user has open NOW, not the picker that started
506+
* this.
497507
*/
498508
export async function selectAltimateBase(input: {
499509
sdk: ReturnType<typeof useSDK>
@@ -502,14 +512,27 @@ export async function selectAltimateBase(input: {
502512
toast: ReturnType<typeof useToast>
503513
dialog: ReturnType<typeof useDialog>
504514
}): Promise<boolean> {
515+
// altimate_change start — Codex review finding: snapshot the top-of-stack item BY REFERENCE at
516+
// entry; `stillOpen()` re-checks it after every await below. `dialog.replace()`/`clear()` always
517+
// install a brand-new stack (and a dismissal empties it), so any of those happening in between —
518+
// whether the user backed out or a different feature took the dialog stack over — makes this
519+
// reference comparison false, and every call site below bails out silently: no toast (there is
520+
// nothing left for it to be about), no model switch, no `clear()`.
521+
const originatingDialog = input.dialog.stack.at(-1)
522+
const stillOpen = () => input.dialog.stack.at(-1) === originatingDialog
523+
// altimate_change end
524+
505525
const outcome = await registerAltimateBase(input.sdk)
526+
if (!stillOpen()) return false
506527
if (!outcome.ok) {
507528
input.toast.show({ variant: "error", message: outcome.message })
508529
return false
509530
}
510531

511532
await input.sdk.client.instance.dispose().catch(() => {})
533+
if (!stillOpen()) return false
512534
await input.sync.bootstrap().catch(() => {})
535+
if (!stillOpen()) return false
513536
const available = input.sync.data.provider.some(
514537
(provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]),
515538
)

‎packages/tui/src/context/local.tsx‎

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -365,14 +365,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
365365
return !!provider?.models[model.modelID]
366366
}
367367

368-
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
369-
for (const modelFn of modelFns) {
370-
const model = modelFn()
371-
if (!model) continue
372-
if (isModelValid(model)) return model
373-
}
374-
}
375-
376368
function createAgent() {
377369
const agents = createMemo(() => sync.data.agent.filter((agent) => agent.mode !== "subagent" && !agent.hidden))
378370
const visibleAgents = createMemo(() => sync.data.agent.filter((agent) => !agent.hidden))
@@ -598,26 +590,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
598590
}
599591
// altimate_change end
600592

601-
const fallbackModel = createMemo(() => {
593+
// altimate_change start — Codex review finding: `currentModel()` below used to apply the
594+
// stale-Zen -> Base substitution uniformly to whatever `fallbackModel()` returned, but
595+
// `fallbackModel()` returns an EXPLICIT `--model`/config `model` pick verbatim when either is
596+
// set (below) — so an explicit ask for the now-broken public Zen tier was silently rewritten
597+
// to Base instead of surfacing as broken. Mirrors just those two explicit checks (not
598+
// `fallbackModel()`'s recents/allowlist implicit tail) so `currentModel()` can tell them apart
599+
// without duplicating `fallbackModel()`'s own logic inline.
600+
const explicitFallbackModel = createMemo(() => {
602601
if (args.model) {
603602
const { providerID, modelID } = parseModel(args.model)
604-
if (isModelValid({ providerID, modelID })) {
605-
return {
606-
providerID,
607-
modelID,
608-
}
609-
}
603+
if (isModelValid({ providerID, modelID })) return { providerID, modelID }
610604
}
611-
612605
if (sync.data.config.model) {
613606
const { providerID, modelID } = parseModel(sync.data.config.model)
614-
if (isModelValid({ providerID, modelID })) {
615-
return {
616-
providerID,
617-
modelID,
618-
}
619-
}
607+
if (isModelValid({ providerID, modelID })) return { providerID, modelID }
620608
}
609+
return undefined
610+
})
611+
// altimate_change end
612+
613+
const fallbackModel = createMemo(() => {
614+
const explicit = explicitFallbackModel() // altimate_change — see its declaration above
615+
if (explicit) return explicit
621616

622617
// altimate_change start — Base is excluded only by an actual enabled_providers/disabled_providers
623618
// verdict, which `sync.data.provider` (server-built) already reflects. The mere presence of
@@ -678,21 +673,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
678673
// be a stale keyless public-Zen pick; replace it with registered Base the same way
679674
// `fallbackModel()`'s recents loop does, rather than replaying a model OpenCode Zen now
680675
// rejects outright. A credentialed/paid selection is untouched.
676+
//
677+
// Codex review finding: that substitution must only ever reach an IMPLICIT pick — the
678+
// persisted per-agent selection below, or `fallbackModel()`'s own implicit recents/allowlist
679+
// tail — never something explicitly asked for: an agent's own configured `model` field, or
680+
// `--model`/config `model` (see `explicitFallbackModel` above `fallbackModel()`). Each
681+
// candidate is checked in the SAME priority order this memo used before; only the two
682+
// implicit branches route through `substituteStaleZen`.
683+
function substituteStaleZen(model: { providerID: string; modelID: string } | undefined) {
684+
if (!model) return model
685+
const provider = sync.data.provider.find((candidate) => candidate.id === model.providerID)
686+
if (provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL)) {
687+
return { ...ALTIMATE_BASE_MODEL }
688+
}
689+
return model
690+
}
691+
681692
const currentModel = createMemo(() => {
682693
const a = agent.current()
683-
const resolved =
684-
getFirstValidModel(
685-
() => a && modelStore.model[a.name],
686-
() => a && a.model,
687-
fallbackModel,
688-
) ?? undefined
689-
if (resolved) {
690-
const provider = sync.data.provider.find((candidate) => candidate.id === resolved.providerID)
691-
if (provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL)) {
692-
return { ...ALTIMATE_BASE_MODEL }
693-
}
694-
}
695-
return resolved
694+
695+
const persistedAgentPick = a ? modelStore.model[a.name] : undefined
696+
if (persistedAgentPick && isModelValid(persistedAgentPick)) return substituteStaleZen(persistedAgentPick)
697+
698+
const agentConfiguredModel = a?.model
699+
if (agentConfiguredModel && isModelValid(agentConfiguredModel)) return agentConfiguredModel
700+
701+
const explicit = explicitFallbackModel()
702+
if (explicit) return explicit
703+
704+
return substituteStaleZen(fallbackModel())
696705
})
697706
// altimate_change end
698707

‎packages/tui/src/context/sdk.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
208208
event: emitter,
209209
fetch: props.fetch ?? fetch,
210210
url: props.url,
211+
// altimate_change — the auth headers `createOpencodeClient` above bakes into every typed SDK
212+
// call (Basic auth for an attached, password-protected server — see cli/cmd/attach.ts). A raw
213+
// `sdk.fetch` call bypasses the client entirely, so a caller hitting an untyped route
214+
// directly (component/altimate-onboarding.tsx's `registerAltimateBase` HTTP fallback) needs
215+
// these to attach them itself, or it 401s against a password-protected server.
216+
headers: props.headers,
211217
registerAltimateBase: props.registerAltimateBase, // altimate_change — see the declaration above
212218
}
213219
},

‎packages/tui/test/component/select-altimate-base.test.ts‎

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,25 @@
55
// this same file), so testing it here covers every call site's outcome without needing an `agent`
66
// in the render harness (selectModel()'s underlying local.model.set() is agent-scoped, which the
77
// component-mount fixtures used elsewhere in this package don't set up).
8-
import { describe, expect, test } from "bun:test"
8+
import { afterEach, describe, expect, test } from "bun:test"
99
import type { useSDK } from "../../src/context/sdk"
1010
import type { useSync } from "../../src/context/sync"
1111
import type { useLocal } from "../../src/context/local"
1212
import type { useToast } from "../../src/ui/toast"
1313
import type { useDialog } from "../../src/ui/dialog"
14-
import { selectAltimateBase } from "../../src/component/altimate-onboarding"
14+
import { selectAltimateBase, resetSetupComplete } from "../../src/component/altimate-onboarding"
15+
16+
// altimate_change start — Codex/CodeRabbit review finding: `selectAltimateBase()` calls
17+
// `markSetupComplete()` on every successful path, which flips the module-global `setupComplete`
18+
// solid-js signal declared in altimate-onboarding.tsx. That signal is shared with
19+
// `test/context/local.test.ts` (imported there too, and reset around every `markSetupComplete()`
20+
// call it makes) and read by every other suite through `useReady()`/`useSetupComplete()` — Bun's
21+
// test runner shares one module registry across every test file in the run, so a test here left
22+
// it `true` for whichever suite runs next. Restoring it here mirrors local.test.ts's own pattern.
23+
afterEach(() => {
24+
resetSetupComplete()
25+
})
26+
// altimate_change end
1527

1628
function fakeCollaborators(options: {
1729
registerAltimateBase?: () => Promise<{ ok: true } | { ok: false; result: "network" | "error"; message: string }>
@@ -20,6 +32,9 @@ function fakeCollaborators(options: {
2032
* "the credential was minted, but the catalogue hasn't caught up yet" edge case sets this to
2133
* false. */
2234
becomesAvailableAfterBootstrap?: boolean
35+
// altimate_change — the Basic-auth headers an attached, password-protected server needs (see
36+
// cli/cmd/attach.ts + context/sdk.tsx's `headers`).
37+
headers?: RequestInit["headers"]
2338
}) {
2439
const modelSetCalls: unknown[] = []
2540
let dialogClearCount = 0
@@ -30,6 +45,14 @@ function fakeCollaborators(options: {
3045
const becomesAvailable = options.becomesAvailableAfterBootstrap ?? true
3146
const providerState: { id: string; models: Record<string, unknown> }[] = []
3247

48+
// altimate_change start — Codex review finding: `selectAltimateBase()`'s liveness guard compares
49+
// `dialog.stack.at(-1)` by reference before and after each await. The fake stack starts as a
50+
// single "the originating picker" item; `simulateDismiss()`/`simulateReplace()` let a test swap
51+
// it out mid-flight, exactly as a real Escape/backdrop dismissal or an unrelated feature taking
52+
// over the dialog stack would.
53+
let stack: { readonly id: string }[] = [{ id: "originating-picker" }]
54+
// altimate_change end
55+
3356
const sdk = {
3457
client: {
3558
instance: {
@@ -40,6 +63,7 @@ function fakeCollaborators(options: {
4063
},
4164
fetch: options.fetchImpl ?? (async () => new Response("should not be called", { status: 500 })),
4265
url: "http://test",
66+
headers: options.headers, // altimate_change — see the option's declaration above
4367
registerAltimateBase: options.registerAltimateBase,
4468
} as unknown as ReturnType<typeof useSDK>
4569

@@ -70,11 +94,17 @@ function fakeCollaborators(options: {
7094
const dialog = {
7195
clear: () => {
7296
dialogClearCount++
97+
stack = [] // altimate_change — mirrors the real clearAll()'s empty stack
7398
},
7499
replace: (..._args: unknown[]) => {
75100
dialogReplaceCount++
101+
stack = [{ id: "replaced" }] // altimate_change — mirrors the real replace()'s new sole item
76102
return true
77103
},
104+
get stack() {
105+
// altimate_change — see `stack`'s declaration above
106+
return stack
107+
},
78108
} as unknown as ReturnType<typeof useDialog>
79109

80110
return {
@@ -97,6 +127,14 @@ function fakeCollaborators(options: {
97127
get dialogReplaceCount() {
98128
return dialogReplaceCount
99129
},
130+
// altimate_change start — see `stack`'s declaration above
131+
simulateDismiss() {
132+
stack = []
133+
},
134+
simulateReplace() {
135+
stack = [{ id: "something-else" }]
136+
},
137+
// altimate_change end
100138
}
101139
}
102140

@@ -136,6 +174,29 @@ describe("selectAltimateBase", () => {
136174
expect(fakes.dialogReplaceCount).toBe(0)
137175
})
138176

177+
// altimate_change start — Codex review finding: the HTTP fallback used a bare `sdk.fetch` call
178+
// with no auth headers, so it 401ed against an attached, password-protected server (`opencode
179+
// attach` — cli/cmd/attach.ts) even though `sdk.headers` (the same Basic-auth headers
180+
// `createOpencodeClient` bakes into every typed SDK call) was one merge away.
181+
test("an attached TUI's HTTP fallback sends the SDK's auth headers", async () => {
182+
let seenHeaders: Headers | undefined
183+
const fakes = fakeCollaborators({
184+
registerAltimateBase: undefined,
185+
headers: { Authorization: "Basic dGVzdDpwYXNz" },
186+
fetchImpl: (async (_input: RequestInfo | URL, init?: RequestInit) => {
187+
seenHeaders = new Headers(init?.headers)
188+
return Response.json({ ok: true })
189+
}) as typeof fetch,
190+
})
191+
192+
const result = await selectAltimateBase(fakes)
193+
194+
expect(result).toBe(true)
195+
expect(seenHeaders?.get("Authorization")).toBe("Basic dGVzdDpwYXNz")
196+
expect(seenHeaders?.get("Content-Type")).toBe("application/json")
197+
})
198+
// altimate_change end
199+
139200
test("a registration failure shows the toast and leaves the model unchanged", async () => {
140201
const fakes = fakeCollaborators({
141202
registerAltimateBase: async () => ({ ok: false, result: "network", message: "offline" }),
@@ -169,5 +230,50 @@ describe("selectAltimateBase", () => {
169230
expect(fakes.modelSetCalls).toHaveLength(0)
170231
expect(fakes.dialogClearCount).toBe(0)
171232
})
233+
234+
// altimate_change start — Codex review finding: registration and bootstrap are both async;
235+
// if the originating picker went away (dismissed, or replaced by something else) while either
236+
// was in flight, `selectAltimateBase()` must not select the model or call `dialog.clear()` —
237+
// that `clear()` would close whatever the user has open NOW, not the picker that started this.
238+
test("does not select the model or clear the dialog if the picker was dismissed while registration was in flight", async () => {
239+
const fakes = fakeCollaborators({
240+
registerAltimateBase: async () => {
241+
// The user pressed Escape while this await was pending.
242+
fakes.simulateDismiss()
243+
return { ok: true }
244+
},
245+
})
246+
247+
const result = await selectAltimateBase(fakes)
248+
249+
expect(result).toBe(false)
250+
expect(fakes.modelSetCalls).toHaveLength(0)
251+
expect(fakes.dialogClearCount).toBe(0)
252+
expect(fakes.disposed).toBe(false)
253+
expect(fakes.bootstrapped).toBe(false)
254+
expect(fakes.toastCalls).toHaveLength(0)
255+
})
256+
257+
test("does not select the model or clear the dialog if something else replaced the dialog during bootstrap", async () => {
258+
const fakes = fakeCollaborators({
259+
registerAltimateBase: async () => ({ ok: true }),
260+
})
261+
const realBootstrap = fakes.sync.bootstrap
262+
fakes.sync.bootstrap = async () => {
263+
const result = await realBootstrap()
264+
// A different feature (e.g. the command palette) took over the dialog stack while this
265+
// await was pending.
266+
fakes.simulateReplace()
267+
return result
268+
}
269+
270+
const result = await selectAltimateBase(fakes)
271+
272+
expect(result).toBe(false)
273+
expect(fakes.modelSetCalls).toHaveLength(0)
274+
expect(fakes.dialogClearCount).toBe(0)
275+
expect(fakes.toastCalls).toHaveLength(0)
276+
})
277+
// altimate_change end
172278
})
173279
// altimate_change end

0 commit comments

Comments
 (0)