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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ If the install later reports `ripgrep not found`, install ripgrep system-wide an
verboo
```

On first run, Verboo Code opens your browser at `https://code.verboo.ai` to complete the OAuth login. Once authenticated, your session tokens are stored securely in your system keychain (macOS Keychain, Windows Credential Manager, or Linux libsecret). No additional configuration required.
On first run, Verboo Code opens your browser at `https://code.verboo.ai` to complete the OAuth login. Once authenticated, your session tokens are stored securely in the native adapter for your platform (macOS Keychain, Windows DPAPI protected per-user storage, or Linux libsecret). No additional configuration required.

To log in manually at any time:

Expand Down
100 changes: 100 additions & 0 deletions docs/desktop-provider-accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Verboo Desktop provider accounts

This document is the desktop/CLI boundary for the optional Claude and Codex
account manager. The CLI remains the only credential authority. Desktop sees
opaque local account IDs and sanitized status/usage data; it never receives an
access token, refresh token, ID token, provider subject, email, organization ID,
credential path, or raw provider response.

## Commands

The CLI exposes one versioned JSON envelope for each command:

```text
verboo provider-accounts capabilities
verboo provider-accounts list
verboo provider-accounts models --provider codex|claude --account <opaque-id>
verboo provider-accounts usage --provider codex|claude [--account <opaque-id>]
verboo provider-accounts set-default --provider <provider> --account <opaque-id>
verboo provider-accounts remove --provider <provider> --account <opaque-id>
```

Every response has `schemaVersion: 1` and either `ok: true, data` or
`ok: false, error: { code, message }`. `capabilities` is safe to query before
authentication and returns `provider_accounts_v1`, `provider_usage_v1`,
`loginTransport: pty-slash-v1`, and a non-secret `secureStorage` descriptor
(`native: true` plus the platform adapter name) with a classified read probe
(`ok`, `missing`, or `error`) that never includes credential data. Provider login remains additive: `/codex login` and
`/claude login` add a new account when its provider identity is new; a
reconnect explicitly names the opaque local account.

## Account and usage fields

Account summaries may contain only:

- `provider` (`codex` or `claude`)
- opaque `accountId`, display label, `isDefault`
- `connectionState` (`connected` or `needs_reconnect`)
- optional sanitized plan label and validation timestamp

Usage snapshots contain the provider, opaque account ID, optional plan label,
fetch timestamp, and windows. A window has an opaque local ID, kind (`session`,
`weekly`, or `model-scoped-weekly`), display label, percentage used, optional
model scope, and optional reset timestamp.

The normalizer is provider-authoritative:

- Codex keeps the reported weekly window for the base limit and any separately
reported scoped weekly limit. A five-hour primary window is not presented as
a Codex weekly quota.
- Claude keeps reported five-hour and weekly windows. A Fable/model-scoped row
appears only when the usage response includes that explicit scope; Pro never
receives a fabricated Fable row.
- Missing resets, malformed limits, and unavailable provider windows are
omitted rather than replaced with zeroes.

No quota state automatically selects, rotates, ranks, or recommends another
account. The user explicitly changes the default or selects an account for a
new process. An active conversation remains owned by the app, so switching the
account for a later turn does not delete its transcript, attachments, or
selections; the spawned CLI process receives one immutable `--provider-account`
ID.

## Storage and migration

The encrypted `providerAccounts` v1 record migrates the old scalar Codex and
Claude credentials idempotently. The old scalar fields remain as a rollback
mirror of the selected default account. Removing a non-default account does not
change that mirror; removing the default selects the deterministic remaining
account, and removing the final account clears the mirror only after the secure
write succeeds. Claude risk acceptance remains bound to the exact provider
subject and cannot be copied to another account.

Storage uses the existing native adapters on every supported desktop:

| Target | Secure storage |
| --- | --- |
| macOS arm64/x64 | Keychain |
| Windows x64 | DPAPI-encrypted per-user file (CurrentUser scope) |
| Linux x64 | Secret Service |

Plaintext fallback remains disabled.

The desktop packaging smoke invokes `provider-accounts capabilities` for every
signed target and rejects an artifact that does not advertise the native
adapter or a classified probe result. The matching release runners still exercise the actual Keychain,
DPAPI, or Secret Service implementation for their operating system.

## Verification matrix

The signed release matrix remains macOS arm64, macOS x64, Windows x64, and
Linux x64. The protocol is additive and does not change the desktop protocol
version or package version. The current evidence is intentionally separated:

- Live verified when available: Codex Plus and Claude Max.
- Fixture-only until matching accounts are supplied: Codex Pro/Spark and Claude
Pro. Their parsers are covered by deterministic fixtures, not claimed as live
account tests.

The upstream maintainer publishes the signed version and release. A feature PR
must not create a tag or GitHub release.
9 changes: 9 additions & 0 deletions scripts/desktop-release/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ describe('desktop CLI release contract', () => {
expect(new Set(DESKTOP_TARGETS.map(item => item.target)).size).toBe(4)
})

test('provider account protocol keeps the signed four-target matrix', () => {
expect(DESKTOP_TARGETS.map(target => target.target)).toEqual([
'aarch64-apple-darwin',
'x86_64-apple-darwin',
'x86_64-pc-windows-msvc',
'x86_64-unknown-linux-gnu',
])
})

test('uses target-qualified immutable artifact names', () => {
expect(artifactName('0.15.5', 'aarch64-apple-darwin')).toBe(
'verboo-cli-0.15.5-aarch64-apple-darwin.tar.gz',
Expand Down
2 changes: 1 addition & 1 deletion scripts/desktop-release/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async function fixture() {
await mkdir(join(source, 'node_modules', 'dependency'), { recursive: true })
await writeFile(
join(source, 'dist', 'cli.mjs'),
"// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nconsole.log('1.2.3 (Verboo Code)')\n",
"// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nif (process.argv[2] === 'provider-accounts') console.log(JSON.stringify({ schemaVersion: 1, ok: true, data: { protocols: ['provider_accounts_v1'], secureStorage: { native: true, backend: 'fixture', probe: 'missing' } } })); else console.log('1.2.3 (Verboo Code)')\n",
)
await writeFile(join(source, 'node_modules', 'dependency', 'index.js'), 'export default 1\n')
await writeFile(join(source, 'LICENSE'), 'MIT\n')
Expand Down
53 changes: 53 additions & 0 deletions scripts/desktop-release/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export async function packageDesktopCli(
try {
const payload = await materializePayload({ ...input, stagingRoot })
await smokePayload(input.nodeExecutable, payload, input.version)
await smokeProviderAccounts(input.nodeExecutable, payload)
await runProcess('tar', [
'-czf',
archivePath,
Expand Down Expand Up @@ -197,6 +198,58 @@ async function smokePayload(
}
}

/**
* Exercise the versioned provider-account entrypoint on every signed target.
* Release builders do not have a user's Verboo session, so an auth-required
* envelope is an expected result; a process crash, malformed JSON, or a
* different failure is not.
*/
async function smokeProviderAccounts(
nodeExecutable: string,
payload: string,
): Promise<void> {
const result = await runProcess(
nodeExecutable,
[join(payload, 'dist', 'cli.mjs'), 'provider-accounts', 'capabilities'],
payload,
)
let envelope: unknown
try {
envelope = JSON.parse(result.stdout.trim())
} catch {
throw new Error('Provider-account smoke did not return JSON')
}
if (!envelope || typeof envelope !== 'object') {
throw new Error('Provider-account smoke returned an invalid envelope')
}
const record = envelope as {
schemaVersion?: unknown
ok?: unknown
data?: { protocols?: unknown; secureStorage?: { native?: unknown; backend?: unknown; probe?: unknown } }
error?: { code?: unknown }
}
if (record.schemaVersion !== 1) {
throw new Error('Provider-account smoke returned an unsupported schema')
}
if (record.ok === true) {
if (!Array.isArray(record.data?.protocols)
|| !record.data.protocols.includes('provider_accounts_v1')) {
throw new Error('Provider-account smoke omitted provider_accounts_v1')
}
if (record.data.secureStorage?.native !== true
|| typeof record.data.secureStorage.backend !== 'string'
|| !['ok', 'missing', 'error'].includes(String(record.data.secureStorage.probe))) {
throw new Error('Provider-account smoke did not verify native secure storage')
}
return
}
if (record.ok === false && (
record.error?.code === 'verboo_auth_required'
|| record.error?.code === 'provider_auth_required'
)) return
throw new Error('Provider-account smoke returned an unexpected failure')
}

async function hashFile(path: string): Promise<{ size: number; sha256: string }> {
const hash = createHash('sha256')
let size = 0
Expand Down
2 changes: 1 addition & 1 deletion scripts/desktop-release/verify-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async function releaseFixture() {
await mkdir(join(source, 'node_modules', 'dependency'), { recursive: true })
await writeFile(
join(source, 'dist', 'cli.mjs'),
"// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nconsole.log('1.2.3 (Verboo Code)')\n",
"// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nif (process.argv[2] === 'provider-accounts') console.log(JSON.stringify({ schemaVersion: 1, ok: true, data: { protocols: ['provider_accounts_v1'], secureStorage: { native: true, backend: 'fixture', probe: 'missing' } } })); else console.log('1.2.3 (Verboo Code)')\n",
)
await writeFile(join(source, 'node_modules', 'dependency', 'index.js'), 'export default 1\n')
await writeFile(join(source, 'LICENSE'), 'MIT\n')
Expand Down
Loading
Loading