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 <noreply@anthropic.com>
This commit is contained in:
Ken Bolton
2026-03-23 20:27:40 -04:00
parent deee4b2a96
commit 724fe7250d
2 changed files with 105 additions and 4 deletions

View File

@@ -121,8 +121,48 @@ def find_group(groups: list[dict], query: str) -> dict | None:
return None return None
def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -> None: def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]:
cmd = [runtime, "run", "-i", "--rm", image] """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)}") dbg(f"cmd: {' '.join(cmd)}")
# Show payload sans secrets # 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") dbg("output sentinel found, terminating container")
done.set() done.set()
try: 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: except ProcessLookupError:
pass pass
return return
@@ -197,6 +242,8 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
stdout, stdout,
re.DOTALL, re.DOTALL,
) )
success = False
if match: if match:
try: try:
data = json.loads(match.group(1)) 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") session_id = data.get("newSessionId") or data.get("sessionId")
if session_id: if session_id:
print(f"\n[session: {session_id}]", file=sys.stderr) print(f"\n[session: {session_id}]", file=sys.stderr)
success = True
else: else:
print(f"[{status}] {data.get('result', '')}", file=sys.stderr) print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
sys.exit(1) 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 # No structured output — print raw stdout
print(stdout) print(stdout)
if success:
return
if proc.returncode not in (0, None): if proc.returncode not in (0, None):
sys.exit(proc.returncode) sys.exit(proc.returncode)
@@ -273,6 +324,7 @@ def main():
# Resolve group → jid # Resolve group → jid
jid = args.jid jid = args.jid
group_name = None group_name = None
group_folder = None
is_main = False is_main = False
if args.group: if args.group:
@@ -281,6 +333,7 @@ def main():
sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.") sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
jid = g["jid"] jid = g["jid"]
group_name = g["name"] group_name = g["name"]
group_folder = g["folder"]
is_main = g["is_main"] is_main = g["is_main"]
elif not jid: elif not jid:
# Default: main group # Default: main group
@@ -288,6 +341,7 @@ def main():
if mains: if mains:
jid = mains[0]["jid"] jid = mains[0]["jid"]
group_name = mains[0]["name"] group_name = mains[0]["name"]
group_folder = mains[0]["folder"]
is_main = True is_main = True
else: else:
sys.exit("error: no group specified and no main group found. Use -g or -j.") sys.exit("error: no group specified and no main group found. Use -g or -j.")
@@ -311,7 +365,9 @@ def main():
payload["resumeAt"] = "latest" payload["resumeAt"] = "latest"
print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr) 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__": if __name__ == "__main__":

45
src/claw-skill.test.ts Normal file
View File

@@ -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]');
});
});