mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 18:17:39 -10:00
feat(extension-host): fill the dsh/broker catalog arm: install plans, approved routes, read-only balance (CW-7988)
The catalog arm decodes four typed methods with `deny_unknown_fields`.
Any other method is a typed `unknown_method` refusal, malformed or
over-specified input is `invalid_input`, and nothing is passed through.
- `install.plan` `{package, version, registry?}` never installs. It checks
that the invocation carries a `PluginRegistry` snapshot (`not_available`
otherwise), passes the request to `plugins::install::dsh::plan_npm_install`,
and returns `review_required` with the reviewed installer's spec and
command or `already_present` with the next review step from
`lookup_reviewable_plugin`. Ranges, tags, non-https registries, or
invalid npm names are refused. No method installs, removes, or trusts.
- `routes.list` and `routes.resolve` `{provider, model?}` answer only with
validated provider declarations from the calling plugin's active manifest
(`call.owner.plugin_id`, never an input field). Before every answer,
`verify_plugin_component_authority` rechecks `PluginActivationCapability::Providers`
inside `spawn_blocking` with propagated policy and test env scope, so
revoking review refuses routes immediately even while the snapshot is cached.
- `balance` `{}` takes no input and returns `known` (`BalanceInfo` fields),
`unknown`, or `absent` without leaking credentials, tokens, or account IDs.
- All four catalog methods check `call.context.act(..., BrokerEffect::Read, ...)`
so every call participates in the shared 50-call invocation budget, honors
mid-call cancellation/withdrawal, and leaves a sealed `broker_acts` receipt
without opening an interactive approval card.
TypeScript: `crates/engine/extension-host/src/dsh/catalog.ts` adds typed
helpers (`approvedRoutes`, `resolveRoute`, `accountBalance`, `planInstall`)
and unregistered adapters (`pluginManagerAdapter`, `deepseekAccountAdapter`),
rejecting credential or state-mutating account methods with `NotAvailable`.
`compatibility.json` and `DSH_ADAPTERS` remain closed until host services
are offered.
Also adds the `CW-7988` release note under `[Unreleased]` in `CHANGELOG.md`
and syncs `crates/tui/CHANGELOG.md`.
Checks (rebased on origin/main efab90bc71 / pre-rebase e6d832ab5a5 with identical code tree):
- Boat xlarge nextest (`20261114T010152Z-dsh11-test-9dadab`): 345 passed, 0 failed, 7165 skipped (`codewhale-plugins`, `codewhale-engine`)
- Boat xlarge clippy (`20261114T010158Z-dsh11-clippy-2beb96`): exit 0, 0 warnings (`-p codewhale-plugins -p codewhale-engine --lib --tests -- -D warnings`)
- JS extension-host (`node --test crates/engine/extension-host/test/catalog.test.mjs`): 3 passed, 0 failed (`471 passed, 7 skipped` across `test/*.test.mjs` at `e6d832ab5a5`)
- `sh scripts/preflight.sh --check` on rebased tip: preflight: OK
- Dual independent `pro` reviews (`92f64cd3`, `dbfa2202`): Verdict LAND
This commit is contained in:
1 parent
adddc5cc85
commit
b7249e5e58
6 files changed
+1042
-6
No files matched your search
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
- **Agent `cwd` in sibling worktrees:** spawning an agent with `cwd` pointing to an existing linked worktree (or a subdirectory inside it) under the repository's `.codewhale-worktrees/<repo>/` root is now accepted when both share the same repository common git directory; outside directories and foreign or plain directories under `.codewhale-worktrees/` stay refused. (CW-8215)
|
||||
- **DSH market npm installs and extension catalog service:** DSH market `npm` entries with an exact semantic version (and optional `https` registry) now resolve to reviewed registry-tarball install plans (`npm:<pkg>@<ver>` or exact `.tgz` URL) without executing `npm` or bypassing plugin trust review. Inside the extension host, the `dsh/catalog` broker arm now plans reviewed installs without writing to disk, returns only verified approved provider routes scoped to the calling plugin, and serves account balance read-only while withholding credentials and account mutations (CW-7988).
|
||||
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
|
||||
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
|
||||
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
export interface ApprovedRoute {
|
||||
provider: string
|
||||
base_url: string
|
||||
default_model: string | null
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface ResolvedRoute extends ApprovedRoute {
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export interface InstallPlan {
|
||||
status: 'review_required' | 'already_present'
|
||||
package: string
|
||||
version: string
|
||||
plugin: string
|
||||
spec?: string
|
||||
command: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BalanceAnswer =
|
||||
| { status: 'known', currency: string, total: string, topped_up: string, granted: string }
|
||||
| { status: 'unknown' }
|
||||
| { status: 'absent' }
|
||||
|
||||
interface AccountWallet {
|
||||
currency: string
|
||||
balance: string
|
||||
}
|
||||
|
||||
type AccountBalance = { status: 'ready', value: AccountWallet[], bonusWallets: AccountWallet[] } | { status: 'failed' }
|
||||
|
||||
interface ChangeResult {
|
||||
changed: false
|
||||
application: 'failed'
|
||||
stage: 'install' | 'remove'
|
||||
target: string
|
||||
error: { code: string, diagnostic: string, message: string }
|
||||
}
|
||||
|
||||
const EXACT_VERSION_ONLY = 'Codewhale plans a plugin install only for one exact npm version, written [email protected]'
|
||||
const REMOVE_IN_CODEWHALE = 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall'
|
||||
const WITHHELD_ACCOUNT_METHODS = [
|
||||
'getState', 'getProfile', 'getUnnotifiedBonuses', 'ackBonusNotified', 'startSignIn', 'cancelSignIn', 'signOut', 'watch',
|
||||
'resolveToken', 'rejectToken', 'getPlatformSession', 'getDeviceIdentity',
|
||||
] as const
|
||||
|
||||
function invalid(what: string): RpcError {
|
||||
return new RpcError(ErrorCode.Internal, `invalid catalog broker ${what}`)
|
||||
}
|
||||
|
||||
function record(value: unknown, what: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalid(what)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function text(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw invalid(what)
|
||||
return value
|
||||
}
|
||||
|
||||
function named(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, `${what} is a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function approvedRoute(value: unknown): ApprovedRoute {
|
||||
const row = record(value, 'route')
|
||||
const models = row.models
|
||||
const defaultModel = row.default_model
|
||||
if (!Array.isArray(models) || models.some((model) => typeof model !== 'string') || (defaultModel !== null && typeof defaultModel !== 'string')) throw invalid('route')
|
||||
return { provider: text(row.provider, 'route'), base_url: text(row.base_url, 'route'), default_model: defaultModel, models: [...models] }
|
||||
}
|
||||
|
||||
export async function approvedRoutes(host: AdapterHost, ctx: unknown): Promise<ApprovedRoute[]> {
|
||||
const routes = record(await host.broker(ctx, 'catalog', 'routes.list', {}), 'routes').routes
|
||||
if (!Array.isArray(routes)) throw invalid('routes')
|
||||
return routes.map(approvedRoute)
|
||||
}
|
||||
|
||||
export async function resolveRoute(host: AdapterHost, ctx: unknown, provider: unknown, model?: unknown): Promise<ResolvedRoute> {
|
||||
const input = model === undefined || model === null
|
||||
? { provider: named(provider, 'a provider route') }
|
||||
: { provider: named(provider, 'a provider route'), model: named(model, 'a model') }
|
||||
const answer = record(await host.broker(ctx, 'catalog', 'routes.resolve', input), 'route')
|
||||
const resolved = answer.model
|
||||
if (resolved !== null && typeof resolved !== 'string') throw invalid('route')
|
||||
return { ...approvedRoute(answer.route), model: resolved }
|
||||
}
|
||||
|
||||
export async function accountBalance(host: AdapterHost, ctx: unknown): Promise<BalanceAnswer> {
|
||||
const answer = record(await host.broker(ctx, 'catalog', 'balance', {}), 'balance')
|
||||
const status = answer.status
|
||||
if (status === 'unknown' || status === 'absent') return { status }
|
||||
const toppedUp = answer.topped_up
|
||||
const granted = answer.granted
|
||||
if (status !== 'known' || typeof toppedUp !== 'string' || typeof granted !== 'string') throw invalid('balance')
|
||||
return { status, currency: text(answer.currency, 'balance'), total: text(answer.total, 'balance'), topped_up: toppedUp, granted }
|
||||
}
|
||||
|
||||
export async function planInstall(host: AdapterHost, ctx: unknown, spec: unknown, registry?: unknown): Promise<InstallPlan> {
|
||||
const exact = typeof spec === 'string' ? spec.lastIndexOf('@') : -1
|
||||
if (typeof spec !== 'string' || exact <= 0 || exact === spec.length - 1) throw new RpcError(ErrorCode.InvalidParams, EXACT_VERSION_ONLY)
|
||||
const request = { package: spec.slice(0, exact), version: spec.slice(exact + 1) }
|
||||
const input = registry === undefined || registry === null ? request : { ...request, registry: named(registry, 'a registry URL') }
|
||||
const plan = record(await host.broker(ctx, 'catalog', 'install.plan', input), 'install plan')
|
||||
const status = plan.status
|
||||
const planned = plan.spec
|
||||
if ((status !== 'review_required' && status !== 'already_present') || (planned !== undefined && typeof planned !== 'string')) throw invalid('install plan')
|
||||
return {
|
||||
status,
|
||||
package: text(plan.package, 'install plan'),
|
||||
version: text(plan.version, 'install plan'),
|
||||
plugin: text(plan.plugin, 'install plan'),
|
||||
...(planned === undefined ? {} : { spec: planned }),
|
||||
command: text(plan.command, 'install plan'),
|
||||
message: text(plan.message, 'install plan'),
|
||||
}
|
||||
}
|
||||
|
||||
function notChanged(stage: ChangeResult['stage'], target: unknown, code: string, diagnostic: string): ChangeResult {
|
||||
return { changed: false, application: 'failed', stage, target: typeof target === 'string' ? target : '', error: { code, diagnostic, message: diagnostic } }
|
||||
}
|
||||
|
||||
function declined(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error)) return undefined
|
||||
if (error instanceof RpcError && error.code === ErrorCode.InvalidParams && error.data === undefined) return error.message
|
||||
const refusal = (error as { data?: { dsh_refusal?: unknown } }).data?.dsh_refusal
|
||||
return refusal === 'refused' || refusal === 'invalid_input' ? error.message : undefined
|
||||
}
|
||||
|
||||
export const pluginManagerAdapter: DshAdapter = {
|
||||
service: 'pluginManager',
|
||||
define(host) {
|
||||
class PluginManagerService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'pluginManager') }
|
||||
|
||||
async installBundle(spec: unknown, options?: { registry?: unknown } | null): Promise<ChangeResult> {
|
||||
try {
|
||||
const plan = await planInstall(host, this.ctx, spec, options?.registry)
|
||||
return notChanged('install', spec, plan.status === 'already_present' ? 'already-installed' : 'management-required', plan.message)
|
||||
} catch (error) {
|
||||
const diagnostic = declined(error)
|
||||
if (diagnostic === undefined) throw error
|
||||
return notChanged('install', spec, 'invalid-spec', diagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
async removeBundle(target: unknown): Promise<ChangeResult> {
|
||||
return notChanged('remove', target, 'management-required', REMOVE_IN_CODEWHALE)
|
||||
}
|
||||
|
||||
async cancelInstall(): Promise<{ status: 'not-running' }> {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
}
|
||||
Object.freeze(PluginManagerService.prototype)
|
||||
return PluginManagerService
|
||||
},
|
||||
}
|
||||
|
||||
export const deepseekAccountAdapter: DshAdapter = {
|
||||
service: 'deepseekAccount',
|
||||
define(host) {
|
||||
class DeepSeekAccountService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'deepseekAccount') }
|
||||
|
||||
async getBalance(): Promise<AccountBalance | null> {
|
||||
const answer = await accountBalance(host, this.ctx)
|
||||
if (answer.status === 'absent') return null
|
||||
if (answer.status === 'unknown') return { status: 'failed' }
|
||||
const { currency, total, topped_up: toppedUp, granted } = answer
|
||||
const wallet = (balance: string): AccountWallet[] => balance.length > 0 ? [{ currency, balance }] : []
|
||||
return { status: 'ready', value: wallet(toppedUp.length > 0 ? toppedUp : total), bonusWallets: wallet(granted) }
|
||||
}
|
||||
}
|
||||
for (const method of WITHHELD_ACCOUNT_METHODS) {
|
||||
Object.defineProperty(DeepSeekAccountService.prototype, method, {
|
||||
value() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, `deepseekAccount.${method} is not provided: Codewhale answers a read-only balance and never gives a plugin an account credential or a sign-in`)
|
||||
},
|
||||
})
|
||||
}
|
||||
Object.freeze(DeepSeekAccountService.prototype)
|
||||
return DeepSeekAccountService
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let catalog
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-catalog-'))
|
||||
const outfile = join(dir, 'catalog.mjs')
|
||||
await build({ entryPoints: [join(here, '../src/dsh/catalog.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
catalog = await import(pathToFileURL(outfile).href)
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function brokerHost(answers) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
host: {
|
||||
tier: 'plugin',
|
||||
broker: async (ctx, service, method, input) => {
|
||||
calls.push({ service, method, input })
|
||||
const answer = typeof answers[method] === 'function' ? answers[method](input) : answers[method]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function serviceOf(adapter, host) {
|
||||
const service = Object.create(adapter.define(host).prototype)
|
||||
service.ctx = {}
|
||||
return service
|
||||
}
|
||||
|
||||
const brokerRefusal = (code, refusal, message) => Object.assign(new Error(message), { code, data: { dsh_refusal: refusal, service: 'catalog' } })
|
||||
|
||||
test('a balance widget reads an absent account, an unknown balance and a known balance, and never a key', async () => {
|
||||
const answers = { balance: { status: 'absent' } }
|
||||
const { host, calls } = brokerHost(answers)
|
||||
const account = serviceOf(catalog.deepseekAccountAdapter, host)
|
||||
const client = { version: '0.4.2', locale: 'zh_CN', timezoneOffsetSeconds: 28800 }
|
||||
|
||||
assert.equal(await account.getBalance(client), null)
|
||||
answers.balance = { status: 'unknown' }
|
||||
assert.deepEqual(await account.getBalance(client), { status: 'failed' })
|
||||
answers.balance = { status: 'known', currency: 'CNY', total: '123.45', topped_up: '100.00', granted: '23.45' }
|
||||
const ready = await account.getBalance(client)
|
||||
assert.deepEqual(ready, { status: 'ready', value: [{ currency: 'CNY', balance: '100.00' }], bonusWallets: [{ currency: 'CNY', balance: '23.45' }] })
|
||||
const shown = [...ready.value, ...ready.bonusWallets].reduce((sum, wallet) => sum + Number(wallet.balance), 0)
|
||||
assert.equal(shown.toFixed(2), '123.45')
|
||||
answers.balance = { status: 'known', currency: 'USD', total: '12.50', topped_up: '', granted: '' }
|
||||
assert.deepEqual(await account.getBalance(client), { status: 'ready', value: [{ currency: 'USD', balance: '12.50' }], bonusWallets: [] })
|
||||
|
||||
assert.deepEqual(calls.map((call) => [call.service, call.method, call.input]), Array(4).fill(['catalog', 'balance', {}]))
|
||||
for (const method of ['resolveToken', 'getPlatformSession', 'getState', 'startSignIn', 'signOut', 'rejectToken']) {
|
||||
assert.throws(() => account[method]('https://api.deepseek.com'), (error) => error.code === -32001 && error.message.includes(`deepseekAccount.${method} is not provided`))
|
||||
}
|
||||
assert.equal(calls.length, 4)
|
||||
|
||||
answers.balance = { status: 'known', currency: 'CNY', total: '1', topped_up: '1', granted: '0', api_key: 'sk-raw' }
|
||||
assert.deepEqual(Object.keys(await catalog.accountBalance(host, {})).sort(), ['currency', 'granted', 'status', 'topped_up', 'total'])
|
||||
answers.balance = { status: 'ready', value: [] }
|
||||
await assert.rejects(account.getBalance(client), /invalid catalog broker balance/)
|
||||
answers.balance = brokerRefusal(-32001, 'not_available', 'the catalog broker is not available yet')
|
||||
await assert.rejects(account.getBalance(client), /not available yet/)
|
||||
})
|
||||
|
||||
test('a market plugin asking to install gets the reviewed-install answer, and nothing installs or removes', async () => {
|
||||
const review = {
|
||||
status: 'review_required', package: '@acme/review', version: '1.10.0', plugin: 'review', spec: 'npm:@acme/[email protected]',
|
||||
command: '/plugin install npm:@acme/[email protected]',
|
||||
message: 'Codewhale installs a plugin only through its reviewed install flow; nothing was installed. Review and install @acme/[email protected] with `/plugin install npm:@acme/[email protected]`.',
|
||||
}
|
||||
const answers = { 'install.plan': review }
|
||||
const { host, calls } = brokerHost(answers)
|
||||
const manager = serviceOf(catalog.pluginManagerAdapter, host)
|
||||
|
||||
const requested = await manager.installBundle('@acme/[email protected]', { requestId: 'r1', registry: 'https://registry.example.com' })
|
||||
assert.deepEqual(requested, {
|
||||
changed: false, application: 'failed', stage: 'install', target: '@acme/[email protected]',
|
||||
error: { code: 'management-required', diagnostic: review.message, message: review.message },
|
||||
})
|
||||
assert.deepEqual(calls[0], { service: 'catalog', method: 'install.plan', input: { package: '@acme/review', version: '1.10.0', registry: 'https://registry.example.com' } })
|
||||
await manager.installBundle('[email protected]')
|
||||
assert.deepEqual(calls[1].input, { package: 'dsh-demo', version: '1.2.3' })
|
||||
|
||||
answers['install.plan'] = { status: 'already_present', package: 'dsh-demo', version: '1.2.3', plugin: 'dsh-demo', command: '/plugin trust dsh-demo', message: 'A plugin named `dsh-demo` is already present; nothing was installed.' }
|
||||
assert.equal((await manager.installBundle('[email protected]')).error.code, 'already-installed')
|
||||
|
||||
answers['install.plan'] = brokerRefusal(-32002, 'refused', '`dsh-demo@^1.2.3` is not an exact npm package version Codewhale can fetch')
|
||||
const range = await manager.installBundle('dsh-demo@^1.2.3')
|
||||
assert.equal(range.error.code, 'invalid-spec')
|
||||
assert.match(range.error.diagnostic, /not an exact npm package version/)
|
||||
|
||||
const sent = calls.length
|
||||
for (const spec of ['dsh-demo', '@acme/review', 'git+https://github.com/acme/review.git#0123456789abcdef0123456789abcdef01234567', 'dsh-demo@', 7]) {
|
||||
const refused = await manager.installBundle(spec)
|
||||
assert.equal(refused.error.code, 'invalid-spec', String(spec))
|
||||
assert.equal(refused.application, 'failed')
|
||||
}
|
||||
assert.equal((await manager.installBundle('[email protected]', { registry: 7 })).error.code, 'invalid-spec')
|
||||
assert.deepEqual(await manager.removeBundle('dsh-demo'), {
|
||||
changed: false, application: 'failed', stage: 'remove', target: 'dsh-demo',
|
||||
error: { code: 'management-required', diagnostic: 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall', message: 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall' },
|
||||
})
|
||||
assert.deepEqual(await manager.cancelInstall('r1'), { status: 'not-running' })
|
||||
assert.equal(calls.length, sent, 'a spec that is not name@exact-version, a removal and a cancel never reach the broker')
|
||||
|
||||
answers['install.plan'] = brokerRefusal(-32002, 'ticket_refused', 'the invocation this dsh/broker:catalog belongs to has ended')
|
||||
await assert.rejects(manager.installBundle('[email protected]'), /has ended/)
|
||||
answers['install.plan'] = { status: 'installed', package: 'dsh-demo', version: '1.2.3', plugin: 'dsh-demo', command: 'x', message: 'x' }
|
||||
await assert.rejects(manager.installBundle('[email protected]'), /invalid catalog broker install plan/)
|
||||
})
|
||||
|
||||
test('a model-proxy directory is built from approved provider routes, and an unapproved route is the refusal', async () => {
|
||||
const gateway = { provider: 'gateway', base_url: 'https://gateway.example/api', default_model: 'tiny-model', models: ['tiny-model', 'large-model'] }
|
||||
const answers = {
|
||||
'routes.list': { routes: [gateway] },
|
||||
'routes.resolve': (input) => input.provider === 'gateway'
|
||||
? { route: gateway, model: input.model ?? gateway.default_model }
|
||||
: brokerRefusal(-32002, 'refused', `\`${input.provider}\` is not a provider route approved for this plugin`),
|
||||
}
|
||||
const { host, calls } = brokerHost(answers)
|
||||
|
||||
const routes = await catalog.approvedRoutes(host, {})
|
||||
const directory = {
|
||||
providers: routes.map((route) => ({ provider: route.provider, active: true })),
|
||||
models: routes.map((route) => ({ provider: route.provider, models: route.models.map((id) => ({ id })) })),
|
||||
}
|
||||
assert.deepEqual(directory, {
|
||||
providers: [{ provider: 'gateway', active: true }],
|
||||
models: [{ provider: 'gateway', models: [{ id: 'tiny-model' }, { id: 'large-model' }] }],
|
||||
})
|
||||
|
||||
assert.deepEqual(await catalog.resolveRoute(host, {}, 'gateway', 'large-model'), { ...gateway, model: 'large-model' })
|
||||
assert.deepEqual(await catalog.resolveRoute(host, {}, 'gateway'), { ...gateway, model: 'tiny-model' })
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, 'openai', 'gpt-x'), /not a provider route approved for this plugin/)
|
||||
assert.deepEqual(calls.map((call) => [call.service, call.method, call.input]), [
|
||||
['catalog', 'routes.list', {}],
|
||||
['catalog', 'routes.resolve', { provider: 'gateway', model: 'large-model' }],
|
||||
['catalog', 'routes.resolve', { provider: 'gateway' }],
|
||||
['catalog', 'routes.resolve', { provider: 'openai', model: 'gpt-x' }],
|
||||
])
|
||||
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, '', 'x'), (error) => error.code === -32602)
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, 'gateway', 7), (error) => error.code === -32602)
|
||||
assert.equal(calls.length, 4)
|
||||
answers['routes.list'] = { routes: [{ provider: 'gateway', base_url: 'https://gateway.example/api', default_model: null, models: [7] }] }
|
||||
await assert.rejects(catalog.approvedRoutes(host, {}), /invalid catalog broker route/)
|
||||
answers['routes.list'] = [gateway]
|
||||
await assert.rejects(catalog.approvedRoutes(host, {}), /invalid catalog broker routes/)
|
||||
})
|
||||
@@ -1866,7 +1866,11 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
|
||||
for service in DshService::ALL.into_iter().filter(|s| {
|
||||
!matches!(
|
||||
s,
|
||||
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
|
||||
DshService::Network
|
||||
| DshService::Remote
|
||||
| DshService::Terminal
|
||||
| DshService::Workspace
|
||||
| DshService::Catalog
|
||||
)
|
||||
}) {
|
||||
let ticket = &tickets_a[service.name()];
|
||||
@@ -2145,7 +2149,11 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
|
||||
}
|
||||
if !matches!(
|
||||
service,
|
||||
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
|
||||
DshService::Network
|
||||
| DshService::Remote
|
||||
| DshService::Terminal
|
||||
| DshService::Workspace
|
||||
| DshService::Catalog
|
||||
) {
|
||||
let result = f
|
||||
.broker(
|
||||
@@ -4229,3 +4237,347 @@ async fn every_way_a_plugin_can_end_its_call_after_an_act_returns_the_record_thr
|
||||
}
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
fn catalog_without_an_account(
|
||||
call: super::dsh_broker::BrokerCall,
|
||||
) -> super::dsh_broker::BrokerFuture {
|
||||
super::dsh_catalog::serve_with(
|
||||
call,
|
||||
Some(super::dsh_catalog::AccountBalance {
|
||||
available: false,
|
||||
reading: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn catalog_with_a_balance(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
|
||||
super::dsh_catalog::serve_with(
|
||||
call,
|
||||
Some(super::dsh_catalog::AccountBalance {
|
||||
available: true,
|
||||
reading: Some(crate::pricing::BalanceInfo {
|
||||
currency: "CNY".into(),
|
||||
total_balance: "123.45".into(),
|
||||
topped_up_balance: "100.00".into(),
|
||||
granted_balance: "23.45".into(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_catalog_arm_plans_reviewed_installs_approved_routes_and_a_keyless_balance() {
|
||||
use super::dsh_broker::{Arm, TABLE, TestGrants};
|
||||
use super::protocol::DshService;
|
||||
use codewhale_plugins::discovery::{DiscoveryConfig, discover_with_config};
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace, DshService::Catalog]);
|
||||
let mut f = fixture(true);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
let user_plugins = temp.path().join("plugins");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let bundle = |name: &str, extension: Value| {
|
||||
let dir = user_plugins.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = json!({
|
||||
"$schema": codewhale_plugins::agent_plugin::PLUGIN_SCHEMA_URL,
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"extensions": { "net.codewhale": extension },
|
||||
});
|
||||
std::fs::write(dir.join("plugin.json"), manifest.to_string()).unwrap();
|
||||
};
|
||||
bundle(
|
||||
"route-demo",
|
||||
json!({"providers": {"gateway": {
|
||||
"base_url": "https://gateway.example/api",
|
||||
"model": "tiny-model",
|
||||
"models": ["tiny-model"],
|
||||
"oauth": {
|
||||
"issuer": "https://gateway.example",
|
||||
"authorization_endpoint": "https://gateway.example/authorize",
|
||||
"token_endpoint": "https://gateway.example/token",
|
||||
"client_id": "custom-cli",
|
||||
"scopes": ["models:invoke"],
|
||||
},
|
||||
}}}),
|
||||
);
|
||||
bundle("idle-demo", json!({}));
|
||||
let discovery = DiscoveryConfig {
|
||||
workspace: workspace.clone(),
|
||||
user_plugins_dir: user_plugins.clone(),
|
||||
workspace_plugins_dir: workspace.join(".codewhale/plugins"),
|
||||
builtin_plugin_dirs: Vec::new(),
|
||||
state_path: temp.path().join("state/plugins.json"),
|
||||
};
|
||||
let mut reviewed = discover_with_config(&discovery);
|
||||
reviewed.trust("route-demo").unwrap();
|
||||
reviewed.enable("route-demo").unwrap();
|
||||
let reviewed = discover_with_config(&discovery);
|
||||
assert!(reviewed.is_active("route-demo"));
|
||||
assert!(
|
||||
reviewed
|
||||
.get("idle-demo")
|
||||
.is_some_and(|plugin| !plugin.active())
|
||||
);
|
||||
|
||||
let owner = {
|
||||
let mut owners = f.manager.shared.registry.lock().unwrap();
|
||||
let owner = owners
|
||||
.begin_owner(
|
||||
HostTier::Plugin,
|
||||
"route-demo",
|
||||
"route-demo",
|
||||
Some(fake_authority("route-demo")),
|
||||
"hash",
|
||||
)
|
||||
.unwrap();
|
||||
owners.mark_active(&owner);
|
||||
owner
|
||||
};
|
||||
f.context = f.context.clone().with_plugin_registry(Arc::new(reviewed));
|
||||
f.in_session("catalog-session-a");
|
||||
let guard = f.begin(7, &owner);
|
||||
let catalog = broker_ticket(&guard, "catalog").unwrap();
|
||||
let workspace_ticket = broker_ticket(&guard, "workspace").unwrap();
|
||||
let ask = async |method: &'static str, input: Value| {
|
||||
f.broker(TABLE, &owner, &catalog, "catalog", method, input)
|
||||
.await
|
||||
};
|
||||
let declined = (error_code::REFUSED, "refused".to_string());
|
||||
|
||||
let plan = ask(
|
||||
"install.plan",
|
||||
json!({"package": "dsh-demo", "version": "1.2.3"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(plan["status"], "review_required");
|
||||
assert_eq!(plan["plugin"], "dsh-demo");
|
||||
assert_eq!(plan["spec"], "npm:[email protected]");
|
||||
assert_eq!(plan["command"], "/plugin install npm:[email protected]");
|
||||
assert!(
|
||||
plan["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("nothing was installed")
|
||||
);
|
||||
let mirrored = ask(
|
||||
"install.plan",
|
||||
json!({"package": "@acme/dsh-demo", "version": "1.2.3", "registry": "https://registry.example.com/npm"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mirrored["spec"],
|
||||
"https://registry.example.com/npm/@acme/dsh-demo/-/dsh-demo-1.2.3.tgz"
|
||||
);
|
||||
let present = ask(
|
||||
"install.plan",
|
||||
json!({"package": "@acme/idle-demo", "version": "2.0.0"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(present["status"], "already_present");
|
||||
assert_eq!(present["command"], "/plugin trust idle-demo");
|
||||
assert!(present.get("spec").is_none());
|
||||
for unreviewable in [
|
||||
json!({"package": "dsh-demo", "version": "^1.2.3"}),
|
||||
json!({"package": "dsh-demo", "version": "latest"}),
|
||||
json!({"package": "../dsh-demo", "version": "1.2.3"}),
|
||||
json!({"package": "dsh-demo", "version": "1.2.3", "registry": "http://registry.example.com"}),
|
||||
] {
|
||||
let result = ask("install.plan", unreviewable.clone()).await;
|
||||
assert_eq!(dsh_refusal(&result), declined, "{unreviewable}");
|
||||
}
|
||||
let forced = ask(
|
||||
"install.plan",
|
||||
json!({"package": "dsh-demo", "version": "1.2.3", "force": true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&forced),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
for method in ["install", "install.run", "routes.register", "balance.topup"] {
|
||||
let result = ask(method, json!({"package": "dsh-demo", "version": "1.2.3"})).await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::INVALID_PARAMS, "unknown_method".into()),
|
||||
"{method}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!user_plugins.join("dsh-demo").exists(),
|
||||
"a plan installs nothing"
|
||||
);
|
||||
|
||||
let gateway = json!({
|
||||
"provider": "gateway",
|
||||
"base_url": "https://gateway.example/api",
|
||||
"default_model": "tiny-model",
|
||||
"models": ["tiny-model"],
|
||||
});
|
||||
assert_eq!(
|
||||
ask("routes.list", json!({})).await.unwrap(),
|
||||
json!({"routes": [gateway.clone()]})
|
||||
);
|
||||
assert_eq!(
|
||||
ask(
|
||||
"routes.resolve",
|
||||
json!({"provider": "gateway", "model": "tiny-model"})
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"route": gateway.clone(), "model": "tiny-model"})
|
||||
);
|
||||
assert_eq!(
|
||||
ask("routes.resolve", json!({"provider": "gateway"}))
|
||||
.await
|
||||
.unwrap()["model"],
|
||||
"tiny-model"
|
||||
);
|
||||
for unapproved in [
|
||||
json!({"provider": "openai"}),
|
||||
json!({"provider": "Gateway"}),
|
||||
json!({"provider": "gateway", "model": "large-model"}),
|
||||
] {
|
||||
let result = ask("routes.resolve", unapproved.clone()).await;
|
||||
assert_eq!(dsh_refusal(&result), declined, "{unapproved}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
ask("balance", json!({})).await.unwrap(),
|
||||
json!({"status": "unknown"})
|
||||
);
|
||||
let steered = ask(
|
||||
"balance",
|
||||
json!({"provider": "deepseek", "api_key": "sk-x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&steered),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
let absent: &[(DshService, Arm)] = &[(DshService::Catalog, catalog_without_an_account as Arm)];
|
||||
assert_eq!(
|
||||
f.broker(absent, &owner, &catalog, "catalog", "balance", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"status": "absent"})
|
||||
);
|
||||
let known: &[(DshService, Arm)] = &[(DshService::Catalog, catalog_with_a_balance as Arm)];
|
||||
assert_eq!(
|
||||
f.broker(known, &owner, &catalog, "catalog", "balance", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"status": "known", "currency": "CNY", "total": "123.45", "topped_up": "100.00", "granted": "23.45"})
|
||||
);
|
||||
|
||||
for (presenter, ticket) in [
|
||||
(&f.other, catalog.as_str()),
|
||||
(&owner, workspace_ticket.as_str()),
|
||||
(&owner, guard.ticket()),
|
||||
] {
|
||||
let result = f
|
||||
.broker(
|
||||
TABLE,
|
||||
presenter,
|
||||
ticket,
|
||||
"catalog",
|
||||
"routes.list",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
}
|
||||
let other_guard = f.begin(7, &f.other);
|
||||
let other_catalog = broker_ticket(&other_guard, "catalog").unwrap();
|
||||
assert_eq!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.other,
|
||||
&other_catalog,
|
||||
"catalog",
|
||||
"routes.list",
|
||||
json!({})
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"routes": []}),
|
||||
"another plugin has no approved routes and sees none of this plugin's"
|
||||
);
|
||||
let borrowed = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.other,
|
||||
&other_catalog,
|
||||
"catalog",
|
||||
"routes.resolve",
|
||||
json!({"provider": "gateway"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(dsh_refusal(&borrowed), declined);
|
||||
drop(other_guard);
|
||||
|
||||
let session_a = f.context.clone();
|
||||
f.context = ToolContext::new(&session_a.workspace);
|
||||
f.in_session("catalog-session-b");
|
||||
let guard_b = f.begin(7, &owner);
|
||||
let catalog_b = broker_ticket(&guard_b, "catalog").unwrap();
|
||||
f.context = session_a;
|
||||
let in_b = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&owner,
|
||||
&catalog_b,
|
||||
"catalog",
|
||||
"routes.list",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&in_b),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into()),
|
||||
"session b's invocation has no plugin snapshot and never borrows session a's"
|
||||
);
|
||||
assert_eq!(
|
||||
f.broker(TABLE, &owner, &catalog, "catalog", "routes.list", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"routes": [gateway]})
|
||||
);
|
||||
|
||||
discover_with_config(&discovery)
|
||||
.revoke_trust("route-demo")
|
||||
.unwrap();
|
||||
for (method, input) in [
|
||||
("routes.list", json!({})),
|
||||
("routes.resolve", json!({"provider": "gateway"})),
|
||||
] {
|
||||
let result = f
|
||||
.broker(TABLE, &owner, &catalog, "catalog", method, input)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
declined,
|
||||
"{method} after the review is withdrawn"
|
||||
);
|
||||
assert!(result.unwrap_err().message.contains("no longer holds"));
|
||||
}
|
||||
f.manager.shared.core_calls.revoke_owner("route-demo");
|
||||
let revoked = f
|
||||
.broker(TABLE, &owner, &catalog, "catalog", "balance", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&revoked),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
drop(guard_b);
|
||||
drop(guard);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
@@ -1,6 +1,337 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Catalog)
|
||||
use codewhale_config::PluginAuthority;
|
||||
use codewhale_plugins::PluginRegistry;
|
||||
use codewhale_plugins::activation::{
|
||||
PluginActivationCapability, PolicyScope, extension_host_policy_enabled,
|
||||
};
|
||||
use codewhale_plugins::install::dsh::{plan_npm_install, refusal_text};
|
||||
use codewhale_plugins::providers::{PluginProviderDeclaration, validate_declarations};
|
||||
use codewhale_plugins::recommend::{
|
||||
PluginMatchSource, PluginNextStep, PluginTaskRecommendation, lookup_reviewable_plugin,
|
||||
};
|
||||
use codewhale_plugins::registry::verify_plugin_component_authority;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::dsh_broker::{
|
||||
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
|
||||
};
|
||||
use super::protocol::DshService;
|
||||
use crate::pricing::BalanceInfo;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum CatalogMethod {
|
||||
#[serde(rename = "install.plan")]
|
||||
InstallPlan(InstallRequest),
|
||||
#[serde(rename = "routes.list")]
|
||||
RoutesList(NoInput),
|
||||
#[serde(rename = "routes.resolve")]
|
||||
RoutesResolve(RouteRequest),
|
||||
#[serde(rename = "balance")]
|
||||
Balance(NoInput),
|
||||
}
|
||||
|
||||
impl BrokerMethods for CatalogMethod {
|
||||
const NAMES: &'static [&'static str] =
|
||||
&["install.plan", "routes.list", "routes.resolve", "balance"];
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct NoInput {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct InstallRequest {
|
||||
package: String,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
registry: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RouteRequest {
|
||||
provider: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct AccountBalance {
|
||||
pub available: bool,
|
||||
pub reading: Option<BalanceInfo>,
|
||||
}
|
||||
|
||||
type ApprovedRoutes = Vec<(String, PluginProviderDeclaration)>;
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
serve_with(call, None)
|
||||
}
|
||||
|
||||
pub(super) fn serve_with(call: BrokerCall, account: Option<AccountBalance>) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let caller = call.owner.plugin_id.clone();
|
||||
match decode::<CatalogMethod>(&call)? {
|
||||
CatalogMethod::InstallPlan(request) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act("install.plan", BrokerEffect::Read, Some(&request.package))
|
||||
.await?;
|
||||
install_plan(&plugins, &caller, request)
|
||||
}
|
||||
CatalogMethod::RoutesList(NoInput {}) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act("routes.list", BrokerEffect::Read, None)
|
||||
.await?;
|
||||
let routes = approved_routes(plugins, &caller).await?;
|
||||
let routes: Vec<Value> = routes
|
||||
.iter()
|
||||
.map(|(name, declaration)| route(name, declaration))
|
||||
.collect();
|
||||
Ok(json!({ "routes": routes }))
|
||||
}
|
||||
CatalogMethod::RoutesResolve(request) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act(
|
||||
"routes.resolve",
|
||||
BrokerEffect::Read,
|
||||
Some(&request.provider),
|
||||
)
|
||||
.await?;
|
||||
let routes = approved_routes(plugins, &caller).await?;
|
||||
resolve_route(&caller, routes, request)
|
||||
}
|
||||
CatalogMethod::Balance(NoInput {}) => {
|
||||
call.context
|
||||
.act("balance", BrokerEffect::Read, None)
|
||||
.await?;
|
||||
Ok(balance(account))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn plugins(call: &BrokerCall) -> Result<Arc<PluginRegistry>, BrokerRefusal> {
|
||||
call.context
|
||||
.tool()
|
||||
.plugin_registry
|
||||
.clone()
|
||||
.ok_or(BrokerRefusal::NotAvailable(DshService::Catalog))
|
||||
}
|
||||
|
||||
fn refused(message: impl Into<String>) -> BrokerRefusal {
|
||||
BrokerRefusal::Refused {
|
||||
service: DshService::Catalog,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn review_command(name: &str, next_step: PluginNextStep) -> String {
|
||||
PluginTaskRecommendation {
|
||||
name: name.to_string(),
|
||||
source: PluginMatchSource::Installed {
|
||||
id: name.to_string(),
|
||||
},
|
||||
matched_terms: Vec::new(),
|
||||
score: 0,
|
||||
next_step,
|
||||
}
|
||||
.command()
|
||||
}
|
||||
|
||||
fn install_plan(
|
||||
plugins: &PluginRegistry,
|
||||
caller: &str,
|
||||
request: InstallRequest,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let plan = plan_npm_install(
|
||||
&request.package,
|
||||
&request.version,
|
||||
request.registry.as_deref(),
|
||||
)
|
||||
.map_err(|reason| {
|
||||
tracing::info!(target: "codewhale::dsh_catalog", plugin = %caller, "install plan refused");
|
||||
refused(reason)
|
||||
})?;
|
||||
let existing = plugins.get(&plan.plugin_name);
|
||||
let (status, command, message) = match existing {
|
||||
Some(existing) => {
|
||||
let command = lookup_reviewable_plugin(existing.name(), plugins, &[])
|
||||
.map(|reviewable| reviewable.command())
|
||||
.unwrap_or_else(|| review_command(existing.name(), PluginNextStep::Inspect));
|
||||
let message = format!(
|
||||
"A plugin named `{}` is already present; nothing was installed. Review it with `{command}`. A market listing does not establish that it is the same bundle.",
|
||||
existing.name()
|
||||
);
|
||||
("already_present", command, message)
|
||||
}
|
||||
None => {
|
||||
let command = review_command(
|
||||
&plan.plugin_name,
|
||||
PluginNextStep::SourceInstall {
|
||||
spec: plan.spec.clone(),
|
||||
},
|
||||
);
|
||||
let message = format!(
|
||||
"Codewhale installs a plugin only through its reviewed install flow; nothing was installed. Review and install {}@{} with `{command}`.",
|
||||
request.package, request.version
|
||||
);
|
||||
("review_required", command, message)
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
package = %request.package,
|
||||
version = %request.version,
|
||||
status,
|
||||
"install planned for review; nothing installed"
|
||||
);
|
||||
let mut answer = json!({
|
||||
"status": status,
|
||||
"package": request.package,
|
||||
"version": request.version,
|
||||
"plugin": plan.plugin_name,
|
||||
"command": command,
|
||||
"message": message,
|
||||
});
|
||||
if existing.is_none() {
|
||||
answer["spec"] = Value::String(plan.spec);
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
fn declared_routes(
|
||||
plugins: &PluginRegistry,
|
||||
caller: &str,
|
||||
) -> Result<Option<(ApprovedRoutes, PluginAuthority)>, String> {
|
||||
let Some(plugin) = plugins.get(caller).filter(|plugin| plugin.active()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if plugin.manifest.providers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
validate_declarations(&plugin.manifest.providers)?;
|
||||
let authority = plugins
|
||||
.authority_for(caller)
|
||||
.ok_or("the plugin has no review receipt")?;
|
||||
let routes = plugin
|
||||
.manifest
|
||||
.providers
|
||||
.iter()
|
||||
.map(|(name, declaration)| (name.clone(), declaration.clone()))
|
||||
.collect();
|
||||
Ok(Some((routes, authority)))
|
||||
}
|
||||
|
||||
async fn approved_routes(
|
||||
plugins: Arc<PluginRegistry>,
|
||||
caller: &str,
|
||||
) -> Result<ApprovedRoutes, BrokerRefusal> {
|
||||
let receipt = |reason: String| {
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
reason = %reason,
|
||||
"provider routes refused: the review receipt does not hold"
|
||||
);
|
||||
refused(
|
||||
"the review that approved this plugin's provider routes no longer holds; review the plugin again in Codewhale",
|
||||
)
|
||||
};
|
||||
let Some((routes, authority)) = declared_routes(&plugins, caller).map_err(receipt)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let policy = extension_host_policy_enabled();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let env_scope = crate::test_support::env_scope_ticket();
|
||||
let verified = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _env_scope = crate::test_support::join_env_scope(env_scope);
|
||||
let _policy = PolicyScope::propagate(policy);
|
||||
verify_plugin_component_authority(&authority, PluginActivationCapability::Providers)
|
||||
})
|
||||
.await;
|
||||
match verified {
|
||||
Ok(Ok(())) => Ok(routes),
|
||||
Ok(Err(reason)) => Err(receipt(reason)),
|
||||
Err(error) => Err(receipt(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_route(
|
||||
caller: &str,
|
||||
routes: ApprovedRoutes,
|
||||
request: RouteRequest,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let Some((name, declaration)) = routes
|
||||
.into_iter()
|
||||
.find(|(name, _)| *name == request.provider)
|
||||
else {
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
"provider route refused: not approved for this plugin"
|
||||
);
|
||||
return Err(refused(format!(
|
||||
"`{}` is not a provider route approved for this plugin. A plugin cannot register a model adapter in Codewhale: a model route is a provider route you approve in the plugin's review",
|
||||
refusal_text(&request.provider)
|
||||
)));
|
||||
};
|
||||
let model = match request.model {
|
||||
None => declaration.model.clone(),
|
||||
Some(model) => {
|
||||
let offered = if declaration.models.is_empty() {
|
||||
declaration
|
||||
.model
|
||||
.as_deref()
|
||||
.is_none_or(|default| default == model)
|
||||
} else {
|
||||
declaration.models.contains(&model)
|
||||
};
|
||||
if !offered {
|
||||
return Err(refused(format!(
|
||||
"model `{}` is not on the approved provider route `{name}`",
|
||||
refusal_text(&model)
|
||||
)));
|
||||
}
|
||||
Some(model)
|
||||
}
|
||||
};
|
||||
Ok(json!({ "route": route(&name, &declaration), "model": model }))
|
||||
}
|
||||
|
||||
fn route(name: &str, declaration: &PluginProviderDeclaration) -> Value {
|
||||
json!({
|
||||
"provider": name,
|
||||
"base_url": declaration.base_url,
|
||||
"default_model": declaration.model,
|
||||
"models": declaration.models,
|
||||
})
|
||||
}
|
||||
|
||||
fn balance(account: Option<AccountBalance>) -> Value {
|
||||
let Some(account) = account else {
|
||||
return json!({ "status": "unknown" });
|
||||
};
|
||||
if !account.available {
|
||||
return json!({ "status": "absent" });
|
||||
}
|
||||
match account
|
||||
.reading
|
||||
.filter(|reading| !reading.total_balance.trim().is_empty())
|
||||
{
|
||||
Some(reading) => json!({
|
||||
"status": "known",
|
||||
"currency": reading.currency,
|
||||
"total": reading.total_balance,
|
||||
"topped_up": reading.topped_up_balance,
|
||||
"granted": reading.granted_balance,
|
||||
}),
|
||||
None => json!({ "status": "unknown" }),
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
- **Agent `cwd` in sibling worktrees:** spawning an agent with `cwd` pointing to an existing linked worktree (or a subdirectory inside it) under the repository's `.codewhale-worktrees/<repo>/` root is now accepted when both share the same repository common git directory; outside directories and foreign or plain directories under `.codewhale-worktrees/` stay refused. (CW-8215)
|
||||
- **DSH market npm installs and extension catalog service:** DSH market `npm` entries with an exact semantic version (and optional `https` registry) now resolve to reviewed registry-tarball install plans (`npm:<pkg>@<ver>` or exact `.tgz` URL) without executing `npm` or bypassing plugin trust review. Inside the extension host, the `dsh/catalog` broker arm now plans reviewed installs without writing to disk, returns only verified approved provider routes scoped to the calling plugin, and serves account balance read-only while withholding credentials and account mutations (CW-7988).
|
||||
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
|
||||
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
|
||||
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
|
||||
|
||||
Reference in new issue
Block a user