fix(cordis-host-runner): make inspect provider registration idempotent on identical manifest
Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 6s
CI / serial / linux (push) Has been skipped
Release (vendor) / Pack npm tarballs (push) Failing after 1m59s
Release (dsh) / Pack npm tarballs (push) Failing after 1m57s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m59s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m23s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 20s

The Host cordisInspect registry threw on any duplicate provider id, but
tool-cordis is mounted per-preset (standing scope), so two presets that
both include it (e.g. `maximum` and a user copy such as `design`) collide.

`register()` now replaces the stored entry when the incoming manifest is
structurally identical (same id, description, and methods); a provider
with the same id but a different manifest still throws as before.

Added `jsonEqual` and `sameManifest` module-private helpers; new spec
covers first register, identical re-register, different-manifest reject,
empty-id validation, and method-array mismatch.
This commit is contained in:
2026-08-27 14:03:40 +07:00
parent 8719fa84f0
commit f27a0dcfb5
2 changed files with 125 additions and 1 deletions

View File

@@ -56,12 +56,21 @@ export class CordisInspectRegistryService extends Service {
/**
* Register one Host provider.
*
* Throws only when a different manifest claims the same provider id.
* An identical re-registration (same id, description, and every method)
* replaces the stored entry so the newest registrant's query closure is
* active. This allows multiple plugin mounts (e.g. two agent presets) that
* register the same capability to coexist without collision.
* @param registration - manifest and local query handler.
* @returns idempotent disposer.
*/
register(registration: HostCordisInspectProviderRegistration): () => void {
const manifest = validateManifest(registration.manifest)
if (this.providers.has(manifest.id)) throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
const existing = this.providers.get(manifest.id)
if (existing !== undefined && !sameManifest(existing.manifest, manifest)) {
throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
}
const stored = { ...registration, manifest }
this.providers.set(manifest.id, stored)
return () => {
@@ -198,6 +207,35 @@ export class CordisInspectRegistryService extends Service {
}
}
/** Deep equality for JSON-compatible values (objects, arrays, primitives). */
function jsonEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
return a.every((entry, index) => jsonEqual(entry, b[index]))
}
const left = a as Record<string, unknown>
const right = b as Record<string, unknown>
const keys = Object.keys(left)
if (keys.length !== Object.keys(right).length) return false
return keys.every(key => key in right && jsonEqual(left[key], right[key]))
}
/** Whether two manifests describe the same provider capability. */
function sameManifest(a: CordisInspectProviderManifest, b: CordisInspectProviderManifest): boolean {
if (a.id !== b.id || a.description !== b.description) return false
if (a.methods.length !== b.methods.length) return false
return a.methods.every((method, index) => {
const other = b.methods[index]
return other !== undefined
&& method.name === other.name
&& method.description === other.description
&& jsonEqual(method.inputSchema, other.inputSchema)
&& jsonEqual(method.outputSchema, other.outputSchema)
})
}
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
return { platform, ...manifest, methods: [...manifest.methods] }
}

View File

@@ -0,0 +1,86 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { CordisInspectRegistryService, type HostCordisInspectProviderRegistration } from '../src/inspect-registry.ts'
function makeProvider(id: string, desc: string): HostCordisInspectProviderRegistration {
return {
manifest: {
id,
description: desc,
methods: [{
name: 'list',
description: 'List things.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
outputSchema: { description: 'anything' },
}],
},
async query() { return {} },
}
}
describe('CordisInspectRegistryService', () => {
it('accepts the first registration of a provider id', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
const p = makeProvider('Service', 'desc')
const dispose = svc.register(p)
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
dispose()
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
})
it('replaces on identical re-registration without throwing', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
const p = makeProvider('Service', 'desc')
const disposeA = svc.register(p)
const disposeB = svc.register(p) // same object — identical manifest
// Still in the directory (replaced, not removed)
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
// disposeA should be a no-op because the map entry was replaced
disposeA()
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
// disposeB removes the current registration
disposeB()
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
})
it('rejects a different manifest claiming the same id', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
svc.register(makeProvider('Service', 'original'))
expect(() => svc.register(makeProvider('Service', 'different')))
.toThrow('Host Cordis inspect provider "Service" is already registered')
})
it('rejects empty provider id', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
expect(() => svc.register(makeProvider('', 'empty')))
.toThrow('Cordis inspect provider id must not be empty')
})
it('detects method-array length mismatch as unequal', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
const a: HostCordisInspectProviderRegistration = {
manifest: {
id: 'X', description: 'd',
methods: [{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} }],
},
async query() { return {} },
}
const b: HostCordisInspectProviderRegistration = {
manifest: {
id: 'X', description: 'd',
methods: [
{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} },
{ name: 'b', description: 'b', inputSchema: {}, outputSchema: {} },
],
},
async query() { return {} },
}
svc.register(a)
expect(() => svc.register(b)).toThrow('already registered')
})
})