From 3e918af3e04e68feaa823670c71ad062dd0b1304 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:35:48 +0200 Subject: [PATCH] mason: grant the vault-approved enrollment principal Read approvedName through common-auth's public enrollment-status API, validate it before building ck arguments, and retain the same principal in the empty-vault remedy. Keep default names only for records without an approved name. Add red-first coverage and a default-principal mutation control. --- CHANGELOG.md | 1 + docs/vault.md | 10 +- mutations.toml | 17 ++ packages/opencode/src/cli-failure.ts | 22 +- .../opencode/src/setup/native-activation.ts | 23 +- .../src/setup/native-enrollment.test.ts | 204 ++++++++++++++---- .../opencode/src/setup/native-enrollment.ts | 33 ++- .../opencode/src/tests/cli-failure.test.ts | 39 ++++ .../src/tests/native-activation.test.ts | 41 ++++ .../opencode/src/tests/setup-command.test.ts | 22 +- 10 files changed, 346 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1f67b1..5e8b7ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/vault.md b/docs/vault.md index d289bd5e..7d777f2c 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -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 @@ -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. diff --git a/mutations.toml b/mutations.toml index 1f06d9a0..80a603d5 100644 --- a/mutations.toml +++ b/mutations.toml @@ -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." diff --git a/packages/opencode/src/cli-failure.ts b/packages/opencode/src/cli-failure.ts index 8190f78b..866e0cf1 100644 --- a/packages/opencode/src/cli-failure.ts +++ b/packages/opencode/src/cli-failure.ts @@ -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 { @@ -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' && diff --git a/packages/opencode/src/setup/native-activation.ts b/packages/opencode/src/setup/native-activation.ts index 4401edde..e69700d1 100644 --- a/packages/opencode/src/setup/native-activation.ts +++ b/packages/opencode/src/setup/native-activation.ts @@ -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': @@ -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.` } @@ -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 } @@ -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 ?? @@ -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') diff --git a/packages/opencode/src/setup/native-enrollment.test.ts b/packages/opencode/src/setup/native-enrollment.test.ts index da246b85..5118e2ac 100644 --- a/packages/opencode/src/setup/native-enrollment.test.ts +++ b/packages/opencode/src/setup/native-enrollment.test.ts @@ -25,6 +25,110 @@ async function fixture(body: (root: string) => Promise) { } } +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', @@ -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', + ], + }, + ]) + }) }) } diff --git a/packages/opencode/src/setup/native-enrollment.ts b/packages/opencode/src/setup/native-enrollment.ts index 5f019589..94247f7b 100644 --- a/packages/opencode/src/setup/native-enrollment.ts +++ b/packages/opencode/src/setup/native-enrollment.ts @@ -181,12 +181,19 @@ export async function enrollNativeVaultForHost( * the vault's anthropic-native category. Setup runs them, and the empty-vault * message shows the same command so the user can run it by hand. */ -export function nativeVaultGrantArgs(host: HarnessKind): string[] { +export function nativeVaultGrantArgs( + host: HarnessKind, + approvedName?: string, +): string[] { + const name = approvedName ?? NATIVE_VAULT_HOST_NAMES[host] + // Match the principal-name rules used by the Accounts enrollment display. + if (typeof name !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(name)) + throw new NativeVaultEnrollmentError('permission-refused') return [ 'auth', 'grant', '--principal', - `enrolled:${NATIVE_VAULT_HOST_NAMES[host]}`, + `enrolled:${name}`, '--selector-kind', 'category', '--selector', @@ -201,18 +208,28 @@ export async function grantNativeVaultForHost( host: HarnessKind, options: Pick< NativeVaultEnrollmentOptions, - 'env' | 'runner' | 'processFence' | 'ckBinary' + 'env' | 'runner' | 'processFence' | 'ckBinary' | 'paths' >, -): Promise { +): Promise { try { await options.processFence() - const grant = await options.runner.run( - options.ckBinary ?? 'ck', - nativeVaultGrantArgs(host), - { env: options.env }, + // common-auth's public status API includes the operator's approvedName; + // the proposed default can belong to a different installation. + const status = await readClaustrumEnrollmentStatus( + options.paths ?? getHostClaustrumEnrollmentPaths(host, options.env), + NATIVE_VAULT_HOST_NAMES[host], ) + if (status.state === 'blocked') + throw new NativeVaultEnrollmentError('permission-refused') + const approvedName = + status.state === 'approved' ? status.approvedName : undefined + const args = nativeVaultGrantArgs(host, approvedName) + const grant = await options.runner.run(options.ckBinary ?? 'ck', args, { + env: options.env, + }) if (grant.exitCode !== 0) throw new NativeVaultEnrollmentError('permission-refused') + return approvedName } catch { throw new NativeVaultEnrollmentError('permission-refused') } diff --git a/packages/opencode/src/tests/cli-failure.test.ts b/packages/opencode/src/tests/cli-failure.test.ts index 75c94780..f7579e22 100644 --- a/packages/opencode/src/tests/cli-failure.test.ts +++ b/packages/opencode/src/tests/cli-failure.test.ts @@ -220,6 +220,45 @@ test('fixed remedies and validated closed codes are admitted word for word', () expect(knownFailureMessage(error)).toBe(error.message) }) +test('empty-vault remedies admit only a canonical command for a validated approved principal', () => { + for (const host of ['opencode', 'pi'] as const) { + const error = new NativeVaultActivationError( + 'no-read-grant', + host, + `anthropic-auth-${host}-v2`, + ) + expect(knownFailureMessage(error)).toBe(error.message) + expect( + knownFailureMessage({ + name: error.name, + code: error.code, + host, + approvedName: error.approvedName, + message: error.message, + }), + ).toBe(error.message) + for (const fields of [ + { approvedName: 'bad;synthetic-secret' }, + { approvedName: 'x'.repeat(129) }, + { host: 'unknown' }, + { code: 'unknown' }, + { name: 'Error' }, + { message: `${error.message} synthetic-secret` }, + ]) { + expect( + knownFailureMessage({ + name: error.name, + code: error.code, + host, + approvedName: error.approvedName, + message: error.message, + ...fields, + }), + ).toBeUndefined() + } + } +}) + test('shared setup enrollment refusals are shown word for word and look-alikes are not', () => { const refusal = (message: string, name = 'HostNotEnrolledError') => Object.assign(new Error(message), { name }) diff --git a/packages/opencode/src/tests/native-activation.test.ts b/packages/opencode/src/tests/native-activation.test.ts index 3d8c52bd..ff0b7f0b 100644 --- a/packages/opencode/src/tests/native-activation.test.ts +++ b/packages/opencode/src/tests/native-activation.test.ts @@ -163,6 +163,47 @@ test('a fresh OpenCode install enrolls and discovers vault accounts without crea }) }) +for (const host of ['opencode', 'pi'] as const) { + test(`${host} activation and the empty-vault remedy use the approved principal`, async () => { + const f = await fixture(host) + const grants: string[][] = [] + const run = f.options.runner.run + f.options.runner.run = async (command, args, options) => { + if (args.includes('grant')) grants.push(args) + return run(command, args, options) + } + f.options.enrollment.client.enrollPoll = async () => ({ + status: 'approved', + name: `anthropic-auth-${host}-v2`, + token: 'c'.repeat(64), + tokenGeneration: 1, + }) + f.options.discover = async () => ({ + view: 'synthetic-view', + credentials: [], + skipped: [], + grantReach: { grants: 0, tuples: [] }, + }) + await expect(activateNativeVaultForHost(host, f.options)).rejects.toThrow( + `Run ck auth grant --principal enrolled:anthropic-auth-${host}-v2 --selector-kind category --selector anthropic-native --operation read, then rerun setup.`, + ) + expect(grants).toEqual([ + [ + 'auth', + 'grant', + '--principal', + `enrolled:anthropic-auth-${host}-v2`, + '--selector-kind', + 'category', + '--selector', + 'anthropic-native', + '--operation', + 'read', + ], + ]) + }) +} + test('a retired local pool is activated without rerunning its migration', async () => { const f = await fixture('opencode') await writeHostAuth(f.paths.hostAuthPath) diff --git a/packages/opencode/src/tests/setup-command.test.ts b/packages/opencode/src/tests/setup-command.test.ts index 666c4b0f..e828cbc9 100644 --- a/packages/opencode/src/tests/setup-command.test.ts +++ b/packages/opencode/src/tests/setup-command.test.ts @@ -3793,13 +3793,20 @@ const UNREACHED_TEXT = "OpenCode'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." const UNVERIFIED_TEXT = 'The vault has no verified default Claude account. Sign in through the vault and rerun setup.' -for (const { name, inventory, code, text } of [ +for (const scenario of [ { name: 'no grant at all', inventory: { grantReach: { grants: 0, tuples: [] }, credentials: [] }, code: 'no-read-grant', text: NO_GRANT_TEXT, }, + { + name: 'no grant for an enrollment approved under a different name', + approvedName: 'anthropic-auth-opencode-v2', + inventory: { grantReach: { grants: 0, tuples: [] }, credentials: [] }, + code: 'no-read-grant', + text: 'OpenCode has no read grant in the vault, so setup cannot see any Claude login. Run ck auth grant --principal enrolled:anthropic-auth-opencode-v2 --selector-kind category --selector anthropic-native --operation read, then rerun setup.', + }, { name: 'a grant that reaches no login', inventory: { @@ -3831,6 +3838,7 @@ for (const { name, inventory, code, text } of [ text: UNVERIFIED_TEXT, }, ]) { + const { name, inventory, code, text } = scenario lifetimes.test( `vault setup with ${name} explains the empty listing with one fixed remedy`, async () => { @@ -3844,6 +3852,18 @@ for (const { name, inventory, code, text } of [ await runSetupCommand(['--yes', '--claustrum', '--opencode=2'], { ...f.io, vaultActivate: async (host, options, preflight) => { + if ('approvedName' in scenario) { + const { statePath } = getHostClaustrumEnrollmentPaths(host, f.env) + const state = JSON.parse(await readFile(statePath, 'utf8')) + await writeFile( + statePath, + JSON.stringify({ + ...state, + approvedName: scenario.approvedName, + }), + { mode: 0o600 }, + ) + } try { return await completeNativeVaultActivation( host,