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:
CodeWhale Bot committed 2026-11-13 17:39:27 -08:00
1 parent adddc5cc85
commit b7249e5e58
6 files changed
+1042 -6

No files matched your search

+1
View File
@@ -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);
}
+335 -4
View File
@@ -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" }),
}
}
+1
View File
@@ -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)