From 8bdcf20a89d3489809ab998eae94c00a5f06453f Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 8 Jul 2026 15:19:01 +0300 Subject: [PATCH] feat(harness): ncl surface for harness capabilities + dispatch escalation gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groups config update gains --harness-capabilities 'k=on|off|default[,...]' (JSON-column read-modify-write, validated against the registry; a harness-only update passes the nothing-to-update guard). config get renders raw overrides plus a resolved view with (default)/(override) markers. dispatch blocks the arg from group-scoped agents exactly like cli_scope — an agent cannot re-enable capabilities its operator turned off. Co-Authored-By: Claude Fable 5 --- src/cli/dispatch.test.ts | 29 ++++++ src/cli/dispatch.ts | 7 ++ src/cli/resources/groups-config.test.ts | 121 ++++++++++++++++++++++++ src/cli/resources/groups.ts | 42 +++++++- 4 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 src/cli/resources/groups-config.test.ts diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts index 4da3c1dca..1a8aac267 100644 --- a/src/cli/dispatch.test.ts +++ b/src/cli/dispatch.test.ts @@ -316,6 +316,35 @@ describe('CLI scope enforcement', () => { } }); + it('group: blocks harness_capabilities escalation', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'groups-test', args: { harness_capabilities: 'agent-teams=on' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('harness_capabilities'); + } + }); + + it('group: blocks harness-capabilities escalation (hyphenated)', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'groups-test', args: { 'harness-capabilities': 'workflow=on' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + it('group: blocks non-group resources', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 5ea9f65c3..bf816cbdb 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -91,6 +91,13 @@ export async function dispatch( return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.'); } + // Same escalation shape for harness capabilities: an agent must not be + // able to re-enable harness features (agent teams, Workflow) that the + // operator turned off for its group. + if (req.args.harness_capabilities !== undefined || req.args['harness-capabilities'] !== undefined) { + return err(req.id, 'forbidden', 'Cannot change harness_capabilities from a group-scoped agent.'); + } + // Auto-fill agent-group-related args so the agent doesn't need // to pass its own group ID explicitly. const fill: Record = { diff --git a/src/cli/resources/groups-config.test.ts b/src/cli/resources/groups-config.test.ts new file mode 100644 index 000000000..013b588ad --- /dev/null +++ b/src/cli/resources/groups-config.test.ts @@ -0,0 +1,121 @@ +/** + * ncl round-trip for the harness-capabilities config surface: set an + * override, clear it with `default`, reject unknown keys/values, and render + * both the raw overrides and the resolved (default)/(override) view in + * `config get`. Host caller — the same code path an approved update takes. + */ +import fs from 'fs'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), + buildAgentGroupImage: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-groups-config' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-cli-groups-config'; + +import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js'; +import { ensureContainerConfig, getContainerConfig } from '../../db/container-configs.js'; +import { dispatch } from '../dispatch.js'; +// Side-effect import: registers the `groups-*` commands. +import './groups.js'; + +const GID = 'ag-caps'; +const hostCtx = { caller: 'host' as const }; + +async function configUpdate(caps: string) { + return dispatch( + { id: 't', command: 'groups-config-update', args: { id: GID, 'harness-capabilities': caps } }, + hostCtx, + ); +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + runMigrations(initTestDb()); + createAgentGroup({ + id: GID, + name: 'caps', + folder: 'caps', + agent_provider: null, + created_at: new Date().toISOString(), + }); + ensureContainerConfig(GID); +}); + +afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +describe('groups config update --harness-capabilities', () => { + it('sets an override and renders it as (override) in the resolved view', async () => { + const resp = await configUpdate('agent-teams=on'); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { + harness_capabilities: Record; + harness_capabilities_resolved: Record; + }; + expect(data.harness_capabilities).toEqual({ 'agent-teams': 'on' }); + expect(data.harness_capabilities_resolved['agent-teams']).toBe('on (override)'); + expect(data.harness_capabilities_resolved.workflow).toBe('off (default)'); + } + expect(JSON.parse(getContainerConfig(GID)!.harness_capabilities)).toEqual({ 'agent-teams': 'on' }); + }); + + it('`default` clears the override and the resolved view returns to (default)', async () => { + await configUpdate('agent-teams=on'); + const resp = await configUpdate('agent-teams=default'); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { harness_capabilities_resolved: Record }; + expect(data.harness_capabilities_resolved['agent-teams']).toBe('off (default)'); + } + expect(JSON.parse(getContainerConfig(GID)!.harness_capabilities)).toEqual({}); + }); + + it('rejects unknown keys and bad values with usable messages', async () => { + const unknown = await configUpdate('web=off'); + expect(unknown.ok).toBe(false); + if (!unknown.ok) expect(unknown.error.message).toContain('unknown harness capability'); + + const badValue = await configUpdate('workflow=sideways'); + expect(badValue.ok).toBe(false); + if (!badValue.ok) expect(badValue.error.message).toContain('on, off, or default'); + }); + + it('a harness-only update passes the nothing-to-update guard', async () => { + const resp = await configUpdate('workflow=on'); + expect(resp.ok).toBe(true); + }); + + it('config get shows raw overrides plus the resolved view', async () => { + await configUpdate('workflow=on'); + const resp = await dispatch({ id: 't', command: 'groups-config-get', args: { id: GID } }, hostCtx); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { + harness_capabilities: Record; + harness_capabilities_resolved: Record; + }; + expect(data.harness_capabilities).toEqual({ workflow: 'on' }); + expect(data.harness_capabilities_resolved).toEqual({ + 'agent-teams': 'off (default)', + workflow: 'on (override)', + }); + } + }); +}); diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 27da6417f..48254eb71 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -13,11 +13,25 @@ import { updateContainerConfigJson, } from '../../db/container-configs.js'; import { createAgentFromTemplate } from '../../templates/create-agent.js'; +import { parseHarnessCapabilitiesArg, resolveHarnessCapabilities } from '../../harness-capabilities.js'; import type { AgentGroup, ContainerConfigRow } from '../../types.js'; import { registerResource } from '../crud.js'; +/** Raw override map from the stored JSON; malformed degrades to {} (resolve warns). */ +function harnessOverrides(row: ContainerConfigRow): Record { + try { + const parsed: unknown = JSON.parse(row.harness_capabilities); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch (err) { + if (!(err instanceof SyntaxError)) throw err; + return {}; + } +} + /** Deserialize JSON columns for display. */ function presentConfig(row: ContainerConfigRow): Record { + const overrides = harnessOverrides(row); + const resolved = resolveHarnessCapabilities(row.harness_capabilities); return { agent_group_id: row.agent_group_id, provider: row.provider, @@ -32,6 +46,10 @@ function presentConfig(row: ContainerConfigRow): Record { packages_npm: JSON.parse(row.packages_npm), additional_mounts: JSON.parse(row.additional_mounts), cli_scope: row.cli_scope, + harness_capabilities: overrides, + harness_capabilities_resolved: Object.fromEntries( + Object.entries(resolved).map(([k, v]) => [k, `${v} (${k in overrides ? 'override' : 'default'})`]), + ), updated_at: row.updated_at, }; } @@ -242,8 +260,9 @@ registerResource({ 'config update': { access: 'approval', description: - 'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' + - 'Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.', + 'Update container config fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' + + 'Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, ' + + "--harness-capabilities 'key=on|off|default[,key=...]' (keys: agent-teams, workflow; `default` clears the per-group override).", handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); @@ -271,13 +290,26 @@ registerResource({ updates.cli_scope = scope; } - if (Object.keys(updates).length === 0) { + // Harness capabilities are a JSON column of sparse overrides — apply + // set/clear ops on the stored map (`default` clears; never stored). + let harnessChanged = false; + const capsArg = (args['harness-capabilities'] ?? args.harness_capabilities) as string | undefined; + if (capsArg !== undefined) { + const ops = parseHarnessCapabilitiesArg(String(capsArg)); + const overrides = harnessOverrides(row); + for (const key of ops.clear) delete overrides[key]; + Object.assign(overrides, ops.set); + updateContainerConfigJson(id, 'harness_capabilities', overrides); + harnessChanged = true; + } + + if (Object.keys(updates).length === 0 && !harnessChanged) { throw new Error( - 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope', + 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, --harness-capabilities', ); } - updateContainerConfigScalars(id, updates); + if (Object.keys(updates).length > 0) updateContainerConfigScalars(id, updates); const updated = getContainerConfig(id)!; return presentConfig(updated);