mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix: validate plugin connector refs in doctor (#2164)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
* fix: validate plugin connector refs in doctor Co-authored-by: multica-agent <github@multica.ai> * chore: refresh pool review queue Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
parent
99921e1883
commit
024e6d86a9
5 changed files with 162 additions and 3 deletions
34
apps/daemon/src/plugins/connector-probe.ts
Normal file
34
apps/daemon/src/plugins/connector-probe.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { ConnectorCatalogDefinition, ConnectorStatus } from '../connectors/catalog.js';
|
||||
import type { ConnectorService } from '../connectors/service.js';
|
||||
import type { ConnectorCatalogEntry, ConnectorGateStatus, ConnectorProbe } from './connector-gate.js';
|
||||
|
||||
function toGateStatus(status: ConnectorStatus): ConnectorGateStatus {
|
||||
if (status === 'connected') return 'connected';
|
||||
if (status === 'available') return 'pending';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
function catalogEntryFor(
|
||||
service: ConnectorService,
|
||||
definition: ConnectorCatalogDefinition,
|
||||
): ConnectorCatalogEntry {
|
||||
const status = service.getStatus(definition);
|
||||
return {
|
||||
id: definition.id,
|
||||
status: toGateStatus(status.status),
|
||||
...(status.accountLabel === undefined ? {} : { accountLabel: status.accountLabel }),
|
||||
allowedToolNames: definition.allowedToolNames.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConnectorProbe(service: ConnectorService): ConnectorProbe {
|
||||
const entries = new Map<string, ConnectorCatalogEntry>();
|
||||
for (const definition of service.listFastDefinitions()) {
|
||||
entries.set(definition.id, catalogEntryFor(service, definition));
|
||||
}
|
||||
return {
|
||||
get(connectorId: string) {
|
||||
return entries.get(connectorId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ export * from './atoms/rewrite-plan.js';
|
|||
export * from './atoms/token-map.js';
|
||||
export * from './bundled.js';
|
||||
export * from './connector-gate.js';
|
||||
export * from './connector-probe.js';
|
||||
export * from './export.js';
|
||||
export * from './doctor.js';
|
||||
export * from './installer.js';
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { ArtifactRegressionError } from './artifact-stub-guard.js';
|
|||
import { listDesignSystems } from './design-systems.js';
|
||||
import {
|
||||
FIRST_PARTY_ATOMS,
|
||||
buildConnectorProbe,
|
||||
getInstalledPlugin,
|
||||
listInstalledPlugins,
|
||||
resolvePluginSnapshot,
|
||||
} from './plugins/index.js';
|
||||
import { connectorService } from './connectors/service.js';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import { listSkills } from './skills.js';
|
||||
import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
||||
|
|
@ -245,6 +247,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
typeof normalizedDesignSystemId === 'string' && normalizedDesignSystemId.length > 0
|
||||
? { id: normalizedDesignSystemId }
|
||||
: undefined,
|
||||
connectorProbe: buildConnectorProbe(connectorService),
|
||||
});
|
||||
if (resolved && !resolved.ok) {
|
||||
if (!explicitPlugin) {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import { createDesignSystemGenerationJobStore } from './design-system-generation
|
|||
import {
|
||||
applyDiffReviewDecisionToCwd,
|
||||
applyPlugin,
|
||||
buildConnectorProbe,
|
||||
defaultBundledRoot,
|
||||
doctorPlugin,
|
||||
FIRST_PARTY_ATOMS,
|
||||
|
|
@ -394,7 +395,7 @@ import { registerChatRoutes } from './chat-routes.js';
|
|||
import { registerStaticResourceRoutes } from './static-resource-routes.js';
|
||||
import { registerRoutineRoutes, routineDbRowToContract } from './routine-routes.js';
|
||||
import { assertServerContextSatisfiesRoutes } from './route-context-contract.js';
|
||||
import { configureConnectorCredentialStore, ConnectorServiceError, FileConnectorCredentialStore } from './connectors/service.js';
|
||||
import { configureConnectorCredentialStore, connectorService, ConnectorServiceError, FileConnectorCredentialStore } from './connectors/service.js';
|
||||
import { composioConnectorProvider } from './connectors/composio.js';
|
||||
import { configureComposioConfigStore } from './connectors/composio-config.js';
|
||||
import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from './tool-tokens.js';
|
||||
|
|
@ -6308,7 +6309,8 @@ export async function startServer({
|
|||
const locale = typeof body.locale === 'string' ? body.locale : undefined;
|
||||
|
||||
const registry = await loadPluginRegistryView();
|
||||
const computed = applyPlugin({ plugin, inputs, registry, locale });
|
||||
const connectorProbe = buildConnectorProbe(connectorService);
|
||||
const computed = applyPlugin({ plugin, inputs, registry, locale, connectorProbe });
|
||||
// Plan §3.B2 — apply-time grants are merged into the snapshot's
|
||||
// capabilitiesGranted so the §9 capability gate sees them, but
|
||||
// they are NOT written back to installed_plugins.capabilities_granted.
|
||||
|
|
@ -6392,6 +6394,7 @@ export async function startServer({
|
|||
});
|
||||
|
||||
const registry = await loadPluginRegistryView();
|
||||
const connectorProbe = buildConnectorProbe(connectorService);
|
||||
const resolved = resolvePluginSnapshot({
|
||||
db,
|
||||
body: {
|
||||
|
|
@ -6408,6 +6411,7 @@ export async function startServer({
|
|||
projectId: id,
|
||||
conversationId: cid,
|
||||
registry,
|
||||
connectorProbe,
|
||||
});
|
||||
if (resolved && !resolved.ok) {
|
||||
res.status(resolved.status).json(resolved.body);
|
||||
|
|
@ -6440,7 +6444,8 @@ export async function startServer({
|
|||
const plugin = getInstalledPlugin(db, req.params.id);
|
||||
if (!plugin) return res.status(404).json({ error: 'plugin not found' });
|
||||
const registry = await loadPluginRegistryView();
|
||||
const report = doctorPlugin(plugin, registry);
|
||||
const connectorProbe = buildConnectorProbe(connectorService);
|
||||
const report = doctorPlugin(plugin, registry, { connectorProbe });
|
||||
res.json(report);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
|
|
@ -11610,6 +11615,7 @@ export async function startServer({
|
|||
? req.body.conversationId
|
||||
: null,
|
||||
registry: registryView,
|
||||
connectorProbe: buildConnectorProbe(connectorService),
|
||||
});
|
||||
if (resolved && !resolved.ok) {
|
||||
if (!explicitPlugin) {
|
||||
|
|
|
|||
115
apps/daemon/tests/plugins-doctor-route.test.ts
Normal file
115
apps/daemon/tests/plugins-doctor-route.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import http from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
type StartedServer = { server: http.Server; url: string };
|
||||
|
||||
let server: http.Server | undefined;
|
||||
let baseUrl = '';
|
||||
let pluginRoot = '';
|
||||
|
||||
async function closeServer(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!server) return resolve(undefined);
|
||||
server.close((error?: Error) => (error ? reject(error) : resolve(undefined)));
|
||||
});
|
||||
server = undefined;
|
||||
}
|
||||
|
||||
async function installPlugin(source: string): Promise<void> {
|
||||
const resp = await fetch(`${baseUrl}/api/plugins/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
||||
body: JSON.stringify({ source }),
|
||||
});
|
||||
expect(resp.status).toBe(200);
|
||||
if (!resp.body) throw new Error('install stream missing body');
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let raw = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
raw += decoder.decode(value, { stream: true });
|
||||
}
|
||||
if (!raw.includes('event: success')) {
|
||||
throw new Error(`installer did not finalize:\n${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
pluginRoot = await mkdtemp(path.join(os.tmpdir(), 'od-doctor-route-'));
|
||||
const started = await startServer({ port: 0, returnServer: true }) as StartedServer;
|
||||
server = started.server;
|
||||
baseUrl = started.url;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeServer();
|
||||
await rm(pluginRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('POST /api/plugins/:id/doctor', () => {
|
||||
it('fails plugins that require a connector missing from the live connector catalog', async () => {
|
||||
const pluginId = `missing-connector-${randomUUID()}`;
|
||||
const folder = path.join(pluginRoot, pluginId);
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(folder, 'open-design.json'),
|
||||
JSON.stringify({
|
||||
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
||||
name: pluginId,
|
||||
title: 'Missing Connector Fixture',
|
||||
version: '1.0.0',
|
||||
description: 'requires a connector that is not in the catalog',
|
||||
license: 'MIT',
|
||||
od: {
|
||||
kind: 'skill',
|
||||
taskKind: 'new-generation',
|
||||
useCase: { query: 'Check connectors.' },
|
||||
connectors: {
|
||||
required: [
|
||||
{ id: 'definitely_missing_connector', tools: ['missing_tool'] },
|
||||
],
|
||||
},
|
||||
capabilities: [
|
||||
'prompt:inject',
|
||||
'connector:definitely_missing_connector',
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(folder, 'SKILL.md'),
|
||||
`---\nname: ${pluginId}\ndescription: missing connector fixture\n---\n# Fixture\n`,
|
||||
);
|
||||
await installPlugin(folder);
|
||||
|
||||
const resp = await fetch(`${baseUrl}/api/plugins/${encodeURIComponent(pluginId)}/doctor`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(resp.status).toBe(200);
|
||||
const report = await resp.json() as {
|
||||
ok: boolean;
|
||||
issues: Array<{ severity: string; code: string; message: string; field?: string }>;
|
||||
};
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'connector.unknown-connector',
|
||||
field: 'od.connectors',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue