mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
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:
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user