mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a4dcb673 | |||
| 4a6cc08cd3 | |||
| df6c393346 | |||
| 2b13dfadce | |||
| ea55b7a993 | |||
| 7c3ebb50f9 | |||
| fb1df2d402 | |||
| c9b97a0332 | |||
| a59eedfc49 | |||
| 3d5dc8d8ef | |||
| 7ce25de444 |
@@ -7,34 +7,6 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
|
||||
|
||||
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
|
||||
|
||||
## Number safety check (required)
|
||||
|
||||
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
|
||||
|
||||
Otherwise, use `AskUserQuestion`:
|
||||
|
||||
**Which WhatsApp number will NanoClaw use?**
|
||||
|
||||
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
|
||||
- **Shared / personal number** — the user's existing everyday WhatsApp number
|
||||
|
||||
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
|
||||
|
||||
If `NUMBER_MODE=shared`, show this warning exactly:
|
||||
|
||||
> ⚠️ **Risk to your WhatsApp account**
|
||||
>
|
||||
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
|
||||
>
|
||||
> We strongly recommend using a separate, dedicated number for NanoClaw.
|
||||
|
||||
Then use `AskUserQuestion`:
|
||||
|
||||
- **Go back and use a dedicated number (Recommended)**
|
||||
- **I understand the risk — continue with my shared number**
|
||||
|
||||
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
|
||||
@@ -120,7 +92,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
@@ -237,7 +209,9 @@ The adapter behaves fundamentally differently depending on whether the linked nu
|
||||
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
|
||||
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
|
||||
|
||||
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
|
||||
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
|
||||
|
||||
@@ -250,7 +224,7 @@ ASSISTANT_HAS_OWN_NUMBER=false
|
||||
|
||||
### Update path: existing install, flag unset
|
||||
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Use the mode established by the required safety check and write it explicitly.
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
|
||||
|
||||
Suggest a default by comparing the authed number against the wired DM chat:
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Per-group harness-capability toggles — new groups default lean, existing groups unchanged.** Claude Code ships built-in features NanoClaw already replaces its own way: **agent teams** (overlaps `create_agent` + destinations; multiplies separately-billed agents invisibly) and the **Workflow** tool (duplicates NanoClaw's orchestration; largest per-turn tool schema). Both are now **OFF by default for newly-created groups** (and `DesignSync` + `ReportFindings`, desktop/UI-only tools, are fixed-off) — cutting ~20% of per-turn context. **This is non-breaking:** a new `container_configs.harness_capabilities` column (migration 020) *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, **not an availability filter** — paired wire captures show no allowlist effect on the offered tool surface, and `bypassPermissions` moots its permission role. (The surface itself shows run-to-run variance in conditional tools on this CLI pin, so neither `allowedTools` nor `disallowedTools` alone should be relied on to shape what the model sees — the runner's PreToolUse hook is the deterministic block.) 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.
|
||||
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
|
||||
- **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).
|
||||
|
||||
@@ -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 |
|
||||
@@ -141,7 +142,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",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { configFromRaw } from './config.js';
|
||||
|
||||
describe('configFromRaw harnessCapabilities', () => {
|
||||
it('treats a missing field as a pre-capability host — legacy all-on, not all-off', () => {
|
||||
// Update-window skew: the bind-mounted runner source is already
|
||||
// capability-aware while the still-running old host wrote container.json
|
||||
// without the field. Those groups ran with teams + Workflow on; defaulting
|
||||
// to {} here would regress them to all-off until the host restarts.
|
||||
expect(configFromRaw({}).harnessCapabilities).toEqual({ 'agent-teams': 'on', workflow: 'on' });
|
||||
});
|
||||
|
||||
it('passes an explicit host-resolved map through untouched', () => {
|
||||
const caps = { 'agent-teams': 'off', workflow: 'off' };
|
||||
expect(configFromRaw({ harnessCapabilities: caps }).harnessCapabilities).toEqual(caps);
|
||||
});
|
||||
});
|
||||
@@ -18,12 +18,37 @@ 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 → legacy all-on (pre-capability host). */
|
||||
harnessCapabilities: Record<string, string>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 10;
|
||||
|
||||
// Pre-capability behavior. A container.json without `harnessCapabilities` was
|
||||
// written by an older host (capability-aware hosts always emit the full
|
||||
// resolved map, never omit it) — and under that host every group ran with
|
||||
// teams + Workflow on. Defaulting to {} here would regress those groups to
|
||||
// all-off during the update window where the bind-mounted runner source is
|
||||
// already new but the still-running old host wrote the config file.
|
||||
const LEGACY_HARNESS_CAPABILITIES: Record<string, string> = { 'agent-teams': 'on', workflow: 'on' };
|
||||
|
||||
let _config: RunnerConfig | null = null;
|
||||
|
||||
/** Map raw container.json fields to a RunnerConfig, applying per-field defaults. */
|
||||
export function configFromRaw(raw: Record<string, unknown>): RunnerConfig {
|
||||
return {
|
||||
provider: (raw.provider as string) || 'claude',
|
||||
assistantName: (raw.assistantName as string) || '',
|
||||
groupName: (raw.groupName as string) || '',
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
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>) ?? LEGACY_HARNESS_CAPABILITIES,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from container.json. Called once at startup.
|
||||
* Falls back to sensible defaults for any missing field.
|
||||
@@ -38,17 +63,7 @@ export function loadConfig(): RunnerConfig {
|
||||
console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`);
|
||||
}
|
||||
|
||||
_config = {
|
||||
provider: (raw.provider as string) || 'claude',
|
||||
assistantName: (raw.assistantName as string) || '',
|
||||
groupName: (raw.groupName as string) || '',
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||
model: (raw.model as string) || undefined,
|
||||
effort: (raw.effort as string) || undefined,
|
||||
};
|
||||
|
||||
_config = configFromRaw(raw);
|
||||
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,74 @@
|
||||
/**
|
||||
* 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 { ClaudeProvider, 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 redirect in the model-visible fields', async () => {
|
||||
const hook = createPreToolUseHook(['Workflow']) as unknown as LooseHook;
|
||||
const res = await hook({ tool_name: 'Workflow', tool_input: {} });
|
||||
expect(res.decision).toBe('block');
|
||||
// The CLI feeds `reason` / permissionDecisionReason back to the model on a
|
||||
// deny — stopReason is only surfaced with continue:false (turn-ending).
|
||||
expect(String(res.reason)).toContain('nanoclaw equivalent');
|
||||
const specific = res.hookSpecificOutput as Record<string, unknown>;
|
||||
expect(specific.permissionDecision).toBe('deny');
|
||||
expect(String(specific.permissionDecisionReason)).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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClaudeProvider instance wiring', () => {
|
||||
// Guards the seam the pure-helper tests can't see: the provider must
|
||||
// actually BUILD its blocklist from the capability map it was constructed
|
||||
// with (a revert to the static SDK_DISALLOWED_TOOLS constant at the query
|
||||
// site would pass every other test in this file).
|
||||
it('builds its disallow list and hook blocklist from the constructor capabilities', async () => {
|
||||
const off = new ClaudeProvider({ harnessCapabilities: { workflow: 'off' } });
|
||||
const on = new ClaudeProvider({ harnessCapabilities: { workflow: 'on' } });
|
||||
|
||||
expect(off['disallowedTools']).toContain('Workflow');
|
||||
expect(on['disallowedTools']).not.toContain('Workflow');
|
||||
expect(on['disallowedTools']).toContain('DesignSync');
|
||||
|
||||
const offHook = off['preToolUseHook'] as unknown as LooseHook;
|
||||
const onHook = on['preToolUseHook'] as unknown as LooseHook;
|
||||
expect((await offHook({ tool_name: 'Workflow', tool_input: {} })).decision).toBe('block');
|
||||
expect((await onHook({ tool_name: 'Workflow', tool_input: {} })).continue).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Drift guard for the harness tool surface. sdk-tools-baseline.json is a wire
|
||||
* capture of every tool the pinned CLI can offer under our configuration
|
||||
* (regenerate with dump-sdk-tools.ts — instructions in its header). These
|
||||
* tests catch upstream renames/removals when the claude-code pin moves:
|
||||
* bumping container/cli-tools.json fails the version assertion until the
|
||||
* fixture is regenerated and the lists below are re-verified.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import cliTools from '../../../cli-tools.json';
|
||||
import { SDK_DISALLOWED_TOOLS, TOOL_ALLOWLIST } from './claude.js';
|
||||
import baseline from './sdk-tools-baseline.json';
|
||||
|
||||
/**
|
||||
* Disallow entries that do NOT exist on the pinned CLI in headless SDK mode
|
||||
* (wire-verified: never offered, in both string and streaming input modes).
|
||||
* Kept in SDK_DISALLOWED_TOOLS as drift insurance — if an upstream version
|
||||
* starts offering one, the fixture regeneration surfaces it here and the
|
||||
* entry moves out of this set.
|
||||
*/
|
||||
const KNOWN_ABSENT_DISALLOWED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
|
||||
|
||||
const installedSdkVersion = (
|
||||
JSON.parse(
|
||||
fs.readFileSync(new URL('../../node_modules/@anthropic-ai/claude-agent-sdk/package.json', import.meta.url), 'utf8'),
|
||||
) as { version: string }
|
||||
).version;
|
||||
|
||||
const baselineTools = new Set<string>(baseline.tools);
|
||||
|
||||
describe('sdk tool-surface drift guard', () => {
|
||||
it('fixture matches the pinned claude-code CLI version', () => {
|
||||
const pin = cliTools.find((t) => t.name === '@anthropic-ai/claude-code')?.version;
|
||||
expect(baseline.cliVersion).toBe(pin);
|
||||
});
|
||||
|
||||
it('fixture matches the installed Agent SDK version', () => {
|
||||
// The SDK is a caret-free pinned dep; a bump must be captured deliberately.
|
||||
expect(baseline.sdkVersion).toBe(installedSdkVersion);
|
||||
});
|
||||
|
||||
it('allowedTools has no surface effect: the production allowlist and the bare surface match', () => {
|
||||
// Paired same-run captures — passing TOOL_ALLOWLIST has never been
|
||||
// observed to filter or promote a tool under bypassPermissions. If a
|
||||
// future CLI makes allowedTools an availability filter, these diverge and
|
||||
// this fails. (The surface has independent run-to-run variance in
|
||||
// conditional tools — see the dump-sdk-tools.ts header — which pairing
|
||||
// within one regen run controls for.)
|
||||
expect([...baseline.tools].sort()).toEqual([...baseline.toolsBare].sort());
|
||||
});
|
||||
|
||||
it('every allowlist entry names a real tool on this surface', () => {
|
||||
for (const name of TOOL_ALLOWLIST) {
|
||||
expect(baselineTools.has(name), `allowlist entry '${name}' not in captured surface`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every disallow entry is either a real tool or documented drift insurance', () => {
|
||||
for (const name of SDK_DISALLOWED_TOOLS) {
|
||||
const real = baselineTools.has(name);
|
||||
const insurance = KNOWN_ABSENT_DISALLOWED.includes(name);
|
||||
expect(
|
||||
real || insurance,
|
||||
`disallow entry '${name}' is neither on the surface nor in KNOWN_ABSENT_DISALLOWED`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('drift-insurance entries are still absent from the surface', () => {
|
||||
for (const name of KNOWN_ABSENT_DISALLOWED) {
|
||||
expect(
|
||||
baselineTools.has(name),
|
||||
`'${name}' now exists on the surface — move it out of KNOWN_ABSENT_DISALLOWED and re-verify its disposition`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('capability-managed tools exist on the surface', () => {
|
||||
// Workflow (the `workflow` capability) and DesignSync (fixed-off) must be
|
||||
// real tools for the disallow/settings mechanisms to be doing anything.
|
||||
for (const name of ['Workflow', 'DesignSync']) {
|
||||
expect(baselineTools.has(name), `'${name}' vanished from the surface — re-audit its capability mapping`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,11 @@ function log(msg: string): void {
|
||||
// the question and blocks on the real reply.
|
||||
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
|
||||
// Code UI affordances; in a headless container they'd appear stuck.
|
||||
const SDK_DISALLOWED_TOOLS = [
|
||||
// - DesignSync: desktop design-tool integration — nothing to sync with in a
|
||||
// headless container, and a ~9.3KB/turn schema (wire-measured).
|
||||
// - ReportFindings: code-review-reporting UI affordance with no headless host
|
||||
// surface to receive it (~1.9KB/turn schema).
|
||||
export const SDK_DISALLOWED_TOOLS = [
|
||||
'CronCreate',
|
||||
'CronDelete',
|
||||
'CronList',
|
||||
@@ -34,32 +38,64 @@ const SDK_DISALLOWED_TOOLS = [
|
||||
'ExitPlanMode',
|
||||
'EnterWorktree',
|
||||
'ExitWorktree',
|
||||
'DesignSync',
|
||||
'ReportFindings',
|
||||
];
|
||||
|
||||
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
|
||||
// at the call site from the registered `mcpServers` map so that any server
|
||||
// added via `add_mcp_server` (or wired in container.json directly) is
|
||||
// reachable to the agent — without this, the SDK's allowedTools filter
|
||||
// silently drops every MCP namespace not listed here.
|
||||
const TOOL_ALLOWLIST = [
|
||||
/**
|
||||
* Configurable capabilities that block a harness tool when OFF, mapped to the
|
||||
* tool name. This is the runner half of the capability contract (the host half
|
||||
* is the settings reconciler); it is genuinely runner-specific because
|
||||
* `disallowedTools` is a Claude concept the host tree can't name. The block is
|
||||
* defense-in-depth — the primary OFF mechanism is the host-reconciled settings
|
||||
* key — covering a malformed or hand-reverted settings.json.
|
||||
*/
|
||||
const CAPABILITY_DISALLOWS: Record<string, string> = {
|
||||
workflow: 'Workflow',
|
||||
};
|
||||
|
||||
/**
|
||||
* Effective disallow list for a provider instance: the fixed set plus, for each
|
||||
* capability in CAPABILITY_DISALLOWS whose resolved state is not explicitly
|
||||
* `on`, its tool. Absent or unexpected state fails closed (tool stays blocked).
|
||||
* `caps` is the resolved map from container.json (host-resolved: only known
|
||||
* keys, valid values).
|
||||
*/
|
||||
export function buildDisallowedTools(caps?: Record<string, string>): string[] {
|
||||
const list = [...SDK_DISALLOWED_TOOLS];
|
||||
for (const [key, tool] of Object.entries(CAPABILITY_DISALLOWS)) {
|
||||
if (caps?.[key] !== 'on') list.push(tool);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// Pre-approved tool set for NanoClaw agent containers. `allowedTools` is a
|
||||
// permission auto-approve list, NOT an availability filter: paired same-run
|
||||
// wire captures show no allowlist effect on the offered surface (the fixture's
|
||||
// `tools` and `toolsBare` are equal; claude.tools.test.ts asserts it), and its
|
||||
// permission role is moot under this runner's `bypassPermissions`. The surface
|
||||
// itself has run-to-run variance in conditional tools (see dump-sdk-tools.ts
|
||||
// header), so never rely on this list — or on `disallowedTools` alone — to
|
||||
// shape what the model sees. Kept as the accurate pre-approval set for a
|
||||
// hypothetical non-bypass mode, and because the per-server `mcpAllowPattern`
|
||||
// entries derived at the call site are retained (MCP invocation-gating under
|
||||
// non-bypass modes is unverified). Exported for the fixture regenerator and
|
||||
// tests.
|
||||
export const TOOL_ALLOWLIST = [
|
||||
'Agent',
|
||||
'Bash',
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'Task',
|
||||
'NotebookEdit',
|
||||
'Read',
|
||||
'SendMessage',
|
||||
'Skill',
|
||||
'TaskOutput',
|
||||
'TaskStop',
|
||||
'TeamCreate',
|
||||
'TeamDelete',
|
||||
'SendMessage',
|
||||
'TodoWrite',
|
||||
'ToolSearch',
|
||||
'Skill',
|
||||
'NotebookEdit',
|
||||
'WebFetch',
|
||||
'WebSearch',
|
||||
'Write',
|
||||
];
|
||||
|
||||
// MCP server names are sanitized by the SDK when forming tool prefixes:
|
||||
@@ -154,31 +190,46 @@ 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)) {
|
||||
// `reason` (legacy) / permissionDecisionReason (modern) are what the CLI
|
||||
// feeds back to the model on a PreToolUse deny. `stopReason` would only
|
||||
// surface with `continue:false`, which ends the whole turn — the agent
|
||||
// must instead see the redirect and carry on with nanoclaw's tools.
|
||||
const reason = `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`;
|
||||
return {
|
||||
decision: 'block',
|
||||
reason,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason,
|
||||
},
|
||||
} 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 () => {
|
||||
@@ -340,6 +391,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;
|
||||
@@ -351,6 +404,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 {
|
||||
@@ -411,7 +470,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
|
||||
@@ -421,7 +480,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
settingSources: ['project', 'user', 'local'],
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [preToolUseHook] }],
|
||||
PreToolUse: [{ hooks: [this.preToolUseHook] }],
|
||||
PostToolUse: [{ hooks: [postToolUseHook] }],
|
||||
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
|
||||
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Regenerates sdk-tools-baseline.json — the bare SDK tool-surface fixture
|
||||
* asserted by claude.tools.test.ts.
|
||||
*
|
||||
* Must run INSIDE the agent container image (the pinned CLI binary only
|
||||
* exists there). From the repo root:
|
||||
*
|
||||
* docker run --rm --network none \
|
||||
* -v "$PWD/container/agent-runner/src":/app/src:ro \
|
||||
* --entrypoint bun <nanoclaw-agent image> /app/src/providers/dump-sdk-tools.ts \
|
||||
* > container/agent-runner/src/providers/sdk-tools-baseline.json
|
||||
*
|
||||
* Three captures:
|
||||
* - `tools` : with the production TOOL_ALLOWLIST.
|
||||
* - `toolsBare` : with no allowedTools.
|
||||
* - `toolsDisallowProbe` : with disallowedTools=[DISALLOW_PROBE] only.
|
||||
* The drift test asserts tools === toolsBare: no allowlist effect on the
|
||||
* offered surface has ever been observed in a paired same-run capture, so the
|
||||
* list is permission-layer only (moot under bypassPermissions) — if a CLI/SDK
|
||||
* bump makes the allowlist shape the surface, that assertion fails and forces
|
||||
* a re-read of its semantics.
|
||||
*
|
||||
* MEASURED NONDETERMINISM on the pinned CLI (2.1.197): across separate query
|
||||
* invocations with byte-identical options, (a) conditional tools — Glob and
|
||||
* Grep — flicker in and out of the surface, and (b) `disallowedTools`
|
||||
* sometimes strips flag-gated tools (Workflow, DesignSync, EnterWorktree) and
|
||||
* sometimes ignores them entirely; each single query is internally coherent.
|
||||
* Consequences: schema-stripping via disallowedTools is BEST-EFFORT — the
|
||||
* deterministic enforcement is the runner's PreToolUse hook, which blocks the
|
||||
* invocation regardless of whether the schema shipped. `toolsDisallowProbe`
|
||||
* records which behavior the regen run happened to observe (no test asserts
|
||||
* stripping — a fixed assertion on a nondeterministic mechanism would be a
|
||||
* coin-flip). If a regen produces tools !== toolsBare or a surface missing
|
||||
* Glob/Grep, rerun it — you sampled the variant bucket.
|
||||
*
|
||||
* Agent-teams is enabled via a temp settings.json (wire-verified: settings
|
||||
* env strictly beats SDK options env).
|
||||
*
|
||||
* Zero API traffic: ANTHROPIC_BASE_URL points at an in-process stub answering
|
||||
* 401; the full tools array rides on the first /v1/messages request, captured
|
||||
* before the run dies on the auth error. The fixture records WIRE tool names
|
||||
* (the SDK init message reports legacy aliases, e.g. `Task` for wire `Agent` —
|
||||
* do not swap this to an init capture).
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { TOOL_ALLOWLIST } from './claude.js';
|
||||
|
||||
let requests: string[] = [];
|
||||
let captured: (() => void) | null = null;
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const body = await req.text();
|
||||
if (url.pathname.includes('/messages')) {
|
||||
requests.push(body);
|
||||
captured?.();
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ type: 'error', error: { type: 'authentication_error', message: 'fixture-capture-stub' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const HOME = '/tmp/dump-sdk-tools-home';
|
||||
const CWD = '/tmp/dump-sdk-tools-ws';
|
||||
fs.mkdirSync(`${HOME}/.claude`, { recursive: true });
|
||||
fs.mkdirSync(CWD, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
`${HOME}/.claude/settings.json`,
|
||||
JSON.stringify({ env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } }, null, 2),
|
||||
);
|
||||
|
||||
/**
|
||||
* Diagnostic probe for disallowedTools: a flag-gated tool whose stripping is
|
||||
* nondeterministic on the current pin (see header). The fixture records what
|
||||
* this regen run observed; future pins can be compared against it.
|
||||
*/
|
||||
export const DISALLOW_PROBE = 'Workflow';
|
||||
|
||||
/** Run one capture and return the sorted wire tool names. */
|
||||
async function capture(opts?: { allowedTools?: string[]; disallowedTools?: 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'],
|
||||
...(opts?.allowedTools ? { allowedTools: opts.allowedTools } : {}),
|
||||
...(opts?.disallowedTools ? { disallowedTools: opts.disallowedTools } : {}),
|
||||
},
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _m of q) {
|
||||
/* drain until the auth error kills the run */
|
||||
}
|
||||
} catch {
|
||||
/* expected: 401 from the stub */
|
||||
}
|
||||
})();
|
||||
await Promise.race([firstRequest, Bun.sleep(75_000)]);
|
||||
await Bun.sleep(1_500); // let retries land so we can pick the largest body
|
||||
if (requests.length === 0) {
|
||||
console.error('[dump-sdk-tools] no /v1/messages request captured');
|
||||
process.exit(1);
|
||||
}
|
||||
const biggest = requests.reduce((a, b) => (b.length > a.length ? b : a));
|
||||
const parsed = JSON.parse(biggest) as { tools?: Array<{ name: string }> };
|
||||
return [...new Set((parsed.tools ?? []).map((t) => t.name))].sort();
|
||||
}
|
||||
|
||||
const tools = await capture({ allowedTools: TOOL_ALLOWLIST });
|
||||
const toolsBare = await capture();
|
||||
const toolsDisallowProbe = await capture({ disallowedTools: [DISALLOW_PROBE] });
|
||||
|
||||
const cliVersionRaw = execFileSync('/pnpm/claude', ['--version'], { encoding: 'utf8' }).trim();
|
||||
const cliVersion = cliVersionRaw.split(/\s+/)[0];
|
||||
const sdkVersion = (
|
||||
JSON.parse(fs.readFileSync('/app/node_modules/@anthropic-ai/claude-agent-sdk/package.json', 'utf8')) as {
|
||||
version: string;
|
||||
}
|
||||
).version;
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
cliVersion,
|
||||
sdkVersion,
|
||||
capturedAt: new Date().toISOString(),
|
||||
capture:
|
||||
'wire names; tools=production allowlist surface, toolsBare=no allowedTools, toolsDisallowProbe=disallowedTools:[probe] only; teams on',
|
||||
disallowProbe: DISALLOW_PROBE,
|
||||
tools,
|
||||
toolsBare,
|
||||
toolsDisallowProbe,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"cliVersion": "2.1.197",
|
||||
"sdkVersion": "0.3.197",
|
||||
"capturedAt": "2026-07-12T08:30:04.954Z",
|
||||
"capture": "wire names; tools=production allowlist surface, toolsBare=no allowedTools, toolsDisallowProbe=disallowedTools:[probe] only; teams on",
|
||||
"disallowProbe": "Workflow",
|
||||
"tools": [
|
||||
"Agent",
|
||||
"Bash",
|
||||
"CronCreate",
|
||||
"CronDelete",
|
||||
"CronList",
|
||||
"DesignSync",
|
||||
"Edit",
|
||||
"EnterWorktree",
|
||||
"ExitWorktree",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"NotebookEdit",
|
||||
"Read",
|
||||
"ReportFindings",
|
||||
"ScheduleWakeup",
|
||||
"SendMessage",
|
||||
"Skill",
|
||||
"TaskCreate",
|
||||
"TaskGet",
|
||||
"TaskList",
|
||||
"TaskOutput",
|
||||
"TaskStop",
|
||||
"TaskUpdate",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Workflow",
|
||||
"Write"
|
||||
],
|
||||
"toolsBare": [
|
||||
"Agent",
|
||||
"Bash",
|
||||
"CronCreate",
|
||||
"CronDelete",
|
||||
"CronList",
|
||||
"DesignSync",
|
||||
"Edit",
|
||||
"EnterWorktree",
|
||||
"ExitWorktree",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"NotebookEdit",
|
||||
"Read",
|
||||
"ReportFindings",
|
||||
"ScheduleWakeup",
|
||||
"SendMessage",
|
||||
"Skill",
|
||||
"TaskCreate",
|
||||
"TaskGet",
|
||||
"TaskList",
|
||||
"TaskOutput",
|
||||
"TaskStop",
|
||||
"TaskUpdate",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Workflow",
|
||||
"Write"
|
||||
],
|
||||
"toolsDisallowProbe": [
|
||||
"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 {
|
||||
|
||||
@@ -148,11 +148,14 @@ class ClaudeProvider implements AgentProvider {
|
||||
systemPrompt: input.systemContext?.instructions
|
||||
? { type: 'preset', preset: 'claude_code', append: input.systemContext.instructions }
|
||||
: undefined,
|
||||
// Base tools plus one `mcp__<server>__*` pattern per registered MCP
|
||||
// server — without the explicit MCP patterns the SDK's allowedTools
|
||||
// filter silently drops every MCP namespace.
|
||||
// Permission auto-approve list (NOT an availability filter — wire
|
||||
// captures show no allowlist effect on the offered tool surface, and
|
||||
// bypassPermissions moots its permission role). Kept accurate for a
|
||||
// hypothetical non-bypass mode; the `mcp__<server>__*` patterns are
|
||||
// retained because MCP invocation-gating under non-bypass modes is
|
||||
// unverified. See sdk-tools-baseline.json + claude.tools.test.ts.
|
||||
allowedTools: [...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern)],
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
disallowedTools: this.disallowedTools, // fixed set + capability-driven entries (buildDisallowedTools)
|
||||
env: this.env,
|
||||
model: this.model,
|
||||
effort: this.effort,
|
||||
@@ -161,7 +164,7 @@ class ClaudeProvider implements AgentProvider {
|
||||
settingSources: ['project', 'user', 'local'],
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [preToolUseHook] }],
|
||||
PreToolUse: [{ hooks: [this.preToolUseHook] }], // per-instance blocklist from the group's resolved capabilities
|
||||
PostToolUse: [{ hooks: [postToolUseHook] }],
|
||||
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
|
||||
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
||||
@@ -195,8 +198,8 @@ SDK message (so the idle timer stays honest) and maps recognized messages to `Pr
|
||||
**Claude-specific behavior inside the provider:**
|
||||
- `MessageStream` for async iterable input (push-based follow-ups)
|
||||
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
|
||||
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
|
||||
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
|
||||
- `TOOL_ALLOWLIST` (Agent, Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Skill, …) — a permission auto-approve list, not an availability filter (moot under `bypassPermissions`; wire-verified, see `claude.tools.test.ts`) — extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; the effective disallow list is `buildDisallowedTools(caps)`: the fixed `SDK_DISALLOWED_TOOLS` (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree, DesignSync, ReportFindings) plus capability-driven entries — `Workflow` unless the group's resolved `workflow` capability is `on` (see [harness-capabilities.md](harness-capabilities.md)). Schema-stripping via `disallowedTools` is best-effort on the pinned CLI; the PreToolUse hook is the deterministic block
|
||||
- **PreToolUse hook** (built per provider instance by `createPreToolUseHook` from the same effective disallow list) records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, deterministically blocks any disallowed-tool call that slips through, feeding the model a redirect to the nanoclaw equivalent. It does **not** sanitize bash env vars — there is no such hook.
|
||||
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
|
||||
- **PreCompact** hook archives the transcript to `conversations/` before compaction
|
||||
- `maybeRotateContinuation` drops an oversized/aged transcript (default caps 12 MB / 14 days, both operator-overridable) so a cold container isn't killed reloading days of `.jsonl` before the host idle ceiling; `isSessionInvalid` clears a continuation whose transcript is gone
|
||||
|
||||
@@ -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,8 @@ 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 | `wiring-threads-override` | `019-wiring-threads.ts` | `ALTER TABLE messaging_group_agents ADD COLUMN threads` — per-wiring thread-policy override (NULL = inherit the adapter declaration) |
|
||||
| 20 | `harness-capabilities` | `020-harness-capabilities.ts` | `ALTER TABLE container_configs ADD COLUMN harness_capabilities` — per-group harness toggles (see [harness-capabilities.md](harness-capabilities.md)); grandfathers existing rows to `{"agent-teams":"on","workflow":"on"}` |
|
||||
|
||||
Numbers 5 and 6 are intentionally absent — migrations were renumbered during early development.
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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). |
|
||||
| `ReportFindings` | — | fixed off | `disallowedTools` + hook (code-review-reporting UI affordance with no headless surface to receive it; ~1.9KB/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). For group-scoped containers (`cli_scope: group`, the default) harness-capability changes through `ncl` are rejected outright — like `cli_scope`, the sanctioned/persistent path is operator-only. A `cli_scope: global` container (owner agents set up via `/init-first-agent`) is unrestricted by design and goes through the normal CLI flow — treat approvals from such agents accordingly.
|
||||
|
||||
### Enforcement strength (be precise about the boundary)
|
||||
|
||||
- **`workflow` off** layers three mechanisms: the reconciled `disableWorkflows` settings key (primary — removes the tool and its agent-types catalog), the runner's PreToolUse hook (deterministic — blocks any Workflow invocation regardless of what shipped in context), and a runner-side `disallowedTools` entry. Schema-stripping via `disallowedTools` is **best-effort on the pinned CLI**: wire measurement shows it strips flag-gated tools on some query invocations and not others (see the [`dump-sdk-tools.ts`](../container/agent-runner/src/providers/dump-sdk-tools.ts) header), which is why the hook — not the strip — is the functional guarantee. The same applies to the fixed-off `DesignSync`/`ReportFindings` schemas: usually stripped, always invocation-blocked.
|
||||
- **`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 — and workspace files **persist across respawns**. An agent that writes the teams key into its own user-scope settings is corrected at the next spawn; one that writes it into a workspace project/local settings file re-enables teams **until an operator removes that file**, because the reconciler manages only the user-scope file. Treat `agent-teams=off` as **configuration hygiene**, not a hard adversarial boundary — the real trust boundary remains the container sandbox + OneCLI. A planned follow-up will mount the managed settings source read-only and constrain `settingSources` to close this.
|
||||
|
||||
## Upgrade behavior — non-breaking (grandfathered)
|
||||
|
||||
Before this feature every group ran with agent teams on and Workflow available. Migration 020 **grandfathers every existing group** to that prior state — it stamps `{"agent-teams":"on","workflow":"on"}` onto each row that exists at upgrade time, and the startup backfill stamps the same state onto legacy groups whose config row is only created at boot — 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 `{}`. The runner applies the same rule to a `container.json` missing the capability field (written by a pre-upgrade host mid-update): legacy all-on, so nothing flips off before the host restarts.
|
||||
|
||||
- **Verify after upgrade**: `ncl groups config get --id <g>` shows `harness_capabilities_resolved` with `{"state": "on", "source": "override"}` for both keys on pre-existing groups; their `settings.json` keeps the teams env key and has no `disableWorkflows`.
|
||||
- **If you had hand-edited a group's `settings.json`** (e.g. deleted the teams env key yourself — the only pre-upgrade off-switch): the grandfather stamps the pre-feature *defaults*, and the reconciler now owns the managed keys, so the first post-upgrade spawn will re-add the teams key. Re-apply your intent the supported way: `ncl groups config update --id <g> --harness-capabilities 'agent-teams=off'`. Unmanaged keys in the file are never touched.
|
||||
- **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.
|
||||
@@ -9,6 +9,7 @@ import { DATA_DIR } from '../src/config.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { createAgentGroup, getAgentGroup } from '../src/db/agent-groups.js';
|
||||
import { ensureContainerConfig } from '../src/db/container-configs.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
createMessagingGroupAgent,
|
||||
@@ -35,6 +36,10 @@ if (!getAgentGroup(AGENT_GROUP_ID)) {
|
||||
} else {
|
||||
console.log('Agent group already exists:', AGENT_GROUP_ID);
|
||||
}
|
||||
// Provision the config row at creation — a group left without one can't
|
||||
// spawn until the next host boot, and the startup backfill would then
|
||||
// mis-grandfather this genuinely new group to the pre-capability defaults.
|
||||
ensureContainerConfig(AGENT_GROUP_ID);
|
||||
|
||||
// Messaging group
|
||||
if (!getMessagingGroup(MESSAGING_GROUP_ID)) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
brightSelect: vi.fn(),
|
||||
note: vi.fn(),
|
||||
userInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/bright-select.js', () => ({
|
||||
brightSelect: mocks.brightSelect,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/theme.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../lib/theme.js')>()),
|
||||
note: mocks.note,
|
||||
}));
|
||||
|
||||
vi.mock('../logs.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../logs.js')>()),
|
||||
userInput: mocks.userInput,
|
||||
}));
|
||||
|
||||
import { BACK_TO_CHANNEL_SELECTION } from '../lib/back-nav.js';
|
||||
import { runWhatsAppChannel } from './whatsapp.js';
|
||||
|
||||
describe('WhatsApp shared-number risk gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the warning and requires explicit acknowledgement for a shared number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('continue').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining('temporarily suspend or permanently ban that number'),
|
||||
'Risk to your WhatsApp account',
|
||||
);
|
||||
expect(mocks.userInput).toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', 'true');
|
||||
});
|
||||
|
||||
it('does not show the warning for a dedicated number', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).not.toHaveBeenCalled();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
|
||||
it('switches to dedicated mode when the user declines the shared-number risk', async () => {
|
||||
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
|
||||
|
||||
const result = await runWhatsAppChannel('Daniel');
|
||||
|
||||
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
|
||||
expect(mocks.note).toHaveBeenCalledOnce();
|
||||
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
|
||||
});
|
||||
});
|
||||
+13
-29
@@ -6,8 +6,8 @@
|
||||
*
|
||||
* 1. Ask whether the agent gets a dedicated number or shares the
|
||||
* operator's personal one. Personal ⇒ interception screen that spells
|
||||
* out the account-ban risk and self-chat-only mode (default is switching
|
||||
* to a dedicated number)
|
||||
* out self-chat-only mode and steers toward alternatives (default is
|
||||
* back to channel selection)
|
||||
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 3. If pairing-code: collect the phone number
|
||||
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
@@ -68,7 +68,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
let mode: 'dedicated' | 'shared' = ownership;
|
||||
if (mode === 'shared') {
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'dedicated') mode = 'dedicated';
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
}
|
||||
|
||||
const method = await askAuthMethod();
|
||||
@@ -121,7 +121,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
|
||||
// Chatting from the bot's own number IS the shared-number setup —
|
||||
// route through the same interception screen as the up-front pick.
|
||||
const proceed = await confirmSharedNumber();
|
||||
if (proceed === 'dedicated') return BACK_TO_CHANNEL_SELECTION;
|
||||
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
mode = 'shared';
|
||||
}
|
||||
}
|
||||
@@ -221,19 +221,13 @@ async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interception screen for the shared-number path: make the account-ban risk
|
||||
* and self-chat-only tradeoff explicit before any install or auth happens.
|
||||
* Default is switching to a dedicated number.
|
||||
* Interception screen for the shared-number path: make the self-chat-only
|
||||
* tradeoff explicit and steer toward alternatives before any install or
|
||||
* auth happens. Default is bailing back to channel selection.
|
||||
*/
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
|
||||
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
|
||||
note(
|
||||
[
|
||||
'Connecting your shared or personal number could cause WhatsApp to',
|
||||
'temporarily suspend or permanently ban that number. You could lose access',
|
||||
'to the WhatsApp account, chats, and groups you rely on.',
|
||||
'',
|
||||
'We strongly recommend using a separate, dedicated number for NanoClaw.',
|
||||
'',
|
||||
'On your personal number, the agent lives only in your "You" / self-chat.',
|
||||
'Messages other people send you are ignored entirely — never read, never',
|
||||
'answered, never flagged for approval. Nobody else can talk to the agent.',
|
||||
@@ -244,29 +238,19 @@ async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
|
||||
` • ${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
|
||||
` • ${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
|
||||
].join('\n'),
|
||||
'Risk to your WhatsApp account',
|
||||
'Personal number = self-chat only',
|
||||
);
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to proceed?',
|
||||
options: [
|
||||
{
|
||||
value: 'dedicated',
|
||||
label: 'Go back and use a dedicated number',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'continue',
|
||||
label: 'I understand the risk — continue with my shared number',
|
||||
},
|
||||
{ value: 'back', label: '← Pick a different channel' },
|
||||
{ value: 'continue', label: 'Continue — self-chat only' },
|
||||
],
|
||||
initialValue: 'dedicated',
|
||||
initialValue: 'back',
|
||||
}),
|
||||
) as 'continue' | 'dedicated';
|
||||
) as 'continue' | 'back';
|
||||
setupLog.userInput('whatsapp_shared_confirm', choice);
|
||||
if (choice === 'continue') {
|
||||
setupLog.userInput('whatsapp_shared_risk_acknowledged', 'true');
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
validateEngageAgainstChannel,
|
||||
} from '../src/channels/channel-defaults.js';
|
||||
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
|
||||
import { backfillContainerConfigs } from '../src/backfill-container-configs.js';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
@@ -184,6 +185,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
const dbPath = path.join(DATA_DIR, 'v2.db');
|
||||
const db = initDb(dbPath);
|
||||
runMigrations(db);
|
||||
// Any process that runs migrations must backfill before creating config
|
||||
// rows: a legacy group without a container_configs row gets its grandfather
|
||||
// stamp from the backfill, and the ensureContainerConfig below would
|
||||
// otherwise claim the row first with the lean defaults (host boot does the
|
||||
// same migrations → backfill sequence in src/index.ts).
|
||||
backfillContainerConfigs();
|
||||
|
||||
// 1. Create or find agent group. Provider-agnostic: provider is a DB
|
||||
// property set via `ncl groups config update --provider`, not a creation
|
||||
|
||||
@@ -65,6 +65,12 @@ export function backfillContainerConfigs(): void {
|
||||
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
|
||||
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
|
||||
cli_scope: 'group',
|
||||
// Grandfather, same rule as migration 020: a group reaching this backfill
|
||||
// has no config row, i.e. it pre-dates the capability feature and ran
|
||||
// with teams + Workflow on. Migration 020's UPDATE can't cover it (the
|
||||
// row doesn't exist yet when migrations run), so stamp the legacy state
|
||||
// here — '{}' would silently flip such a group to the lean defaults.
|
||||
harness_capabilities: '{"agent-teams":"on","workflow":"on"}',
|
||||
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
@@ -87,9 +87,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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,19 @@ import {
|
||||
} from '../../db/container-configs.js';
|
||||
import { initGroupFilesystem } from '../../group-init.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,
|
||||
@@ -33,6 +41,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,
|
||||
};
|
||||
}
|
||||
@@ -252,8 +271,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');
|
||||
@@ -281,13 +301,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,21 @@ describe('buildContainerArgs ordering invariant (structural)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('harness-capability spawn seam (structural)', () => {
|
||||
// The spawn site is the ONLY place resolved capabilities reach the settings
|
||||
// reconciler — initGroupFilesystem's opt is optional, so dropping it in a
|
||||
// refactor breaks no type and no other test, while every group's
|
||||
// settings.json silently stops converging (agent-teams has no runner-side
|
||||
// backstop). spawnContainer needs a live container runtime to drive, so
|
||||
// guard the seam structurally.
|
||||
it('spawnContainer passes containerConfig.harnessCapabilities to initGroupFilesystem', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src', 'container-runner.ts'), 'utf-8');
|
||||
expect(src).toMatch(
|
||||
/initGroupFilesystem\(agentGroup, \{[\s\S]*?harnessCapabilities: containerConfig\.harnessCapabilities,[\s\S]*?\}\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-container resource limits (structural)', () => {
|
||||
// CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT pass through to `docker run` as
|
||||
// --cpus / --memory, but only when set. The default is empty string → no flag →
|
||||
|
||||
@@ -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,87 @@
|
||||
/**
|
||||
* Migration 020 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 { backfillContainerConfigs } from '../../backfill-container-configs.js';
|
||||
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 020 (harness-capabilities) upgrade safety', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
// Run everything EXCEPT 020 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 020.
|
||||
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('grandfathers a legacy group whose config row is created by the post-migration backfill', () => {
|
||||
const db = getDb();
|
||||
// Startup sequence on a pre-container-configs-era install: the group
|
||||
// exists but has NO container_configs row when migrations run, so 020's
|
||||
// grandfather UPDATE has nothing to touch. The row is created moments
|
||||
// later by backfillContainerConfigs() (index.ts boot order: migrations →
|
||||
// backfill) — that path must apply the same grandfather rule.
|
||||
group('ag-backfill');
|
||||
|
||||
runMigrations(
|
||||
db,
|
||||
migrations.filter((m) => m.name === 'harness-capabilities'),
|
||||
);
|
||||
backfillContainerConfigs();
|
||||
|
||||
expect(JSON.parse(getContainerConfig('ag-backfill')!.harness_capabilities)).toEqual({
|
||||
'agent-teams': 'on',
|
||||
workflow: 'on',
|
||||
});
|
||||
});
|
||||
|
||||
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 migration020: Migration = {
|
||||
version: 20,
|
||||
name: 'harness-capabilities',
|
||||
up(db: Database.Database) {
|
||||
db.prepare("ALTER TABLE container_configs ADD COLUMN harness_capabilities TEXT NOT NULL DEFAULT '{}'").run();
|
||||
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { moduleApprovalsPendingApprovals } from './module-approvals-pending-appr
|
||||
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
|
||||
import { migration018 } from './018-approvals-approver-user-id.js';
|
||||
import { migration019 } from './019-wiring-threads.js';
|
||||
import { migration020 } from './020-harness-capabilities.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -52,6 +53,7 @@ export const migrations: Migration[] = [
|
||||
migration015,
|
||||
migration016,
|
||||
migration019,
|
||||
migration020,
|
||||
];
|
||||
|
||||
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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('warns instead of throwing when settings.json is unreadable (EISDIR), so the spawn proceeds', () => {
|
||||
// Regression: a non-SyntaxError I/O failure (path replaced by a directory,
|
||||
// EACCES, EIO) used to propagate out of initGroupFilesystem and wedge the
|
||||
// group in a wakeContainer retry-fail loop. The dir is mounted read-write
|
||||
// into the container, so this state is reachable without operator error.
|
||||
const ag = makeGroup();
|
||||
const file = settingsPath(ag);
|
||||
fs.mkdirSync(file, { recursive: true }); // settings.json IS a directory
|
||||
|
||||
expect(() => initGroupFilesystem(ag, { harnessCapabilities: DEFAULT_CAPS })).not.toThrow();
|
||||
expect(vi.mocked(log.warn).mock.calls.some(([msg]) => String(msg).includes('reconcile failed'))).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);
|
||||
});
|
||||
});
|
||||
+118
-49
@@ -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,106 @@ 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) — and any read/write
|
||||
* I/O failure — is a warn-and-skip, never a throw: 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 {
|
||||
// Nothing may escape this function: it runs on the every-spawn path, and a
|
||||
// thrown EISDIR/EACCES/ENOSPC would wedge the group in a wakeContainer
|
||||
// retry-fail loop (the pre-capability code swallowed all errors here). A
|
||||
// group whose settings can't be read or written spawns with whatever state
|
||||
// is on disk — degraded, but alive.
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(settingsFile, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
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)');
|
||||
} catch (err) {
|
||||
log.warn('settings.json reconcile failed — group spawns with the on-disk state', {
|
||||
settingsFile,
|
||||
err: String(err),
|
||||
});
|
||||
|
||||
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