Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi.
- OpenCode 2 now shows the CLAUDE quota sidebar too, without opening `/claude`. It is the same panel as OpenCode 1 (per-account 5h, 7d and model bars with pacing, collapsed rows, routing, cache, Prime and health), drawn from one shared component in the active OpenCode 2 theme's colors and configured by the same `anthropic-auth` entry in `tui-preferences.jsonc`. The OpenCode 2 server answers it through the native RPC (`sidebar` method) from the account pool, with no file and no credential; the active account is the one that last served the sidebar's session. Turned-off vault accounts stay listed, as on OpenCode 1, and reset countdowns, reading ages and pacing keep advancing between server changes. A TUI talking to an older server shows "Waiting for quota…".
- Before an account has served the session, the collapsed sidebar no longer marks `main` as active, matching the expanded view.

## 2.1.2
### Accounts

- Requests no longer wait on relay or session-pin bookkeeping locks; relay settings and tokens are checked together before sending.
- Valid tokens no longer wait for proactive refreshes; requests waiting for a rejected-token refresh fail retryably after two seconds while the refresh continues.

### Dependencies

- OpenTUI core and solid move to 0.6.6 together and the OpenCode 2 plugin SDK packages to 2.0.32, so each installs once. solid-js stays at 1.9.12, the exact version OpenTUI Solid 0.6.6 requires.

## 2.1.2

- Requests no longer wait on relay or session-pin bookkeeping locks; relay settings and tokens are checked together before sending.

## 2.1.1

### Packaging
Expand Down
34 changes: 34 additions & 0 deletions mutations.toml
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,40 @@ broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core
broad_report = "tmp/mutations/core.xml"
broad_id = "{name}"

[[control]]
id = "refresh-join-checks-cause"
guards = "A proactive refresh must not delay a proven unexpired access token."
file = "packages/core/src/native-refresh-coordinator.ts"
old = "if (current.status !== 'usable' || !jobRejectsAccess(job, row))"
new = "if (current.status !== 'usable' || !job) // NON-VACUITY BREAK: join every existing refresh"
test_file = "packages/opencode/src/tests/native-serving.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/native-serving.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["OC1 refresh join proactive preserves valid-token admission"]
expect_message = "error: expect(received).toBe(expected)"
broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"]
broad_report = "tmp/mutations/opencode.xml"
broad_id = "{name}"
junit_class = "suites"

[[control]]
id = "refresh-join-has-overall-deadline"
guards = "A refresh replacing a rejected token has one bounded request wait across all attempts."
file = "packages/core/src/native-refresh-coordinator.ts"
old = "? joinRejectedRefresh(job)"
new = "? job.promise // NON-VACUITY BREAK: wait without an overall deadline"
test_file = "packages/opencode/src/tests/native-serving.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/native-serving.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["OC1 refresh join deadline preserves valid-token admission"]
expect_message = "error: expect(received).toMatchObject(expected)"
broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/opencode", "tmp/mutations/opencode.xml"]
broad_report = "tmp/mutations/opencode.xml"
broad_id = "{name}"
junit_class = "suites"

[[control]]
id = "native-activation-before-401-refresh"
guards = "A revoked host activation must not trigger a credential refresh."
Expand Down
103 changes: 93 additions & 10 deletions packages/core/src/native-refresh-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
nativeLocalCredentialValidationMatches,
} from './native-credential-validation.ts'
import { nativeLockObserver } from './native-lock-observer.ts'
import { NativeTransientAccountError } from './native-transient-account.ts'
import { isTransientNetworkError } from './network-errors.ts'
import {
isNativeLocalPoolBinding,
Expand All @@ -49,6 +50,10 @@ import { tokenFingerprint } from './token-fingerprint.ts'

export const NATIVE_REFRESH_MAX_ATTEMPTS = 3

// Limit a caller's rejected-token refresh wait to two seconds across all attempts.
// The shared refresh may continue, just like an account write after a join expires.
const NATIVE_REFRESH_JOIN_BUDGET_MS = 2_000

/**
* Jobs distinguish credential replacements. This lock must still serialize
* all refreshes for the same account.
Expand Down Expand Up @@ -302,7 +307,7 @@ export interface NativeRefreshRequest {
intent: 'serve' | 'refresh'
mode: 'local' | 'claustrum'
binding: NativeLocalPoolBinding
/** A 401's dispatched access token, used only for replacement adoption. */
/** The access token dispatched on a 401; it must not be served again. */
rejectedAccessToken?: string
signal?: AbortSignal
}
Expand Down Expand Up @@ -348,12 +353,43 @@ type JobResult =
interface Job {
keys: Set<string>
promise: Promise<JobResult>
cause: { kind: 'proactive' } | { kind: '401'; accessFingerprint: string }
additionalRejections: Set<string>
}

// Shared by every platform/coordinator instance in this process. Deletion is
// compare-and-delete: an old job can never dispose a successor registration.
const jobs = new Map<string, Job>()

function jobRejectsAccess(job: Job | undefined, row: PoolRow | undefined) {
if (!job || row?.credential?.type !== 'oauth' || !row.credential.access)
return false
const fingerprint = tokenFingerprint(row.credential.access)
return (
(job.cause.kind === '401' && job.cause.accessFingerprint === fingerprint) ||
job.additionalRejections.has(fingerprint)
)
}

async function joinRejectedRefresh(job: Job): Promise<JobResult> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
// Only the caller's wait ends. The shared exchange retains its leases and
// must still save any consumed single-use refresh token's successor.
return await Promise.race([
job.promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new NativeTransientAccountError()),
NATIVE_REFRESH_JOIN_BUDGET_MS,
)
}),
])
} finally {
clearTimeout(timer)
}
}

function unregister(job: Job, key: string) {
if (jobs.get(key) === job) jobs.delete(key)
job.keys.delete(key)
Expand Down Expand Up @@ -594,8 +630,6 @@ export function createNativeRefreshCoordinator(
credential.expires <= now()
)
return refuse('access-unavailable')
if (credential.access === request.rejectedAccessToken)
return refuse('rejected-access-unchanged')
const subject: NativeKnownCredentialSubject = copySubject({
binding: { ...expected, identity: expected.identity },
credentialFingerprint: fingerprintOf(credential),
Expand All @@ -611,7 +645,18 @@ export function createNativeRefreshCoordinator(
} catch (cause) {
throw new Error('Native admission callback failed', { cause })
}
if (admission.status === 'blocked') return refuse(admission.reason)
// A validation retry delays another identity lookup, not replacement of an
// access token the caller already received a 401 for. Other exclusions still win.
if (
admission.status === 'blocked' &&
!(
admission.reason === 'validation-backoff' &&
credential.access === request.rejectedAccessToken
)
)
return refuse(admission.reason)
if (credential.access === request.rejectedAccessToken)
return refuse('rejected-access-unchanged')
if (admission.status !== 'proven') return refuse('proof-missing')
if (
!nativeLocalCredentialValidationMatches(
Expand Down Expand Up @@ -1282,12 +1327,26 @@ export function createNativeRefreshCoordinator(
return { status: 'refused', reason: 'cancelled', persisted: false }
const key = nativeLocalRefreshJobKey(request.binding)
let job = jobs.get(key)
if (!job && request.intent === 'serve') {
const recordRejection = () => {
// A 401 can arrive after proactive work started. Record its rejected token
// so no caller serves it again, without changing the job's original cause.
if (job && request.rejectedAccessToken)
job.additionalRejections.add(
tokenFingerprint(request.rejectedAccessToken),
)
}
recordRejection()
if (request.intent === 'serve') {
try {
const row = await rowFor(request.binding)
job = jobs.get(key) ?? job
recordRejection()
// A pending recovery cannot override custody, quota or refresh-error
// exclusions. Check admission before deciding whether a token must wait.
const current = await admit(
request,
request.binding,
await rowFor(request.binding),
row,
'current',
false,
)
Expand All @@ -1299,8 +1358,14 @@ export function createNativeRefreshCoordinator(
'access-unavailable',
'rejected-access-unchanged',
].includes(current.reason)
)
return current
) {
// Admission yields to other callers. A 401 recorded during that
// read must prevent dispatch of the token it rejected.
job = jobs.get(key) ?? job
recordRejection()
if (current.status !== 'usable' || !jobRejectsAccess(job, row))
return current
}
} catch (caught) {
return {
status: 'failed',
Expand All @@ -1313,10 +1378,18 @@ export function createNativeRefreshCoordinator(
// recovery for the same storage, credential epoch and account UUID (or
// unknown-identity row). Check for that job before starting duplicate work.
job = jobs.get(key)
recordRejection()
}
if (!job) {
job = {
keys: new Set([key]),
cause: request.rejectedAccessToken
? {
kind: '401',
accessFingerprint: tokenFingerprint(request.rejectedAccessToken),
}
: { kind: 'proactive' },
additionalRejections: new Set(),
promise: Promise.resolve({
status: 'refused',
reason: 'initializing',
Expand All @@ -1329,7 +1402,10 @@ export function createNativeRefreshCoordinator(
for (const ownedKey of owner.keys) unregister(owner, ownedKey)
})
}
const result = await job.promise
const result = await (job.cause.kind === '401' ||
job.additionalRejections.size > 0
? joinRejectedRefresh(job)
: job.promise)
if (request.signal?.aborted)
return {
status: 'refused',
Expand All @@ -1346,13 +1422,20 @@ export function createNativeRefreshCoordinator(
}
: request.binding
const row = await rowFor(request.binding)
return await admit(
const current = await admit(
request,
expected,
row,
result.source,
result.persisted,
)
if (current.status === 'usable' && jobRejectsAccess(job, row))
return {
status: 'refused',
reason: 'rejected-access-unchanged',
persisted: result.persisted,
}
return current
} catch (error) {
return {
status: 'failed',
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/native-transient-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ const TRANSIENT_ACCOUNT_MESSAGE =
const transientAccountErrors = new WeakSet<object>()

/**
* A local account could not be authorized because another writer was part
* way through saving it (for example the refresh that records its first
* account UUID) and did not finish within the short wait Core allows.
* A local account could not be authorized because the caller's short wait for
* an account write or a refresh replacing a provider-rejected token expired.
* The shared write or refresh may still finish after the caller stops waiting.
*
* The request should be retried. `code: 'ECONNRESET'` is how this package
* marks an error retryable for its hosts. A host must surface it rather than
Expand Down
Loading
Loading