Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
0b52f96
feat(custody): vendor claustrum client and add custody policy core
iceteaSA Sep 3, 2026
3ec8042
fix(custody): assert the manifest floor and scrub provenance comments
iceteaSA Sep 3, 2026
4fa8ec4
feat(custody): gate every local refresh on manifest entry or tombstone
iceteaSA Sep 3, 2026
ef45369
fix(custody): keep enrolling accounts routable; prove backoff key and…
iceteaSA Sep 3, 2026
f0e6b54
refactor(custody): behavioural test names, shared fixtures, one predi…
iceteaSA Sep 3, 2026
12f1663
feat(custody): plugin-wide custody config and sidebar custody projection
iceteaSA Sep 3, 2026
f5c83ee
feat(custody): quota poll never locally refreshes a refresh-inert fal…
iceteaSA Sep 3, 2026
a841536
fix(custody): quota poll fails closed on partial custody wiring
iceteaSA Sep 3, 2026
e6b6f83
feat(custody): loader lifecycle, vault tick, and enroll-completion sweep
iceteaSA Sep 3, 2026
8989bc3
fix(custody): latch tests, sweep-failure log, served record version i…
iceteaSA Sep 3, 2026
188a7eb
feat(custody): resolve every fallback bearer through the vault resolver
iceteaSA Sep 3, 2026
4dbe910
test(custody): loader-path coverage for refused traversal, disabled e…
iceteaSA Sep 3, 2026
7dad1f9
refactor(custody): one sidebar projection, one manifest walk in the l…
iceteaSA Sep 3, 2026
62d5056
test(custody): cachekeep replay resolves through custody; one manifes…
iceteaSA Sep 3, 2026
08dd6e5
fix(custody): boot completes the enrollment sweep before the backgrou…
iceteaSA Sep 3, 2026
e5dfcdc
fix(custody): vault verdicts exclude serving; report bound resets on …
iceteaSA Sep 3, 2026
7899634
fix(custody): expired reauth restarts the report bound
iceteaSA Sep 3, 2026
3858c5e
fix(custody): cachekeep replay and reset preview report vault 401s
iceteaSA Sep 3, 2026
27d66c1
refactor(custody): move the custody runtime out of the loader; bounda…
iceteaSA Sep 3, 2026
967947f
test(custody): strengthen boot-order and reporter-throw tests; restor…
iceteaSA Sep 3, 2026
def224b
fix(custody): arm enrollment completion behind claustrum.manifestWrite
iceteaSA Sep 4, 2026
b920b9f
docs(custody): date the claustrum vendor review
iceteaSA Sep 4, 2026
5bb027a
test(custody): pin default-disarmed completion and the manifest-remov…
iceteaSA Sep 4, 2026
1cdfd6b
docs(sidebar): point the custody reason comment at the union instead …
iceteaSA Sep 4, 2026
c0d4901
docs(custody): define manifestWrite by the property it gates
iceteaSA Sep 4, 2026
b33a1ce
feat(custody): persist claustrum mode and transition fences
iceteaSA Sep 5, 2026
56cfc83
test(custody): migrate v6 claustrum seeds to the v7 mode shape
iceteaSA Sep 5, 2026
29eeb74
test(custody): sidebar parser round-trip tests follow the v7 mode con…
iceteaSA Sep 5, 2026
7a3a794
fix(custody): derived manifestWrite stays disarmed under claustrum mode
iceteaSA Sep 5, 2026
e5216fe
refactor(custody): harden persisted mode fences
iceteaSA Sep 5, 2026
0150e08
feat(custody): enforce v7 tombstone containment
iceteaSA Sep 5, 2026
af0b9ab
test(custody): pin tombstone containment
iceteaSA Sep 5, 2026
0734feb
refactor(custody): drift pass A — one import idiom, one fixture, v7 v…
iceteaSA Sep 5, 2026
98ae134
feat(custody): pure §16 verdict engine and v7 sidebar projection
iceteaSA Sep 5, 2026
1f1a05c
test(custody): pin the §16 coordinate table and sidebar projection
iceteaSA Sep 5, 2026
966dcfe
feat(custody): classify the main host slot before hooks
iceteaSA Sep 5, 2026
d395cd0
fix(custody): keep the main slot identity and refresh inert under a r…
iceteaSA Sep 5, 2026
70c2106
feat(custody): add fenced readiness barrier
iceteaSA Sep 5, 2026
541a7e0
fix(custody): gate resume on persisted fingerprints
iceteaSA Sep 5, 2026
c4ac6b2
test(custody): prove store lock artifacts
iceteaSA Sep 5, 2026
1f4a915
refactor(custody): drift pass B consistency
iceteaSA Sep 5, 2026
e794b7d
feat(custody): guard mode account operations
iceteaSA Sep 5, 2026
7786944
fix(custody): refresh account dialog mode
iceteaSA Sep 5, 2026
24956b7
test(custody): cover production transition paths
iceteaSA Sep 5, 2026
0ccd8d0
test(custody): isolate authorize flow dependencies
iceteaSA Sep 5, 2026
8cc4df4
test(custody): inject the OAuth seam instead of mocking the module
iceteaSA Sep 5, 2026
0cdaf74
test(custody): cover served binding identity
iceteaSA Sep 5, 2026
e676895
test(custody): preserve Claustrum command verbs
iceteaSA Sep 5, 2026
382e5ce
test(custody): cover authorize lease deadlines
iceteaSA Sep 5, 2026
bfb0155
test(custody): cover CLI transition exclusion
iceteaSA Sep 5, 2026
f181f21
test(custody): pin authorize callback lease
iceteaSA Sep 5, 2026
c3a3665
feat(custody): record verified in-process logins
iceteaSA Sep 5, 2026
b637086
test(custody): cover verified login bindings
iceteaSA Sep 5, 2026
b2b5262
fix(custody): read the mode after the barrier; require the account lo…
iceteaSA Sep 5, 2026
b9a9a77
fix(custody): gate the local re-login row on the verified in-process …
iceteaSA Sep 5, 2026
44a7034
fix(custody): gate bound real rows by claustrum mode
iceteaSA Sep 5, 2026
e566a64
feat(custody): retain corrupt OAuth fallback rows
iceteaSA Sep 5, 2026
09fc1bd
feat(custody): install fallback tombstones during reconcile
iceteaSA Sep 5, 2026
cecc488
feat(custody): resume fingerprinted custody transitions
iceteaSA Sep 5, 2026
36c707a
fix(custody): persist the corrupt marker across saves
iceteaSA Sep 5, 2026
3ceae4a
test(custody): pin locked install revalidation
iceteaSA Sep 5, 2026
6413893
feat(custody): retain removed rows in custody history
iceteaSA Sep 5, 2026
e8e3379
feat(custody): discover orphan bindings before reconciliation
iceteaSA Sep 5, 2026
50b1c30
feat(custody): bind pending rows under the shared lock
iceteaSA Sep 5, 2026
0ee5ec8
feat(custody): reevaluate changed manifest revisions
iceteaSA Sep 5, 2026
af63116
refactor(custody): remove legacy completion surfaces
iceteaSA Sep 5, 2026
cf3479d
fix(custody): gate discovery on manifest revision; contend the real l…
iceteaSA Sep 5, 2026
1cbdd03
docs(custody): add the standalone custody state machine
iceteaSA Sep 5, 2026
dbaf023
fix(custody): one canonical tombstone row
iceteaSA Sep 5, 2026
cba3dce
refactor(custody): drop the unused enrolled-under-local reason
iceteaSA Sep 5, 2026
d0045e4
test(custody): share the JWT fixture; no real timers in runtime tests
iceteaSA Sep 5, 2026
99538d3
docs(custody): state-machine wording
iceteaSA Sep 5, 2026
d0c8b5c
test(custody): type the tombstone fixture write
iceteaSA Sep 5, 2026
a0274a9
fix(custody): share enrollment tombstone shape
iceteaSA Sep 5, 2026
3cc8838
fix(custody): reconnect to the vault on tick
iceteaSA Sep 5, 2026
099e036
fix(custody): release the transition mutex before observers run
iceteaSA Sep 5, 2026
44c7ae6
fix(custody): prefer needs_reauth over vault-cold
iceteaSA Sep 5, 2026
d471f87
fix(logger): redact manifest handles
iceteaSA Sep 5, 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.1",
"@opentui/solid": ">=0.5.1",
"ai": "^7.0.90",
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