mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
feat(harness): ncl surface for harness capabilities + dispatch escalation gate
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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' });
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
|
||||
@@ -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<string, string>;
|
||||
harness_capabilities_resolved: Record<string, string>;
|
||||
};
|
||||
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<string, string> };
|
||||
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<string, string>;
|
||||
harness_capabilities_resolved: Record<string, string>;
|
||||
};
|
||||
expect(data.harness_capabilities).toEqual({ workflow: 'on' });
|
||||
expect(data.harness_capabilities_resolved).toEqual({
|
||||
'agent-teams': 'off (default)',
|
||||
workflow: 'on (override)',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(row.harness_capabilities);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||
} catch (err) {
|
||||
if (!(err instanceof SyntaxError)) throw err;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Deserialize JSON columns for display. */
|
||||
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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 <group-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 <group-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);
|
||||
|
||||
Reference in New Issue
Block a user