mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 803f3413ec | |||
| a8b7da7bcf | |||
| 0b6ad5550d | |||
| c1965cfcaf | |||
| 3cefbfccf4 | |||
| 6f22c73aac | |||
| 31dd37b3a8 | |||
| 0dfde3aa5b | |||
| 33d2366252 | |||
| 9bf1514f26 | |||
| be3502c23b | |||
| e3f38dbed9 | |||
| d8a6b99f0e | |||
| 8ca7564fbc | |||
| 3906104960 | |||
| ed9a3e330d | |||
| 102ce80fda | |||
| 0626dcec92 | |||
| b42329551d |
@@ -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"
|
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
|
## Add Directories
|
||||||
|
|
||||||
Ask which directories the user wants agents to access. For each path:
|
Ask which directories the user wants agents to access. For each path:
|
||||||
- Validate the path exists
|
- 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:
|
Build the JSON config and write it:
|
||||||
|
|
||||||
```bash
|
```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.
|
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`):
|
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
|
```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
|
## Reset to Empty
|
||||||
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
|||||||
|
|
||||||
## After Changes
|
## 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
|
```bash
|
||||||
source setup/lib/install-slug.sh
|
ncl groups restart --id <group-id>
|
||||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
|
||||||
systemctl --user restart $(systemd_unit) # Linux
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
- **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
|
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||||
- **Web access** — search and fetch content from the web
|
- **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.
|
- **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).
|
- **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?**
|
**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?**
|
**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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
|
|||||||
// so the singleton survives for the rest of the test.
|
// so the singleton survives for the rest of the test.
|
||||||
if (_testMode && _inbound) {
|
if (_testMode && _inbound) {
|
||||||
const db = _inbound;
|
const db = _inbound;
|
||||||
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
|
return {
|
||||||
|
prepare: (sql: string) => db.prepare(sql),
|
||||||
|
exec: (sql: string) => db.exec(sql),
|
||||||
|
close: () => {},
|
||||||
|
} as unknown as Database;
|
||||||
}
|
}
|
||||||
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||||
db.exec('PRAGMA busy_timeout = 5000');
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
|
|||||||
_outbound?.close();
|
_outbound?.close();
|
||||||
_outbound = null;
|
_outbound = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getInboundDb() / getOutboundDb() instead.
|
|
||||||
* Kept for backward compatibility during migration.
|
|
||||||
*/
|
|
||||||
export function getSessionDb(): Database {
|
|
||||||
return getInboundDb();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
getInboundDb,
|
getInboundDb,
|
||||||
getOutboundDb,
|
getOutboundDb,
|
||||||
getSessionDb,
|
|
||||||
initTestSessionDb,
|
initTestSessionDb,
|
||||||
closeSessionDb,
|
closeSessionDb,
|
||||||
touchHeartbeat,
|
touchHeartbeat,
|
||||||
|
|||||||
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
|
|||||||
export function clearContinuation(providerName: string): void {
|
export function clearContinuation(providerName: string): void {
|
||||||
deleteValue(continuationKey(providerName));
|
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,
|
* 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
|
* send_file) must pick it up so a2a return-path routing on the host can
|
||||||
* correlate replies back to the originating session.
|
* 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 { 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 { getUndeliveredMessages } from '../db/messages-out.js';
|
||||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
|
||||||
import { sendMessage } from './core.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(() => {
|
beforeEach(() => {
|
||||||
initTestSessionDb();
|
initTestSessionDb();
|
||||||
// Seed a peer agent destination
|
// Seed a peer agent destination
|
||||||
@@ -24,13 +41,12 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearCurrentInReplyTo();
|
|
||||||
closeSessionDb();
|
closeSessionDb();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
|
||||||
setCurrentInReplyTo('inbound-msg-1');
|
publishInReplyTo('inbound-msg-1');
|
||||||
|
|
||||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
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 () => {
|
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' });
|
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||||
|
|
||||||
const out = getUndeliveredMessages();
|
const out = getUndeliveredMessages();
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
|
||||||
import { findByName, getAllDestinations } from '../destinations.js';
|
import { findByName, getAllDestinations } from '../destinations.js';
|
||||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.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 { getSessionRouting } from '../db/session-routing.js';
|
||||||
import { registerTools } from './server.js';
|
import { registerTools } from './server.js';
|
||||||
import type { McpToolDefinition } from './types.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 { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||||
import { writeMessageOut } from './db/messages-out.js';
|
import { writeMessageOut } from './db/messages-out.js';
|
||||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
import {
|
||||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
clearContinuation,
|
||||||
|
clearCurrentInReplyTo,
|
||||||
|
migrateLegacyContinuation,
|
||||||
|
setContinuation,
|
||||||
|
setCurrentInReplyTo,
|
||||||
|
} from './db/session-state.js';
|
||||||
import {
|
import {
|
||||||
formatMessages,
|
formatMessages,
|
||||||
extractRouting,
|
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) |
|
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
| [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
|
## 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 |
|
| Entity | Trust Level | Rationale |
|
||||||
|--------|-------------|-----------|
|
|--------|-------------|-----------|
|
||||||
| Main group | Trusted | Private self-chat, admin control |
|
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
|
||||||
| Non-main groups | Untrusted | Other users may be malicious |
|
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
|
||||||
| Container agents | Sandboxed | Isolated execution environment |
|
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
|
||||||
| Incoming messages | User input | Potential prompt injection |
|
| 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
|
## Security Boundaries
|
||||||
|
|
||||||
### 1. Container Isolation (Primary Boundary)
|
### 1. Container Isolation (Primary Boundary)
|
||||||
|
|
||||||
Agents execute in containers (lightweight Linux VMs), providing:
|
Agents execute in containers (Docker), providing:
|
||||||
- **Process isolation** - Container processes cannot affect the host
|
- **Process isolation** — container processes cannot affect the host
|
||||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
- **Filesystem isolation** — only explicitly mounted directories are visible
|
||||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
|
||||||
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
|
- **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
|
### 2. Mount Security
|
||||||
|
|
||||||
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
|
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
|
||||||
- Outside project root
|
spawn. For the default (Claude) provider these are:
|
||||||
- Never mounted into containers
|
|
||||||
- Cannot be modified by agents
|
|
||||||
|
|
||||||
**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
|
private_key, .secret
|
||||||
```
|
```
|
||||||
|
|
||||||
**Protections:**
|
**Enforcement** (`src/modules/mount-security/index.ts`):
|
||||||
- Symlink resolution before validation (prevents traversal attacks)
|
- **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.
|
||||||
- Container path validation (rejects `..` and absolute paths)
|
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
|
||||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
- 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-Only Project Root:**
|
- **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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### 3. Session Isolation
|
### 3. Session Isolation
|
||||||
|
|
||||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
|
||||||
- Groups cannot see other groups' conversation history
|
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
|
||||||
- Session data includes full message history and file contents read
|
(`.claude-shared`) and the working folder are scoped to the agent group, so:
|
||||||
- Prevents cross-group information disclosure
|
- 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:
|
### 4. Credential Isolation (OneCLI Agent Vault)
|
||||||
|
|
||||||
| 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)
|
|
||||||
|
|
||||||
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.
|
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:**
|
**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.
|
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:**
|
**Never on the container filesystem:**
|
||||||
- Channel auth sessions (`store/auth/`) — host only
|
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
|
||||||
- Mount allowlist — external, never mounted
|
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
|
||||||
- Any credentials matching blocked patterns
|
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
|
||||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
|
||||||
|
|
||||||
### 6. Egress Lockdown (Forced Proxy)
|
### 5. Egress Lockdown (Forced Proxy)
|
||||||
|
|
||||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
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
|
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
|
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.
|
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:**
|
**Configuration:**
|
||||||
|
|
||||||
| Env | Default | Meaning |
|
| 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. |
|
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
| `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
|
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
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
|
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.
|
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`.
|
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||||
|
|
||||||
## Privilege Comparison
|
## Resource Limits
|
||||||
|
|
||||||
| Capability | Main Group | Non-Main Group |
|
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:
|
||||||
| Project root access | `/workspace/project` (ro) | None |
|
|
||||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
| Env | Default | Meaning |
|
||||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
| --- | --- | --- |
|
||||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
|
||||||
| Additional mounts | Configurable | Read-only unless allowed |
|
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
|
||||||
| Network access | Unrestricted | Unrestricted |
|
|
||||||
| MCP tools | All | All |
|
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
|
## Security Architecture Diagram
|
||||||
|
|
||||||
@@ -149,7 +191,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
|||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ HOST PROCESS (TRUSTED) │
|
│ HOST PROCESS (TRUSTED) │
|
||||||
│ • Message routing │
|
│ • Message routing │
|
||||||
│ • IPC authorization │
|
│ • Role / access checks (user_roles, agent_group_members) │
|
||||||
│ • Mount validation (external allowlist) │
|
│ • Mount validation (external allowlist) │
|
||||||
│ • Container lifecycle │
|
│ • Container lifecycle │
|
||||||
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
│ • 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",
|
"name": "nanoclaw",
|
||||||
"version": "2.1.32",
|
"version": "2.1.36",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="207k tokens, 104% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="208k tokens, 104% of context window">
|
||||||
<title>207k tokens, 104% of context window</title>
|
<title>208k tokens, 104% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
|
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">208k</text>
|
||||||
<text x="71" y="14">207k</text>
|
<text x="71" y="14">208k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -33,7 +33,9 @@ db.exec(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Insert test message
|
// Insert test message
|
||||||
db.prepare(`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`).run(
|
db.prepare(
|
||||||
|
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
|
||||||
|
).run(
|
||||||
'test-1',
|
'test-1',
|
||||||
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
|
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
|
||||||
);
|
);
|
||||||
@@ -44,7 +46,7 @@ db.close();
|
|||||||
process.env.SESSION_DB_PATH = DB_PATH;
|
process.env.SESSION_DB_PATH = DB_PATH;
|
||||||
process.env.AGENT_PROVIDER = 'claude';
|
process.env.AGENT_PROVIDER = 'claude';
|
||||||
|
|
||||||
const { getSessionDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
const { getInboundDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
||||||
const { getUndeliveredMessages } = await import('../container/agent-runner/src/db/messages-out.js');
|
const { getUndeliveredMessages } = await import('../container/agent-runner/src/db/messages-out.js');
|
||||||
const { getPendingMessages } = await import('../container/agent-runner/src/db/messages-in.js');
|
const { getPendingMessages } = await import('../container/agent-runner/src/db/messages-in.js');
|
||||||
const { createProvider } = await import('../container/agent-runner/src/providers/factory.js');
|
const { createProvider } = await import('../container/agent-runner/src/providers/factory.js');
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ import { routeInbound } from '../src/router.js';
|
|||||||
import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js';
|
import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js';
|
||||||
import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js';
|
import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js';
|
||||||
import { findSession } from '../src/db/sessions.js';
|
import { findSession } from '../src/db/sessions.js';
|
||||||
import { sessionDbPath } from '../src/session-manager.js';
|
import { inboundDbPath } from '../src/session-manager.js';
|
||||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js';
|
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js';
|
||||||
|
|
||||||
// Track delivered messages
|
// Track delivered messages
|
||||||
@@ -99,7 +99,9 @@ const mockAdapter: ChannelAdapter = {
|
|||||||
|
|
||||||
async setTyping() {},
|
async setTyping() {},
|
||||||
async teardown() {},
|
async teardown() {},
|
||||||
isConnected() { return true; },
|
isConnected() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register mock adapter
|
// Register mock adapter
|
||||||
@@ -179,15 +181,19 @@ console.log(`✓ Container status: ${session.container_status}`);
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
const checkContainerLogs = () => {
|
const checkContainerLogs = () => {
|
||||||
try {
|
try {
|
||||||
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"').toString().trim();
|
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"')
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
for (const name of containers.split('\n').filter(Boolean)) {
|
for (const name of containers.split('\n').filter(Boolean)) {
|
||||||
console.log(`\nContainer logs (${name}):`);
|
console.log(`\nContainer logs (${name}):`);
|
||||||
console.log(execSync(`docker logs ${name} 2>&1`).toString());
|
console.log(execSync(`docker logs ${name} 2>&1`).toString());
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessDbPath = sessionDbPath('ag-chan', session.id);
|
const sessDbPath = inboundDbPath('ag-chan', session.id);
|
||||||
console.log(`✓ Session DB: ${sessDbPath}`);
|
console.log(`✓ Session DB: ${sessDbPath}`);
|
||||||
|
|
||||||
// --- Step 4: Wait for delivery through mock adapter ---
|
// --- Step 4: Wait for delivery through mock adapter ---
|
||||||
@@ -210,7 +216,9 @@ await new Promise<void>((resolve) => {
|
|||||||
console.log(` messages_out rows: ${out.length}`);
|
console.log(` messages_out rows: ${out.length}`);
|
||||||
if (out.length > 0) console.log(' (messages exist but delivery failed)');
|
if (out.length > 0) console.log(' (messages exist but delivery failed)');
|
||||||
db.close();
|
db.close();
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
checkContainerLogs();
|
checkContainerLogs();
|
||||||
cleanup();
|
cleanup();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -181,14 +181,6 @@ export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Data
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStuckProcessingIds(outDb: Database.Database): string[] {
|
|
||||||
return (
|
|
||||||
outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{
|
|
||||||
message_id: string;
|
|
||||||
}>
|
|
||||||
).map((r) => r.message_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessingClaim {
|
export interface ProcessingClaim {
|
||||||
message_id: string;
|
message_id: string;
|
||||||
status_changed: string;
|
status_changed: string;
|
||||||
|
|||||||
+1
-10
@@ -24,16 +24,7 @@ import { enforceUpgradeTripwire } from './upgrade-state.js';
|
|||||||
// effects, and the modules call registerResponseHandler/onShutdown at top
|
// effects, and the modules call registerResponseHandler/onShutdown at top
|
||||||
// level — which would hit a TDZ error if the arrays lived here. Re-exported
|
// level — which would hit a TDZ error if the arrays lived here. Re-exported
|
||||||
// here so existing callers see the same surface.
|
// here so existing callers see the same surface.
|
||||||
import {
|
import { getResponseHandlers, getShutdownCallbacks, type ResponsePayload } from './response-registry.js';
|
||||||
registerResponseHandler,
|
|
||||||
getResponseHandlers,
|
|
||||||
onShutdown,
|
|
||||||
getShutdownCallbacks,
|
|
||||||
type ResponsePayload,
|
|
||||||
type ResponseHandler,
|
|
||||||
} from './response-registry.js';
|
|
||||||
export { registerResponseHandler, onShutdown };
|
|
||||||
export type { ResponsePayload, ResponseHandler };
|
|
||||||
|
|
||||||
async function dispatchResponse(payload: ResponsePayload): Promise<void> {
|
async function dispatchResponse(payload: ResponsePayload): Promise<void> {
|
||||||
for (const handler of getResponseHandlers()) {
|
for (const handler of getResponseHandlers()) {
|
||||||
|
|||||||
@@ -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;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the allowlist in memory - only reloads on process restart
|
// Cache the last successfully-parsed allowlist, keyed on the file's path +
|
||||||
let cachedAllowlist: MountAllowlist | null = null;
|
// mtime. A changed or fixed file is picked up on the next call (no restart),
|
||||||
let allowlistLoadError: string | null = null;
|
// 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
|
* 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.
|
* Normalize a raw allowed-root entry into an {@link AllowedRoot}.
|
||||||
* Returns null if the file doesn't exist or is invalid.
|
*
|
||||||
* Result is cached in memory for the lifetime of the process.
|
* 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 {
|
function normalizeRoot(root: Record<string, unknown>): AllowedRoot {
|
||||||
if (cachedAllowlist !== null) {
|
const rootPath = typeof root.path === 'string' ? root.path : '';
|
||||||
return cachedAllowlist;
|
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) {
|
return { path: rootPath, allowReadWrite, description };
|
||||||
// Already tried and failed, don't spam logs
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Serve from cache only while the same file is unchanged since the last
|
||||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
// successful load. Any edit (including fixing a previously broken file) bumps
|
||||||
// Do NOT cache this as an error — file may be created later without restart.
|
// the mtime and is picked up on the next call.
|
||||||
// Only parse/structural errors are permanently cached.
|
if (cache !== null && cache.path === MOUNT_ALLOWLIST_PATH && cache.mtimeMs === stat.mtimeMs) {
|
||||||
log.warn(
|
return cache.allowlist;
|
||||||
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
}
|
||||||
{ path: MOUNT_ALLOWLIST_PATH },
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
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
|
// Validate structure
|
||||||
if (!Array.isArray(allowlist.allowedRoots)) {
|
if (!Array.isArray(raw.allowedRoots)) {
|
||||||
throw new Error('allowedRoots must be an array');
|
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');
|
throw new Error('blockedPatterns must be an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge with default blocked patterns
|
// Warn-and-ignore the top-level `nonMainReadOnly` key. Setup writes it into
|
||||||
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
|
// every fresh install, but this validator has no concept of a "main" agent —
|
||||||
allowlist.blockedPatterns = mergedBlockedPatterns;
|
// 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', {
|
log.info('Mount allowlist loaded successfully', {
|
||||||
path: MOUNT_ALLOWLIST_PATH,
|
path: MOUNT_ALLOWLIST_PATH,
|
||||||
allowedRoots: allowlist.allowedRoots.length,
|
allowedRoots: allowlist.allowedRoots.length,
|
||||||
blockedPatterns: allowlist.blockedPatterns.length,
|
blockedPatterns: allowlist.blockedPatterns.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return cachedAllowlist;
|
return allowlist;
|
||||||
} catch (err) {
|
} 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', {
|
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
|
||||||
path: MOUNT_ALLOWLIST_PATH,
|
path: MOUNT_ALLOWLIST_PATH,
|
||||||
error: allowlistLoadError,
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,17 @@ vi.mock('./config.js', async () => {
|
|||||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-write-outbound' };
|
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-write-outbound' };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { initSessionFolder, outboundDbPath, writeOutboundDirect } from './session-manager.js';
|
import {
|
||||||
|
initSessionFolder,
|
||||||
|
inboundDbPath,
|
||||||
|
outboundDbPath,
|
||||||
|
sessionDir,
|
||||||
|
writeOutboundDirect,
|
||||||
|
writeSessionMessage,
|
||||||
|
} from './session-manager.js';
|
||||||
|
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
||||||
|
import { createSession } from './db/sessions.js';
|
||||||
|
import type { Session } from './types.js';
|
||||||
|
|
||||||
const TEST_DIR = '/tmp/nanoclaw-test-write-outbound';
|
const TEST_DIR = '/tmp/nanoclaw-test-write-outbound';
|
||||||
const AG = 'ag-test';
|
const AG = 'ag-test';
|
||||||
@@ -98,3 +108,71 @@ describe('writeOutboundDirect', () => {
|
|||||||
expect(rows.map((r) => r.seq)).toEqual([2, 4]);
|
expect(rows.map((r) => r.seq)).toEqual([2, 4]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `/debug` skill tells operators to `rm -rf` a session folder to reset a
|
||||||
|
* stuck session. The sessions row survives, so the next message takes the
|
||||||
|
* existing-session path and lands in `writeSessionMessage` with a missing
|
||||||
|
* inbound.db. Without re-provisioning, better-sqlite3 throws on open and the
|
||||||
|
* message is logged-and-dropped forever — the reset silently kills the chat.
|
||||||
|
*/
|
||||||
|
describe('writeSessionMessage re-provisions a deleted session folder', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const db = initTestDb();
|
||||||
|
runMigrations(db);
|
||||||
|
createAgentGroup({
|
||||||
|
id: AG,
|
||||||
|
name: 'Reset',
|
||||||
|
folder: 'reset',
|
||||||
|
agent_provider: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const sess: Session = {
|
||||||
|
id: SESS,
|
||||||
|
agent_group_id: AG,
|
||||||
|
messaging_group_id: null,
|
||||||
|
thread_id: null,
|
||||||
|
agent_provider: null,
|
||||||
|
status: 'active',
|
||||||
|
container_status: 'stopped',
|
||||||
|
last_active: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
createSession(sess);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-creates the folder + inbound.db and does not throw when the row still exists', () => {
|
||||||
|
// Operator resets a stuck session by deleting its folder; the row survives.
|
||||||
|
fs.rmSync(sessionDir(AG, SESS), { recursive: true, force: true });
|
||||||
|
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(false);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
writeSessionMessage(AG, SESS, {
|
||||||
|
id: 'after-reset-1',
|
||||||
|
kind: 'chat',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platformId: 'slack:C1',
|
||||||
|
channelType: 'slack',
|
||||||
|
threadId: null,
|
||||||
|
content: JSON.stringify({ text: 'still here?' }),
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
// The folder + inbound.db are back and the message landed.
|
||||||
|
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(true);
|
||||||
|
const db = new Database(inboundDbPath(AG, SESS), { readonly: true });
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT id, content FROM messages_in WHERE id = ?').get('after-reset-1') as
|
||||||
|
| { id: string; content: string }
|
||||||
|
| undefined;
|
||||||
|
expect(row?.id).toBe('after-reset-1');
|
||||||
|
expect(JSON.parse(row!.content).text).toBe('still here?');
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+10
-36
@@ -64,14 +64,6 @@ export function heartbeatPath(agentGroupId: string, sessionId: string): string {
|
|||||||
return path.join(sessionDir(agentGroupId, sessionId), '.heartbeat');
|
return path.join(sessionDir(agentGroupId, sessionId), '.heartbeat');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use inboundDbPath / outboundDbPath instead.
|
|
||||||
* Kept temporarily for test compatibility during migration.
|
|
||||||
*/
|
|
||||||
export function sessionDbPath(agentGroupId: string, sessionId: string): string {
|
|
||||||
return inboundDbPath(agentGroupId, sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
@@ -219,6 +211,16 @@ export function writeSessionMessage(
|
|||||||
onWake?: 0 | 1;
|
onWake?: 0 | 1;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
|
// Documented reset: operators `rm -rf` a session folder to clear a stuck
|
||||||
|
// session. The sessions row survives, so the next message takes the
|
||||||
|
// existing-session path and lands here with a missing inbound.db — the open
|
||||||
|
// below would throw and the message would be logged-and-dropped forever.
|
||||||
|
// Re-provision the folder + DBs (initSessionFolder is idempotent) so the
|
||||||
|
// documented reset actually re-provisions instead of killing the chat.
|
||||||
|
if (!fs.existsSync(inboundDbPath(agentGroupId, sessionId))) {
|
||||||
|
initSessionFolder(agentGroupId, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract base64 attachment data, save to inbox, replace with file paths
|
// Extract base64 attachment data, save to inbox, replace with file paths
|
||||||
const content = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content);
|
const content = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content);
|
||||||
|
|
||||||
@@ -392,34 +394,6 @@ export function writeOutboundDirect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use openInboundDb / openOutboundDb instead.
|
|
||||||
*/
|
|
||||||
export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database {
|
|
||||||
return openInboundDb(agentGroupId, sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Write a system response to a session's inbound.db so the container's findQuestionResponse() picks it up. */
|
|
||||||
export function writeSystemResponse(
|
|
||||||
agentGroupId: string,
|
|
||||||
sessionId: string,
|
|
||||||
requestId: string,
|
|
||||||
status: string,
|
|
||||||
result: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
writeSessionMessage(agentGroupId, sessionId, {
|
|
||||||
id: `sys-resp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
kind: 'system',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
content: JSON.stringify({
|
|
||||||
type: 'question_response',
|
|
||||||
questionId: requestId,
|
|
||||||
status,
|
|
||||||
result,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load outbox attachments for a delivered message.
|
* Load outbox attachments for a delivered message.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user