From 724fe7250dd44b336abc7208d2b37f95db646b8c Mon Sep 17 00:00:00 2001 From: Ken Bolton Date: Mon, 23 Mar 2026 20:27:40 -0400 Subject: [PATCH] fix(claw): mount group folder and sessions into container claw was running containers with no volume mounts, so the agent always saw an empty /workspace/group. Add build_mounts() to replicate the same bind-mounts that container-runner.ts sets up (group folder, .claude sessions, IPC dir, agent-runner source, and project root for main). Also includes upstream fix from qwibitai/nanoclaw#1368: graceful terminate() before kill() on output sentinel, and early return after a successful structured response so exit code stays 0. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/claw/scripts/claw | 64 ++++++++++++++++++++++++++++++-- src/claw-skill.test.ts | 45 ++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 src/claw-skill.test.ts diff --git a/.claude/skills/claw/scripts/claw b/.claude/skills/claw/scripts/claw index 3878e48..b64a225 100644 --- a/.claude/skills/claw/scripts/claw +++ b/.claude/skills/claw/scripts/claw @@ -121,8 +121,48 @@ def find_group(groups: list[dict], query: str) -> dict | None: return None -def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -> None: - cmd = [runtime, "run", "-i", "--rm", image] +def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]: + """Return list of (host_path, container_path, readonly) tuples.""" + groups_dir = NANOCLAW_DIR / "groups" + data_dir = NANOCLAW_DIR / "data" + sessions_dir = data_dir / "sessions" / folder + ipc_dir = data_dir / "ipc" / folder + + # Ensure required dirs exist + group_dir = groups_dir / folder + group_dir.mkdir(parents=True, exist_ok=True) + (sessions_dir / ".claude").mkdir(parents=True, exist_ok=True) + for sub in ("messages", "tasks", "input"): + (ipc_dir / sub).mkdir(parents=True, exist_ok=True) + + agent_runner_src = sessions_dir / "agent-runner-src" + project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src" + if not agent_runner_src.exists() and project_agent_runner.exists(): + import shutil + shutil.copytree(project_agent_runner, agent_runner_src) + + mounts: list[tuple[str, str, bool]] = [] + if is_main: + mounts.append((str(NANOCLAW_DIR), "/workspace/project", True)) + mounts.append((str(group_dir), "/workspace/group", False)) + mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False)) + mounts.append((str(ipc_dir), "/workspace/ipc", False)) + if agent_runner_src.exists(): + mounts.append((str(agent_runner_src), "/app/src", False)) + return mounts + + +def run_container(runtime: str, image: str, payload: dict, + folder: str | None = None, is_main: bool = False, + timeout: int = 300) -> None: + cmd = [runtime, "run", "-i", "--rm"] + if folder: + for host, container, readonly in build_mounts(folder, is_main): + if readonly: + cmd += ["--mount", f"type=bind,source={host},target={container},readonly"] + else: + cmd += ["-v", f"{host}:{container}"] + cmd.append(image) dbg(f"cmd: {' '.join(cmd)}") # Show payload sans secrets @@ -167,7 +207,12 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - dbg("output sentinel found, terminating container") done.set() try: - proc.kill() + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + dbg("graceful stop timed out, force killing container") + proc.kill() except ProcessLookupError: pass return @@ -197,6 +242,8 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - stdout, re.DOTALL, ) + success = False + if match: try: data = json.loads(match.group(1)) @@ -206,6 +253,7 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - session_id = data.get("newSessionId") or data.get("sessionId") if session_id: print(f"\n[session: {session_id}]", file=sys.stderr) + success = True else: print(f"[{status}] {data.get('result', '')}", file=sys.stderr) sys.exit(1) @@ -215,6 +263,9 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) - # No structured output — print raw stdout print(stdout) + if success: + return + if proc.returncode not in (0, None): sys.exit(proc.returncode) @@ -273,6 +324,7 @@ def main(): # Resolve group → jid jid = args.jid group_name = None + group_folder = None is_main = False if args.group: @@ -281,6 +333,7 @@ def main(): sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.") jid = g["jid"] group_name = g["name"] + group_folder = g["folder"] is_main = g["is_main"] elif not jid: # Default: main group @@ -288,6 +341,7 @@ def main(): if mains: jid = mains[0]["jid"] group_name = mains[0]["name"] + group_folder = mains[0]["folder"] is_main = True else: sys.exit("error: no group specified and no main group found. Use -g or -j.") @@ -311,7 +365,9 @@ def main(): payload["resumeAt"] = "latest" print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr) - run_container(runtime, args.image, payload, timeout=args.timeout) + run_container(runtime, args.image, payload, + folder=group_folder, is_main=is_main, + timeout=args.timeout) if __name__ == "__main__": diff --git a/src/claw-skill.test.ts b/src/claw-skill.test.ts new file mode 100644 index 0000000..24260c9 --- /dev/null +++ b/src/claw-skill.test.ts @@ -0,0 +1,45 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +import { describe, expect, it } from 'vitest'; + +describe('claw skill script', () => { + it('exits zero after successful structured output even if the runtime is terminated', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claw-skill-test-')); + const binDir = path.join(tempDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + const runtimePath = path.join(binDir, 'container'); + fs.writeFileSync( + runtimePath, + `#!/bin/sh +cat >/dev/null +printf '%s\n' '---NANOCLAW_OUTPUT_START---' '{"status":"success","result":"4","newSessionId":"sess-1"}' '---NANOCLAW_OUTPUT_END---' +sleep 30 +`, + ); + fs.chmodSync(runtimePath, 0o755); + + const result = spawnSync( + 'python3', + ['.claude/skills/claw/scripts/claw', '-j', 'tg:123', 'What is 2+2?'], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + NANOCLAW_DIR: tempDir, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, + timeout: 15000, + }, + ); + + expect(result.status).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stdout).toContain('4'); + expect(result.stderr).toContain('[session: sess-1]'); + }); +});