From 3d5dc8d8ef74bc287263eaf0437bb6f387da7672 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 8 Jul 2026 15:16:25 +0300 Subject: [PATCH] feat(harness): capability registry, schema, materialization, settings reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backfill-container-configs.ts | 1 + src/container-config.ts | 5 + src/container-runner.ts | 13 +- src/db/container-configs.ts | 11 +- src/db/migrations/020-harness-capabilities.ts | 11 ++ src/db/migrations/index.ts | 2 + src/group-init.settings.test.ts | 143 ++++++++++++++++++ src/group-init.ts | 75 ++++++++- src/harness-capabilities.test.ts | 62 ++++++++ src/harness-capabilities.ts | 119 +++++++++++++++ src/provider-surfaces.test.ts | 8 +- src/types.ts | 1 + 12 files changed, 442 insertions(+), 9 deletions(-) create mode 100644 src/db/migrations/020-harness-capabilities.ts create mode 100644 src/group-init.settings.test.ts create mode 100644 src/harness-capabilities.test.ts create mode 100644 src/harness-capabilities.ts diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts index 5551c90ad..a375e7a68 100644 --- a/src/backfill-container-configs.ts +++ b/src/backfill-container-configs.ts @@ -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(), }; diff --git a/src/container-config.ts b/src/container-config.ts index 597ca9295..40f82eae0 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -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; } /** 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), }; } diff --git a/src/container-runner.ts b/src/container-runner.ts index 7330a4c52..971a8c451 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -132,11 +132,16 @@ async function spawnContainer(session: Session): Promise { 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 diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts index 219c73fe9..b86481224 100644 --- a/src/db/container-configs.ts +++ b/src/db/container-configs.ts @@ -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}`); diff --git a/src/db/migrations/020-harness-capabilities.ts b/src/db/migrations/020-harness-capabilities.ts new file mode 100644 index 000000000..859592b9c --- /dev/null +++ b/src/db/migrations/020-harness-capabilities.ts @@ -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(); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 6f13ae2f7..44d6e9032 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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 diff --git a/src/group-init.settings.test.ts b/src/group-init.settings.test.ts new file mode 100644 index 000000000..f06d85b7b --- /dev/null +++ b/src/group-init.settings.test.ts @@ -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()), + 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 = { '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 & { env?: Record } { + 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); + }); +}); diff --git a/src/group-init.ts b/src/group-init.ts index 1ff05e6f0..915b43f29 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -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; + }, ): 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, + initialized: string[], +): void { + try { + const raw = fs.readFileSync(settingsFile, 'utf-8'); + const settings = JSON.parse(raw) as Record & { env?: Record }; + + // 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 }); + } +} diff --git a/src/harness-capabilities.test.ts b/src/harness-capabilities.test.ts new file mode 100644 index 000000000..205e7cd7c --- /dev/null +++ b/src/harness-capabilities.test.ts @@ -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/); + }); +}); diff --git a/src/harness-capabilities.ts b/src/harness-capabilities.ts new file mode 100644 index 000000000..65e35b785 --- /dev/null +++ b/src/harness-capabilities.ts @@ -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 = { + 'agent-teams': 'off', + workflow: 'off', +}; + +const VALID_STATES = new Set(['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 { + let overrides: Record = {}; + if (overridesJson) { + try { + const parsed: unknown = JSON.parse(overridesJson); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + overrides = parsed as Record; + } 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 = { ...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; + 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; +} diff --git a/src/provider-surfaces.test.ts b/src/provider-surfaces.test.ts index e23380342..e9a409771 100644 --- a/src/provider-surfaces.test.ts +++ b/src/provider-surfaces.test.ts @@ -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(() => { diff --git a/src/types.ts b/src/types.ts index a4575e89e..30fd9544f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 overrides — see src/harness-capabilities.ts updated_at: string; }