feat: per-group CLI scope (disabled/group/global)
Add cli_scope column to container_configs with three levels: - disabled: agent never learns about ncl (instructions excluded from CLAUDE.md) and host dispatch rejects any cli_request - group (default): agent can only access groups, sessions, destinations, and members resources, scoped to its own agent group with auto-filled --id/--agent_group_id/--group args. Help output reflects the scope. - global: unrestricted access (current behavior) Enforcement is host-side only — no image rebuild or env var needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,7 @@ export function backfillContainerConfigs(): void {
|
||||
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
|
||||
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
|
||||
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
|
||||
cli_scope: 'group',
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -79,13 +79,15 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
// Built-in module fragments — every MCP tool source file that ships a
|
||||
// sibling `<name>.instructions.md`. These describe how the agent should
|
||||
// use that module's MCP tools (schedule_task, install_packages, etc.).
|
||||
// Always included — these are built-in, not toggleable.
|
||||
// Skip cli.instructions.md when cli_scope is disabled.
|
||||
const cliDisabled = configRow?.cli_scope === 'disabled';
|
||||
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
|
||||
if (fs.existsSync(mcpToolsHostDir)) {
|
||||
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
|
||||
const match = entry.match(/^(.+)\.instructions\.md$/);
|
||||
if (!match) continue;
|
||||
const moduleName = match[1];
|
||||
if (moduleName === 'cli' && cliDisabled) continue;
|
||||
desired.set(`module-${moduleName}.md`, {
|
||||
type: 'symlink',
|
||||
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
|
||||
|
||||
@@ -4,19 +4,38 @@
|
||||
* ncl help — list all resources and commands
|
||||
* ncl groups help — show group resource details (verbs, columns, enums)
|
||||
*/
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { getResource, getResources } from '../crud.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import { listCommands, register } from '../registry.js';
|
||||
|
||||
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
|
||||
function getCliScope(ctx: CallerContext): string | undefined {
|
||||
if (ctx.caller !== 'agent') return undefined;
|
||||
return getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group';
|
||||
}
|
||||
|
||||
register({
|
||||
name: 'help',
|
||||
description: 'List available resources and commands.',
|
||||
access: 'open',
|
||||
parseArgs: () => ({}),
|
||||
handler: async () => {
|
||||
const resources = getResources();
|
||||
handler: async (_args, ctx) => {
|
||||
const cliScope = getCliScope(ctx);
|
||||
let resources = getResources();
|
||||
if (cliScope === 'group') {
|
||||
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
|
||||
}
|
||||
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (cliScope === 'group') {
|
||||
lines.push('CLI scope: group (--id and group args are auto-filled to your agent group)');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (resources.length > 0) {
|
||||
lines.push('Resources:');
|
||||
for (const r of resources) {
|
||||
@@ -61,18 +80,28 @@ export function registerResourceHelpCommands(): void {
|
||||
access: 'open',
|
||||
resource: res.plural,
|
||||
parseArgs: () => ({}),
|
||||
handler: async () => {
|
||||
handler: async (_args, ctx) => {
|
||||
const cliScope = getCliScope(ctx);
|
||||
const lines: string[] = [];
|
||||
lines.push(`${res.plural}: ${res.description}`);
|
||||
|
||||
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
|
||||
lines.push('');
|
||||
lines.push('Note: --id and group args are auto-filled to your agent group. You do not need to pass them.');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Verbs
|
||||
const idAutoFilled =
|
||||
cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
|
||||
const idHint = idAutoFilled ? '' : ' <id>';
|
||||
const verbs: string[] = [];
|
||||
if (res.operations.list) verbs.push(`list [open]`);
|
||||
if (res.operations.get) verbs.push(`get <id> [open]`);
|
||||
if (res.operations.get) verbs.push(`get${idHint} [open]`);
|
||||
if (res.operations.create) verbs.push(`create [approval]`);
|
||||
if (res.operations.update) verbs.push(`update <id> [approval]`);
|
||||
if (res.operations.delete) verbs.push(`delete <id> [approval]`);
|
||||
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
|
||||
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
|
||||
if (res.customOperations) {
|
||||
for (const [verb, op] of Object.entries(res.customOperations)) {
|
||||
verbs.push(`${verb} [${op.access}] — ${op.description}`);
|
||||
@@ -83,9 +112,13 @@ export function registerResourceHelpCommands(): void {
|
||||
lines.push('');
|
||||
|
||||
// Columns
|
||||
const autoFilledFields = cliScope === 'group'
|
||||
? new Set(['id', 'agent_group_id', 'group'])
|
||||
: new Set<string>();
|
||||
lines.push('Fields:');
|
||||
for (const col of res.columns) {
|
||||
const tags: string[] = [];
|
||||
if (autoFilledFields.has(col.name)) tags.push('auto-filled');
|
||||
if (col.generated) tags.push('auto');
|
||||
if (col.required) tags.push('required');
|
||||
if (col.updatable) tags.push('updatable');
|
||||
|
||||
304
src/cli/dispatch.test.ts
Normal file
304
src/cli/dispatch.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
vi.mock('../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
|
||||
}));
|
||||
|
||||
const mockGetAgentGroup = vi.fn();
|
||||
vi.mock('../db/agent-groups.js', () => ({
|
||||
getAgentGroup: (...args: unknown[]) => mockGetAgentGroup(...args),
|
||||
}));
|
||||
|
||||
const mockGetSession = vi.fn();
|
||||
vi.mock('../db/sessions.js', () => ({
|
||||
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../modules/approvals/index.js', () => ({
|
||||
registerApprovalHandler: vi.fn(),
|
||||
requestApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
// Register a test command so dispatch has something to find
|
||||
import { register } from './registry.js';
|
||||
|
||||
register({
|
||||
name: 'test-cmd',
|
||||
description: 'test command (non-group resource)',
|
||||
resource: 'test',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-test',
|
||||
description: 'test command (groups resource)',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'general-cmd',
|
||||
description: 'test command (no resource, like help)',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'sessions-list',
|
||||
description: 'test command (sessions resource)',
|
||||
resource: 'sessions',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'destinations-list',
|
||||
description: 'test command (destinations resource)',
|
||||
resource: 'destinations',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'members-add',
|
||||
description: 'test command (members resource)',
|
||||
resource: 'members',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'wirings-list',
|
||||
description: 'test command (wirings resource — not allowed)',
|
||||
resource: 'wirings',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
import { dispatch } from './dispatch.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }>>): CallerContext {
|
||||
return {
|
||||
caller: 'agent',
|
||||
sessionId: 's1',
|
||||
agentGroupId: 'g1',
|
||||
messagingGroupId: 'mg1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('CLI scope enforcement', () => {
|
||||
it('disabled: rejects all CLI requests from agent', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: auto-fills --id with caller agent group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe('g1');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: rejects cross-group access', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'other-group' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('scoped');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows same-group id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'g1' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('group: blocks non-group resources', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('test');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows general commands with no resource (e.g. help)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'general-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('group: allows sessions, auto-fills --agent_group_id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'sessions-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.agent_group_id).toBe('g1');
|
||||
// --id should NOT be auto-filled for sessions (it's session UUID, not group)
|
||||
expect(data.echo.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows destinations, auto-fills --id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'destinations-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe('g1');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows members, auto-fills --group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'members-add', args: { user: 'u1' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.group).toBe('g1');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: blocks non-whitelisted resources (wirings)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('wirings');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: rejects cross-group --agent_group_id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: '1', command: 'sessions-list', args: { agent_group_id: 'other-group' } },
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('group: rejects cross-group --group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch(
|
||||
{ id: '1', command: 'members-add', args: { user: 'u1', group: 'other-group' } },
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('global: allows cross-group access', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'other-group' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('global: allows non-group resources', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('global: does not auto-fill --id', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { foo: 'bar' } }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to group when cli_scope is missing', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({});
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('host caller bypasses CLI scope enforcement', async () => {
|
||||
// No config check should happen for host callers
|
||||
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'any-group' } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(mockGetContainerConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* Approval gating for risky calls from the container is the only branch
|
||||
* that differs by caller. Host callers and `open` commands run inline.
|
||||
*/
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { getAgentGroup } from '../db/agent-groups.js';
|
||||
import { getSession } from '../db/sessions.js';
|
||||
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
|
||||
@@ -36,6 +37,46 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
return err(req.id, 'unknown-command', `no command "${req.command}"`);
|
||||
}
|
||||
|
||||
// CLI scope enforcement for agent callers
|
||||
if (ctx.caller === 'agent') {
|
||||
const configRow = getContainerConfig(ctx.agentGroupId);
|
||||
const cliScope = configRow?.cli_scope ?? 'group';
|
||||
|
||||
if (cliScope === 'disabled') {
|
||||
return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.');
|
||||
}
|
||||
|
||||
if (cliScope === 'group') {
|
||||
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
// Only allow whitelisted resources and general commands (no resource, like help)
|
||||
if (cmd.resource && !allowed.has(cmd.resource)) {
|
||||
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
|
||||
}
|
||||
|
||||
// Enforce group scope on all agent-group-related args.
|
||||
// Different resources use different arg names for the agent group ID.
|
||||
const groupArgs = ['id', 'agent_group_id', 'group'] as const;
|
||||
for (const key of groupArgs) {
|
||||
if (req.args[key] && req.args[key] !== ctx.agentGroupId) {
|
||||
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill agent-group-related args so the agent doesn't need
|
||||
// to pass its own group ID explicitly.
|
||||
const fill: Record<string, unknown> = {
|
||||
agent_group_id: req.args.agent_group_id ?? ctx.agentGroupId,
|
||||
group: req.args.group ?? ctx.agentGroupId,
|
||||
};
|
||||
// Only auto-fill --id for resources where it IS the agent group ID
|
||||
// (groups, destinations). For sessions/members --id is a different key.
|
||||
if (cmd.resource === 'groups' || cmd.resource === 'destinations') {
|
||||
fill.id = req.args.id ?? ctx.agentGroupId;
|
||||
}
|
||||
req = { ...req, args: { ...req.args, ...fill } };
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval') {
|
||||
const session = getSession(ctx.sessionId);
|
||||
if (!session) {
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ErrorCode =
|
||||
| 'unknown-command'
|
||||
| 'invalid-args'
|
||||
| 'permission-denied'
|
||||
| 'forbidden'
|
||||
| 'approval-pending'
|
||||
| 'not-found'
|
||||
| 'handler-error'
|
||||
|
||||
@@ -26,6 +26,7 @@ function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
|
||||
packages_apt: JSON.parse(row.packages_apt),
|
||||
packages_npm: JSON.parse(row.packages_npm),
|
||||
additional_mounts: JSON.parse(row.additional_mounts),
|
||||
cli_scope: row.cli_scope,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -57,7 +58,7 @@ registerResource({
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
customOperations: {
|
||||
'restart': {
|
||||
restart: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Restart containers for a group. Use --id <group-id> [--rebuild] [--message <text>]. ' +
|
||||
@@ -86,10 +87,16 @@ registerResource({
|
||||
onWake: 1,
|
||||
});
|
||||
}
|
||||
killContainer(ctx.sessionId, 'restarted via ncl', message ? () => {
|
||||
const s = getSession(ctx.sessionId);
|
||||
if (s) wakeContainer(s);
|
||||
} : undefined);
|
||||
killContainer(
|
||||
ctx.sessionId,
|
||||
'restarted via ncl',
|
||||
message
|
||||
? () => {
|
||||
const s = getSession(ctx.sessionId);
|
||||
if (s) wakeContainer(s);
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
return { restarted: 1, rebuilt: !!args.rebuild };
|
||||
}
|
||||
|
||||
@@ -112,7 +119,7 @@ registerResource({
|
||||
'config update': {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Update container config scalar fields. Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt.',
|
||||
'Update container config scalar fields. Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
@@ -122,7 +129,7 @@ registerResource({
|
||||
const updates: Partial<
|
||||
Pick<
|
||||
ContainerConfigRow,
|
||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt'
|
||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
||||
>
|
||||
> = {};
|
||||
if (args.provider !== undefined) updates.provider = args.provider as string;
|
||||
@@ -132,10 +139,17 @@ registerResource({
|
||||
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
|
||||
if (args.max_messages_per_prompt !== undefined)
|
||||
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
|
||||
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
|
||||
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
|
||||
if (!['disabled', 'group', 'global'].includes(scope)) {
|
||||
throw new Error('--cli-scope must be one of: disabled, group, global');
|
||||
}
|
||||
updates.cli_scope = scope;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error(
|
||||
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt',
|
||||
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void
|
||||
const mockWakeContainer = vi.fn();
|
||||
vi.mock('./container-runner.js', () => ({
|
||||
isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string),
|
||||
killContainer: (...args: unknown[]) => mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined),
|
||||
killContainer: (...args: unknown[]) =>
|
||||
mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined),
|
||||
wakeContainer: (...args: unknown[]) => mockWakeContainer(...args),
|
||||
}));
|
||||
|
||||
@@ -43,10 +44,7 @@ function makeSession(id: string, agentGroupId: string, status = 'active') {
|
||||
|
||||
describe('restartAgentGroupContainers', () => {
|
||||
it('skips sessions without a running container', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([
|
||||
makeSession('s1', 'g1'),
|
||||
makeSession('s2', 'g1'),
|
||||
]);
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(false);
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test');
|
||||
@@ -57,9 +55,7 @@ describe('restartAgentGroupContainers', () => {
|
||||
});
|
||||
|
||||
it('skips non-active sessions', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([
|
||||
makeSession('s1', 'g1', 'closed'),
|
||||
]);
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1', 'closed')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test');
|
||||
@@ -69,10 +65,7 @@ describe('restartAgentGroupContainers', () => {
|
||||
});
|
||||
|
||||
it('kills running containers and returns count', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([
|
||||
makeSession('s1', 'g1'),
|
||||
makeSession('s2', 'g1'),
|
||||
]);
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||
mockIsContainerRunning.mockImplementation((id) => id === 's1');
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test');
|
||||
@@ -142,10 +135,7 @@ describe('restartAgentGroupContainers', () => {
|
||||
});
|
||||
|
||||
it('handles multiple running sessions with wake message', () => {
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([
|
||||
makeSession('s1', 'g1'),
|
||||
makeSession('s2', 'g1'),
|
||||
]);
|
||||
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||
mockIsContainerRunning.mockReturnValue(true);
|
||||
|
||||
const count = restartAgentGroupContainers('g1', 'test', 'Config updated.');
|
||||
|
||||
@@ -18,11 +18,7 @@ import { writeSessionMessage } from './session-manager.js';
|
||||
* wakeContainer call on exit. Without it, containers are killed and
|
||||
* only come back on the next real user message.
|
||||
*/
|
||||
export function restartAgentGroupContainers(
|
||||
agentGroupId: string,
|
||||
reason: string,
|
||||
wakeMessage?: string,
|
||||
): number {
|
||||
export function restartAgentGroupContainers(agentGroupId: string, reason: string, wakeMessage?: string): number {
|
||||
const sessions = getSessionsByAgentGroup(agentGroupId).filter(
|
||||
(s) => s.status === 'active' && isContainerRunning(s.id),
|
||||
);
|
||||
@@ -44,10 +40,16 @@ export function restartAgentGroupContainers(
|
||||
onWake: 1,
|
||||
});
|
||||
}
|
||||
killContainer(session.id, reason, wakeMessage ? () => {
|
||||
const s = getSession(session.id);
|
||||
if (s) wakeContainer(s);
|
||||
} : undefined);
|
||||
killContainer(
|
||||
session.id,
|
||||
reason,
|
||||
wakeMessage
|
||||
? () => {
|
||||
const s = getSession(session.id);
|
||||
if (s) wakeContainer(s);
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length > 0) {
|
||||
|
||||
@@ -473,7 +473,6 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
||||
if (!configRow) throw new Error('Container config not found');
|
||||
const aptPackages = JSON.parse(configRow.packages_apt) as string[];
|
||||
const npmPackages = JSON.parse(configRow.packages_npm) as string[];
|
||||
|
||||
if (aptPackages.length === 0 && npmPackages.length === 0) {
|
||||
throw new Error('No packages to install. Use install_packages first.');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const SCALAR_COLUMNS = new Set([
|
||||
'image_tag',
|
||||
'assistant_name',
|
||||
'max_messages_per_prompt',
|
||||
'cli_scope',
|
||||
]);
|
||||
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
|
||||
|
||||
@@ -54,7 +55,7 @@ export function updateContainerConfigScalars(
|
||||
updates: Partial<
|
||||
Pick<
|
||||
ContainerConfigRow,
|
||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt'
|
||||
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
|
||||
10
src/db/migrations/015-cli-scope.ts
Normal file
10
src/db/migrations/015-cli-scope.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
export const migration015: Migration = {
|
||||
version: 15,
|
||||
name: 'cli-scope',
|
||||
up(db: Database.Database) {
|
||||
db.prepare("ALTER TABLE container_configs ADD COLUMN cli_scope TEXT NOT NULL DEFAULT 'group'").run();
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { migration011 } from './011-pending-sender-approvals.js';
|
||||
import { migration012 } from './012-channel-registration.js';
|
||||
import { migration013 } from './013-approval-render-metadata.js';
|
||||
import { migration014 } from './014-container-configs.js';
|
||||
import { migration015 } from './015-cli-scope.js';
|
||||
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
|
||||
@@ -33,6 +34,7 @@ const migrations: Migration[] = [
|
||||
migration012,
|
||||
migration013,
|
||||
migration014,
|
||||
migration015,
|
||||
];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ContainerConfigRow {
|
||||
packages_apt: string; // JSON: string[]
|
||||
packages_npm: string; // JSON: string[]
|
||||
additional_mounts: string; // JSON: AdditionalMountConfig[]
|
||||
cli_scope: string; // 'disabled' | 'group' | 'global'
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user