From aebcffe1807a9e5c0813fa784b3757b8428878c7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:02:31 +0300 Subject: [PATCH] feat: per-group CLI scope (disabled/group/global) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/backfill-container-configs.ts | 1 + src/claude-md-compose.ts | 4 +- src/cli/commands/help.ts | 45 ++++- src/cli/dispatch.test.ts | 304 +++++++++++++++++++++++++++++ src/cli/dispatch.ts | 41 ++++ src/cli/frame.ts | 1 + src/cli/resources/groups.ts | 30 ++- src/container-restart.test.ts | 22 +-- src/container-restart.ts | 20 +- src/container-runner.ts | 1 - src/db/container-configs.ts | 3 +- src/db/migrations/015-cli-scope.ts | 10 + src/db/migrations/index.ts | 2 + src/types.ts | 1 + 14 files changed, 443 insertions(+), 42 deletions(-) create mode 100644 src/cli/dispatch.test.ts create mode 100644 src/db/migrations/015-cli-scope.ts diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts index b046c3c..5551c90 100644 --- a/src/backfill-container-configs.ts +++ b/src/backfill-container-configs.ts @@ -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(), }; diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts index 64ad799..285f79a 100644 --- a/src/claude-md-compose.ts +++ b/src/claude-md-compose.ts @@ -79,13 +79,15 @@ export function composeGroupClaudeMd(group: AgentGroup): void { // Built-in module fragments — every MCP tool source file that ships a // sibling `.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}`, diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index d50eaef..138f05f 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -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 ? '' : ' '; const verbs: string[] = []; if (res.operations.list) verbs.push(`list [open]`); - if (res.operations.get) verbs.push(`get [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 [approval]`); - if (res.operations.delete) verbs.push(`delete [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(); 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'); diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts new file mode 100644 index 0000000..9eb63af --- /dev/null +++ b/src/cli/dispatch.test.ts @@ -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>): 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 }; + 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 }; + 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 }; + 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 }; + 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 }; + 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(); + }); +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 268e4d2..5ec6843 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -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 = { + 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) { diff --git a/src/cli/frame.ts b/src/cli/frame.ts index 8e7604a..67cd61c 100644 --- a/src/cli/frame.ts +++ b/src/cli/frame.ts @@ -25,6 +25,7 @@ export type ErrorCode = | 'unknown-command' | 'invalid-args' | 'permission-denied' + | 'forbidden' | 'approval-pending' | 'not-found' | 'handler-error' diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 8ea42b0..c6a19ec 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -26,6 +26,7 @@ function presentConfig(row: ContainerConfigRow): Record { 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 [--rebuild] [--message ]. ' + @@ -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 and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt.', + 'Update container config scalar fields. Use --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', ); } diff --git a/src/container-restart.test.ts b/src/container-restart.test.ts index 956df63..d07d17f 100644 --- a/src/container-restart.test.ts +++ b/src/container-restart.test.ts @@ -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.'); diff --git a/src/container-restart.ts b/src/container-restart.ts index 6f83531..e09d6f3 100644 --- a/src/container-restart.ts +++ b/src/container-restart.ts @@ -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) { diff --git a/src/container-runner.ts b/src/container-runner.ts index 903bd8b..d529c9c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -473,7 +473,6 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise 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.'); } diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts index a401544..219c73f 100644 --- a/src/db/container-configs.ts +++ b/src/db/container-configs.ts @@ -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 { diff --git a/src/db/migrations/015-cli-scope.ts b/src/db/migrations/015-cli-scope.ts new file mode 100644 index 0000000..6c0c7dd --- /dev/null +++ b/src/db/migrations/015-cli-scope.ts @@ -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(); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index a181cb3..0cefb37 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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 { diff --git a/src/types.ts b/src/types.ts index ece7b76..26a40f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; }