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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi.

- In sticky-balanced mode on OpenCode 1 and Pi, a session now waits on its account when whichever quota window blocks it resets within 15 minutes, including 7-day and model limits, instead of moving and rebuilding its cache. OpenCode 2 does not hold yet.

- Addresses #471 (vault grant principal)
- OpenCode 2: finishing an OAuth sign-in from `/claude` → Accounts no longer fails with "Action failed"; the Accounts menu explains how to connect OpenCode when no main account exists. Thanks to @colinmollenhour. Addresses #464.

## 2.2.6
Expand Down
10 changes: 9 additions & 1 deletion docs/vault.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Vault custody

Claustrum is a local credential-vault service. Each host explicitly enrolls as `anthropic-auth-opencode` or `anthropic-auth-pi`; its enrollment record, not an account setting, selects vault custody. OpenCode and Pi can be enrolled, disconnected or revoked independently.
Claustrum is a local credential-vault service. Each host explicitly proposes enrollment as `anthropic-auth-opencode` or `anthropic-auth-pi`; the vault operator can approve a different name. Its enrollment record, not an account setting, selects vault custody. OpenCode and Pi can be enrolled, disconnected or revoked independently.

## Setup

Expand All @@ -14,6 +14,14 @@ npx @cortexkit/opencode-anthropic-auth setup --claustrum

The shared runner owns the per-host enrollment ceremony. Anthropic callbacks then ensure the host-specific grant to read the `anthropic-native` category, discover inventory and require a verified default Claude account. If that default is unverified, sign in through the vault and rerun setup; setup does not import local tokens into the vault.

Setup grants the principal named by the enrollment's `approvedName`, using the proposed default only when no approved name is recorded. If setup reports no read grant, its recovery command names that principal. To grant access by hand, use the approved principal shown in `/claude` → **Accounts**. For example, if the operator approved OpenCode as `anthropic-auth-opencode-v2`, run:

```sh
ck auth grant --principal enrolled:anthropic-auth-opencode-v2 --selector-kind category --selector anthropic-native --operation read
```

Do not substitute `anthropic-auth-opencode` or `anthropic-auth-pi` unless that is this installation's approved name (or its default when no approved name was recorded). If a read grant reaches no Claude login, run `ck auth reclassify --from-registry` and check the logins with `ck auth status` before rerunning setup.

If setup fails after enrollment is approved, the host stays connected to the vault. Run setup again to finish connecting. Or, in that host, run `/claude` → **Accounts** → **Disconnect from the vault** to undo it. Local settings do not undo enrollment.

For noninteractive setup, add `--yes`. If Pi has stored Anthropic auth, explicit `--remove-pi-auth` consent is also required; `--yes` alone is not consent. Do not combine `--login` with `--claustrum`. Dry runs invoke no callbacks and do not validate inventory or consent.
Expand Down
17 changes: 17 additions & 0 deletions mutations.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ prebuild = [
{ name = "opencode-current-source", command = ["bun", "run", "--cwd", "packages/opencode", "build"] },
]

[[control]]
id = "oc-native-enrollment-grant-uses-approved-name"
guards = "A grant must use the enrollment's approved principal, not another installation's default name."
file = "packages/opencode/src/setup/native-enrollment.ts"
old = " const args = nativeVaultGrantArgs(host, approvedName)"
new = " const args = nativeVaultGrantArgs(host) // NON-VACUITY BREAK: restore the default enrollment principal."
test_file = "packages/opencode/src/tests/native-activation.test.ts"
runner = "command"
command = ["bun", "scripts/run-mutation-bun.ts", "packages/opencode/src/tests/native-activation.test.ts", "{test}"]
test_count_pattern = "CKDEV executed {count} tests"
expect_red = ["opencode activation and the empty-vault remedy use the approved principal"]
expect_message = "error: expect(received).toEqual(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 = "local-bookkeeping-never-nests-pool-lock"
guards = "A held pool writer must not delay independent runtime bookkeeping."
Expand Down
22 changes: 21 additions & 1 deletion packages/opencode/src/cli-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ function closedFenceHosts(
* Display text must be a fixed remedy, the runner's unknown-argument message,
* an OAuth metadata error matching its own integer HTTP status (100–599),
* a shared-setup enrollment refusal matching HOST_NOT_ENROLLED_PATTERNS, or
* a running-host list rebuilt from validated own host names and PIDs.
* a running-host list or vault grant remedy rebuilt from validated fields.
*/
export function knownFailureMessage(error: unknown): string | undefined {
try {
Expand All @@ -472,6 +472,26 @@ export function knownFailureMessage(error: unknown): string | undefined {
)
return message
if (CLOSED_MESSAGES.has(message)) return message
if (
ownData(error, 'name') === 'NativeVaultActivationError' &&
ownData(error, 'code') === 'no-read-grant'
) {
const host = ownData(error, 'host')
const approvedName = ownData(error, 'approvedName')
if (
(host === 'opencode' || host === 'pi') &&
typeof approvedName === 'string'
) {
// The constructor validates the principal before building the command.
// Compare the whole remedy so no other error text reaches the terminal.
const rebuilt = new NativeVaultActivationError(
'no-read-grant',
host,
approvedName,
).message
if (message === rebuilt) return rebuilt
}
}
if (message.startsWith('Unknown setup argument: ')) return message
if (
ownData(error, 'name') === 'HostNotEnrolledError' &&
Expand Down
23 changes: 15 additions & 8 deletions packages/opencode/src/setup/native-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const HOST_LABELS = { opencode: 'OpenCode', pi: 'Pi' } as const
function activationMessage(
code: HostIndependentActivationCode | HostActivationCode,
host: HarnessKind,
approvedName?: string,
): string {
switch (code) {
case 'auth-content-refused':
Expand All @@ -59,7 +60,7 @@ function activationMessage(
case 'primary-unverified':
return 'The vault has no verified default Claude account. Sign in through the vault and rerun setup.'
case 'no-read-grant':
return `${HOST_LABELS[host]} has no read grant in the vault, so setup cannot see any Claude login. Run ${['ck', ...nativeVaultGrantArgs(host)].join(' ')}, then rerun setup.`
return `${HOST_LABELS[host]} has no read grant in the vault, so setup cannot see any Claude login. Run ${['ck', ...nativeVaultGrantArgs(host, approvedName)].join(' ')}, then rerun setup.`
case 'grant-reaches-nothing':
return `${HOST_LABELS[host]}'s vault read grant reaches no Claude login: no vault login carries the anthropic-native category, which logins saved before vault categories existed lack. Run ck auth reclassify --from-registry, check the logins with ck auth status, then rerun setup.`
}
Expand All @@ -69,14 +70,19 @@ export class NativeVaultActivationError extends Error {
readonly code: HostIndependentActivationCode | HostActivationCode

constructor(code: HostIndependentActivationCode)
constructor(code: HostActivationCode, host: HarnessKind)
constructor(
code: HostActivationCode,
host: HarnessKind,
approvedName?: string,
)
constructor(
code: HostIndependentActivationCode | HostActivationCode,
host: HarnessKind = 'opencode',
readonly host: HarnessKind = 'opencode',
readonly approvedName?: string,
) {
// Every message is fixed text chosen by code and host; nothing from a
// vault reply or a caught error is ever part of it.
super(activationMessage(code, host))
// The remedy includes only a validated principal name; vault replies and
// caught error messages are never displayed as free-form text.
super(activationMessage(code, host, approvedName))
this.name = 'NativeVaultActivationError'
this.code = code
}
Expand Down Expand Up @@ -237,11 +243,12 @@ export async function completeNativeVaultActivation(
preflight.fingerprint
)
throw new NativeVaultActivationError('host-auth-invalid')
await grantNativeVaultForHost(host, {
const approvedName = await grantNativeVaultForHost(host, {
env,
runner: options.runner,
processFence,
ckBinary: options.enrollment?.ckBinary,
paths: options.enrollment?.paths,
})
const listVault =
options.discover ??
Expand Down Expand Up @@ -282,7 +289,7 @@ export async function completeNativeVaultActivation(
const code = unservedCode(listed)
throw code === 'primary-unverified'
? new NativeVaultActivationError(code)
: new NativeVaultActivationError(code, host)
: new NativeVaultActivationError(code, host, approvedName)
}
await processFence()
if (host === 'pi')
Expand Down
204 changes: 157 additions & 47 deletions packages/opencode/src/setup/native-enrollment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,110 @@ async function fixture(body: (root: string) => Promise<void>) {
}
}

for (const host of ['opencode', 'pi'] as const) {
test(`${host} enrollment grants the principal approved under a different name`, async () => {
await fixture(async (root) => {
const calls: string[][] = []
await enrollNativeVaultForHost(host, {
env: {},
paths: {
tokenPath: join(root, 'token.json'),
statePath: join(root, 'state.json'),
},
processFence: async () => {},
runner: {
run: async (_command, args) => {
calls.push(args)
return { exitCode: 0, stdout: '', stderr: '' }
},
},
client: {
enrollPropose: async () => ({ requestId: 'synthetic-request-id' }),
enrollPoll: async () => ({
status: 'approved',
name: `anthropic-auth-${host}-v2`,
token: 'a'.repeat(64),
tokenGeneration: 1,
}),
},
})
expect(calls).toEqual([
[
'auth',
'grant',
'--principal',
`enrolled:anthropic-auth-${host}-v2`,
'--selector-kind',
'category',
'--selector',
'anthropic-native',
'--operation',
'read',
],
])
})
})

for (const approvedName of [
undefined,
'bad;synthetic-secret',
'has space',
'x'.repeat(129),
'',
42,
]) {
test(`${host} grant ${approvedName === undefined ? 'falls back only when no approved name is recorded' : `refuses invalid approved name ${JSON.stringify(approvedName)}`}`, async () => {
await fixture(async (root) => {
const env = {
[`${host.toUpperCase()}_ANTHROPIC_AUTH_CLAUSTRUM_ENROLLMENT_FILE`]:
join(root, 'token.json'),
}
const paths = getHostClaustrumEnrollmentPaths(host, env)
await writeFile(
paths.tokenPath,
JSON.stringify({ token: 'a'.repeat(64), token_generation: 1 }),
{ mode: 0o600 },
)
await writeFile(
paths.statePath,
JSON.stringify({
version: 1,
phase: 'approved',
proposedName: `anthropic-auth-${host}`,
approvedName,
tokenGeneration: 1,
updatedAt: 1,
}),
{ mode: 0o600 },
)
const calls: string[][] = []
const operation = grantNativeVaultForHost(host, {
env,
processFence: async () => {},
runner: {
run: async (_command, args) => {
calls.push(args)
return { exitCode: 0, stdout: '', stderr: '' }
},
},
})
if (approvedName === undefined) {
await operation
expect(calls).toHaveLength(1)
expect(calls[0]?.[3]).toBe(`enrolled:anthropic-auth-${host}`)
} else {
await expect(operation).rejects.toMatchObject({
code: 'permission-refused',
message:
'Native vault setup: permission-refused. Could not grant this host read access to native Claude accounts. An older ck may refuse a grant that already exists. Update CortexKit tools and run setup again.',
})
expect(calls).toEqual([])
}
})
})
}
}

for (const [name, exitCode, stdout] of [
[
'a fresh grant',
Expand All @@ -39,55 +143,61 @@ for (const [name, exitCode, stdout] of [
['a real grant failure', 1, 'synthetic-secret-output'],
] as const) {
test(`${name} uses only the grant exit status and never exposes command output`, async () => {
const calls: Array<{ command: string; args: string[] }> = []
let fences = 0
const operation = grantNativeVaultForHost('opencode', {
env: {},
ckBinary: '/fixture/ck',
processFence: async () => {
fences++
},
runner: {
run: async (command, args) => {
calls.push({ command, args })
return {
exitCode,
stdout,
stderr:
'error: the running module refused the op: credential already exists; synthetic-secret-stderr',
}
await fixture(async (root) => {
const calls: Array<{ command: string; args: string[] }> = []
let fences = 0
const operation = grantNativeVaultForHost('opencode', {
env: {},
paths: {
tokenPath: join(root, 'token.json'),
statePath: join(root, 'state.json'),
},
ckBinary: '/fixture/ck',
processFence: async () => {
fences++
},
runner: {
run: async (command, args) => {
calls.push({ command, args })
return {
exitCode,
stdout,
stderr:
'error: the running module refused the op: credential already exists; synthetic-secret-stderr',
}
},
},
},
})
if (exitCode === 0) await expect(operation).resolves.toBeUndefined()
else {
await expect(operation).rejects.toMatchObject({
code: 'permission-refused',
})
await expect(operation).rejects.toThrow(
'Update CortexKit tools and run setup again.',
)
await expect(operation).rejects.not.toThrow('synthetic-secret')
await expect(operation).rejects.not.toThrow('credential already exists')
}
expect(fences).toBe(1)
expect(calls).toEqual([
{
command: '/fixture/ck',
args: [
'auth',
'grant',
'--principal',
'enrolled:anthropic-auth-opencode',
'--selector-kind',
'category',
'--selector',
'anthropic-native',
'--operation',
'read',
],
},
])
if (exitCode === 0) await expect(operation).resolves.toBeUndefined()
else {
await expect(operation).rejects.toMatchObject({
code: 'permission-refused',
})
await expect(operation).rejects.toThrow(
'Update CortexKit tools and run setup again.',
)
await expect(operation).rejects.not.toThrow('synthetic-secret')
await expect(operation).rejects.not.toThrow('credential already exists')
}
expect(fences).toBe(1)
expect(calls).toEqual([
{
command: '/fixture/ck',
args: [
'auth',
'grant',
'--principal',
'enrolled:anthropic-auth-opencode',
'--selector-kind',
'category',
'--selector',
'anthropic-native',
'--operation',
'read',
],
},
])
})
})
}

Expand Down
Loading
Loading