diff --git a/README.md b/README.md index dd8a0d9..0aca3b2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Quit OpenCode and Pi, then run: npx @cortexkit/opencode-claustrum-auth setup ``` -Setup detects the hosts and registers the appropriate package: `@cortexkit/opencode-claustrum-auth` for OpenCode or `@cortexkit/pi-claustrum-auth` for Pi. Approve enrollment and grant access in `ck auth`, then follow the printed provider activation commands. See [setup and activation](docs/setup.md) for a dry run, host selection and the separate grant step. +Setup detects the hosts and registers the appropriate package: `@cortexkit/opencode-claustrum-auth` for OpenCode or `@cortexkit/pi-claustrum-auth` for Pi. Approve enrollment in `ck auth` to grant model-key read access in the same step on vaults that support it, then follow the printed provider activation commands. Older vaults need the manual grant command. See [setup and activation](docs/setup.md) for a dry run, host selection and the fallback grant step. ## What it does diff --git a/bun.lock b/bun.lock index f0a2fc8..6ed3e82 100644 --- a/bun.lock +++ b/bun.lock @@ -19,8 +19,8 @@ "name": "@cortexkit/claustrum-auth-core", "version": "0.0.0", "dependencies": { - "@cortexkit/claustrum-client": "^0.17.0", - "@cortexkit/common-auth": "^0.43.0", + "@cortexkit/claustrum-client": "^0.18.0", + "@cortexkit/common-auth": "^0.44.0", "@cortexkit/private-files": "^0.2.2", "@cortexkit/subc-client": "^0.24.0", }, @@ -32,8 +32,8 @@ "claustrum-auth": "./dist/setup-bin.js", }, "dependencies": { - "@cortexkit/claustrum-client": "^0.17.0", - "@cortexkit/common-auth": "^0.43.0", + "@cortexkit/claustrum-client": "^0.18.0", + "@cortexkit/common-auth": "^0.44.0", "@cortexkit/private-files": "^0.2.2", "@cortexkit/subc-client": "^0.24.0", }, @@ -53,8 +53,8 @@ "name": "@cortexkit/pi-claustrum-auth", "version": "0.3.0", "dependencies": { - "@cortexkit/claustrum-client": "^0.17.0", - "@cortexkit/common-auth": "^0.43.0", + "@cortexkit/claustrum-client": "^0.18.0", + "@cortexkit/common-auth": "^0.44.0", "@cortexkit/private-files": "^0.2.2", "@cortexkit/subc-client": "^0.24.0", }, @@ -152,9 +152,9 @@ "@cortexkit/claustrum-auth-core": ["@cortexkit/claustrum-auth-core@workspace:packages/core"], - "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.17.0", "", { "dependencies": { "@cortexkit/store": "^0.2.3", "@cortexkit/subc-client": "^0.24.0" } }, "sha512-8PF7fI8pnRyZWgLpzO8zeywc3dGaYYDIhU3ztrJtfwDkw+dq9o2UQtCaoHo776d2aHo+JPZXFfN+7E2VaG75vw=="], + "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@0.18.0", "", { "dependencies": { "@cortexkit/store": "^0.2.3", "@cortexkit/subc-client": "^0.24.0" } }, "sha512-tdRTrtnmOk3En1ZT1T+J+8NDwZl5FNxtN4w4Im2KV12QypPwyMi1ZBUXS2X4ZyDn4vPbHiOK8gjP+hQMsZ/V9g=="], - "@cortexkit/common-auth": ["@cortexkit/common-auth@0.43.0", "", { "dependencies": { "@cortexkit/private-files": "^0.2.2", "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@cortexkit/opencode-sidebar": "~0.1.0", "@cortexkit/subc-client": "^0.16.0 || ^0.22.0 || ^0.24.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.12 <2" }, "optionalPeers": ["@cortexkit/claustrum-client", "@cortexkit/opencode-sidebar", "@cortexkit/subc-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-J0vA1VHliTdDhBghn/G5oR6oRcm5sA0ScL/6ghwqMEsQjlSZ5K8q40vYVbW15gZcw69hcmVuA6Gbq1/26yKR4A=="], + "@cortexkit/common-auth": ["@cortexkit/common-auth@0.44.0", "", { "dependencies": { "@cortexkit/private-files": "^0.2.2", "jsonc-parser": "^3.3.1" }, "peerDependencies": { "@cortexkit/claustrum-client": ">=0.6.2", "@cortexkit/opencode-sidebar": "~0.1.0", "@cortexkit/subc-client": "^0.16.0 || ^0.22.0 || ^0.24.0", "@opencode/plugin": ">=2.0.21", "@opentui/core": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.12 <2" }, "optionalPeers": ["@cortexkit/claustrum-client", "@cortexkit/opencode-sidebar", "@cortexkit/subc-client", "@opencode/plugin", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-2MGgT3DpyrwuxhbXw7XXfbxQJZR2X2eznPW8KP7GRtHPEdFngx4JY+nAdPAygy5kZ7WpOUWTR1J2kFwLeOK/lg=="], "@cortexkit/opencode-claustrum-auth": ["@cortexkit/opencode-claustrum-auth@workspace:packages/opencode"], diff --git a/docs/setup.md b/docs/setup.md index c2e047a..4aec7eb 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -40,10 +40,19 @@ Vault mode is always on. `--claustrum` is accepted for compatibility; Approve each host's separate enrollment in `ck auth`. The apps show "Model API keys for OpenCode" or "Model API keys for Pi" when you approve the -connection. Setup prints the exact -approval and grant commands and the resolved state paths. Enrollment alone -does not grant credential access: follow the printed grant command for the -`llm-provider` category before activating a provider. +connection. Setup requests read access to the `llm-provider` category with +each enrollment, so approval grants that access in the same step on vaults +that support requested grants. Setup checks access after approval and prints +the resolved state paths and provider activation commands. + +Older vaults may approve enrollment without applying the requested grant. If +access is missing or cannot be confirmed, follow setup's printed grant command +before activating a provider: + +```sh +ck auth grant --principal enrolled:claustrum-auth-opencode --selector-kind category --selector llm-provider --operation read +ck auth grant --principal enrolled:claustrum-auth-pi --selector-kind category --selector llm-provider --operation read +``` Approve each additional instance of OpenCode or Pi with its own `--name`, and use that same name in the grant's `--principal enrolled:`. diff --git a/packages/core/package.json b/packages/core/package.json index 53f6165..c3bda07 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,8 +12,8 @@ "test": "bun test" }, "dependencies": { - "@cortexkit/claustrum-client": "^0.17.0", - "@cortexkit/common-auth": "^0.43.0", + "@cortexkit/claustrum-client": "^0.18.0", + "@cortexkit/common-auth": "^0.44.0", "@cortexkit/private-files": "^0.2.2", "@cortexkit/subc-client": "^0.24.0" } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c5b93fc..ae2b38c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -33,8 +33,8 @@ "test": "bun test" }, "dependencies": { - "@cortexkit/claustrum-client": "^0.17.0", - "@cortexkit/common-auth": "^0.43.0", + "@cortexkit/claustrum-client": "^0.18.0", + "@cortexkit/common-auth": "^0.44.0", "@cortexkit/private-files": "^0.2.2", "@cortexkit/subc-client": "^0.24.0" }, diff --git a/packages/opencode/src/setup.ts b/packages/opencode/src/setup.ts index da1a613..a91a2ce 100644 --- a/packages/opencode/src/setup.ts +++ b/packages/opencode/src/setup.ts @@ -16,7 +16,6 @@ import { printLine, processTerminal } from '@cortexkit/common-auth/auth-menu' import { connectClaustrumScopedClient, hostEnrollmentPaths, - readClaustrumEnrollmentStatus, VAULT_HOST_CONNECT_REFUSAL, type VaultGrantReach, vaultAuthority, @@ -104,11 +103,17 @@ export async function runClaustrumSetup( opencode: { name: opencodeEnrollment, displayName: 'Model API keys for OpenCode', + requestedGrants: [ + { selector: { category: 'llm-provider' }, operation: 'read' }, + ], paths: { stateDir: states.opencode, host: 'opencode' }, }, pi: { name: piEnrollment, displayName: 'Model API keys for Pi', + requestedGrants: [ + { selector: { category: 'llm-provider' }, operation: 'read' }, + ], paths: { stateDir: '', host: 'pi' }, }, }, @@ -194,20 +199,9 @@ export async function runClaustrumSetup( await built.hub.dispose() } } - let grantName = enrollment.name - if (discover) { - const status = await readClaustrumEnrollmentStatus( - hostEnrollmentPaths(enrollment.paths), - enrollment.name, - ) - if (status.state === 'approved' && status.approvedName) - grantName = status.approvedName - } - if (!reach || reach.grants === 0) { - if (discover && !reach) - print( - 'Grant reach is unknown; if access has not already been granted, use the grant command below.', - ) + // Enrollment prints confirmed access or the remedy. Dry runs skip enrollment. + if (!discover) { + const grantName = enrollment.name if (/^[a-z0-9][a-z0-9-]*$/u.test(grantName)) print( `Grant command: ck auth grant --principal enrolled:${grantName} --selector-kind category --selector llm-provider --operation read`, diff --git a/packages/opencode/test/setup.test.ts b/packages/opencode/test/setup.test.ts index 2b2c8ca..9b7c79d 100644 --- a/packages/opencode/test/setup.test.ts +++ b/packages/opencode/test/setup.test.ts @@ -1,9 +1,18 @@ import { afterEach, expect, test } from 'bun:test' import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { chmod, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' import path from 'node:path' import { + ClaustrumCredentialError, enrollmentAuthority, hostEnrollmentPaths, } from '@cortexkit/common-auth/claustrum' @@ -298,7 +307,7 @@ test('test setup build enrolls once per host, discovers rows, prints provider ac ) }) -test('setup proposes a person-facing display name for each vault host', async () => { +test('setup proposes a person-facing display name and provider read grant for each vault host', async () => { const root = await profile() const h = setupIO(root) await runClaustrumSetup( @@ -309,16 +318,150 @@ test('setup proposes a person-facing display name for each vault host', async () { name: 'claustrum-auth-opencode', displayName: 'Model API keys for OpenCode', + requestedGrants: [ + { selector: { category: 'llm-provider' }, operation: 'read' }, + ], requestSecretHash: expect.stringMatching(/^[a-f0-9]{64}$/u), }, { name: 'claustrum-auth-pi', displayName: 'Model API keys for Pi', + requestedGrants: [ + { selector: { category: 'llm-provider' }, operation: 'read' }, + ], requestSecretHash: expect.stringMatching(/^[a-f0-9]{64}$/u), }, ]) }) +test.each(['reachable', 'no-logins', 'unconfirmed', 'older-vault'] as const)( + 'setup relies on enrollment access guidance without duplicate grant hints (%s)', + async (access) => { + const root = await profile() + const h = setupIO(root) + const connect = h.io.connect + if (!connect) throw new Error('missing enrollment transport') + let lists = 0 + h.io.connect = async (options) => { + const client = await connect(options) + return { + ...client, + async enrollPropose(input) { + if (access === 'older-vault' && input.requestedGrants) + throw new ClaustrumCredentialError( + 'invalid_params', + 'permanent', + 'gone', + ) + return client.enrollPropose(input) + }, + async listScoped() { + lists++ + if (access === 'unconfirmed') throw new Error('vault unavailable') + return { + rows: access === 'reachable' ? [row('a')] : [], + view: 'setup', + grants: access === 'reachable' ? 1 : 0, + grantTuples: [], + } + }, + } + } + await runClaustrumSetup( + ['--yes', '--host=opencode', '--host=pi', '--opencode=both'], + h.io, + { + table: fixtureEntries, + createCore: (options) => + createTestCore({ + ...options, + connect: async () => ({ + async listScoped() { + return { rows: [], view: 'empty', grants: 0, grantTuples: [] } + }, + async getScoped() { + throw new Error('setup must not read credential material') + }, + async reportAuthFailureScoped() {}, + close() {}, + }), + }), + }, + ) + expect(lists).toBe(access === 'older-vault' ? 0 : 2) + const text = h.output() + if (access === 'reachable') { + expect(text).toContain('Confirmed read access') + expect(text).not.toContain('ck auth grant ') + } else { + expect(text).toContain( + access === 'older-vault' + ? 'This vault is too old to grant access with the approval' + : access === 'no-logins' + ? 'Approved, but no logins are reachable yet' + : 'Approved, but access could not be confirmed', + ) + for (const host of ['opencode', 'pi']) + expect(text).toContain( + `ck auth grant --principal enrolled:claustrum-auth-${host} --selector-kind category --selector llm-provider --operation read`, + ) + expect(text.match(/ck auth grant /gu)).toHaveLength(2) + } + expect(text).not.toContain('Grant command:') + expect(text).toContain('/claustrum use fixture-a') + expect(text).toContain( + 'opencode auth login fixture-a --method claustrum-auth', + ) + }, +) + +test.each([false, true])( + 'setup confirms access on an already approved enrollment (without stored grants: %j)', + async (withoutStoredGrants) => { + const root = await profile() + const h = setupIO(root) + const args = ['--yes', '--enroll-only', '--host=opencode', '--host=pi'] + await runClaustrumSetup(args, h.io) + if (withoutStoredGrants) { + const states = { + opencode: resolveOpencodeStateDir(h.env, root), + pi: path.join(h.env.PI_CODING_AGENT_DIR, 'claustrum-auth'), + } + for (const host of ['opencode', 'pi'] as const) { + const { statePath } = hostEnrollmentPaths({ + stateDir: states[host], + host, + }) + const state = JSON.parse(await readFile(statePath, 'utf8')) + delete state.requestedGrants + delete state.requestedGrantsStatus + await writeFile(statePath, JSON.stringify(state)) + } + } + const connect = h.io.connect + if (!connect) throw new Error('missing enrollment transport') + let lists = 0 + h.io.connect = async (options) => ({ + ...(await connect(options)), + async listScoped() { + lists++ + return { rows: [row('a')], view: 'setup', grants: 1, grantTuples: [] } + }, + }) + const start = h.output().length + await runClaustrumSetup(args, h.io) + expect(lists).toBe(2) + expect(h.proposals).toEqual([ + 'claustrum-auth-opencode', + 'claustrum-auth-pi', + ]) + const text = h.output().slice(start) + expect(text.match(/Already connected as /gu)).toHaveLength(2) + expect(text.match(/Confirmed read access /gu)).toHaveLength(2) + expect(text).not.toContain('ck auth grant ') + }, +) + test('setup refuses local sign-in and delegates enroll-only without host registration or credential reads', async () => { const root = await profile() const h = setupIO(root) diff --git a/packages/pi/package.json b/packages/pi/package.json index 4fb5092..a08ff73 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -31,8 +31,8 @@ "test": "bun test" }, "dependencies": { - "@cortexkit/claustrum-client": "^0.17.0", - "@cortexkit/common-auth": "^0.43.0", + "@cortexkit/claustrum-client": "^0.18.0", + "@cortexkit/common-auth": "^0.44.0", "@cortexkit/private-files": "^0.2.2", "@cortexkit/subc-client": "^0.24.0" },