agentic-teams: epics, tasks, git forges, agent authority #139

Merged
michal merged 36 commits from feat/agentic-teams into main 2026-10-09 14:47:34 +00:00
7 changed files with 618 additions and 14 deletions
Showing only changes of commit a186d95a85 - Show all commits

View File

@@ -154,7 +154,7 @@ mcpctl get task $ID
## RBAC
A new `tasks` resource. Default action mapping:
The `inference-tasks` resource. Default action mapping:
| Method | Action |
|--------|--------|
@@ -162,13 +162,22 @@ A new `tasks` resource. Default action mapping:
| `POST` | `create` |
| `DELETE` | `delete` |
Aliases: `task`, `inference-task`, `inference-tasks` all normalize to
`tasks` so `mcpctl create rbac-binding --resource task --role view ...`
works regardless of which form the operator types.
Aliases: `inference-task` and `inference-tasks` both normalize to
`inference-tasks`, so `mcpctl create rbac-binding --resource inference-task
--role view ...` works either way.
> **Renamed.** v5 shipped this queue under the RBAC resource `tasks`, on the
> reasoning that inference was the only kind of task we had. Agentic teams
> introduces work-item tasks, which take `tasks` (and the `task` alias) over.
> Bindings written before the split literally stored `"tasks"`, so mcpd rewrites
> them to `inference-tasks` once at startup — see
> `src/mcpd/src/bootstrap/rbac-tasks-backfill.ts`. Nothing to do by hand; the
> rewrite logs what it changed. `view:tasks` now grants **work items**, not
> inference.
Owner scoping is enforced at the route layer ON TOP of the RBAC hook:
- A caller with only the implicit role sees and can cancel their own tasks.
- `view:tasks` resource-wide is required to see / list / cancel
- `view:inference-tasks` resource-wide is required to see / list / cancel
another user's tasks.
## GC

View File

@@ -0,0 +1,90 @@
/**
* One-shot rewrite of pre-split `tasks` RBAC bindings to `inference-tasks`.
*
* v5 gave the durable inference queue the RBAC resource `tasks`. Agentic teams
* introduces work-item tasks, which take that name over, and the queue moves to
* `inference-tasks` (see validation/rbac-definition.schema.ts).
*
* The rename is not purely a code change. `ResourceBindingSchema` runs
* `normalizeResource` at parse time and stores the *normalized* string, so every
* RbacDefinition row written before the split literally contains `"tasks"`
* meaning inference. Without this rewrite those grants would silently stop
* authorizing `/api/v1/inference-tasks` and start authorizing work items —
* a privilege change in both directions.
*
* Guarded to run exactly once. A blanket rewrite on every boot would clobber
* legitimate work-item grants created after the split, so the marker matters as
* much as the walk. If the marker cannot be persisted the rewrite is skipped
* entirely rather than risk running twice — see the warning path below.
*
* Wildcard bindings (`resource: '*'`) already cover both resources and are left
* alone. Operation bindings carry `action`, not `resource`, and are untouched.
*/
import type { PrismaClient } from '@prisma/client';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import type { UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js';
import {
SETTING_INFERENCE_TASK_BINDINGS_MIGRATED,
getBooleanSetting,
setSetting,
} from './system-settings.js';
/** The pre-split resource string stored on legacy inference grants. */
const LEGACY_RESOURCE = 'tasks';
/** Where those grants move to. */
const NEW_RESOURCE = 'inference-tasks';
export async function backfillInferenceTaskBindings(
prisma: PrismaClient,
rbacRepo: IRbacDefinitionRepository,
): Promise<void> {
const alreadyRun = await getBooleanSetting(prisma, SETTING_INFERENCE_TASK_BINDINGS_MIGRATED, false);
if (alreadyRun) return;
// Claim the marker BEFORE rewriting. If we rewrote first and the marker write
// then failed, the next startup would rewrite again — turning work-item grants
// created in between into inference grants. Losing the rewrite is recoverable
// (an admin re-grants); running it twice silently is not.
const claimed = await setSetting(prisma, SETTING_INFERENCE_TASK_BINDINGS_MIGRATED, true);
if (!claimed) {
console.warn(
`mcpd: skipped the ${LEGACY_RESOURCE} → ${NEW_RESOURCE} RBAC rewrite — could not persist the ` +
`run-once marker. Existing '${LEGACY_RESOURCE}' grants now authorize work items, not the ` +
`inference queue. Re-grant them with 'mcpctl apply' or fix the system-settings secret and restart.`,
);
return;
}
const definitions = await rbacRepo.findAll();
let rowsChanged = 0;
let bindingsChanged = 0;
for (const def of definitions) {
const bindings = def.roleBindings as Array<Record<string, unknown>>;
let touched = 0;
const rewritten = bindings.map((b) => {
if (b['resource'] !== LEGACY_RESOURCE) return b;
touched++;
return { ...b, resource: NEW_RESOURCE };
});
if (touched === 0) continue;
await rbacRepo.update(def.id, {
roleBindings: rewritten as UpdateRbacDefinitionInput['roleBindings'],
});
rowsChanged++;
bindingsChanged += touched;
}
if (rowsChanged > 0) {
// eslint-disable-next-line no-console
console.log(
`mcpd: migrated ${String(bindingsChanged)} RBAC binding(s) across ${String(rowsChanged)} ` +
`definition(s) from '${LEGACY_RESOURCE}' to '${NEW_RESOURCE}'`,
);
}
}

View File

@@ -7,7 +7,9 @@
* See the `backup-ssh` secret for the original precedent.
*/
import type { PrismaClient } from '@prisma/client';
import type { Prisma, PrismaClient } from '@prisma/client';
import { DEFAULT_PLAINTEXT_BACKEND_NAME } from './secret-backends.js';
/** Well-known secret holding global mcpd settings as `data` JSON. */
export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings';
@@ -15,6 +17,12 @@ export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings';
/** Settings key: whether newly-created users get self password-change permission. */
export const SETTING_ALLOW_SELF_PASSWORD = 'allowSelfPasswordChange';
/**
* Settings key: whether the one-shot `tasks` → `inference-tasks` RBAC binding
* rewrite has run. See bootstrap/rbac-tasks-backfill.ts.
*/
export const SETTING_INFERENCE_TASK_BINDINGS_MIGRATED = 'inferenceTaskBindingsMigrated';
/** Coerce a stored JSON value (boolean, or "true"/"false"/"0" string) to boolean. */
function toBool(v: unknown, dflt: boolean): boolean {
if (typeof v === 'boolean') return v;
@@ -45,3 +53,87 @@ export async function getAllowSelfPasswordChange(prisma: PrismaClient): Promise<
return true;
}
}
/**
* Read one boolean setting. Missing secret / missing key / read error all
* return `dflt` — callers treat "can't tell" as "not set".
*/
export async function getBooleanSetting(
prisma: PrismaClient,
key: string,
dflt: boolean,
): Promise<boolean> {
try {
const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } });
if (!secret) return dflt;
const data = (secret.data ?? {}) as Record<string, unknown>;
if (!(key in data)) return dflt;
return toBool(data[key], dflt);
} catch {
return dflt;
}
}
/**
* Persist one setting into the system-settings secret, creating the secret on
* the default plaintext backend if it doesn't exist yet.
*
* Returns whether the value was durably stored. Callers that use a setting as
* a run-once marker MUST treat `false` as "the marker did not stick" — the
* guarded work will be attempted again on the next startup.
*
* Writes `data` directly, which is only correct for the plaintext backend
* (non-plaintext backends keep their values behind `externalRef`). If the
* secret has been migrated onto another backend we refuse rather than write a
* value that reads back empty.
*/
export async function setSetting(
prisma: PrismaClient,
key: string,
value: unknown,
): Promise<boolean> {
try {
const secret = await prisma.secret.findUnique({
where: { name: SYSTEM_SETTINGS_SECRET },
include: { backend: true },
});
if (secret) {
if (secret.backend.type !== 'plaintext') {
console.warn(
`mcpd: cannot persist system setting '${key}' — secret '${SYSTEM_SETTINGS_SECRET}' lives on ` +
`the '${secret.backend.type}' backend; set it there manually.`,
);
return false;
}
const data = { ...((secret.data ?? {}) as Record<string, unknown>), [key]: value };
await prisma.secret.update({
where: { id: secret.id },
data: {
data: data as Prisma.InputJsonValue,
keyNames: Object.keys(data).sort(),
version: { increment: 1 },
},
});
return true;
}
const backend =
(await prisma.secretBackend.findUnique({ where: { name: DEFAULT_PLAINTEXT_BACKEND_NAME } })) ??
(await prisma.secretBackend.findFirst({ where: { isDefault: true } }));
if (!backend) return false;
const data = { [key]: value };
await prisma.secret.create({
data: {
name: SYSTEM_SETTINGS_SECRET,
backendId: backend.id,
data: data as Prisma.InputJsonValue,
keyNames: Object.keys(data).sort(),
},
});
return true;
} catch {
return false;
}
}

View File

@@ -23,6 +23,7 @@ import {
import { SecretBackendRepository } from './repositories/secret-backend.repository.js';
import { SecretBackendService } from './services/secret-backend.service.js';
import { SecretMigrateService } from './services/secret-migrate.service.js';
import { backfillInferenceTaskBindings } from './bootstrap/rbac-tasks-backfill.js';
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
@@ -211,7 +212,11 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
// any other resource (GET=view, POST=create, DELETE=delete). The
// route layer enforces owner-scoping on top — a caller without
// resource-wide grant only sees their own tasks.
'inference-tasks': 'tasks',
//
// Maps to its own `inference-tasks` resource, NOT `tasks` — agentic-teams
// work items own `tasks` now. Pre-split grants are rewritten by
// backfillInferenceTaskBindings at startup.
'inference-tasks': 'inference-tasks',
'agents': 'agents',
// Personalities inherit the agent's RBAC: managing a personality
// requires view/edit/create/delete on the `agents` resource.
@@ -449,6 +454,11 @@ async function main(): Promise<void> {
// Migrate legacy 'admin' role → granular roles
await migrateAdminRole(rbacDefinitionRepo);
// Repoint pre-split inference-queue grants at the `inference-tasks` resource
// now that `tasks` means agentic-teams work items. Runs once; needs the
// default secret backend, so it must follow bootstrapSecretBackends.
await backfillInferenceTaskBindings(prisma, rbacDefinitionRepo);
// Orchestrator — select backend via MCPD_ORCHESTRATOR env var
const orchestrator = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes'
? new KubernetesOrchestrator()

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'tasks'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'inference-tasks', 'epics', 'epictasks', 'tasks', 'repos', 'sessions'] as const;
/** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = {
@@ -17,13 +17,27 @@ const RESOURCE_ALIASES: Record<string, string> = {
mcptoken: 'mcptokens',
secretbackend: 'secretbackends',
llm: 'llms',
// v5: durable inference task queue. The route prefix is
// `/api/v1/inference-tasks` (multi-word for clarity in the URL); the
// RBAC resource is just `tasks` (shorter; the only "task" we have
// is inference for now). Singular alias `task` maps to the same.
// v5 shipped the durable inference task queue under the RBAC resource
// `tasks`, on the reasoning that inference was the only "task" we had.
// Agentic teams introduces work-item tasks, so the queue moves to its own
// `inference-tasks` resource — matching its `/api/v1/inference-tasks` route
// prefix — and `task`/`tasks` now mean work items.
//
// Pre-split RbacDefinition rows literally store `"tasks"` (ResourceBindingSchema
// normalizes at parse time), so they are rewritten to `inference-tasks` by the
// one-shot backfill in bootstrap/rbac-tasks-backfill.ts. Do not repoint these
// aliases without that migration — existing inference grants would silently
// become work-item grants.
'inference-task': 'inference-tasks',
// ── Agentic teams (Epic → epictask → task) ──
epic: 'epics',
epictask: 'epictasks',
et: 'epictasks',
task: 'tasks',
'inference-task': 'tasks',
'inference-tasks': 'tasks',
repo: 'repos',
repository: 'repos',
repositories: 'repos',
session: 'sessions',
};
/** Normalize a resource name to its canonical plural form. */

View File

@@ -0,0 +1,233 @@
import type { PrismaClient, RbacDefinition } from '@prisma/client';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { backfillInferenceTaskBindings } from '../src/bootstrap/rbac-tasks-backfill.js';
import { SYSTEM_SETTINGS_SECRET } from '../src/bootstrap/system-settings.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
import { RBAC_RESOURCES, normalizeResource } from '../src/validation/rbac-definition.schema.js';
function makeDef(overrides: Partial<RbacDefinition> = {}): RbacDefinition {
return {
id: 'def-1',
name: 'test-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockRepo(definitions: RbacDefinition[]): IRbacDefinitionRepository & {
update: ReturnType<typeof vi.fn>;
} {
const update = vi.fn(async () => makeDef());
return {
findAll: vi.fn(async () => definitions),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => makeDef()),
update,
delete: vi.fn(async () => {}),
} as unknown as IRbacDefinitionRepository & { update: ReturnType<typeof vi.fn> };
}
/**
* In-memory stand-in for the system-settings secret plus its backend, enough to
* exercise the read/claim path in setSetting.
*/
function mockPrisma(opts: {
/** Existing settings data, or null when the secret does not exist yet. */
settings?: Record<string, unknown> | null;
/** Backend type the settings secret sits on. */
backendType?: string;
/** When false, no default SecretBackend exists, so the marker cannot be created. */
hasDefaultBackend?: boolean;
} = {}): { prisma: PrismaClient; written: () => Record<string, unknown> | null } {
const backendType = opts.backendType ?? 'plaintext';
const hasDefaultBackend = opts.hasDefaultBackend ?? true;
let stored: Record<string, unknown> | null = opts.settings ?? null;
const secretRow = (): unknown =>
stored === null
? null
: {
id: 'sec-1',
name: SYSTEM_SETTINGS_SECRET,
data: stored,
backend: { id: 'be-1', name: 'default', type: backendType },
};
const prisma = {
secret: {
findUnique: vi.fn(async () => secretRow()),
update: vi.fn(async ({ data }: { data: { data: Record<string, unknown> } }) => {
stored = data.data;
return secretRow();
}),
create: vi.fn(async ({ data }: { data: { data: Record<string, unknown> } }) => {
stored = data.data;
return secretRow();
}),
},
secretBackend: {
findUnique: vi.fn(async () =>
hasDefaultBackend ? { id: 'be-1', name: 'default', type: backendType } : null,
),
findFirst: vi.fn(async () =>
hasDefaultBackend ? { id: 'be-1', name: 'default', type: backendType } : null,
),
},
} as unknown as PrismaClient;
return { prisma, written: () => stored };
}
const MARKER = 'inferenceTaskBindingsMigrated';
describe('RBAC tasks namespace split', () => {
it('maps task aliases to the work-item resource', () => {
expect(normalizeResource('task')).toBe('tasks');
expect(normalizeResource('tasks')).toBe('tasks');
});
it('maps inference aliases to their own resource', () => {
expect(normalizeResource('inference-task')).toBe('inference-tasks');
expect(normalizeResource('inference-tasks')).toBe('inference-tasks');
});
it('no longer collapses inference-tasks onto tasks', () => {
expect(normalizeResource('inference-tasks')).not.toBe('tasks');
expect(normalizeResource('inference-task')).not.toBe('tasks');
});
it('maps the agentic-teams resource aliases', () => {
expect(normalizeResource('epic')).toBe('epics');
expect(normalizeResource('epictask')).toBe('epictasks');
expect(normalizeResource('et')).toBe('epictasks');
expect(normalizeResource('repo')).toBe('repos');
expect(normalizeResource('repository')).toBe('repos');
expect(normalizeResource('repositories')).toBe('repos');
expect(normalizeResource('session')).toBe('sessions');
});
it('leaves unknown resources untouched', () => {
expect(normalizeResource('widgets')).toBe('widgets');
expect(normalizeResource('*')).toBe('*');
});
it('declares the new resources', () => {
for (const r of ['epics', 'epictasks', 'tasks', 'repos', 'sessions', 'inference-tasks']) {
expect(RBAC_RESOURCES).toContain(r);
}
});
});
describe('backfillInferenceTaskBindings', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('rewrites legacy tasks bindings to inference-tasks', async () => {
const def = makeDef({
roleBindings: [
{ role: 'view', resource: 'tasks' },
{ role: 'create', resource: 'tasks' },
{ role: 'view', resource: 'llms' },
],
});
const repo = mockRepo([def]);
const { prisma } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).toHaveBeenCalledTimes(1);
expect(repo.update.mock.calls[0]?.[1]).toEqual({
roleBindings: [
{ role: 'view', resource: 'inference-tasks' },
{ role: 'create', resource: 'inference-tasks' },
{ role: 'view', resource: 'llms' },
],
});
});
it('leaves wildcard bindings alone — they already cover both resources', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'edit', resource: '*' }] })]);
const { prisma } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).not.toHaveBeenCalled();
});
it('leaves operation bindings alone', async () => {
const repo = mockRepo([
makeDef({ roleBindings: [{ role: 'run', action: 'backup' }] }),
]);
const { prisma } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).not.toHaveBeenCalled();
});
it('sets the run-once marker', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
const { prisma, written } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(written()).toMatchObject({ [MARKER]: true });
});
it('is idempotent — a second run does not rewrite new work-item grants', async () => {
const { prisma, written } = mockPrisma();
const first = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
await backfillInferenceTaskBindings(prisma, first);
expect(first.update).toHaveBeenCalledTimes(1);
expect(written()).toMatchObject({ [MARKER]: true });
// A genuine post-split work-item grant must survive the next startup.
const second = mockRepo([
makeDef({ id: 'def-2', roleBindings: [{ role: 'view', resource: 'tasks' }] }),
]);
await backfillInferenceTaskBindings(prisma, second);
expect(second.update).not.toHaveBeenCalled();
expect(second.findAll).not.toHaveBeenCalled();
});
it('preserves other settings when claiming the marker', async () => {
const repo = mockRepo([]);
const { prisma, written } = mockPrisma({ settings: { allowSelfPasswordChange: false } });
await backfillInferenceTaskBindings(prisma, repo);
expect(written()).toEqual({ allowSelfPasswordChange: false, [MARKER]: true });
});
it('skips the rewrite entirely when the marker cannot be persisted', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
const { prisma } = mockPrisma({ hasDefaultBackend: false });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await backfillInferenceTaskBindings(prisma, repo);
// Running without a durable marker risks a second pass clobbering real
// work-item grants, so we would rather do nothing.
expect(repo.update).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalled();
});
it('refuses to write settings onto a non-plaintext backend', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
const { prisma } = mockPrisma({ settings: {}, backendType: 'openbao' });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,156 @@
/**
* Smoke: the `tasks` → `inference-tasks` RBAC namespace split, against live mcpd.
*
* `ResourceBindingSchema` normalizes a binding's `resource` at parse time and
* stores the normalized string, so what a definition reads back as is the whole
* behaviour under test:
*
* 1. `resource: inference-task` → stored as `inference-tasks` (the queue).
* 2. `resource: task` → stored as `tasks` (agentic-teams work items).
* 3. The agentic-teams aliases (`epic`, `et`, `repository`, `session`) resolve.
*
* Pre-split, cases 1 and 2 both stored `tasks`. A failure here against a
* reachable daemon means mcpd is running code older than the split — deploy it,
* don't relax the assertion.
*/
import { execSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const SUFFIX = Date.now().toString(36);
const RBAC_NAME = `smoke-tasksplit-${SUFFIX}`;
interface CliResult { code: number; stdout: string; stderr: string }
function run(args: string): CliResult {
try {
const stdout = execSync(`mcpctl --direct ${args}`, {
encoding: 'utf-8',
timeout: 30_000,
stdio: ['ignore', 'pipe', 'pipe'],
});
return { code: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
return {
code: e.status ?? 1,
stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '',
stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '',
};
}
}
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); });
});
}
interface RbacDoc { name?: string; roleBindings?: Array<{ resource?: string }> }
/**
* The resource strings mcpd stored, in binding order.
*
* `get -o json` emits an array of apply-shaped documents even for a single
* named resource, so unwrap before reading bindings.
*/
function storedResources(): string[] {
const res = run(`get rbac ${RBAC_NAME} -o json`);
expect(res.code, `get rbac failed: ${res.stderr || res.stdout}`).toBe(0);
const parsed = JSON.parse(res.stdout) as RbacDoc | RbacDoc[];
const doc = Array.isArray(parsed)
? parsed.find((d) => d.name === RBAC_NAME) ?? parsed[0]
: parsed;
const bindings = doc?.roleBindings ?? [];
expect(bindings.length, `no roleBindings on '${RBAC_NAME}'`).toBeGreaterThan(0);
return bindings.map((b) => b.resource ?? '');
}
let mcpdUp = false;
let workDir: string | null = null;
describe('rbac tasks namespace split smoke', () => {
beforeAll(async () => {
mcpdUp = await healthz(MCPD_URL);
if (!mcpdUp) {
console.warn(`\n ○ rbac tasks split smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`);
return;
}
// RBAC definitions have no `create` subcommand — they come in via apply.
// Note `apply` maps a few singular aliases client-side, but none of the ones
// under test, so these strings reach mcpd verbatim and it is mcpd's
// normalizeResource that we are exercising.
workDir = mkdtempSync(join(tmpdir(), 'mcpctl-smoke-rbac-'));
const manifestPath = join(workDir, 'rbac.yaml');
writeFileSync(
manifestPath,
[
'rbacBindings:',
` - name: ${RBAC_NAME}`,
' subjects:',
' - kind: Group',
` name: smoke-nonexistent-group-${SUFFIX}`,
' roleBindings:',
' - role: view',
' resource: inference-task',
' - role: view',
' resource: task',
' - role: view',
' resource: epic',
' - role: view',
' resource: et',
' - role: view',
' resource: repository',
' - role: view',
' resource: session',
'',
].join('\n'),
'utf-8',
);
const res = run(`apply -f ${manifestPath}`);
expect(res.code, `apply failed: ${res.stderr || res.stdout}`).toBe(0);
}, 40_000);
afterAll(() => {
if (mcpdUp) run(`delete rbac ${RBAC_NAME}`);
if (workDir) rmSync(workDir, { recursive: true, force: true });
});
it('stores inference-task as inference-tasks, not tasks', () => {
if (!mcpdUp) return;
const resources = storedResources();
expect(resources[0]).toBe('inference-tasks');
expect(resources[0]).not.toBe('tasks');
});
it('stores task as tasks — work items own the short name now', () => {
if (!mcpdUp) return;
const resources = storedResources();
expect(resources[1]).toBe('tasks');
});
it('resolves the agentic-teams aliases', () => {
if (!mcpdUp) return;
const resources = storedResources();
expect(resources.slice(2)).toEqual(['epics', 'epictasks', 'repos', 'sessions']);
});
});