mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4a1679666 | |||
| 2e40e17155 | |||
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 803f3413ec | |||
| a8b7da7bcf | |||
| 0b6ad5550d | |||
| c1965cfcaf | |||
| 3cefbfccf4 | |||
| 6f22c73aac | |||
| 31dd37b3a8 | |||
| c82f062d57 | |||
| 3906104960 | |||
| ed9a3e330d | |||
| 102ce80fda |
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
|
||||
```
|
||||
|
||||
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
|
||||
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
|
||||
|
||||
## Add Directories
|
||||
|
||||
Ask which directories the user wants agents to access. For each path:
|
||||
- Validate the path exists
|
||||
- Ask if it should be read-only for non-main agents (default: yes)
|
||||
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
|
||||
|
||||
Build the JSON config and write it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
Use `--force` to overwrite the existing config.
|
||||
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
|
||||
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
## Reset to Empty
|
||||
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automatically — no service restart needed.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
To apply the new config to a group that already has a running container, restart just that group:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
@@ -80,7 +80,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
@@ -165,7 +165,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The a2a reply stamp: the id of the first inbound message in the batch the
|
||||
* agent is currently processing. The poll loop publishes it at batch start;
|
||||
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
|
||||
* rows so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This lives in outbound.db rather than module state because the MCP server
|
||||
* runs as a separate stdio subprocess from the poll loop — module state set
|
||||
* by the poll loop is invisible to it. Both processes open outbound.db
|
||||
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
|
||||
*/
|
||||
const IN_REPLY_TO_KEY = 'current_in_reply_to';
|
||||
|
||||
/**
|
||||
* Ignore a stamp older than this. The poll loop clears the stamp in a
|
||||
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
|
||||
* the guard stops a later out-of-batch read from picking up a dead stamp.
|
||||
* Generous so a long-running batch's late sends still stamp correctly.
|
||||
*/
|
||||
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
if (id === null) {
|
||||
clearCurrentInReplyTo();
|
||||
return;
|
||||
}
|
||||
setValue(IN_REPLY_TO_KEY, id);
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
deleteValue(IN_REPLY_TO_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
const row = getOutboundDb()
|
||||
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
|
||||
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
|
||||
if (!row) return null;
|
||||
const age = Date.now() - new Date(row.updated_at).getTime();
|
||||
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
|
||||
return row.value;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,31 @@
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*
|
||||
* The stamp is published through session_state in outbound.db, not module
|
||||
* state — the MCP server runs as a separate stdio subprocess from the poll
|
||||
* loop, so it can only see the stamp through the shared DB. These tests seed
|
||||
* it the same way the poll-loop process does (a direct DB write) rather than
|
||||
* via any in-memory helper, so they exercise the real process boundary.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
/**
|
||||
* Publish the a2a reply stamp the way the poll loop does: a direct write to
|
||||
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
|
||||
* staleness guard MCP tools apply when reading it.
|
||||
*/
|
||||
function publishInReplyTo(id: string, ageMs = 0): void {
|
||||
const updatedAt = new Date(Date.now() - ageMs).toISOString();
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('current_in_reply_to', id, updatedAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
@@ -24,13 +41,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
|
||||
publishInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stale stamp left behind by a killed container', async () => {
|
||||
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getCurrentInReplyTo } from '../db/session-state.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -2,8 +2,13 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
clearCurrentInReplyTo,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
setCurrentInReplyTo,
|
||||
} from './db/session-state.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# Apple Container Networking Setup (macOS 26)
|
||||
|
||||
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
|
||||
|
||||
## Quick Setup
|
||||
|
||||
Run these two commands (requires `sudo`):
|
||||
|
||||
```bash
|
||||
# 1. Enable IP forwarding so the host routes container traffic
|
||||
sudo sysctl -w net.inet.ip.forwarding=1
|
||||
|
||||
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
|
||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
|
||||
|
||||
## Making It Persistent
|
||||
|
||||
These settings reset on reboot. To make them permanent:
|
||||
|
||||
**IP Forwarding** — add to `/etc/sysctl.conf`:
|
||||
```
|
||||
net.inet.ip.forwarding=1
|
||||
```
|
||||
|
||||
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
|
||||
```
|
||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
||||
```
|
||||
|
||||
Then reload: `sudo pfctl -f /etc/pf.conf`
|
||||
|
||||
## IPv6 DNS Issue
|
||||
|
||||
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
|
||||
|
||||
The container image and runner are configured to prefer IPv4 via:
|
||||
```
|
||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
```
|
||||
|
||||
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check IP forwarding is enabled
|
||||
sysctl net.inet.ip.forwarding
|
||||
# Expected: net.inet.ip.forwarding: 1
|
||||
|
||||
# Test container internet access
|
||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
||||
# Expected: 404
|
||||
|
||||
# Check bridge interface (only exists when a container is running)
|
||||
ifconfig bridge100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
||||
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
||||
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
|
||||
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Container VM (192.168.64.x)
|
||||
│
|
||||
├── eth0 → gateway 192.168.64.1
|
||||
│
|
||||
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
|
||||
│
|
||||
├── IP forwarding (sysctl) routes packets from bridge100 → en0
|
||||
│
|
||||
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
|
||||
│
|
||||
en0 (your WiFi/Ethernet) → Internet
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
|
||||
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
|
||||
@@ -9,5 +9,3 @@ The files in this directory are original design documents and developer referenc
|
||||
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
|
||||
+102
-60
@@ -2,69 +2,101 @@
|
||||
|
||||
## Trust Model
|
||||
|
||||
Privilege is **user-level**, persisted in the `user_roles` table (owner /
|
||||
admin, global or scoped to an agent group) plus `agent_group_members` (the
|
||||
unprivileged access gate).
|
||||
|
||||
| Entity | Trust Level | Rationale |
|
||||
|--------|-------------|-----------|
|
||||
| Main group | Trusted | Private self-chat, admin control |
|
||||
| Non-main groups | Untrusted | Other users may be malicious |
|
||||
| Container agents | Sandboxed | Isolated execution environment |
|
||||
| Incoming messages | User input | Potential prompt injection |
|
||||
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
|
||||
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
|
||||
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
|
||||
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
|
||||
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
### 1. Container Isolation (Primary Boundary)
|
||||
|
||||
Agents execute in containers (lightweight Linux VMs), providing:
|
||||
- **Process isolation** - Container processes cannot affect the host
|
||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
||||
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
|
||||
Agents execute in containers (Docker), providing:
|
||||
- **Process isolation** — container processes cannot affect the host
|
||||
- **Filesystem isolation** — only explicitly mounted directories are visible
|
||||
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
|
||||
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
|
||||
|
||||
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
|
||||
This is the primary security boundary. Rather than relying on application-level
|
||||
permission checks, the attack surface is limited by what's mounted.
|
||||
|
||||
### 2. Mount Security
|
||||
|
||||
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
|
||||
- Outside project root
|
||||
- Never mounted into containers
|
||||
- Cannot be modified by agents
|
||||
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
|
||||
spawn. For the default (Claude) provider these are:
|
||||
|
||||
**Default Blocked Patterns:**
|
||||
| Container path | Host source | Mode | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
|
||||
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
|
||||
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
|
||||
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
|
||||
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
|
||||
| `/workspace/global` | `groups/global/` | RO | Shared global memory |
|
||||
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
|
||||
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
|
||||
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
|
||||
| `/app/skills` | `container/skills/` | RO | Shared container skills |
|
||||
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
|
||||
|
||||
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
|
||||
**nested read-only mounts on top of the read-write group dir** — the agent can
|
||||
read its config but cannot modify it. The project root is **never mounted**: the
|
||||
container only ever sees the paths above plus any provider-contributed mounts
|
||||
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
|
||||
`package.json`) is not reachable.
|
||||
|
||||
**Additional-mount allowlist** — extra mounts from a group's container config
|
||||
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
|
||||
which is:
|
||||
- Outside the project root
|
||||
- Never mounted into containers
|
||||
- Not modifiable by agents
|
||||
|
||||
Its schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedRoots": [
|
||||
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
|
||||
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
|
||||
],
|
||||
"blockedPatterns": ["password", "secret", "token"]
|
||||
}
|
||||
```
|
||||
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
|
||||
|
||||
**Default blocked patterns** (merged with any in the file):
|
||||
```
|
||||
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
|
||||
private_key, .secret
|
||||
```
|
||||
|
||||
**Protections:**
|
||||
- Symlink resolution before validation (prevents traversal attacks)
|
||||
- Container path validation (rejects `..` and absolute paths)
|
||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
||||
|
||||
**Read-Only Project Root:**
|
||||
|
||||
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
|
||||
**Enforcement** (`src/modules/mount-security/index.ts`):
|
||||
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
|
||||
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
|
||||
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
|
||||
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
|
||||
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
|
||||
|
||||
### 3. Session Isolation
|
||||
|
||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
||||
- Groups cannot see other groups' conversation history
|
||||
- Session data includes full message history and file contents read
|
||||
- Prevents cross-group information disclosure
|
||||
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
|
||||
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
|
||||
(`.claude-shared`) and the working folder are scoped to the agent group, so:
|
||||
- Different agent groups cannot see each other's conversation history or files.
|
||||
- A group's sessions share that group's memory but keep separate message DBs.
|
||||
|
||||
### 4. IPC Authorization
|
||||
This prevents cross-group information disclosure.
|
||||
|
||||
Messages and task operations are verified against group identity:
|
||||
|
||||
| Operation | Main Group | Non-Main Group |
|
||||
|-----------|------------|----------------|
|
||||
| Send message to own chat | ✓ | ✓ |
|
||||
| Send message to other chats | ✓ | ✗ |
|
||||
| Schedule task for self | ✓ | ✓ |
|
||||
| Schedule task for others | ✓ | ✗ |
|
||||
| View all tasks | ✓ | Own only |
|
||||
| Manage other groups | ✓ | ✗ |
|
||||
|
||||
### 5. Credential Isolation (OneCLI Agent Vault)
|
||||
### 4. Credential Isolation (OneCLI Agent Vault)
|
||||
|
||||
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
|
||||
|
||||
@@ -77,13 +109,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
|
||||
**Per-agent policies:**
|
||||
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
|
||||
|
||||
**NOT Mounted:**
|
||||
- Channel auth sessions (`store/auth/`) — host only
|
||||
- Mount allowlist — external, never mounted
|
||||
- Any credentials matching blocked patterns
|
||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||
**Never on the container filesystem:**
|
||||
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
|
||||
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
|
||||
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
|
||||
|
||||
### 6. Egress Lockdown (Forced Proxy)
|
||||
### 5. Egress Lockdown (Forced Proxy)
|
||||
|
||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
||||
ignores it (or a raw socket) could reach the internet directly and bypass
|
||||
@@ -111,31 +142,42 @@ no `host-gateway` route).
|
||||
exception: a heal failure there is logged but not fatal, since already-running
|
||||
agents stay on the internal net (no leak) until the gateway returns.
|
||||
|
||||
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
|
||||
the agent reaches the OneCLI gateway over the host-gateway path and outbound
|
||||
traffic is not confined to the internal network.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
|
||||
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
||||
|
||||
These variables are read from the **host process** environment (the service's
|
||||
environment / `.env`), not from inside the container. The agent container is
|
||||
started with only `TZ` and any provider-declared variables — host environment
|
||||
variables, including secrets, are never forwarded into the agent.
|
||||
|
||||
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
||||
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
||||
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
||||
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
|
||||
## Privilege Comparison
|
||||
## Resource Limits
|
||||
|
||||
| Capability | Main Group | Non-Main Group |
|
||||
|------------|------------|----------------|
|
||||
| Project root access | `/workspace/project` (ro) | None |
|
||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||
| Additional mounts | Configurable | Read-only unless allowed |
|
||||
| Network access | Unrestricted | Unrestricted |
|
||||
| MCP tools | All | All |
|
||||
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
|
||||
agent is not throttled unless the operator configures a limit:
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
|
||||
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
|
||||
|
||||
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
|
||||
the host having no swap (a deployment concern). On a swapless host a runaway is
|
||||
OOM-killed at the limit.
|
||||
|
||||
## Security Architecture Diagram
|
||||
|
||||
@@ -149,7 +191,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ HOST PROCESS (TRUSTED) │
|
||||
│ • Message routing │
|
||||
│ • IPC authorization │
|
||||
│ • Role / access checks (user_roles, agent_group_members) │
|
||||
│ • Mount validation (external allowlist) │
|
||||
│ • Container lifecycle │
|
||||
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
# Running NanoClaw in Docker Sandboxes (Manual Setup)
|
||||
|
||||
This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host (macOS / Windows WSL)
|
||||
└── Docker Sandbox (micro VM with isolated kernel)
|
||||
├── NanoClaw process (Node.js)
|
||||
│ ├── Channel adapters (WhatsApp, Telegram, etc.)
|
||||
│ └── Container spawner → nested Docker daemon
|
||||
└── Docker-in-Docker
|
||||
└── nanoclaw-agent containers
|
||||
└── Claude Agent SDK
|
||||
```
|
||||
|
||||
Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
|
||||
|
||||
The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
|
||||
|
||||
> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker Desktop v4.40+** with Sandbox support
|
||||
- **Anthropic API key** (the sandbox proxy manages injection)
|
||||
- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
|
||||
- For **WhatsApp**: a phone with WhatsApp installed
|
||||
|
||||
Verify sandbox support:
|
||||
```bash
|
||||
docker sandbox version
|
||||
```
|
||||
|
||||
## Step 1: Create the Sandbox
|
||||
|
||||
On your host machine:
|
||||
|
||||
```bash
|
||||
# Create a workspace directory
|
||||
mkdir -p ~/nanoclaw-workspace
|
||||
|
||||
# Create a shell sandbox with the workspace mounted
|
||||
docker sandbox create shell ~/nanoclaw-workspace
|
||||
```
|
||||
|
||||
If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
|
||||
|
||||
```bash
|
||||
docker sandbox network proxy shell-nanoclaw-workspace \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
Telegram does not need proxy bypass.
|
||||
|
||||
Enter the sandbox:
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
```
|
||||
|
||||
## Step 2: Install Prerequisites
|
||||
|
||||
Inside the sandbox:
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
## Step 3: Clone and Install NanoClaw
|
||||
|
||||
NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
|
||||
|
||||
```bash
|
||||
# Clone to home first (virtiofs can corrupt git pack files during clone)
|
||||
cd ~
|
||||
git clone https://github.com/nanocoai/nanoclaw.git
|
||||
|
||||
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
|
||||
WORKSPACE=/Users/you/nanoclaw-workspace
|
||||
|
||||
# Move into workspace so DinD mounts work
|
||||
mv nanoclaw "$WORKSPACE/nanoclaw"
|
||||
cd "$WORKSPACE/nanoclaw"
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
pnpm install https-proxy-agent
|
||||
```
|
||||
|
||||
## Step 4: Apply Proxy and Sandbox Patches
|
||||
|
||||
NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
|
||||
|
||||
### 4a. Dockerfile — proxy args for container image build
|
||||
|
||||
`pnpm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
|
||||
|
||||
Add these lines after the `FROM` line:
|
||||
|
||||
```dockerfile
|
||||
# Accept proxy build args
|
||||
ARG http_proxy
|
||||
ARG https_proxy
|
||||
ARG no_proxy
|
||||
ARG NODE_EXTRA_CA_CERTS
|
||||
ARG npm_config_strict_ssl=true
|
||||
RUN npm config set strict-ssl ${npm_config_strict_ssl}
|
||||
```
|
||||
|
||||
And after the `RUN pnpm install` line:
|
||||
|
||||
```dockerfile
|
||||
RUN npm config set strict-ssl true
|
||||
```
|
||||
|
||||
### 4b. Build script — forward proxy args
|
||||
|
||||
Patch `container/build.sh` to pass proxy env vars to `docker build`:
|
||||
|
||||
Add these `--build-arg` flags to the `docker build` command:
|
||||
|
||||
```bash
|
||||
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
|
||||
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
|
||||
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
|
||||
--build-arg npm_config_strict_ssl=false \
|
||||
```
|
||||
|
||||
### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
|
||||
|
||||
Three changes to `src/container-runner.ts`:
|
||||
|
||||
**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
|
||||
|
||||
```typescript
|
||||
// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
|
||||
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
|
||||
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
|
||||
// Use emptyEnvPath instead of '/dev/null' in the mount
|
||||
```
|
||||
|
||||
**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
|
||||
|
||||
**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
|
||||
|
||||
```typescript
|
||||
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
|
||||
if (caCertSrc) {
|
||||
const certDir = path.join(DATA_DIR, 'ca-cert');
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
|
||||
// Mount: certDir -> /workspace/ca-cert (read-only)
|
||||
// Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
|
||||
}
|
||||
```
|
||||
|
||||
### 4d. Container runtime — prevent self-termination
|
||||
|
||||
In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
|
||||
|
||||
```typescript
|
||||
// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
|
||||
```
|
||||
|
||||
### 4e. Credential proxy — route through MITM proxy
|
||||
|
||||
In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
|
||||
|
||||
```typescript
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
// Pass upstreamAgent to https.request() options
|
||||
```
|
||||
|
||||
### 4f. Setup script — proxy build args
|
||||
|
||||
Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
|
||||
|
||||
## Step 5: Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
bash container/build.sh
|
||||
```
|
||||
|
||||
## Step 6: Add a Channel
|
||||
|
||||
### Telegram
|
||||
|
||||
```bash
|
||||
# Apply the Telegram skill
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram
|
||||
|
||||
# Rebuild after applying the skill
|
||||
pnpm run build
|
||||
|
||||
# Configure .env
|
||||
cat > .env << EOF
|
||||
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# Register your chat
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "tg:<your-chat-id>" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "telegram_main" \
|
||||
--channel telegram \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**To find your chat ID:** Send any message to your bot, then:
|
||||
```bash
|
||||
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
|
||||
|
||||
**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
|
||||
|
||||
### WhatsApp
|
||||
|
||||
Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
|
||||
|
||||
```bash
|
||||
# Apply the WhatsApp skill
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
|
||||
|
||||
# Rebuild
|
||||
pnpm run build
|
||||
|
||||
# Configure .env
|
||||
cat > .env << EOF
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# Authenticate (choose one):
|
||||
|
||||
# QR code — scan with WhatsApp camera:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
|
||||
# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
|
||||
pnpm exec tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
|
||||
|
||||
# Register your chat (JID = your phone number + @s.whatsapp.net)
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "<phone>@s.whatsapp.net" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "whatsapp_main" \
|
||||
--channel whatsapp \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
|
||||
|
||||
### Both Channels
|
||||
|
||||
Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
|
||||
|
||||
## Step 7: Run
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
|
||||
|
||||
## Networking Details
|
||||
|
||||
### How the proxy works
|
||||
|
||||
All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
|
||||
|
||||
```
|
||||
Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
|
||||
```
|
||||
|
||||
**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
|
||||
|
||||
### Shared paths for DinD mounts
|
||||
|
||||
Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
|
||||
- `/dev/null` → replace with an empty file in the project dir
|
||||
- `/usr/local/share/ca-certificates/` → copy cert to project dir
|
||||
- `/home/agent/` → clone to workspace instead
|
||||
|
||||
### Git clone and virtiofs
|
||||
|
||||
The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### pnpm install fails with SELF_SIGNED_CERT_IN_CHAIN
|
||||
```bash
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
### Container build fails with proxy errors
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg http_proxy=$http_proxy \
|
||||
--build-arg https_proxy=$https_proxy \
|
||||
-t nanoclaw-agent:latest container/
|
||||
```
|
||||
|
||||
### Agent containers fail with "path not shared"
|
||||
All bind-mounted paths must be under the workspace directory. Check:
|
||||
- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
|
||||
- Is the CA cert copied to the project root?
|
||||
- Has the empty `.env` shadow file been created?
|
||||
|
||||
### Agent containers can't reach Anthropic API
|
||||
Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
|
||||
|
||||
### WhatsApp error 405
|
||||
The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
|
||||
|
||||
### WhatsApp "Connection failed" immediately
|
||||
Proxy bypass not configured. From the **host**, run:
|
||||
```bash
|
||||
docker sandbox network proxy <sandbox-name> \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
### Telegram bot doesn't receive messages
|
||||
1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
|
||||
2. Check Group Privacy is disabled in @BotFather if using in groups
|
||||
|
||||
### Git clone fails with "inflate: data stream error"
|
||||
Clone to a non-workspace path first, then move:
|
||||
```bash
|
||||
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
```
|
||||
|
||||
### WhatsApp QR code doesn't display
|
||||
Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
# Then inside:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
```
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.34",
|
||||
"version": "2.1.36",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
|
||||
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# PUBLIC_KEY to .env, and restart the service. Non-interactive —
|
||||
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
|
||||
# server-invite step live in setup/channels/discord.ts. Credentials come in via
|
||||
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
|
||||
@@ -105,10 +105,6 @@ upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
|
||||
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
|
||||
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# Install the iMessage adapter, persist mode/creds to .env,
|
||||
# and restart the service. Non-interactive — the Full Disk Access walkthrough
|
||||
# (local mode) and Photon URL/key prompts (remote mode) live in
|
||||
# setup/channels/imessage.ts. Creds come in via env vars:
|
||||
@@ -135,10 +135,6 @@ else
|
||||
remove_env IMESSAGE_ENABLED
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the creds…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
|
||||
+1
-5
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# .env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
@@ -108,10 +108,6 @@ if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
|
||||
+1
-5
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
|
||||
# _TYPE to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# _TYPE to .env, and restart the service. Non-interactive —
|
||||
# the operator-facing Azure portal walkthroughs live in
|
||||
# setup/channels/teams.ts. Credentials come in via env vars:
|
||||
# TEAMS_APP_ID (required)
|
||||
@@ -114,10 +114,6 @@ if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
|
||||
# Install the Telegram adapter, persist the bot token to .env,
|
||||
# restart the service, and open the bot's chat page in the local Telegram
|
||||
# client. Non-interactive — the operator-facing "Create a bot" instructions
|
||||
# and token paste live in setup/auto.ts. The token comes in via the
|
||||
@@ -134,10 +134,6 @@ if echo "$INFO" | grep -q '"ok":true'; then
|
||||
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
|
||||
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
|
||||
# surprise the user. Keeping it out of here means this script stays pure
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
|
||||
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
|
||||
* a terminal QR the operator scans from Signal → Linked Devices.
|
||||
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
|
||||
* 4. Persist SIGNAL_ACCOUNT to .env.
|
||||
* 5. Kick the service so the adapter picks up the new credentials.
|
||||
* 6. Ask operator role + agent name.
|
||||
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
@@ -333,7 +333,7 @@ async function renderQr(url: string): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
|
||||
/** Persist SIGNAL_ACCOUNT to .env. */
|
||||
function writeSignalAccount(account: string): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
@@ -353,10 +353,6 @@ function writeSignalAccount(account: string): void {
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
|
||||
setupLog.userInput('signal_account', account);
|
||||
}
|
||||
|
||||
|
||||
@@ -433,7 +433,7 @@ async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env. */
|
||||
function writeAssistantHasOwnNumber(): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
@@ -452,11 +452,6 @@ function writeAssistantHasOwnNumber(): void {
|
||||
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
// Container reads from data/env/env.
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
|
||||
@@ -116,15 +116,6 @@ function main(): void {
|
||||
channelsProcessed++;
|
||||
}
|
||||
|
||||
// Sync to data/env/env
|
||||
if (fs.existsSync(v2EnvPath)) {
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
try {
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`);
|
||||
if (missing.length > 0) {
|
||||
console.log(`MISSING:${missing.join(',')}`);
|
||||
|
||||
@@ -65,15 +65,6 @@ function main(): void {
|
||||
fs.writeFileSync(v2EnvPath, result);
|
||||
}
|
||||
|
||||
// Sync to data/env/env (container reads from here)
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
try {
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
console.log(`OK:copied=${copied.length},skipped=${skipped.length}`);
|
||||
if (copied.length > 0) console.log(`COPIED:${copied.join(',')}`);
|
||||
}
|
||||
|
||||
+2
-14
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Step: set-env — Write or update a KEY=VALUE in .env, with optional sync to
|
||||
* data/env/env (the container-mounted copy).
|
||||
* Step: set-env — Write or update a KEY=VALUE in .env.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx setup/index.ts --step set-env -- \
|
||||
* --key TELEGRAM_BOT_TOKEN --value "<token>" [--sync-container]
|
||||
* --key TELEGRAM_BOT_TOKEN --value "<token>"
|
||||
*
|
||||
* Exists so channel-install flows don't have to invent grep/sed/rm pipelines
|
||||
* (which can't be allowlisted tightly — sed can read any file, and each
|
||||
@@ -21,7 +20,6 @@ import { emitStatus } from './status.js';
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const keyIdx = args.indexOf('--key');
|
||||
const valueIdx = args.indexOf('--value');
|
||||
const syncContainer = args.includes('--sync-container');
|
||||
|
||||
if (keyIdx === -1 || !args[keyIdx + 1]) {
|
||||
throw new Error('--key <KEY> is required');
|
||||
@@ -59,19 +57,9 @@ export async function run(args: string[]): Promise<void> {
|
||||
fs.writeFileSync(envFile, content);
|
||||
log.info('Updated .env', { key, existed });
|
||||
|
||||
let synced = false;
|
||||
if (syncContainer) {
|
||||
const dataEnvDir = path.join(projectRoot, 'data', 'env');
|
||||
fs.mkdirSync(dataEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
|
||||
synced = true;
|
||||
log.info('Synced .env to container mount', { path: 'data/env/env' });
|
||||
}
|
||||
|
||||
emitStatus('SET_ENV', {
|
||||
KEY: key,
|
||||
EXISTED: existed,
|
||||
SYNCED_TO_CONTAINER: synced,
|
||||
STATUS: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Tests for the mount allowlist loader/validator.
|
||||
*
|
||||
* Covers the two cleanups:
|
||||
* - The loader honors the per-root `readOnly` key (translating it to
|
||||
* `allowReadWrite`) and tolerates the top-level `nonMainReadOnly` key that
|
||||
* setup writes into every fresh install.
|
||||
* - The allowlist is read per call (mtime-keyed cache), so a parse error is
|
||||
* never cached permanently — a fixed file is picked up without a restart.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The config path is a module-level const in production; point it at a
|
||||
// per-test temp file via a getter so each test is isolated from the cache.
|
||||
const mockState = vi.hoisted(() => ({ allowlistPath: '' }));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('../../config.js');
|
||||
return {
|
||||
...actual,
|
||||
get MOUNT_ALLOWLIST_PATH() {
|
||||
return mockState.allowlistPath;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { loadMountAllowlist, validateMount } from './index.js';
|
||||
|
||||
let tmpDir: string;
|
||||
let configFile: string;
|
||||
let projectsDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mnt-sec-'));
|
||||
configFile = path.join(tmpDir, 'mount-allowlist.json');
|
||||
mockState.allowlistPath = configFile;
|
||||
|
||||
projectsDir = path.join(tmpDir, 'projects');
|
||||
repoDir = path.join(projectsDir, 'repo');
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeAllowlist(obj: unknown): void {
|
||||
fs.writeFileSync(configFile, JSON.stringify(obj, null, 2) + '\n');
|
||||
}
|
||||
|
||||
describe('loadMountAllowlist', () => {
|
||||
it('translates per-root readOnly:false into a read-write grant', () => {
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, readOnly: false }],
|
||||
blockedPatterns: [],
|
||||
});
|
||||
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist).not.toBeNull();
|
||||
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
|
||||
|
||||
// ...and a mount that requests read-write actually gets it.
|
||||
const result = validateMount({ hostPath: repoDir, readonly: false });
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.effectiveReadonly).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps readOnly:true as a read-only grant', () => {
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, readOnly: true }],
|
||||
blockedPatterns: [],
|
||||
});
|
||||
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(false);
|
||||
|
||||
const result = validateMount({ hostPath: repoDir, readonly: false });
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.effectiveReadonly).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates an unknown top-level nonMainReadOnly key', () => {
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: true,
|
||||
});
|
||||
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist).not.toBeNull();
|
||||
expect(allowlist!.allowedRoots).toHaveLength(1);
|
||||
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('picks up a fixed file without a restart (parse errors are not cached)', () => {
|
||||
// A broken edit blocks all mounts...
|
||||
fs.writeFileSync(configFile, 'not valid json {');
|
||||
expect(loadMountAllowlist()).toBeNull();
|
||||
|
||||
// ...but fixing the file recovers on the very next call — no restart.
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
});
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist).not.toBeNull();
|
||||
expect(allowlist!.allowedRoots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null when the allowlist file is missing', () => {
|
||||
// No file written.
|
||||
expect(loadMountAllowlist()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,11 @@ export interface AllowedRoot {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Cache the allowlist in memory - only reloads on process restart
|
||||
let cachedAllowlist: MountAllowlist | null = null;
|
||||
let allowlistLoadError: string | null = null;
|
||||
// Cache the last successfully-parsed allowlist, keyed on the file's path +
|
||||
// mtime. A changed or fixed file is picked up on the next call (no restart),
|
||||
// and a parse error is never cached permanently — one bad edit blocks mounts
|
||||
// only until the file is fixed.
|
||||
let cache: { path: string; mtimeMs: number; allowlist: MountAllowlist } | null = null;
|
||||
|
||||
/**
|
||||
* Default blocked patterns - paths that should never be mounted
|
||||
@@ -57,60 +59,108 @@ const DEFAULT_BLOCKED_PATTERNS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the mount allowlist from the external config location.
|
||||
* Returns null if the file doesn't exist or is invalid.
|
||||
* Result is cached in memory for the lifetime of the process.
|
||||
* Normalize a raw allowed-root entry into an {@link AllowedRoot}.
|
||||
*
|
||||
* The read-only decision is per-root. Historically this validator only read
|
||||
* `allowReadWrite`, but the /manage-mounts skill and setup write `readOnly`
|
||||
* instead — so a `readOnly: false` grant was silently forced read-only.
|
||||
* Translate `readOnly` → `allowReadWrite = !readOnly` (with a warning) unless an
|
||||
* explicit `allowReadWrite` is already present. With neither key, default to
|
||||
* read-only (fail safe).
|
||||
*/
|
||||
export function loadMountAllowlist(): MountAllowlist | null {
|
||||
if (cachedAllowlist !== null) {
|
||||
return cachedAllowlist;
|
||||
function normalizeRoot(root: Record<string, unknown>): AllowedRoot {
|
||||
const rootPath = typeof root.path === 'string' ? root.path : '';
|
||||
const description = typeof root.description === 'string' ? root.description : undefined;
|
||||
|
||||
let allowReadWrite: boolean;
|
||||
if (typeof root.allowReadWrite === 'boolean') {
|
||||
allowReadWrite = root.allowReadWrite;
|
||||
} else if (typeof root.readOnly === 'boolean') {
|
||||
allowReadWrite = !root.readOnly;
|
||||
log.warn('Mount allowlist root uses "readOnly" — translating to allowReadWrite', {
|
||||
root: rootPath,
|
||||
readOnly: root.readOnly,
|
||||
});
|
||||
} else {
|
||||
allowReadWrite = false;
|
||||
}
|
||||
|
||||
if (allowlistLoadError !== null) {
|
||||
// Already tried and failed, don't spam logs
|
||||
return { path: rootPath, allowReadWrite, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the mount allowlist from the external config location.
|
||||
* Returns null if the file doesn't exist or is invalid.
|
||||
* Re-reads on every call, but serves from an in-memory cache while the file's
|
||||
* mtime is unchanged. A parse error is never cached — fix the file and the next
|
||||
* call recovers without a service restart.
|
||||
*/
|
||||
export function loadMountAllowlist(): MountAllowlist | null {
|
||||
// Missing-file behavior: warn and block additional mounts, but do NOT cache
|
||||
// the miss — the file may be created later without a restart.
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(MOUNT_ALLOWLIST_PATH);
|
||||
} catch {
|
||||
log.warn(
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
||||
// Do NOT cache this as an error — file may be created later without restart.
|
||||
// Only parse/structural errors are permanently cached.
|
||||
log.warn(
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// Serve from cache only while the same file is unchanged since the last
|
||||
// successful load. Any edit (including fixing a previously broken file) bumps
|
||||
// the mtime and is picked up on the next call.
|
||||
if (cache !== null && cache.path === MOUNT_ALLOWLIST_PATH && cache.mtimeMs === stat.mtimeMs) {
|
||||
return cache.allowlist;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
||||
const allowlist = JSON.parse(content) as MountAllowlist;
|
||||
const raw = JSON.parse(content) as Record<string, unknown>;
|
||||
|
||||
// Validate structure
|
||||
if (!Array.isArray(allowlist.allowedRoots)) {
|
||||
if (!Array.isArray(raw.allowedRoots)) {
|
||||
throw new Error('allowedRoots must be an array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowlist.blockedPatterns)) {
|
||||
if (!Array.isArray(raw.blockedPatterns)) {
|
||||
throw new Error('blockedPatterns must be an array');
|
||||
}
|
||||
|
||||
// Merge with default blocked patterns
|
||||
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
|
||||
allowlist.blockedPatterns = mergedBlockedPatterns;
|
||||
// Warn-and-ignore the top-level `nonMainReadOnly` key. Setup writes it into
|
||||
// every fresh install, but this validator has no concept of a "main" agent —
|
||||
// read-only is decided per-root. Do NOT throw: a hard reject would fail
|
||||
// closed and brick all mounts on a standard install.
|
||||
if ('nonMainReadOnly' in raw) {
|
||||
log.warn('Mount allowlist has unsupported top-level "nonMainReadOnly" key — ignoring (read-only is per-root)', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
cachedAllowlist = allowlist;
|
||||
const allowedRoots = (raw.allowedRoots as Array<Record<string, unknown>>).map(normalizeRoot);
|
||||
|
||||
// Merge with default blocked patterns
|
||||
const blockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...(raw.blockedPatterns as string[])])];
|
||||
|
||||
const allowlist: MountAllowlist = { allowedRoots, blockedPatterns };
|
||||
|
||||
cache = { path: MOUNT_ALLOWLIST_PATH, mtimeMs: stat.mtimeMs, allowlist };
|
||||
log.info('Mount allowlist loaded successfully', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
allowedRoots: allowlist.allowedRoots.length,
|
||||
blockedPatterns: allowlist.blockedPatterns.length,
|
||||
});
|
||||
|
||||
return cachedAllowlist;
|
||||
return allowlist;
|
||||
} catch (err) {
|
||||
allowlistLoadError = err instanceof Error ? err.message : String(err);
|
||||
// Do NOT poison the cache — a corrupt edit blocks mounts only until it's
|
||||
// fixed, then the next call re-reads and recovers.
|
||||
cache = null;
|
||||
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
error: allowlistLoadError,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user