feat(harness): capability registry, schema, materialization, settings reconciler

harness_capabilities JSON column (migration 019) stores sparse per-group
overrides; code defaults live in src/harness-capabilities.ts (agent-teams
off, workflow off). configFromDb materializes the RESOLVED map into
container.json. reconcileHarnessSettings converges each group's
settings.json on every spawn — manages exactly the teams env key and
disableWorkflows, preserves everything else, write-if-changed with
tmp+rename. The teams key leaves DEFAULT_SETTINGS_JSON: managed keys
enter settings.json only through the reconciler, so pre-existing groups
converge to default-off at their next spawn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-07-08 15:16:25 +03:00
parent 7ce25de444
commit 3d5dc8d8ef
12 changed files with 442 additions and 9 deletions
+1
View File
@@ -65,6 +65,7 @@ export function backfillContainerConfigs(): void {
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
cli_scope: 'group',
harness_capabilities: '{}',
updated_at: new Date().toISOString(),
};
+5
View File
@@ -14,6 +14,8 @@ import path from 'path';
import { GROUPS_DIR } from './config.js';
import { getContainerConfig } from './db/container-configs.js';
import { getAgentGroup } from './db/agent-groups.js';
import { resolveHarnessCapabilities } from './harness-capabilities.js';
import type { HarnessCapabilityState } from './harness-capabilities.js';
import type { AgentGroup, ContainerConfigRow } from './types.js';
export interface McpServerConfig {
@@ -43,6 +45,8 @@ export interface ContainerConfig {
maxMessagesPerPrompt?: number;
model?: string;
effort?: string;
/** RESOLVED harness-capability map (code defaults ⊕ per-group overrides) — never the raw overrides. */
harnessCapabilities: Record<string, HarnessCapabilityState>;
}
/** Build a `ContainerConfig` from a DB row + agent group identity. */
@@ -63,6 +67,7 @@ export function configFromDb(row: ContainerConfigRow, group: AgentGroup): Contai
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
model: row.model ?? undefined,
effort: row.effort ?? undefined,
harnessCapabilities: resolveHarnessCapabilities(row.harness_capabilities),
};
}
+9 -4
View File
@@ -132,11 +132,16 @@ async function spawnContainer(session: Session): Promise<void> {
const containerConfig = materializeContainerJson(agentGroup.id);
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before. Runs before the provider
// contribution so a surfaces-providing provider finds the group dir ready.
// idempotent: it only writes paths that don't already exist (plus the
// harness-capability reconcile, which converges settings.json to the
// resolved state), so this call is safe for groups that have spawned
// before. Runs before the provider contribution so a surfaces-providing
// provider finds the group dir ready.
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
initGroupFilesystem(agentGroup, { provider: providerName });
initGroupFilesystem(agentGroup, {
provider: providerName,
harnessCapabilities: containerConfig.harnessCapabilities,
});
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
+9 -2
View File
@@ -10,7 +10,14 @@ const SCALAR_COLUMNS = new Set([
'max_messages_per_prompt',
'cli_scope',
]);
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
const JSON_COLUMNS = new Set([
'skills',
'mcp_servers',
'packages_apt',
'packages_npm',
'additional_mounts',
'harness_capabilities',
]);
export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
@@ -82,7 +89,7 @@ export function updateContainerConfigScalars(
/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
export function updateContainerConfigJson(
agentGroupId: string,
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts' | 'harness_capabilities',
value: unknown,
): void {
if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
@@ -0,0 +1,11 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration020: Migration = {
version: 20,
name: 'harness-capabilities',
up(db: Database.Database) {
db.prepare("ALTER TABLE container_configs ADD COLUMN harness_capabilities TEXT NOT NULL DEFAULT '{}'").run();
},
};
+2
View File
@@ -18,6 +18,7 @@ import { moduleApprovalsPendingApprovals } from './module-approvals-pending-appr
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
import { migration018 } from './018-approvals-approver-user-id.js';
import { migration019 } from './019-wiring-threads.js';
import { migration020 } from './020-harness-capabilities.js';
export interface Migration {
version: number;
@@ -52,6 +53,7 @@ export const migrations: Migration[] = [
migration015,
migration016,
migration019,
migration020,
];
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
+143
View File
@@ -0,0 +1,143 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-group-init-settings-test';
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-group-init-settings-test/data',
GROUPS_DIR: '/tmp/nanoclaw-group-init-settings-test/groups',
}));
vi.mock('./log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { initGroupFilesystem } from './group-init.js';
import { log } from './log.js';
import type { HarnessCapabilityState } from './harness-capabilities.js';
import type { AgentGroup } from './types.js';
const TEAMS_ENV_KEY = 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS';
const DEFAULT_CAPS: Record<string, HarnessCapabilityState> = { 'agent-teams': 'off', workflow: 'off' };
let seq = 0;
function makeGroup(): AgentGroup {
seq += 1;
const ag = {
id: `ag-settings-${seq}`,
name: `settings-${seq}`,
folder: `settings-${seq}`,
agent_provider: null,
created_at: new Date().toISOString(),
} as AgentGroup;
createAgentGroup(ag);
return ag;
}
function settingsPath(ag: AgentGroup): string {
return path.join(TEST_ROOT, 'data', 'v2-sessions', ag.id, '.claude-shared', 'settings.json');
}
function readSettings(ag: AgentGroup): Record<string, unknown> & { env?: Record<string, string> } {
return JSON.parse(fs.readFileSync(settingsPath(ag), 'utf-8'));
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('reconcileHarnessSettings via initGroupFilesystem', () => {
it('first spawn with defaults: no teams key, disableWorkflows set, unmanaged keys intact', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
const s = readSettings(ag);
expect(s.env?.[TEAMS_ENV_KEY]).toBeUndefined();
expect(s.disableWorkflows).toBe(true);
expect(s.env?.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD).toBe('1');
expect(s.env?.CLAUDE_CODE_DISABLE_AUTO_MEMORY).toBe('0');
expect(JSON.stringify(s.hooks)).toContain('compact-instructions');
});
it('agent-teams=on adds the env key; workflow=on removes disableWorkflows', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: { 'agent-teams': 'on', workflow: 'on' } });
const s = readSettings(ag);
expect(s.env?.[TEAMS_ENV_KEY]).toBe('1');
expect('disableWorkflows' in s).toBe(false);
});
it('converges a legacy settings.json: strips the always-on teams key, preserves hand additions', () => {
const ag = makeGroup();
const file = settingsPath(ag);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
JSON.stringify(
{
env: { [TEAMS_ENV_KEY]: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', OPERATOR_CUSTOM: 'keep-me' },
hooks: { PreCompact: [{ hooks: [{ type: 'command', command: 'bun /app/src/compact-instructions.ts' }] }] },
operatorCustomTopLevel: { nested: true },
},
null,
2,
) + '\n',
);
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
const s = readSettings(ag);
expect(s.env?.[TEAMS_ENV_KEY]).toBeUndefined();
expect(s.env?.OPERATOR_CUSTOM).toBe('keep-me');
expect(s.operatorCustomTopLevel).toEqual({ nested: true });
expect(s.disableWorkflows).toBe(true);
});
it('is write-stable: same caps on a second run leave the file byte-identical and unwritten', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
const file = settingsPath(ag);
const before = fs.readFileSync(file, 'utf-8');
const mtimeBefore = fs.statSync(file).mtimeMs;
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
expect(fs.readFileSync(file, 'utf-8')).toBe(before);
expect(fs.statSync(file).mtimeMs).toBe(mtimeBefore);
});
it('leaves the file alone and warns instead of throwing on malformed JSON', () => {
const ag = makeGroup();
const file = settingsPath(ag);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '{ not json');
expect(() => initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS })).not.toThrow();
expect(fs.readFileSync(file, 'utf-8')).toBe('{ not json');
expect(vi.mocked(log.warn).mock.calls.some(([msg]) => String(msg).includes('malformed'))).toBe(true);
});
it('does not touch settings.json when no capabilities are passed (non-spawn callers)', () => {
const ag = makeGroup();
initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS });
const file = settingsPath(ag);
const withCaps = fs.readFileSync(file, 'utf-8');
// Simulate a non-spawn caller (create-agent, channel-approval): no opt.
initGroupFilesystem(ag);
expect(fs.readFileSync(file, 'utf-8')).toBe(withCaps);
});
});
+73 -2
View File
@@ -5,13 +5,16 @@ import { DATA_DIR, GROUPS_DIR } from './config.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
import type { HarnessCapabilityState } from './harness-capabilities.js';
import type { AgentGroup } from './types.js';
// Managed harness keys (the teams env key, disableWorkflows) are deliberately
// NOT in the static default — they enter settings.json exclusively through
// reconcileHarnessSettings() from the group's resolved capability state.
const DEFAULT_SETTINGS_JSON =
JSON.stringify(
{
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
@@ -49,7 +52,12 @@ const DEFAULT_SETTINGS_JSON =
*/
export function initGroupFilesystem(
group: AgentGroup,
opts?: { instructions?: string; provider?: string | null },
opts?: {
instructions?: string;
provider?: string | null;
/** RESOLVED harness-capability map — when provided (the spawn path), settings.json is reconciled to it. */
harnessCapabilities?: Record<string, HarnessCapabilityState>;
},
): void {
const initialized: string[] = [];
@@ -126,6 +134,13 @@ export function initGroupFilesystem(
ensurePreCompactHook(settingsFile, initialized);
}
// Runs in BOTH branches: a freshly written default must be reconciled in
// the same call, or the group's first container lifetime ships without
// its capability state (the default file deliberately omits managed keys).
if (opts?.harnessCapabilities) {
reconcileHarnessSettings(settingsFile, opts.harnessCapabilities, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
@@ -173,3 +188,59 @@ function ensurePreCompactHook(settingsFile: string, initialized: string[]): void
// Don't break init if settings.json is malformed — it'll use whatever's there.
}
}
const TEAMS_ENV_KEY = 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS';
/**
* Reconcile the managed harness-capability keys in settings.json to the
* group's resolved capability state. Manages exactly two keys — the
* agent-teams env key and `disableWorkflows` — adding AND removing them;
* everything else in the file (operator hand-edits, the PreCompact hook,
* unmanaged env keys) is preserved verbatim. Runs on every spawn, so
* pre-existing groups converge to the configured state, including removal
* of the legacy always-on teams key. See docs/harness-capabilities.md.
*/
function reconcileHarnessSettings(
settingsFile: string,
caps: Record<string, HarnessCapabilityState>,
initialized: string[],
): void {
try {
const raw = fs.readFileSync(settingsFile, 'utf-8');
const settings = JSON.parse(raw) as Record<string, unknown> & { env?: Record<string, unknown> };
// agent-teams: the settings env key is the ONLY working switch on the
// pinned CLI — settings env strictly beats SDK options env, so presence
// in this file IS the state.
if (caps['agent-teams'] === 'on') {
if (!settings.env) settings.env = {};
settings.env[TEAMS_ENV_KEY] = '1';
} else if (settings.env && TEAMS_ENV_KEY in settings.env) {
delete settings.env[TEAMS_ENV_KEY];
}
// workflow: disableWorkflows removes the Workflow tool AND its agent-types
// catalog block from every request. The runner's disallowedTools backstop
// covers a malformed or hand-reverted file — that backstop is what makes
// the warn-and-continue below acceptable.
if (caps.workflow === 'off') {
settings.disableWorkflows = true;
} else if ('disableWorkflows' in settings) {
delete settings.disableWorkflows;
}
const next = JSON.stringify(settings, null, 2) + '\n';
if (next === raw) return; // no churn on the every-spawn path
// tmp+rename: two sessions of one group can spawn concurrently; both
// compute identical output from the same DB row, so last-rename-wins is
// harmless — but a torn write mid-read by the CLI would not be.
const tmp = `${settingsFile}.tmp-${process.pid}`;
fs.writeFileSync(tmp, next);
fs.renameSync(tmp, settingsFile);
initialized.push('settings.json (reconciled harness capabilities)');
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
log.warn('settings.json is malformed — skipping harness-capability reconcile', { settingsFile });
}
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
HARNESS_CAPABILITY_DEFAULTS,
parseHarnessCapabilitiesArg,
resolveHarnessCapabilities,
} from './harness-capabilities.js';
describe('resolveHarnessCapabilities', () => {
it('returns code defaults for empty/missing overrides', () => {
for (const input of [undefined, null, '', '{}']) {
expect(resolveHarnessCapabilities(input)).toEqual(HARNESS_CAPABILITY_DEFAULTS);
}
expect(HARNESS_CAPABILITY_DEFAULTS).toEqual({ 'agent-teams': 'off', workflow: 'off' });
});
it('applies stored overrides on top of defaults', () => {
expect(resolveHarnessCapabilities('{"workflow":"on"}')).toEqual({ 'agent-teams': 'off', workflow: 'on' });
expect(resolveHarnessCapabilities('{"agent-teams":"on","workflow":"on"}')).toEqual({
'agent-teams': 'on',
workflow: 'on',
});
});
it('degrades malformed JSON to defaults instead of throwing', () => {
expect(resolveHarnessCapabilities('{nope')).toEqual(HARNESS_CAPABILITY_DEFAULTS);
expect(resolveHarnessCapabilities('[1,2]')).toEqual(HARNESS_CAPABILITY_DEFAULTS);
expect(resolveHarnessCapabilities('"off"')).toEqual(HARNESS_CAPABILITY_DEFAULTS);
});
it('passes unknown keys through and keeps defaults for garbage values on known keys', () => {
const resolved = resolveHarnessCapabilities('{"monitor":"off","workflow":"sideways"}');
expect(resolved.monitor).toBe('off');
expect(resolved.workflow).toBe('off'); // garbage value → default, not 'sideways'
expect(resolved['agent-teams']).toBe('off');
});
});
describe('parseHarnessCapabilitiesArg', () => {
it('parses set and clear operations', () => {
expect(parseHarnessCapabilitiesArg('agent-teams=on')).toEqual({ set: { 'agent-teams': 'on' }, clear: [] });
expect(parseHarnessCapabilitiesArg('workflow=default')).toEqual({ set: {}, clear: ['workflow'] });
expect(parseHarnessCapabilitiesArg('agent-teams=on, workflow=default')).toEqual({
set: { 'agent-teams': 'on' },
clear: ['workflow'],
});
});
it('normalizes key case and underscores', () => {
expect(parseHarnessCapabilitiesArg('AGENT_TEAMS=ON')).toEqual({ set: { 'agent-teams': 'on' }, clear: [] });
});
it('rejects unknown keys with the allowed set in the message', () => {
expect(() => parseHarnessCapabilitiesArg('web=off')).toThrow(/unknown harness capability "web".*agent-teams/);
});
it('rejects bad values and malformed pairs', () => {
expect(() => parseHarnessCapabilitiesArg('workflow=maybe')).toThrow(/must be on, off, or default/);
expect(() => parseHarnessCapabilitiesArg('workflow')).toThrow(/must be key=value/);
expect(() => parseHarnessCapabilitiesArg(' , ')).toThrow(/requires one or more/);
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Harness-capability registry: which harness-native features NanoClaw exposes
* as per-group toggles, and their code defaults.
*
* Policy lives here on the host; mechanism lives provider-side in the agent
* runner. Per-group overrides are a sparse JSON map in
* `container_configs.harness_capabilities`; the RESOLVED map (defaults ⊕
* overrides) is what gets materialized into container.json and applied to the
* group's settings.json by the reconciler in group-init.ts. Fixed-off
* capabilities (scheduling, ask-user, plan/worktree, DesignSync) are not keys
* here by design — a toggle nobody can meaningfully use is surface without
* value. See docs/harness-capabilities.md.
*/
import { log } from './log.js';
export type HarnessCapabilityState = 'on' | 'off';
/**
* Configurable keys and their defaults. Adding a key is additive — no schema
* change. Keys are lowercase kebab-case; both toggles default OFF because
* NanoClaw's own systems (a2a messaging, host-side orchestration) are the
* authoritative equivalents.
*/
export const HARNESS_CAPABILITY_DEFAULTS: Record<string, HarnessCapabilityState> = {
'agent-teams': 'off',
workflow: 'off',
};
const VALID_STATES = new Set<string>(['on', 'off']);
/**
* Resolve a group's stored override JSON against the code defaults.
*
* Malformed JSON degrades to defaults with a warning rather than throwing:
* unlike structural config (mcp_servers, mounts), capabilities have a safe
* fallback, and the only sanctioned write path (ncl) validates its input.
* Unknown keys pass through untouched (with a warning) so the runner's own
* unknown-key diagnostics stay exercised; known keys with garbage values
* fall back to their default.
*/
export function resolveHarnessCapabilities(
overridesJson: string | null | undefined,
): Record<string, HarnessCapabilityState> {
let overrides: Record<string, unknown> = {};
if (overridesJson) {
try {
const parsed: unknown = JSON.parse(overridesJson);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
overrides = parsed as Record<string, unknown>;
} else {
log.warn('harness_capabilities is not a JSON object — using defaults', { value: overridesJson });
}
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
log.warn('harness_capabilities is malformed JSON — using defaults', { value: overridesJson });
}
}
const resolved: Record<string, HarnessCapabilityState> = { ...HARNESS_CAPABILITY_DEFAULTS };
for (const [key, value] of Object.entries(overrides)) {
if (!(key in HARNESS_CAPABILITY_DEFAULTS)) {
log.warn('Unknown harness capability key in overrides — passing through', { key });
resolved[key] = value as HarnessCapabilityState;
continue;
}
if (typeof value !== 'string' || !VALID_STATES.has(value)) {
log.warn('Invalid harness capability value — using default', { key, value });
continue;
}
resolved[key] = value as HarnessCapabilityState;
}
return resolved;
}
export interface HarnessCapabilityOps {
set: Record<string, HarnessCapabilityState>;
clear: string[];
}
/**
* Parse the ncl `--harness-capabilities` flag value: comma-separated `k=v`
* pairs where v is `on`, `off`, or `default` (`default` clears the override —
* it is never stored). Keys are normalized (trim, lowercase, `_`→`-`) and
* validated against the registry. Throws with an actionable message on the
* first problem.
*/
export function parseHarnessCapabilitiesArg(input: string): HarnessCapabilityOps {
const allowed = Object.keys(HARNESS_CAPABILITY_DEFAULTS).join(', ');
const ops: HarnessCapabilityOps = { set: {}, clear: [] };
const pairs = input
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
if (pairs.length === 0) {
throw new Error(`--harness-capabilities requires one or more key=value pairs (keys: ${allowed})`);
}
for (const pair of pairs) {
const eq = pair.indexOf('=');
if (eq === -1) {
throw new Error(`--harness-capabilities entry "${pair}" must be key=value (on|off|default)`);
}
const key = pair.slice(0, eq).trim().toLowerCase().replace(/_/g, '-');
const value = pair
.slice(eq + 1)
.trim()
.toLowerCase();
if (!(key in HARNESS_CAPABILITY_DEFAULTS)) {
throw new Error(`unknown harness capability "${key}" — configurable keys: ${allowed}`);
}
if (value === 'default') {
ops.clear.push(key);
} else if (VALID_STATES.has(value)) {
ops.set[key] = value as HarnessCapabilityState;
} else {
throw new Error(`harness capability "${key}" must be on, off, or default — got "${value}"`);
}
}
return ops;
}
+7 -1
View File
@@ -43,7 +43,13 @@ function session(id: string, agentGroupId: string): Session {
}
function containerConfig(): ContainerConfig {
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
return {
mcpServers: {},
packages: { apt: [], npm: [] },
additionalMounts: [],
skills: [],
harnessCapabilities: { 'agent-teams': 'off', workflow: 'off' },
};
}
beforeEach(() => {
+1
View File
@@ -25,6 +25,7 @@ export interface ContainerConfigRow {
packages_npm: string; // JSON: string[]
additional_mounts: string; // JSON: AdditionalMountConfig[]
cli_scope: string; // 'disabled' | 'group' | 'global'
harness_capabilities: string; // JSON: sparse Record<key, 'on'|'off'> overrides — see src/harness-capabilities.ts
updated_at: string;
}