merge: integrate v7-rbac-visibility on top of skills feature
Brings together the two unmerged feature lines onto one integration branch: - skills/review/proposals/revisions (via fix/mcpd-instance-health-and-retry, which contains the full skills-1..7 chain + mcpd env/retry/readiness fix) - v7 visibility scope + ownership for Llms and Agents Auto-merge resolved schema.prisma, mcpd main.ts, mcplocal config, CLI create, and completions with no conflicts. Both Prisma migrations coexist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,14 @@ export interface LlmProviderFileEntry {
|
||||
* logical pool that auto-grows as more workers come online.
|
||||
*/
|
||||
poolName?: string;
|
||||
/**
|
||||
* v7: per-user RBAC scoping. mcplocal-published virtuals default to
|
||||
* 'private' on register — the publishing user owns the row and other
|
||||
* users don't see it without an explicit `view:llms:<name>` grant.
|
||||
* Set to 'public' here to opt into org-wide sharing for this
|
||||
* provider.
|
||||
*/
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
export type WakeRecipe =
|
||||
@@ -146,6 +154,8 @@ export interface AgentFileEntry {
|
||||
project?: string;
|
||||
defaultParams?: Record<string, unknown>;
|
||||
extras?: Record<string, unknown>;
|
||||
/** v7: see LlmProviderFileEntry.visibility — same default ('private'). */
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -233,6 +233,10 @@ async function maybeStartVirtualLlmRegistrar(
|
||||
if (entry.wake !== undefined) item.wake = entry.wake;
|
||||
if (entry.poolName !== undefined) item.poolName = entry.poolName;
|
||||
if (wireName !== provider.name) item.publishName = wireName;
|
||||
// v7: pass visibility through; registrar already defaults to
|
||||
// 'private' when omitted, and the per-provider override flows
|
||||
// straight through to the register payload.
|
||||
if (entry.visibility !== undefined) item.visibility = entry.visibility;
|
||||
published.push(item);
|
||||
}
|
||||
// v3: forward locally-declared agents alongside the providers. We
|
||||
@@ -255,6 +259,7 @@ async function maybeStartVirtualLlmRegistrar(
|
||||
if (a.project !== undefined) item.project = a.project;
|
||||
if (a.defaultParams !== undefined) item.defaultParams = a.defaultParams;
|
||||
if (a.extras !== undefined) item.extras = a.extras;
|
||||
if (a.visibility !== undefined) item.visibility = a.visibility;
|
||||
publishedAgents.push(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,14 @@ export interface RegistrarPublishedProvider {
|
||||
* `publishName ?? provider.name` everywhere.
|
||||
*/
|
||||
publishName?: string;
|
||||
/**
|
||||
* v7: per-user RBAC scoping. mcplocal-published virtuals default to
|
||||
* 'private' (visible only to the publishing user) — workstations
|
||||
* shouldn't broadcast their models org-wide unless explicitly
|
||||
* shared. The publisher can override per provider with
|
||||
* `"visibility": "public"` in their mcplocal config.
|
||||
*/
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +96,8 @@ export interface RegistrarPublishedAgent {
|
||||
project?: string;
|
||||
defaultParams?: Record<string, unknown>;
|
||||
extras?: Record<string, unknown>;
|
||||
/** v7: per-user RBAC scoping, defaults to 'private' on register. */
|
||||
visibility?: 'public' | 'private';
|
||||
}
|
||||
|
||||
export interface RegistrarOptions {
|
||||
@@ -207,6 +217,10 @@ export class VirtualLlmRegistrar {
|
||||
...(p.tier !== undefined ? { tier: p.tier } : {}),
|
||||
...(p.description !== undefined ? { description: p.description } : {}),
|
||||
...(p.poolName !== undefined ? { poolName: p.poolName } : {}),
|
||||
// v7: virtuals default to private. Operators who want their
|
||||
// workstation model org-visible set "visibility": "public" per
|
||||
// provider in mcplocal config.
|
||||
visibility: p.visibility ?? 'private',
|
||||
initialStatus,
|
||||
};
|
||||
}));
|
||||
@@ -224,6 +238,9 @@ export class VirtualLlmRegistrar {
|
||||
...(a.project !== undefined ? { project: a.project } : {}),
|
||||
...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
|
||||
...(a.extras !== undefined ? { extras: a.extras } : {}),
|
||||
// v7: forward visibility to mcpd. Defaults to 'private' for
|
||||
// virtual agents on the server side when omitted.
|
||||
visibility: a.visibility ?? 'private',
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
208
src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts
Normal file
208
src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Smoke: v7 visibility round-trip.
|
||||
*
|
||||
* Publishes two virtual Llms via the registrar — one explicitly public,
|
||||
* one explicitly private — and verifies the GET /api/v1/llms response
|
||||
* carries the visibility + ownerId fields end-to-end. The cross-user
|
||||
* filter (private rows hidden from non-owner non-admin) is fully
|
||||
* covered by mcpd's visibility-filter unit tests; smoke only proves
|
||||
* the new fields make the round-trip from registrar → mcpd → list
|
||||
* payload without dropping or being defaulted away.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
VirtualLlmRegistrar,
|
||||
type RegistrarPublishedProvider,
|
||||
} from '../../src/providers/registrar.js';
|
||||
import type { LlmProvider, CompletionResult } from '../../src/providers/types.js';
|
||||
|
||||
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
|
||||
const SUFFIX = Date.now().toString(36);
|
||||
const PUBLIC_NAME = `smoke-vis-public-${SUFFIX}`;
|
||||
const PRIVATE_NAME = `smoke-vis-private-${SUFFIX}`;
|
||||
|
||||
function makeFakeProvider(name: string): LlmProvider {
|
||||
return {
|
||||
name,
|
||||
async complete(): Promise<CompletionResult> {
|
||||
return {
|
||||
content: 'ok',
|
||||
toolCalls: [],
|
||||
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
|
||||
finishReason: 'stop',
|
||||
};
|
||||
},
|
||||
async listModels() { return []; },
|
||||
async isAvailable() { return true; },
|
||||
};
|
||||
}
|
||||
|
||||
function healthz(url: string, timeoutMs = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`);
|
||||
const driver = parsed.protocol === 'https:' ? https : http;
|
||||
const req = driver.get(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); },
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
const home = process.env.HOME ?? '';
|
||||
const path = `${home}/.mcpctl/credentials`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
if (!fs.existsSync(path)) return null;
|
||||
const raw = fs.readFileSync(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { token?: string };
|
||||
return parsed.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface HttpResponse { status: number; body: string }
|
||||
|
||||
function httpRequest(method: string, urlStr: string): Promise<HttpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tokenRaw = readToken();
|
||||
const parsed = new URL(urlStr);
|
||||
const driver = parsed.protocol === 'https:' ? https : http;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...(tokenRaw !== null ? { Authorization: `Bearer ${tokenRaw}` } : {}),
|
||||
};
|
||||
const req = driver.request({
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
headers,
|
||||
timeout: 30_000,
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error(`httpRequest timeout: ${method} ${urlStr}`)); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
let mcpdUp = false;
|
||||
let registrar: VirtualLlmRegistrar | null = null;
|
||||
let tempDir: string;
|
||||
|
||||
interface LlmListRow {
|
||||
id: string;
|
||||
name: string;
|
||||
visibility?: 'public' | 'private';
|
||||
ownerId?: string | null;
|
||||
}
|
||||
|
||||
describe('virtual-LLM smoke — visibility (v7)', () => {
|
||||
beforeAll(async () => {
|
||||
mcpdUp = await healthz(MCPD_URL);
|
||||
if (!mcpdUp) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`\n ○ visibility smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`);
|
||||
return;
|
||||
}
|
||||
if (readToken() === null) {
|
||||
mcpdUp = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('\n ○ visibility smoke: skipped — no ~/.mcpctl/credentials.\n');
|
||||
return;
|
||||
}
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-vis-smoke-'));
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (registrar !== null) registrar.stop();
|
||||
if (tempDir !== undefined) rmSync(tempDir, { recursive: true, force: true });
|
||||
if (mcpdUp) {
|
||||
const list = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`);
|
||||
if (list.status === 200) {
|
||||
const rows = JSON.parse(list.body) as LlmListRow[];
|
||||
for (const target of [PUBLIC_NAME, PRIVATE_NAME]) {
|
||||
const row = rows.find((r) => r.name === target);
|
||||
if (row !== undefined) {
|
||||
await httpRequest('DELETE', `${MCPD_URL}/api/v1/llms/${row.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes one public + one private virtual Llm and the list payload reflects both', async () => {
|
||||
if (!mcpdUp) return;
|
||||
const token = readToken();
|
||||
if (token === null) return;
|
||||
const published: RegistrarPublishedProvider[] = [
|
||||
{ provider: makeFakeProvider(PUBLIC_NAME), type: 'openai', model: 'fake-vis', tier: 'fast', visibility: 'public' },
|
||||
{ provider: makeFakeProvider(PRIVATE_NAME), type: 'openai', model: 'fake-vis', tier: 'fast', visibility: 'private' },
|
||||
];
|
||||
registrar = new VirtualLlmRegistrar({
|
||||
mcpdUrl: MCPD_URL,
|
||||
token,
|
||||
publishedProviders: published,
|
||||
sessionFilePath: join(tempDir, 'session'),
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
await registrar.start();
|
||||
expect(registrar.getSessionId()).not.toBeNull();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`);
|
||||
expect(res.status).toBe(200);
|
||||
const rows = JSON.parse(res.body) as LlmListRow[];
|
||||
|
||||
const pub = rows.find((r) => r.name === PUBLIC_NAME);
|
||||
expect(pub, `${PUBLIC_NAME} must be visible to its owner`).toBeDefined();
|
||||
expect(pub!.visibility).toBe('public');
|
||||
// ownerId is the auth principal that ran register; non-empty proves
|
||||
// mcpd actually stamped it on the row (otherwise the v7 register
|
||||
// path would have left it NULL = legacy public).
|
||||
expect(typeof pub!.ownerId).toBe('string');
|
||||
expect((pub!.ownerId ?? '').length).toBeGreaterThan(0);
|
||||
|
||||
const priv = rows.find((r) => r.name === PRIVATE_NAME);
|
||||
expect(priv, `${PRIVATE_NAME} must be visible to its owner (visibility filter is owner-bypass)`).toBeDefined();
|
||||
expect(priv!.visibility).toBe('private');
|
||||
expect(typeof priv!.ownerId).toBe('string');
|
||||
expect((priv!.ownerId ?? '').length).toBeGreaterThan(0);
|
||||
|
||||
// Same publisher, same session — both rows must share the same owner.
|
||||
expect(priv!.ownerId).toBe(pub!.ownerId);
|
||||
}, 30_000);
|
||||
|
||||
it('GET /api/v1/llms/<name> returns the row to its owner without 404', async () => {
|
||||
if (!mcpdUp) return;
|
||||
// Owner is calling — visibility filter must let the row through. A
|
||||
// 404 here would indicate the service-layer filter is wrongly hiding
|
||||
// it from the very user who created it.
|
||||
const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms/${PRIVATE_NAME}`);
|
||||
expect(res.status).toBe(200);
|
||||
const row = JSON.parse(res.body) as LlmListRow;
|
||||
expect(row.name).toBe(PRIVATE_NAME);
|
||||
expect(row.visibility).toBe('private');
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user