Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
fd64962
feat(custody): vendor claustrum client and add custody policy core
iceteaSA Sep 3, 2026
37d0139
fix(custody): assert the manifest floor and scrub provenance comments
iceteaSA Sep 3, 2026
efe22e4
feat(custody): gate every local refresh on manifest entry or tombstone
iceteaSA Sep 3, 2026
957b413
fix(custody): keep enrolling accounts routable; prove backoff key and…
iceteaSA Sep 3, 2026
205d9f1
refactor(custody): behavioural test names, shared fixtures, one predi…
iceteaSA Sep 3, 2026
70bab02
feat(custody): plugin-wide custody config and sidebar custody projection
iceteaSA Sep 3, 2026
25c0493
feat(custody): quota poll never locally refreshes a refresh-inert fal…
iceteaSA Sep 3, 2026
6c973e4
fix(custody): quota poll fails closed on partial custody wiring
iceteaSA Sep 3, 2026
6d8f774
feat(custody): loader lifecycle, vault tick, and enroll-completion sweep
iceteaSA Sep 3, 2026
4aa0840
fix(custody): latch tests, sweep-failure log, served record version i…
iceteaSA Sep 3, 2026
29c6501
feat(custody): resolve every fallback bearer through the vault resolver
iceteaSA Sep 3, 2026
6887727
test(custody): loader-path coverage for refused traversal, disabled e…
iceteaSA Sep 3, 2026
85a4070
refactor(custody): one sidebar projection, one manifest walk in the l…
iceteaSA Sep 3, 2026
3b9dad4
test(custody): cachekeep replay resolves through custody; one manifes…
iceteaSA Sep 3, 2026
3da8f6a
fix(custody): boot completes the enrollment sweep before the backgrou…
iceteaSA Sep 3, 2026
9f9dd81
fix(custody): vault verdicts exclude serving; report bound resets on …
iceteaSA Sep 3, 2026
eaae64f
fix(custody): expired reauth restarts the report bound
iceteaSA Sep 3, 2026
0f86f93
fix(custody): cachekeep replay and reset preview report vault 401s
iceteaSA Sep 3, 2026
5c1e0a8
refactor(custody): move the custody runtime out of the loader; bounda…
iceteaSA Sep 3, 2026
da5e6e5
test(custody): strengthen boot-order and reporter-throw tests; restor…
iceteaSA Sep 3, 2026
fef3663
fix(custody): arm enrollment completion behind claustrum.manifestWrite
iceteaSA Sep 4, 2026
77e300d
docs(custody): date the claustrum vendor review
iceteaSA Sep 4, 2026
a9b7262
test(custody): pin default-disarmed completion and the manifest-remov…
iceteaSA Sep 4, 2026
676aedf
docs(sidebar): point the custody reason comment at the union instead …
iceteaSA Sep 4, 2026
0fdb8ca
docs(custody): define manifestWrite by the property it gates
iceteaSA Sep 4, 2026
e974206
feat(custody): persist claustrum mode and transition fences
iceteaSA Sep 5, 2026
3f262cd
test(custody): migrate v6 claustrum seeds to the v7 mode shape
iceteaSA Sep 5, 2026
ddbed1d
test(custody): sidebar parser round-trip tests follow the v7 mode con…
iceteaSA Sep 5, 2026
896ec53
fix(custody): derived manifestWrite stays disarmed under claustrum mode
iceteaSA Sep 5, 2026
2a78583
refactor(custody): harden persisted mode fences
iceteaSA Sep 5, 2026
e1c8c46
feat(custody): enforce v7 tombstone containment
iceteaSA Sep 5, 2026
9d205a2
test(custody): pin tombstone containment
iceteaSA Sep 5, 2026
1439dfb
refactor(custody): drift pass A — one import idiom, one fixture, v7 v…
iceteaSA Sep 5, 2026
dcdc1eb
feat(custody): pure §16 verdict engine and v7 sidebar projection
iceteaSA Sep 5, 2026
ca791ac
test(custody): pin the §16 coordinate table and sidebar projection
iceteaSA Sep 5, 2026
6197d53
feat(custody): classify the main host slot before hooks
iceteaSA Sep 5, 2026
894f7ad
fix(custody): keep the main slot identity and refresh inert under a r…
iceteaSA Sep 5, 2026
779b0e0
feat(custody): add fenced readiness barrier
iceteaSA Sep 5, 2026
4f605dc
fix(custody): gate resume on persisted fingerprints
iceteaSA Sep 5, 2026
386da93
test(custody): prove store lock artifacts
iceteaSA Sep 5, 2026
d4c5738
refactor(custody): drift pass B consistency
iceteaSA Sep 5, 2026
d7c8e76
feat(custody): guard mode account operations
iceteaSA Sep 5, 2026
d5bea99
fix(custody): refresh account dialog mode
iceteaSA Sep 5, 2026
3402c60
test(custody): cover production transition paths
iceteaSA Sep 5, 2026
1544dd0
test(custody): isolate authorize flow dependencies
iceteaSA Sep 5, 2026
7678fdc
test(custody): inject the OAuth seam instead of mocking the module
iceteaSA Sep 5, 2026
c824b12
test(custody): cover served binding identity
iceteaSA Sep 5, 2026
01a5602
test(custody): preserve Claustrum command verbs
iceteaSA Sep 5, 2026
c453480
test(custody): cover authorize lease deadlines
iceteaSA Sep 5, 2026
ad3d951
test(custody): cover CLI transition exclusion
iceteaSA Sep 5, 2026
5e2b697
test(custody): pin authorize callback lease
iceteaSA Sep 5, 2026
1cdce64
feat(custody): record verified in-process logins
iceteaSA Sep 5, 2026
7f4cdcd
test(custody): cover verified login bindings
iceteaSA Sep 5, 2026
312287e
fix(custody): read the mode after the barrier; require the account lo…
iceteaSA Sep 5, 2026
64842d2
fix(custody): gate the local re-login row on the verified in-process …
iceteaSA Sep 5, 2026
68a1a61
fix(custody): gate bound real rows by claustrum mode
iceteaSA Sep 5, 2026
4426ec7
feat(custody): retain corrupt OAuth fallback rows
iceteaSA Sep 5, 2026
cbe2be4
feat(custody): install fallback tombstones during reconcile
iceteaSA Sep 5, 2026
45ad9b0
feat(custody): resume fingerprinted custody transitions
iceteaSA Sep 5, 2026
219724a
fix(custody): persist the corrupt marker across saves
iceteaSA Sep 5, 2026
a85e525
test(custody): pin locked install revalidation
iceteaSA Sep 5, 2026
20f75aa
feat(custody): retain removed rows in custody history
iceteaSA Sep 5, 2026
8842aa9
feat(custody): discover orphan bindings before reconciliation
iceteaSA Sep 5, 2026
eb68815
feat(custody): bind pending rows under the shared lock
iceteaSA Sep 5, 2026
cc9fb70
feat(custody): reevaluate changed manifest revisions
iceteaSA Sep 5, 2026
ad6a5d5
refactor(custody): remove legacy completion surfaces
iceteaSA Sep 5, 2026
e4c0334
fix(custody): gate discovery on manifest revision; contend the real l…
iceteaSA Sep 5, 2026
38fefe0
docs(custody): add the standalone custody state machine
iceteaSA Sep 5, 2026
e5a6274
fix(custody): one canonical tombstone row
iceteaSA Sep 5, 2026
9fbf02d
refactor(custody): drop the unused enrolled-under-local reason
iceteaSA Sep 5, 2026
200211c
test(custody): share the JWT fixture; no real timers in runtime tests
iceteaSA Sep 5, 2026
f653118
docs(custody): state-machine wording
iceteaSA Sep 5, 2026
60208a9
test(custody): type the tombstone fixture write
iceteaSA Sep 5, 2026
e5dc93b
fix(custody): share enrollment tombstone shape
iceteaSA Sep 5, 2026
e47fbb8
fix(custody): reconnect to the vault on tick
iceteaSA Sep 5, 2026
02a39a2
fix(custody): release the transition mutex before observers run
iceteaSA Sep 5, 2026
f01eea6
fix(custody): prefer needs_reauth over vault-cold
iceteaSA Sep 5, 2026
0060027
fix(logger): redact manifest handles
iceteaSA Sep 5, 2026
3ffae77
test(custody): make active fallback routing tests self-seeding
iceteaSA Sep 11, 2026
f12b764
fix(custody): scope manifest credential ids to their provider
iceteaSA Sep 16, 2026
2835bac
fix(custody): require an absolute handles path from the environment
iceteaSA Sep 16, 2026
75237c9
fix(custody): discard credential writes over a tombstone
iceteaSA Sep 16, 2026
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
2 changes: 2 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"includes": [
"packages/*/src/**",
"!packages/opencode/src/tui-compiled",
"!packages/opencode/src/vendor",
"!packages/opencode/src/tests/fixtures/claustrum-golden",
"packages/*/*.json",
"scripts/**",
"*.json",
Expand Down
7 changes: 5 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

249 changes: 249 additions & 0 deletions packages/opencode/docs/custody-state-machine.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"src/tui",
"src/tui-compiled",
"src/sidebar-state.ts",
"src/core/custody-state.ts",
"src/tui-preferences.ts",
"src/logger.ts",
"src/core/account-paths.ts",
Expand All @@ -56,12 +57,14 @@
"test": "bun test src/tests",
"typecheck": "tsc",
"types": "tsc",
"check:claustrum-golden": "bun scripts/check-claustrum-golden.ts",
"prepublishOnly": "bun run build"
},
"peerDependencies": {
"@opencode-ai/plugin": "*"
},
"dependencies": {
"@cortexkit/subc-client": "^0.8.1",
"@opentui/core": ">=0.5.11",
"@opentui/solid": ">=0.5.11",
"ai": "^7.0.99",
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/scripts/build-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const shippedSourceFiles = [
'tui/command-dialogs.tsx',
'sidebar-state.ts',
'core/account-paths.ts',
'core/custody-state.ts',
'core/refresh-file-lock.ts',
'tui-preferences.ts',
'logger.ts',
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/scripts/check-claustrum-golden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Golden check for the Claustrum handle/tombstone fixtures.
*
* The script compares the local fixtures under
* `packages/opencode/src/tests/fixtures/claustrum-golden/` against the bytes
* published at the upstream commit recorded in `SOURCE.json`. Drift is
* reported on stderr and exits non-zero so CI can fail the gate.
*
* Run via `bun run check:claustrum-golden` (see package.json scripts).
*/

import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

const fixtureDir = join(
import.meta.dir,
'..',
'src',
'tests',
'fixtures',
'claustrum-golden',
)
const source = JSON.parse(
await readFile(join(fixtureDir, 'SOURCE.json'), 'utf8'),
) as {
repo: string
ref: string
paths: Record<string, string>
}

function rejectSource(reason: string): never {
console.error(`INVALID SOURCE.json: ${reason}`)
process.exit(1)
}

if (!source.repo) rejectSource('repo is missing')
if (!source.ref) rejectSource('ref is missing')
if (!/^[0-9a-f]{40}$/.test(source.ref)) {
rejectSource('ref must be a 40-hex SHA')
}
const paths = Object.entries(source.paths ?? {})
if (paths.length === 0) rejectSource('paths must contain at least one entry')

for (const [name] of paths) {
try {
await readFile(join(fixtureDir, `${name}.json`))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
rejectSource(`vendored file is missing: ${name}.json`)
}
throw error
}
}

let drifted = false
for (const [name, sourcePath] of paths) {
const url = `https://raw.githubusercontent.com/${source.repo}/${source.ref}/${sourcePath}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${name} golden: ${response.status} ${url}`)
}
const remote = Buffer.from(await response.arrayBuffer())
const local = await readFile(join(fixtureDir, `${name}.json`))
if (Buffer.compare(remote, local) !== 0) {
console.error(`DRIFT: ${name}.json differs from ${url}`)
drifted = true
continue
}
console.log(`${name}.json: IDENTICAL (${source.ref})`)
}

if (drifted) process.exitCode = 1
148 changes: 100 additions & 48 deletions packages/opencode/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#!/usr/bin/env node

import {
claustrumMode,
FALLBACK_REFRESH_LOCK_TTL_MS,
fallbackRefreshLockName,
getAccountStoragePath,
loadAccounts,
mutateAccounts,
Expand All @@ -12,12 +15,24 @@ import {
beginAccountLogin,
upsertAccount,
} from './core/oauth'
import { acquireRefreshFileLock } from './core/refresh-file-lock'
import { openUrl } from './util/open-url'

export { openUrl as openBrowserForLogin } from './util/open-url'

function usage() {
console.log(`Usage:
type CliDeps = Partial<{
beginAccountLogin: typeof beginAccountLogin
openUrl: typeof openUrl
getAccountStoragePath: typeof getAccountStoragePath
loadAccounts: typeof loadAccounts
mutateAccounts: typeof mutateAccounts
acquireRefreshFileLock: typeof acquireRefreshFileLock
log: typeof console.log
error: typeof console.error
}>

function usage(log: typeof console.log = console.log) {
log(`Usage:
npx @cortexkit/opencode-openai-auth login [--label <name>] [--headless]
npx @cortexkit/opencode-openai-auth list
npx @cortexkit/opencode-openai-auth remove <id>
Expand Down Expand Up @@ -48,13 +63,24 @@ function parseArgs(argv: string[]) {
return { positional, flags }
}

async function main() {
const { positional, flags } = parseArgs(process.argv.slice(2))
export async function runCli(
argv = process.argv.slice(2),
deps: CliDeps = {},
): Promise<number> {
const login = deps.beginAccountLogin ?? beginAccountLogin
const open = deps.openUrl ?? openUrl
const storagePath = deps.getAccountStoragePath ?? getAccountStoragePath
const load = deps.loadAccounts ?? loadAccounts
const mutate = deps.mutateAccounts ?? mutateAccounts
const acquireLock = deps.acquireRefreshFileLock ?? acquireRefreshFileLock
const log = deps.log ?? console.log
const writeError = deps.error ?? console.error
const { positional, flags } = parseArgs(argv)
const [command, ...rest] = positional

if (!command || command === 'help') {
usage()
process.exit(0)
usage(log)
return 0
}

switch (command) {
Expand All @@ -65,81 +91,103 @@ async function main() {
try {
assertFallbackAccountIdAllowed(label)
} catch (error) {
console.error(
writeError(
`\nError: ${error instanceof Error ? error.message : String(error)}`,
)
process.exit(1)
return 1
}

const { url, instructions, completion } = await beginAccountLogin({
const { url, instructions, completion } = await login({
label,
headless,
})

console.log('\nOpen this URL in your browser and complete sign-in:\n')
console.log(`${url}\n`)
if (instructions) console.log(`${instructions}\n`)
log('\nOpen this URL in your browser and complete sign-in:\n')
log(`${url}\n`)
if (instructions) log(`${instructions}\n`)

openUrl(url)
open(url)

const account = await completion

// Read-modify-write under the store lock so a concurrent add/remove
// (another CLI invocation or a TUI command) cannot clobber this insertion,
// and the self-fallback check sees the freshest mainAccountId.
const configPath = storagePath()
const lock = await acquireLock({
name: fallbackRefreshLockName(account.id),
ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
path: configPath,
renew: true,
})
if (!lock) throw new Error('Fallback account lock unavailable')
let selfFallback = false
await mutateAccounts((current) => {
// Reject self-fallback: adding main's ChatGPT account as a fallback
// would let routing retry on the account that just returned 429.
if (
account.accountId &&
current.mainAccountId &&
account.accountId === current.mainAccountId
) {
selfFallback = true
return current
let blockedByClaustrum = false
try {
if (claustrumMode((await load(configPath)) ?? {}) === 'claustrum') {
blockedByClaustrum = true
} else {
await mutate((current) => {
if (
account.accountId &&
current.mainAccountId &&
account.accountId === current.mainAccountId
) {
selfFallback = true
return current
}
upsertAccount(current.accounts, account as unknown as OAuthAccount)
return current
}, configPath)
}
upsertAccount(current.accounts, account as unknown as OAuthAccount)
return current
})
} finally {
await lock.release()
}

if (blockedByClaustrum) {
writeError(
'\nError: Claustrum mode is active. Run /openai-account local before adding a fallback account.',
)
return 1
}

if (selfFallback) {
console.error(
writeError(
'\nError: that account is already your main (same ChatGPT account).',
)
console.error(
writeError(
'A self-fallback would retry on the account that just returned 429.',
)
process.exit(1)
return 1
}

console.log(`\n✓ Added account ${account.id}`)
if (account.label) console.log(` Label: ${account.label}`)
break
log(`\n✓ Added account ${account.id}`)
if (account.label) log(` Label: ${account.label}`)
return 0
}

case 'list': {
const storage = await loadAccounts()
const storage = await load()
if (!storage || storage.accounts.length === 0) {
console.log('No fallback accounts configured.')
log('No fallback accounts configured.')
} else {
for (const a of storage.accounts) {
const label = (a as { label?: string }).label
const parts = [` ${a.id}`]
if (label) parts.push(`(${label})`)
parts.push(a.enabled !== false ? '[enabled]' : '[disabled]')
console.log(parts.join(' '))
log(parts.join(' '))
}
}
break
return 0
}

case 'remove': {
const targetId = rest[0]
if (!targetId) {
console.error('Error: remove requires an account ID.')
usage()
process.exit(1)
writeError('Error: remove requires an account ID.')
usage(log)
return 1
}

// `allowDrop` is unconditional — for a healthy entry it is a no-op,
Expand All @@ -150,12 +198,12 @@ async function main() {
// cannot see. The pre-read is purely diagnostic — a stale read can
// only change the message when another writer races us, and the
// mutator signal covers exactly that case.
const configPath = getAccountStoragePath()
const configPath = storagePath()
const rawRoster = await readConfigRosterIds(configPath)
const preReadSawIt = rawRoster ? rawRoster.has(targetId) : false

let mutatorSplicedIt = false
await mutateAccounts(
await mutate(
(current) => {
const idx = current.accounts.findIndex((a) => a.id === targetId)
if (idx === -1) return current
Expand All @@ -169,20 +217,24 @@ async function main() {

const removed = mutatorSplicedIt || preReadSawIt
if (!removed) {
console.error(`No account with id "${targetId}".`)
process.exit(1)
writeError(`No account with id "${targetId}".`)
return 1
}
console.log(`Removed account ${targetId}.`)
break
log(`Removed account ${targetId}.`)
return 0
}

default:
console.error(`Unknown command: ${command}`)
usage()
process.exit(1)
writeError(`Unknown command: ${command}`)
usage(log)
return 1
}
}

async function main() {
process.exit(await runCli())
}

if (import.meta.main) {
main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err))
Expand Down
Loading