Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
1cfb62c
feat(custody): vendor claustrum client and add custody policy core
iceteaSA Sep 3, 2026
5852506
fix(custody): assert the manifest floor and scrub provenance comments
iceteaSA Sep 3, 2026
de1a65e
feat(custody): gate every local refresh on manifest entry or tombstone
iceteaSA Sep 3, 2026
f75f7f7
fix(custody): keep enrolling accounts routable; prove backoff key and…
iceteaSA Sep 3, 2026
3f12209
refactor(custody): behavioural test names, shared fixtures, one predi…
iceteaSA Sep 3, 2026
24bb29a
feat(custody): plugin-wide custody config and sidebar custody projection
iceteaSA Sep 3, 2026
9092e48
feat(custody): quota poll never locally refreshes a refresh-inert fal…
iceteaSA Sep 3, 2026
aee73ba
fix(custody): quota poll fails closed on partial custody wiring
iceteaSA Sep 3, 2026
38cb4dd
feat(custody): loader lifecycle, vault tick, and enroll-completion sweep
iceteaSA Sep 3, 2026
ac46cc1
fix(custody): latch tests, sweep-failure log, served record version i…
iceteaSA Sep 3, 2026
f9c960d
feat(custody): resolve every fallback bearer through the vault resolver
iceteaSA Sep 3, 2026
fef8894
test(custody): loader-path coverage for refused traversal, disabled e…
iceteaSA Sep 3, 2026
09dfc4b
refactor(custody): one sidebar projection, one manifest walk in the l…
iceteaSA Sep 3, 2026
9cdfd3e
test(custody): cachekeep replay resolves through custody; one manifes…
iceteaSA Sep 3, 2026
db0f874
fix(custody): boot completes the enrollment sweep before the backgrou…
iceteaSA Sep 3, 2026
912af55
fix(custody): vault verdicts exclude serving; report bound resets on …
iceteaSA Sep 3, 2026
f5d6ac5
fix(custody): expired reauth restarts the report bound
iceteaSA Sep 3, 2026
8ba5508
fix(custody): cachekeep replay and reset preview report vault 401s
iceteaSA Sep 3, 2026
be7bd34
refactor(custody): move the custody runtime out of the loader; bounda…
iceteaSA Sep 3, 2026
dbc0b64
test(custody): strengthen boot-order and reporter-throw tests; restor…
iceteaSA Sep 3, 2026
5b430cf
fix(custody): arm enrollment completion behind claustrum.manifestWrite
iceteaSA Sep 4, 2026
2cd1d34
docs(custody): date the claustrum vendor review
iceteaSA Sep 4, 2026
067c5b1
test(custody): pin default-disarmed completion and the manifest-remov…
iceteaSA Sep 4, 2026
86ff7c7
docs(sidebar): point the custody reason comment at the union instead …
iceteaSA Sep 4, 2026
49825f9
docs(custody): define manifestWrite by the property it gates
iceteaSA Sep 4, 2026
5df3d14
feat(custody): persist claustrum mode and transition fences
iceteaSA Sep 5, 2026
a9a6905
test(custody): migrate v6 claustrum seeds to the v7 mode shape
iceteaSA Sep 5, 2026
e3089e3
test(custody): sidebar parser round-trip tests follow the v7 mode con…
iceteaSA Sep 5, 2026
710ca3f
fix(custody): derived manifestWrite stays disarmed under claustrum mode
iceteaSA Sep 5, 2026
b86db32
refactor(custody): harden persisted mode fences
iceteaSA Sep 5, 2026
9e7da65
feat(custody): enforce v7 tombstone containment
iceteaSA Sep 5, 2026
3bbec2a
test(custody): pin tombstone containment
iceteaSA Sep 5, 2026
ab6a9e3
refactor(custody): drift pass A — one import idiom, one fixture, v7 v…
iceteaSA Sep 5, 2026
ee88b7e
feat(custody): pure §16 verdict engine and v7 sidebar projection
iceteaSA Sep 5, 2026
e1d6bf4
test(custody): pin the §16 coordinate table and sidebar projection
iceteaSA Sep 5, 2026
8150c04
feat(custody): classify the main host slot before hooks
iceteaSA Sep 5, 2026
c986700
fix(custody): keep the main slot identity and refresh inert under a r…
iceteaSA Sep 5, 2026
31b396e
feat(custody): add fenced readiness barrier
iceteaSA Sep 5, 2026
0ea0ba6
fix(custody): gate resume on persisted fingerprints
iceteaSA Sep 5, 2026
1ea9fd5
test(custody): prove store lock artifacts
iceteaSA Sep 5, 2026
80d4a4d
refactor(custody): drift pass B consistency
iceteaSA Sep 5, 2026
4cfc7bd
feat(custody): guard mode account operations
iceteaSA Sep 5, 2026
4d334ef
fix(custody): refresh account dialog mode
iceteaSA Sep 5, 2026
797d68f
test(custody): cover production transition paths
iceteaSA Sep 5, 2026
308abcf
test(custody): isolate authorize flow dependencies
iceteaSA Sep 5, 2026
ad211ed
test(custody): inject the OAuth seam instead of mocking the module
iceteaSA Sep 5, 2026
4b10f08
test(custody): cover served binding identity
iceteaSA Sep 5, 2026
d487417
test(custody): preserve Claustrum command verbs
iceteaSA Sep 5, 2026
1f97e65
test(custody): cover authorize lease deadlines
iceteaSA Sep 5, 2026
ae6530d
test(custody): cover CLI transition exclusion
iceteaSA Sep 5, 2026
e01bcdd
test(custody): pin authorize callback lease
iceteaSA Sep 5, 2026
fe7e326
feat(custody): record verified in-process logins
iceteaSA Sep 5, 2026
556ac67
test(custody): cover verified login bindings
iceteaSA Sep 5, 2026
cd64d9a
fix(custody): read the mode after the barrier; require the account lo…
iceteaSA Sep 5, 2026
6a54f80
fix(custody): gate the local re-login row on the verified in-process …
iceteaSA Sep 5, 2026
9f642eb
fix(custody): gate bound real rows by claustrum mode
iceteaSA Sep 5, 2026
1a02025
feat(custody): retain corrupt OAuth fallback rows
iceteaSA Sep 5, 2026
2b51881
feat(custody): install fallback tombstones during reconcile
iceteaSA Sep 5, 2026
9b28a6a
feat(custody): resume fingerprinted custody transitions
iceteaSA Sep 5, 2026
7ec0ffa
fix(custody): persist the corrupt marker across saves
iceteaSA Sep 5, 2026
52e5c26
test(custody): pin locked install revalidation
iceteaSA Sep 5, 2026
80f5f0f
feat(custody): retain removed rows in custody history
iceteaSA Sep 5, 2026
a647dc3
feat(custody): discover orphan bindings before reconciliation
iceteaSA Sep 5, 2026
d464ef0
feat(custody): bind pending rows under the shared lock
iceteaSA Sep 5, 2026
82c10f9
feat(custody): reevaluate changed manifest revisions
iceteaSA Sep 5, 2026
4a4d971
refactor(custody): remove legacy completion surfaces
iceteaSA Sep 5, 2026
5a3cb48
fix(custody): gate discovery on manifest revision; contend the real l…
iceteaSA Sep 5, 2026
f899764
docs(custody): add the standalone custody state machine
iceteaSA Sep 5, 2026
7098bf2
fix(custody): one canonical tombstone row
iceteaSA Sep 5, 2026
8214f04
refactor(custody): drop the unused enrolled-under-local reason
iceteaSA Sep 5, 2026
23ec21f
test(custody): share the JWT fixture; no real timers in runtime tests
iceteaSA Sep 5, 2026
d90fd6d
docs(custody): state-machine wording
iceteaSA Sep 5, 2026
7adfe6a
test(custody): type the tombstone fixture write
iceteaSA Sep 5, 2026
7f75f9f
fix(custody): share enrollment tombstone shape
iceteaSA Sep 5, 2026
80b3589
fix(custody): reconnect to the vault on tick
iceteaSA Sep 5, 2026
d395f27
fix(custody): release the transition mutex before observers run
iceteaSA Sep 5, 2026
b556fb7
fix(custody): prefer needs_reauth over vault-cold
iceteaSA Sep 5, 2026
e248860
fix(logger): redact manifest handles
iceteaSA Sep 5, 2026
0d4635e
test(custody): make active fallback routing tests self-seeding
iceteaSA Sep 11, 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