Compare commits

...

11 Commits

Author SHA1 Message Date
Gabi Simons 3d4cb6320c feat(harness): grandfather existing groups — make the capability flip non-breaking
Migration 019 now stamps existing container_configs rows with
{agent-teams:on, workflow:on} — their pre-feature behavior — so upgrading
changes nothing for current groups. Only rows inserted after the migration
(new groups; every group on a fresh install, which has none yet) get the
lean column default. Operators opt existing groups into the lean defaults
per group via ncl.

Verified e2e on a real main->branch upgrade: existing groups keep teams +
Workflow through the full reconcile+spawn path (settings.json retains the
teams env key, no disableWorkflows, container.json shows both on); a new
group gets {}. Drops both [BREAKING] CHANGELOG entries — the change is now
non-breaking. Also corrects the allowlist CHANGELOG line (allowedTools is
fully inert, not Glob/Grep-promoting) and notes the added SDK-pin guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:36:47 +03:00
Gabi Simons 10c1a47faf fix(harness): address code-review findings (correctness, security honesty, altitude)
Correctness:
- reconciler + PreCompact hook unified into one read-modify-write with a
  single atomic write, and the parsed value is shape-validated: a settings.json
  holding valid-JSON-non-object (null, a scalar, {"env":"bad"}) no longer
  throws a TypeError that bricked the group — it warns and skips, like the old
  hook. Fixes divergent atomic/non-atomic writers and a double read per spawn.
- config update wraps both DB writes in one transaction — a failing scalar
  update can no longer leave the harness override half-applied.
- parseHarnessCapabilitiesArg returns a single key->directive map, so a repeated
  key resolves last-wins (was: clears always beat sets regardless of order).
- validation uses Object.hasOwn, not `key in` — inherited names like
  `constructor`/`toString` are no longer accepted as capabilities.
- createContainerConfig now binds cli_scope + harness_capabilities (were
  silently dropped to the column default).

Security honesty:
- agent-teams=off is documented as spawn-time hygiene, not a hard in-container
  boundary: settings.json is RW-mounted so an agent can rewrite it for the
  container lifetime. workflow=off keeps its disallowedTools backstop. Follow-up
  filed to mount the managed settings source read-only.
- dispatch escalation gate protects canonical field names and normalizes each
  incoming arg key — no spelling-variant bypass.

Altitude / cleanup:
- capabilities carry their host mechanism in the registry; the reconciler
  iterates it, so adding a key is one registry entry (+ one runner map line if
  it blocks a tool). Runner unknown-key lockstep + warning loop deleted (host
  drops unknown keys at resolve; warns once per distinct problem, not per spawn).
- resolved view is structured {state, source}; source is 'override' only for a
  VALID stored override (an invalid value reports 'default', not a lie).
- ReportFindings joins the fixed disallow list (headless has no UI to receive
  it; ~1.9KB/turn). PreToolUse hook built once in the constructor with a Set.
- drift guard: assert the installed SDK version too (SDK now pinned exactly),
  and dual-capture the fixture (tools vs toolsBare). This EMPIRICALLY corrected
  a prior claim: allowedTools is fully inert here (surfaces are byte-identical),
  it does not promote Glob/Grep — comment fixed to match.

Host 663 + container 125 green; wire re-probed (default drops
Workflow/DesignSync/ReportFindings; workflow=on restores only Workflow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:34:32 +03:00
Gabi Simons b5a66e87d9 test(harness): materialization end-to-end — override row reaches container.json resolved
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:34:32 +03:00
Gabi Simons 1d1f3992a1 docs(harness): capability doc ([BREAKING] anchor), CHANGELOG entries, db-central + CLAUDE.md rows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:34:32 +03:00
Gabi Simons 04975929c3 feat(agent-runner): capability-driven disallow list + per-instance hook blocklist
container.json's resolved harnessCapabilities map threads RunnerConfig →
ProviderOptions → ClaudeProvider. The fixed disallow set gains DesignSync
(desktop tool, nothing to sync with headless; 9.3KB/turn schema);
buildDisallowedTools adds Workflow unless workflow=on — defense-in-depth
behind the host-reconciled disableWorkflows settings key, fail-closed on
absent/garbage state. preToolUseHook becomes a factory so the hook blocks
the same per-instance list. Unknown resolved keys warn at construction;
agent-teams is known-but-host-managed so it never warns. Scheduling
instructions gain the harness-task-list vs schedule_task naming note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:33:26 +03:00
Gabi Simons 8bdcf20a89 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>
2026-07-09 12:32:36 +03:00
Gabi Simons 155e685c58 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>
2026-07-09 12:32:36 +03:00
Gabi Simons 60d1324652 fix(agent-runner): reconcile Claude tool allowlist with pinned CLI, add drift guard
TOOL_ALLOWLIST named five tools that don't exist on claude-code 2.1.197
(Task was renamed Agent upstream; TodoWrite, TeamCreate, TeamDelete, and
ToolSearch are gone). Remove the phantoms and correct the comment:
allowedTools is a permission auto-approve list, not an availability
filter — but it does promote the optional Glob/Grep tools onto the
surface, which is why those stay listed. No wire-visible change.

Add a wire-captured fixture (sdk-tools-baseline.json, regenerated via
dump-sdk-tools.ts inside the agent image with a zero-API-cost 401-stub
capture) and claude.tools.test.ts, which fails on any future claude-code
pin bump until the fixture is regenerated and the lists re-verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:31:34 +03:00
github-actions[bot] 0c0f4c2592 docs: update token count to 223k tokens · 111% of context window 2026-07-09 08:24:27 +00:00
github-actions[bot] 92635f1934 chore: bump version to 2.1.41 2026-07-09 08:24:22 +00:00
gavrielc f7a43ef881 Merge pull request #2981 from nanocoai/tasks/02-ncl-tasks-core
Scheduled tasks: ncl tasks control plane, isolated sessions, script gate
2026-07-09 11:24:07 +03:00
36 changed files with 1377 additions and 121 deletions
+2
View File
@@ -4,6 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Per-group harness-capability toggles — new groups default lean, existing groups unchanged.** Claude Code ships built-in features NanoClaw already replaces its own way: **agent teams** (overlaps `create_agent` + destinations; multiplies separately-billed agents invisibly) and the **Workflow** tool (duplicates NanoClaw's orchestration; largest per-turn tool schema). Both are now **OFF by default for newly-created groups** (and `DesignSync`, a desktop-only tool, is fixed-off) — cutting ~20% of per-turn context. **This is non-breaking:** a new `container_configs.harness_capabilities` column (migration 019) *grandfathers every existing group* to its prior behavior (teams + Workflow on), so upgrading changes nothing for current agents. Defaults live in `src/harness-capabilities.ts`; the host reconciles each group's settings.json at spawn from the resolved state. Toggle per group with `ncl groups config update --id <g> --harness-capabilities 'agent-teams=on,workflow=off'` (+ `ncl groups restart`); opt an existing group into the lean defaults the same way. Details: [docs/harness-capabilities.md](docs/harness-capabilities.md).
- **Claude tool allowlist reconciled with the pinned CLI, plus a tool-surface drift guard.** `TOOL_ALLOWLIST` in the agent runner named five tools that don't exist on claude-code 2.1.197 (`Task` — renamed `Agent` upstream — plus `TodoWrite`, `TeamCreate`, `TeamDelete`, `ToolSearch`); the phantom entries are removed and the comment corrected: `allowedTools` is a permission auto-approve list that is **fully inert** under `bypassPermissions` (a two-way wire capture proved the production-allowlist and bare tool surfaces are byte-identical — it neither filters nor promotes tools). No wire-visible behavior change. A new wire-captured fixture (`container/agent-runner/src/providers/sdk-tools-baseline.json`) and `claude.tools.test.ts` now fail on any future claude-code CLI **or SDK** pin bump until the fixture is regenerated with `dump-sdk-tools.ts` and the lists re-verified.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
+2 -1
View File
@@ -73,6 +73,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/harness-capabilities.ts` | Harness-capability registry — `agent-teams`/`workflow` toggles (both default off), resolved into `container.json`; settings reconciler lives in `group-init.ts`. See [docs/harness-capabilities.md](docs/harness-capabilities.md) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
@@ -138,7 +139,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` and `harness_capabilities` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
+1 -1
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/claude-agent-sdk": "0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/claude-agent-sdk": "0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
+3
View File
@@ -18,6 +18,8 @@ export interface RunnerConfig {
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
/** Resolved harness-capability map (host-resolved). Missing → {} = all defaults (fail closed). */
harnessCapabilities: Record<string, string>;
}
const DEFAULT_MAX_MESSAGES = 10;
@@ -47,6 +49,7 @@ export function loadConfig(): RunnerConfig {
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
harnessCapabilities: (raw.harnessCapabilities as Record<string, string>) || {},
};
return _config;
+1
View File
@@ -94,6 +94,7 @@ async function main(): Promise<void> {
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
harnessCapabilities: config.harnessCapabilities,
});
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
@@ -4,6 +4,8 @@ Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent grou
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"``sales-briefing-a25c`); without it ids are `t-<hex>`.
Note: your in-session task-list tools (TaskCreate/TaskUpdate/TaskList) are per-conversation scratch for organizing the current turn's work — they are unrelated to `ncl tasks` scheduled tasks and do not survive the session.
Common commands:
```bash
@@ -0,0 +1,49 @@
/**
* Harness-capability mapping in the Claude provider: the disallow list is the
* fixed set plus capability-driven entries (fail closed), and the PreToolUse
* hook blocks exactly that list. Pure no SDK, no DB (the hook's
* container_state write is try/caught by design).
*/
import { describe, expect, it } from 'bun:test';
import { SDK_DISALLOWED_TOOLS, buildDisallowedTools, createPreToolUseHook } from './claude.js';
type LooseHook = (input: unknown) => Promise<Record<string, unknown>>;
describe('buildDisallowedTools', () => {
it('fails closed: absent/empty/off/garbage all include Workflow plus the fixed set', () => {
for (const caps of [undefined, {}, { workflow: 'off' }, { workflow: 'garbage' }]) {
const list = buildDisallowedTools(caps);
for (const fixed of SDK_DISALLOWED_TOOLS) expect(list).toContain(fixed);
expect(list).toContain('Workflow');
expect(list).toContain('DesignSync');
expect(list).toContain('ReportFindings');
}
});
it('workflow=on removes only Workflow', () => {
const list = buildDisallowedTools({ workflow: 'on' });
expect(list).not.toContain('Workflow');
expect(list).toContain('DesignSync');
expect(list).toContain('CronCreate');
});
it('agent-teams has no runner mechanism and never changes the list', () => {
expect(buildDisallowedTools({ 'agent-teams': 'on' })).toEqual(buildDisallowedTools({ 'agent-teams': 'off' }));
});
});
describe('createPreToolUseHook', () => {
it('blocks a listed tool with the nanoclaw-equivalent message', async () => {
const hook = createPreToolUseHook(['Workflow']) as unknown as LooseHook;
const res = await hook({ tool_name: 'Workflow', tool_input: {} });
expect(res.decision).toBe('block');
expect(String(res.stopReason)).toContain('nanoclaw equivalent');
});
it('passes an unlisted tool through', async () => {
const hook = createPreToolUseHook(['Workflow']) as unknown as LooseHook;
const res = await hook({ tool_name: 'Bash', tool_input: { timeout: 1000 } });
expect(res.continue).toBe(true);
});
});
@@ -0,0 +1,87 @@
/**
* Drift guard for the harness tool surface. sdk-tools-baseline.json is a wire
* capture of every tool the pinned CLI can offer under our configuration
* (regenerate with dump-sdk-tools.ts instructions in its header). These
* tests catch upstream renames/removals when the claude-code pin moves:
* bumping container/cli-tools.json fails the version assertion until the
* fixture is regenerated and the lists below are re-verified.
*/
import fs from 'fs';
import { describe, expect, it } from 'bun:test';
import cliTools from '../../../cli-tools.json';
import { SDK_DISALLOWED_TOOLS, TOOL_ALLOWLIST } from './claude.js';
import baseline from './sdk-tools-baseline.json';
/**
* Disallow entries that do NOT exist on the pinned CLI in headless SDK mode
* (wire-verified: never offered, in both string and streaming input modes).
* Kept in SDK_DISALLOWED_TOOLS as drift insurance if an upstream version
* starts offering one, the fixture regeneration surfaces it here and the
* entry moves out of this set.
*/
const KNOWN_ABSENT_DISALLOWED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
const installedSdkVersion = (
JSON.parse(
fs.readFileSync(new URL('../../node_modules/@anthropic-ai/claude-agent-sdk/package.json', import.meta.url), 'utf8'),
) as { version: string }
).version;
const baselineTools = new Set<string>(baseline.tools);
describe('sdk tool-surface drift guard', () => {
it('fixture matches the pinned claude-code CLI version', () => {
const pin = cliTools.find((t) => t.name === '@anthropic-ai/claude-code')?.version;
expect(baseline.cliVersion).toBe(pin);
});
it('fixture matches the installed Agent SDK version', () => {
// The SDK is a caret-free pinned dep; a bump must be captured deliberately.
expect(baseline.sdkVersion).toBe(installedSdkVersion);
});
it('allowedTools is inert: the production allowlist and the bare surface match', () => {
// Wire-verified invariant — passing TOOL_ALLOWLIST neither filters nor
// promotes any tool under bypassPermissions. If a future CLI makes
// allowedTools an availability filter, these diverge and this fails.
expect([...baseline.tools].sort()).toEqual([...baseline.toolsBare].sort());
});
it('every allowlist entry names a real tool on this surface', () => {
for (const name of TOOL_ALLOWLIST) {
expect(baselineTools.has(name), `allowlist entry '${name}' not in captured surface`).toBe(true);
}
});
it('every disallow entry is either a real tool or documented drift insurance', () => {
for (const name of SDK_DISALLOWED_TOOLS) {
const real = baselineTools.has(name);
const insurance = KNOWN_ABSENT_DISALLOWED.includes(name);
expect(
real || insurance,
`disallow entry '${name}' is neither on the surface nor in KNOWN_ABSENT_DISALLOWED`,
).toBe(true);
}
});
it('drift-insurance entries are still absent from the surface', () => {
for (const name of KNOWN_ABSENT_DISALLOWED) {
expect(
baselineTools.has(name),
`'${name}' now exists on the surface — move it out of KNOWN_ABSENT_DISALLOWED and re-verify its disposition`,
).toBe(false);
}
});
it('capability-managed tools exist on the surface', () => {
// Workflow (the `workflow` capability) and DesignSync (fixed-off) must be
// real tools for the disallow/settings mechanisms to be doing anything.
for (const name of ['Workflow', 'DesignSync']) {
expect(baselineTools.has(name), `'${name}' vanished from the surface — re-audit its capability mapping`).toBe(
true,
);
}
});
});
+92 -45
View File
@@ -23,7 +23,11 @@ function log(msg: string): void {
// the question and blocks on the real reply.
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
// Code UI affordances; in a headless container they'd appear stuck.
const SDK_DISALLOWED_TOOLS = [
// - DesignSync: desktop design-tool integration — nothing to sync with in a
// headless container, and a ~9.3KB/turn schema (wire-measured).
// - ReportFindings: code-review-reporting UI affordance with no headless host
// surface to receive it (~1.9KB/turn schema).
export const SDK_DISALLOWED_TOOLS = [
'CronCreate',
'CronDelete',
'CronList',
@@ -33,32 +37,62 @@ const SDK_DISALLOWED_TOOLS = [
'ExitPlanMode',
'EnterWorktree',
'ExitWorktree',
'DesignSync',
'ReportFindings',
];
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
// at the call site from the registered `mcpServers` map so that any server
// added via `add_mcp_server` (or wired in container.json directly) is
// reachable to the agent — without this, the SDK's allowedTools filter
// silently drops every MCP namespace not listed here.
const TOOL_ALLOWLIST = [
/**
* Configurable capabilities that block a harness tool when OFF, mapped to the
* tool name. This is the runner half of the capability contract (the host half
* is the settings reconciler); it is genuinely runner-specific because
* `disallowedTools` is a Claude concept the host tree can't name. The block is
* defense-in-depth the primary OFF mechanism is the host-reconciled settings
* key covering a malformed or hand-reverted settings.json.
*/
const CAPABILITY_DISALLOWS: Record<string, string> = {
workflow: 'Workflow',
};
/**
* Effective disallow list for a provider instance: the fixed set plus, for each
* capability in CAPABILITY_DISALLOWS whose resolved state is not explicitly
* `on`, its tool. Absent or unexpected state fails closed (tool stays blocked).
* `caps` is the resolved map from container.json (host-resolved: only known
* keys, valid values).
*/
export function buildDisallowedTools(caps?: Record<string, string>): string[] {
const list = [...SDK_DISALLOWED_TOOLS];
for (const [key, tool] of Object.entries(CAPABILITY_DISALLOWS)) {
if (caps?.[key] !== 'on') list.push(tool);
}
return list;
}
// Pre-approved tool set for NanoClaw agent containers. `allowedTools` is fully
// INERT on the pinned CLI under this runner's `bypassPermissions`: the wire
// tool surface is byte-identical whether this list is passed or omitted
// entirely (wire-captured — the fixture's `tools` and `toolsBare` are equal;
// claude.tools.test.ts asserts it). It neither filters nor promotes tools; its
// only role is a permission auto-approve list, which bypassPermissions moots.
// Kept as the accurate pre-approval set for a hypothetical non-bypass mode, and
// because the per-server `mcpAllowPattern` entries derived at the call site
// are retained (MCP invocation-gating under non-bypass modes is unverified).
// Exported for the fixture regenerator and tests.
export const TOOL_ALLOWLIST = [
'Agent',
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'WebSearch',
'WebFetch',
'Task',
'NotebookEdit',
'Read',
'SendMessage',
'Skill',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit',
'WebFetch',
'WebSearch',
'Write',
];
// MCP server names are sanitized by the SDK when forming tool prefixes:
@@ -153,31 +187,36 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
}
/**
* PreToolUse hook: record the current tool + its declared timeout so the host
* sweep can widen its stuck tolerance while Bash is running a long-declared
* script. Defense-in-depth: if SDK_DISALLOWED_TOOLS slips through somehow,
* block the call here instead of letting the agent hang.
* PreToolUse hook factory: record the current tool + its declared timeout so
* the host sweep can widen its stuck tolerance while Bash is running a
* long-declared script. Defense-in-depth: if a disallowed tool slips through
* somehow, block the call here instead of letting the agent hang. A factory
* (like createPreCompactHook) because the blocklist is per-instance it
* depends on the group's resolved harness capabilities.
*/
const preToolUseHook: HookCallback = async (input) => {
const i = input as { tool_name?: string; tool_input?: Record<string, unknown> };
const toolName = i.tool_name ?? '';
if (SDK_DISALLOWED_TOOLS.includes(toolName)) {
return {
decision: 'block',
stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`,
} as unknown as ReturnType<HookCallback>;
}
// Bash exposes its timeout via the tool_input.timeout field (ms). Any other
// tool: no declared timeout.
const declaredTimeoutMs =
toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null;
try {
setContainerToolInFlight(toolName, declaredTimeoutMs);
} catch (err) {
log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`);
}
return { continue: true };
};
export function createPreToolUseHook(blockedTools: Iterable<string>): HookCallback {
const blocked = new Set(blockedTools);
return async (input) => {
const i = input as { tool_name?: string; tool_input?: Record<string, unknown> };
const toolName = i.tool_name ?? '';
if (blocked.has(toolName)) {
return {
decision: 'block',
stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`,
} as unknown as ReturnType<HookCallback>;
}
// Bash exposes its timeout via the tool_input.timeout field (ms). Any other
// tool: no declared timeout.
const declaredTimeoutMs =
toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null;
try {
setContainerToolInFlight(toolName, declaredTimeoutMs);
} catch (err) {
log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`);
}
return { continue: true };
};
}
/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */
const postToolUseHook: HookCallback = async () => {
@@ -337,6 +376,8 @@ export class ClaudeProvider implements AgentProvider {
private additionalDirectories?: string[];
private model?: string;
private effort?: string;
private disallowedTools: string[];
private preToolUseHook: HookCallback;
constructor(options: ProviderOptions = {}) {
this.assistantName = options.assistantName;
@@ -348,6 +389,12 @@ export class ClaudeProvider implements AgentProvider {
...(options.env ?? {}),
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
};
// The resolved harness-capability map in container.json is host-resolved:
// only known keys with valid values reach here, so the runner never has to
// reason about unknown keys. Blocklist + hook are per-instance but immutable
// after construction — build both once.
this.disallowedTools = buildDisallowedTools(options.harnessCapabilities);
this.preToolUseHook = createPreToolUseHook(this.disallowedTools);
}
isSessionInvalid(err: unknown): boolean {
@@ -408,7 +455,7 @@ export class ClaudeProvider implements AgentProvider {
...TOOL_ALLOWLIST,
...Object.keys(this.mcpServers).map(mcpAllowPattern),
],
disallowedTools: SDK_DISALLOWED_TOOLS,
disallowedTools: this.disallowedTools,
env: this.env,
model: this.model,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -418,7 +465,7 @@ export class ClaudeProvider implements AgentProvider {
settingSources: ['project', 'user', 'local'],
mcpServers: this.mcpServers,
hooks: {
PreToolUse: [{ hooks: [preToolUseHook] }],
PreToolUse: [{ hooks: [this.preToolUseHook] }],
PostToolUse: [{ hooks: [postToolUseHook] }],
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
@@ -0,0 +1,136 @@
/**
* Regenerates sdk-tools-baseline.json the bare SDK tool-surface fixture
* asserted by claude.tools.test.ts.
*
* Must run INSIDE the agent container image (the pinned CLI binary only
* exists there). From the repo root:
*
* docker run --rm --network none \
* -v "$PWD/container/agent-runner/src":/app/src:ro \
* --entrypoint bun <nanoclaw-agent image> /app/src/providers/dump-sdk-tools.ts \
* > container/agent-runner/src/providers/sdk-tools-baseline.json
*
* Two captures, so the fixture makes tool PROMOTION visible (the allowlist
* promotes optional tools Glob/Grep only appear when listed):
* - `tools` : with the production TOOL_ALLOWLIST (the real surface).
* - `toolsBare` : with no allowedTools (the un-promoted surface).
* The drift test derives `promoted = tools toolsBare`, so a removed allowlist
* entry that USED to be promoted fails the test instead of silently vanishing
* from both. Neither capture passes disallowedTools; agent-teams is enabled via
* a temp settings.json (settings env strictly beats SDK options env see
* docs/harness-capabilities.md).
*
* Zero API traffic: ANTHROPIC_BASE_URL points at an in-process stub answering
* 401; the full tools array rides on the first /v1/messages request, captured
* before the run dies on the auth error. The fixture records WIRE tool names
* (the SDK init message reports legacy aliases, e.g. `Task` for wire `Agent`
* do not swap this to an init capture).
*/
import { execFileSync } from 'child_process';
import fs from 'fs';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { TOOL_ALLOWLIST } from './claude.js';
let requests: string[] = [];
let captured: (() => void) | null = null;
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
const url = new URL(req.url);
const body = await req.text();
if (url.pathname.includes('/messages')) {
requests.push(body);
captured?.();
}
return new Response(
JSON.stringify({ type: 'error', error: { type: 'authentication_error', message: 'fixture-capture-stub' } }),
{ status: 401, headers: { 'content-type': 'application/json' } },
);
},
});
const HOME = '/tmp/dump-sdk-tools-home';
const CWD = '/tmp/dump-sdk-tools-ws';
fs.mkdirSync(`${HOME}/.claude`, { recursive: true });
fs.mkdirSync(CWD, { recursive: true });
fs.writeFileSync(
`${HOME}/.claude/settings.json`,
JSON.stringify({ env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } }, null, 2),
);
/** Run one capture and return the sorted wire tool names. */
async function capture(allowedTools?: string[]): Promise<string[]> {
requests = [];
const firstRequest = new Promise<void>((resolve) => {
captured = resolve;
});
const q = query({
prompt: 'fixture capture: reply with one word',
options: {
cwd: CWD,
pathToClaudeCodeExecutable: '/pnpm/claude',
systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const },
env: {
...process.env,
HOME,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`,
ANTHROPIC_API_KEY: 'fixture-dummy-key',
ANTHROPIC_AUTH_TOKEN: undefined,
},
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['user'],
...(allowedTools ? { allowedTools } : {}),
},
});
void (async () => {
try {
for await (const _m of q) {
/* drain until the auth error kills the run */
}
} catch {
/* expected: 401 from the stub */
}
})();
await Promise.race([firstRequest, Bun.sleep(75_000)]);
await Bun.sleep(1_500); // let retries land so we can pick the largest body
if (requests.length === 0) {
console.error('[dump-sdk-tools] no /v1/messages request captured');
process.exit(1);
}
const biggest = requests.reduce((a, b) => (b.length > a.length ? b : a));
const parsed = JSON.parse(biggest) as { tools?: Array<{ name: string }> };
return [...new Set((parsed.tools ?? []).map((t) => t.name))].sort();
}
const tools = await capture(TOOL_ALLOWLIST);
const toolsBare = await capture();
const cliVersionRaw = execFileSync('/pnpm/claude', ['--version'], { encoding: 'utf8' }).trim();
const cliVersion = cliVersionRaw.split(/\s+/)[0];
const sdkVersion = (
JSON.parse(fs.readFileSync('/app/node_modules/@anthropic-ai/claude-agent-sdk/package.json', 'utf8')) as {
version: string;
}
).version;
console.log(
JSON.stringify(
{
cliVersion,
sdkVersion,
capturedAt: new Date().toISOString(),
capture:
'wire names; tools=production allowlist surface, toolsBare=no allowedTools; no disallowedTools; teams on',
tools,
toolsBare,
},
null,
2,
),
);
process.exit(0);
@@ -0,0 +1,64 @@
{
"cliVersion": "2.1.197",
"sdkVersion": "0.3.197",
"capturedAt": "2026-07-09T07:30:04.445Z",
"capture": "wire names; tools=production allowlist surface, toolsBare=no allowedTools; no disallowedTools; teams on",
"tools": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
],
"toolsBare": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
]
}
@@ -79,6 +79,12 @@ export interface ProviderOptions {
* through to the underlying SDK. If omitted, the SDK default is used.
*/
effort?: string;
/**
* RESOLVED harness-capability map from container.json (key 'on'|'off',
* resolved host-side against code defaults). Providers map keys to their
* own mechanisms and may ignore the map entirely; absent means all-off.
*/
harnessCapabilities?: Record<string, string>;
}
export interface QueryInput {
+3
View File
@@ -320,12 +320,14 @@ CREATE TABLE container_configs (
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
harness_capabilities TEXT NOT NULL DEFAULT '{}', -- sparse overrides: {"agent-teams"|"workflow": "on"|"off"}
updated_at TEXT NOT NULL
);
```
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
- `harness_capabilities` stores per-group overrides only; code defaults + resolution live in `src/harness-capabilities.ts` (see [harness-capabilities.md](harness-capabilities.md))
### 1.16 `pending_sender_approvals`
@@ -425,6 +427,7 @@ Several early migrations were later renamed/retired and replaced by "module" fil
| 16 | `messaging-group-instance` | `016-messaging-group-instance.ts` | `messaging_groups` gets an `instance` column (adapter-instance dimension); table recreate (`disableForeignKeys: true`) backfills `instance = channel_type` on every existing row and relaxes the `UNIQUE` to `(channel_type, platform_id, instance)` |
| 17 | `agent-message-policies` | `017-agent-message-policies.ts` | `agent_message_policies` (see §1.18) |
| 18 | `approvals-approver-user-id` | `018-approvals-approver-user-id.ts` | `pending_approvals.approver_user_id` — names a single required approver for a2a message-gate policies |
| 19 | `harness-capabilities` | `019-harness-capabilities.ts` | `ALTER TABLE container_configs ADD COLUMN harness_capabilities` — per-group harness toggles (see [harness-capabilities.md](harness-capabilities.md)) |
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
+46
View File
@@ -0,0 +1,46 @@
# Harness capabilities
NanoClaw disables harness-native features that overlap its own systems, and exposes a small per-group toggle surface for the ones where both states are meaningful. Policy (keys, defaults, resolution) lives host-side in [`src/harness-capabilities.ts`](../src/harness-capabilities.ts); per-group overrides live in the `harness_capabilities` column of `container_configs`; mechanisms live in the agent runner and the settings reconciler ([`src/group-init.ts`](../src/group-init.ts)).
## Capability table
| Capability | Key | Default | Mechanism |
|---|---|---|---|
| Agent teams (experimental multi-agent coordination inside one session) | `agent-teams` | **off** | Settings reconciler adds/removes `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` in the group's `settings.json` on every spawn. On the pinned CLI, settings env strictly beats SDK options env, so the settings file is the only working switch. |
| Workflow tool (in-session multi-agent orchestration scripts) | `workflow` | **off** | Reconciler sets `disableWorkflows: true` (removes the tool and its agent-types catalog — ~26KB/turn); the runner also adds `Workflow` to `disallowedTools` + PreToolUse hook as a backstop. |
| Cron/scheduling (`CronCreate/CronDelete/CronList`, `ScheduleWakeup`) | — | fixed off | `disallowedTools` + hook. NanoClaw's `ncl tasks` is the authoritative scheduler. |
| `AskUserQuestion` | — | fixed off | `disallowedTools` (returns a placeholder headless; `ask_user_question` is the real mechanism). |
| Plan/worktree modes | — | fixed off | `disallowedTools` (broken headless). |
| `DesignSync` | — | fixed off | `disallowedTools` (desktop design-tool integration; nothing to sync with in a container; ~9.3KB/turn schema). |
| Task list (`TaskCreate/…`), subagents (`Agent`), web (`WebSearch/WebFetch`) | — | fixed on | No NanoClaw overlap. Harness task lists are per-session scratch — not NanoClaw scheduled tasks. |
Toggling:
```bash
ncl groups config get --id <group-id> # shows raw overrides + resolved view
ncl groups config update --id <group-id> --harness-capabilities 'agent-teams=on'
ncl groups config update --id <group-id> --harness-capabilities 'workflow=on,agent-teams=default'
ncl groups restart --id <group-id> # apply
```
`default` clears the per-group override (it is never stored). Config changes through `ncl` from inside a container are rejected — like `cli_scope`, the sanctioned/persistent path is operator-only.
### Enforcement strength (be precise about the boundary)
- **`workflow` off** has two independent locks: the reconciled `disableWorkflows` settings key *and* a runner-side `disallowedTools` block. The tool cannot come back inside a running container even if the settings file is edited.
- **`agent-teams` off** has only one mechanism: the absence of the env key from the group's `settings.json`. That file is mounted **read-write** into the container (the CLI needs to write transcripts there), and `settingSources` also loads project/local settings from the agent-writable workspace. So an agent that actively rewrites its own settings can re-enable teams **for the current container lifetime**, until the next spawn re-reconciles it. Treat `agent-teams=off` as **configuration hygiene enforced at spawn**, not a hard adversarial boundary inside a live container — the real trust boundary remains the container sandbox + OneCLI. A planned follow-up will mount the managed settings source read-only to close this.
## Upgrade behavior — non-breaking (grandfathered)
Before this feature every group ran with agent teams on and Workflow available. Migration 019 **grandfathers every existing group** to that prior state — it stamps `{"agent-teams":"on","workflow":"on"}` onto each row that exists at upgrade time — so **upgrading changes nothing for your current agents**. Only newly-created groups (and every group on a fresh install) get the lean defaults via the column default `{}`.
- **Verify after upgrade**: `ncl groups config get --id <g>` shows `agent-teams`/`workflow` as `on (override)` for pre-existing groups; their `settings.json` keeps the teams env key and has no `disableWorkflows`.
- **Opt an existing group into the lean defaults** (to get the ~20%/turn saving): `ncl groups config update --id <g> --harness-capabilities 'agent-teams=off,workflow=off'` then `ncl groups restart --id <g>`.
- **Re-enable on a new group**: `ncl groups config update --id <g> --harness-capabilities 'agent-teams=on,workflow=on'` then restart. The `ncl` command is the rollback — per group, no code changes.
Why the defaults are off for new groups: agent teams overlaps NanoClaw's a2a (`create_agent` + destinations), is experimental upstream, and multiplies separately-billed agents invisibly to ops; Workflow is redundant with NanoClaw's orchestration and is the single largest tool schema on every turn.
## Notes for forks
- If your fork patched `SDK_DISALLOWED_TOOLS` in `container/agent-runner/src/providers/claude.ts`: the fixed list still lives there, but per-group state now composes through `buildDisallowedTools()` — re-apply your patch to the fixed list, or express it as capability keys if it fits.
- The measured numbers above are for `@anthropic-ai/claude-code` 2.1.197 / SDK 0.3.197. When bumping the pin, `claude.tools.test.ts` fails until you regenerate the tool-surface fixture: run [`dump-sdk-tools.ts`](../container/agent-runner/src/providers/dump-sdk-tools.ts) inside the agent image (invocation in its header) and re-verify the allow/disallow lists against the new surface.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.40",
"version": "2.1.41",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="213k tokens, 106% of context window">
<title>213k tokens, 106% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="223k tokens, 111% of context window">
<title>223k tokens, 111% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">213k</text>
<text x="71" y="14">213k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">223k</text>
<text x="71" y="14">223k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+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(),
};
+1 -1
View File
@@ -222,7 +222,7 @@ function syncSymlink(linkPath: string, target: string): void {
fs.symlinkSync(target, linkPath);
}
function writeAtomic(filePath: string, content: string): void {
export function writeAtomic(filePath: string, content: string): void {
const tmp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, filePath);
+29
View File
@@ -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' });
+11 -3
View File
@@ -86,9 +86,17 @@ export async function dispatch(
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
}
// Block cli_scope changes from group-scoped agents (privilege escalation)
if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) {
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
// Operator-only config fields — block changes from group-scoped agents
// (privilege escalation: cli_scope widens CLI access, harness
// capabilities re-enable harness features the operator turned off). This
// gate runs on RAW args before normalizeArgs, so we normalize each
// incoming key to canonical snake_case and compare — one spelling per
// protected field, no spelling-variant bypass. Add future fields HERE.
const OPERATOR_ONLY_ARGS = new Set(['cli_scope', 'harness_capabilities']);
for (const key of Object.keys(req.args)) {
if (OPERATOR_ONLY_ARGS.has(key.replace(/-/g, '_'))) {
return err(req.id, 'forbidden', `Cannot change ${key.replace(/-/g, '_')} from a group-scoped agent.`);
}
}
// Auto-fill agent-group-related args so the agent doesn't need
+135
View File
@@ -0,0 +1,135 @@
/**
* 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, updateContainerConfigJson } 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', () => {
type Resolved = Record<string, { state: string; source: string }>;
it('sets an override and marks it as an 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: Resolved;
};
expect(data.harness_capabilities).toEqual({ 'agent-teams': 'on' });
expect(data.harness_capabilities_resolved['agent-teams']).toEqual({ state: 'on', source: 'override' });
expect(data.harness_capabilities_resolved.workflow).toEqual({ state: 'off', source: '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: Resolved };
expect(data.harness_capabilities_resolved['agent-teams']).toEqual({ state: 'off', source: '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: Resolved;
};
expect(data.harness_capabilities).toEqual({ workflow: 'on' });
expect(data.harness_capabilities_resolved).toEqual({
'agent-teams': { state: 'off', source: 'default' },
workflow: { state: 'on', source: 'override' },
});
}
});
it('a stored invalid value reports source=default, not a lying override', async () => {
// Direct DB write bypassing validation (version skew / manual edit).
updateContainerConfigJson(GID, 'harness_capabilities', { workflow: 'sideways' });
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_resolved: Resolved };
expect(data.harness_capabilities_resolved.workflow).toEqual({ state: 'off', source: 'default' });
}
});
});
+43 -5
View File
@@ -13,11 +13,19 @@ import {
updateContainerConfigJson,
} from '../../db/container-configs.js';
import { createAgentFromTemplate } from '../../templates/create-agent.js';
import {
HARNESS_CAPABILITY_KEYS,
parseHarnessCapabilitiesArg,
parseHarnessOverrides,
resolveHarnessCapabilities,
} from '../../harness-capabilities.js';
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
/** Deserialize JSON columns for display. */
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
const overrides = parseHarnessOverrides(row.harness_capabilities);
const resolved = resolveHarnessCapabilities(overrides);
return {
agent_group_id: row.agent_group_id,
provider: row.provider,
@@ -32,6 +40,17 @@ 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,
// Structured {state, source} per key — machine-readable and honest: source
// is 'override' only when a VALID override is stored (an invalid stored
// value resolves to the default, so it reports 'default', not a lie).
harness_capabilities_resolved: Object.fromEntries(
Object.entries(resolved).map(([k, state]) => {
const ov = (overrides as Record<string, unknown>)[k];
const source = ov === 'on' || ov === 'off' ? 'override' : 'default';
return [k, { state, source }];
}),
),
updated_at: row.updated_at,
};
}
@@ -242,8 +261,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: ${HARNESS_CAPABILITY_KEYS.join(', ')}; \`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 +291,31 @@ registerResource({
updates.cli_scope = scope;
}
if (Object.keys(updates).length === 0) {
// Harness capabilities are a sparse-override JSON column. parse returns
// a key→directive map (last-wins per key); apply each: `default` clears
// the override, on/off set it.
let nextOverrides: Record<string, unknown> | undefined;
if (args.harness_capabilities !== undefined) {
const directives = parseHarnessCapabilitiesArg(String(args.harness_capabilities));
nextOverrides = parseHarnessOverrides(row.harness_capabilities);
for (const [key, directive] of Object.entries(directives)) {
if (directive === 'default') delete nextOverrides[key];
else nextOverrides[key] = directive;
}
}
if (Object.keys(updates).length === 0 && nextOverrides === undefined) {
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);
// Single transaction: a failure in either write (e.g. a bad scalar
// value) rolls back both, so the command can never half-apply.
getDb().transaction(() => {
if (nextOverrides !== undefined) updateContainerConfigJson(id, 'harness_capabilities', nextOverrides);
if (Object.keys(updates).length > 0) updateContainerConfigScalars(id, updates);
})();
const updated = getContainerConfig(id)!;
return presentConfig(updated);
+49
View File
@@ -0,0 +1,49 @@
/**
* Materialization end-to-end: a stored harness_capabilities override reaches
* groups/<folder>/container.json as the RESOLVED map (defaults overrides),
* which is the only shape the runner ever sees.
*/
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-container-config-test';
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-container-config-test/data',
GROUPS_DIR: '/tmp/nanoclaw-container-config-test/groups',
}));
import { materializeContainerJson } from './container-config.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { ensureContainerConfig, updateContainerConfigJson } from './db/container-configs.js';
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('materializeContainerJson harness capabilities', () => {
it('writes the resolved map — defaults for a fresh row, override applied when stored', () => {
const ag = { id: 'ag-mat', name: 'mat', folder: 'mat', agent_provider: null, created_at: new Date().toISOString() };
createAgentGroup(ag);
ensureContainerConfig(ag.id);
let config = materializeContainerJson(ag.id);
expect(config.harnessCapabilities).toEqual({ 'agent-teams': 'off', workflow: 'off' });
updateContainerConfigJson(ag.id, 'harness_capabilities', { workflow: 'on' });
config = materializeContainerJson(ag.id);
expect(config.harnessCapabilities).toEqual({ 'agent-teams': 'off', workflow: 'on' });
const onDisk = JSON.parse(fs.readFileSync(path.join(TEST_ROOT, 'groups', 'mat', 'container.json'), 'utf-8'));
expect(onDisk.harnessCapabilities).toEqual({ 'agent-teams': 'off', workflow: 'on' });
});
});
+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
+11 -4
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
@@ -29,11 +36,11 @@ export function createContainerConfig(config: ContainerConfigRow): void {
`INSERT INTO container_configs (
agent_group_id, provider, model, effort, image_tag, assistant_name,
max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
additional_mounts, updated_at
additional_mounts, cli_scope, harness_capabilities, updated_at
) VALUES (
@agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
@max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
@additional_mounts, @updated_at
@additional_mounts, @cli_scope, @harness_capabilities, @updated_at
)`,
)
.run(config);
@@ -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,65 @@
/**
* Migration 019 is upgrade-safe: it grandfathers EXISTING groups to their
* pre-feature behavior (agent-teams + Workflow on) so an upgrade changes
* nothing, while groups created after it get the lean column default.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createAgentGroup } from '../agent-groups.js';
import { closeDb, getDb, initTestDb } from '../connection.js';
import { getContainerConfig } from '../container-configs.js';
import { migrations, runMigrations } from './index.js';
const now = () => new Date().toISOString();
function group(id: string): void {
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: now() });
}
describe('migration 019 (harness-capabilities) upgrade safety', () => {
beforeEach(() => {
const db = initTestDb();
// Run everything EXCEPT 019 to reach the pre-upgrade schema (no column yet).
runMigrations(
db,
migrations.filter((m) => m.name !== 'harness-capabilities'),
);
});
afterEach(() => closeDb());
it('grandfathers a pre-existing group to teams+workflow on; leaves later rows lean', () => {
const db = getDb();
// A group that existed before the upgrade (raw insert — column absent yet).
group('ag-old');
db.prepare('INSERT INTO container_configs (agent_group_id, updated_at) VALUES (?, ?)').run('ag-old', now());
// The upgrade: run migration 019.
runMigrations(
db,
migrations.filter((m) => m.name === 'harness-capabilities'),
);
// Existing group keeps its prior behavior — non-breaking.
expect(JSON.parse(getContainerConfig('ag-old')!.harness_capabilities)).toEqual({
'agent-teams': 'on',
workflow: 'on',
});
// A group created AFTER the migration gets the lean column default.
group('ag-new');
db.prepare('INSERT INTO container_configs (agent_group_id, updated_at) VALUES (?, ?)').run('ag-new', now());
expect(JSON.parse(getContainerConfig('ag-new')!.harness_capabilities)).toEqual({});
});
it('is a no-op on a fresh install (no existing rows) — new groups start lean', () => {
const db = getDb();
runMigrations(
db,
migrations.filter((m) => m.name === 'harness-capabilities'),
);
group('ag-fresh');
db.prepare('INSERT INTO container_configs (agent_group_id, updated_at) VALUES (?, ?)').run('ag-fresh', now());
expect(JSON.parse(getContainerConfig('ag-fresh')!.harness_capabilities)).toEqual({});
});
});
@@ -0,0 +1,22 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration019: Migration = {
version: 19,
name: 'harness-capabilities',
up(db: Database.Database) {
db.prepare("ALTER TABLE container_configs ADD COLUMN harness_capabilities TEXT NOT NULL DEFAULT '{}'").run();
// Upgrade safety (grandfather). Before this feature every group ran with
// agent-teams on (the old default settings.json) and Workflow available.
// Stamp that onto EXISTING rows so an upgrade changes nothing for current
// groups — this makes the feature non-breaking. Only rows inserted AFTER
// this migration (new groups, and every group on a fresh install, which
// has no rows here yet, so this UPDATE is a no-op there) get the lean
// defaults via the column DEFAULT '{}'. Operators opt existing groups into
// the lean defaults per group with
// ncl groups config update --id <g> --harness-capabilities 'agent-teams=off,workflow=off'
db.prepare(`UPDATE container_configs SET harness_capabilities = '{"agent-teams":"on","workflow":"on"}'`).run();
},
};
+2
View File
@@ -17,6 +17,7 @@ import { migration016 } from './016-messaging-group-instance.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
import { migration018 } from './018-approvals-approver-user-id.js';
import { migration019 } from './019-harness-capabilities.js';
export interface Migration {
version: number;
@@ -50,6 +51,7 @@ export const migrations: Migration[] = [
migration014,
migration015,
migration016,
migration019,
];
/** 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);
});
});
+108 -50
View File
@@ -1,36 +1,30 @@
import fs from 'fs';
import path from 'path';
import { writeAtomic } from './claude-md-compose.js';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { HARNESS_CAPABILITIES } from './harness-capabilities.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';
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',
},
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: 'bun /app/src/compact-instructions.ts',
},
],
},
],
},
},
null,
2,
) + '\n';
const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts';
// Base settings for a brand-new group. Managed harness keys (the teams env key,
// disableWorkflows) are deliberately NOT here — they enter settings.json
// exclusively through the reconciler from the group's resolved capability state,
// applied on top of this base in the same write.
const DEFAULT_SETTINGS = {
env: {
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
hooks: {
PreCompact: [{ hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }] }],
},
};
/**
* Initialize the on-disk filesystem state for an agent group. Idempotent
@@ -49,7 +43,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[] = [];
@@ -119,12 +118,7 @@ export function initGroupFilesystem(
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
applyGroupSettings(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.
@@ -145,31 +139,95 @@ export function initGroupFilesystem(
}
}
const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts';
type SettingsObject = Record<string, unknown>;
function asObject(value: unknown): SettingsObject | null {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as SettingsObject) : null;
}
/**
* Patch an existing settings.json to add the PreCompact hook if missing.
* Runs on every group init so pre-existing groups pick up the hook.
* Bring a group's settings.json to its desired state in a single read-modify-
* write: ensure the PreCompact hook is present and reconcile the managed
* harness-capability keys to `caps`. A fresh file is composed from
* DEFAULT_SETTINGS + the capability keys in one atomic write; an existing file
* is mutated in memory and rewritten only if it changed. Any content that isn't
* a JSON object (malformed, `null`, a scalar, an array) is left untouched with
* a warning settings trouble must never break group init or block a spawn.
*/
function ensurePreCompactHook(settingsFile: string, initialized: string[]): void {
function applyGroupSettings(
settingsFile: string,
caps: Record<string, HarnessCapabilityState> | undefined,
initialized: string[],
): void {
if (!fs.existsSync(settingsFile)) {
const settings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) as SettingsObject;
reconcileHarnessKeys(settings, caps);
writeAtomic(settingsFile, JSON.stringify(settings, null, 2) + '\n');
initialized.push('settings.json');
return;
}
let raw: string;
let parsed: unknown;
try {
const raw = fs.readFileSync(settingsFile, 'utf-8');
const settings = JSON.parse(raw);
raw = fs.readFileSync(settingsFile, 'utf-8');
parsed = JSON.parse(raw);
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
log.warn('settings.json is malformed — leaving it untouched', { settingsFile });
return;
}
const settings = asObject(parsed);
if (!settings) {
log.warn('settings.json is not a JSON object — leaving it untouched', { settingsFile });
return;
}
// Check if there's already a PreCompact hook with our command.
const existing = settings.hooks?.PreCompact as unknown[] | undefined;
if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return;
ensurePreCompactHook(settings);
reconcileHarnessKeys(settings, caps);
// Add the hook, preserving existing hooks.
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
settings.hooks.PreCompact.push({
hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }],
});
const next = JSON.stringify(settings, null, 2) + '\n';
if (next === raw) return; // no churn on the every-spawn path
writeAtomic(settingsFile, next);
initialized.push('settings.json (updated)');
}
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
initialized.push('settings.json (added PreCompact hook)');
} catch {
// Don't break init if settings.json is malformed — it'll use whatever's there.
/** Ensure the PreCompact archiving hook is present, preserving existing hooks. */
function ensurePreCompactHook(settings: SettingsObject): void {
const hooks = asObject(settings.hooks) ?? (settings.hooks = {});
const existing = Array.isArray(hooks.PreCompact) ? (hooks.PreCompact as unknown[]) : (hooks.PreCompact = []);
if (JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return;
existing.push({ hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }] });
}
/**
* Reconcile the managed harness-capability keys to `caps`, iterating the
* registry's host mechanisms adding AND removing each key so pre-existing
* groups converge (including removal of the legacy always-on teams key).
* Everything unmanaged in the file is preserved. `caps` undefined (non-spawn
* callers) leaves capability keys untouched.
*/
function reconcileHarnessKeys(
settings: SettingsObject,
caps: Record<string, HarnessCapabilityState> | undefined,
): void {
if (!caps) return;
for (const [key, def] of Object.entries(HARNESS_CAPABILITIES)) {
const state = caps[key] ?? def.default;
const m = def.host;
if (m.kind === 'env') {
// Presence of the env key IS the on-state.
if (state === 'on') {
const env = asObject(settings.env) ?? (settings.env = {});
env[m.key] = '1';
} else {
const env = asObject(settings.env);
if (env && m.key in env) delete env[m.key];
}
} else {
// disableFlag: the flag is set true when the capability is OFF.
if (state === 'off') settings[m.key] = true;
else if (m.key in settings) delete settings[m.key];
}
}
}
+79
View File
@@ -0,0 +1,79 @@
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('drops unknown keys and garbage values back to defaults (never leaks into the resolved map)', () => {
const resolved = resolveHarnessCapabilities('{"monitor":"off","workflow":"sideways","future":{"nested":1}}');
expect(Object.hasOwn(resolved, 'monitor')).toBe(false); // unknown key dropped
expect(Object.hasOwn(resolved, 'future')).toBe(false); // unknown non-string dropped
expect(resolved.workflow).toBe('off'); // garbage value → default, not 'sideways'
expect(resolved['agent-teams']).toBe('off');
});
it('accepts an already-parsed override map', () => {
expect(resolveHarnessCapabilities({ workflow: 'on' })).toEqual({ 'agent-teams': 'off', workflow: 'on' });
});
it('does not treat inherited Object.prototype names as known keys', () => {
// `key in obj` would be true for 'constructor'/'toString'; Object.hasOwn is not.
const resolved = resolveHarnessCapabilities('{"constructor":"on","toString":"off"}');
expect(resolved).toEqual({ 'agent-teams': 'off', workflow: 'off' });
});
});
describe('parseHarnessCapabilitiesArg', () => {
it('parses on/off/default directives into a key→directive map', () => {
expect(parseHarnessCapabilitiesArg('agent-teams=on')).toEqual({ 'agent-teams': 'on' });
expect(parseHarnessCapabilitiesArg('workflow=default')).toEqual({ workflow: 'default' });
expect(parseHarnessCapabilitiesArg('agent-teams=on, workflow=default')).toEqual({
'agent-teams': 'on',
workflow: 'default',
});
});
it('resolves a repeated key last-wins', () => {
expect(parseHarnessCapabilitiesArg('workflow=on,workflow=default')).toEqual({ workflow: 'default' });
expect(parseHarnessCapabilitiesArg('workflow=default,workflow=on')).toEqual({ workflow: 'on' });
});
it('normalizes key case and underscores', () => {
expect(parseHarnessCapabilitiesArg('AGENT_TEAMS=ON')).toEqual({ 'agent-teams': 'on' });
});
it('rejects unknown keys, including inherited prototype names', () => {
expect(() => parseHarnessCapabilitiesArg('web=off')).toThrow(/unknown harness capability "web".*agent-teams/);
expect(() => parseHarnessCapabilitiesArg('constructor=on')).toThrow(/unknown harness capability "constructor"/);
});
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/);
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Harness-capability registry: which harness-native features NanoClaw exposes
* as per-group toggles, their code defaults, and how the host applies each one
* to a group's settings.json.
*
* This is the single host-side source of truth. Per-group overrides are a
* sparse JSON map in `container_configs.harness_capabilities`; the RESOLVED map
* (defaults overrides) is materialized into container.json and applied to the
* group's settings.json by the reconciler in group-init.ts, which iterates the
* `host` mechanism of every entry below so adding a capability is one entry
* here plus (if it also needs a runtime tool block) one line in the runner's
* CAPABILITY_DISALLOWS map. 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';
/**
* How the settings reconciler expresses a capability in settings.json.
* - `env`: the presence of an env key IS the on-state (the key is written when
* on, deleted when off). Verified on the pinned CLI: settings env strictly
* beats SDK options env, so this file is the only working switch.
* - `disableFlag`: a top-level boolean set `true` when the capability is OFF
* (disable-style flag), deleted when on.
*/
export type HostMechanism = { kind: 'env'; key: string } | { kind: 'disableFlag'; key: string };
export interface CapabilityDef {
default: HarnessCapabilityState;
host: HostMechanism;
}
/**
* Configurable capabilities. Both default OFF because NanoClaw's own systems
* (a2a messaging, host-side orchestration) are the authoritative equivalents.
*/
export const HARNESS_CAPABILITIES: Record<string, CapabilityDef> = {
'agent-teams': { default: 'off', host: { kind: 'env', key: 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' } },
workflow: { default: 'off', host: { kind: 'disableFlag', key: 'disableWorkflows' } },
};
export const HARNESS_CAPABILITY_KEYS = Object.keys(HARNESS_CAPABILITIES);
/** Derived key→default map, for callers that only need the defaults. */
export const HARNESS_CAPABILITY_DEFAULTS: Record<string, HarnessCapabilityState> = Object.fromEntries(
Object.entries(HARNESS_CAPABILITIES).map(([key, def]) => [key, def.default]),
);
const VALID_STATES = new Set<string>(['on', 'off']);
function isKnownKey(key: string): boolean {
return Object.hasOwn(HARNESS_CAPABILITIES, key);
}
// Durable bad state (a stale key or garbage value persisted in the DB) would
// otherwise log on every spawn — under scheduled-task wakes, forever. Warn once
// per distinct problem per host process instead, so real warnings aren't buried.
const warnedOnce = new Set<string>();
function warnOnce(signature: string, message: string, data: Record<string, unknown>): void {
if (warnedOnce.has(signature)) return;
warnedOnce.add(signature);
log.warn(message, data);
}
/**
* Parse a group's stored override JSON into the raw sparse map. Malformed or
* non-object JSON degrades to {} 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.
*/
export function parseHarnessOverrides(overridesJson: string | null | undefined): Record<string, unknown> {
if (!overridesJson) return {};
try {
const parsed: unknown = JSON.parse(overridesJson);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
warnOnce(`shape:${overridesJson}`, 'harness_capabilities is not a JSON object — using defaults', {
value: overridesJson,
});
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
warnOnce(`syntax:${overridesJson}`, 'harness_capabilities is malformed JSON — using defaults', {
value: overridesJson,
});
}
return {};
}
/**
* Resolve stored overrides (JSON string, or an already-parsed map) against the
* code defaults. Unknown keys and invalid values are dropped (they fall back to
* defaults) so the resolved map only ever contains known keys with valid
* states the runner therefore never has to reason about unknown keys.
*/
export function resolveHarnessCapabilities(
overrides: string | Record<string, unknown> | null | undefined,
): Record<string, HarnessCapabilityState> {
const raw = typeof overrides === 'string' || overrides == null ? parseHarnessOverrides(overrides) : overrides;
const resolved: Record<string, HarnessCapabilityState> = { ...HARNESS_CAPABILITY_DEFAULTS };
for (const [key, value] of Object.entries(raw)) {
if (!isKnownKey(key)) {
warnOnce(`unknown:${key}`, 'Unknown harness capability key in overrides — dropping', { key });
continue;
}
if (typeof value !== 'string' || !VALID_STATES.has(value)) {
warnOnce(`value:${key}:${String(value)}`, 'Invalid harness capability value — using default', { key, value });
continue;
}
resolved[key] = value as HarnessCapabilityState;
}
return resolved;
}
/**
* 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. Returns a plain keydirective map, so a
* repeated key resolves last-wins by ordinary assignment. Throws with an
* actionable message on the first problem.
*/
export function parseHarnessCapabilitiesArg(input: string): Record<string, HarnessCapabilityState | 'default'> {
const allowed = HARNESS_CAPABILITY_KEYS.join(', ');
const out: Record<string, HarnessCapabilityState | 'default'> = {};
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 (!isKnownKey(key)) {
throw new Error(`unknown harness capability "${key}" — configurable keys: ${allowed}`);
}
if (value === 'default' || VALID_STATES.has(value)) {
out[key] = value as HarnessCapabilityState | 'default';
} else {
throw new Error(`harness capability "${key}" must be on, off, or default — got "${value}"`);
}
}
return out;
}
+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;
}