mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d1324652 |
@@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Claude tool allowlist reconciled with the pinned CLI, plus a tool-surface drift guard.** `TOOL_ALLOWLIST` in the agent runner named five tools that don't exist on claude-code 2.1.197 (`Task` — renamed `Agent` upstream — plus `TodoWrite`, `TeamCreate`, `TeamDelete`, `ToolSearch`); the phantom entries are removed and the comment corrected: `allowedTools` is a permission auto-approve list, not an availability filter — though it does *promote* the optional `Glob`/`Grep` tools onto the surface, which is why those stay listed. No wire-visible behavior change. A new wire-captured fixture (`container/agent-runner/src/providers/sdk-tools-baseline.json`) and `claude.tools.test.ts` now fail on any future claude-code pin bump until the fixture is regenerated with `dump-sdk-tools.ts` and the lists re-verified.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 { 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 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('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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ 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 = [
|
||||
export const SDK_DISALLOWED_TOOLS = [
|
||||
'CronCreate',
|
||||
'CronDelete',
|
||||
'CronList',
|
||||
@@ -35,30 +35,35 @@ const SDK_DISALLOWED_TOOLS = [
|
||||
'ExitWorktree',
|
||||
];
|
||||
|
||||
// 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 = [
|
||||
// Pre-approved tool set for NanoClaw agent containers. Two verified facts
|
||||
// about `allowedTools` on the pinned CLI (wire-captured, see
|
||||
// sdk-tools-baseline.json + claude.tools.test.ts):
|
||||
//
|
||||
// 1. It is NOT an availability filter — omitting a tool does not remove it
|
||||
// (true for builtin and MCP names alike), and its permission-approval
|
||||
// function is moot under this runner's `bypassPermissions`.
|
||||
// 2. It DOES promote optional tools into the surface: `Glob` and `Grep` are
|
||||
// only offered to the model because they are listed here. Do not remove
|
||||
// them casually — that is the one way this list changes agent behavior.
|
||||
//
|
||||
// The per-server `mcpAllowPattern` entries derived at the call site are
|
||||
// retained because permission-gating of MCP invocation under non-bypass
|
||||
// modes is unverified. Exported for the fixture regenerator and tests.
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* Maximal-surface capture: the production TOOL_ALLOWLIST is passed (it
|
||||
* PROMOTES optional tools — Glob/Grep only appear when listed) but no
|
||||
* disallowedTools, and agent-teams is enabled via a temp settings.json
|
||||
* (settings env strictly beats SDK options env, so this is the only way to
|
||||
* enable it — see docs/harness-capabilities.md). Zero API traffic:
|
||||
* ANTHROPIC_BASE_URL points at an in-process stub answering 401; the full
|
||||
* tools array rides on the first /v1/messages request, which is captured
|
||||
* before the run dies on the auth error.
|
||||
*
|
||||
* The fixture records WIRE tool names (what the model actually sees). The
|
||||
* SDK init message reports legacy alias names for some tools (e.g. `Task`
|
||||
* where the wire says `Agent`) — do not swap this to an init capture.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { TOOL_ALLOWLIST } from './claude.js';
|
||||
|
||||
const requests: string[] = [];
|
||||
let captured: (() => void) | null = null;
|
||||
const firstRequest = new Promise<void>((resolve) => {
|
||||
captured = resolve;
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: 'fixture capture: reply with one word',
|
||||
options: {
|
||||
cwd: CWD,
|
||||
pathToClaudeCodeExecutable: '/pnpm/claude',
|
||||
systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const },
|
||||
env: {
|
||||
...process.env,
|
||||
HOME,
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`,
|
||||
ANTHROPIC_API_KEY: 'fixture-dummy-key',
|
||||
ANTHROPIC_AUTH_TOKEN: undefined,
|
||||
},
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['user'],
|
||||
allowedTools: TOOL_ALLOWLIST,
|
||||
},
|
||||
});
|
||||
|
||||
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 }> };
|
||||
const tools = [...new Set((parsed.tools ?? []).map((t) => t.name))].sort();
|
||||
|
||||
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: 'production allowlist (promotes Glob/Grep), no disallowedTools, teams enabled via settings; wire names',
|
||||
tools,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"cliVersion": "2.1.197",
|
||||
"sdkVersion": "0.3.197",
|
||||
"capturedAt": "2026-07-08T12:06:41.811Z",
|
||||
"capture": "production allowlist (promotes Glob/Grep), no disallowedTools, teams enabled via settings; wire names",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user