mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4cb6320c | |||
| 10c1a47faf | |||
| b5a66e87d9 | |||
| 1d1f3992a1 | |||
| 04975929c3 | |||
| 8bdcf20a89 | |||
| 155e685c58 |
+2
-1
@@ -4,7 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **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, not an availability filter — though it does *promote* the optional `Glob`/`Grep` tools onto the surface, which is why those stay listed. 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 pin bump until the fixture is regenerated with `dump-sdk-tools.ts` and the lists re-verified.
|
||||
- **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^(n−1) 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
* 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';
|
||||
@@ -21,6 +23,12 @@ import baseline from './sdk-tools-baseline.json';
|
||||
*/
|
||||
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', () => {
|
||||
@@ -29,6 +37,18 @@ describe('sdk tool-surface drift guard', () => {
|
||||
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);
|
||||
@@ -54,4 +74,14 @@ describe('sdk tool-surface drift guard', () => {
|
||||
).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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ 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.
|
||||
// - 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',
|
||||
@@ -33,22 +37,47 @@ export const SDK_DISALLOWED_TOOLS = [
|
||||
'ExitPlanMode',
|
||||
'EnterWorktree',
|
||||
'ExitWorktree',
|
||||
'DesignSync',
|
||||
'ReportFindings',
|
||||
];
|
||||
|
||||
// Pre-approved tool set for NanoClaw agent containers. Two verified facts
|
||||
// about `allowedTools` on the pinned CLI (wire-captured, see
|
||||
// sdk-tools-baseline.json + claude.tools.test.ts):
|
||||
//
|
||||
// 1. It is NOT an availability filter — omitting a tool does not remove it
|
||||
// (true for builtin and MCP names alike), and its permission-approval
|
||||
// function is moot under this runner's `bypassPermissions`.
|
||||
// 2. It DOES promote optional tools into the surface: `Glob` and `Grep` are
|
||||
// only offered to the model because they are listed here. Do not remove
|
||||
// them casually — that is the one way this list changes agent behavior.
|
||||
//
|
||||
// The per-server `mcpAllowPattern` entries derived at the call site are
|
||||
// retained because permission-gating of MCP invocation under non-bypass
|
||||
// modes is unverified. Exported for the fixture regenerator and tests.
|
||||
/**
|
||||
* 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',
|
||||
@@ -158,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 () => {
|
||||
@@ -342,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;
|
||||
@@ -353,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 {
|
||||
@@ -413,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
|
||||
@@ -423,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)] }],
|
||||
|
||||
@@ -10,18 +10,21 @@
|
||||
* --entrypoint bun <nanoclaw-agent image> /app/src/providers/dump-sdk-tools.ts \
|
||||
* > container/agent-runner/src/providers/sdk-tools-baseline.json
|
||||
*
|
||||
* Maximal-surface capture: the production TOOL_ALLOWLIST is passed (it
|
||||
* PROMOTES optional tools — Glob/Grep only appear when listed) but no
|
||||
* disallowedTools, and agent-teams is enabled via a temp settings.json
|
||||
* (settings env strictly beats SDK options env, so this is the only way to
|
||||
* enable it — 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, which is captured
|
||||
* before the run dies on the auth error.
|
||||
* 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).
|
||||
*
|
||||
* The fixture records WIRE tool names (what the model actually sees). The
|
||||
* SDK init message reports legacy alias names for some tools (e.g. `Task`
|
||||
* where the wire says `Agent`) — do not swap this to an init capture.
|
||||
* 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';
|
||||
@@ -30,11 +33,8 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { TOOL_ALLOWLIST } from './claude.js';
|
||||
|
||||
const requests: string[] = [];
|
||||
let requests: string[] = [];
|
||||
let captured: (() => void) | null = null;
|
||||
const firstRequest = new Promise<void>((resolve) => {
|
||||
captured = resolve;
|
||||
});
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
@@ -62,47 +62,53 @@ fs.writeFileSync(
|
||||
JSON.stringify({ env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } }, null, 2),
|
||||
);
|
||||
|
||||
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,
|
||||
/** 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 } : {}),
|
||||
},
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['user'],
|
||||
allowedTools: TOOL_ALLOWLIST,
|
||||
},
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _m of q) {
|
||||
/* drain until the auth error kills the run */
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _m of q) {
|
||||
/* drain until the auth error kills the run */
|
||||
}
|
||||
} catch {
|
||||
/* expected: 401 from the stub */
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
})();
|
||||
|
||||
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 biggest = requests.reduce((a, b) => (b.length > a.length ? b : a));
|
||||
const parsed = JSON.parse(biggest) as { tools?: Array<{ name: string }> };
|
||||
const tools = [...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];
|
||||
@@ -118,8 +124,10 @@ console.log(
|
||||
cliVersion,
|
||||
sdkVersion,
|
||||
capturedAt: new Date().toISOString(),
|
||||
capture: 'production allowlist (promotes Glob/Grep), no disallowedTools, teams enabled via settings; wire names',
|
||||
capture:
|
||||
'wire names; tools=production allowlist surface, toolsBare=no allowedTools; no disallowedTools; teams on',
|
||||
tools,
|
||||
toolsBare,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"cliVersion": "2.1.197",
|
||||
"sdkVersion": "0.3.197",
|
||||
"capturedAt": "2026-07-08T12:06:41.811Z",
|
||||
"capture": "production allowlist (promotes Glob/Grep), no disallowedTools, teams enabled via settings; wire names",
|
||||
"capturedAt": "2026-07-09T07:30:04.445Z",
|
||||
"capture": "wire names; tools=production allowlist surface, toolsBare=no allowedTools; no disallowedTools; teams on",
|
||||
"tools": [
|
||||
"Agent",
|
||||
"Bash",
|
||||
@@ -31,5 +31,34 @@
|
||||
"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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import path from 'path';
|
||||
import { GROUPS_DIR } from './config.js';
|
||||
import { getContainerConfig } from './db/container-configs.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { resolveHarnessCapabilities } from './harness-capabilities.js';
|
||||
import type { HarnessCapabilityState } from './harness-capabilities.js';
|
||||
import type { AgentGroup, ContainerConfigRow } from './types.js';
|
||||
|
||||
export interface McpServerConfig {
|
||||
@@ -43,6 +45,8 @@ export interface ContainerConfig {
|
||||
maxMessagesPerPrompt?: number;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
/** RESOLVED harness-capability map (code defaults ⊕ per-group overrides) — never the raw overrides. */
|
||||
harnessCapabilities: Record<string, HarnessCapabilityState>;
|
||||
}
|
||||
|
||||
/** Build a `ContainerConfig` from a DB row + agent group identity. */
|
||||
@@ -63,6 +67,7 @@ export function configFromDb(row: ContainerConfigRow, group: AgentGroup): Contai
|
||||
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
|
||||
model: row.model ?? undefined,
|
||||
effort: row.effort ?? undefined,
|
||||
harnessCapabilities: resolveHarnessCapabilities(row.harness_capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -132,11 +132,16 @@ async function spawnContainer(session: Session): Promise<void> {
|
||||
const containerConfig = materializeContainerJson(agentGroup.id);
|
||||
|
||||
// Per-group filesystem state lives forever after first creation. Init is
|
||||
// idempotent: it only writes paths that don't already exist, so this call
|
||||
// is a no-op for groups that have spawned before. Runs before the provider
|
||||
// contribution so a surfaces-providing provider finds the group dir ready.
|
||||
// idempotent: it only writes paths that don't already exist (plus the
|
||||
// harness-capability reconcile, which converges settings.json to the
|
||||
// resolved state), so this call is safe for groups that have spawned
|
||||
// before. Runs before the provider contribution so a surfaces-providing
|
||||
// provider finds the group dir ready.
|
||||
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
|
||||
initGroupFilesystem(agentGroup, { provider: providerName });
|
||||
initGroupFilesystem(agentGroup, {
|
||||
provider: providerName,
|
||||
harnessCapabilities: containerConfig.harnessCapabilities,
|
||||
});
|
||||
|
||||
// Resolve the effective provider + any host-side contribution it declares
|
||||
// (extra mounts, env passthrough). Computed once and threaded through both
|
||||
|
||||
@@ -10,7 +10,14 @@ const SCALAR_COLUMNS = new Set([
|
||||
'max_messages_per_prompt',
|
||||
'cli_scope',
|
||||
]);
|
||||
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
|
||||
const JSON_COLUMNS = new Set([
|
||||
'skills',
|
||||
'mcp_servers',
|
||||
'packages_apt',
|
||||
'packages_npm',
|
||||
'additional_mounts',
|
||||
'harness_capabilities',
|
||||
]);
|
||||
|
||||
export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
|
||||
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 key→directive 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;
|
||||
}
|
||||
@@ -43,7 +43,13 @@ function session(id: string, agentGroupId: string): Session {
|
||||
}
|
||||
|
||||
function containerConfig(): ContainerConfig {
|
||||
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
|
||||
return {
|
||||
mcpServers: {},
|
||||
packages: { apt: [], npm: [] },
|
||||
additionalMounts: [],
|
||||
skills: [],
|
||||
harnessCapabilities: { 'agent-teams': 'off', workflow: 'off' },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ContainerConfigRow {
|
||||
packages_npm: string; // JSON: string[]
|
||||
additional_mounts: string; // JSON: AdditionalMountConfig[]
|
||||
cli_scope: string; // 'disabled' | 'group' | 'global'
|
||||
harness_capabilities: string; // JSON: sparse Record<key, 'on'|'off'> overrides — see src/harness-capabilities.ts
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user